mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Review follow-ups on #282. All four are the same failure shape: a list that enumerates run modes, missed by the sweep that added 'pi'. 1. Cron ignored pi's project-trust clamp. The PR widened CronJobBaseSchema's agentType to accept 'pi' but not the matching clamp beside gemini's, so a non-granted multi-user owner's cron pi job spawned bare `pi` (pi's own defaultProjectTrust, an interactive prompt they can answer "yes" to, which loads and EXECUTES repo-local .pi/extensions TypeScript) while the same user's UI/API launch was forced to --no-approve. The clamp is now a pure exported helper, clampCronExternalCliConfigs(), so both it and gemini's previously untested materialization are pinned. 2. POST /api/sessions/:id/interactive auto-enabled the Ralph tracker for pi: its denylist covered opencode/codex/gemini/antigravity only. The tracker is never fed for an external CLI (_processExpensiveParsers returns early), so a pi session reported ralphEnabled and Ralph UI state no sibling backend shows. 3. REMOTE_CLI_BIN had no pi entry, so buildRemoteCliVersionProbeCommand() returned null and Session.cliVersion stayed blank for every remote-SSH pi session, even though the PR wired the remote launch command and the per-mode override schema field. 4. The desktop home rail's badge map had no pi entry, and its lookup falls back to '', which is what claude renders. A pi session read as Claude there while the tab strip and phone overview badged it correctly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
244 lines
9.8 KiB
TypeScript
244 lines
9.8 KiB
TypeScript
// Port: none (pure model + static markup assertions — no browser, no server).
|
|
//
|
|
// The desktop home screen's tab column (src/web/public/home-sessions.js) fills
|
|
// the welcome overlay's left gutter. Two things about it can silently go wrong
|
|
// and are pinned here: the row ORDER (it mirrors the tab strip, unlike the phone
|
|
// overview which sorts by urgency, and the number badges are only correct if it
|
|
// does), and the WIDTH GATE, which lives in two places at once — the JS constant
|
|
// and a CSS media query — because the column is absolutely positioned and would
|
|
// overlap the search panel in a narrow window.
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
|
|
|
/** Minimal fake DOM node — enough surface for the programmatic row builders. */
|
|
function fakeElement(): any {
|
|
const el: any = {
|
|
className: '',
|
|
type: '',
|
|
title: '',
|
|
textContent: '',
|
|
dataset: {},
|
|
style: {},
|
|
children: [] as any[],
|
|
setAttribute() {},
|
|
appendChild(child: any) {
|
|
el.children.push(child);
|
|
return child;
|
|
},
|
|
};
|
|
return el;
|
|
}
|
|
|
|
/**
|
|
* home-sessions.js reuses `_mobileOverviewState` / `_mobileOverviewCaseFor` /
|
|
* `shouldUseMobileOverview` from mobile-overview.js, so both files run in the
|
|
* same context — which is also the point: if that reuse ever breaks, these
|
|
* tests stop loading rather than quietly testing a divergent copy.
|
|
*/
|
|
function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1512) {
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
console,
|
|
window: { innerWidth },
|
|
document: {
|
|
getElementById: () => null,
|
|
createElement: () => fakeElement(),
|
|
createElementNS: () => fakeElement(),
|
|
},
|
|
MobileDetection: { getDeviceType: () => (innerWidth < 430 ? 'mobile' : 'desktop') },
|
|
});
|
|
for (const file of ['mobile-overview.js', 'home-sessions.js']) {
|
|
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
|
}
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.getSessionName = (session: any) => session.name || session.id.slice(0, 8);
|
|
app._shortenHomePath = (p: string) => (p || '').replace(/^\/home\/[^/]+\//, '~/');
|
|
app.loadAppSettingsFromStorage = () => ({});
|
|
Object.assign(app, overrides);
|
|
return app;
|
|
}
|
|
|
|
const CASES = [{ name: 'claudeman', path: '/home/arkon/default/claudeman', location: 'local' }];
|
|
|
|
function sessionMap(list: Array<Record<string, any>>) {
|
|
return new Map(
|
|
list.map((over) => {
|
|
const s = { id: 'x', status: 'idle', mode: 'claude', workingDir: '/home/arkon/default/claudeman', ...over };
|
|
return [s.id, s];
|
|
})
|
|
);
|
|
}
|
|
|
|
describe('home sessions column: model', () => {
|
|
it('lists rows in TAB order, not by urgency, so the number badges match Alt+1..9', () => {
|
|
// The phone overview would hoist 'needy' to the top; this surface must not,
|
|
// because its badges are the Alt+N indices.
|
|
const app = loadHomeSessionsApp({
|
|
sessions: sessionMap([{ id: 'first' }, { id: 'needy' }, { id: 'third' }]),
|
|
sessionOrder: ['first', 'needy', 'third'],
|
|
cases: CASES,
|
|
pendingHooks: new Map([['needy', new Set(['permission_prompt'])]]),
|
|
});
|
|
|
|
const rows = app.buildHomeSessionRows();
|
|
expect(rows.map((r: any) => r.id)).toEqual(['first', 'needy', 'third']);
|
|
expect(rows.map((r: any) => r.index)).toEqual([0, 1, 2]);
|
|
expect(rows[1].state).toBe('needs');
|
|
expect(rows[1].pill).toBe('needs you');
|
|
});
|
|
|
|
it('shows a session that is not in the order list yet', () => {
|
|
// A freshly created session exists in this.sessions before the order array
|
|
// catches up; its tab is already on screen, so its row must be too.
|
|
const app = loadHomeSessionsApp({
|
|
sessions: sessionMap([{ id: 'known' }, { id: 'fresh' }]),
|
|
sessionOrder: ['known'],
|
|
cases: CASES,
|
|
});
|
|
|
|
expect(app.buildHomeSessionRows().map((r: any) => r.id)).toEqual(['known', 'fresh']);
|
|
});
|
|
|
|
it('classifies state through the shared phone-overview helper', () => {
|
|
const app = loadHomeSessionsApp({
|
|
sessions: sessionMap([
|
|
{ id: 'w', status: 'busy' },
|
|
{ id: 'i', status: 'idle' },
|
|
{ id: 'd', status: 'stopped' },
|
|
{ id: 'e', status: 'error' },
|
|
]),
|
|
sessionOrder: ['w', 'i', 'd', 'e'],
|
|
cases: CASES,
|
|
});
|
|
|
|
expect(app.buildHomeSessionRows().map((r: any) => [r.state, r.pill])).toEqual([
|
|
['working', 'working'],
|
|
['idle', 'idle'],
|
|
['done', 'done'],
|
|
['error', 'error'],
|
|
]);
|
|
});
|
|
|
|
it('labels a row with its case and a short backend badge', () => {
|
|
const app = loadHomeSessionsApp({
|
|
sessions: sessionMap([{ id: 'a', name: 'w1-claudeman', mode: 'codex' }]),
|
|
sessionOrder: ['a'],
|
|
cases: CASES,
|
|
});
|
|
|
|
const [row] = app.buildHomeSessionRows();
|
|
expect(row.caseName).toBe('claudeman');
|
|
expect(row.modeBadge).toBe('cx');
|
|
// claude is the default backend and gets no badge — the strip does the same.
|
|
const plain = loadHomeSessionsApp({
|
|
sessions: sessionMap([{ id: 'a', mode: 'claude' }]),
|
|
sessionOrder: ['a'],
|
|
cases: CASES,
|
|
});
|
|
expect(plain.buildHomeSessionRows()[0].modeBadge).toBe('');
|
|
});
|
|
|
|
it('badges every non-claude backend, so a new run mode cannot read as claude here', () => {
|
|
// The badge map is a per-mode lookup with a '' fallback, so a mode missing from it
|
|
// is indistinguishable from claude in this rail while the tab strip badges it fine.
|
|
for (const [mode, badge] of [
|
|
['shell', 'sh'],
|
|
['opencode', 'oc'],
|
|
['codex', 'cx'],
|
|
['gemini', 'gm'],
|
|
['antigravity', 'ag'],
|
|
['pi', 'pi'],
|
|
] as const) {
|
|
const app = loadHomeSessionsApp({
|
|
sessions: sessionMap([{ id: 'a', mode }]),
|
|
sessionOrder: ['a'],
|
|
cases: CASES,
|
|
});
|
|
expect(app.buildHomeSessionRows()[0].modeBadge).toBe(badge);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('home sessions column: gate', () => {
|
|
it('renders on a wide desktop', () => {
|
|
const app = loadHomeSessionsApp({}, 1512);
|
|
expect(app.shouldShowHomeSessions()).toBe(true);
|
|
});
|
|
|
|
it('stays out of a window too narrow to hold it beside the centered content', () => {
|
|
// Absolutely positioned: below the gate it would overlap the search panel
|
|
// rather than push it aside.
|
|
expect(loadHomeSessionsApp({}, 1100).shouldShowHomeSessions()).toBe(false);
|
|
expect(loadHomeSessionsApp({}, 1179).shouldShowHomeSessions()).toBe(false);
|
|
expect(loadHomeSessionsApp({}, 1180).shouldShowHomeSessions()).toBe(true);
|
|
});
|
|
|
|
it('yields to the phone overview, which already lists the same sessions', () => {
|
|
const app = loadHomeSessionsApp({}, 390);
|
|
expect(app.shouldUseMobileOverview()).toBe(true);
|
|
expect(app.shouldShowHomeSessions()).toBe(false);
|
|
});
|
|
|
|
it('stays out of a popped-out solo window', () => {
|
|
expect(loadHomeSessionsApp({ isSoloWindow: true }, 1512).shouldShowHomeSessions()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('home sessions column: wiring', () => {
|
|
const js = readFileSync(resolve(PUBLIC, 'home-sessions.js'), 'utf8');
|
|
const css = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
|
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
|
|
|
it('keeps the JS width gate and the CSS media query in agreement', () => {
|
|
// Two gates for one decision: the JS one hides the element, the CSS one is
|
|
// the backstop for a resize that outruns the matchMedia listener. Drift
|
|
// means a column that overlaps the welcome content at some widths.
|
|
const jsMin = Number(/HOME_SESSIONS_MIN_WIDTH = (\d+)/.exec(js)?.[1]);
|
|
const cssMax = Number(/@media \(max-width: (\d+)px\) \{\s*\.home-sessions \{/.exec(css)?.[1]);
|
|
expect(jsMin).toBeGreaterThan(0);
|
|
expect(cssMax).toBe(jsMin - 1);
|
|
});
|
|
|
|
it('re-asserts [hidden] over the flex display', () => {
|
|
// .home-sessions is display:flex, which defeats the `hidden` attribute — the
|
|
// module's only visibility lever — unless this rule exists.
|
|
expect(css).toMatch(/\.home-sessions\[hidden\]\s*\{\s*display:\s*none;/);
|
|
});
|
|
|
|
it('reuses the tab-load spinner rather than declaring a second one', () => {
|
|
// The working ring is the same motion a tab shows while it loads, on both
|
|
// home screens. Re-declaring the keyframes here is how they drift apart.
|
|
expect(js).toContain('tab-load-spin');
|
|
expect(css).toMatch(/\.home-sessions-dot--working::after[\s\S]*?animation: tab-load-spin/);
|
|
expect(css).not.toMatch(/@keyframes home-sessions-load-spin/);
|
|
const mobileCss = readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8');
|
|
expect(mobileCss).toMatch(/\.mobile-overview-dot--working::after[\s\S]*?animation: tab-load-spin/);
|
|
});
|
|
|
|
it('gives the working dot the same green halo on both home screens', () => {
|
|
const halo = /box-shadow: 0 0 8px 2px color-mix\(in srgb, var\(--green\) 55%, transparent\)/;
|
|
expect(css).toMatch(halo);
|
|
expect(readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8')).toMatch(halo);
|
|
});
|
|
|
|
it('ships the container hidden, inside the welcome overlay, loaded after mobile-overview.js', () => {
|
|
expect(html).toMatch(/<aside class="home-sessions" id="homeSessions" hidden><\/aside>/);
|
|
const overlayStart = html.indexOf('id="welcomeOverlay"');
|
|
const aside = html.indexOf('id="homeSessions"');
|
|
const content = html.indexOf('class="welcome-content"');
|
|
expect(overlayStart).toBeGreaterThan(-1);
|
|
expect(aside).toBeGreaterThan(overlayStart);
|
|
expect(aside).toBeLessThan(content);
|
|
// Load order: the module reuses prototype methods installed by
|
|
// mobile-overview.js. Compare the <script> tags, not any mention: both
|
|
// files are named in explanatory comments earlier in the document.
|
|
expect(html.indexOf('src="home-sessions.js"')).toBeGreaterThan(html.indexOf('src="mobile-overview.js"'));
|
|
});
|
|
});
|