mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
194 lines
7.2 KiB
TypeScript
194 lines
7.2 KiB
TypeScript
// Port: none (pure model + fake-DOM row builders — no browser, no server).
|
|||
|
|
//
|
||
|
|
// The phone overview and the desktop home rail showing an exited agent as
|
||
|
|
// "exited" rather than "idle" (Ark0N/Codeman#446).
|
||
|
|
//
|
||
|
|
// The server publishes `session.paneExit` when the agent inside a local tmux
|
||
|
|
// pane has exited, while `status` stays `idle` or `busy` by design. Part 1 of
|
||
|
|
// #446 taught the tab strip and the rich rail rows to say so; both home screens
|
||
|
|
// still said "idle" beside nothing running. `_mobileOverviewExit()`
|
||
|
|
// (mobile-overview.js) is now the one rule all three surfaces read. It changes
|
||
|
|
// what a row SHOWS and leaves its `state` alone, because `state` picks the
|
||
|
|
// section and the sort order.
|
||
|
|
//
|
||
|
|
// `paneExitLabel()` is lifted out of the shipped app.js rather than restated
|
||
|
|
// here, so a change to what counts as "exited" there reaches these tests.
|
||
|
|
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');
|
||
|
|
|
||
|
|
/** The shipped `paneExitLabel()` from app.js, as source text. */
|
||
|
|
function paneExitLabelSource(): string {
|
||
|
|
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||
|
|
const match = appJs.match(/function paneExitLabel\(paneExit\) \{[\s\S]*?\n\}\n/);
|
||
|
|
if (!match) throw new Error('paneExitLabel() not found in app.js');
|
||
|
|
return match[0];
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Every className in a fake-DOM subtree, depth first. */
|
||
|
|
function classNames(el: any): string[] {
|
||
|
|
return [el.className, ...(el.children || []).flatMap(classNames)].filter(Boolean);
|
||
|
|
}
|
||
|
|
|
||
|
|
function loadApp({ withPaneExitLabel = true } = {}) {
|
||
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
||
|
|
const context = vm.createContext({
|
||
|
|
CodemanApp,
|
||
|
|
console,
|
||
|
|
window: { innerWidth: 1512 },
|
||
|
|
document: {
|
||
|
|
documentElement: { getAttribute: () => null },
|
||
|
|
getElementById: () => null,
|
||
|
|
createElement: () => fakeElement(),
|
||
|
|
createElementNS: () => fakeElement(),
|
||
|
|
},
|
||
|
|
MobileDetection: { getDeviceType: () => 'desktop' },
|
||
|
|
});
|
||
|
|
if (withPaneExitLabel) vm.runInContext(paneExitLabelSource(), context, { filename: 'app.js' });
|
||
|
|
for (const file of ['constants.js', '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 || '';
|
||
|
|
app.loadAppSettingsFromStorage = () => ({});
|
||
|
|
app.cases = [];
|
||
|
|
app.pendingHooks = new Map();
|
||
|
|
return app;
|
||
|
|
}
|
||
|
|
|
||
|
|
const EXIT_AT = 1_700_000_000_000;
|
||
|
|
|
||
|
|
function sessions(list: Array<Record<string, any>>) {
|
||
|
|
return new Map(list.map((s) => [s.id, { status: 'idle', mode: 'claude', workingDir: '/w', ...s }]));
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('the desktop home rail', () => {
|
||
|
|
it('says "exited" for an idle session whose agent exited, and measures from the exit', () => {
|
||
|
|
const app = loadApp();
|
||
|
|
app.sessions = sessions([{ id: 'gone', paneExit: { status: 3, at: EXIT_AT }, lastActivityAt: 5 }]);
|
||
|
|
app.sessionOrder = ['gone'];
|
||
|
|
|
||
|
|
const [row] = app.buildHomeSessionRows();
|
||
|
|
expect(row.state).toBe('idle');
|
||
|
|
expect(row.display).toBe('exited');
|
||
|
|
expect(row.pill).toBe('exited');
|
||
|
|
expect(row.since).toEqual({ key: 'exited', at: EXIT_AT });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('draws a neutral row: no idle or working class on the row, the dot or the pill', () => {
|
||
|
|
// A pane whose agent died mid-turn still has `status: 'busy'`, so without
|
||
|
|
// the display class its dot would pulse green beside "exited".
|
||
|
|
const app = loadApp();
|
||
|
|
app.sessions = sessions([{ id: 'gone', status: 'busy', paneExit: { at: EXIT_AT } }]);
|
||
|
|
app.sessionOrder = ['gone'];
|
||
|
|
|
||
|
|
const [row] = app.buildHomeSessionRows();
|
||
|
|
expect(row.state).toBe('working');
|
||
|
|
const classes = classNames(app._buildHomeSessionRow(row));
|
||
|
|
expect(classes).toEqual(
|
||
|
|
expect.arrayContaining([
|
||
|
|
'home-sessions-row home-sessions-row--exited',
|
||
|
|
'home-sessions-dot home-sessions-dot--exited',
|
||
|
|
'home-sessions-pill home-sessions-pill--exited',
|
||
|
|
])
|
||
|
|
);
|
||
|
|
expect(classes.join(' ')).not.toMatch(/--(working|idle)\b/);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('lets a pending permission prompt win over the exit', () => {
|
||
|
|
const app = loadApp();
|
||
|
|
app.sessions = sessions([{ id: 'blocked', paneExit: { status: 0, at: EXIT_AT } }]);
|
||
|
|
app.sessionOrder = ['blocked'];
|
||
|
|
app.pendingHooks = new Map([['blocked', new Set(['permission_prompt'])]]);
|
||
|
|
|
||
|
|
const [row] = app.buildHomeSessionRows();
|
||
|
|
expect(row.display).toBe('needs');
|
||
|
|
expect(row.pill).toBe('needs you');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('leaves a session without an exit exactly as it was', () => {
|
||
|
|
const app = loadApp();
|
||
|
|
app.sessions = sessions([{ id: 'live', lastActivityAt: 5 }]);
|
||
|
|
app.sessionOrder = ['live'];
|
||
|
|
|
||
|
|
const [row] = app.buildHomeSessionRows();
|
||
|
|
expect(row.display).toBe('idle');
|
||
|
|
expect(row.pill).toBe('idle');
|
||
|
|
expect(row.since).toEqual({ key: 'idle', at: 5 });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('degrades to no override when a stale cached app.js lacks paneExitLabel', () => {
|
||
|
|
const app = loadApp({ withPaneExitLabel: false });
|
||
|
|
app.sessions = sessions([{ id: 'gone', paneExit: { status: 3, at: EXIT_AT } }]);
|
||
|
|
app.sessionOrder = ['gone'];
|
||
|
|
|
||
|
|
expect(app.buildHomeSessionRows()[0].pill).toBe('idle');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('the phone overview', () => {
|
||
|
|
it('says "exited" and keeps the row in its section', () => {
|
||
|
|
const app = loadApp();
|
||
|
|
const model = app.buildMobileOverviewModel({
|
||
|
|
sessions: sessions([
|
||
|
|
{ id: 'gone', paneExit: { status: 137, at: EXIT_AT } },
|
||
|
|
{ id: 'live', lastActivityAt: 5 },
|
||
|
|
]),
|
||
|
|
cases: [],
|
||
|
|
sessionOrder: ['gone', 'live'],
|
||
|
|
});
|
||
|
|
|
||
|
|
const byId = Object.fromEntries(model.current.map((r: any) => [r.id, r]));
|
||
|
|
expect(byId.gone).toMatchObject({ state: 'idle', display: 'exited', pill: 'exited' });
|
||
|
|
expect(byId.gone.since).toEqual({ key: 'exited', at: EXIT_AT });
|
||
|
|
expect(byId.live).toMatchObject({ display: 'idle', pill: 'idle' });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('draws a neutral row', () => {
|
||
|
|
const app = loadApp();
|
||
|
|
const model = app.buildMobileOverviewModel({
|
||
|
|
sessions: sessions([{ id: 'gone', status: 'busy', paneExit: { at: EXIT_AT } }]),
|
||
|
|
cases: [],
|
||
|
|
});
|
||
|
|
app._pendingApprovalForSession = () => null;
|
||
|
|
|
||
|
|
const classes = classNames(app._buildMobileOverviewRow(model.current[0]));
|
||
|
|
expect(classes).toEqual(
|
||
|
|
expect.arrayContaining([
|
||
|
|
'mobile-overview-row mobile-overview-row--exited',
|
||
|
|
'mobile-overview-dot mobile-overview-dot--exited',
|
||
|
|
'mobile-overview-pill mobile-overview-pill--exited',
|
||
|
|
])
|
||
|
|
);
|
||
|
|
expect(classes.join(' ')).not.toMatch(/--(working|idle)\b/);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('the exited pill styles', () => {
|
||
|
|
it('gives both home screens a neutral exited pill', () => {
|
||
|
|
expect(readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8')).toMatch(/\.home-sessions-pill--exited \{/);
|
||
|
|
expect(readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8')).toMatch(/\.mobile-overview-pill--exited \{/);
|
||
|
|
});
|
||
|
|
});
|