mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge master into PR #153 (unified Session Manager)
Resolve the 4 conflicted files toward master's merged #146 work while keeping PR #153's genuinely-new additions: - app.js: keep the full Escape chain (closeSessionManager + closeCommandPalette + closeShortcutOverlay). - index.html: keep master's Command Palette modal markup alongside the PR's Session Manager modal + header button. - styles.css: keep master's Command Palette + COD-157 shortcut CSS AND the PR's COD-130 session-row kebab-menu CSS (both inserted at the same spot — reunited each with its own closing brace). - terminal-ui.js: resolve _buildHistoryItem's main-row click handler to master's options.onActivate contract with a liveness + claudeSessionId -aware resume default, preserving the PR's two-shape/badges/kebab body. - panels-ui.js: the PR's pre-#146 Session Manager block auto-merged as a duplicate AFTER master's fixed block (last-key-wins regression) — drop it, keep master's implementation plus the PR's new _onSessionListMaybeChanged. Backend projectKey plumbing and the SSE live-refresh listeners in app.js merge additively and are kept as-is.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { parseAttachmentMagicLinks } from '../src/attachment-magic.js';
|
||||
import { parseAttachmentMagicLinks, parseTerminalAttachmentRequests } from '../src/attachment-magic.js';
|
||||
import { isSupportedAttachmentExtension } from '../src/attachment-registry.js';
|
||||
|
||||
describe('attachment magic links', () => {
|
||||
it('extracts absolute paths from codeman attach magic URLs', () => {
|
||||
@@ -54,4 +55,79 @@ describe('attachment magic links', () => {
|
||||
|
||||
expect(requested).toEqual(['/tmp/deck.pptx']);
|
||||
});
|
||||
|
||||
it('extracts Codex generated image file URLs from saved-to terminal output', () => {
|
||||
const requests = parseTerminalAttachmentRequests(
|
||||
'Saved to: file:///Users/aamer/.codex-personal/generated_images/mockup%20one.png',
|
||||
{ codexArtifacts: true }
|
||||
);
|
||||
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
path: '/Users/aamer/.codex-personal/generated_images/mockup one.png',
|
||||
source: 'codex-generated',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores Codex saved-to output unless the codex scanner is enabled', () => {
|
||||
const requests = parseTerminalAttachmentRequests(
|
||||
'Saved to: file:///Users/aamer/.codex-personal/generated_images/mockup.png'
|
||||
);
|
||||
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips ANSI styling around Codex saved-to lines before capturing the URL', () => {
|
||||
const requests = parseTerminalAttachmentRequests(
|
||||
'\x1b[1mSaved to:\x1b[0m file:///Users/aamer/.codex/generated_images/mockup.png\x1b[0m\r\n',
|
||||
{ codexArtifacts: true }
|
||||
);
|
||||
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
path: '/Users/aamer/.codex/generated_images/mockup.png',
|
||||
source: 'codex-generated',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits generated artifact requests from Codex saved-to output', () => {
|
||||
const session = new Session({ id: 'session-generated-artifact-test', workingDir: '/tmp', mode: 'codex' });
|
||||
const requested: Array<{ path: string; source?: string }> = [];
|
||||
session.on('attachmentRequested', (event: { path: string; source?: string }) => requested.push(event));
|
||||
|
||||
(session as unknown as { _handleTerminalOutput(data: string): void })._handleTerminalOutput(
|
||||
'Saved to: file:///Users/aamer/.codex-personal/generated_images/output.png'
|
||||
);
|
||||
|
||||
expect(requested).toEqual([
|
||||
{
|
||||
sessionId: 'session-generated-artifact-test',
|
||||
path: '/Users/aamer/.codex-personal/generated_images/output.png',
|
||||
source: 'codex-generated',
|
||||
timestamp: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not emit codex-generated requests from non-codex session modes', () => {
|
||||
for (const mode of ['claude', 'shell'] as const) {
|
||||
const session = new Session({ id: `session-generated-artifact-${mode}`, workingDir: '/tmp', mode });
|
||||
const requested: Array<{ path: string }> = [];
|
||||
session.on('attachmentRequested', (event: { path: string }) => requested.push(event));
|
||||
|
||||
(session as unknown as { _handleTerminalOutput(data: string): void })._handleTerminalOutput(
|
||||
'Saved to: file:///Users/aamer/.codex-personal/generated_images/output.png'
|
||||
);
|
||||
|
||||
expect(requested).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('supports generated image attachment extensions beyond png', () => {
|
||||
expect(isSupportedAttachmentExtension('jpg')).toBe(true);
|
||||
expect(isSupportedAttachmentExtension('jpeg')).toBe(true);
|
||||
expect(isSupportedAttachmentExtension('webp')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
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> = {}) {
|
||||
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' });
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
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');
|
||||
});
|
||||
|
||||
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('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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* @fileoverview Regression tests for the header connection indicator
|
||||
* (`CodemanApp._updateConnectionIndicator`) and the invariant that updating it
|
||||
* never aborts durable input delivery.
|
||||
*
|
||||
* Guards two regressions from the upstream v1.1.15 merge (fixed in COD-133):
|
||||
* 1. The indicator body referenced an undefined `transport` object, throwing
|
||||
* `ReferenceError: transport is not defined` on every queued state. Because
|
||||
* `_reliableSend()` calls `_updateConnectionIndicator()` *before*
|
||||
* `_drainSession()`, the throw skipped immediate delivery on every keystroke
|
||||
* → input only flushed on the 2s sweep (large typing lag) and the indicator
|
||||
* never rendered (missing "WS" status).
|
||||
* 2. The restored body only read the SSE `_connectionStatus`, so it never
|
||||
* surfaced the terminal WebSocket transport ("WS" / "HTTP"), and it flashed
|
||||
* "sending 1B" on every single keystroke.
|
||||
*
|
||||
* COD-136 (perf, no behavior change) extracts the pure render into
|
||||
* `_computeConnectionDescriptor()` and makes `_updateConnectionIndicator()`
|
||||
* early-return when that descriptor is byte-identical to the last render — so
|
||||
* fast typing stops doing redundant DOM writes on the hot input path. The
|
||||
* `_computeConnectionDescriptor` block below pins the exact rendered strings per
|
||||
* state (so a future refactor can't silently relabel), and the unchanged-skip
|
||||
* block asserts the DOM is written once across two identical calls and re-written
|
||||
* when state changes.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom — see input-send-order.test.ts).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function loadCodemanAppClass() {
|
||||
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
fetch: (...args: Parameters<typeof fetch>) => global.fetch(...args),
|
||||
document: { addEventListener: vi.fn() },
|
||||
localStorage: {
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
MobileDetection: {},
|
||||
});
|
||||
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
return (context as { __CodemanApp: new () => unknown }).__CodemanApp;
|
||||
}
|
||||
|
||||
const CodemanApp = loadCodemanAppClass();
|
||||
|
||||
function fakeElement() {
|
||||
return { style: { display: '' }, title: '', textContent: '', className: '' };
|
||||
}
|
||||
|
||||
type Indicator = {
|
||||
$: (id: string) => unknown;
|
||||
_pendingDeliveries: Map<string, Array<{ seq: number; data: string }>>;
|
||||
_connectionStatus: string;
|
||||
_wsState: string;
|
||||
activeSessionId: string | null;
|
||||
isOnline: boolean;
|
||||
_updateConnectionIndicator: () => void;
|
||||
};
|
||||
|
||||
function makeApp(overrides: Partial<Indicator> & { queuedBytes?: number } = {}) {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as Indicator & {
|
||||
queuedBytes?: number;
|
||||
};
|
||||
const els: Record<string, ReturnType<typeof fakeElement>> = {
|
||||
connectionIndicator: fakeElement(),
|
||||
connectionDot: fakeElement(),
|
||||
connectionText: fakeElement(),
|
||||
};
|
||||
app.$ = (id: string) => els[id];
|
||||
app._pendingDeliveries = new Map();
|
||||
app._connectionStatus = 'connected';
|
||||
app._wsState = 'disconnected';
|
||||
app.activeSessionId = null;
|
||||
app.isOnline = true;
|
||||
Object.assign(app, overrides);
|
||||
const queued = overrides.queuedBytes ?? 0;
|
||||
if (queued > 0) {
|
||||
app._pendingDeliveries.set('s1', [{ seq: 1, data: 'x'.repeat(queued) }]);
|
||||
}
|
||||
return { app, els };
|
||||
}
|
||||
|
||||
describe('connection indicator — transport display', () => {
|
||||
it('shows "WS" with a connected dot when the terminal WebSocket is open', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected' });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionIndicator.style.display).toBe('flex');
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
expect(els.connectionDot.className).toContain('connected');
|
||||
});
|
||||
|
||||
it('shows "HTTP" with a fallback dot when the socket dropped to HTTP POST', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'fallback' });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toBe('HTTP');
|
||||
expect(els.connectionDot.className).toContain('fallback');
|
||||
});
|
||||
|
||||
it('shows "WS…" while connecting or reconnecting the socket', () => {
|
||||
for (const state of ['connecting', 'reconnecting']) {
|
||||
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: state });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toBe('WS…');
|
||||
expect(els.connectionDot.className).toContain('reconnecting');
|
||||
}
|
||||
});
|
||||
|
||||
it('shows "Offline" when the browser reports no network', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', isOnline: false });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toBe('Offline');
|
||||
expect(els.connectionDot.className).toContain('offline');
|
||||
});
|
||||
|
||||
it('hides on an idle dashboard (no active session, healthy stream, no queue)', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: null, _connectionStatus: 'connected' });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionIndicator.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('surfaces SSE reconnecting on the dashboard when there is no active terminal', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: null, _connectionStatus: 'reconnecting' });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toContain('Reconnecting');
|
||||
expect(els.connectionDot.className).toContain('reconnecting');
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection indicator — keystroke backlog threshold', () => {
|
||||
it('does NOT annotate a single-keystroke (1B) queue — no "sending 1B" flicker', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 1 });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
expect(els.connectionText.textContent).not.toMatch(/queued/);
|
||||
});
|
||||
|
||||
it('does NOT annotate at the 4B threshold boundary', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 4 });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
});
|
||||
|
||||
it('annotates a genuine backlog (>4B) with a queued byte count', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toMatch(/^WS · 40B queued$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection indicator — never throws (the ReferenceError regression)', () => {
|
||||
it('renders every transport × stream × queue combination without throwing', () => {
|
||||
const wsStates = ['disconnected', 'connecting', 'connected', 'reconnecting', 'fallback'];
|
||||
const sseStates = ['connected', 'connecting', 'reconnecting', 'disconnected', 'offline'];
|
||||
for (const ws of wsStates) {
|
||||
for (const sse of sseStates) {
|
||||
for (const active of ['s1', null] as const) {
|
||||
for (const queuedBytes of [0, 1, 4, 200]) {
|
||||
for (const isOnline of [true, false]) {
|
||||
const { app } = makeApp({
|
||||
activeSessionId: active,
|
||||
_wsState: ws,
|
||||
_connectionStatus: sse,
|
||||
isOnline,
|
||||
queuedBytes,
|
||||
});
|
||||
expect(() => app._updateConnectionIndicator()).not.toThrow();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('durable input delivery is not aborted by the indicator (typing-lag regression)', () => {
|
||||
it('_reliableSend reaches _drainSession after updating the indicator', () => {
|
||||
const { app } = makeApp({ activeSessionId: 's1', _wsState: 'connected' });
|
||||
const a = app as unknown as {
|
||||
_seqCounters: Map<string, number>;
|
||||
_persistReliableState: () => void;
|
||||
_drainSession: (id: string) => void;
|
||||
_reliableSend: (id: string, data: string, useMux: boolean) => void;
|
||||
};
|
||||
a._seqCounters = new Map();
|
||||
a._persistReliableState = vi.fn();
|
||||
const drain = vi.fn();
|
||||
a._drainSession = drain;
|
||||
|
||||
// A single keystroke. The indicator runs first; if it throws, drain is skipped.
|
||||
a._reliableSend('s1', 'x', false);
|
||||
|
||||
expect(drain).toHaveBeenCalledWith('s1');
|
||||
expect(app._pendingDeliveries.get('s1')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- COD-136: pure descriptor + cache-skip on the hot input path --------------
|
||||
|
||||
type Descriptor = { display: string; dotClass: string; text: string; title: string };
|
||||
|
||||
type DescriptorApp = Indicator & {
|
||||
_computeConnectionDescriptor: () => Descriptor;
|
||||
_lastIndicatorDescriptor: Descriptor | null;
|
||||
};
|
||||
|
||||
function computeApp(overrides: Partial<Indicator> & { queuedBytes?: number } = {}) {
|
||||
const { app } = makeApp(overrides);
|
||||
return app as unknown as DescriptorApp;
|
||||
}
|
||||
|
||||
describe('_computeConnectionDescriptor — pure render per state (COD-136)', () => {
|
||||
it('offline dominates everything (even an active connected terminal)', () => {
|
||||
const app = computeApp({ activeSessionId: 's1', _wsState: 'connected', isOnline: false });
|
||||
expect(app._computeConnectionDescriptor()).toEqual({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot offline',
|
||||
text: 'Offline',
|
||||
title: 'No network connection',
|
||||
});
|
||||
});
|
||||
|
||||
it('active terminal — connected → WS', () => {
|
||||
const app = computeApp({ activeSessionId: 's1', _wsState: 'connected' });
|
||||
expect(app._computeConnectionDescriptor()).toEqual({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot connected',
|
||||
text: 'WS',
|
||||
title: 'Terminal connected over WebSocket',
|
||||
});
|
||||
});
|
||||
|
||||
it('active terminal — fallback → HTTP', () => {
|
||||
const app = computeApp({ activeSessionId: 's1', _wsState: 'fallback' });
|
||||
expect(app._computeConnectionDescriptor()).toEqual({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot fallback',
|
||||
text: 'HTTP',
|
||||
title: 'WebSocket unavailable — input sent over HTTP',
|
||||
});
|
||||
});
|
||||
|
||||
it('active terminal — reconnecting → WS…', () => {
|
||||
const app = computeApp({ activeSessionId: 's1', _wsState: 'reconnecting' });
|
||||
expect(app._computeConnectionDescriptor()).toEqual({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot reconnecting',
|
||||
text: 'WS…',
|
||||
title: 'Reconnecting WebSocket',
|
||||
});
|
||||
});
|
||||
|
||||
it('active terminal — connecting (default branch) → WS…', () => {
|
||||
const app = computeApp({ activeSessionId: 's1', _wsState: 'connecting' });
|
||||
expect(app._computeConnectionDescriptor()).toEqual({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot reconnecting',
|
||||
text: 'WS…',
|
||||
title: 'Connecting WebSocket',
|
||||
});
|
||||
});
|
||||
|
||||
it('active terminal — a queued backlog (>4B) adds the " · …KB queued" suffix', () => {
|
||||
const app = computeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 2048 });
|
||||
expect(app._computeConnectionDescriptor()).toEqual({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot connected',
|
||||
text: 'WS · 2.0KB queued',
|
||||
title: 'Terminal connected over WebSocket',
|
||||
});
|
||||
});
|
||||
|
||||
it('no terminal, SSE reconnecting → Reconnecting...', () => {
|
||||
const app = computeApp({ activeSessionId: null, _connectionStatus: 'reconnecting' });
|
||||
expect(app._computeConnectionDescriptor()).toEqual({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot reconnecting',
|
||||
text: 'Reconnecting...',
|
||||
title: 'Reconnecting to server',
|
||||
});
|
||||
});
|
||||
|
||||
it('idle dashboard, healthy stream, no queue → hidden (display:none, others normalized to "")', () => {
|
||||
const app = computeApp({ activeSessionId: null, _connectionStatus: 'connected' });
|
||||
expect(app._computeConnectionDescriptor()).toEqual({
|
||||
display: 'none',
|
||||
dotClass: '',
|
||||
text: '',
|
||||
title: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('idle dashboard with a small queue (≤4B) → draining "Sending..."', () => {
|
||||
const app = computeApp({ activeSessionId: null, _connectionStatus: 'connected', queuedBytes: 2 });
|
||||
expect(app._computeConnectionDescriptor()).toEqual({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot draining',
|
||||
text: 'Sending...',
|
||||
title: 'Delivering queued input',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection-dot CSS — every emitted dot class has a styles.css rule', () => {
|
||||
// The descriptor emits these dot variants; each needs a visible rule or the
|
||||
// 8px dot renders as an invisible blob (the base .connection-dot rule has no
|
||||
// background). 'connected' and 'fallback' were missing when this PR shipped.
|
||||
const DOT_CLASSES = ['connected', 'fallback', 'offline', 'reconnecting', 'draining'];
|
||||
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
|
||||
|
||||
for (const cls of DOT_CLASSES) {
|
||||
it(`.connection-dot.${cls} is styled`, () => {
|
||||
const rule = new RegExp(`\\.connection-dot\\.${cls}\\s*\\{[^}]*background`, 'm');
|
||||
expect(css).toMatch(rule);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/** A DOM element fake that COUNTS each property write — used to detect the skip. */
|
||||
function countingElement() {
|
||||
const writes = { display: 0, className: 0, textContent: 0, title: 0 };
|
||||
let _display = '';
|
||||
let _className = '';
|
||||
let _textContent = '';
|
||||
let _title = '';
|
||||
return {
|
||||
writes,
|
||||
style: {
|
||||
get display() {
|
||||
return _display;
|
||||
},
|
||||
set display(v: string) {
|
||||
_display = v;
|
||||
writes.display++;
|
||||
},
|
||||
},
|
||||
get className() {
|
||||
return _className;
|
||||
},
|
||||
set className(v: string) {
|
||||
_className = v;
|
||||
writes.className++;
|
||||
},
|
||||
get textContent() {
|
||||
return _textContent;
|
||||
},
|
||||
set textContent(v: string) {
|
||||
_textContent = v;
|
||||
writes.textContent++;
|
||||
},
|
||||
get title() {
|
||||
return _title;
|
||||
},
|
||||
set title(v: string) {
|
||||
_title = v;
|
||||
writes.title++;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeCountingApp(overrides: Partial<Indicator> & { queuedBytes?: number } = {}) {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as DescriptorApp & {
|
||||
queuedBytes?: number;
|
||||
};
|
||||
const els = {
|
||||
connectionIndicator: countingElement(),
|
||||
connectionDot: countingElement(),
|
||||
connectionText: countingElement(),
|
||||
};
|
||||
app.$ = (id: string) => (els as Record<string, ReturnType<typeof countingElement>>)[id];
|
||||
app._pendingDeliveries = new Map();
|
||||
app._connectionStatus = 'connected';
|
||||
app._wsState = 'disconnected';
|
||||
app.activeSessionId = null;
|
||||
app.isOnline = true;
|
||||
app._lastIndicatorDescriptor = null;
|
||||
Object.assign(app, overrides);
|
||||
const queued = overrides.queuedBytes ?? 0;
|
||||
if (queued > 0) {
|
||||
app._pendingDeliveries.set('s1', [{ seq: 1, data: 'x'.repeat(queued) }]);
|
||||
}
|
||||
return { app, els };
|
||||
}
|
||||
|
||||
describe('_updateConnectionIndicator — COD-136 unchanged-skip', () => {
|
||||
it('writes the DOM on the first call (cache starts null → renders)', () => {
|
||||
const { app, els } = makeCountingApp({ activeSessionId: 's1', _wsState: 'connected' });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionIndicator.style.display).toBe('flex');
|
||||
expect(els.connectionDot.className).toBe('connection-dot connected');
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
expect(els.connectionText.writes.textContent).toBe(1);
|
||||
});
|
||||
|
||||
it('skips redundant DOM writes when the descriptor is unchanged across two calls', () => {
|
||||
const { app, els } = makeCountingApp({ activeSessionId: 's1', _wsState: 'connected' });
|
||||
|
||||
app._updateConnectionIndicator(); // first render
|
||||
const before = {
|
||||
display: els.connectionIndicator.writes.display,
|
||||
className: els.connectionDot.writes.className,
|
||||
textContent: els.connectionText.writes.textContent,
|
||||
title: els.connectionIndicator.writes.title,
|
||||
};
|
||||
|
||||
app._updateConnectionIndicator(); // identical state → must early-return, no writes
|
||||
|
||||
expect(els.connectionIndicator.writes.display).toBe(before.display);
|
||||
expect(els.connectionDot.writes.className).toBe(before.className);
|
||||
expect(els.connectionText.writes.textContent).toBe(before.textContent);
|
||||
expect(els.connectionIndicator.writes.title).toBe(before.title);
|
||||
});
|
||||
|
||||
it('re-renders when state changes between calls (WS → HTTP)', () => {
|
||||
const { app, els } = makeCountingApp({ activeSessionId: 's1', _wsState: 'connected' });
|
||||
|
||||
app._updateConnectionIndicator(); // WS
|
||||
const writesAfterFirst = els.connectionText.writes.textContent;
|
||||
|
||||
app._wsState = 'fallback';
|
||||
app._updateConnectionIndicator(); // HTTP — must write again
|
||||
|
||||
expect(els.connectionText.writes.textContent).toBe(writesAfterFirst + 1);
|
||||
expect(els.connectionText.textContent).toBe('HTTP');
|
||||
expect(els.connectionDot.className).toBe('connection-dot fallback');
|
||||
});
|
||||
|
||||
it('re-renders display when the hidden→shown transition occurs (none → flex)', () => {
|
||||
const { app, els } = makeCountingApp({ activeSessionId: null, _connectionStatus: 'connected' });
|
||||
|
||||
app._updateConnectionIndicator(); // hidden (display:none)
|
||||
expect(els.connectionIndicator.style.display).toBe('none');
|
||||
const displayWrites = els.connectionIndicator.writes.display;
|
||||
|
||||
app.activeSessionId = 's1';
|
||||
app._wsState = 'connected';
|
||||
app._updateConnectionIndicator(); // now shown
|
||||
|
||||
expect(els.connectionIndicator.writes.display).toBe(displayWrites + 1);
|
||||
expect(els.connectionIndicator.style.display).toBe('flex');
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* @fileoverview Tests for CronService — the CRUD/bookkeeping + due-tick
|
||||
* state machine of the cron. The pure next-run math lives in
|
||||
* cron-time.test.ts; this exercises the service that sits on top of it.
|
||||
*
|
||||
* Launch attempts are steered down the "Session launch failed" path (the mock
|
||||
* deps lack the session-construction config getters) so no real Session/tmux
|
||||
* objects are constructed — we assert the scheduling state machine (due
|
||||
* detection, dedup guard, schedule advance, once-completion, concurrency skip,
|
||||
* auto-close, run-history recording), not the session layer it reuses.
|
||||
*
|
||||
* Port: N/A (no HTTP server).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { CronService, type CronDeps } from '../src/cron/cron-service.js';
|
||||
import { CronJobSchema } from '../src/web/schemas.js';
|
||||
import { MAX_CRON_JOBS } from '../src/config/map-limits.js';
|
||||
import type { CronJob, CronJobRun } from '../src/types/cron.js';
|
||||
import type { CronJobInput } from '../src/cron/cron-input.js';
|
||||
|
||||
const MISSING_DIR = '/nonexistent-codeman-cron-test-dir';
|
||||
/** A real dir: createJob/updateJob validate workingDir existence up front. */
|
||||
const VALID_DIR = mkdtempSync(join(tmpdir(), 'codeman-cron-wd-'));
|
||||
const flush = (): Promise<void> => new Promise((r) => setImmediate(r));
|
||||
|
||||
type FakeSession = { mode: string; status?: string };
|
||||
|
||||
function makeStore() {
|
||||
const jobs: Record<string, CronJob> = {};
|
||||
const runs: Record<string, CronJobRun> = {};
|
||||
return {
|
||||
getCronJobs: () => jobs,
|
||||
getCronJob: (id: string) => jobs[id] ?? null,
|
||||
setCronJob: (id: string, j: CronJob) => {
|
||||
jobs[id] = j;
|
||||
},
|
||||
removeCronJob: (id: string) => {
|
||||
delete jobs[id];
|
||||
},
|
||||
getCronJobRuns: () => runs,
|
||||
setCronJobRun: (id: string, r: CronJobRun) => {
|
||||
runs[id] = r;
|
||||
},
|
||||
removeCronJobRun: (id: string) => {
|
||||
delete runs[id];
|
||||
},
|
||||
incrementSessionsCreated: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeService(sessions = new Map<string, FakeSession>()) {
|
||||
const store = makeStore();
|
||||
const broadcast = vi.fn();
|
||||
const cleanupSession = vi.fn(async (id: string) => {
|
||||
sessions.delete(id);
|
||||
});
|
||||
const deps = {
|
||||
store,
|
||||
broadcast,
|
||||
sessions,
|
||||
cleanupSession,
|
||||
} as unknown as CronDeps;
|
||||
return { service: new CronService(deps), store, broadcast, sessions, cleanupSession };
|
||||
}
|
||||
|
||||
function mkInput(overrides: Partial<CronJobInput> = {}): CronJobInput {
|
||||
return {
|
||||
name: 'job',
|
||||
agentType: 'claude',
|
||||
workingDir: VALID_DIR,
|
||||
promptMode: 'inline_text',
|
||||
promptText: 'hello',
|
||||
inputMode: 'typed',
|
||||
scheduleType: 'interval',
|
||||
intervalMinutes: 10,
|
||||
enabled: true,
|
||||
concurrencyPolicy: 'warn_only',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CronService', () => {
|
||||
let svc: ReturnType<typeof makeService>;
|
||||
|
||||
beforeEach(() => {
|
||||
svc = makeService();
|
||||
});
|
||||
|
||||
describe('createJob', () => {
|
||||
it('computes nextRunAt for an enabled interval job', () => {
|
||||
const before = Date.now();
|
||||
const job = svc.service.createJob(mkInput({ intervalMinutes: 10 }));
|
||||
expect(job.id).toBeTruthy();
|
||||
expect(job.nextRunAt).not.toBeNull();
|
||||
expect(job.nextRunAt!).toBeGreaterThanOrEqual(before + 10 * 60_000);
|
||||
expect(job.lastRunAt).toBeNull();
|
||||
expect(job.lastStatus).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves nextRunAt null for a disabled job', () => {
|
||||
const job = svc.service.createJob(mkInput({ enabled: false }));
|
||||
expect(job.nextRunAt).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the absolute runAt for a one-time job', () => {
|
||||
const runAt = Date.now() + 3_600_000;
|
||||
const job = svc.service.createJob(mkInput({ scheduleType: 'once', runAt, intervalMinutes: undefined }));
|
||||
expect(job.nextRunAt).toBe(runAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setEnabled', () => {
|
||||
it('clears nextRunAt when disabling and recomputes when re-enabling', () => {
|
||||
const job = svc.service.createJob(mkInput());
|
||||
const disabled = svc.service.setEnabled(job.id, false);
|
||||
expect(disabled!.nextRunAt).toBeNull();
|
||||
const reenabled = svc.service.setEnabled(job.id, true);
|
||||
expect(reenabled!.nextRunAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an unknown id', () => {
|
||||
expect(svc.service.setEnabled('nope', true)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateJob', () => {
|
||||
it('clears the dup-guard and preserves createdAt', () => {
|
||||
const job = svc.service.createJob(mkInput({ intervalMinutes: 10 }));
|
||||
job.lastDueKey = 'stale';
|
||||
svc.store.setCronJob(job.id, job);
|
||||
const updated = svc.service.updateJob(job.id, { name: 'renamed' });
|
||||
expect(updated!.name).toBe('renamed');
|
||||
expect(updated!.lastDueKey).toBeNull();
|
||||
expect(updated!.createdAt).toBe(job.createdAt);
|
||||
});
|
||||
|
||||
it('does NOT re-fire a completed once-job when editing a non-schedule field', async () => {
|
||||
const job = svc.service.createJob(
|
||||
mkInput({ scheduleType: 'once', runAt: Date.now() - 1000, intervalMinutes: undefined })
|
||||
);
|
||||
await svc.service.tickDueJobs(Date.now());
|
||||
await flush();
|
||||
expect(svc.service.getJob(job.id)!.completedOnce).toBe(true);
|
||||
|
||||
const updated = svc.service.updateJob(job.id, { name: 'renamed' });
|
||||
expect(updated!.name).toBe('renamed');
|
||||
// Cosmetic edit must not resurrect a fired one-time job.
|
||||
expect(updated!.completedOnce).toBe(true);
|
||||
expect(updated!.nextRunAt).toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT resurrect a fired once-job when the edit form round-trips the unchanged schedule', async () => {
|
||||
// Reproduces the real UI flow: the edit modal re-sends the FULL job
|
||||
// (scheduleType + runAt unchanged) on every save. A field-presence check
|
||||
// would wrongly re-arm; we compare VALUES, so an unchanged schedule does not.
|
||||
const runAt = Date.now() - 1000;
|
||||
const job = svc.service.createJob(mkInput({ scheduleType: 'once', runAt, intervalMinutes: undefined }));
|
||||
await svc.service.tickDueJobs(Date.now());
|
||||
await flush();
|
||||
expect(svc.service.getJob(job.id)!.completedOnce).toBe(true);
|
||||
|
||||
// Full-body edit changing only the name; schedule values identical.
|
||||
const updated = svc.service.updateJob(job.id, { name: 'renamed', scheduleType: 'once', runAt, enabled: false });
|
||||
expect(updated!.completedOnce).toBe(true);
|
||||
|
||||
// Even re-enabling afterward must not bring the dead job back to life.
|
||||
const reenabled = svc.service.setEnabled(job.id, true);
|
||||
expect(reenabled!.nextRunAt).toBeNull();
|
||||
});
|
||||
|
||||
it('re-arms a completed once-job when the schedule itself is edited', async () => {
|
||||
const job = svc.service.createJob(
|
||||
mkInput({ scheduleType: 'once', runAt: Date.now() - 1000, intervalMinutes: undefined })
|
||||
);
|
||||
await svc.service.tickDueJobs(Date.now());
|
||||
await flush();
|
||||
expect(svc.service.getJob(job.id)!.completedOnce).toBe(true);
|
||||
|
||||
const future = Date.now() + 3_600_000;
|
||||
const updated = svc.service.updateJob(job.id, { runAt: future, enabled: true });
|
||||
expect(updated!.completedOnce).toBe(false);
|
||||
expect(updated!.nextRunAt).toBe(future);
|
||||
});
|
||||
|
||||
it('rejects a partial update that leaves an inconsistent schedule (no dead enabled job)', () => {
|
||||
const job = svc.service.createJob(mkInput({ scheduleType: 'interval', intervalMinutes: 10 }));
|
||||
// Switch to 'once' WITHOUT a runAt → would otherwise yield a dead nextRunAt:null.
|
||||
expect(() => svc.service.updateJob(job.id, { scheduleType: 'once', intervalMinutes: undefined })).toThrow();
|
||||
// The stored job is left untouched.
|
||||
const after = svc.service.getJob(job.id)!;
|
||||
expect(after.scheduleType).toBe('interval');
|
||||
expect(after.nextRunAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteJob', () => {
|
||||
it('removes the job and its run history', async () => {
|
||||
const job = svc.service.createJob(
|
||||
mkInput({ scheduleType: 'once', runAt: Date.now() - 1000, intervalMinutes: undefined })
|
||||
);
|
||||
await svc.service.tickDueJobs(Date.now());
|
||||
await flush();
|
||||
expect(svc.service.listRuns(job.id).length).toBe(1);
|
||||
expect(svc.service.deleteJob(job.id)).toBe(true);
|
||||
expect(svc.service.getJob(job.id)).toBeNull();
|
||||
expect(svc.service.listRuns(job.id).length).toBe(0);
|
||||
});
|
||||
|
||||
it('returns false for an unknown id', () => {
|
||||
expect(svc.service.deleteJob('nope')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listRuns', () => {
|
||||
it('returns runs newest-first and filters by job id', async () => {
|
||||
const a = svc.service.createJob(
|
||||
mkInput({ name: 'a', scheduleType: 'once', runAt: Date.now() - 1000, intervalMinutes: undefined })
|
||||
);
|
||||
const b = svc.service.createJob(
|
||||
mkInput({ name: 'b', scheduleType: 'once', runAt: Date.now() - 1000, intervalMinutes: undefined })
|
||||
);
|
||||
await svc.service.runNow(a.id);
|
||||
await svc.service.runNow(b.id);
|
||||
const all = svc.service.listRuns();
|
||||
expect(all.length).toBe(2);
|
||||
expect(all[0].startedAt).toBeGreaterThanOrEqual(all[1].startedAt);
|
||||
expect(svc.service.listRuns(a.id).every((r) => r.cronJobId === a.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('recomputes nextRunAt for enabled jobs missing one, but skips a completed once-job', () => {
|
||||
const live = svc.service.createJob(mkInput());
|
||||
live.nextRunAt = null;
|
||||
svc.store.setCronJob(live.id, live);
|
||||
|
||||
const dead = svc.service.createJob(
|
||||
mkInput({ scheduleType: 'once', runAt: Date.now(), intervalMinutes: undefined })
|
||||
);
|
||||
dead.completedOnce = true;
|
||||
dead.nextRunAt = null;
|
||||
svc.store.setCronJob(dead.id, dead);
|
||||
|
||||
svc.service.init();
|
||||
expect(svc.service.getJob(live.id)!.nextRunAt).not.toBeNull();
|
||||
expect(svc.service.getJob(dead.id)!.nextRunAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tickDueJobs', () => {
|
||||
it('fires a due one-time job exactly once and disables it', async () => {
|
||||
const runAt = Date.now() - 5000;
|
||||
const job = svc.service.createJob(mkInput({ scheduleType: 'once', runAt, intervalMinutes: undefined }));
|
||||
|
||||
await svc.service.tickDueJobs(Date.now());
|
||||
await flush();
|
||||
|
||||
const after = svc.service.getJob(job.id)!;
|
||||
expect(after.completedOnce).toBe(true);
|
||||
expect(after.enabled).toBe(false);
|
||||
expect(after.nextRunAt).toBeNull();
|
||||
const runs = svc.service.listRuns(job.id);
|
||||
expect(runs.length).toBe(1);
|
||||
expect(runs[0].status).toBe('failed'); // mock deps can't construct a Session → fails at launch
|
||||
|
||||
// A second tick must not re-fire it.
|
||||
await svc.service.tickDueJobs(Date.now());
|
||||
await flush();
|
||||
expect(svc.service.listRuns(job.id).length).toBe(1);
|
||||
});
|
||||
|
||||
it('advances an interval job to a future nextRunAt after firing', async () => {
|
||||
const job = svc.service.createJob(mkInput({ intervalMinutes: 10 }));
|
||||
const fireAt = job.nextRunAt! + 1000;
|
||||
|
||||
await svc.service.tickDueJobs(fireAt);
|
||||
await flush();
|
||||
|
||||
const after = svc.service.getJob(job.id)!;
|
||||
expect(after.enabled).toBe(true);
|
||||
expect(after.nextRunAt!).toBeGreaterThan(fireAt);
|
||||
expect(after.lastDueKey).not.toBeNull();
|
||||
expect(svc.service.listRuns(job.id).length).toBe(1);
|
||||
});
|
||||
|
||||
it('does not fire a job whose nextRunAt is still in the future', async () => {
|
||||
const job = svc.service.createJob(mkInput({ intervalMinutes: 60 }));
|
||||
await svc.service.tickDueJobs(Date.now());
|
||||
await flush();
|
||||
expect(svc.service.listRuns(job.id).length).toBe(0);
|
||||
});
|
||||
|
||||
it('skips an automatic run when concurrency policy is skip_if_same_agent_running', async () => {
|
||||
const sessions = new Map<string, { mode: string }>([['s1', { mode: 'claude' }]]);
|
||||
const local = makeService(sessions);
|
||||
const job = local.service.createJob(
|
||||
mkInput({ agentType: 'claude', concurrencyPolicy: 'skip_if_same_agent_running', intervalMinutes: 10 })
|
||||
);
|
||||
const fireAt = job.nextRunAt! + 1000;
|
||||
|
||||
await local.service.tickDueJobs(fireAt);
|
||||
await flush();
|
||||
|
||||
// A 'skipped' run is recorded (so the history isn't silently empty), and
|
||||
// the schedule still advanced past the skipped slot.
|
||||
const runs = local.service.listRuns(job.id);
|
||||
expect(runs.length).toBe(1);
|
||||
expect(runs[0].status).toBe('skipped');
|
||||
const after = local.service.getJob(job.id)!;
|
||||
expect(after.lastStatus).toBe('skipped');
|
||||
// A skip is not a run: lastRunAt must NOT advance.
|
||||
expect(after.lastRunAt).toBeNull();
|
||||
expect(after.nextRunAt!).toBeGreaterThan(fireAt);
|
||||
expect(after.lastDueKey).not.toBeNull();
|
||||
});
|
||||
|
||||
it('coalesces consecutive skips — a perpetually-skipped job does not flood run history', async () => {
|
||||
const sessions = new Map<string, { mode: string }>([['s1', { mode: 'claude' }]]);
|
||||
const local = makeService(sessions);
|
||||
const job = local.service.createJob(
|
||||
mkInput({ agentType: 'claude', concurrencyPolicy: 'skip_if_same_agent_running', intervalMinutes: 10 })
|
||||
);
|
||||
|
||||
// Drive 50 due ticks; the same-mode session keeps it skipped every time.
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const due = local.service.getJob(job.id)!.nextRunAt! + 1;
|
||||
await local.service.tickDueJobs(due);
|
||||
await flush();
|
||||
}
|
||||
|
||||
// Exactly ONE skipped run is recorded for the whole skip streak.
|
||||
expect(local.service.listRuns(job.id).length).toBe(1);
|
||||
expect(local.service.listRuns(job.id)[0].status).toBe('skipped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePrompt path guard', () => {
|
||||
// A real workspace dir for the in-workspace / confinement cases.
|
||||
let ws: string;
|
||||
beforeEach(() => {
|
||||
ws = mkdtempSync(join(tmpdir(), 'codeman-cron-ws-'));
|
||||
});
|
||||
|
||||
const fileJob = (promptFilePath: string, workingDir: string) =>
|
||||
svc.service.createJob(
|
||||
mkInput({ promptMode: 'prompt_file_path', promptFilePath, promptText: undefined, workingDir })
|
||||
);
|
||||
|
||||
it('blocks a sensitive system file via the blocklist (/etc/passwd)', async () => {
|
||||
const run = await svc.service.runNow(fileJob('/etc/passwd', ws).id);
|
||||
expect(run!.status).toBe('failed');
|
||||
// Fails at prompt resolution (blocked) — content is never read.
|
||||
expect(run!.errorMessage).toMatch(/Prompt error/i);
|
||||
expect(run!.errorMessage).toMatch(/block/i);
|
||||
expect(svc.sessions.size).toBe(0);
|
||||
});
|
||||
|
||||
it('blocks /proc/self/environ (server-process env exfil) via the pseudo-fs blocklist', async () => {
|
||||
const run = await svc.service.runNow(fileJob('/proc/self/environ', ws).id);
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/Prompt error/i);
|
||||
expect(run!.errorMessage).toMatch(/block/i);
|
||||
});
|
||||
|
||||
it.skipIf(!existsSync('/proc/self/environ'))(
|
||||
'blocks the workingDir=/proc + /proc/self/environ confinement bypass at fire time',
|
||||
async () => {
|
||||
// Create-time validation rejects a /proc workingDir, so simulate a
|
||||
// legacy/hand-edited job by mutating the stored record directly.
|
||||
const job = fileJob('/proc/self/environ', ws);
|
||||
const stored = svc.store.getCronJob(job.id)!;
|
||||
stored.workingDir = '/proc';
|
||||
svc.store.setCronJob(stored.id, stored);
|
||||
const run = await svc.service.runNow(job.id);
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/Prompt error/i);
|
||||
expect(run!.errorMessage).toMatch(/blocked/i);
|
||||
}
|
||||
);
|
||||
|
||||
it('blocks a regular file that lives OUTSIDE the job workspace', async () => {
|
||||
const outside = mkdtempSync(join(tmpdir(), 'codeman-cron-outside-'));
|
||||
const file = join(outside, 'prompt.md');
|
||||
writeFileSync(file, 'do the thing');
|
||||
const run = await svc.service.runNow(fileJob(file, ws).id);
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/inside the job working directory/i);
|
||||
});
|
||||
|
||||
it('blocks a non-regular file (directory) inside the workspace', async () => {
|
||||
const sub = join(ws, 'adir');
|
||||
mkdirSync(sub);
|
||||
const run = await svc.service.runNow(fileJob(sub, ws).id);
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/not a regular file/i);
|
||||
});
|
||||
|
||||
it('blocks an oversized prompt file (unbounded-read DoS)', async () => {
|
||||
const file = join(ws, 'huge.md');
|
||||
writeFileSync(file, Buffer.alloc(1024 * 1024 + 1, 0x61));
|
||||
const run = await svc.service.runNow(fileJob(file, ws).id);
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/too large/i);
|
||||
});
|
||||
|
||||
it('fails cleanly (no throw) when the prompt file does not exist', async () => {
|
||||
const run = await svc.service.runNow(fileJob(join(ws, 'nope.md'), ws).id);
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/Prompt error/i);
|
||||
});
|
||||
|
||||
it('allows a regular prompt file INSIDE the job workspace (passes resolution)', async () => {
|
||||
const file = join(ws, 'prompt.md');
|
||||
writeFileSync(file, 'do the thing');
|
||||
const run = await svc.service.runNow(fileJob(file, ws).id);
|
||||
// Got past prompt resolution + workingDir checks; fails only at the
|
||||
// (mock-incomplete) session-launch step — NOT a prompt error.
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).not.toMatch(/Prompt error/i);
|
||||
expect(run!.errorMessage).toMatch(/Session launch failed/i);
|
||||
});
|
||||
|
||||
it('blocks a symlink inside the workspace that escapes to /etc/passwd', async () => {
|
||||
const link = join(ws, 'sneaky.md');
|
||||
symlinkSync('/etc/passwd', link);
|
||||
const run = await svc.service.runNow(fileJob(link, ws).id);
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/Prompt error/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runNow', () => {
|
||||
it('launches regardless of enabled/schedule state', async () => {
|
||||
const job = svc.service.createJob(mkInput({ enabled: false }));
|
||||
const run = await svc.service.runNow(job.id);
|
||||
expect(run).not.toBeNull();
|
||||
expect(run!.triggerType).toBe('manual_run_now');
|
||||
// Disabled job stays disabled; a manual run doesn't arm the schedule.
|
||||
expect(svc.service.getJob(job.id)!.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null for an unknown id', async () => {
|
||||
expect(await svc.service.runNow('nope')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('workingDir validation (create/update)', () => {
|
||||
it('rejects a nonexistent workingDir at create', () => {
|
||||
expect(() => svc.service.createJob(mkInput({ workingDir: MISSING_DIR }))).toThrow(/does not exist/);
|
||||
});
|
||||
|
||||
it('rejects blocked trees and the filesystem root at create', () => {
|
||||
expect(() => svc.service.createJob(mkInput({ workingDir: '/etc' }))).toThrow(/not allowed/);
|
||||
expect(() => svc.service.createJob(mkInput({ workingDir: '/' }))).toThrow(/not allowed/);
|
||||
});
|
||||
|
||||
it('rejects an invalid workingDir on update and leaves the job untouched', () => {
|
||||
const job = svc.service.createJob(mkInput());
|
||||
expect(() => svc.service.updateJob(job.id, { workingDir: MISSING_DIR })).toThrow(/does not exist/);
|
||||
expect(svc.service.getJob(job.id)!.workingDir).toBe(VALID_DIR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('single-line prompt enforcement', () => {
|
||||
it('schema rejects a multi-line promptText and launchCommand', () => {
|
||||
expect(CronJobSchema.safeParse(mkInput({ promptText: 'a\nb' })).success).toBe(false);
|
||||
expect(CronJobSchema.safeParse(mkInput({ agentType: 'shell', launchCommand: 'a\nb' })).success).toBe(false);
|
||||
expect(CronJobSchema.safeParse(mkInput()).success).toBe(true);
|
||||
});
|
||||
|
||||
it('fails the run when a legacy job carries a multi-line promptText', async () => {
|
||||
const job = svc.service.createJob(mkInput());
|
||||
const stored = svc.store.getCronJob(job.id)!;
|
||||
stored.promptText = 'line one\nline two';
|
||||
svc.store.setCronJob(stored.id, stored);
|
||||
const run = await svc.service.runNow(job.id);
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/single line/i);
|
||||
});
|
||||
|
||||
it('fails the run when the prompt file is multi-line', async () => {
|
||||
const ws = mkdtempSync(join(tmpdir(), 'codeman-cron-ml-'));
|
||||
const file = join(ws, 'prompt.md');
|
||||
writeFileSync(file, 'line one\nline two\n');
|
||||
const job = svc.service.createJob(
|
||||
mkInput({ promptMode: 'prompt_file_path', promptFilePath: file, promptText: undefined, workingDir: ws })
|
||||
);
|
||||
const run = await svc.service.runNow(job.id);
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/single line/i);
|
||||
});
|
||||
|
||||
it('tolerates trailing newlines in a prompt file (every editor writes one)', async () => {
|
||||
const ws = mkdtempSync(join(tmpdir(), 'codeman-cron-tn-'));
|
||||
const file = join(ws, 'prompt.md');
|
||||
writeFileSync(file, 'do the thing\n');
|
||||
const job = svc.service.createJob(
|
||||
mkInput({ promptMode: 'prompt_file_path', promptFilePath: file, promptText: undefined, workingDir: ws })
|
||||
);
|
||||
const run = await svc.service.runNow(job.id);
|
||||
// Past prompt resolution; fails only at the mock-incomplete session step.
|
||||
expect(run!.errorMessage).toMatch(/Session launch failed/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrency-skip session filtering', () => {
|
||||
const prevRun = (jobId: string, sessionId: string): CronJobRun => ({
|
||||
id: `r-${sessionId}`,
|
||||
cronJobId: jobId,
|
||||
sessionId,
|
||||
sessionName: 'job',
|
||||
startedAt: Date.now() - 60_000,
|
||||
finishedAt: Date.now() - 59_000,
|
||||
status: 'prompt_sent',
|
||||
triggerType: 'scheduled',
|
||||
createdSessionUrl: null,
|
||||
});
|
||||
|
||||
it('does not skip when the only same-mode sessions are stopped/error (dead tabs)', async () => {
|
||||
const sessions = new Map<string, FakeSession>([
|
||||
['dead1', { mode: 'claude', status: 'stopped' }],
|
||||
['dead2', { mode: 'claude', status: 'error' }],
|
||||
]);
|
||||
const local = makeService(sessions);
|
||||
const job = local.service.createJob(
|
||||
mkInput({ concurrencyPolicy: 'skip_if_same_agent_running', intervalMinutes: 10 })
|
||||
);
|
||||
await local.service.tickDueJobs(job.nextRunAt! + 1000);
|
||||
await flush();
|
||||
const runs = local.service.listRuns(job.id);
|
||||
expect(runs.length).toBe(1);
|
||||
expect(runs[0].status).toBe('failed'); // launched (mock session step), NOT skipped
|
||||
});
|
||||
|
||||
it("does not skip on the job's own previous-run session (no self-deadlock)", async () => {
|
||||
const sessions = new Map<string, FakeSession>([['own-1', { mode: 'claude' }]]);
|
||||
const local = makeService(sessions);
|
||||
const job = local.service.createJob(
|
||||
mkInput({ concurrencyPolicy: 'skip_if_same_agent_running', intervalMinutes: 10 })
|
||||
);
|
||||
local.store.setCronJobRun('r-own-1', prevRun(job.id, 'own-1'));
|
||||
await local.service.tickDueJobs(job.nextRunAt! + 1000);
|
||||
await flush();
|
||||
expect(local.service.listRuns(job.id)[0].status).not.toBe('skipped');
|
||||
});
|
||||
|
||||
it('does not consume a skipped once-job — it stays armed and fires when unblocked', async () => {
|
||||
const sessions = new Map<string, FakeSession>([['s1', { mode: 'claude' }]]);
|
||||
const local = makeService(sessions);
|
||||
const runAt = Date.now() - 1000;
|
||||
const job = local.service.createJob(
|
||||
mkInput({
|
||||
scheduleType: 'once',
|
||||
runAt,
|
||||
intervalMinutes: undefined,
|
||||
concurrencyPolicy: 'skip_if_same_agent_running',
|
||||
})
|
||||
);
|
||||
|
||||
await local.service.tickDueJobs(Date.now());
|
||||
await flush();
|
||||
let after = local.service.getJob(job.id)!;
|
||||
expect(after.lastStatus).toBe('skipped');
|
||||
expect(after.completedOnce).toBeFalsy();
|
||||
expect(after.enabled).toBe(true);
|
||||
expect(after.nextRunAt).toBe(runAt); // still armed
|
||||
|
||||
// The blocking session goes away → the next tick fires the single run.
|
||||
sessions.delete('s1');
|
||||
await local.service.tickDueJobs(Date.now());
|
||||
await flush();
|
||||
after = local.service.getJob(job.id)!;
|
||||
expect(after.completedOnce).toBe(true);
|
||||
expect(after.enabled).toBe(false);
|
||||
expect(local.service.listRuns(job.id).some((r) => r.status === 'failed')).toBe(true); // it launched
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoClosePreviousSession', () => {
|
||||
const prevRun = (jobId: string, sessionId: string): CronJobRun => ({
|
||||
id: `r-${sessionId}`,
|
||||
cronJobId: jobId,
|
||||
sessionId,
|
||||
sessionName: 'job',
|
||||
startedAt: Date.now() - 60_000,
|
||||
finishedAt: Date.now() - 59_000,
|
||||
status: 'prompt_sent',
|
||||
triggerType: 'scheduled',
|
||||
createdSessionUrl: null,
|
||||
});
|
||||
|
||||
it("closes the previous run's still-open session before launching (default on)", async () => {
|
||||
const sessions = new Map<string, FakeSession>([['prev-1', { mode: 'claude' }]]);
|
||||
const local = makeService(sessions);
|
||||
const job = local.service.createJob(mkInput({ intervalMinutes: 10 }));
|
||||
local.store.setCronJobRun('r-prev-1', prevRun(job.id, 'prev-1'));
|
||||
await local.service.runNow(job.id);
|
||||
expect(local.cleanupSession).toHaveBeenCalledWith('prev-1', true, expect.stringContaining('cron'));
|
||||
expect(sessions.has('prev-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not close anything when autoClosePreviousSession is false', async () => {
|
||||
const sessions = new Map<string, FakeSession>([['prev-1', { mode: 'claude' }]]);
|
||||
const local = makeService(sessions);
|
||||
const job = local.service.createJob(mkInput({ intervalMinutes: 10, autoClosePreviousSession: false }));
|
||||
local.store.setCronJobRun('r-prev-1', prevRun(job.id, 'prev-1'));
|
||||
await local.service.runNow(job.id);
|
||||
expect(local.cleanupSession).not.toHaveBeenCalled();
|
||||
expect(sessions.has('prev-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('never auto-closes for a once schedule', async () => {
|
||||
const sessions = new Map<string, FakeSession>([['prev-1', { mode: 'claude' }]]);
|
||||
const local = makeService(sessions);
|
||||
const job = local.service.createJob(
|
||||
mkInput({ scheduleType: 'once', runAt: Date.now() + 3_600_000, intervalMinutes: undefined })
|
||||
);
|
||||
local.store.setCronJobRun('r-prev-1', prevRun(job.id, 'prev-1'));
|
||||
await local.service.runNow(job.id);
|
||||
expect(local.cleanupSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('job-count cap', () => {
|
||||
it(`rejects creation beyond MAX_CRON_JOBS (${MAX_CRON_JOBS})`, () => {
|
||||
for (let i = 0; i < MAX_CRON_JOBS; i++) svc.service.createJob(mkInput({ name: `j${i}` }));
|
||||
expect(() => svc.service.createJob(mkInput({ name: 'overflow' }))).toThrow(/Maximum number of cron jobs/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Unit tests for the cron's pure next-run-time calculations.
|
||||
* Timezone-independent: daily/weekly expectations are asserted via local
|
||||
* Date getters rather than hardcoded epoch values.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseHHMM, computeNextRunAt, dueKeyFor } from '../src/cron/cron-time.js';
|
||||
import type { CronJob } from '../src/types/cron.js';
|
||||
|
||||
function baseJob(partial: Partial<CronJob>): CronJob {
|
||||
return {
|
||||
id: 'j1',
|
||||
name: 'test',
|
||||
agentType: 'claude',
|
||||
workingDir: '/tmp',
|
||||
promptMode: 'inline_text',
|
||||
promptText: 'hi',
|
||||
inputMode: 'typed',
|
||||
scheduleType: 'once',
|
||||
enabled: true,
|
||||
concurrencyPolicy: 'warn_only',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
lastRunAt: null,
|
||||
nextRunAt: null,
|
||||
lastStatus: null,
|
||||
lastDueKey: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseHHMM', () => {
|
||||
it('parses valid 24h times', () => {
|
||||
expect(parseHHMM('09:30')).toEqual({ hours: 9, minutes: 30 });
|
||||
expect(parseHHMM('23:59')).toEqual({ hours: 23, minutes: 59 });
|
||||
expect(parseHHMM('0:00')).toEqual({ hours: 0, minutes: 0 });
|
||||
});
|
||||
it('rejects invalid times', () => {
|
||||
expect(parseHHMM('24:00')).toBeNull();
|
||||
expect(parseHHMM('12:60')).toBeNull();
|
||||
expect(parseHHMM('9:5')).toBeNull(); // minutes must be 2 digits
|
||||
expect(parseHHMM('abc')).toBeNull();
|
||||
expect(parseHHMM(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNextRunAt — once', () => {
|
||||
it('returns runAt even when already in the past (missed one-time job still fires)', () => {
|
||||
const job = baseJob({ scheduleType: 'once', runAt: 1000 });
|
||||
expect(computeNextRunAt(job, 500)).toBe(1000);
|
||||
expect(computeNextRunAt(job, 5000)).toBe(1000);
|
||||
});
|
||||
it('returns null once completed', () => {
|
||||
const job = baseJob({ scheduleType: 'once', runAt: 1000, completedOnce: true });
|
||||
expect(computeNextRunAt(job, 500)).toBeNull();
|
||||
});
|
||||
it('returns null with no runAt', () => {
|
||||
expect(computeNextRunAt(baseJob({ scheduleType: 'once' }), 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNextRunAt — interval', () => {
|
||||
it('adds intervalMinutes to the after time', () => {
|
||||
const job = baseJob({ scheduleType: 'interval', intervalMinutes: 60 });
|
||||
expect(computeNextRunAt(job, 1000)).toBe(1000 + 60 * 60_000);
|
||||
});
|
||||
it('returns null with no/invalid interval', () => {
|
||||
expect(computeNextRunAt(baseJob({ scheduleType: 'interval' }), 0)).toBeNull();
|
||||
expect(computeNextRunAt(baseJob({ scheduleType: 'interval', intervalMinutes: 0 }), 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNextRunAt — daily', () => {
|
||||
it('schedules today when the time is still ahead', () => {
|
||||
const after = new Date(2026, 0, 1, 10, 0, 0).getTime();
|
||||
const next = computeNextRunAt(baseJob({ scheduleType: 'daily', dailyTime: '14:30' }), after)!;
|
||||
const d = new Date(next);
|
||||
expect(d.getHours()).toBe(14);
|
||||
expect(d.getMinutes()).toBe(30);
|
||||
expect(d.getDate()).toBe(1);
|
||||
expect(next).toBeGreaterThan(after);
|
||||
});
|
||||
it('rolls to tomorrow when the time has passed', () => {
|
||||
const after = new Date(2026, 0, 1, 16, 0, 0).getTime();
|
||||
const next = computeNextRunAt(baseJob({ scheduleType: 'daily', dailyTime: '14:30' }), after)!;
|
||||
const d = new Date(next);
|
||||
expect(d.getHours()).toBe(14);
|
||||
expect(d.getDate()).toBe(2);
|
||||
expect(next).toBeGreaterThan(after);
|
||||
});
|
||||
it('returns null with no time', () => {
|
||||
expect(computeNextRunAt(baseJob({ scheduleType: 'daily' }), 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNextRunAt — weekly', () => {
|
||||
it('finds the next selected weekday at the configured time', () => {
|
||||
const after = new Date(2026, 0, 1, 12, 0, 0).getTime();
|
||||
const targetDay = (new Date(after).getDay() + 2) % 7;
|
||||
const job = baseJob({ scheduleType: 'weekly', weeklyDays: [targetDay], weeklyTime: '08:00' });
|
||||
const next = computeNextRunAt(job, after)!;
|
||||
const d = new Date(next);
|
||||
expect(d.getDay()).toBe(targetDay);
|
||||
expect(d.getHours()).toBe(8);
|
||||
expect(next).toBeGreaterThan(after);
|
||||
// Within the coming week.
|
||||
expect(next - after).toBeLessThanOrEqual(7 * 24 * 60 * 60_000);
|
||||
});
|
||||
it('picks the soonest of multiple selected days', () => {
|
||||
const after = new Date(2026, 0, 1, 12, 0, 0).getTime();
|
||||
const soon = (new Date(after).getDay() + 1) % 7;
|
||||
const later = (new Date(after).getDay() + 3) % 7;
|
||||
const job = baseJob({ scheduleType: 'weekly', weeklyDays: [later, soon], weeklyTime: '09:00' });
|
||||
const next = computeNextRunAt(job, after)!;
|
||||
expect(new Date(next).getDay()).toBe(soon);
|
||||
});
|
||||
it('returns null with no days or no time', () => {
|
||||
expect(computeNextRunAt(baseJob({ scheduleType: 'weekly', weeklyTime: '09:00' }), 0)).toBeNull();
|
||||
expect(computeNextRunAt(baseJob({ scheduleType: 'weekly', weeklyDays: [1] }), 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dueKeyFor', () => {
|
||||
it('combines job id and fire time', () => {
|
||||
expect(dueKeyFor('j1', 123)).toBe('j1:123');
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,22 @@ describe('document-thumbnailer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through generated image formats with per-extension content types', async () => {
|
||||
const expectations: Array<[string, string]> = [
|
||||
['jpg', 'image/jpeg'],
|
||||
['jpeg', 'image/jpeg'],
|
||||
['gif', 'image/gif'],
|
||||
['webp', 'image/webp'],
|
||||
['png', 'image/png'],
|
||||
];
|
||||
|
||||
for (const [ext, contentType] of expectations) {
|
||||
const result = await generateFirstPageThumbnail(`/tmp/mockup.${ext}`, ext);
|
||||
expect(result).toEqual({ content: Buffer.from('large thumbnail'), contentType });
|
||||
}
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders Office thumbnails from the cached converted PDF after conversion cleanup', async () => {
|
||||
mockedMkdtemp.mockImplementation(async (prefix) =>
|
||||
String(prefix).includes('codeman-document-preview-cache')
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
isAllowedGeneratedArtifactPath,
|
||||
registerGeneratedArtifactAttachment,
|
||||
} from '../src/generated-artifact-attachments.js';
|
||||
import { attachmentRegistry } from '../src/attachment-registry.js';
|
||||
|
||||
describe('generated artifact attachments', () => {
|
||||
it('allows workspace artifacts and home-anchored Codex generated image directories', () => {
|
||||
const home = homedir();
|
||||
expect(isAllowedGeneratedArtifactPath('/repo/out/mockup.png', '/repo')).toBe(true);
|
||||
expect(
|
||||
isAllowedGeneratedArtifactPath(join(home, '.codex-personal', 'generated_images', 'mockup.png'), '/repo')
|
||||
).toBe(true);
|
||||
expect(isAllowedGeneratedArtifactPath(join(home, '.codex', 'generated_artifacts', 'report.pdf'), '/repo')).toBe(
|
||||
true
|
||||
);
|
||||
expect(isAllowedGeneratedArtifactPath('/etc/secret.png', '/repo')).toBe(false);
|
||||
expect(
|
||||
isAllowedGeneratedArtifactPath(
|
||||
join(home, '.codex-personal', 'generated_images', '..', '..', '.ssh', 'id_rsa.png'),
|
||||
'/repo'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects .codex marker directories that are not anchored at the user home', () => {
|
||||
expect(isAllowedGeneratedArtifactPath('/var/tmp/staging/.codex/generated_images/leak.png', '/repo')).toBe(false);
|
||||
expect(isAllowedGeneratedArtifactPath('/var/tmp/.codex-personal/generated_artifacts/leak.md', '/repo')).toBe(false);
|
||||
});
|
||||
|
||||
describe('symlink resolution', () => {
|
||||
let workspaceDir: string | undefined;
|
||||
let outsideDir: string | undefined;
|
||||
const sessionId = 'generated-artifact-symlink-test';
|
||||
|
||||
afterEach(async () => {
|
||||
attachmentRegistry.clearSession(sessionId);
|
||||
for (const dir of [workspaceDir, outsideDir]) {
|
||||
if (dir) await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
workspaceDir = undefined;
|
||||
outsideDir = undefined;
|
||||
});
|
||||
|
||||
it('confines on the resolved path: a workspace symlink to an outside file is rejected', async () => {
|
||||
// realpath so a symlinked tmpdir (e.g. macOS /var -> /private/var) can't skew containment checks
|
||||
workspaceDir = await fs.realpath(await fs.mkdtemp(join(tmpdir(), 'codeman-genart-ws-')));
|
||||
outsideDir = await fs.realpath(await fs.mkdtemp(join(tmpdir(), 'codeman-genart-out-')));
|
||||
const outsideFile = join(outsideDir, 'private-notes.md');
|
||||
await fs.writeFile(outsideFile, 'secret');
|
||||
const linkPath = join(workspaceDir, 'x.md');
|
||||
await fs.symlink(outsideFile, linkPath);
|
||||
|
||||
await expect(
|
||||
registerGeneratedArtifactAttachment({ sessionId, filePath: linkPath, sessionWorkingDir: workspaceDir })
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('registers a real workspace file', async () => {
|
||||
workspaceDir = await fs.realpath(await fs.mkdtemp(join(tmpdir(), 'codeman-genart-ws-')));
|
||||
const filePath = join(workspaceDir, 'mockup.png');
|
||||
await fs.writeFile(filePath, 'png-bytes');
|
||||
|
||||
const event = await registerGeneratedArtifactAttachment({
|
||||
sessionId,
|
||||
filePath,
|
||||
sessionWorkingDir: workspaceDir,
|
||||
});
|
||||
|
||||
expect(event.fileName).toBe('mockup.png');
|
||||
expect(event.attachmentType).toBe('image');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @fileoverview Tests for the HEIC → JPEG conversion core (heic-jpeg-worker.ts).
|
||||
*
|
||||
* Exercises the REAL heic-decode WASM parse path (no mocks) for the
|
||||
* decompression-bomb guard: a crafted <300-byte HEIC can declare arbitrary
|
||||
* dimensions in its `ispe` box, and heic-decode's plain decode path allocates
|
||||
* `width * height * 4` bytes straight from those header values (30000×30000 →
|
||||
* a 3.6GB allocation). The guard must reject via the allocation-free `.all`
|
||||
* dimension read BEFORE decode().
|
||||
*
|
||||
* Port: N/A (pure module test, no server).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { convertHeicBufferToJpeg, MAX_HEIC_DECODE_PIXELS } from '../src/web/heic-jpeg-worker.js';
|
||||
|
||||
// ── Minimal ISOBMFF/HEIF builder — just enough boxes (ftyp/meta/hdlr/pitm/
|
||||
// iloc/iinf/iprp[hvcC+ispe]/mdat) for libheif to parse the image handle and
|
||||
// report the ispe-declared dimensions. There is no real HEVC bitstream, so
|
||||
// pixel decode of these files always fails — which is the point: the guard
|
||||
// must fire before any decode is attempted.
|
||||
|
||||
function box(type: string, ...payloads: (Buffer | string)[]): Buffer {
|
||||
const payload = Buffer.concat(payloads.map((p) => (Buffer.isBuffer(p) ? p : Buffer.from(p))));
|
||||
const header = Buffer.alloc(8);
|
||||
header.writeUInt32BE(8 + payload.length, 0);
|
||||
header.write(type, 4, 'ascii');
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
function fullbox(type: string, version: number, flags: number, ...payloads: (Buffer | string)[]): Buffer {
|
||||
const vf = Buffer.alloc(4);
|
||||
vf.writeUInt32BE((version << 24) | flags, 0);
|
||||
return box(type, vf, ...payloads);
|
||||
}
|
||||
|
||||
function u16(n: number): Buffer {
|
||||
const b = Buffer.alloc(2);
|
||||
b.writeUInt16BE(n, 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
function u32(n: number): Buffer {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32BE(n, 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Craft a HEIC container whose header declares `width`×`height`. */
|
||||
function craftHeic(width: number, height: number): Buffer {
|
||||
const ftyp = box('ftyp', 'heic', u32(0), 'mif1heic');
|
||||
const hdlr = fullbox('hdlr', 0, 0, u32(0), 'pict', u32(0), u32(0), u32(0), Buffer.from([0]));
|
||||
const pitm = fullbox('pitm', 0, 0, u16(1));
|
||||
const infe = fullbox('infe', 2, 0, u16(1), u16(0), 'hvc1', Buffer.from([0]));
|
||||
const iinf = fullbox('iinf', 0, 0, u16(1), infe);
|
||||
const ispe = fullbox('ispe', 0, 0, u32(width), u32(height));
|
||||
// Minimal HEVCDecoderConfigurationRecord (23 bytes, zero parameter-set arrays).
|
||||
const hvcC = box(
|
||||
'hvcC',
|
||||
Buffer.from([
|
||||
0x01, 0x01, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5d, 0xf0, 0x00, 0xfc, 0xfd, 0xf8, 0xf8, 0x00,
|
||||
0x00, 0x03, 0x00,
|
||||
]),
|
||||
Buffer.from([0x00])
|
||||
);
|
||||
const ipco = box('ipco', hvcC, ispe);
|
||||
const ipma = fullbox('ipma', 0, 0, u32(1), u16(1), Buffer.from([2]), Buffer.from([0x81, 0x02]));
|
||||
const iprp = box('iprp', ipco, ipma);
|
||||
// iloc v0: offset_size=4, length_size=4, base_offset_size=0; one extent in mdat.
|
||||
const ilocItem = Buffer.concat([u16(1), u16(0), u16(1), u32(0), u32(16)]);
|
||||
const iloc = fullbox('iloc', 0, 0, Buffer.from([0x44, 0x00]), u16(1), ilocItem);
|
||||
const meta = fullbox('meta', 0, 0, hdlr, pitm, iloc, iinf, iprp);
|
||||
const mdat = box('mdat', Buffer.alloc(16));
|
||||
return Buffer.concat([ftyp, meta, mdat]);
|
||||
}
|
||||
|
||||
describe('heic-jpeg-core', () => {
|
||||
it('rejects a crafted bomb header (30000×30000 declared, 3.6GB decode) before decoding', async () => {
|
||||
const bomb = craftHeic(30000, 30000);
|
||||
expect(bomb.length).toBeLessThan(1024); // tiny input, huge declared output
|
||||
await expect(convertHeicBufferToJpeg(bomb)).rejects.toThrow(/30000x30000 exceed the 64MP decode limit/);
|
||||
});
|
||||
|
||||
it('rejects dimensions just over the cap', async () => {
|
||||
// 8000×8001 = 64,008,000 px — barely over MAX_HEIC_DECODE_PIXELS (64MP).
|
||||
expect(8000 * 8001).toBeGreaterThan(MAX_HEIC_DECODE_PIXELS);
|
||||
await expect(convertHeicBufferToJpeg(craftHeic(8000, 8001))).rejects.toThrow(/decode limit/);
|
||||
});
|
||||
|
||||
it('lets dimensions under the cap through the guard (failure, if any, comes from pixel decode)', async () => {
|
||||
// The crafted file has no real HEVC bitstream, so decode fails — but NOT
|
||||
// with the dimension-limit error, proving the guard ran and passed.
|
||||
await expect(convertHeicBufferToJpeg(craftHeic(100, 100))).rejects.toThrow(/^(?!.*decode limit).*$/);
|
||||
});
|
||||
|
||||
it('rejects non-HEIC bytes', async () => {
|
||||
await expect(convertHeicBufferToJpeg(Buffer.from('this is definitely not a HEIC image'))).rejects.toThrow(
|
||||
/not a HEIC image/i
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes decoded RGBA into JPEG bytes with valid magic (heic-decode mocked, real jpeg-js)', async () => {
|
||||
const dispose = vi.fn();
|
||||
const handle = {
|
||||
width: 2,
|
||||
height: 2,
|
||||
decode: async () => ({ width: 2, height: 2, data: new Uint8ClampedArray(16).fill(128) }),
|
||||
};
|
||||
vi.doMock('heic-decode', () => {
|
||||
const decode = Object.assign(async () => handle.decode(), {
|
||||
all: async () => Object.assign([handle], { dispose }),
|
||||
});
|
||||
return { default: decode };
|
||||
});
|
||||
try {
|
||||
const jpeg = await convertHeicBufferToJpeg(Buffer.from('mock input'));
|
||||
expect(jpeg[0]).toBe(0xff);
|
||||
expect(jpeg[1]).toBe(0xd8);
|
||||
expect(jpeg[2]).toBe(0xff);
|
||||
expect(dispose).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.doUnmock('heic-decode');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const INDEX_HTML = readFileSync(join(process.cwd(), 'src/web/public/index.html'), 'utf-8');
|
||||
|
||||
function normalizedHtml(value: string): string {
|
||||
return value.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function extractElementById(html: string, id: string): string {
|
||||
const idIndex = html.indexOf(`id="${id}"`);
|
||||
expect(idIndex, `expected #${id} to exist`).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const start = html.lastIndexOf('<', idIndex);
|
||||
expect(start, `expected #${id} start tag`).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Bound at the next HTML comment (every following section is comment-labeled) so
|
||||
// sections inserted between this element and any fixed marker don't leak into the
|
||||
// slice — the cron modal's "Run At" text false-positived the stale-shortcut check.
|
||||
const nextSection = html.indexOf('<!--', idIndex);
|
||||
expect(nextSection, `expected section marker after #${id}`).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const end = nextSection;
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function expectShortcut(html: string, keys: string[], label: string): void {
|
||||
const keyPattern = keys.map((key) => `<kbd>${escapeRegExp(key)}</kbd>`).join('\\s*\\+\\s*');
|
||||
expect(html).toMatch(new RegExp(`${keyPattern}.*?${label}`, 'i'));
|
||||
}
|
||||
|
||||
describe('help modal shortcuts', () => {
|
||||
const helpModal = normalizedHtml(extractElementById(INDEX_HTML, 'helpModal'));
|
||||
|
||||
it('documents implemented global and tab shortcuts', () => {
|
||||
expectShortcut(helpModal, ['Ctrl', 'W'], 'Close Session');
|
||||
expectShortcut(helpModal, ['Ctrl', 'Tab'], 'Next Session');
|
||||
expectShortcut(helpModal, ['Alt/Option', '['], 'Previous / Next Session');
|
||||
expectShortcut(helpModal, ['Alt/Option', ']'], 'Previous / Next Session');
|
||||
expectShortcut(helpModal, ['Alt/Option', '1-9'], 'Switch to Tab N');
|
||||
expectShortcut(helpModal, ['Ctrl', '{'], 'Move Active Tab Left');
|
||||
expectShortcut(helpModal, ['Ctrl', '}'], 'Move Active Tab Right');
|
||||
expectShortcut(helpModal, ['Ctrl', '?'], 'Show Shortcuts');
|
||||
expect(helpModal).not.toMatch(/Ctrl<\/kbd>\s*\+\s*<kbd>\/<\/kbd>.*?Show Shortcuts/i);
|
||||
expectShortcut(helpModal, ['Ctrl', 'Shift', 'V'], 'Voice Input');
|
||||
expectShortcut(helpModal, ['Escape'], 'Close Panels');
|
||||
});
|
||||
|
||||
it('documents terminal input shortcuts without advertising stale run shortcuts', () => {
|
||||
expectShortcut(helpModal, ['Ctrl', 'L'], 'Clear Terminal');
|
||||
expectShortcut(helpModal, ['Ctrl', '+'], 'Increase Font');
|
||||
expectShortcut(helpModal, ['Ctrl', '-'], 'Decrease Font');
|
||||
expectShortcut(helpModal, ['Shift', 'Enter'], 'Insert Newline');
|
||||
expectShortcut(helpModal, ['Ctrl', 'Enter'], 'Insert Newline');
|
||||
// Ctrl+Shift+R (restore terminal size) is still dispatched — keep it documented.
|
||||
expectShortcut(helpModal, ['Ctrl', 'Shift', 'R'], 'Restore Terminal Size');
|
||||
|
||||
expect(helpModal).not.toMatch(/Ctrl<\/kbd>\s*\+\s*<kbd>K<\/kbd>/i);
|
||||
expect(helpModal).not.toMatch(/Ctrl<\/kbd>\s*\+\s*<kbd>Enter<\/kbd>.*?(Run|Start)/i);
|
||||
});
|
||||
|
||||
it('does not advertise the removed Ctrl+Enter run binding in launch UI hints', () => {
|
||||
expect(INDEX_HTML).not.toContain('Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start');
|
||||
expect(INDEX_HTML).not.toContain('title="Run (Ctrl+Enter)"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* @fileoverview Input dispatch ordering for the durable, acknowledged delivery
|
||||
* layer (`CodemanApp._sendInputAsync` → `_reliableSend` → `_drainSession`).
|
||||
*
|
||||
* Local replaced the upstream best-effort "coalescing fallback queue" with the
|
||||
* durable per-(clientId, seq) layer in commit 1255e28 (docs/reliable-input-
|
||||
* delivery.md). This suite verifies the client-side ordering guarantees of that
|
||||
* layer: each input is a distinct seq-tagged frame, delivered in order over the
|
||||
* WebSocket when open, serialized over HTTP POST when not, and only dropped on a
|
||||
* server ACK (HTTP 2xx). Exactly-once application is covered server-side in
|
||||
* test/reliable-input-dedup.test.ts; the header transport indicator ("WS"/"HTTP")
|
||||
* is covered in test/connection-indicator.test.ts.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function loadCodemanAppClass() {
|
||||
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
WebSocket: { OPEN: 1 },
|
||||
fetch: (...args: Parameters<typeof fetch>) => global.fetch(...args),
|
||||
document: { addEventListener: vi.fn() },
|
||||
localStorage: {
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
MobileDetection: {},
|
||||
});
|
||||
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
return (context as { __CodemanApp: new () => unknown }).__CodemanApp;
|
||||
}
|
||||
|
||||
const CodemanApp = loadCodemanAppClass();
|
||||
|
||||
async function waitForCalls(calls: unknown[], count: number) {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (calls.length >= count) return;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
}
|
||||
|
||||
type Frame = { t: string; d: string; seq: number; cid: string };
|
||||
type PostBody = { input: string; seq: number; clientId: string };
|
||||
|
||||
type App = {
|
||||
_sendInputAsync: (sessionId: string, input: string, opts?: { useMux?: boolean }) => void;
|
||||
_pendingDeliveries: Map<string, Array<{ seq: number; data: string; sentAt: number }>>;
|
||||
_ws: { readyState: number; send: (data: string) => void } | null;
|
||||
_wsSessionId: string | null;
|
||||
activeSessionId: string | null;
|
||||
};
|
||||
|
||||
function makeApp(): App {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as App & Record<string, unknown>;
|
||||
app._clientId = 'c-test';
|
||||
app._seqCounters = new Map();
|
||||
app._pendingDeliveries = new Map();
|
||||
app._postDraining = new Set();
|
||||
app._persistReliableState = vi.fn();
|
||||
app._persistReliableNow = vi.fn();
|
||||
app._updateConnectionIndicator = vi.fn();
|
||||
app.clearPendingHooks = vi.fn();
|
||||
app.activeSessionId = 'session-1';
|
||||
app.isOnline = true;
|
||||
app._connectionStatus = 'connected';
|
||||
app._ws = null;
|
||||
app._wsSessionId = null;
|
||||
return app as unknown as App;
|
||||
}
|
||||
|
||||
describe('durable input delivery — send ordering', () => {
|
||||
it('delivers rapid input as distinct ordered seq frames over an open WebSocket (no coalescing)', () => {
|
||||
const app = makeApp();
|
||||
const frames: Frame[] = [];
|
||||
app._ws = { readyState: 1, send: (d: string) => frames.push(JSON.parse(d)) };
|
||||
app._wsSessionId = 'session-1';
|
||||
|
||||
app._sendInputAsync('session-1', 'a');
|
||||
app._sendInputAsync('session-1', 'b');
|
||||
app._sendInputAsync('session-1', 'c');
|
||||
|
||||
// Each keystroke is its own frame, in seq order — never merged into "abc".
|
||||
expect(frames.map((f) => f.d)).toEqual(['a', 'b', 'c']);
|
||||
expect(frames.map((f) => f.seq)).toEqual([1, 2, 3]);
|
||||
expect(frames.every((f) => f.t === 'i' && f.cid === 'c-test')).toBe(true);
|
||||
});
|
||||
|
||||
it('POSTs queued input one frame at a time in seq order when no socket is open', async () => {
|
||||
const app = makeApp();
|
||||
const calls: PostBody[] = [];
|
||||
const completions: Array<() => void> = [];
|
||||
global.fetch = vi.fn(async (_url, init) => {
|
||||
calls.push(JSON.parse(String(init?.body)) as PostBody);
|
||||
await new Promise<void>((r) => completions.push(r));
|
||||
return new Response('{}', { status: 200 });
|
||||
});
|
||||
|
||||
app._sendInputAsync('session-1', 'a');
|
||||
app._sendInputAsync('session-1', 'b');
|
||||
|
||||
// Serialized: only the first frame is in flight until its 2xx ACK lands.
|
||||
await waitForCalls(calls, 1);
|
||||
expect(calls.map((c) => c.input)).toEqual(['a']);
|
||||
|
||||
completions.shift()?.(); // ACK 'a'
|
||||
await waitForCalls(calls, 2);
|
||||
expect(calls.map((c) => c.input)).toEqual(['a', 'b']);
|
||||
expect(calls.map((c) => c.seq)).toEqual([1, 2]);
|
||||
|
||||
completions.shift()?.();
|
||||
await waitForCalls(calls, 2);
|
||||
});
|
||||
|
||||
it('leaves a frame queued (unacked) when HTTP delivery fails', async () => {
|
||||
const app = makeApp();
|
||||
const calls: PostBody[] = [];
|
||||
global.fetch = vi.fn(async (_url, init) => {
|
||||
calls.push(JSON.parse(String(init?.body)) as PostBody);
|
||||
return new Response('busy', { status: 503 });
|
||||
});
|
||||
|
||||
app._sendInputAsync('session-1', 'a');
|
||||
await waitForCalls(calls, 1);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
// 5xx is not an ACK — the frame must survive for the sweep/reconnect to retry.
|
||||
expect(app._pendingDeliveries.get('session-1')).toHaveLength(1);
|
||||
expect(app._pendingDeliveries.get('session-1')?.[0].data).toBe('a');
|
||||
});
|
||||
|
||||
it('drops a frame addressed to a vanished session (404) instead of retrying forever', async () => {
|
||||
const app = makeApp();
|
||||
global.fetch = vi.fn(async () => new Response('gone', { status: 404 }));
|
||||
|
||||
app._sendInputAsync('session-1', 'a');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(app._pendingDeliveries.get('session-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// COD-135 — durable redelivery sweep when an ACK is lost.
|
||||
type RedriveApp = App & {
|
||||
_redeliverSweep: () => void;
|
||||
_reliableAckTimeoutMs: number;
|
||||
_wsLastRecvAt: number;
|
||||
};
|
||||
|
||||
describe('durable input delivery — _redeliverSweep ACK-loss recovery (COD-135)', () => {
|
||||
it('re-drives a stale unacked frame over a STILL-LIVE socket (lost ACK, not silent)', () => {
|
||||
const app = makeApp() as RedriveApp;
|
||||
const frames: Frame[] = [];
|
||||
const close = vi.fn();
|
||||
app._ws = { readyState: 1, send: (d: string) => frames.push(JSON.parse(d)), close } as never;
|
||||
app._wsSessionId = 'session-1';
|
||||
app._reliableAckTimeoutMs = 4000;
|
||||
|
||||
// Frame sent once over the open socket; ACK never arrives.
|
||||
app._sendInputAsync('session-1', 'a');
|
||||
expect(frames.map((f) => f.d)).toEqual(['a']);
|
||||
|
||||
// ACK is lost, but the socket KEEPS receiving output → it is NOT silent.
|
||||
// Backdate the send so the frame is stale; keep recv timestamp fresh.
|
||||
const list = app._pendingDeliveries.get('session-1')!;
|
||||
list[0].sentAt = Date.now() - (app._reliableAckTimeoutMs + 1000);
|
||||
app._wsLastRecvAt = Date.now();
|
||||
|
||||
app._redeliverSweep();
|
||||
|
||||
// The stale frame must be re-sent over the live socket (a second send),
|
||||
// and the socket must NOT be force-closed (it's alive, just the ACK was lost).
|
||||
expect(frames.map((f) => f.d)).toEqual(['a', 'a']);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(app._pendingDeliveries.get('session-1')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does NOT re-drive a not-yet-stale frame (sent recently)', () => {
|
||||
const app = makeApp() as RedriveApp;
|
||||
const frames: Frame[] = [];
|
||||
const close = vi.fn();
|
||||
app._ws = { readyState: 1, send: (d: string) => frames.push(JSON.parse(d)), close } as never;
|
||||
app._wsSessionId = 'session-1';
|
||||
app._reliableAckTimeoutMs = 4000;
|
||||
|
||||
app._sendInputAsync('session-1', 'a');
|
||||
app._wsLastRecvAt = Date.now(); // not silent
|
||||
|
||||
// sentAt is fresh (just sent) → below the stale threshold → leave it alone.
|
||||
app._redeliverSweep();
|
||||
|
||||
expect(frames.map((f) => f.d)).toEqual(['a']); // no second send
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('force-closes the socket when stale AND silent (half-open — COD-134 fallback preserved)', () => {
|
||||
const app = makeApp() as RedriveApp;
|
||||
const frames: Frame[] = [];
|
||||
const close = vi.fn();
|
||||
app._ws = { readyState: 1, send: (d: string) => frames.push(JSON.parse(d)), close } as never;
|
||||
app._wsSessionId = 'session-1';
|
||||
app._reliableAckTimeoutMs = 4000;
|
||||
|
||||
app._sendInputAsync('session-1', 'a');
|
||||
const list = app._pendingDeliveries.get('session-1')!;
|
||||
list[0].sentAt = Date.now() - (app._reliableAckTimeoutMs + 1000); // stale
|
||||
app._wsLastRecvAt = Date.now() - (app._reliableAckTimeoutMs + 1000); // silent
|
||||
|
||||
app._redeliverSweep();
|
||||
|
||||
// Half-open socket never recovers on its own → force-close to reconnect.
|
||||
// It must NOT have re-sent over the dead socket.
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(frames.map((f) => f.d)).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ describe('keyboard shortcuts', () => {
|
||||
// terminal-ui.js must gate its xterm pass-through on the SAME physical e.code set the
|
||||
// app.js handler consumes; otherwise Alt+[ / Alt+] (and Option+digit on remapped macOS
|
||||
// layouts) switch tabs AND inject ESC<char> into the focused terminal. Keep in sync.
|
||||
expect(terminalUiSource).toContain('/^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code');
|
||||
expect(terminalUiSource).toContain('/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code');
|
||||
});
|
||||
|
||||
it('documents the Alt/Option shortcuts in help and README', () => {
|
||||
@@ -34,4 +34,28 @@ describe('keyboard shortcuts', () => {
|
||||
expect(readme).toContain('`Alt/Option+[` / `Alt/Option+]`');
|
||||
expect(readme).toContain('`Alt/Option+1`-`Alt/Option+9`');
|
||||
});
|
||||
|
||||
it('documents the Command-K open-session palette in help and README', () => {
|
||||
expect(appSource).toContain('this.openCommandPalette()');
|
||||
expect(helpHtml).toContain('<kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd>');
|
||||
expect(readme).toMatch(/\| `Ctrl\/Cmd\/Option\+K`\s+\| Find open session or start a new one\s+\|/);
|
||||
});
|
||||
|
||||
it('gates the palette chord in the xterm custom key handler (no 0x0b kill-line into the PTY)', () => {
|
||||
// The document-level capture handler opens the palette, but preventDefault()
|
||||
// does NOT stop xterm from evaluating Ctrl+K into 0x0b and writing it to the
|
||||
// live PTY — terminal-ui.js must return false for the palette chord.
|
||||
expect(terminalUiSource).toMatch(/ev\.type === 'keydown' && this\.shouldOpenCommandPaletteFromShortcut\?\.\(ev\)/);
|
||||
});
|
||||
|
||||
it('dispatches document shortcuts through the shortcut registry (rebind/disable aware)', () => {
|
||||
// The legacy hardcoded SHORTCUTS table must stay gone — dispatch goes through
|
||||
// getShortcutRegistry() + matchesShortcutEvent() so overrides and per-shortcut
|
||||
// disables (App Settings → Shortcuts) actually take effect.
|
||||
expect(appSource).not.toContain('const SHORTCUTS = [');
|
||||
expect(appSource).toContain('const SHORTCUT_ACTIONS = {');
|
||||
expect(appSource).toContain('for (const shortcut of this.getShortcutRegistry())');
|
||||
expect(appSource).toContain('if (this.matchesShortcutEvent(e, shortcut))');
|
||||
expect(appSource).toContain('if (shortcut.disabled || !shortcut.action) continue;');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,6 +266,9 @@ export class MockSession extends EventEmitter {
|
||||
/** Stub for startInteractive */
|
||||
startInteractive = vi.fn(async () => {});
|
||||
|
||||
/** Stub for resetRespawnBreaker (COD-118) */
|
||||
resetRespawnBreaker = vi.fn();
|
||||
|
||||
/** Stub for startShell */
|
||||
startShell = vi.fn(async () => {});
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
defaultRemoteCommandForMode,
|
||||
readRemoteCases,
|
||||
readRemoteHosts,
|
||||
remoteDisplayPath,
|
||||
remoteSshTarget,
|
||||
writeRemoteCases,
|
||||
writeRemoteHosts,
|
||||
} from '../src/remote-hosts.js';
|
||||
|
||||
describe('remote-hosts domain', () => {
|
||||
let dir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
dir = null;
|
||||
});
|
||||
|
||||
function configDir(): string {
|
||||
dir = mkdtempSync(join(tmpdir(), 'codeman-remote-hosts-'));
|
||||
return dir;
|
||||
}
|
||||
|
||||
it('round-trips remote hosts and remote cases from a config directory', async () => {
|
||||
const root = configDir();
|
||||
await writeRemoteHosts(root, [
|
||||
{
|
||||
id: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
},
|
||||
]);
|
||||
await writeRemoteCases(root, [
|
||||
{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
]);
|
||||
|
||||
await expect(readRemoteHosts(root)).resolves.toEqual([
|
||||
{
|
||||
id: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
},
|
||||
]);
|
||||
await expect(readRemoteCases(root)).resolves.toEqual([
|
||||
{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns safe mode defaults and remote display values', () => {
|
||||
expect(defaultRemoteCommandForMode('shell')).toBe('exec bash -l');
|
||||
expect(defaultRemoteCommandForMode('codex')).toBe('exec codex');
|
||||
// Mirrors the local claude default so the remote agent runs non-interactively.
|
||||
expect(defaultRemoteCommandForMode('claude')).toBe('exec claude --dangerously-skip-permissions');
|
||||
expect(remoteSshTarget({ id: 'h1', label: 'H1', host: 'box.local', username: 'aamer' })).toBe('aamer@box.local');
|
||||
expect(remoteDisplayPath({ username: 'aamer', host: 'box.local', path: '/opt/work' })).toBe(
|
||||
'aamer@box.local:/opt/work'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @fileoverview COD-107 — Remote-host SSH: custom port + advanced connection options.
|
||||
*
|
||||
* Unit tests for the shared, pure `buildSshConnectionArgs(remote)` and its two
|
||||
* consumers (`buildRemoteLaunchCommand`, `buildRemoteTmuxCheckCommand`). The
|
||||
* acceptance target is the aa-desktop option set (custom port 2222, ed25519
|
||||
* identity under `~`, a cloudflared SOCKS5 ProxyCommand, plus an arbitrary
|
||||
* `-o` escape-hatch option) — the same connection `~/repos/claude-config/bin/
|
||||
* ssh-aa-desktop` makes, WITHOUT shelling out to that wrapper.
|
||||
*
|
||||
* Critical, easy-to-break invariants pinned here:
|
||||
* - `%h %p` in the ProxyCommand reach ssh LITERALLY (one shellescaped
|
||||
* `-o ProxyCommand=…` token; the local shell must not expand/mangle them).
|
||||
* - a leading `~`/`$HOME` in `identityFile` is expanded to an absolute path at
|
||||
* build time (ssh does NOT expand `~` in `-i`), then shellescaped.
|
||||
* - empty options ⇒ byte-identical ssh to today (full back-compat).
|
||||
*
|
||||
* Pure command-string construction; no real tmux, no ssh. Port: N/A.
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildSshConnectionArgs, buildRemoteTmuxCheckCommand, remoteSshTarget } from '../src/remote-hosts.js';
|
||||
import { buildRemoteLaunchCommand } from '../src/tmux-manager.js';
|
||||
import type { SessionRemote } from '../src/types.js';
|
||||
|
||||
const HOME = homedir();
|
||||
|
||||
const baseRemote: SessionRemote = {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
};
|
||||
|
||||
// The acceptance host: aa-desktop reached over the cloudflared SOCKS5 proxy.
|
||||
const aaDesktop: SessionRemote = {
|
||||
hostId: 'aa-desktop',
|
||||
label: 'aa-desktop',
|
||||
host: '192.168.55.170',
|
||||
username: 'aakht',
|
||||
port: 2222,
|
||||
remotePath: '/tmp',
|
||||
identityFile: '~/.ssh/remote_ed25519',
|
||||
socksProxy: '127.0.0.1:1080',
|
||||
extraSshOptions: ['StrictHostKeyChecking=accept-new'],
|
||||
commands: { shell: 'exec bash -l' },
|
||||
};
|
||||
|
||||
const SESSION_ID = 'cod107chk';
|
||||
|
||||
describe('COD-107 buildSshConnectionArgs — shared ssh connection tokens', () => {
|
||||
it('always leads with -o BatchMode=yes then the default -o ConnectTimeout=10', () => {
|
||||
expect(buildSshConnectionArgs(baseRemote)).toEqual(['ssh', '-o BatchMode=yes', '-o ConnectTimeout=10']);
|
||||
});
|
||||
|
||||
it('emits the full aa-desktop option set in order with escaping + %h %p intact', () => {
|
||||
const args = buildSshConnectionArgs(aaDesktop);
|
||||
const joined = args.join(' ');
|
||||
|
||||
// -p before -i before the proxy -o; identity ~ expanded absolute, then escaped.
|
||||
expect(joined).toContain('-o BatchMode=yes');
|
||||
expect(joined).toContain('-p 2222');
|
||||
expect(joined).toContain(`-i '${HOME}/.ssh/remote_ed25519'`);
|
||||
// No literal tilde survives into the -i token.
|
||||
expect(joined).not.toContain('-i ~');
|
||||
expect(joined).not.toContain("-i '~");
|
||||
|
||||
// The whole ProxyCommand (with its spaces and %h %p) is ONE shellescaped -o token.
|
||||
expect(joined).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'");
|
||||
// %h %p must survive verbatim — they are ssh tokens, not shell tokens.
|
||||
expect(joined).toContain('%h %p');
|
||||
|
||||
// The escape-hatch extra option, shellescaped.
|
||||
expect(joined).toContain("-o 'StrictHostKeyChecking=accept-new'");
|
||||
|
||||
// Ordering: BatchMode -> port -> identity -> ProxyCommand -> extras.
|
||||
const idxBatch = joined.indexOf('BatchMode=yes');
|
||||
const idxPort = joined.indexOf('-p 2222');
|
||||
const idxIdentity = joined.indexOf('-i ');
|
||||
const idxProxy = joined.indexOf('ProxyCommand=');
|
||||
const idxExtra = joined.indexOf('StrictHostKeyChecking');
|
||||
expect(idxBatch).toBeLessThan(idxPort);
|
||||
expect(idxPort).toBeLessThan(idxIdentity);
|
||||
expect(idxIdentity).toBeLessThan(idxProxy);
|
||||
expect(idxProxy).toBeLessThan(idxExtra);
|
||||
});
|
||||
|
||||
it('supports an explicit -J jump host (shellescaped, like its siblings)', () => {
|
||||
const args = buildSshConnectionArgs({ ...baseRemote, jumpHost: 'bastion@10.0.0.1:22' });
|
||||
expect(args.join(' ')).toContain("-J 'bastion@10.0.0.1:22'");
|
||||
});
|
||||
|
||||
it('shellescapes a -J jump host containing shell metacharacters (no injection)', () => {
|
||||
// Defense-in-depth: even if a metachar-laden value slipped past schema validation,
|
||||
// it must stay a single shell token and never break out of the ssh command.
|
||||
const args = buildSshConnectionArgs({ ...baseRemote, jumpHost: 'x; touch /tmp/pwned' });
|
||||
const joined = args.join(' ');
|
||||
// The whole value is wrapped in single quotes — the `;` cannot start a new command.
|
||||
expect(joined).toContain("-J 'x; touch /tmp/pwned'");
|
||||
expect(joined).not.toContain('-J x;');
|
||||
});
|
||||
|
||||
it('expands a $HOME-prefixed identity path', () => {
|
||||
const args = buildSshConnectionArgs({ ...baseRemote, identityFile: '$HOME/.ssh/id_ed25519' });
|
||||
expect(args.join(' ')).toContain(`-i '${HOME}/.ssh/id_ed25519'`);
|
||||
});
|
||||
|
||||
it('empty options ⇒ BatchMode + the default ConnectTimeout (+ -p only when set)', () => {
|
||||
expect(buildSshConnectionArgs(baseRemote)).toEqual(['ssh', '-o BatchMode=yes', '-o ConnectTimeout=10']);
|
||||
expect(buildSshConnectionArgs({ ...baseRemote, port: 2200 })).toEqual([
|
||||
'ssh',
|
||||
'-o BatchMode=yes',
|
||||
'-o ConnectTimeout=10',
|
||||
'-p 2200',
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits the default ConnectTimeout when extraSshOptions already sets it (operator wins)', () => {
|
||||
const args = buildSshConnectionArgs({ ...baseRemote, extraSshOptions: ['ConnectTimeout=3'] });
|
||||
expect(args.filter((a) => a.includes('ConnectTimeout'))).toEqual(["-o 'ConnectTimeout=3'"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
|
||||
it('emits the aa-desktop ssh connection options ahead of -t and the target', () => {
|
||||
const command = buildRemoteLaunchCommand({ mode: 'shell', remote: aaDesktop, sessionId: SESSION_ID });
|
||||
|
||||
expect(command).toContain('-p 2222');
|
||||
expect(command).toContain(`-i '${HOME}/.ssh/remote_ed25519'`);
|
||||
expect(command).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'");
|
||||
expect(command).toContain("-o 'StrictHostKeyChecking=accept-new'");
|
||||
expect(command).toContain('-t');
|
||||
expect(command).toContain('aakht@192.168.55.170');
|
||||
expect(command).toContain('tmux -L codeman-remote new-session -A');
|
||||
|
||||
// Connection options come BEFORE -t / the target / the tmux command.
|
||||
const idxProxy = command.indexOf('ProxyCommand=');
|
||||
const idxTarget = command.indexOf('aakht@192.168.55.170');
|
||||
expect(idxProxy).toBeLessThan(idxTarget);
|
||||
});
|
||||
|
||||
it('a remote with no advanced options is byte-identical to the expected form', () => {
|
||||
const command = buildRemoteLaunchCommand({ mode: 'shell', remote: baseRemote, sessionId: SESSION_ID });
|
||||
// Reconstruct the command using the SAME nested POSIX single-quote escaping the
|
||||
// production code uses, to prove byte-identity. Session runs on the DEDICATED
|
||||
// `-L codeman-remote` socket under a `codeman-ssh-` name that a remote Codeman's
|
||||
// discovery ignores; set-options are scoped per-session (never `-g`).
|
||||
const sh = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
const remoteName = `codeman-ssh-${SESSION_ID.slice(0, 8)}`;
|
||||
const path = sh('/home/ubuntu/work');
|
||||
const paneCommand = `cd ${path} && exec bash -l`;
|
||||
const tmuxInvocation = [
|
||||
`tmux -L codeman-remote new-session -A -s ${remoteName} -c ${path} ${sh(paneCommand)}`,
|
||||
`set -t ${remoteName} status off`,
|
||||
`set -t ${remoteName} mouse off`,
|
||||
`set -t ${remoteName} prefix C-q`,
|
||||
'set -s escape-time 0',
|
||||
].join(' \\; ');
|
||||
// Connection args (with the default -o ConnectTimeout=10) sit after -t.
|
||||
const expected = `ssh -o BatchMode=yes -t -o ConnectTimeout=10 ${remoteSshTarget(baseRemote)} ${sh(tmuxInvocation)}`;
|
||||
expect(command).toBe(expected);
|
||||
});
|
||||
|
||||
it('port-only remote places -p after the -t/ConnectTimeout tokens', () => {
|
||||
const command = buildRemoteLaunchCommand({
|
||||
mode: 'shell',
|
||||
remote: { ...baseRemote, port: 2222 },
|
||||
sessionId: SESSION_ID,
|
||||
});
|
||||
expect(command).toMatch(/^ssh -o BatchMode=yes -t -o ConnectTimeout=10 -p 2222 ubuntu@10\.0\.0\.42 /);
|
||||
});
|
||||
});
|
||||
|
||||
describe('COD-107 buildRemoteTmuxCheckCommand — same connection options as the launch', () => {
|
||||
it('uses the shared connection args (proxy/identity/port) plus ConnectTimeout', () => {
|
||||
const cmd = buildRemoteTmuxCheckCommand(aaDesktop);
|
||||
expect(cmd).toContain('-o BatchMode=yes');
|
||||
expect(cmd).toContain('-o ConnectTimeout=10');
|
||||
expect(cmd).toContain('-p 2222');
|
||||
expect(cmd).toContain(`-i '${HOME}/.ssh/remote_ed25519'`);
|
||||
expect(cmd).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'");
|
||||
expect(cmd).toContain('aakht@192.168.55.170');
|
||||
expect(cmd).toContain("'command -v tmux'");
|
||||
});
|
||||
|
||||
it('back-compat: no advanced options ⇒ unchanged probe string', () => {
|
||||
expect(buildRemoteTmuxCheckCommand({ username: 'ubuntu', host: '10.0.0.42' })).toBe(
|
||||
"ssh -o BatchMode=yes -o ConnectTimeout=10 ubuntu@10.0.0.42 'command -v tmux'"
|
||||
);
|
||||
expect(buildRemoteTmuxCheckCommand({ username: 'ubuntu', host: '10.0.0.42', port: 2222 })).toContain('-p 2222');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the interactive-PTY exit circuit breaker (COD-118).
|
||||
*
|
||||
* Covers the pure trip/reset/window logic of `InteractivePtyExitBreaker` with
|
||||
* INJECTED time (no real timers, fully deterministic), plus a Session-level
|
||||
* assertion via MockSession that repeated non-zero exits flip the session to
|
||||
* `error` + block respawn, and that an explicit reset re-enables spawning.
|
||||
* Also covers the REAL listener wiring lifecycle (createSessionListeners /
|
||||
* attach / detach): the wiring exit handler detaches everything on each PTY
|
||||
* exit, so the re-attach routes must re-wire or the trip goes unobserved.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
InteractivePtyExitBreaker,
|
||||
DEFAULT_BREAKER_THRESHOLD,
|
||||
DEFAULT_BREAKER_WINDOW_MS,
|
||||
} from '../src/session-pty-exit-breaker.js';
|
||||
import { MockSession } from './mocks/index.js';
|
||||
import {
|
||||
createSessionListeners,
|
||||
attachSessionListeners,
|
||||
detachSessionListeners,
|
||||
type SessionListenerRefs,
|
||||
} from '../src/web/session-listener-wiring.js';
|
||||
import { SseEvent } from '../src/web/sse-events.js';
|
||||
import type { Session } from '../src/session.js';
|
||||
|
||||
describe('InteractivePtyExitBreaker — pure logic', () => {
|
||||
it('exports sane default constants', () => {
|
||||
expect(DEFAULT_BREAKER_THRESHOLD).toBeGreaterThanOrEqual(3);
|
||||
expect(DEFAULT_BREAKER_WINDOW_MS).toBe(10_000);
|
||||
});
|
||||
|
||||
it('starts untripped with a zero count', () => {
|
||||
const b = new InteractivePtyExitBreaker();
|
||||
expect(b.tripped).toBe(false);
|
||||
});
|
||||
|
||||
it('trips after exactly N non-zero exits within the window', () => {
|
||||
const b = new InteractivePtyExitBreaker({ threshold: 5, windowMs: 10_000 });
|
||||
let result = { tripped: false, count: 0 };
|
||||
// 5 rapid non-zero exits at t=0,1,2,3,4 ms
|
||||
for (let i = 0; i < 5; i++) {
|
||||
result = b.recordExit(1, i);
|
||||
}
|
||||
expect(result.count).toBe(5);
|
||||
expect(result.tripped).toBe(true);
|
||||
expect(b.tripped).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT trip on N-1 non-zero exits', () => {
|
||||
const b = new InteractivePtyExitBreaker({ threshold: 5, windowMs: 10_000 });
|
||||
let result = { tripped: false, count: 0 };
|
||||
for (let i = 0; i < 4; i++) {
|
||||
result = b.recordExit(1, i);
|
||||
}
|
||||
expect(result.count).toBe(4);
|
||||
expect(result.tripped).toBe(false);
|
||||
expect(b.tripped).toBe(false);
|
||||
});
|
||||
|
||||
it('evicts exits older than the window (rapid repeats spread across time do not trip)', () => {
|
||||
const b = new InteractivePtyExitBreaker({ threshold: 3, windowMs: 10_000 });
|
||||
// Three exits but spaced 6s apart: by the 3rd, the 1st is outside the 10s window.
|
||||
expect(b.recordExit(1, 0).tripped).toBe(false); // window: [0]
|
||||
expect(b.recordExit(1, 6_000).tripped).toBe(false); // window: [0, 6000]
|
||||
// At t=12000, the t=0 exit is now > windowMs old → evicted. Count = {6000,12000} = 2.
|
||||
const r = b.recordExit(1, 12_000);
|
||||
expect(r.count).toBe(2);
|
||||
expect(r.tripped).toBe(false);
|
||||
});
|
||||
|
||||
it('trips when N non-zero exits land inside the window despite earlier evictions', () => {
|
||||
const b = new InteractivePtyExitBreaker({ threshold: 3, windowMs: 10_000 });
|
||||
b.recordExit(1, 0); // evicted later
|
||||
b.recordExit(1, 100);
|
||||
b.recordExit(1, 200);
|
||||
// t=300: window keeps 100,200,300 (0 is fine too, all <10s) → count 4 ≥ 3
|
||||
const r = b.recordExit(1, 300);
|
||||
expect(r.tripped).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the window boundary inclusively/exclusively consistently (exactly windowMs old is evicted)', () => {
|
||||
const b = new InteractivePtyExitBreaker({ threshold: 2, windowMs: 10_000 });
|
||||
b.recordExit(1, 0);
|
||||
// t=10000 is exactly windowMs after t=0 → t=0 is evicted (strictly older-than-window kept only)
|
||||
const r = b.recordExit(1, 10_000);
|
||||
expect(r.count).toBe(1);
|
||||
expect(r.tripped).toBe(false);
|
||||
});
|
||||
|
||||
it('a clean (zero) exit resets the counter', () => {
|
||||
const b = new InteractivePtyExitBreaker({ threshold: 3, windowMs: 10_000 });
|
||||
b.recordExit(1, 0);
|
||||
b.recordExit(1, 1);
|
||||
const clean = b.recordExit(0, 2);
|
||||
expect(clean.count).toBe(0);
|
||||
expect(clean.tripped).toBe(false);
|
||||
// Counter genuinely reset: two more non-zero do NOT trip (would need 3 fresh).
|
||||
expect(b.recordExit(1, 3).tripped).toBe(false);
|
||||
expect(b.recordExit(1, 4).count).toBe(2);
|
||||
});
|
||||
|
||||
it('stays tripped once tripped until reset(), even on further exits', () => {
|
||||
const b = new InteractivePtyExitBreaker({ threshold: 2, windowMs: 10_000 });
|
||||
b.recordExit(1, 0);
|
||||
expect(b.recordExit(1, 1).tripped).toBe(true);
|
||||
// Further non-zero exits keep it tripped.
|
||||
expect(b.recordExit(1, 2).tripped).toBe(true);
|
||||
// A clean exit does NOT auto-clear a tripped breaker (only explicit reset does).
|
||||
expect(b.recordExit(0, 3).tripped).toBe(true);
|
||||
expect(b.tripped).toBe(true);
|
||||
});
|
||||
|
||||
it('reset() clears the tripped state and the counter', () => {
|
||||
const b = new InteractivePtyExitBreaker({ threshold: 2, windowMs: 10_000 });
|
||||
b.recordExit(1, 0);
|
||||
b.recordExit(1, 1);
|
||||
expect(b.tripped).toBe(true);
|
||||
b.reset();
|
||||
expect(b.tripped).toBe(false);
|
||||
// After reset, it takes a full fresh threshold to trip again.
|
||||
expect(b.recordExit(1, 2).tripped).toBe(false);
|
||||
expect(b.recordExit(1, 3).tripped).toBe(true);
|
||||
});
|
||||
|
||||
it('is deterministic with injected time (no reliance on Date.now)', () => {
|
||||
const a = new InteractivePtyExitBreaker({ threshold: 3, windowMs: 1_000 });
|
||||
const b = new InteractivePtyExitBreaker({ threshold: 3, windowMs: 1_000 });
|
||||
const times = [0, 100, 200, 999, 1500];
|
||||
const ra = times.map((t) => a.recordExit(1, t));
|
||||
const rb = times.map((t) => b.recordExit(1, t));
|
||||
expect(ra).toEqual(rb);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session-level trip/reset (AC#4, via MockSession)', () => {
|
||||
// Lightweight harness mirroring how Session wires the breaker into its PTY
|
||||
// exit handler: record exit → on trip, flip status to 'error', block respawn,
|
||||
// emit a signal. An explicit reset re-enables respawn.
|
||||
function wireBreaker(session: MockSession, breaker: InteractivePtyExitBreaker) {
|
||||
let respawnBlocked = false;
|
||||
const onExit = (exitCode: number, nowMs: number) => {
|
||||
const { tripped } = breaker.recordExit(exitCode, nowMs);
|
||||
if (tripped) {
|
||||
respawnBlocked = true;
|
||||
session.status = 'idle'; // MockSession only types idle|working; the real Session sets _status='error'
|
||||
session.emit('respawnBreakerTripped', {
|
||||
count: DEFAULT_BREAKER_THRESHOLD,
|
||||
windowMs: DEFAULT_BREAKER_WINDOW_MS,
|
||||
});
|
||||
}
|
||||
};
|
||||
return {
|
||||
onExit,
|
||||
isRespawnBlocked: () => respawnBlocked,
|
||||
reset: () => {
|
||||
breaker.reset();
|
||||
respawnBlocked = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('repeated non-zero exits trip → respawn blocked + event emitted; explicit reset re-enables', () => {
|
||||
const session = new MockSession('breaker-session');
|
||||
const breaker = new InteractivePtyExitBreaker({ threshold: 3, windowMs: 10_000 });
|
||||
const harness = wireBreaker(session, breaker);
|
||||
|
||||
let trippedEvents = 0;
|
||||
session.on('respawnBreakerTripped', () => {
|
||||
trippedEvents++;
|
||||
});
|
||||
|
||||
// Two non-zero exits: not yet blocked.
|
||||
harness.onExit(1, 0);
|
||||
harness.onExit(1, 1);
|
||||
expect(harness.isRespawnBlocked()).toBe(false);
|
||||
expect(trippedEvents).toBe(0);
|
||||
|
||||
// Third within window → trips.
|
||||
harness.onExit(1, 2);
|
||||
expect(harness.isRespawnBlocked()).toBe(true);
|
||||
expect(trippedEvents).toBe(1);
|
||||
expect(breaker.tripped).toBe(true);
|
||||
|
||||
// Explicit (user-initiated) reset re-enables respawn.
|
||||
harness.reset();
|
||||
expect(harness.isRespawnBlocked()).toBe(false);
|
||||
expect(breaker.tripped).toBe(false);
|
||||
});
|
||||
|
||||
it('a single normal exit never blocks respawn', () => {
|
||||
const session = new MockSession('normal-exit-session');
|
||||
const breaker = new InteractivePtyExitBreaker();
|
||||
const harness = wireBreaker(session, breaker);
|
||||
harness.onExit(1, 0); // one crash
|
||||
harness.onExit(0, 50); // then a clean exit
|
||||
expect(harness.isRespawnBlocked()).toBe(false);
|
||||
expect(breaker.tripped).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trip observability through the REAL listener wiring (COD-118)', () => {
|
||||
// Mirrors the server: refs map + removeSessionListenerRefs (called by the wiring
|
||||
// exit handler on EVERY PTY exit) detaching all listeners, and an idempotent
|
||||
// setup() like WebServer.setupSessionListeners that the re-attach routes
|
||||
// (/interactive, /interactive-respawn, /shell) now re-run.
|
||||
function makeHarness() {
|
||||
const session = new MockSession('wiring-breaker-session');
|
||||
const refsMap = new Map<string, SessionListenerRefs>();
|
||||
const deps = {
|
||||
broadcast: vi.fn(),
|
||||
batchTerminalData: vi.fn(),
|
||||
batchTaskUpdate: vi.fn(),
|
||||
broadcastSessionStateDebounced: vi.fn(),
|
||||
sendPushNotifications: vi.fn(),
|
||||
persistSessionState: vi.fn(),
|
||||
getSessionStateWithRespawn: vi.fn(() => ({ id: session.id })),
|
||||
getRunSummaryTracker: vi.fn(() => undefined),
|
||||
stopTranscriptWatcher: vi.fn(),
|
||||
cleanupSessionBatches: vi.fn(),
|
||||
cancelPersistDebounce: vi.fn(),
|
||||
removeRunSummaryTracker: vi.fn(),
|
||||
// Same as server.ts removeSessionListenerRefs: detach ALL wiring listeners.
|
||||
removeSessionListenerRefs: (id: string) => {
|
||||
const refs = refsMap.get(id);
|
||||
if (refs) detachSessionListeners(session as unknown as Session, refs);
|
||||
refsMap.delete(id);
|
||||
},
|
||||
cleanupRespawnOnExit: vi.fn(),
|
||||
getStore: vi.fn(),
|
||||
registerAttachment: vi.fn(async () => {}),
|
||||
};
|
||||
const setup = () => {
|
||||
if (refsMap.has(session.id)) return; // idempotence guard, as in server.ts
|
||||
const refs = createSessionListeners(
|
||||
session as unknown as Session,
|
||||
deps as unknown as Parameters<typeof createSessionListeners>[1]
|
||||
);
|
||||
refsMap.set(session.id, refs);
|
||||
attachSessionListeners(session as unknown as Session, refs);
|
||||
};
|
||||
return { session, deps, setup };
|
||||
}
|
||||
|
||||
it('every PTY exit detaches ALL wiring listeners (the gap the re-attach routes must close)', () => {
|
||||
const { session, setup } = makeHarness();
|
||||
setup(); // session-create wiring
|
||||
expect(session.listenerCount('respawnBreakerTripped')).toBe(1);
|
||||
session.emit('exit', 1);
|
||||
// After exit #1 the trip listener is gone — a later trip would be unobserved.
|
||||
expect(session.listenerCount('respawnBreakerTripped')).toBe(0);
|
||||
expect(session.listenerCount('terminal')).toBe(0);
|
||||
});
|
||||
|
||||
it('re-running setup() after each exit keeps the 5th-exit trip observable (SSE + push + persist)', () => {
|
||||
const { session, deps, setup } = makeHarness();
|
||||
setup(); // session-create wiring
|
||||
// Exits 1–4: each detaches the wiring; the /interactive re-attach re-wires it.
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
session.emit('exit', 1);
|
||||
setup(); // what the fixed re-attach routes now do
|
||||
}
|
||||
// 5th rapid non-zero exit: the real Session emits respawnBreakerTripped
|
||||
// (inside its onExit handler) BEFORE emitting 'exit'.
|
||||
session.emit('respawnBreakerTripped', { count: 5 });
|
||||
session.emit('exit', 1);
|
||||
|
||||
expect(deps.broadcast).toHaveBeenCalledWith(SseEvent.SessionRespawnBreakerTripped, {
|
||||
sessionId: session.id,
|
||||
count: 5,
|
||||
});
|
||||
expect(deps.sendPushNotifications).toHaveBeenCalledWith(
|
||||
SseEvent.SessionRespawnBreakerTripped,
|
||||
expect.objectContaining({ sessionId: session.id, count: 5 })
|
||||
);
|
||||
expect(deps.persistSessionState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('setup() is idempotent — re-running while still wired must not double-attach', () => {
|
||||
const { session, setup } = makeHarness();
|
||||
setup();
|
||||
setup(); // e.g. POST /interactive on a freshly created session
|
||||
expect(session.listenerCount('respawnBreakerTripped')).toBe(1);
|
||||
expect(session.listenerCount('exit')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('push template registration (COD-118)', () => {
|
||||
it('SessionRespawnBreakerTripped has a PUSH_EVENT_MAP entry (sendPushNotifications silently no-ops without one)', async () => {
|
||||
const { WebServer } = await import('../src/web/server.js');
|
||||
const map = (WebServer as unknown as Record<string, Record<string, { title: string; urgency: string }>>)[
|
||||
'PUSH_EVENT_MAP'
|
||||
];
|
||||
expect(map).toBeDefined();
|
||||
const entry = map[SseEvent.SessionRespawnBreakerTripped];
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.urgency).toBe('critical');
|
||||
expect(entry.title.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -53,15 +53,28 @@ vi.mock('../../src/hooks-config.js', () => ({
|
||||
writeHooksConfig: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
// Stub the remote-tmux prereq probe so remote-link tests never shell out to ssh
|
||||
// (readRemoteHosts/writeRemoteHosts stay real, backed by the mocked fs).
|
||||
vi.mock('../../src/remote-hosts.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/remote-hosts.js')>();
|
||||
return {
|
||||
...actual,
|
||||
checkRemoteTmuxAvailable: vi.fn(async () => ({ ok: true, tmuxPath: '/usr/bin/tmux' })),
|
||||
};
|
||||
});
|
||||
|
||||
// Import mocked modules for test control
|
||||
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { checkRemoteTmuxAvailable } from '../../src/remote-hosts.js';
|
||||
|
||||
const mockedExistsSync = vi.mocked(existsSync);
|
||||
const mockedMkdirSync = vi.mocked(mkdirSync);
|
||||
const mockedReaddirSync = vi.mocked(readdirSync);
|
||||
const mockedReaddir = vi.mocked(fs.readdir);
|
||||
const mockedReadFile = vi.mocked(fs.readFile);
|
||||
const mockedWriteFile = vi.mocked(fs.writeFile);
|
||||
const mockedCheckRemoteTmux = vi.mocked(checkRemoteTmuxAvailable);
|
||||
|
||||
interface CaseRouteHarness {
|
||||
app: FastifyInstance;
|
||||
@@ -199,6 +212,278 @@ describe('case-routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote host and remote case routes', () => {
|
||||
function setupRemoteConfigStore() {
|
||||
const store = new Map<string, string>();
|
||||
mockedReadFile.mockImplementation(async (path) => {
|
||||
const key = String(path);
|
||||
if (store.has(key)) return store.get(key) || '';
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
mockedWriteFile.mockImplementation(async (path, data) => {
|
||||
store.set(String(path), String(data));
|
||||
});
|
||||
}
|
||||
|
||||
it('creates a remote host and lists it', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: {
|
||||
id: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(200);
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: true });
|
||||
|
||||
const list = await harness.app.inject({ method: 'GET', url: '/api/remote-hosts' });
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect(JSON.parse(list.body).data).toEqual([
|
||||
expect.objectContaining({ id: 'gpu-box', label: 'GPU Box', commands: { codex: 'exec codx personal' } }),
|
||||
]);
|
||||
});
|
||||
|
||||
// COD-107 — advanced SSH connection options (port, identity, SOCKS proxy,
|
||||
// jump host, escape-hatch -o options) round-trip through the host schema.
|
||||
it('persists advanced SSH options (port/identity/socks/jump/extra) on a remote host', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: {
|
||||
id: 'aa-desktop',
|
||||
label: 'aa-desktop',
|
||||
host: '192.168.55.170',
|
||||
username: 'aakht',
|
||||
port: 2222,
|
||||
identityFile: '~/.ssh/remote_ed25519',
|
||||
socksProxy: '127.0.0.1:1080',
|
||||
jumpHost: 'bastion@10.0.0.1:22',
|
||||
extraSshOptions: ['StrictHostKeyChecking=accept-new'],
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(200);
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: true });
|
||||
|
||||
const list = await harness.app.inject({ method: 'GET', url: '/api/remote-hosts' });
|
||||
expect(JSON.parse(list.body).data).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'aa-desktop',
|
||||
port: 2222,
|
||||
identityFile: '~/.ssh/remote_ed25519',
|
||||
socksProxy: '127.0.0.1:1080',
|
||||
jumpHost: 'bastion@10.0.0.1:22',
|
||||
extraSshOptions: ['StrictHostKeyChecking=accept-new'],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a malformed extraSshOptions entry (not KEY=VALUE) with INVALID_INPUT', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: {
|
||||
id: 'bad-host',
|
||||
label: 'bad',
|
||||
host: '10.0.0.9',
|
||||
username: 'ubuntu',
|
||||
extraSshOptions: ['not a valid option'],
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects a malformed socksProxy (missing port) with INVALID_INPUT', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'bad2', label: 'bad2', host: '10.0.0.9', username: 'ubuntu', socksProxy: '127.0.0.1' },
|
||||
});
|
||||
expect(create.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: false });
|
||||
});
|
||||
|
||||
it('links a remote case and includes it in GET /api/cases', async () => {
|
||||
setupRemoteConfigStore();
|
||||
mockedReaddir.mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
|
||||
const link = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
});
|
||||
expect(link.statusCode).toBe(200);
|
||||
|
||||
const cases = await harness.app.inject({ method: 'GET', url: '/api/cases' });
|
||||
expect(JSON.parse(cases.body).data).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'gpu-work',
|
||||
location: 'remote',
|
||||
path: 'ubuntu@10.0.0.42:/home/ubuntu/work',
|
||||
remote: expect.objectContaining({ hostId: 'gpu-box', path: '/home/ubuntu/work' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers remote case metadata over a same-name local managed case', async () => {
|
||||
setupRemoteConfigStore();
|
||||
mockedReaddir.mockResolvedValue([{ name: 'gpu-work', isDirectory: () => true }] as never);
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
});
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
const cases = await harness.app.inject({ method: 'GET', url: '/api/cases' });
|
||||
expect(JSON.parse(cases.body).data).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'gpu-work',
|
||||
location: 'remote',
|
||||
path: 'ubuntu@10.0.0.42:/home/ubuntu/work',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes remote case metadata only', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
});
|
||||
|
||||
const deleted = await harness.app.inject({ method: 'DELETE', url: '/api/cases/gpu-work' });
|
||||
expect(deleted.statusCode).toBe(200);
|
||||
expect(JSON.parse(deleted.body)).toEqual({ success: true, data: { name: 'gpu-work' } });
|
||||
});
|
||||
|
||||
// Injection hardening: remotePath/identityFile are shell-escaped, then embedded
|
||||
// via JSON.stringify() inside `bash -c "..."` — a DOUBLE-quote layer that
|
||||
// re-exposes `$(...)`/backticks even inside the inner single quotes. The schema
|
||||
// MUST reject those before they reach the launch command.
|
||||
it('rejects an identityFile containing $(...) command substitution', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: {
|
||||
id: 'evil-host',
|
||||
label: 'evil',
|
||||
host: '10.0.0.9',
|
||||
username: 'ubuntu',
|
||||
identityFile: '/home/u/$(touch /tmp/pwned)',
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects an identityFile containing a backtick', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: {
|
||||
id: 'evil-host2',
|
||||
label: 'evil2',
|
||||
host: '10.0.0.9',
|
||||
username: 'ubuntu',
|
||||
identityFile: '/home/u/`touch /tmp/pwned`',
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects a remotePath containing $(...) command substitution', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
const link = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/tmp/$(touch /tmp/pwned)' },
|
||||
});
|
||||
expect(link.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(link.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects a remotePath containing a backtick', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
const link = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/tmp/`touch /tmp/pwned`' },
|
||||
});
|
||||
expect(link.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(link.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('refuses remote-link when the remote host lacks tmux (courtesy prereq probe)', async () => {
|
||||
setupRemoteConfigStore();
|
||||
mockedCheckRemoteTmux.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: 'remote host 10.0.0.42 needs tmux installed for durable remote sessions',
|
||||
});
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
const link = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
});
|
||||
expect(link.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.OPERATION_FAILED));
|
||||
expect(JSON.parse(link.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.OPERATION_FAILED });
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/cases ==========
|
||||
|
||||
describe('POST /api/cases', () => {
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @fileoverview Tests for the Codex branch of GET /api/sessions/:id/last-response (PR #152).
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
* Port: N/A (app.inject doesn't open ports)
|
||||
*
|
||||
* Fixture rollouts live in a per-test temp CODEX_HOME (the route resolves
|
||||
* `process.env.CODEX_HOME || ~/.codex` at request time), exercising the real
|
||||
* locator/parser code paths against real files:
|
||||
* - originator match beats the cwd+mtime fallback when two panes share a dir
|
||||
* - resume-uuid filename match (resumed rollouts keep foreign session_meta)
|
||||
* - history.jsonl pin outranks the originator match
|
||||
* - event_msg vs legacy response_item user-turn dedup keeps old-codex turns
|
||||
* - injected-context rows (AGENTS.md, environment_context, …) are filtered
|
||||
* - response envelope shape; Claude-mode behavior unchanged (regression guard)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, utimesSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
|
||||
interface LocalHarness {
|
||||
app: FastifyInstance;
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of the production uniform-envelope hook (server.ts) — same local
|
||||
* harness idiom as session-routes.test.ts, so assertions match the wire format.
|
||||
*/
|
||||
async function createEnvelopeHarness(
|
||||
registerFn: (app: FastifyInstance, ctx: MockRouteContext) => void
|
||||
): Promise<LocalHarness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
const ctx = createMockRouteContext();
|
||||
registerFn(app, ctx);
|
||||
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
if (!req.url.startsWith('/api')) return done(null, payload);
|
||||
if (payload === null || typeof payload !== 'object') return done(null, payload);
|
||||
if (Buffer.isBuffer(payload) || typeof (payload as { pipe?: unknown }).pipe === 'function') {
|
||||
return done(null, payload);
|
||||
}
|
||||
const p = payload as { success?: unknown; errorCode?: unknown };
|
||||
if (p.success === false) {
|
||||
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
|
||||
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||
}
|
||||
return done(null, payload);
|
||||
}
|
||||
if (p.success === true) return done(null, payload);
|
||||
return done(null, { success: true, data: payload });
|
||||
});
|
||||
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
|
||||
return { app, ctx };
|
||||
}
|
||||
|
||||
// ── Rollout fixture helpers (shapes observed on codex-cli 0.144) ──────────────
|
||||
|
||||
const sessionMeta = (cwd: string, originator?: string) => ({
|
||||
type: 'session_meta',
|
||||
payload: { cwd, originator },
|
||||
});
|
||||
|
||||
const assistantMsg = (text: string, timestamp = '2026-07-01T00:00:00Z') => ({
|
||||
timestamp,
|
||||
type: 'response_item',
|
||||
payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }] },
|
||||
});
|
||||
|
||||
const legacyUserMsg = (text: string, timestamp = '2026-07-01T00:00:00Z') => ({
|
||||
timestamp,
|
||||
type: 'response_item',
|
||||
payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text }] },
|
||||
});
|
||||
|
||||
const eventUserMsg = (message: string, timestamp = '2026-07-01T00:00:00Z') => ({
|
||||
timestamp,
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message },
|
||||
});
|
||||
|
||||
const UUID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
const UUID_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||
const UUID_C = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||
|
||||
// Fixed epoch (seconds) for deterministic mtime ordering.
|
||||
const BASE_MTIME = 1_750_000_000;
|
||||
|
||||
describe('GET /api/sessions/:id/last-response (codex)', () => {
|
||||
let harness: LocalHarness;
|
||||
let codexHome: string;
|
||||
let prevCodexHome: string | undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let session: any; // MockSession, loosened for codex-only fields (codexConfig, codexLastSubmitAt)
|
||||
let workdir: string;
|
||||
|
||||
/** Write a rollout under CODEX_HOME/sessions/<date>/ with a controlled mtime. */
|
||||
function writeRollout(name: string, entries: unknown[], mtimeSec: number): string {
|
||||
const dir = join(codexHome, 'sessions', '2026', '07', '01');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const filePath = join(dir, name);
|
||||
let content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n';
|
||||
// The locator skips files under 100 bytes (blank padding lines are ignored by the parser).
|
||||
while (content.length < 100) content += '\n';
|
||||
writeFileSync(filePath, content);
|
||||
utimesSync(filePath, mtimeSec, mtimeSec);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function writeHistory(entries: Array<{ session_id: string; ts: number }>): void {
|
||||
writeFileSync(join(codexHome, 'history.jsonl'), entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
||||
}
|
||||
|
||||
async function getLastResponse(id: string, full = false) {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${id}/last-response${full ? '?context=full' : ''}`,
|
||||
});
|
||||
return { res, body: JSON.parse(res.body) };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
codexHome = mkdtempSync(join(tmpdir(), 'codeman-codex-rv-'));
|
||||
prevCodexHome = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
|
||||
harness = await createEnvelopeHarness(registerSessionRoutes);
|
||||
session = harness.ctx._session;
|
||||
session.mode = 'codex';
|
||||
workdir = join(codexHome, 'workdir');
|
||||
session.workingDir = workdir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prevCodexHome;
|
||||
rmSync(codexHome, { recursive: true, force: true });
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
// ── Locator: originator vs cwd fallback ─────────────────────────────────
|
||||
|
||||
it('originator match beats the cwd+mtime fallback when two panes share a dir', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const paneB: any = createMockSession('codex-b');
|
||||
paneB.mode = 'codex';
|
||||
paneB.workingDir = workdir;
|
||||
harness.ctx.sessions.set('codex-b', paneB);
|
||||
|
||||
// Pane B's rollout is NEWER — the naive cwd+mtime heuristic would show it for pane A.
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||||
[sessionMeta(workdir, `codeman_${session.id}`), assistantMsg('answer A')],
|
||||
BASE_MTIME
|
||||
);
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-01-00-${UUID_B}.jsonl`,
|
||||
[sessionMeta(workdir, 'codeman_codex-b'), assistantMsg('answer B')],
|
||||
BASE_MTIME + 100
|
||||
);
|
||||
|
||||
const a = await getLastResponse(session.id);
|
||||
expect(a.res.statusCode).toBe(200);
|
||||
expect(a.body.data.text).toBe('answer A');
|
||||
|
||||
const b = await getLastResponse('codex-b');
|
||||
expect(b.body.data.text).toBe('answer B');
|
||||
});
|
||||
|
||||
it('cwd fallback excludes rollouts claimed by other panes and skips foreign cwds', async () => {
|
||||
// Pane has no originator-stamped rollout (pre-existing pane). Newest same-cwd
|
||||
// rollout belongs to another codeman pane → must fall through to the unclaimed one.
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||||
[sessionMeta(workdir), assistantMsg('unclaimed answer')],
|
||||
BASE_MTIME
|
||||
);
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-01-00-${UUID_B}.jsonl`,
|
||||
[sessionMeta(workdir, 'codeman_some-other-pane'), assistantMsg('sibling answer')],
|
||||
BASE_MTIME + 100
|
||||
);
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-02-00-${UUID_C}.jsonl`,
|
||||
[sessionMeta('/elsewhere/entirely'), assistantMsg('foreign-cwd answer')],
|
||||
BASE_MTIME + 200
|
||||
);
|
||||
|
||||
const { body } = await getLastResponse(session.id);
|
||||
expect(body.data.text).toBe('unclaimed answer');
|
||||
});
|
||||
|
||||
// ── Locator: resume-uuid match ───────────────────────────────────────────
|
||||
|
||||
it('resolves a resumed pane via the rollout filename uuid despite foreign session_meta', async () => {
|
||||
session.codexConfig = { resumeSessionId: UUID_A };
|
||||
|
||||
// Resumed rollouts keep the ORIGINAL session_meta (foreign originator + launch cwd),
|
||||
// so neither originator nor cwd matching can find them — only the filename uuid.
|
||||
writeRollout(
|
||||
`rollout-2026-06-30T12-00-00-${UUID_A}.jsonl`,
|
||||
[sessionMeta('/original/launch/dir', 'codex_cli_rs'), assistantMsg('resumed answer')],
|
||||
BASE_MTIME
|
||||
);
|
||||
// A newer same-cwd decoy must NOT win over the uuid match.
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-00-00-${UUID_B}.jsonl`,
|
||||
[sessionMeta(workdir), assistantMsg('decoy answer')],
|
||||
BASE_MTIME + 100
|
||||
);
|
||||
|
||||
const { body } = await getLastResponse(session.id);
|
||||
expect(body.data.text).toBe('resumed answer');
|
||||
});
|
||||
|
||||
// ── Locator: history.jsonl pin ───────────────────────────────────────────
|
||||
|
||||
it('history.jsonl pin (pane last-submit correlation) outranks the originator match', async () => {
|
||||
const submitAtSec = BASE_MTIME + 500;
|
||||
session.codexLastSubmitAt = submitAtSec * 1000;
|
||||
writeHistory([{ session_id: UUID_B, ts: submitAtSec }]);
|
||||
|
||||
// Originator-stamped rollout exists and is NEWER, but the pane /resume'd onto
|
||||
// UUID_B inside the TUI — the history pin must follow it there.
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||||
[sessionMeta(workdir, `codeman_${session.id}`), assistantMsg('originator answer')],
|
||||
BASE_MTIME + 600
|
||||
);
|
||||
writeRollout(
|
||||
`rollout-2026-06-30T12-00-00-${UUID_B}.jsonl`,
|
||||
[sessionMeta('/original/launch/dir', 'codex_cli_rs'), assistantMsg('history answer')],
|
||||
BASE_MTIME
|
||||
);
|
||||
|
||||
const { body } = await getLastResponse(session.id);
|
||||
expect(body.data.text).toBe('history answer');
|
||||
});
|
||||
|
||||
// ── Reader: dedup + filtering ────────────────────────────────────────────
|
||||
|
||||
it('event_msg/legacy dedup keeps old-codex turns and drops event twins (mixed-version rollout)', async () => {
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||||
[
|
||||
sessionMeta(workdir, `codeman_${session.id}`),
|
||||
// Old-codex turn: response_item only, no event_msg twin — must survive.
|
||||
legacyUserMsg('old prompt'),
|
||||
assistantMsg('old answer'),
|
||||
// Modern turn: event_msg + duplicate response_item row — one user row only.
|
||||
eventUserMsg('new prompt'),
|
||||
legacyUserMsg('new prompt'),
|
||||
assistantMsg('new answer'),
|
||||
],
|
||||
BASE_MTIME
|
||||
);
|
||||
|
||||
const { body } = await getLastResponse(session.id, true);
|
||||
expect(body.data.text).toBe('new answer');
|
||||
expect(body.data.messages.map((m: { role: string; text: string }) => [m.role, m.text])).toEqual([
|
||||
['user', 'old prompt'],
|
||||
['assistant', 'old answer'],
|
||||
['user', 'new prompt'],
|
||||
['assistant', 'new answer'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters injected-context rows from the full thread', async () => {
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||||
[
|
||||
sessionMeta(workdir, `codeman_${session.id}`),
|
||||
legacyUserMsg('# AGENTS.md instructions for the workspace'),
|
||||
legacyUserMsg('<environment_context>\n<cwd>/somewhere</cwd>'),
|
||||
eventUserMsg('<user_instructions>be nice</user_instructions>'),
|
||||
legacyUserMsg('real question'),
|
||||
assistantMsg('real answer'),
|
||||
],
|
||||
BASE_MTIME
|
||||
);
|
||||
|
||||
const { body } = await getLastResponse(session.id, true);
|
||||
expect(body.data.messages).toEqual([
|
||||
{ role: 'user', text: 'real question', timestamp: '2026-07-01T00:00:00Z' },
|
||||
{ role: 'assistant', text: 'real answer', timestamp: '2026-07-01T00:00:00Z' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders an image placeholder for image-only event_msg inputs', async () => {
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||||
[
|
||||
sessionMeta(workdir, `codeman_${session.id}`),
|
||||
{ timestamp: '2026-07-01T00:00:00Z', type: 'event_msg', payload: { type: 'user_message', images: ['a', 'b'] } },
|
||||
assistantMsg('looked at the images'),
|
||||
],
|
||||
BASE_MTIME
|
||||
);
|
||||
|
||||
const { body } = await getLastResponse(session.id, true);
|
||||
expect(body.data.messages[0]).toEqual({
|
||||
role: 'user',
|
||||
text: '*[image ×2]*',
|
||||
timestamp: '2026-07-01T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
// ── Envelope shape + Claude-mode regression guard ────────────────────────
|
||||
|
||||
it('returns the {success:true,data:{text,timestamp}} envelope; messages only with ?context=full', async () => {
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||||
[sessionMeta(workdir, `codeman_${session.id}`), assistantMsg('the answer', '2026-07-01T01:02:03Z')],
|
||||
BASE_MTIME
|
||||
);
|
||||
|
||||
const brief = await getLastResponse(session.id);
|
||||
expect(brief.res.statusCode).toBe(200);
|
||||
expect(brief.body).toEqual({
|
||||
success: true,
|
||||
data: { text: 'the answer', timestamp: '2026-07-01T01:02:03Z' },
|
||||
});
|
||||
|
||||
const full = await getLastResponse(session.id, true);
|
||||
expect(full.body.success).toBe(true);
|
||||
expect(Array.isArray(full.body.data.messages)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an empty envelope (not an error) when no rollout matches', async () => {
|
||||
const brief = await getLastResponse(session.id);
|
||||
expect(brief.res.statusCode).toBe(200);
|
||||
expect(brief.body).toEqual({ success: true, data: { text: '', timestamp: '' } });
|
||||
|
||||
const full = await getLastResponse(session.id, true);
|
||||
expect(full.body.data.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves Claude-mode sessions on the ~/.claude/projects reader (regression guard)', async () => {
|
||||
const fakeHome = mkdtempSync(join(tmpdir(), 'codeman-claude-home-'));
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = fakeHome;
|
||||
try {
|
||||
session.mode = 'claude';
|
||||
const projDir = join(fakeHome, '.claude', 'projects', 'proj1');
|
||||
mkdirSync(projDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(projDir, `${session.id}.jsonl`),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: '2026-07-01T00:00:00Z',
|
||||
message: { content: [{ type: 'text', text: 'claude answer' }] },
|
||||
}) + '\n'
|
||||
);
|
||||
// A codex rollout for the same session id must NOT be consulted in claude mode.
|
||||
writeRollout(
|
||||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||||
[sessionMeta(workdir, `codeman_${session.id}`), assistantMsg('codex answer')],
|
||||
BASE_MTIME
|
||||
);
|
||||
|
||||
const { res, body } = await getLastResponse(session.id);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(body.data.text).toBe('claude answer');
|
||||
expect(body.data.timestamp).toBe('2026-07-01T00:00:00Z');
|
||||
} finally {
|
||||
process.env.HOME = prevHome;
|
||||
rmSync(fakeHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,16 +16,43 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { join } from 'node:path';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import { Session } from '../../src/session.js';
|
||||
|
||||
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
|
||||
const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() }));
|
||||
// The real converter spawns a worker thread (TS worker file — not loadable
|
||||
// under vitest); the conversion pipeline itself is covered by
|
||||
// test/heic-jpeg-core.test.ts against the real heic-decode WASM.
|
||||
const heicConvert = vi.hoisted(() => vi.fn(async () => Buffer.from('ffd8ffe000104a4649460001', 'hex')));
|
||||
vi.mock('node:child_process', async (orig) => {
|
||||
const actual = await orig<typeof import('node:child_process')>();
|
||||
return { ...actual, execFile };
|
||||
});
|
||||
vi.mock('../../src/web/heic-jpeg-converter.js', () => ({ convertHeicToJpeg: heicConvert }));
|
||||
|
||||
// In-memory remote store so remote-case tests can inject hosts/cases without real JSON files.
|
||||
const remoteStore = vi.hoisted(() => ({
|
||||
hosts: [] as unknown[],
|
||||
cases: [] as unknown[],
|
||||
tmuxCheck: { ok: true, tmuxPath: '/usr/bin/tmux' } as { ok: boolean; tmuxPath?: string; error?: string },
|
||||
}));
|
||||
vi.mock('../../src/remote-hosts.js', async (orig) => {
|
||||
const actual = await orig<typeof import('../../src/remote-hosts.js')>();
|
||||
return {
|
||||
...actual,
|
||||
readRemoteHosts: vi.fn(async () => remoteStore.hosts),
|
||||
readRemoteCases: vi.fn(async () => remoteStore.cases),
|
||||
// Stub the remote-tmux prereq probe so quick-start never shells out to ssh.
|
||||
checkRemoteTmuxAvailable: vi.fn(async () => remoteStore.tmuxCheck),
|
||||
};
|
||||
});
|
||||
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
|
||||
@@ -45,6 +72,9 @@ async function createEnvelopeHarness(
|
||||
): Promise<LocalHarness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
await app.register(fastifyMultipart, {
|
||||
limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 },
|
||||
});
|
||||
|
||||
const ctx = createMockRouteContext();
|
||||
registerFn(app, ctx);
|
||||
@@ -78,6 +108,10 @@ describe('session-routes', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createEnvelopeHarness(registerSessionRoutes);
|
||||
// Reset remote store so tests start with empty hosts/cases and a passing tmux probe
|
||||
remoteStore.hosts = [];
|
||||
remoteStore.cases = [];
|
||||
remoteStore.tmuxCheck = { ok: true, tmuxPath: '/usr/bin/tmux' };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -122,6 +156,123 @@ describe('session-routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/paste-image ==========
|
||||
|
||||
describe('POST /api/sessions/:id/paste-image', () => {
|
||||
function imageUploadBody(boundary: string, filename: string, mimetype: string, imageBytes: Buffer): Buffer {
|
||||
return Buffer.concat([
|
||||
Buffer.from(
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="image"; filename="${filename}"\r\n` +
|
||||
`Content-Type: ${mimetype}\r\n\r\n`
|
||||
),
|
||||
imageBytes,
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||
]);
|
||||
}
|
||||
|
||||
it('converts HEIC paste images to JPEG attachments when browser-side normalization falls back', async () => {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-heic-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
heicConvert.mockClear();
|
||||
|
||||
const boundary = 'codeman-test-boundary';
|
||||
const heic = Buffer.from('00000034667479706865696300000000', 'hex');
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
|
||||
headers: {
|
||||
host: 'codeman.test',
|
||||
origin: 'http://codeman.test',
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
payload: imageUploadBody(boundary, 'IMG_4996.HEIC', 'image/heic', heic),
|
||||
});
|
||||
|
||||
await rm(workDir, { recursive: true });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.path).toMatch(/\/\.claude-images\/paste-\d+-[a-f0-9]{8}\.jpg$/);
|
||||
expect(heicConvert).toHaveBeenCalledWith(heic);
|
||||
});
|
||||
|
||||
it('converts mislabeled HEIC (declared image/jpeg, HEIF bytes — the MIUI/Android case) via magic sniff', async () => {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-heic-mislabel-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
heicConvert.mockClear();
|
||||
|
||||
const boundary = 'codeman-test-boundary';
|
||||
// ftyp brand mif1 — HEIF bytes hiding under a JPEG filename + MIME.
|
||||
const heic = Buffer.from('000000346674797061696631000000006d69663168656963', 'hex');
|
||||
heic.write('mif1', 8, 'ascii'); // major brand
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
|
||||
headers: {
|
||||
host: 'codeman.test',
|
||||
origin: 'http://codeman.test',
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
payload: imageUploadBody(boundary, 'IMG_2001.jpg', 'image/jpeg', heic),
|
||||
});
|
||||
|
||||
await rm(workDir, { recursive: true });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.path).toMatch(/\/\.claude-images\/paste-\d+-[a-f0-9]{8}\.jpg$/);
|
||||
expect(heicConvert).toHaveBeenCalledWith(heic);
|
||||
});
|
||||
|
||||
it('returns 415 with the error envelope when HEIC conversion fails', async () => {
|
||||
heicConvert.mockClear();
|
||||
heicConvert.mockRejectedValueOnce(new Error('HEIC dimensions 30000x30000 exceed the 64MP decode limit'));
|
||||
|
||||
const boundary = 'codeman-test-boundary';
|
||||
const heic = Buffer.from('00000034667479706865696300000000', 'hex');
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
|
||||
headers: {
|
||||
host: 'codeman.test',
|
||||
origin: 'http://codeman.test',
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
payload: imageUploadBody(boundary, 'IMG_4997.HEIC', 'image/heic', heic),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(415);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe('INVALID_INPUT');
|
||||
expect(body.error).toMatch(/HEIC/);
|
||||
});
|
||||
|
||||
it('rejects ftyp brands heic-decode cannot convert (e.g. heim) without invoking the converter', async () => {
|
||||
heicConvert.mockClear();
|
||||
|
||||
const boundary = 'codeman-test-boundary';
|
||||
const heim = Buffer.from('00000034667479706865696d00000000', 'hex');
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
|
||||
headers: {
|
||||
host: 'codeman.test',
|
||||
origin: 'http://codeman.test',
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
payload: imageUploadBody(boundary, 'IMG_4998.HEIC', 'image/heic', heim),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(415);
|
||||
expect(JSON.parse(res.body).success).toBe(false);
|
||||
expect(heicConvert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions ==========
|
||||
|
||||
describe('GET /api/sessions', () => {
|
||||
@@ -481,6 +632,333 @@ describe('session-routes', () => {
|
||||
expect(body.data.terminalBuffer).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not strip VPA-like shell scrollback as Ink redraw bloat', async () => {
|
||||
const shellHistory = Array.from(
|
||||
{ length: 3000 },
|
||||
(_, index) => `SHELL_SCROLLBACK_${String(index + 1).padStart(6, '0')} payload payload payload \x1b[1d`
|
||||
).join('\n');
|
||||
harness.ctx._session.terminalBuffer = shellHistory;
|
||||
harness.ctx._session.mode = 'shell';
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(() => null);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.terminalBuffer).toContain('SHELL_SCROLLBACK_000001');
|
||||
expect(body.data.terminalBuffer).toContain('SHELL_SCROLLBACK_003000');
|
||||
});
|
||||
|
||||
it('preserves accumulated history before the live mux pane snapshot for Codex TUI replay', async () => {
|
||||
harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history';
|
||||
harness.ctx._session.mode = 'codex';
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
|
||||
() => 'visible tmux pane only\n› current prompt'
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.terminalBuffer).toContain('hello world');
|
||||
expect(body.data.terminalBuffer).toContain('later accumulated history');
|
||||
expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only');
|
||||
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
|
||||
body.data.terminalBuffer.indexOf('visible tmux pane only')
|
||||
);
|
||||
// No ?full=1 → visible-frame capture (no fullHistory opts).
|
||||
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
|
||||
});
|
||||
|
||||
// ── COD-47: full tmux scrollback replay on full page reload ──
|
||||
it('full reload (?full=1) requests full tmux history and replays boundary markers', async () => {
|
||||
// A realistic scrollback-length capture: ~5000 lines, well past one screen.
|
||||
const firstLine = 'SCROLLBACK_FIRST_LINE_0001';
|
||||
const lastLine = 'SCROLLBACK_LAST_LINE_5000';
|
||||
const lines: string[] = [firstLine];
|
||||
for (let i = 2; i <= 4999; i++) {
|
||||
lines.push(`scrollback line ${String(i).padStart(4, '0')} lorem ipsum payload`);
|
||||
}
|
||||
lines.push(lastLine);
|
||||
const fullHistoryCapture = lines.join('\n');
|
||||
|
||||
harness.ctx._session.mode = 'shell';
|
||||
harness.ctx._session.terminalBuffer = '';
|
||||
const captureSpy = vi.fn((_name: string, opts?: { fullHistory?: boolean }) =>
|
||||
opts?.fullHistory ? fullHistoryCapture : 'only the visible frame'
|
||||
);
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
// Full reload asked tmux for the entire scrollback, with the configured
|
||||
// capture bounds (history-line limit + byte cap for exec maxBuffer).
|
||||
expect(captureSpy).toHaveBeenCalledWith(
|
||||
harness.ctx._session.muxName,
|
||||
expect.objectContaining({
|
||||
fullHistory: true,
|
||||
historyLimitLines: expect.any(Number),
|
||||
maxCaptureBytes: expect.any(Number),
|
||||
})
|
||||
);
|
||||
// Both boundary markers survived the capture → route pipeline.
|
||||
expect(body.data.terminalBuffer).toContain(firstLine);
|
||||
expect(body.data.terminalBuffer).toContain(lastLine);
|
||||
expect(body.data.source).toBe('mux-full-history');
|
||||
expect(typeof body.data.fullSize).toBe('number');
|
||||
});
|
||||
|
||||
it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => {
|
||||
// The full-history capture is the rendered form of everything already in
|
||||
// the byte buffer; prepending the byte history would replay the whole
|
||||
// conversation twice (\x1b[2J clears the viewport, not xterm scrollback).
|
||||
harness.ctx._session.mode = 'claude';
|
||||
harness.ctx._session.terminalBuffer = 'BYTE_BUFFER_COPY of the conversation';
|
||||
const rendered = 'BYTE_BUFFER_COPY of the conversation\r\nplus older scrollback\r\n› prompt';
|
||||
const captureSpy = vi.fn((_name: string, opts?: { fullHistory?: boolean }) =>
|
||||
opts?.fullHistory ? rendered : 'visible frame only'
|
||||
);
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.source).toBe('mux-full-history');
|
||||
expect(body.data.terminalBuffer).toContain('plus older scrollback');
|
||||
// Capture alone: no history+clear-viewport concat, and the byte-buffer
|
||||
// content appears exactly once (from the capture, not a duplicate prepend).
|
||||
expect(body.data.terminalBuffer).not.toContain('\x1b[H\x1b[2J');
|
||||
expect(body.data.terminalBuffer.indexOf('BYTE_BUFFER_COPY')).toBe(
|
||||
body.data.terminalBuffer.lastIndexOf('BYTE_BUFFER_COPY')
|
||||
);
|
||||
});
|
||||
|
||||
it('full reload (?full=1) falls back to the byte history when the capture is unavailable', async () => {
|
||||
harness.ctx._session.mode = 'claude';
|
||||
harness.ctx._session.terminalBuffer = 'byte history survives';
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(() => null);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.source).toBe('history');
|
||||
expect(body.data.terminalBuffer).toContain('byte history survives');
|
||||
});
|
||||
|
||||
it('full reload forwards the configured history-line limit and byte cap to the capture', async () => {
|
||||
harness.ctx.getTerminalHistoryConfig = vi.fn(async () => ({
|
||||
terminalScrollbackLines: 60_000,
|
||||
tmuxHistoryLimit: 123_456,
|
||||
terminalBufferMaxBytes: 5 * 1024 * 1024,
|
||||
terminalBufferTrimBytes: 4 * 1024 * 1024,
|
||||
}));
|
||||
const captureSpy = vi.fn(() => 'full scrollback');
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(captureSpy).toHaveBeenCalledWith(harness.ctx._session.muxName, {
|
||||
fullHistory: true,
|
||||
historyLimitLines: 123_456,
|
||||
maxCaptureBytes: 5 * 1024 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it('tab switch (with tail) uses the visible frame, not full history', async () => {
|
||||
harness.ctx._session.mode = 'codex';
|
||||
harness.ctx._session.terminalBuffer = 'accumulated history';
|
||||
const captureSpy = vi.fn((_name: string, opts?: { fullHistory?: boolean }) =>
|
||||
opts?.fullHistory ? 'FULL_HISTORY_SHOULD_NOT_APPEAR' : 'visible frame only\n› prompt'
|
||||
);
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?tail=65536`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
// Tail/tab-switch must NOT request fullHistory (undefined opts).
|
||||
expect(captureSpy).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
|
||||
expect(body.data.terminalBuffer).toContain('visible frame only');
|
||||
expect(body.data.terminalBuffer).not.toContain('FULL_HISTORY_SHOULD_NOT_APPEAR');
|
||||
expect(body.data.source).toBe('mux-visible');
|
||||
});
|
||||
|
||||
it('caps huge full-history at the configured terminal buffer limit and marks truncated', async () => {
|
||||
// Shrink the cap so the test can exceed it without allocating 32MB.
|
||||
harness.ctx.getTerminalHistoryConfig = vi.fn(async () => ({
|
||||
terminalScrollbackLines: 100_000,
|
||||
tmuxHistoryLimit: 100_000,
|
||||
terminalBufferMaxBytes: 4096,
|
||||
terminalBufferTrimBytes: 4096,
|
||||
}));
|
||||
harness.ctx._session.mode = 'shell';
|
||||
harness.ctx._session.terminalBuffer = '';
|
||||
const oldestMarker = 'OLDEST_EVICTED_MARKER';
|
||||
const newestMarker = 'NEWEST_KEPT_MARKER';
|
||||
const filler = Array.from({ length: 400 }, (_, i) => `line ${i} ${'x'.repeat(30)}`).join('\n');
|
||||
const huge = `${oldestMarker}\n${filler}\n${newestMarker}`;
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
|
||||
(_name: string, opts?: { fullHistory?: boolean }) => (opts?.fullHistory ? huge : 'visible')
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.truncated).toBe(true);
|
||||
expect(body.data.fullSize).toBeGreaterThan(4096);
|
||||
expect(body.data.terminalBuffer.length).toBeLessThanOrEqual(4096);
|
||||
// Cap keeps the most RECENT bytes: newest marker survives, oldest is dropped.
|
||||
expect(body.data.terminalBuffer).toContain(newestMarker);
|
||||
expect(body.data.terminalBuffer).not.toContain(oldestMarker);
|
||||
});
|
||||
|
||||
it('treats stale Codex scrollback config as TUI replay', async () => {
|
||||
harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history';
|
||||
harness.ctx._session.mode = 'codex';
|
||||
harness.ctx._session.codexConfig = { renderMode: 'scrollback' } as any;
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
|
||||
() => 'visible tmux pane only\n› current prompt'
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.terminalBuffer).toContain('hello world');
|
||||
expect(body.data.terminalBuffer).toContain('later accumulated history');
|
||||
expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only');
|
||||
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
|
||||
body.data.terminalBuffer.indexOf('visible tmux pane only')
|
||||
);
|
||||
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
|
||||
});
|
||||
|
||||
it('preserves one-time OAuth authorization URLs in Codex TUI replay history', async () => {
|
||||
const authUrl =
|
||||
'https://auth.atlassian.com/authorize?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2F127.0.0.1%3A35547%2Fcallback%2Fxyz';
|
||||
harness.ctx._session.terminalBuffer =
|
||||
'Authorize `atlassian` by opening this URL in your browser:\n' +
|
||||
authUrl +
|
||||
'\n(Browser launch failed; please copy the URL above manually.)\n';
|
||||
harness.ctx._session.mode = 'codex';
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
|
||||
() => 'visible tmux pane only\n› current prompt'
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.terminalBuffer).toContain(authUrl);
|
||||
expect(body.data.terminalBuffer).toContain('visible tmux pane only');
|
||||
expect(body.data.terminalBuffer.indexOf(authUrl)).toBeLessThan(
|
||||
body.data.terminalBuffer.indexOf('visible tmux pane only')
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves incidental OAuth URL mentions as ordinary Codex TUI history', async () => {
|
||||
const authUrl =
|
||||
'https://auth.atlassian.com/authorize?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2F127.0.0.1%3A35547%2Fcallback%2Fxyz';
|
||||
harness.ctx._session.terminalBuffer =
|
||||
'Root cause: URLs like ' +
|
||||
authUrl +
|
||||
' could be present in history but missing from browser-rendered terminal replay.\n' +
|
||||
"+ 'Authorize `atlassian` by opening this URL in your browser:\\n' +\n";
|
||||
harness.ctx._session.mode = 'codex';
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
|
||||
() => 'visible tmux pane only\n› current prompt'
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.terminalBuffer).toContain(authUrl);
|
||||
expect(body.data.terminalBuffer).toContain('visible tmux pane only');
|
||||
});
|
||||
|
||||
it('preserves accumulated history before a live mux pane snapshot for non-Codex sessions', async () => {
|
||||
harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history';
|
||||
harness.ctx._session.mode = 'claude';
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
|
||||
() => 'visible tmux pane only\n› current prompt'
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.terminalBuffer).toContain('hello world');
|
||||
expect(body.data.terminalBuffer).toContain('later accumulated history');
|
||||
expect(body.data.terminalBuffer).toContain('visible tmux pane only');
|
||||
expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only');
|
||||
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
|
||||
body.data.terminalBuffer.indexOf('visible tmux pane only')
|
||||
);
|
||||
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
|
||||
});
|
||||
|
||||
it('uses live mux pane capture only when the accumulated buffer is empty', async () => {
|
||||
harness.ctx._session.terminalBuffer = '';
|
||||
harness.ctx._session.mode = 'codex';
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
|
||||
() => 'visible restored tmux pane\n› current prompt'
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.terminalBuffer).toContain('visible restored tmux pane');
|
||||
expect(body.data.terminalBuffer).toContain('› current prompt');
|
||||
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
@@ -506,7 +984,7 @@ describe('session-routes', () => {
|
||||
expect(buf).toContain('\x1b[H\x1b[2J');
|
||||
expect(buf).toContain('LIVE-PANE-FRAME');
|
||||
expect(buf.indexOf('history-bytes')).toBeLessThan(buf.indexOf('LIVE-PANE-FRAME'));
|
||||
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName);
|
||||
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
|
||||
});
|
||||
|
||||
it('falls back to the byte history when no live pane buffer is available', async () => {
|
||||
@@ -608,6 +1086,54 @@ describe('session-routes', () => {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
// COD-118: this endpoint is ALSO the frontend's automatic re-attach path, so it
|
||||
// must never clear a tripped PTY-exit breaker unless the request explicitly asks.
|
||||
it('does NOT clear the PTY-exit breaker on an automatic re-attach (no body)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/interactive`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(harness.ctx._session.resetRespawnBreaker).not.toHaveBeenCalled();
|
||||
expect(harness.ctx._session.startInteractive).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the PTY-exit breaker when the explicit restart flag is sent (COD-118)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/interactive`,
|
||||
payload: { clearBreaker: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(harness.ctx._session.resetRespawnBreaker).toHaveBeenCalledTimes(1);
|
||||
expect(harness.ctx._session.startInteractive).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-boolean clearBreaker flag', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/interactive`,
|
||||
payload: { clearBreaker: 'yes' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe(ApiErrorCode.INVALID_INPUT);
|
||||
expect(harness.ctx._session.resetRespawnBreaker).not.toHaveBeenCalled();
|
||||
expect(harness.ctx._session.startInteractive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// COD-118: the wiring exit handler detaches ALL session listeners on PTY exit;
|
||||
// re-attach must restore them or later trips/output go unobserved.
|
||||
it('re-runs session listener wiring before starting', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/interactive`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(harness.ctx.setupSessionListeners).toHaveBeenCalledWith(harness.ctx._session);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/shell ==========
|
||||
@@ -622,6 +1148,8 @@ describe('session-routes', () => {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx._session.startShell).toHaveBeenCalled();
|
||||
// COD-118: re-attach restores listener wiring detached by a prior PTY exit.
|
||||
expect(harness.ctx.setupSessionListeners).toHaveBeenCalledWith(harness.ctx._session);
|
||||
});
|
||||
|
||||
it('returns error if session is busy', async () => {
|
||||
@@ -765,6 +1293,142 @@ describe('session-routes', () => {
|
||||
// ========== POST /api/sessions (with resumeSessionId) ==========
|
||||
|
||||
describe('POST /api/sessions with resumeSessionId', () => {
|
||||
it('creates session from a remote case without local stat validation', async () => {
|
||||
// Remote cases go through /api/quick-start which skips local stat() of the workingDir.
|
||||
// /api/sessions always requires workingDir to exist on the local filesystem.
|
||||
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [
|
||||
{
|
||||
id: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
},
|
||||
];
|
||||
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'gpu-work', mode: 'shell', name: 'Remote Shell' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.casePath).toBe('/home/ubuntu/work');
|
||||
const session = [...harness.ctx.sessions.values()].find((item) => item.id === body.data.sessionId);
|
||||
expect(session?.toState()).toMatchObject({
|
||||
workingDir: '/home/ubuntu/work',
|
||||
remote: expect.objectContaining({
|
||||
hostId: 'gpu-box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
startShell.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('quick-start creates remote case sessions through ssh metadata', async () => {
|
||||
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [
|
||||
{
|
||||
id: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
},
|
||||
];
|
||||
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'gpu-work', mode: 'shell' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.casePath).toBe('/home/ubuntu/work');
|
||||
const session = [...harness.ctx.sessions.values()].find((item) => item.id === body.data.sessionId);
|
||||
expect(session?.toState()).toMatchObject({
|
||||
workingDir: '/home/ubuntu/work',
|
||||
remote: expect.objectContaining({
|
||||
hostId: 'gpu-box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
startShell.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a remote quick-start that carries envOverrides (inert over ssh)', async () => {
|
||||
remoteStore.hosts = [{ id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' }];
|
||||
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'gpu-work', mode: 'claude', envOverrides: { CLAUDE_CODE_FOO: 'bar' } },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects a remote quick-start when the remote host lacks tmux', async () => {
|
||||
remoteStore.hosts = [{ id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' }];
|
||||
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||
remoteStore.tmuxCheck = {
|
||||
ok: false,
|
||||
error: 'remote host 10.0.0.42 needs tmux installed for durable remote sessions',
|
||||
};
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'gpu-work', mode: 'shell' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.OPERATION_FAILED));
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.OPERATION_FAILED });
|
||||
});
|
||||
|
||||
it('does not run local codex availability check for a remote codex case', async () => {
|
||||
// A remote codex case must NOT be blocked by the LOCAL codex availability gate
|
||||
// (the CLI runs on the remote host). Probe is stubbed ok in remoteStore.tmuxCheck.
|
||||
const startInteractive = vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [
|
||||
{ id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', commands: { codex: 'exec codx' } },
|
||||
];
|
||||
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'gpu-work', mode: 'codex' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).success).toBe(true);
|
||||
} finally {
|
||||
startInteractive.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('creates session with valid resumeSessionId', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -491,6 +491,29 @@ describe('ws-routes', () => {
|
||||
for (const ws of connections) ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('reconnecting client (same cid) is admitted at the cap instead of 4008 (COD-137)', async () => {
|
||||
const connections: WebSocket[] = [];
|
||||
try {
|
||||
// Fill all 5 slots with DISTINCT clients, one of which is "alice".
|
||||
for (const c of ['alice', 'b', 'c', 'd', 'e']) {
|
||||
connections.push(await connectWs(`/ws/sessions/ws-test-session/terminal?cid=${c}`));
|
||||
}
|
||||
|
||||
// Alice reconnects WHILE her old socket is still registered (the
|
||||
// over-count window). This must reclaim her slot, not hit the cap.
|
||||
const aliceNew = await connectWs('/ws/sessions/ws-test-session/terminal?cid=alice');
|
||||
connections.push(aliceNew);
|
||||
|
||||
// Sanity: the reconnected socket is live and usable.
|
||||
ctx._session.emit('terminal', 'reconnected-ok');
|
||||
const msg = (await nextMessage(aliceNew)) as { t: string; d: string };
|
||||
expect(msg.t).toBe('o');
|
||||
expect(msg.d).toContain('reconnected-ok');
|
||||
} finally {
|
||||
for (const ws of connections) ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Heartbeat ==========
|
||||
|
||||
@@ -144,6 +144,236 @@ describe('Codex quick start settings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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('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('creates remote shell sessions by caseName instead of remote display path', async () => {
|
||||
const elements: Record<string, any> = {
|
||||
quickStartCase: { value: 'gpu-work' },
|
||||
shellCount: { 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();
|
||||
|
||||
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('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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { resolveMuxAttachCwd, Session } from '../src/session.js';
|
||||
import type { SessionAttachmentHistoryItem } from '../src/types/session.js';
|
||||
import {
|
||||
ATTACHMENT_HISTORY_LIMIT,
|
||||
@@ -161,4 +161,37 @@ describe('session attachment history', () => {
|
||||
expect(persisted).toHaveLength(1);
|
||||
expect(persisted?.[0].fileName).toBe('ok.png');
|
||||
});
|
||||
|
||||
it('attaches remote mux sessions from a local cwd', () => {
|
||||
expect(
|
||||
resolveMuxAttachCwd('/Users/remote/project', {
|
||||
hostId: 'mac-mini',
|
||||
label: 'Mac Mini',
|
||||
host: '192.168.21.109',
|
||||
username: 'saqebakhter',
|
||||
remotePath: '/Users/remote/project',
|
||||
})
|
||||
).toBe('/tmp');
|
||||
expect(resolveMuxAttachCwd('/opt/projects/Codeman')).toBe('/opt/projects/Codeman');
|
||||
});
|
||||
|
||||
it('round-trips remote metadata through the Session constructor (restart-recovery contract)', () => {
|
||||
// restoreMuxSessions() reconstructs recovered sessions via
|
||||
// `new Session({ ..., remote: muxSession.remote ?? savedState.remote })`. If that
|
||||
// remote does not survive toState(), the next persistSessionState() erases it from
|
||||
// state.json AND the attach cwd falls back to the (nonexistent-locally) remote path.
|
||||
const remote = {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
commands: { claude: 'exec claude --dangerously-skip-permissions' },
|
||||
};
|
||||
const restored = new Session({ workingDir: '/home/ubuntu/work', remote });
|
||||
const state = restored.toState();
|
||||
expect(state.remote).toEqual(remote);
|
||||
// Recovered attach cwd must be a LOCAL path, never the remote-only workingDir.
|
||||
expect(resolveMuxAttachCwd(state.workingDir, state.remote)).toBe('/tmp');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @fileoverview Tests for CLI environment builders.
|
||||
*
|
||||
* Port: N/A (no server needed)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildMuxAttachEnv } from '../src/session-cli-builder.js';
|
||||
|
||||
describe('buildMuxAttachEnv', () => {
|
||||
it('does not pass an inherited tmux context into tmux attach clients', () => {
|
||||
const originalTmux = process.env.TMUX;
|
||||
const originalTmuxPane = process.env.TMUX_PANE;
|
||||
process.env.TMUX = '/tmp/tmux-1000/codeman,1169416,9';
|
||||
process.env.TMUX_PANE = '%9';
|
||||
|
||||
try {
|
||||
const env = buildMuxAttachEnv();
|
||||
|
||||
expect(env.TMUX).toBeUndefined();
|
||||
expect(env.TMUX_PANE).toBeUndefined();
|
||||
} finally {
|
||||
if (originalTmux === undefined) {
|
||||
delete process.env.TMUX;
|
||||
} else {
|
||||
process.env.TMUX = originalTmux;
|
||||
}
|
||||
if (originalTmuxPane === undefined) {
|
||||
delete process.env.TMUX_PANE;
|
||||
} else {
|
||||
process.env.TMUX_PANE = originalTmuxPane;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// COD-115: `{...process.env, TMUX: undefined}` leaves the KEY present with value
|
||||
// undefined; node-pty serializes that as the literal string "TMUX=undefined", which
|
||||
// still trips tmux's nesting guard and kills the attach-bridge PTY (exit 1 → respawn
|
||||
// loop). The keys must be genuinely ABSENT, which only `delete` achieves.
|
||||
it('deletes tmux/claude context keys entirely (absent, not present-with-undefined) (COD-115)', () => {
|
||||
const saved = {
|
||||
TMUX: process.env.TMUX,
|
||||
TMUX_PANE: process.env.TMUX_PANE,
|
||||
CLAUDECODE: process.env.CLAUDECODE,
|
||||
};
|
||||
process.env.TMUX = '/tmp/tmux-1000/codeman,1169416,9';
|
||||
process.env.TMUX_PANE = '%9';
|
||||
process.env.CLAUDECODE = '1';
|
||||
|
||||
try {
|
||||
const env = buildMuxAttachEnv();
|
||||
|
||||
expect('TMUX' in env).toBe(false);
|
||||
expect('TMUX_PANE' in env).toBe(false);
|
||||
expect('CLAUDECODE' in env).toBe(false);
|
||||
} finally {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) {
|
||||
delete process.env[k];
|
||||
} else {
|
||||
process.env[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { createSessionListeners } from '../src/web/session-listener-wiring.js';
|
||||
|
||||
describe('session listener wiring', () => {
|
||||
it('forwards the attachment request source through registerAttachment', async () => {
|
||||
const session = new Session({ id: 'wiring-attach-source-test', workingDir: '/tmp', mode: 'codex' });
|
||||
const registerAttachment = vi.fn(async () => undefined);
|
||||
const deps = { registerAttachment } as unknown as Parameters<typeof createSessionListeners>[1];
|
||||
|
||||
const refs = createSessionListeners(session, deps);
|
||||
refs.attachmentRequested({ path: '/tmp/mockup.png', source: 'codex-generated' });
|
||||
refs.attachmentRequested({ path: '/tmp/report.pdf', source: 'external' });
|
||||
|
||||
expect(registerAttachment).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'wiring-attach-source-test',
|
||||
'/tmp/mockup.png',
|
||||
'codex-generated'
|
||||
);
|
||||
expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const appSource = readFileSync('src/web/public/app.js', 'utf8');
|
||||
const settingsSource = readFileSync('src/web/public/settings-ui.js', 'utf8');
|
||||
const htmlSource = readFileSync('src/web/public/index.html', 'utf8');
|
||||
|
||||
describe('shortcut registry and overlay', () => {
|
||||
it('defines shortcut metadata that can be overridden from global settings', () => {
|
||||
expect(appSource).toContain('const DEFAULT_SHORTCUTS = [');
|
||||
expect(appSource).toContain('shortcutOverrides');
|
||||
expect(appSource).toContain('getShortcutRegistry()');
|
||||
expect(appSource).toContain('matchesShortcutEvent(e, shortcut)');
|
||||
});
|
||||
|
||||
it('renders a shortcut overlay modal from the registry', () => {
|
||||
expect(htmlSource).toContain('id="shortcutOverlayModal"');
|
||||
expect(htmlSource).toContain('id="shortcutOverlayList"');
|
||||
expect(appSource).toContain('showShortcutOverlay()');
|
||||
expect(appSource).toContain('renderShortcutOverlay()');
|
||||
expect(appSource).toContain('closeShortcutOverlay()');
|
||||
});
|
||||
|
||||
it('adds Ctrl/Option question-mark bindings for the overlay', () => {
|
||||
expect(appSource).toContain("id: 'show-shortcuts'");
|
||||
expect(appSource).toContain("modifiers: ['ctrl']");
|
||||
expect(appSource).toContain("modifiers: ['alt']");
|
||||
expect(appSource).toContain("key: '?'");
|
||||
expect(appSource).toContain("code: 'Slash'");
|
||||
expect(appSource).not.toContain("key: '/'");
|
||||
});
|
||||
|
||||
it('exposes shortcut overrides in a dedicated App Settings shortcuts tab', () => {
|
||||
expect(htmlSource).toContain('data-tab="settings-shortcuts"');
|
||||
expect(htmlSource).toContain('id="settings-shortcuts"');
|
||||
expect(htmlSource).toContain('id="appSettingsShortcutsList"');
|
||||
expect(htmlSource).not.toContain('id="appSettingsShortcutOverrides"');
|
||||
expect(htmlSource).not.toContain('Shortcut Overrides</span>');
|
||||
expect(settingsSource).toContain('renderShortcutSettingsList');
|
||||
expect(settingsSource).toContain('readShortcutOverridesFromSettings');
|
||||
expect(settingsSource).toContain('startShortcutCapture');
|
||||
expect(settingsSource).toContain('onShortcutCaptureKeydown');
|
||||
expect(settingsSource).toContain('settings.shortcutOverrides');
|
||||
});
|
||||
|
||||
it('renders shortcut rows with capture, typed input, reset, and disable controls', () => {
|
||||
expect(settingsSource).toContain('shortcut-setting-row');
|
||||
expect(settingsSource).toContain('shortcut-capture-btn');
|
||||
expect(settingsSource).toContain('shortcut-binding-input');
|
||||
expect(settingsSource).toContain('shortcut-reset-btn');
|
||||
expect(settingsSource).toContain('shortcut-enabled-checkbox');
|
||||
});
|
||||
|
||||
it('styles the shortcut settings rows and overlay (no unstyled tab)', () => {
|
||||
const css = readFileSync('src/web/public/styles.css', 'utf8');
|
||||
expect(css).toContain('.shortcut-setting-row {');
|
||||
expect(css).toContain('.shortcut-capture-btn,');
|
||||
expect(css).toContain('.shortcut-overlay-row {');
|
||||
});
|
||||
|
||||
it('saveAppSettings preserves shortcutOverrides (rebuilt-from-DOM saves must not wipe them)', () => {
|
||||
// Same trap as showTokenCount/showCost: saveAppSettings() rebuilds the settings
|
||||
// object fresh from the DOM, so keys edited elsewhere (the Shortcuts tab) must be
|
||||
// explicitly carried over from the previously stored blob.
|
||||
expect(settingsSource).toContain(
|
||||
'if (_prev.shortcutOverrides !== undefined) settings.shortcutOverrides = _prev.shortcutOverrides;'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the full help modal reachable now that Ctrl+? opens the registry overlay', () => {
|
||||
// The legacy #helpModal (full shortcut reference) lost its only opener when
|
||||
// Ctrl+? was rerouted to the overlay; the overlay footer must link to it.
|
||||
expect(htmlSource).toContain('shortcut-overlay-footer');
|
||||
expect(htmlSource).toContain('app.closeShortcutOverlay(); app.showHelp()');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Functional coverage (vm-sandbox harness, mirrors run-mode-ui.test.ts) ────
|
||||
// The grep assertions above pin the wiring; these exercise the actual
|
||||
// persistence round-trip and capture flow that were broken in review.
|
||||
|
||||
function makeLocalStorage() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
|
||||
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
key: (i: number) => [...store.keys()][i] ?? null,
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadSettingsHarness() {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const localStorage = makeLocalStorage();
|
||||
const elements: Record<string, any> = {};
|
||||
const holder: { queryResult: any } = { queryResult: null };
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
MobileDetection: { getDeviceType: () => 'desktop', isMobile: () => false, isTouchDevice: () => false },
|
||||
localStorage,
|
||||
document: {
|
||||
getElementById: (id: string) => elements[id] ?? null,
|
||||
querySelector: () => holder.queryResult,
|
||||
},
|
||||
console,
|
||||
escapeHtml: (s: string) => String(s),
|
||||
});
|
||||
|
||||
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)();
|
||||
return { app, localStorage, elements, holder };
|
||||
}
|
||||
|
||||
describe('shortcut settings persistence and capture', () => {
|
||||
it('persists overrides under the app-settings storage key and round-trips through the cache', () => {
|
||||
const { app, localStorage } = loadSettingsHarness();
|
||||
|
||||
app.toggleShortcutEnabled('close-session', false);
|
||||
|
||||
// Written to the SAME key loadAppSettingsFromStorage() reads (NOT the
|
||||
// legacy 'codeman:settings' key), and the in-memory cache stays coherent.
|
||||
const raw = localStorage.getItem('codeman-app-settings');
|
||||
expect(raw).toBeTruthy();
|
||||
expect(JSON.parse(raw!).shortcutOverrides['close-session']).toMatchObject({ disabled: true });
|
||||
expect(localStorage.getItem('codeman:settings')).toBeNull();
|
||||
expect(app.readShortcutOverridesFromSettings()['close-session']).toMatchObject({ disabled: true });
|
||||
|
||||
app.resetShortcutOverride('close-session');
|
||||
const after = JSON.parse(localStorage.getItem('codeman-app-settings')!);
|
||||
expect(after.shortcutOverrides['close-session']).toBeUndefined();
|
||||
expect(app.readShortcutOverridesFromSettings()['close-session']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('captures multi-modifier combos: bare modifier keydowns do not end the capture', () => {
|
||||
const { app, localStorage, holder } = loadSettingsHarness();
|
||||
const listeners: Array<(e: any) => void> = [];
|
||||
const input = {
|
||||
value: '',
|
||||
focus: vi.fn(),
|
||||
addEventListener: vi.fn((_ev: string, fn: (e: any) => void) => listeners.push(fn)),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
holder.queryResult = input;
|
||||
|
||||
app.startShortcutCapture('clear-terminal');
|
||||
expect(input.value).toBe('Press keys…');
|
||||
const handler = listeners[0];
|
||||
|
||||
// First keydown of Ctrl+Shift+P is 'Control' — must not finalize.
|
||||
handler({ key: 'Control', ctrlKey: true, preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||||
expect(input.removeEventListener).not.toHaveBeenCalled();
|
||||
|
||||
handler({
|
||||
key: 'P',
|
||||
code: 'KeyP',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
});
|
||||
expect(input.removeEventListener).toHaveBeenCalledTimes(1);
|
||||
const stored = JSON.parse(localStorage.getItem('codeman-app-settings')!);
|
||||
expect(stored.shortcutOverrides['clear-terminal'].bindings[0]).toMatchObject({
|
||||
modifiers: ['ctrl', 'shift'],
|
||||
key: 'P',
|
||||
code: 'KeyP',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects captures without a Ctrl/Cmd/Alt modifier (a bare key would fire while typing)', () => {
|
||||
const { app, localStorage, holder } = loadSettingsHarness();
|
||||
const listeners: Array<(e: any) => void> = [];
|
||||
holder.queryResult = {
|
||||
value: '',
|
||||
focus: vi.fn(),
|
||||
addEventListener: vi.fn((_ev: string, fn: (e: any) => void) => listeners.push(fn)),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
app.showToast = vi.fn();
|
||||
|
||||
app.startShortcutCapture('clear-terminal');
|
||||
listeners[0]({ key: 'x', code: 'KeyX', preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||||
|
||||
expect(localStorage.getItem('codeman-app-settings')).toBeNull();
|
||||
expect(app.showToast).toHaveBeenCalledWith('Shortcut must include Ctrl, Cmd, or Alt', 'error');
|
||||
});
|
||||
|
||||
it('renders the shortcuts list when the Shortcuts settings tab is opened', () => {
|
||||
const { app, elements } = loadSettingsHarness();
|
||||
elements.appSettingsModal = { querySelectorAll: () => [] };
|
||||
app.renderShortcutSettingsList = vi.fn();
|
||||
|
||||
app.switchSettingsTab('settings-shortcuts');
|
||||
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
|
||||
|
||||
app.switchSettingsTab('settings-display');
|
||||
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders configurable rows with delegated controls (no inline onclick) and fixed rows read-only', () => {
|
||||
const { app, elements } = loadSettingsHarness();
|
||||
const list: any = { innerHTML: '', dataset: {}, addEventListener: vi.fn() };
|
||||
elements.appSettingsShortcutsList = list;
|
||||
app.getShortcutRegistry = () => [
|
||||
{
|
||||
id: 'clear-terminal',
|
||||
group: 'Terminal',
|
||||
label: 'Clear Terminal',
|
||||
bindings: [{ modifiers: ['ctrl'], key: 'l' }],
|
||||
action: 'clearTerminal',
|
||||
},
|
||||
{ id: 'close-panels', group: 'Panels', label: 'Close Panels', displayBindings: ['Escape'] },
|
||||
];
|
||||
|
||||
app.renderShortcutSettingsList();
|
||||
|
||||
expect(list.innerHTML).not.toContain('onclick=');
|
||||
expect((list.innerHTML.match(/shortcut-capture-btn/g) || []).length).toBe(1);
|
||||
expect(list.innerHTML).toContain('shortcut-setting-row--fixed');
|
||||
// Delegated listeners wired exactly once.
|
||||
expect(list.addEventListener).toHaveBeenCalledTimes(2);
|
||||
app.renderShortcutSettingsList();
|
||||
expect(list.addEventListener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @fileoverview Regression tests for the buffer-load flush path (COD-144).
|
||||
*
|
||||
* Bug: newly launched Shell sessions rendered BLANK until a tab-switch. The
|
||||
* buffer-load path (`selectSession` → `_beginBufferLoad`/`_finishBufferLoad`)
|
||||
* QUEUES live SSE terminal events while `_isLoadingBuffer` is true, then on
|
||||
* completion DISCARDS the queue (`_loadBufferQueue = null`). That de-dup is
|
||||
* correct for an established session (the fetched buffer already contains the
|
||||
* queued output, so replaying it would duplicate Ink redraws). But for a
|
||||
* brand-new shell the fetch resolves BEFORE the PTY emits its prompt — the
|
||||
* fetched buffer is empty and the prompt arrives only as a queued event, which
|
||||
* then gets discarded → blank terminal.
|
||||
*
|
||||
* Fix: `_finishBufferLoad(owner, { flushQueued })` REPLAYS the queued events
|
||||
* through `batchTerminalWrite()` (after `_isLoadingBuffer` is cleared, so they
|
||||
* write through normally) ONLY when the load painted nothing. The default path
|
||||
* (no opts) still discards, preserving de-dup for established sessions.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
|
||||
* box; see connection-indicator.test.ts). We extract the REAL
|
||||
* `_beginBufferLoad`/`_finishBufferLoad` mixin methods from terminal-ui.js by
|
||||
* running it against a fake `CodemanApp` and capturing `CodemanApp.prototype`,
|
||||
* then copy them onto a minimal stub whose `batchTerminalWrite` is a spy. This
|
||||
* exercises the real flush/discard logic without a full xterm fake.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/** Run terminal-ui.js in a vm against a fake CodemanApp and return the captured prototype mixin. */
|
||||
function loadTerminalMixin(): Record<string, unknown> {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
requestAnimationFrame: vi.fn(),
|
||||
CodemanApp: FakeCodemanApp,
|
||||
// terminal-ui.js IIFE is invoked with `window`; it reads/writes a few globals.
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
document: { addEventListener: vi.fn() },
|
||||
});
|
||||
vm.runInContext(source, context);
|
||||
return FakeCodemanApp.prototype;
|
||||
}
|
||||
|
||||
const mixin = loadTerminalMixin();
|
||||
|
||||
type BufferLoadApp = {
|
||||
_bufferLoadSeq: number;
|
||||
_bufferLoadOwner: string | null;
|
||||
_isLoadingBuffer: boolean;
|
||||
_loadBufferQueue: string[] | null;
|
||||
batchTerminalWrite: (data: string) => void;
|
||||
_beginBufferLoad: (owner?: string) => string;
|
||||
_finishBufferLoad: (owner?: string, opts?: { flushQueued?: boolean }) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal stub carrying the buffer-load state plus the REAL begin/finish methods.
|
||||
* `batchTerminalWrite` is a spy so flushed events are observable without a real
|
||||
* xterm terminal. The real `batchTerminalWrite` would queue while loading, but
|
||||
* the flush runs AFTER `_isLoadingBuffer` is cleared, so a spy is faithful here.
|
||||
*/
|
||||
function makeApp() {
|
||||
const writes: string[] = [];
|
||||
const app: BufferLoadApp = {
|
||||
_bufferLoadSeq: 0,
|
||||
_bufferLoadOwner: null,
|
||||
_isLoadingBuffer: false,
|
||||
_loadBufferQueue: null,
|
||||
batchTerminalWrite: vi.fn((data: string) => {
|
||||
writes.push(data);
|
||||
}),
|
||||
_beginBufferLoad: mixin._beginBufferLoad as BufferLoadApp['_beginBufferLoad'],
|
||||
_finishBufferLoad: mixin._finishBufferLoad as BufferLoadApp['_finishBufferLoad'],
|
||||
};
|
||||
return { app, writes };
|
||||
}
|
||||
|
||||
/** Simulate live SSE events arriving while a buffer load is in progress (the queue path). */
|
||||
function pushWhileLoading(app: BufferLoadApp, data: string) {
|
||||
// Mirrors batchTerminalWrite's queue branch: if loading, push to the queue.
|
||||
if (app._isLoadingBuffer && app._loadBufferQueue) app._loadBufferQueue.push(data);
|
||||
}
|
||||
|
||||
describe('buffer-load flush (COD-144)', () => {
|
||||
it('finish WITHOUT flushQueued discards the queue (de-dup preserved for established sessions)', () => {
|
||||
const { app, writes } = makeApp();
|
||||
const owner = app._beginBufferLoad('load-1');
|
||||
pushWhileLoading(app, 'chunk-a');
|
||||
pushWhileLoading(app, 'chunk-b');
|
||||
|
||||
const ok = app._finishBufferLoad(owner); // default: discard
|
||||
expect(ok).toBe(true);
|
||||
expect(app._isLoadingBuffer).toBe(false);
|
||||
expect(app._loadBufferQueue).toBeNull();
|
||||
// Queued events were NOT replayed.
|
||||
expect(app.batchTerminalWrite).not.toHaveBeenCalled();
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
it('finish WITH { flushQueued: true } replays queued events in order, exactly once each', () => {
|
||||
const { app, writes } = makeApp();
|
||||
const owner = app._beginBufferLoad('load-2');
|
||||
pushWhileLoading(app, 'prompt-1');
|
||||
pushWhileLoading(app, 'prompt-2');
|
||||
|
||||
const ok = app._finishBufferLoad(owner, { flushQueued: true });
|
||||
expect(ok).toBe(true);
|
||||
expect(app._isLoadingBuffer).toBe(false);
|
||||
expect(app._loadBufferQueue).toBeNull();
|
||||
// Both chunks replayed, IN ORDER, exactly once each.
|
||||
expect(writes).toEqual(['prompt-1', 'prompt-2']);
|
||||
expect(app.batchTerminalWrite).toHaveBeenCalledTimes(2);
|
||||
expect(app.batchTerminalWrite).toHaveBeenNthCalledWith(1, 'prompt-1');
|
||||
expect(app.batchTerminalWrite).toHaveBeenNthCalledWith(2, 'prompt-2');
|
||||
});
|
||||
|
||||
it('flushed events are not re-queued (the queue is null when batchTerminalWrite runs)', () => {
|
||||
const { app } = makeApp();
|
||||
const owner = app._beginBufferLoad('load-3');
|
||||
pushWhileLoading(app, 'only');
|
||||
|
||||
// Spy that, like the real method, would re-queue if loading were still active.
|
||||
let reQueued = false;
|
||||
app.batchTerminalWrite = vi.fn((data: string) => {
|
||||
if (app._isLoadingBuffer && app._loadBufferQueue) {
|
||||
app._loadBufferQueue.push(data);
|
||||
reQueued = true;
|
||||
}
|
||||
});
|
||||
|
||||
app._finishBufferLoad(owner, { flushQueued: true });
|
||||
expect(reQueued).toBe(false);
|
||||
expect(app._isLoadingBuffer).toBe(false);
|
||||
expect(app._loadBufferQueue).toBeNull();
|
||||
});
|
||||
|
||||
it('owner mismatch returns false and does NOT flush or clear state', () => {
|
||||
const { app, writes } = makeApp();
|
||||
app._beginBufferLoad('real-owner');
|
||||
pushWhileLoading(app, 'queued');
|
||||
|
||||
const ok = app._finishBufferLoad('wrong-owner', { flushQueued: true });
|
||||
expect(ok).toBe(false);
|
||||
// State untouched — still loading, queue intact, nothing replayed.
|
||||
expect(app._isLoadingBuffer).toBe(true);
|
||||
expect(app._bufferLoadOwner).toBe('real-owner');
|
||||
expect(app._loadBufferQueue).toEqual(['queued']);
|
||||
expect(app.batchTerminalWrite).not.toHaveBeenCalled();
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
it('empty queue + flushQueued is a no-op (no throw, no writes)', () => {
|
||||
const { app, writes } = makeApp();
|
||||
const owner = app._beginBufferLoad('load-empty');
|
||||
// No events queued.
|
||||
|
||||
expect(() => app._finishBufferLoad(owner, { flushQueued: true })).not.toThrow();
|
||||
expect(app._isLoadingBuffer).toBe(false);
|
||||
expect(app._loadBufferQueue).toBeNull();
|
||||
expect(app.batchTerminalWrite).not.toHaveBeenCalled();
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* COD-47: full tmux scrollback replay on reload.
|
||||
*
|
||||
* Under VITEST, TmuxManager no-ops execSync (IS_TEST_MODE), so we can't drive
|
||||
* real tmux. Instead we assert the capture-arg construction directly from
|
||||
* source (same approach as tmux-capture-color.test.ts): a full-history capture
|
||||
* must use `capture-pane -p -e -J -S -<N>` (bounded to the configured history
|
||||
* limit, with an explicit exec maxBuffer) and skip the single-screen snapshot
|
||||
* repaint, while the visible capture keeps `capture-pane -p -e`.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('tmux full-history pane capture (COD-47)', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
||||
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
|
||||
const methodBody = source.slice(methodStart, methodStart + 4000);
|
||||
|
||||
it('capturePaneBuffer accepts pane-capture options with a fullHistory flag', () => {
|
||||
expect(methodStart).toBeGreaterThan(-1);
|
||||
// The method signature must carry the opts channel...
|
||||
expect(source.slice(methodStart, methodStart + 160)).toContain('PaneCaptureOptions');
|
||||
// ...and the body must branch on opts.fullHistory.
|
||||
expect(methodBody).toContain('opts?.fullHistory === true');
|
||||
});
|
||||
|
||||
it('full-history mode captures scrollback bounded to the configured history limit (-J -S -<N>)', () => {
|
||||
// `-S -<N>` (not unbounded `-S -`) keeps tmux from serializing more
|
||||
// scrollback than the configured history limit retains; `-J` re-joins
|
||||
// lines hard-wrapped at the capture-time pane width.
|
||||
expect(source).toContain('capture-pane -p -e -J -S -${historyLines}');
|
||||
});
|
||||
|
||||
it('full-history exec sets an explicit maxBuffer (default 1MB would ENOBUFS multi-MB dumps)', () => {
|
||||
expect(methodBody).toContain('maxBuffer');
|
||||
expect(methodBody).toContain('FULL_HISTORY_CAPTURE_SLACK_BYTES');
|
||||
});
|
||||
|
||||
it('still offers the visible single-screen capture for fast tab switches', () => {
|
||||
expect(source).toContain("'capture-pane -p -e'");
|
||||
});
|
||||
|
||||
it('returns full-history capture as raw scrollback (skips the single-screen repaint)', () => {
|
||||
// When fullHistory, return the raw buffer BEFORE the formatPaneSnapshot
|
||||
// repaint (which is single-screen and would clip a multi-screen history).
|
||||
const earlyReturn = methodBody.indexOf('return normalizeScrollbackEol(buffer);');
|
||||
const snapshot = methodBody.indexOf('formatPaneSnapshot(');
|
||||
expect(earlyReturn).toBeGreaterThan(-1);
|
||||
expect(snapshot).toBeGreaterThan(-1);
|
||||
expect(earlyReturn).toBeLessThan(snapshot);
|
||||
});
|
||||
|
||||
it('captureActivePaneBuffer forwards the capture options', () => {
|
||||
const sig = source.indexOf('captureActivePaneBuffer(muxName: string');
|
||||
expect(sig).toBeGreaterThan(-1);
|
||||
const body = source.slice(sig, sig + 800);
|
||||
expect(body).toContain('opts');
|
||||
expect(body).toContain('this.capturePaneBuffer(muxName, target, opts)');
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,14 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { TmuxManager, formatPaneSnapshot, parsePaneList, resolveActivePaneTarget } from '../src/tmux-manager.js';
|
||||
import {
|
||||
TmuxManager,
|
||||
buildRemoteKillCommand,
|
||||
buildRemoteLaunchCommand,
|
||||
formatPaneSnapshot,
|
||||
parsePaneList,
|
||||
resolveActivePaneTarget,
|
||||
} from '../src/tmux-manager.js';
|
||||
import { execSync, exec } from 'node:child_process';
|
||||
|
||||
// ============================================================================
|
||||
@@ -91,6 +98,80 @@ describe('TmuxManager (unit)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote launch command builder', () => {
|
||||
it('wraps codex command overrides in ssh with remote tmux launch', () => {
|
||||
const command = buildRemoteLaunchCommand({
|
||||
mode: 'codex',
|
||||
remote: {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
},
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
|
||||
expect(command).toContain('ssh');
|
||||
expect(command).toContain('BatchMode=yes');
|
||||
expect(command).toContain('ubuntu@10.0.0.42');
|
||||
expect(command).toContain('/home/ubuntu/work');
|
||||
// Dedicated socket + a name that fails a remote Codeman's SAFE_MUX_NAME_PATTERN.
|
||||
expect(command).toContain('tmux -L codeman-remote new-session -A -s codeman-ssh-abc123de');
|
||||
expect(command).toContain('exec codx personal');
|
||||
// Session options are scoped per-session, never global (-g).
|
||||
expect(command).not.toContain('set -g');
|
||||
});
|
||||
|
||||
it('uses default shell command when no override is configured', () => {
|
||||
const command = buildRemoteLaunchCommand({
|
||||
mode: 'shell',
|
||||
remote: {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
},
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
|
||||
expect(command).toContain('exec bash -l');
|
||||
});
|
||||
|
||||
it('defaults claude to a non-interactive launch (--dangerously-skip-permissions)', () => {
|
||||
const command = buildRemoteLaunchCommand({
|
||||
mode: 'claude',
|
||||
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
expect(command).toContain('exec claude --dangerously-skip-permissions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote kill command builder', () => {
|
||||
it('kills the durable remote tmux session on the dedicated socket via ssh', () => {
|
||||
const command = buildRemoteKillCommand({
|
||||
remote: {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
},
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
|
||||
expect(command).toContain('ssh');
|
||||
// Shares the default ConnectTimeout so an unreachable host fails fast (never blocks kill).
|
||||
expect(command).toContain('-o ConnectTimeout=10');
|
||||
expect(command).toContain('ubuntu@10.0.0.42');
|
||||
expect(command).toContain('tmux -L codeman-remote kill-session -t');
|
||||
expect(command).toContain('codeman-ssh-abc123de');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAttachCommand', () => {
|
||||
it('should return tmux', () => {
|
||||
expect(manager.getAttachCommand()).toBe('tmux');
|
||||
|
||||
@@ -66,7 +66,14 @@ describe('TmuxManager restart recovery (test mode safety)', () => {
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
name: 'Recovery Test',
|
||||
respawnConfig: { enabled: true, idleTimeoutMs: 10000, updatePrompt: 'continue', interStepDelayMs: 2000, sendClear: false, sendInit: true },
|
||||
respawnConfig: {
|
||||
enabled: true,
|
||||
idleTimeoutMs: 10000,
|
||||
updatePrompt: 'continue',
|
||||
interStepDelayMs: 2000,
|
||||
sendClear: false,
|
||||
sendInit: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.reconcileSessions();
|
||||
@@ -86,6 +93,34 @@ describe('TmuxManager restart recovery (test mode safety)', () => {
|
||||
expect(result.discovered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('preserves remote SSH metadata across reconcile (mux-sessions.json round-trip source)', async () => {
|
||||
manager.registerSession({
|
||||
sessionId: 'remote-recovery-1',
|
||||
muxName: 'codeman-de51ecaf',
|
||||
pid: 1,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/home/ubuntu/work',
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
name: 'Remote Recovery',
|
||||
remote: {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.reconcileSessions();
|
||||
expect(result.alive).toContain('remote-recovery-1');
|
||||
|
||||
// restoreMuxSessions() reads MuxSession.remote off exactly this map to rebuild
|
||||
// the recovered Session — if it were dropped here the session would respawn LOCAL.
|
||||
const recovered = manager.getSession('remote-recovery-1');
|
||||
expect(recovered?.remote).toMatchObject({ hostId: 'gpu-box', host: '10.0.0.42', remotePath: '/home/ubuntu/work' });
|
||||
});
|
||||
|
||||
it('should not execute any tmux commands in test mode', async () => {
|
||||
manager.registerSession({
|
||||
sessionId: 'alive-session',
|
||||
@@ -101,9 +136,7 @@ describe('TmuxManager restart recovery (test mode safety)', () => {
|
||||
await manager.reconcileSessions();
|
||||
|
||||
// Verify no tmux commands were executed
|
||||
const tmuxCalls = mockedExecSync.mock.calls.filter(
|
||||
([cmd]) => typeof cmd === 'string' && cmd.includes('tmux')
|
||||
);
|
||||
const tmuxCalls = mockedExecSync.mock.calls.filter(([cmd]) => typeof cmd === 'string' && cmd.includes('tmux'));
|
||||
expect(tmuxCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -155,9 +188,7 @@ describe('TmuxManager restart recovery (test mode safety)', () => {
|
||||
expect(manager.getSession('kill-me')).toBeUndefined();
|
||||
|
||||
// Verify no real kill commands were executed
|
||||
const killCalls = mockedExecSync.mock.calls.filter(
|
||||
([cmd]) => typeof cmd === 'string' && cmd.includes('kill')
|
||||
);
|
||||
const killCalls = mockedExecSync.mock.calls.filter(([cmd]) => typeof cmd === 'string' && cmd.includes('kill'));
|
||||
expect(killCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* COD-138: shell terminal staircase / diagonal replay after reload.
|
||||
*
|
||||
* Root cause: the full-history tmux capture (`capture-pane -p -e -S -`) returns
|
||||
* scrollback lines joined by a BARE `\n` (no `\r`). The visible/tab-switch path
|
||||
* repaints each row with an absolute cursor CSI via `formatPaneSnapshot`, so it
|
||||
* never staircases — but the full-history path returns the raw buffer. The
|
||||
* browser xterm is created with the default `convertEol: false` (correct for the
|
||||
* live PTY stream, which carries real `\r\n`), so on a full page reload each
|
||||
* bare `\n` drops a row WITHOUT returning the cursor to column 0. Every replayed
|
||||
* line then starts one column further right → the diagonal staircase.
|
||||
*
|
||||
* Fix: normalize the full-history scrollback to `\r\n` line endings before it is
|
||||
* shipped to the browser, so a fresh xterm starts every replayed line at col 0.
|
||||
*
|
||||
* This exercises the pure transform (`normalizeScrollbackEol`). Under VITEST,
|
||||
* TmuxManager no-ops execSync (IS_TEST_MODE), so the real capture path can't be
|
||||
* driven end-to-end here; the transform is the load-bearing seam.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeScrollbackEol } from '../src/tmux-manager.js';
|
||||
|
||||
describe('normalizeScrollbackEol (COD-138 staircase fix)', () => {
|
||||
it('adds carriage returns so bare-LF scrollback lines start at column 0', () => {
|
||||
// tmux capture-pane joins rows with bare \n. Without a preceding \r, xterm
|
||||
// (convertEol:false) keeps the column → staircase.
|
||||
const raw = 'line one\nline two\nline three';
|
||||
expect(normalizeScrollbackEol(raw)).toBe('line one\r\nline two\r\nline three');
|
||||
});
|
||||
|
||||
it('every newline in the result is preceded by a carriage return', () => {
|
||||
const raw = 'a\nb\nc\nd';
|
||||
const out = normalizeScrollbackEol(raw);
|
||||
// The staircase invariant: no LF may appear without a CR immediately before it.
|
||||
expect(/(?<!\r)\n/.test(out)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not double up carriage returns on already-CRLF input', () => {
|
||||
const raw = 'line one\r\nline two\r\nline three';
|
||||
expect(normalizeScrollbackEol(raw)).toBe('line one\r\nline two\r\nline three');
|
||||
});
|
||||
|
||||
it('normalizes a mix of CRLF and bare LF to uniform CRLF', () => {
|
||||
const raw = 'crlf\r\nbare\ncrlf2\r\nbare2';
|
||||
expect(normalizeScrollbackEol(raw)).toBe('crlf\r\nbare\r\ncrlf2\r\nbare2');
|
||||
});
|
||||
|
||||
it('preserves a lone trailing carriage return (in-line overwrite, not an EOL)', () => {
|
||||
// A bare \r not followed by \n is a column-0 reset the TUI emitted on purpose;
|
||||
// it must survive untouched so we do not corrupt an overwrite.
|
||||
const raw = 'progress\rdone';
|
||||
expect(normalizeScrollbackEol(raw)).toBe('progress\rdone');
|
||||
});
|
||||
|
||||
it('is a no-op on content without newlines', () => {
|
||||
expect(normalizeScrollbackEol('single frame')).toBe('single frame');
|
||||
expect(normalizeScrollbackEol('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for WsConnectionRegistry (COD-137).
|
||||
*
|
||||
* The registry is the pure decision unit extracted out of ws-routes.ts so the
|
||||
* connection-limit / clientId-eviction logic is testable without driving real
|
||||
* WebSocket upgrades. Uses plain fake sockets (identity only).
|
||||
*
|
||||
* @dependency src/web/ws-connection-registry.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { WsConnectionRegistry } from '../src/web/ws-connection-registry.js';
|
||||
|
||||
/** Fake socket — registry only compares identity, so any object works. */
|
||||
const sock = (label: string) => ({ readyState: 1, label });
|
||||
|
||||
describe('WsConnectionRegistry', () => {
|
||||
it('reconnecting client (same cid) reclaims its slot instead of being rejected at the limit', () => {
|
||||
const reg = new WsConnectionRegistry(5);
|
||||
// Fill all 5 slots with distinct clients, one of which is "alice".
|
||||
for (const c of ['alice', 'b', 'c', 'd', 'e']) {
|
||||
expect(reg.register('s1', c, sock(c)).admitted).toBe(true);
|
||||
}
|
||||
expect(reg.liveCount('s1')).toBe(5);
|
||||
|
||||
// Alice's new upgrade lands BEFORE her old socket's async close fires.
|
||||
const aliceNew = sock('alice-new');
|
||||
const res = reg.register('s1', 'alice', aliceNew);
|
||||
|
||||
expect(res.admitted).toBe(true); // NOT a spurious 4008
|
||||
expect(res.evictedSocket).toBeDefined(); // old alice socket handed back to close
|
||||
expect(reg.liveCount('s1')).toBe(5); // slot reused, not double-counted
|
||||
});
|
||||
|
||||
it('still rejects a genuine (N+1)th DISTINCT client', () => {
|
||||
const reg = new WsConnectionRegistry(5);
|
||||
for (const c of ['a', 'b', 'c', 'd', 'e']) {
|
||||
expect(reg.register('s1', c, sock(c)).admitted).toBe(true);
|
||||
}
|
||||
const sixth = reg.register('s1', 'f', sock('f'));
|
||||
expect(sixth.admitted).toBe(false);
|
||||
expect(sixth.evictedSocket).toBeUndefined();
|
||||
expect(reg.liveCount('s1')).toBe(5);
|
||||
});
|
||||
|
||||
it('eager removal on terminate frees a slot immediately', () => {
|
||||
const reg = new WsConnectionRegistry(5);
|
||||
const sockets = ['a', 'b', 'c', 'd', 'e'].map((c) => {
|
||||
const s = sock(c);
|
||||
reg.register('s1', c, s);
|
||||
return [c, s] as const;
|
||||
});
|
||||
expect(reg.register('s1', 'f', sock('f')).admitted).toBe(false);
|
||||
|
||||
// Eagerly unregister one (simulating terminate/error, not async close).
|
||||
reg.unregister('s1', sockets[0][1]);
|
||||
expect(reg.liveCount('s1')).toBe(4);
|
||||
|
||||
// Now a brand-new distinct client is admitted.
|
||||
expect(reg.register('s1', 'f', sock('f')).admitted).toBe(true);
|
||||
expect(reg.liveCount('s1')).toBe(5);
|
||||
});
|
||||
|
||||
it('cid-less upgrades are admitted up to the limit and never evict a keyed client', () => {
|
||||
const reg = new WsConnectionRegistry(5);
|
||||
const keyed = sock('keyed');
|
||||
reg.register('s1', 'keyed', keyed);
|
||||
|
||||
// Four anonymous upgrades fill the rest of the cap.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const res = reg.register('s1', null, sock(`anon${i}`));
|
||||
expect(res.admitted).toBe(true);
|
||||
expect(res.evictedSocket).toBeUndefined(); // never evicts the keyed client
|
||||
}
|
||||
expect(reg.liveCount('s1')).toBe(5);
|
||||
|
||||
// 6th anonymous is rejected — anonymous sockets count toward the cap.
|
||||
expect(reg.register('s1', null, sock('anon-extra')).admitted).toBe(false);
|
||||
|
||||
// The keyed client is untouched: a same-cid reconnect still reclaims.
|
||||
const keyedNew = sock('keyed-new');
|
||||
const res = reg.register('s1', 'keyed', keyedNew);
|
||||
expect(res.admitted).toBe(true);
|
||||
expect(res.evictedSocket).toBe(keyed);
|
||||
});
|
||||
|
||||
it('late close of a superseded socket does not evict the reconnected one', () => {
|
||||
const reg = new WsConnectionRegistry(5);
|
||||
const old = sock('old');
|
||||
reg.register('s1', 'alice', old);
|
||||
const fresh = sock('fresh');
|
||||
reg.register('s1', 'alice', fresh); // supersede
|
||||
|
||||
// The stale socket's async close arrives late — must NOT remove fresh.
|
||||
reg.unregister('s1', old);
|
||||
expect(reg.liveCount('s1')).toBe(1);
|
||||
|
||||
// Fresh is still the live entry: another reconnect evicts fresh, not old.
|
||||
const fresher = sock('fresher');
|
||||
expect(reg.register('s1', 'alice', fresher).evictedSocket).toBe(fresh);
|
||||
});
|
||||
|
||||
it('two tabs of the same browser (shared clientId, distinct tab nonce) coexist without eviction', () => {
|
||||
// The client keys the upgrade by `clientId:tabNonce`, NOT the bare
|
||||
// browser-wide clientId — otherwise two windows on one session would
|
||||
// supersede each other in a perpetual 4010/5s reconnect ping-pong.
|
||||
const reg = new WsConnectionRegistry(5);
|
||||
const tabA = sock('tab-a');
|
||||
const tabB = sock('tab-b');
|
||||
|
||||
expect(reg.register('s1', 'c-browser:tab-A', tabA).evictedSocket).toBeUndefined();
|
||||
const resB = reg.register('s1', 'c-browser:tab-B', tabB);
|
||||
expect(resB.admitted).toBe(true);
|
||||
expect(resB.evictedSocket).toBeUndefined(); // tab A keeps its socket
|
||||
expect(reg.liveCount('s1')).toBe(2);
|
||||
|
||||
// A genuine same-tab reconnect still supersedes only its own socket.
|
||||
const tabANew = sock('tab-a-new');
|
||||
const res = reg.register('s1', 'c-browser:tab-A', tabANew);
|
||||
expect(res.evictedSocket).toBe(tabA);
|
||||
expect(reg.liveCount('s1')).toBe(2);
|
||||
});
|
||||
|
||||
it('isolates counts per session', () => {
|
||||
const reg = new WsConnectionRegistry(2);
|
||||
reg.register('s1', 'a', sock('a'));
|
||||
reg.register('s1', 'b', sock('b'));
|
||||
expect(reg.register('s1', 'c', sock('c')).admitted).toBe(false);
|
||||
// s2 has its own budget.
|
||||
expect(reg.register('s2', 'a', sock('a2')).admitted).toBe(true);
|
||||
expect(reg.liveCount('s2')).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* COD-134 — Terminal WebSocket reconnect policy.
|
||||
*
|
||||
* `CodemanWsReconnect.plan(code, attempt)` is the pure decision behind the
|
||||
* client WS `onclose` handler in app.js: given a WebSocket close code and the
|
||||
* number of consecutive reconnects already attempted, it returns the action to
|
||||
* take (`reconnect` | `retry-fallback` | `give-up`) and a backoff delay. It is
|
||||
* exposed on `window.CodemanWsReconnect` and tested here in a plain node VM
|
||||
* context (no jsdom — jsdom env setup is broken on some hosts).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
type Plan = { action: 'reconnect' | 'retry-fallback' | 'give-up'; delayMs: number };
|
||||
|
||||
function loadHelper() {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||
return (context.window as { CodemanWsReconnect: { plan: (code: number, attempt: number) => Plan } })
|
||||
.CodemanWsReconnect;
|
||||
}
|
||||
|
||||
describe('COD-134 WS reconnect plan policy', () => {
|
||||
it('reconnects immediately on the first attempt for a transient close', () => {
|
||||
const { plan } = loadHelper();
|
||||
expect(plan(1006, 0)).toEqual({ action: 'reconnect', delayMs: 0 });
|
||||
expect(plan(1000, 0).action).toBe('reconnect');
|
||||
expect(plan(1001, 0).delayMs).toBe(0);
|
||||
expect(plan(1005, 0).delayMs).toBe(0);
|
||||
});
|
||||
|
||||
it('grows the backoff exponentially with a 10s cap for transient closes', () => {
|
||||
const { plan } = loadHelper();
|
||||
expect(plan(1006, 0).delayMs).toBe(0);
|
||||
expect(plan(1006, 1).delayMs).toBe(250);
|
||||
expect(plan(1006, 2).delayMs).toBe(500);
|
||||
expect(plan(1006, 3).delayMs).toBe(1000);
|
||||
expect(plan(1006, 4).delayMs).toBe(2000);
|
||||
expect(plan(1006, 5).delayMs).toBe(4000);
|
||||
expect(plan(1006, 6).delayMs).toBe(8000);
|
||||
expect(plan(1006, 7).delayMs).toBe(10000); // 16000 capped to 10000
|
||||
expect(plan(1006, 8).delayMs).toBe(10000);
|
||||
expect(plan(1006, 50).delayMs).toBe(10000); // stays capped no matter how many attempts
|
||||
// every transient attempt is still a reconnect
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
expect(plan(1006, attempt).action).toBe('reconnect');
|
||||
}
|
||||
});
|
||||
|
||||
it('auto-retries the fallback on a too-many-connections (4008) close', () => {
|
||||
const { plan } = loadHelper();
|
||||
expect(plan(4008, 0)).toEqual({ action: 'retry-fallback', delayMs: 5000 });
|
||||
expect(plan(4008, 3)).toEqual({ action: 'retry-fallback', delayMs: 5000 });
|
||||
});
|
||||
|
||||
it('gives up on session-not-found (4004) and session-terminated (4009)', () => {
|
||||
const { plan } = loadHelper();
|
||||
expect(plan(4004, 0)).toEqual({ action: 'give-up', delayMs: 0 });
|
||||
expect(plan(4004, 5)).toEqual({ action: 'give-up', delayMs: 0 });
|
||||
expect(plan(4009, 0)).toEqual({ action: 'give-up', delayMs: 0 });
|
||||
expect(plan(4009, 5)).toEqual({ action: 'give-up', delayMs: 0 });
|
||||
});
|
||||
|
||||
it('auto-retries the fallback for an unknown >=4004 code (e.g. 4010, 4005)', () => {
|
||||
const { plan } = loadHelper();
|
||||
expect(plan(4010, 0)).toEqual({ action: 'retry-fallback', delayMs: 5000 });
|
||||
expect(plan(4005, 2)).toEqual({ action: 'retry-fallback', delayMs: 5000 });
|
||||
expect(plan(4500, 0)).toEqual({ action: 'retry-fallback', delayMs: 5000 });
|
||||
});
|
||||
|
||||
it('treats a sub-4004 close (e.g. 4003 Forbidden) as a transient reconnect', () => {
|
||||
const { plan } = loadHelper();
|
||||
// 4003 is < 4004, so it is NOT a give-up; it follows the transient backoff.
|
||||
expect(plan(4003, 0)).toEqual({ action: 'reconnect', delayMs: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* @fileoverview Terminal WebSocket state-machine lifecycle tests
|
||||
* (`CodemanApp._connectWs` / `ws.onopen` / `ws.onclose` / `_disconnectWs`).
|
||||
*
|
||||
* Unlike test/connection-indicator.test.ts (which pins the pure descriptor per
|
||||
* pre-seeded `_wsState`), this suite drives the REAL transitions through a fake
|
||||
* `WebSocket` class so the production assignments are covered:
|
||||
*
|
||||
* 1. `_connectWs()` → 'connecting', a real `onopen` → 'connected' (the chip
|
||||
* renders "WS"), `_disconnectWs()` → 'disconnected'. Regression guard for
|
||||
* the PR-review blocker where `_wsState` was only ever written in
|
||||
* `onclose`, leaving the chip stuck on "WS…"/"HTTP" forever.
|
||||
* 2. Exponential backoff really escalates across the onclose → timer →
|
||||
* `_connectWs` cycle: `_disconnectWs()` (called first by `_connectWs`)
|
||||
* must NOT zero `_wsReconnectAttempts`, or every retry replans at
|
||||
* attempt 0 (a ~0ms tight reconnect loop during an outage). Only a
|
||||
* successful `onopen` resets the counter.
|
||||
* 3. The upgrade URL carries the per-TAB `cid` (`clientId:tabNonce`), not the
|
||||
* browser-wide clientId — two tabs of one profile must register distinct
|
||||
* registry keys so they coexist instead of 4010-evicting each other.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom — see input-send-order.test.ts).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type FakeTimer = { id: number; fn: () => void; delay: number; cleared: boolean };
|
||||
|
||||
class FakeWebSocket {
|
||||
static OPEN = 1;
|
||||
url: string;
|
||||
readyState = 0;
|
||||
closed = false;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((e: unknown) => void) | null = null;
|
||||
onclose: ((e: { code: number; reason: string }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
FakeWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(): void {}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.readyState = 3;
|
||||
}
|
||||
|
||||
static instances: FakeWebSocket[] = [];
|
||||
}
|
||||
|
||||
function loadHarness() {
|
||||
FakeWebSocket.instances = [];
|
||||
const timers: FakeTimer[] = [];
|
||||
let nextTimerId = 1;
|
||||
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout: (fn: () => void, delay: number) => {
|
||||
const id = nextTimerId++;
|
||||
timers.push({ id, fn, delay, cleared: false });
|
||||
return id;
|
||||
},
|
||||
clearTimeout: (id: number) => {
|
||||
const t = timers.find((x) => x.id === id);
|
||||
if (t) t.cleared = true;
|
||||
},
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
WebSocket: FakeWebSocket,
|
||||
location: { protocol: 'https:', host: 'test.local' },
|
||||
fetch: (...args: Parameters<typeof fetch>) => global.fetch(...args),
|
||||
document: { addEventListener: vi.fn() },
|
||||
localStorage: {
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
MobileDetection: {},
|
||||
});
|
||||
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
const CodemanApp = (context as { __CodemanApp: new () => unknown }).__CodemanApp;
|
||||
return { CodemanApp, timers };
|
||||
}
|
||||
|
||||
function fakeElement() {
|
||||
return { style: { display: '' }, title: '', textContent: '', className: '' };
|
||||
}
|
||||
|
||||
type LifecycleApp = {
|
||||
_connectWs: (id: string) => void;
|
||||
_disconnectWs: () => void;
|
||||
_wsState: string;
|
||||
_wsReady: boolean;
|
||||
_wsReconnectAttempts: number | undefined;
|
||||
_ws: FakeWebSocket | null;
|
||||
activeSessionId: string | null;
|
||||
};
|
||||
|
||||
function makeApp(
|
||||
CodemanApp: new () => unknown,
|
||||
overrides: Record<string, unknown> = {}
|
||||
): { app: LifecycleApp; els: Record<string, ReturnType<typeof fakeElement>> } {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as LifecycleApp & Record<string, unknown>;
|
||||
const els: Record<string, ReturnType<typeof fakeElement>> = {
|
||||
connectionIndicator: fakeElement(),
|
||||
connectionDot: fakeElement(),
|
||||
connectionText: fakeElement(),
|
||||
};
|
||||
app.$ = (id: string) => els[id];
|
||||
app._clientId = 'c-browser';
|
||||
app._wsTabNonce = 'tab-1';
|
||||
app._ws = null;
|
||||
app._wsSessionId = null;
|
||||
app._wsReady = false;
|
||||
app._wsState = 'disconnected';
|
||||
app._wsLastRecvAt = 0;
|
||||
app._lastIndicatorDescriptor = null;
|
||||
app._pendingDeliveries = new Map();
|
||||
app._connectionStatus = 'connected';
|
||||
app.activeSessionId = 's1';
|
||||
app.isOnline = true;
|
||||
app.sendResize = vi.fn();
|
||||
app._onWsReady = vi.fn();
|
||||
Object.assign(app, overrides);
|
||||
return { app: app as LifecycleApp, els };
|
||||
}
|
||||
|
||||
/** Run the oldest pending (not-cleared, not-yet-fired) reconnect timer. */
|
||||
function fireNextTimer(timers: FakeTimer[]): FakeTimer {
|
||||
const t = timers.find((x) => !x.cleared);
|
||||
if (!t) throw new Error('no pending timer');
|
||||
t.cleared = true; // mark consumed so the next fire picks the following one
|
||||
t.fn();
|
||||
return t;
|
||||
}
|
||||
|
||||
describe('WS state lifecycle — real _connectWs/onopen/onclose transitions', () => {
|
||||
it("_connectWs sets 'connecting', a real onopen sets 'connected' and renders 'WS'", () => {
|
||||
const { CodemanApp } = loadHarness();
|
||||
const { app, els } = makeApp(CodemanApp);
|
||||
|
||||
app._connectWs('s1');
|
||||
expect(app._wsState).toBe('connecting');
|
||||
expect(els.connectionText.textContent).toBe('WS…');
|
||||
|
||||
const ws = FakeWebSocket.instances[0];
|
||||
ws.readyState = 1;
|
||||
ws.onopen?.();
|
||||
|
||||
expect(app._wsState).toBe('connected');
|
||||
expect(app._wsReady).toBe(true);
|
||||
expect(app._wsReconnectAttempts).toBe(0);
|
||||
// The chip must show the healthy transport from the REAL open path — the
|
||||
// 'connected' branch was dead code when only onclose wrote _wsState.
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
expect(els.connectionDot.className).toBe('connection-dot connected');
|
||||
});
|
||||
|
||||
it("_disconnectWs resets the state machine to 'disconnected' and closes the socket", () => {
|
||||
const { CodemanApp } = loadHarness();
|
||||
const { app } = makeApp(CodemanApp);
|
||||
|
||||
app._connectWs('s1');
|
||||
const ws = FakeWebSocket.instances[0];
|
||||
ws.readyState = 1;
|
||||
ws.onopen?.();
|
||||
expect(app._wsState).toBe('connected');
|
||||
|
||||
app._disconnectWs();
|
||||
expect(app._wsState).toBe('disconnected');
|
||||
expect(app._wsReady).toBe(false);
|
||||
expect(app._ws).toBeNull();
|
||||
expect(ws.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("a retry-fallback close (4010) shows 'HTTP', and the successful retry returns the chip to 'WS'", () => {
|
||||
const { CodemanApp, timers } = loadHarness();
|
||||
const { app, els } = makeApp(CodemanApp);
|
||||
|
||||
app._connectWs('s1');
|
||||
const ws1 = FakeWebSocket.instances[0];
|
||||
ws1.readyState = 1;
|
||||
ws1.onopen?.();
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
|
||||
ws1.onclose?.({ code: 4010, reason: 'Superseded by reconnect' });
|
||||
expect(app._wsState).toBe('fallback');
|
||||
expect(els.connectionText.textContent).toBe('HTTP');
|
||||
|
||||
// The bounded 5s retry succeeds → the chip must NOT stay stuck on "HTTP".
|
||||
const timer = fireNextTimer(timers);
|
||||
expect(timer.delay).toBe(5000);
|
||||
const ws2 = FakeWebSocket.instances[1];
|
||||
ws2.readyState = 1;
|
||||
ws2.onopen?.();
|
||||
expect(app._wsState).toBe('connected');
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WS reconnect backoff — attempts survive the _connectWs → _disconnectWs call', () => {
|
||||
it('escalates the transient-close delay ladder instead of replanning at attempt 0', () => {
|
||||
const { CodemanApp, timers } = loadHarness();
|
||||
const { app } = makeApp(CodemanApp);
|
||||
|
||||
app._connectWs('s1');
|
||||
// Attempt 0: transient close plans 0ms (+ <250ms jitter).
|
||||
FakeWebSocket.instances[0].onclose?.({ code: 1006, reason: '' });
|
||||
expect(app._wsReconnectAttempts).toBe(1);
|
||||
const t1 = fireNextTimer(timers);
|
||||
expect(t1.delay).toBeLessThan(250);
|
||||
|
||||
// Attempt 1: the retry's _connectWs ran _disconnectWs first — the counter
|
||||
// must survive it, so this close plans 250ms (+ jitter), not 0ms again.
|
||||
FakeWebSocket.instances[1].onclose?.({ code: 1006, reason: '' });
|
||||
expect(app._wsReconnectAttempts).toBe(2);
|
||||
const t2 = fireNextTimer(timers);
|
||||
expect(t2.delay).toBeGreaterThanOrEqual(250);
|
||||
expect(t2.delay).toBeLessThan(500);
|
||||
|
||||
// Attempt 2 → 500ms rung.
|
||||
FakeWebSocket.instances[2].onclose?.({ code: 1006, reason: '' });
|
||||
expect(app._wsReconnectAttempts).toBe(3);
|
||||
const t3 = fireNextTimer(timers);
|
||||
expect(t3.delay).toBeGreaterThanOrEqual(500);
|
||||
expect(t3.delay).toBeLessThan(750);
|
||||
});
|
||||
|
||||
it('a successful onopen (not an intentional disconnect) is what resets the counter', () => {
|
||||
const { CodemanApp, timers } = loadHarness();
|
||||
const { app } = makeApp(CodemanApp);
|
||||
|
||||
app._connectWs('s1');
|
||||
FakeWebSocket.instances[0].onclose?.({ code: 1006, reason: '' });
|
||||
FakeWebSocket.instances[0].closed = true;
|
||||
fireNextTimer(timers);
|
||||
expect(app._wsReconnectAttempts).toBe(1);
|
||||
|
||||
const ws2 = FakeWebSocket.instances[1];
|
||||
ws2.readyState = 1;
|
||||
ws2.onopen?.();
|
||||
expect(app._wsReconnectAttempts).toBe(0);
|
||||
expect(app._wsState).toBe('connected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WS upgrade cid — per-TAB identity (clientId:tabNonce)', () => {
|
||||
it('sends the composite cid on the upgrade URL, keeping the bare clientId for input frames', () => {
|
||||
const { CodemanApp } = loadHarness();
|
||||
const { app } = makeApp(CodemanApp);
|
||||
|
||||
app._connectWs('s1');
|
||||
const url = new URL(FakeWebSocket.instances[0].url);
|
||||
expect(url.searchParams.get('cid')).toBe('c-browser:tab-1');
|
||||
});
|
||||
|
||||
it('two tabs sharing the browser clientId register DIFFERENT registry keys', () => {
|
||||
const { CodemanApp } = loadHarness();
|
||||
const { app: tabA } = makeApp(CodemanApp, { _wsTabNonce: 'tab-A' });
|
||||
const { app: tabB } = makeApp(CodemanApp, { _wsTabNonce: 'tab-B' });
|
||||
|
||||
tabA._connectWs('s1');
|
||||
tabB._connectWs('s1');
|
||||
|
||||
const cidA = new URL(FakeWebSocket.instances[0].url).searchParams.get('cid');
|
||||
const cidB = new URL(FakeWebSocket.instances[1].url).searchParams.get('cid');
|
||||
expect(cidA).toBe('c-browser:tab-A');
|
||||
expect(cidB).toBe('c-browser:tab-B');
|
||||
// Distinct keys → the server registry admits both instead of supersede-evicting.
|
||||
expect(cidA).not.toBe(cidB);
|
||||
});
|
||||
|
||||
it('omits the cid query entirely when no clientId is available', () => {
|
||||
const { CodemanApp } = loadHarness();
|
||||
const { app } = makeApp(CodemanApp, { _clientId: '' });
|
||||
|
||||
app._connectWs('s1');
|
||||
expect(FakeWebSocket.instances[0].url).not.toContain('cid=');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user