mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
Merge origin/master into feat/remote-host-wake
Resolves CLAUDE.md count tables (route counts recounted on the merged tree: 235 handlers, sessions 37) and keeps both the host-wake and the reboot-restore banner in index.html. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdGP4jUTjc9J2RYYykDrCG
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @fileoverview Output arriving after a pane capture survives the buffer load.
|
||||
*
|
||||
* `batchTerminalWrite` queues live terminal events while a buffer load runs,
|
||||
* and `_finishBufferLoad` discards that queue by default. That is right when
|
||||
* the loaded buffer is the server's accumulated byte history, which is current
|
||||
* up to the response. A tmux pane capture is current only up to CAPTURE time,
|
||||
* so anything arriving between the capture and the end of the chunked write is
|
||||
* queued and then dropped, with nothing scheduling a re-fetch.
|
||||
*
|
||||
* The queue now stamps each entry with its arrival time, and a capture load
|
||||
* replays the tail that arrived after the response headers. These drive the
|
||||
* real client in chromium: the event is injected from inside the response's
|
||||
* own `json()` call, which is the one place guaranteed to land after the
|
||||
* headers and before the chunked write.
|
||||
*
|
||||
* Port: 3256 (capture load window)
|
||||
*
|
||||
* Run: npx vitest run --config config/vitest.browser.config.ts test/capture-load-window.browser.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3256;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
const MARKER = 'ARRIVED-AFTER-THE-CAPTURE';
|
||||
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true); // testMode
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.stop();
|
||||
}, 30_000);
|
||||
|
||||
/**
|
||||
* Select the session with the terminal fetch stubbed, injecting one live event
|
||||
* from inside `json()`. Returns how many terminal rows carry the marker, so a
|
||||
* flush that replays too much fails as loudly as one that replays nothing.
|
||||
*/
|
||||
async function runLoad(page: Page, sessionId: string, source: string): Promise<number> {
|
||||
return page.evaluate(
|
||||
async ({ sid, src, marker }) => {
|
||||
const app = (
|
||||
window as unknown as {
|
||||
app: {
|
||||
selectSession: (id: string, o?: object) => Promise<void>;
|
||||
_onSessionTerminal: (e: { id: string; data: string }) => void;
|
||||
terminal: {
|
||||
buffer: {
|
||||
active: {
|
||||
length: number;
|
||||
getLine: (i: number) => { translateToString: (t: boolean) => string } | undefined;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
).app;
|
||||
|
||||
const realFetch = window.fetch.bind(window);
|
||||
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(typeof input === 'string' ? input : ((input as Request).url ?? input));
|
||||
if (!url.includes('/terminal')) return realFetch(input as RequestInfo, init);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
// `selectSession` timestamps the headers the moment this promise
|
||||
// resolves, then calls json(). Injecting here puts the event after
|
||||
// that timestamp and inside the load window, which is exactly the
|
||||
// gap a pane capture cannot cover.
|
||||
json: async () => {
|
||||
app._onSessionTerminal({ id: sid, data: `\r\n${marker}\r\n` });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
terminalBuffer: '\x1b[1;1Hcaptured frame line one\r\n',
|
||||
status: 'idle',
|
||||
fullSize: 512,
|
||||
retainedBytes: 512,
|
||||
truncated: false,
|
||||
truncationReason: null,
|
||||
source: src,
|
||||
captureCols: 80,
|
||||
captureRows: 24,
|
||||
},
|
||||
};
|
||||
},
|
||||
}) as unknown as Promise<Response>;
|
||||
}) as typeof window.fetch;
|
||||
|
||||
try {
|
||||
await app.selectSession(sid);
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
const buf = app.terminal.buffer.active;
|
||||
let hits = 0;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
if (buf.getLine(i)?.translateToString(true).includes(marker)) hits += 1;
|
||||
}
|
||||
return hits;
|
||||
} finally {
|
||||
window.fetch = realFetch;
|
||||
}
|
||||
},
|
||||
{ sid: sessionId, src: source, marker: MARKER }
|
||||
);
|
||||
}
|
||||
|
||||
async function openSession(page: Page): Promise<string> {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
|
||||
// xterm loads from /vendor, so the terminal appears a beat after the app.
|
||||
// Without it every buffer assertion below would throw rather than compare.
|
||||
await page.waitForFunction(() => (window as unknown as { app?: { terminal?: unknown } }).app?.terminal, null, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
return page.evaluate(async () => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp', name: 'capture-load-window-test' }),
|
||||
});
|
||||
const body = await res.json();
|
||||
return body.data?.session?.id ?? body.data?.id ?? body.id;
|
||||
});
|
||||
}
|
||||
|
||||
describe('output emitted during a capture load', () => {
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
|
||||
afterAll(async () => {
|
||||
await context?.close();
|
||||
});
|
||||
|
||||
it('reaches the terminal exactly once when the buffer came from a pane capture', async () => {
|
||||
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
page = await context.newPage();
|
||||
const sessionId = await openSession(page);
|
||||
expect(sessionId).toBeTruthy();
|
||||
|
||||
// Exactly once. The cutoff exists so the flush cannot also replay events the
|
||||
// payload already carried, which would double the output rather than heal it.
|
||||
expect(await runLoad(page, sessionId, 'mux-visible')).toBe(1);
|
||||
|
||||
await page.evaluate(
|
||||
(sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
|
||||
sessionId
|
||||
);
|
||||
await context.close();
|
||||
}, 60_000);
|
||||
|
||||
it('stays dropped when the buffer came from the accumulated byte history', async () => {
|
||||
// The byte history already contains everything up to the response, so
|
||||
// replaying the queue on top of it would duplicate the output — most
|
||||
// visibly Ink's cursor-up redraws. The discard has to survive this fix.
|
||||
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
page = await context.newPage();
|
||||
const sessionId = await openSession(page);
|
||||
// Without this, a failed create passes the zero-hit assertion below
|
||||
// vacuously — nothing was loaded, so nothing was replayed.
|
||||
expect(sessionId).toBeTruthy();
|
||||
|
||||
expect(await runLoad(page, sessionId, 'history')).toBe(0);
|
||||
|
||||
await page.evaluate(
|
||||
(sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
|
||||
sessionId
|
||||
);
|
||||
await context.close();
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* `WebServer.discardPartiallyBuiltSession()` against the real server object.
|
||||
*
|
||||
* The reboot-restore route calls this when a rebuild registers a session and
|
||||
* then fails to start its pane. It has to be the exact inverse of
|
||||
* `registerSessionWithLayout()` plus `setupSessionListeners()`, and it must NOT
|
||||
* be the user-initiated delete: banking the session's token totals, demoting a
|
||||
* pinned record or deleting the workspace's files would all be wrong for a
|
||||
* session that never ran.
|
||||
*
|
||||
* These tests drive the real method rather than the route, because the route
|
||||
* tests run against a mock context whose `discardPartiallyBuiltSession` is a
|
||||
* one-line stub — an earlier version of this function left four registrations
|
||||
* behind and every route test still passed.
|
||||
*
|
||||
* The retry assertion is the important one. `setupSessionListeners()` returns
|
||||
* early when `sessionListenerRefs` still holds the session id, so a discard that
|
||||
* leaves that entry makes the next attempt wire nothing at all, and the user
|
||||
* gets a tab that never shows output.
|
||||
*/
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { safeRmHomeTree } from './mocks/test-helpers.js';
|
||||
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { Session } from '../src/session.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
|
||||
/** Reach the private collections the discard is responsible for emptying. */
|
||||
interface ServerInternals {
|
||||
sessions: Map<string, Session>;
|
||||
sessionListenerRefs: Map<string, unknown>;
|
||||
runSummaryTrackers: Map<string, unknown>;
|
||||
registerSessionWithLayout(session: Session): Promise<void>;
|
||||
setupSessionListeners(session: Session): Promise<void>;
|
||||
discardPartiallyBuiltSession(sessionId: string): Promise<void>;
|
||||
}
|
||||
|
||||
const WORKSPACE = join(homedir(), '.codeman-test-discard');
|
||||
const SESSION_ID = 'a1b2c3d4e5f60718';
|
||||
|
||||
let server: WebServer;
|
||||
let internals: ServerInternals;
|
||||
let mux: TmuxManager;
|
||||
|
||||
function buildSession(): Session {
|
||||
return new Session({
|
||||
id: SESSION_ID,
|
||||
workingDir: WORKSPACE,
|
||||
mode: 'claude',
|
||||
name: 'rebuilt session',
|
||||
mux,
|
||||
useMux: true,
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
mkdirSync(WORKSPACE, { recursive: true });
|
||||
// Test mode: no port is opened and no CLI is launched. One server for the file,
|
||||
// stopped at the end: the constructor registers handlers on the module-level
|
||||
// image, subagent, team and workflow watchers, and only stop() removes them.
|
||||
server = new WebServer(0, false, true);
|
||||
internals = server as unknown as ServerInternals;
|
||||
mux = new TmuxManager();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await internals.discardPartiallyBuiltSession(SESSION_ID).catch(() => {});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.stop().catch(() => {});
|
||||
safeRmHomeTree(WORKSPACE);
|
||||
});
|
||||
|
||||
describe('discarding a session whose pane never started', () => {
|
||||
it('takes the session back out of the server', async () => {
|
||||
const session = buildSession();
|
||||
await internals.registerSessionWithLayout(session);
|
||||
await internals.setupSessionListeners(session);
|
||||
expect(internals.sessions.has(SESSION_ID)).toBe(true);
|
||||
|
||||
await internals.discardPartiallyBuiltSession(SESSION_ID);
|
||||
expect(internals.sessions.has(SESSION_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it('releases the listener registration, so a retry can wire itself again', async () => {
|
||||
const first = buildSession();
|
||||
await internals.registerSessionWithLayout(first);
|
||||
await internals.setupSessionListeners(first);
|
||||
expect(internals.sessionListenerRefs.has(SESSION_ID)).toBe(true);
|
||||
|
||||
const firstRefs = internals.sessionListenerRefs.get(SESSION_ID);
|
||||
await internals.discardPartiallyBuiltSession(SESSION_ID);
|
||||
expect(internals.sessionListenerRefs.has(SESSION_ID)).toBe(false);
|
||||
|
||||
// The retry reuses the id by design. `setupSessionListeners()` returns early
|
||||
// while the refs are still there, so a session built now would run blind: no
|
||||
// terminal output, no status updates, no exit broadcast. Asserting a DIFFERENT
|
||||
// refs object is what distinguishes wiring the retry from finding the corpse
|
||||
// of the first attempt still in place.
|
||||
const retry = buildSession();
|
||||
await internals.registerSessionWithLayout(retry);
|
||||
await internals.setupSessionListeners(retry);
|
||||
const retryRefs = internals.sessionListenerRefs.get(SESSION_ID);
|
||||
expect(retryRefs).toBeDefined();
|
||||
expect(retryRefs).not.toBe(firstRefs);
|
||||
});
|
||||
|
||||
it('stops the run-summary tracker, whose interval would otherwise keep firing', async () => {
|
||||
const session = buildSession();
|
||||
await internals.registerSessionWithLayout(session);
|
||||
await internals.setupSessionListeners(session);
|
||||
const tracker = internals.runSummaryTrackers.get(SESSION_ID) as { stop: () => void };
|
||||
expect(tracker).toBeDefined();
|
||||
// Dropping the map entry is not enough: the tracker arms a setInterval in its
|
||||
// constructor, and only stop() clears it, so a discard that merely forgot the
|
||||
// entry would leave the timer running for the life of the process.
|
||||
const stopped = vi.spyOn(tracker, 'stop');
|
||||
|
||||
await internals.discardPartiallyBuiltSession(SESSION_ID);
|
||||
expect(stopped).toHaveBeenCalled();
|
||||
expect(internals.runSummaryTrackers.has(SESSION_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it('does nothing at all for a session it never registered', async () => {
|
||||
await expect(internals.discardPartiallyBuiltSession('never-existed')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -48,13 +48,12 @@ describe('install.sh generated-catalogue block', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('declares every array the detection code indexes', () => {
|
||||
it('declares every array install.sh actually reads', () => {
|
||||
for (const name of [
|
||||
'CLI_IDS',
|
||||
'CLI_LABELS',
|
||||
'CLI_ENABLED',
|
||||
'CLI_KIND',
|
||||
'CLI_NPM',
|
||||
'CLI_LAUNCHER_ONLY',
|
||||
'CLI_DOCS',
|
||||
'CLI_CMD_LINUX',
|
||||
'CLI_CMD_DARWIN',
|
||||
@@ -69,6 +68,17 @@ describe('install.sh generated-catalogue block', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('declares no array install.sh never reads', () => {
|
||||
// CLI_KIND and CLI_NPM were generated and read by nothing (the .mjs/docker-hosts.ts
|
||||
// producers read the JSON's `kind`/`npmPackage` fields directly; only these two bash
|
||||
// arrays were dead). A generated-but-unread array is a maintenance trap the generator
|
||||
// itself cannot warn about — it has no reader to check against — so this pins the
|
||||
// opposite of the test above: naming what must NOT come back rather than what must.
|
||||
for (const name of ['CLI_KIND', 'CLI_NPM']) {
|
||||
expect(new RegExp(`^${name}=\\(`, 'm').test(SOURCE), `${name} is declared but nothing reads it`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps no hand-written per-CLI detection behind', () => {
|
||||
// The nine `*_SEARCH_PATHS` arrays and eighteen `check_<cli>`/`get_<cli>_path` pairs are
|
||||
// what this change removes. One left behind would be a second source of truth that the
|
||||
@@ -93,6 +103,16 @@ describe('install.sh generated-catalogue block', () => {
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps no dead generic-lookup helpers behind', () => {
|
||||
// _cli_index/check_cli/get_cli_path were the ungenericized precursor to the per-CLI
|
||||
// helpers above: same shape, one level of indirection, called from nowhere once the
|
||||
// catalogue-driven menu and hints stopped needing a lookup-by-id. Unlike the per-CLI
|
||||
// pairs these are exact names, not derived from the catalogue.
|
||||
for (const fn of ['_cli_index()', 'check_cli()', 'get_cli_path()']) {
|
||||
expect(CODE.includes(fn), `${fn} should have been removed as dead code`).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('install.sh trust boundary', () => {
|
||||
@@ -245,3 +265,42 @@ describe('install.sh AI CLI install menu', () => {
|
||||
expect(run.status).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install.sh detect_all_clis and a disabled entry', () => {
|
||||
// No stock entry ships disabled today, so this is characterization rather than a regression
|
||||
// pin on real data: it drives the real function in a real bash with entry 0 fabricated
|
||||
// disabled, and points its binary at `bash` — guaranteed resolvable via `command -v` — to
|
||||
// prove the entry is genuinely never PROBED (CLI_FOUND_PATH stays empty) rather than merely
|
||||
// filtered out downstream by every consumer's own `CLI_ENABLED` check.
|
||||
function driveDetect(disableEntry0: boolean) {
|
||||
const driver = `
|
||||
set -euo pipefail
|
||||
export CODEMAN_INSTALL_SH_LIB=1
|
||||
. "$1"
|
||||
k=0; while [[ $k -lt \${#CLI_ALL_BINS[@]} ]]; do CLI_ALL_BINS[$k]="codeman-test-no-such-bin-$k"; k=$((k + 1)); done
|
||||
k=0; while [[ $k -lt \${#CLI_ALL_PATHS[@]} ]]; do CLI_ALL_PATHS[$k]="/nonexistent/codeman-test/$k"; k=$((k + 1)); done
|
||||
# Point entry 0's first declared binary at something that WILL resolve, so a probe that
|
||||
# runs at all finds it.
|
||||
CLI_ALL_BINS[\${CLI_BIN_OFF[0]}]="bash"
|
||||
${disableEntry0 ? 'CLI_ENABLED[0]="0"' : ''}
|
||||
CLI_DETECT_DONE=""
|
||||
detect_all_clis
|
||||
echo "path0=[\${CLI_FOUND_PATH[0]}]"
|
||||
echo "found=$CLI_FOUND_COUNT"
|
||||
`;
|
||||
const result = spawnSync('bash', ['-c', driver, 'bash', INSTALL_SH], { encoding: 'utf-8', timeout: 30_000 });
|
||||
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
||||
}
|
||||
|
||||
it('probes an enabled entry (control case)', () => {
|
||||
const run = driveDetect(false);
|
||||
expect(run.stdout, run.stderr).not.toContain('path0=[]');
|
||||
expect(run.stdout).toContain('found=1');
|
||||
});
|
||||
|
||||
it('never probes a disabled entry', () => {
|
||||
const run = driveDetect(true);
|
||||
expect(run.stdout, run.stderr).toContain('path0=[]');
|
||||
expect(run.stdout).toContain('found=0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* The mock route context must offer everything the real one does.
|
||||
*
|
||||
* Route tests pass their context as `ctx as never`, and `tsconfig.json` includes
|
||||
* only `src/**`, so no type check ever compares the mock against the ports. A
|
||||
* port that gained a method left this mock missing it twice; both times the
|
||||
* route under test threw a TypeError inside its own catch, and the suite
|
||||
* reported a plausible-looking failure for an unrelated reason.
|
||||
*
|
||||
* So the comparison is made at runtime, against `WebServer.createRouteContext()`
|
||||
* rather than against the port types, which is what keeps it from drifting: the
|
||||
* server's own context object is the thing route modules are really given.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { WebServer } from '../../src/web/server.js';
|
||||
import { createMockRouteContext } from './mock-route-context.js';
|
||||
|
||||
describe('the mock route context', () => {
|
||||
it('offers every member the real route context does', () => {
|
||||
const server = new WebServer(0, false, true);
|
||||
const real = (server as unknown as { createRouteContext(): Record<string, unknown> }).createRouteContext();
|
||||
const mock = createMockRouteContext() as unknown as Record<string, unknown>;
|
||||
|
||||
const missing = Object.keys(real).filter((key) => !(key in mock));
|
||||
expect(missing, `mock-route-context.ts is missing: ${missing.join(', ')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,10 @@ export function createMockRouteContext(options?: {
|
||||
setupSessionListeners: vi.fn(async () => {}),
|
||||
persistSessionState: vi.fn(),
|
||||
persistSessionStateNow: vi.fn(),
|
||||
reapplyPersistedSessionState: vi.fn(async () => {}),
|
||||
discardPartiallyBuiltSession: vi.fn(async (id: string) => {
|
||||
sessions.delete(id);
|
||||
}),
|
||||
getSessionStateWithRespawn: vi.fn((s: MockSession) => s.toState()),
|
||||
|
||||
// -- EventPort --
|
||||
@@ -149,6 +153,7 @@ export function createMockRouteContext(options?: {
|
||||
clearRespawnConfig: vi.fn(),
|
||||
updateRespawnConfig: vi.fn(),
|
||||
setHistoryLimit: vi.fn(async () => {}),
|
||||
startStatsCollection: vi.fn(),
|
||||
},
|
||||
runSummaryTrackers: new Map(),
|
||||
activePlanOrchestrators: new Map(),
|
||||
|
||||
@@ -68,6 +68,9 @@ export class MockSession extends EventEmitter {
|
||||
this.lastSubmitAt = Date.now();
|
||||
}
|
||||
|
||||
/** Mirrors Session.trackUserInput (the send-key route feeds it around the write path). */
|
||||
trackUserInput(_data: string): void {}
|
||||
|
||||
private _muxName: string | null = null;
|
||||
|
||||
constructor(id: string = 'mock-session-id') {
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* @fileoverview The decision half of reboot restore, and proof that the existing
|
||||
* recovery construction path can CREATE a resumed pane.
|
||||
*
|
||||
* Three things are under test. `src/reboot-restore.ts` decides whether the
|
||||
* machine rebooted and which dead sessions may be offered back. The plan
|
||||
* registry in `src/web/reboot-restore-registry.ts` holds that offer between the
|
||||
* boot that builds it and the click that spends it. The third is the claim the
|
||||
* whole feature rests on: a `Session` built the way `restoreMuxSessions()`
|
||||
* already builds one, but given no `muxSession` and a `resumeSessionId`, creates
|
||||
* a fresh pane that resumes the old conversation. If that holds, the restore
|
||||
* needs no new session-creation service.
|
||||
*
|
||||
* `reconcileSessions()` reports every session ALIVE under vitest, so the
|
||||
* server's own boot pass cannot be reached from here. The decision logic is
|
||||
* therefore driven directly, and the construction claim is driven through a real
|
||||
* `Session` against the in-memory tmux layer vitest substitutes.
|
||||
*/
|
||||
import { mkdirSync, rmSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { Session } from '../src/session.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import type { SessionState } from '../src/types.js';
|
||||
import {
|
||||
looksLikeHostReboot,
|
||||
newestPersistedActivity,
|
||||
planRebootRestore,
|
||||
rejectAlreadyLive,
|
||||
resolveResumeConversationId,
|
||||
type RebootRestoreEntry,
|
||||
} from '../src/reboot-restore.js';
|
||||
import { RebootRestoreRegistry } from '../src/web/reboot-restore-registry.js';
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const NOW = 1_760_000_000_000;
|
||||
|
||||
function persistedSession(overrides: Partial<SessionState> & { id: string }): SessionState {
|
||||
return {
|
||||
// A live agent's record carries its process id; `/exit` persists null instead.
|
||||
pid: 99999,
|
||||
status: 'idle',
|
||||
workingDir: '/tmp/spike',
|
||||
currentTaskId: null,
|
||||
createdAt: NOW - 4 * HOUR,
|
||||
lastActivityAt: NOW - 2 * HOUR,
|
||||
mode: 'claude',
|
||||
...overrides,
|
||||
} as SessionState;
|
||||
}
|
||||
|
||||
describe('reboot detection', () => {
|
||||
const base = {
|
||||
livePaneCount: 0,
|
||||
deadSessionCount: 2,
|
||||
// The host came up 10 minutes ago, well after the sessions were last active.
|
||||
uptimeSeconds: 600,
|
||||
newestPersistedActivityAt: NOW - 2 * HOUR,
|
||||
now: NOW,
|
||||
};
|
||||
|
||||
it('calls it a reboot when the socket is empty and the host booted after the last activity', () => {
|
||||
expect(looksLikeHostReboot(base)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses when some panes survived, which is an ordinary server restart', () => {
|
||||
expect(looksLikeHostReboot({ ...base, livePaneCount: 3 })).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses on a long-uptime host, where someone wiped the tmux socket by hand', () => {
|
||||
// Up for 30 days: the sessions were active long AFTER this boot, so the panes
|
||||
// went away for some reason other than the machine restarting.
|
||||
expect(looksLikeHostReboot({ ...base, uptimeSeconds: 30 * 24 * 60 * 60 })).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses when nothing died', () => {
|
||||
expect(looksLikeHostReboot({ ...base, deadSessionCount: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the newest activity stamp across the persisted records', () => {
|
||||
const persisted = {
|
||||
a: persistedSession({ id: 'a', lastActivityAt: NOW - 5 * HOUR }),
|
||||
b: persistedSession({ id: 'b', lastActivityAt: NOW - 1 * HOUR }),
|
||||
};
|
||||
expect(newestPersistedActivity(persisted)).toBe(NOW - 1 * HOUR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('which dead sessions may be rebuilt', () => {
|
||||
it('rebuilds a session that was simply running when the power went out', () => {
|
||||
const persisted = { live: persistedSession({ id: 'live', status: 'busy' }) };
|
||||
const plan = planRebootRestore(['live'], persisted, () => true);
|
||||
expect(plan.restore.map((s) => s.sessionId)).toEqual(['live']);
|
||||
});
|
||||
|
||||
it('never revives a session the user killed while pinned (COD-142 demotes it to stopped)', () => {
|
||||
const persisted = { killed: persistedSession({ id: 'killed', status: 'stopped', pinned: true }) };
|
||||
const plan = planRebootRestore(['killed'], persisted, () => true);
|
||||
expect(plan.restore).toEqual([]);
|
||||
expect(plan.skipped).toEqual([{ sessionId: 'killed', reason: 'intentionally-ended' }]);
|
||||
});
|
||||
|
||||
it('never revives a session whose record an unpinned kill already deleted', () => {
|
||||
const plan = planRebootRestore(['gone'], {}, () => true);
|
||||
expect(plan.restore).toEqual([]);
|
||||
expect(plan.skipped).toEqual([{ sessionId: 'gone', reason: 'no-persisted-record' }]);
|
||||
});
|
||||
|
||||
it('never revives a pane whose PTY-exit breaker had tripped', () => {
|
||||
const persisted = { crashy: persistedSession({ id: 'crashy', respawnBlocked: true }) };
|
||||
expect(planRebootRestore(['crashy'], persisted, () => true).skipped[0].reason).toBe('respawn-blocked');
|
||||
});
|
||||
|
||||
it('leaves remote sessions to the COD-108 reconnect watcher', () => {
|
||||
const persisted = {
|
||||
r: persistedSession({
|
||||
id: 'r',
|
||||
remote: { hostId: 'h', host: 'example.test', username: 'u', sessionName: 'n', owned: true },
|
||||
} as Partial<SessionState> & { id: string }),
|
||||
};
|
||||
expect(planRebootRestore(['r'], persisted, () => true).skipped[0].reason).toBe('remote-or-docker');
|
||||
});
|
||||
|
||||
it('leaves docker sessions alone, since the container may not be up', () => {
|
||||
const persisted = {
|
||||
d: persistedSession({ id: 'd', docker: { containerId: 'abc', caseId: 'c' } } as Partial<SessionState> & {
|
||||
id: string;
|
||||
}),
|
||||
};
|
||||
expect(planRebootRestore(['d'], persisted, () => true).skipped[0].reason).toBe('remote-or-docker');
|
||||
});
|
||||
|
||||
it('skips a CLI whose history the claude transcript reader does not understand', () => {
|
||||
const persisted = { c: persistedSession({ id: 'c', mode: 'codex' }) };
|
||||
expect(planRebootRestore(['c'], persisted, () => true).skipped[0].reason).toBe('unsupported-mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a session with no attach process in its record', () => {
|
||||
it('is refused, because there was nothing running to bring back', () => {
|
||||
// A session that never started, or whose pane died outright. NOT a session
|
||||
// the user ended with `/exit`: that keeps its pid, because the pid is the
|
||||
// tmux attach process and `remain-on-exit` keeps the pane alive.
|
||||
const persisted = { exited: persistedSession({ id: 'exited', status: 'idle', pid: null }) };
|
||||
const plan = planRebootRestore(['exited'], persisted, () => true);
|
||||
expect(plan.restore).toEqual([]);
|
||||
expect(plan.skipped).toEqual([{ sessionId: 'exited', reason: 'not-running' }]);
|
||||
});
|
||||
|
||||
it('still restores the session beside it that was attached when the power went', () => {
|
||||
const persisted = {
|
||||
exited: persistedSession({ id: 'exited', pid: null }),
|
||||
running: persistedSession({ id: 'running', pid: 4242 }),
|
||||
};
|
||||
const plan = planRebootRestore(['exited', 'running'], persisted, () => true);
|
||||
expect(plan.restore.map((entry) => entry.sessionId)).toEqual(['running']);
|
||||
expect(plan.skipped.map((s) => s.reason)).toEqual(['not-running']);
|
||||
});
|
||||
|
||||
it('refuses a record with no pid field at all', () => {
|
||||
const persisted = { odd: persistedSession({ id: 'odd', pid: undefined as unknown as null }) };
|
||||
expect(planRebootRestore(['odd'], persisted, () => true).skipped[0].reason).toBe('not-running');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a workspace that is no longer on disk', () => {
|
||||
it('is kept out of the offer, so a click cannot scaffold a deleted repo', () => {
|
||||
const persisted = { gone: persistedSession({ id: 'gone', workingDir: '/tmp/deleted-repo' }) };
|
||||
const plan = planRebootRestore(['gone'], persisted, () => false);
|
||||
expect(plan.restore).toEqual([]);
|
||||
expect(plan.skipped).toEqual([{ sessionId: 'gone', reason: 'workspace-missing' }]);
|
||||
});
|
||||
|
||||
it('is judged per session, not for the batch', () => {
|
||||
const persisted = {
|
||||
kept: persistedSession({ id: 'kept', workingDir: '/tmp/still-here' }),
|
||||
gone: persistedSession({ id: 'gone', workingDir: '/tmp/deleted-repo' }),
|
||||
};
|
||||
const plan = planRebootRestore(['kept', 'gone'], persisted, (dir) => dir === '/tmp/still-here');
|
||||
expect(plan.restore.map((entry) => entry.sessionId)).toEqual(['kept']);
|
||||
expect(plan.skipped.map((s) => s.reason)).toEqual(['workspace-missing']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a conversation that came back on its own before the click', () => {
|
||||
const entry: RebootRestoreEntry = {
|
||||
sessionId: 'abc',
|
||||
workingDir: '/tmp/spike',
|
||||
mode: 'claude',
|
||||
resumeConversationId: 'conv-1',
|
||||
state: persistedSession({ id: 'abc' }),
|
||||
};
|
||||
|
||||
it('is skipped when the user resumed it by hand from the Resume list', () => {
|
||||
// Same conversation, different session id: the Resume list creates a NEW id.
|
||||
const result = rejectAlreadyLive([entry], new Set(['other']), new Set(['conv-1']));
|
||||
expect(result.restore).toEqual([]);
|
||||
expect(result.skipped).toEqual([{ sessionId: 'abc', reason: 'already-live' }]);
|
||||
});
|
||||
|
||||
it('is skipped when a session with that id is already on the board', () => {
|
||||
const result = rejectAlreadyLive([entry], new Set(['abc']), new Set());
|
||||
expect(result.skipped).toEqual([{ sessionId: 'abc', reason: 'already-live' }]);
|
||||
});
|
||||
|
||||
it('is rebuilt when neither its id nor its conversation is live', () => {
|
||||
const result = rejectAlreadyLive([entry], new Set(['other']), new Set(['conv-other']));
|
||||
expect(result.restore.map((e) => e.sessionId)).toEqual(['abc']);
|
||||
expect(result.skipped).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the plan the banner spends', () => {
|
||||
const all = () => true;
|
||||
const entryFor = (sessionId: string, owner?: string): RebootRestoreEntry => ({
|
||||
sessionId,
|
||||
owner,
|
||||
workingDir: '/tmp/spike',
|
||||
mode: 'claude',
|
||||
resumeConversationId: `conv-${sessionId}`,
|
||||
state: persistedSession({ id: sessionId, owner }),
|
||||
});
|
||||
|
||||
it('hands an entry to the first caller and nothing to the second', () => {
|
||||
const registry = new RebootRestoreRegistry();
|
||||
registry.set([entryFor('a'), entryFor('b')]);
|
||||
expect(registry.take(all, undefined, undefined).map((e) => e.sessionId)).toEqual(['a', 'b']);
|
||||
// The double-click: two panes on one conversation is what this prevents.
|
||||
expect(registry.take(all, undefined, undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('spends only the ids a caller asked for', () => {
|
||||
const registry = new RebootRestoreRegistry();
|
||||
registry.set([entryFor('a'), entryFor('b')]);
|
||||
expect(registry.take(all, ['b'], undefined).map((e) => e.sessionId)).toEqual(['b']);
|
||||
expect(registry.list(all).map((e) => e.sessionId)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it("shows a user their own sessions and leaves another owner's alone", () => {
|
||||
const registry = new RebootRestoreRegistry();
|
||||
registry.set([entryFor('mine', 'alice'), entryFor('theirs', 'bob')]);
|
||||
const asAlice = (owner: string | undefined) => owner === 'alice';
|
||||
expect(registry.list(asAlice).map((e) => e.sessionId)).toEqual(['mine']);
|
||||
expect(registry.take(asAlice, undefined, 'alice').map((e) => e.sessionId)).toEqual(['mine']);
|
||||
// Bob's entry is still on offer for Bob.
|
||||
expect(registry.list(() => true).map((e) => e.sessionId)).toEqual(['theirs']);
|
||||
});
|
||||
|
||||
it('puts back an entry that no pane was created for', () => {
|
||||
const registry = new RebootRestoreRegistry();
|
||||
registry.set([entryFor('a')]);
|
||||
const taken = registry.take(all, undefined, undefined);
|
||||
registry.releaseFlight(undefined, taken);
|
||||
expect(registry.list(all).map((e) => e.sessionId)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('runs one restore at a time', () => {
|
||||
const registry = new RebootRestoreRegistry();
|
||||
expect(registry.beginSpending()).toBe(true);
|
||||
expect(registry.beginSpending()).toBe(false);
|
||||
registry.endSpending();
|
||||
expect(registry.beginSpending()).toBe(true);
|
||||
});
|
||||
|
||||
it('drops what a dismiss cleared', () => {
|
||||
const registry = new RebootRestoreRegistry();
|
||||
registry.set([entryFor('a'), entryFor('b')]);
|
||||
expect(registry.clear(all)).toBe(2);
|
||||
expect(registry.list(all)).toEqual([]);
|
||||
});
|
||||
|
||||
it('forgets a plan nobody took for a day', () => {
|
||||
const registry = new RebootRestoreRegistry();
|
||||
registry.set([entryFor('a')]);
|
||||
const dayLater = Date.now() + 25 * HOUR;
|
||||
const realNow = Date.now;
|
||||
Date.now = () => dayLater;
|
||||
try {
|
||||
expect(registry.list(all)).toEqual([]);
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('which conversation a rebuilt pane resumes', () => {
|
||||
it('prefers the chain tail, the conversation the CLI reported last', () => {
|
||||
const state = persistedSession({
|
||||
id: 'sess-1',
|
||||
resumeSessionId: 'launch-id',
|
||||
claudeSessionChain: ['launch-id', 'after-clear'],
|
||||
});
|
||||
expect(resolveResumeConversationId(state)).toBe('after-clear');
|
||||
});
|
||||
|
||||
it('falls back to the id the session originally resumed', () => {
|
||||
const state = persistedSession({ id: 'sess-1', resumeSessionId: 'resumed-id' });
|
||||
expect(resolveResumeConversationId(state)).toBe('resumed-id');
|
||||
});
|
||||
|
||||
it('falls back to the session id, which is what Claude was launched with', () => {
|
||||
expect(resolveResumeConversationId(persistedSession({ id: 'sess-1' }))).toBe('sess-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the recovery construction path can create a resumed pane', () => {
|
||||
const workingDir = join(homedir(), 'codeman-cases', 'reboot-restore-spike');
|
||||
const sessions: Session[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const s of sessions.splice(0)) s.stop();
|
||||
rmSync(workingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Built exactly as the reboot pass builds one: no `muxSession`, plus a resume id. */
|
||||
function rebuildFromPersistedState(state: SessionState, mux: TmuxManager): Session {
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
const session = new Session({
|
||||
id: state.id,
|
||||
workingDir,
|
||||
mode: state.mode,
|
||||
name: state.name,
|
||||
createdAt: state.createdAt,
|
||||
mux,
|
||||
useMux: true,
|
||||
resumeSessionId: resolveResumeConversationId(state),
|
||||
owner: state.owner,
|
||||
lastActivityAt: state.lastActivityAt,
|
||||
claudeSessionChain: state.claudeSessionChain,
|
||||
});
|
||||
sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
it('creates a NEW mux session rather than needing one to attach to', async () => {
|
||||
const mux = new TmuxManager();
|
||||
const state = persistedSession({ id: 'aaaaaaa1-1111-4111-8111-111111111111', name: 'w1-spike' });
|
||||
const session = rebuildFromPersistedState(state, mux);
|
||||
|
||||
expect(mux.getSessions()).toHaveLength(0);
|
||||
await session.startInteractive();
|
||||
|
||||
const created = mux.getSessions();
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0].sessionId).toBe('aaaaaaa1-1111-4111-8111-111111111111');
|
||||
expect(created[0].workingDir).toBe(workingDir);
|
||||
});
|
||||
|
||||
it('comes back pointed at the conversation the pane was holding', async () => {
|
||||
const mux = new TmuxManager();
|
||||
const state = persistedSession({
|
||||
id: 'aaaaaaa2-2222-4222-8222-222222222222',
|
||||
resumeSessionId: 'launch-id',
|
||||
claudeSessionChain: ['launch-id', 'after-clear'],
|
||||
});
|
||||
const session = rebuildFromPersistedState(state, mux);
|
||||
|
||||
await session.startInteractive();
|
||||
|
||||
// The chain tail wins: a `/clear` before the reboot moved the CLI off the launch id.
|
||||
expect(session.claudeSessionId).toBe('after-clear');
|
||||
});
|
||||
|
||||
it('comes back idle, with no prompt sent and no autonomous loop armed', async () => {
|
||||
const mux = new TmuxManager();
|
||||
const state = persistedSession({
|
||||
id: 'aaaaaaa3-3333-4333-8333-333333333333',
|
||||
ralphEnabled: true,
|
||||
respawnEnabled: true,
|
||||
});
|
||||
const session = rebuildFromPersistedState(state, mux);
|
||||
|
||||
await session.startInteractive();
|
||||
|
||||
// No prompt was queued: nothing is waiting on a task. The status itself is not
|
||||
// assertable here, because the test PTY echoes and the activity detector reads
|
||||
// that echo as work; in production the pane settles once the CLI finishes booting.
|
||||
expect(session.currentTaskId).toBeNull();
|
||||
// The pass never touches the tracker, so a persisted Ralph loop stays cold.
|
||||
expect(session.ralphTracker.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the owner it was persisted with, there being no request to read one from', async () => {
|
||||
const mux = new TmuxManager();
|
||||
const state = persistedSession({ id: 'aaaaaaa4-4444-4444-8444-444444444444', owner: 'alice' });
|
||||
const session = rebuildFromPersistedState(state, mux);
|
||||
|
||||
await session.startInteractive();
|
||||
|
||||
expect(session.owner).toBe('alice');
|
||||
expect(mux.getSessions()[0].owner).toBe('alice');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* Reboot-restore route: what happens when a rebuild gets part-way and then fails.
|
||||
*
|
||||
* The other route test file deliberately uses workspaces that do not exist, so it
|
||||
* never reaches `new Session()`. This one mocks the `Session` module so the route
|
||||
* runs its whole construction path — `addSession`, `setupSessionListeners`,
|
||||
* `reapplyPersistedSessionState`, `startInteractive` — and then throws.
|
||||
*
|
||||
* The mock is the only way in. Driven against a real server, `startInteractive()`
|
||||
* does not throw for either obvious cause: the CLI resolver finds its binary by
|
||||
* absolute path rather than through PATH, and tmux falls back to another
|
||||
* directory rather than failing when it cannot enter the workspace. A mux-layer
|
||||
* failure is what is left, and it cannot be provoked from a test. Without the
|
||||
* mock this path would go unexercised, which is how the original version of this
|
||||
* route shipped a session leak the tests could not see.
|
||||
*
|
||||
* It also covers the session caps, because those too are only reachable once the
|
||||
* route is actually willing to build something.
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
|
||||
/** Set per test: whether the mocked `startInteractive()` rejects. */
|
||||
let startShouldThrow = false;
|
||||
/** Ordering log, so a test can assert what ran before the pane spawned. */
|
||||
const callOrder: string[] = [];
|
||||
|
||||
vi.mock('../../src/session.js', () => ({
|
||||
Session: class {
|
||||
id: string;
|
||||
mode: string;
|
||||
name?: string;
|
||||
workingDir: string;
|
||||
owner?: string;
|
||||
claudeSessionId: string | null = null;
|
||||
constructor(config: { id: string; mode?: string; name?: string; workingDir: string; owner?: string }) {
|
||||
this.id = config.id;
|
||||
this.mode = config.mode ?? 'claude';
|
||||
this.name = config.name;
|
||||
this.workingDir = config.workingDir;
|
||||
this.owner = config.owner;
|
||||
}
|
||||
async startInteractive() {
|
||||
callOrder.push('startInteractive');
|
||||
if (startShouldThrow) throw new Error('spawn claude ENOENT');
|
||||
}
|
||||
/** The mock route context projects a session through this on broadcast. */
|
||||
toState() {
|
||||
return { id: this.id, mode: this.mode, name: this.name, workingDir: this.workingDir, owner: this.owner };
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const { registerRebootRestoreRoutes } = await import('../../src/web/routes/reboot-restore-routes.js');
|
||||
const { rebootRestoreRegistry } = await import('../../src/web/reboot-restore-registry.js');
|
||||
const { installRouteErrorHandler } = await import('../../src/web/route-error-handler.js');
|
||||
const { httpStatusForErrorCode } = await import('../../src/types.js');
|
||||
const { createMockRouteContext } = await import('../mocks/index.js');
|
||||
type ApiErrorCode = import('../../src/types.js').ApiErrorCode;
|
||||
type RebootRestoreEntry = import('../../src/reboot-restore.js').RebootRestoreEntry;
|
||||
type SessionState = import('../../src/types.js').SessionState;
|
||||
|
||||
/** A real directory, so the route's workspace checks pass and it reaches the build. */
|
||||
const WORKSPACE = process.cwd();
|
||||
|
||||
function offerEntry(sessionId: string, owner?: string): RebootRestoreEntry {
|
||||
return {
|
||||
sessionId,
|
||||
name: `session ${sessionId}`,
|
||||
workingDir: WORKSPACE,
|
||||
owner,
|
||||
mode: 'claude',
|
||||
resumeConversationId: `conv-${sessionId}`,
|
||||
state: {
|
||||
id: sessionId,
|
||||
pid: null,
|
||||
status: 'idle',
|
||||
workingDir: WORKSPACE,
|
||||
currentTaskId: null,
|
||||
createdAt: 1_760_000_000_000,
|
||||
mode: 'claude',
|
||||
owner,
|
||||
} as SessionState,
|
||||
};
|
||||
}
|
||||
|
||||
async function createHarness(ctx: ReturnType<typeof createMockRouteContext>): Promise<FastifyInstance> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
registerRebootRestoreRoutes(app, ctx as never);
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
startShouldThrow = false;
|
||||
callOrder.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rebootRestoreRegistry.reset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('a rebuild that fails after the session is registered', () => {
|
||||
it('reports why it failed rather than blaming the workspace', async () => {
|
||||
startShouldThrow = true;
|
||||
rebootRestoreRegistry.set([offerEntry('a')]);
|
||||
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
|
||||
const app = await createHarness(ctx);
|
||||
|
||||
const res = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
|
||||
expect(res.restored).toEqual([]);
|
||||
// Not `workspace-missing`: the directory is there, the agent would not start.
|
||||
expect(res.skipped).toEqual([{ sessionId: 'a', reason: 'rebuild-failed' }]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('does not leave a registered session with no pane behind it', async () => {
|
||||
startShouldThrow = true;
|
||||
rebootRestoreRegistry.set([offerEntry('a')]);
|
||||
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
|
||||
const app = await createHarness(ctx);
|
||||
|
||||
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
|
||||
// The session reached ctx.sessions via addSession; the route has to take it
|
||||
// back out, or the board shows a tab whose pane never existed.
|
||||
expect(ctx.discardPartiallyBuiltSession).toHaveBeenCalledWith('a');
|
||||
expect(ctx.sessions.has('a')).toBe(false);
|
||||
// NOT the user-initiated delete: that would bank this session's historical
|
||||
// tokens into the lifetime totals, demote a pinned record to `stopped`, and
|
||||
// delete the workspace's .claude-images.
|
||||
expect(ctx.cleanupSession).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('keeps the entry on offer, so the user can fix the PATH and click again', async () => {
|
||||
startShouldThrow = true;
|
||||
rebootRestoreRegistry.set([offerEntry('a')]);
|
||||
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
|
||||
const app = await createHarness(ctx);
|
||||
|
||||
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
|
||||
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(left.sessions.map((s: { id: string }) => s.id)).toEqual(['a']);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a rebuild that succeeds', () => {
|
||||
it('re-applies the persisted state before the record is written again', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a')]);
|
||||
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
|
||||
const app = await createHarness(ctx);
|
||||
|
||||
const res = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
|
||||
expect(res.restored.map((s: { id: string }) => s.id)).toEqual(['a']);
|
||||
// A session built from a record carries none of the pin, token totals or
|
||||
// custom-model selection, so persisting it first would replace the fuller
|
||||
// record with the reduced one.
|
||||
expect(ctx.reapplyPersistedSessionState).toHaveBeenCalled();
|
||||
const reapplyOrder = (ctx.reapplyPersistedSessionState as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0];
|
||||
const persistOrder = (ctx.persistSessionState as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0];
|
||||
expect(reapplyOrder).toBeLessThan(persistOrder);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('shapes the pane before it spawns, and restores the history after', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a')]);
|
||||
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
|
||||
(ctx.reapplyPersistedSessionState as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async (_s: unknown, _saved: unknown, phase: string) => {
|
||||
callOrder.push(`reapply:${phase}`);
|
||||
}
|
||||
);
|
||||
(ctx.setupSessionListeners as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
||||
callOrder.push('setupSessionListeners');
|
||||
});
|
||||
const app = await createHarness(ctx);
|
||||
|
||||
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
|
||||
// `setupSessionListeners()` READS the image-watcher flag that `before-spawn`
|
||||
// restores, so the phase has to precede it or the session comes back
|
||||
// reporting the watcher as on with nothing watching. The custom-model
|
||||
// environment has to reach the process, and the token totals must not land
|
||||
// on a session whose pane never started.
|
||||
expect(callOrder).toEqual([
|
||||
'reapply:before-spawn',
|
||||
'setupSessionListeners',
|
||||
'startInteractive',
|
||||
'reapply:after-spawn',
|
||||
]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('tells every other board about the rebuilt session', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a')]);
|
||||
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
|
||||
const app = await createHarness(ctx);
|
||||
|
||||
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
|
||||
expect(ctx.broadcast).toHaveBeenCalledWith('session:created', expect.anything());
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('spends the entry, so it is no longer on offer', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a')]);
|
||||
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
|
||||
const app = await createHarness(ctx);
|
||||
|
||||
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
|
||||
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(left.sessions).toEqual([]);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the session caps', () => {
|
||||
it('counts the sessions it is itself creating, not just the ones it started with', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
|
||||
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
|
||||
// One seat short of the documented maximum of 50, counting the session the
|
||||
// mock context seeds. A check that ran once before the loop would restore
|
||||
// BOTH entries; only a per-iteration check refuses the second.
|
||||
for (let i = 0; i < 48; i += 1) {
|
||||
ctx.sessions.set(`filler-${i}`, { id: `filler-${i}`, owner: undefined } as never);
|
||||
}
|
||||
const app = await createHarness(ctx);
|
||||
|
||||
const res = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
|
||||
expect(res.restored.map((s: { id: string }) => s.id)).toEqual(['a']);
|
||||
expect(res.skipped).toEqual([{ sessionId: 'b', reason: 'capacity-reached' }]);
|
||||
|
||||
// Refused rather than lost: closing a session and clicking again works.
|
||||
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(left.sessions.map((s: { id: string }) => s.id)).toEqual(['b']);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('refuses every entry when the board is already at the cap', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
|
||||
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
ctx.sessions.set(`filler-${i}`, { id: `filler-${i}`, owner: undefined } as never);
|
||||
}
|
||||
const app = await createHarness(ctx);
|
||||
|
||||
const res = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
|
||||
expect(res.restored).toEqual([]);
|
||||
expect(res.skipped.map((s: { reason: string }) => s.reason)).toEqual(['capacity-reached', 'capacity-reached']);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a failure before any entry is considered', () => {
|
||||
it('returns the whole plan rather than spending it', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
|
||||
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
|
||||
(ctx.getWorkspaceHooksEnabled as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('settings unreadable'));
|
||||
const app = await createHarness(ctx);
|
||||
|
||||
const res = await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(500);
|
||||
|
||||
// The plan cannot be rebuilt once boot has pruned the records, so a throw
|
||||
// anywhere in the route has to hand the entries back.
|
||||
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(left.sessions.map((s: { id: string }) => s.id).sort()).toEqual(['a', 'b']);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('releases the single flight, so the next click is not refused', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a')]);
|
||||
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
|
||||
(ctx.getWorkspaceHooksEnabled as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('settings unreadable'));
|
||||
const app = await createHarness(ctx);
|
||||
|
||||
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
|
||||
expect(rebootRestoreRegistry.beginSpending(undefined)).toBe(true);
|
||||
rebootRestoreRegistry.endSpending(undefined);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a dismiss that lands while a restore is running', () => {
|
||||
it('wins when an admin is restoring the entries and their owner dismisses', async () => {
|
||||
const theirs = offerEntry('theirs', 'bob');
|
||||
rebootRestoreRegistry.set([theirs]);
|
||||
// An admin may spend another user's entries, so the caller doing the restore
|
||||
// and the owner of what is being restored are different people.
|
||||
const taken = rebootRestoreRegistry.take(() => true, undefined, 'admin');
|
||||
expect(taken.map((e) => e.sessionId)).toEqual(['theirs']);
|
||||
|
||||
// Bob dismisses his own banner. Nothing of his is in the plan any more, and
|
||||
// the restore is running under a different name than his.
|
||||
rebootRestoreRegistry.clear((owner) => owner === 'bob');
|
||||
rebootRestoreRegistry.releaseFlight('admin', taken);
|
||||
|
||||
expect(rebootRestoreRegistry.list(() => true)).toEqual([]);
|
||||
});
|
||||
|
||||
it('wins when an admin dismisses everything mid-restore', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('theirs', 'bob')]);
|
||||
const taken = rebootRestoreRegistry.take(() => true, undefined, 'admin');
|
||||
|
||||
rebootRestoreRegistry.clear(() => true);
|
||||
rebootRestoreRegistry.releaseFlight('admin', taken);
|
||||
|
||||
expect(rebootRestoreRegistry.list(() => true)).toEqual([]);
|
||||
});
|
||||
|
||||
it('wins, rather than being undone when the route hands its entries back', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a')]);
|
||||
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
|
||||
// The user clicks Dismiss while the restore is between its take and its
|
||||
// return. Driven through the ROUTE, so removing the generation argument from
|
||||
// the route would make this fail.
|
||||
(ctx.getWorkspaceHooksEnabled as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
||||
rebootRestoreRegistry.clear(() => true);
|
||||
throw new Error('settings unreadable');
|
||||
});
|
||||
const app = await createHarness(ctx);
|
||||
|
||||
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
|
||||
|
||||
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(left.sessions).toEqual([]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('reaches an in-flight restore the dismisser can see, even once its entries are taken', async () => {
|
||||
const mine = offerEntry('mine', 'alice');
|
||||
rebootRestoreRegistry.set([mine]);
|
||||
const taken = rebootRestoreRegistry.take((owner) => owner === 'alice', undefined, 'alice');
|
||||
expect(taken).toHaveLength(1);
|
||||
|
||||
// The plan is empty now, so the dismiss has nothing of Alice's left in the
|
||||
// plan; it has to reach the entry the restore is holding.
|
||||
rebootRestoreRegistry.clear((owner) => owner === 'alice');
|
||||
rebootRestoreRegistry.releaseFlight('alice', taken);
|
||||
|
||||
expect(rebootRestoreRegistry.list(() => true)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not reach another owner, whose unspent entries still come back', async () => {
|
||||
const mine = offerEntry('mine', 'alice');
|
||||
const theirs = offerEntry('theirs', 'bob');
|
||||
rebootRestoreRegistry.set([mine, theirs]);
|
||||
|
||||
// Bob is mid-restore, holding his own entry.
|
||||
const bobsTaken = rebootRestoreRegistry.take((owner) => owner === 'bob', undefined, 'bob');
|
||||
expect(bobsTaken.map((e) => e.sessionId)).toEqual(['theirs']);
|
||||
|
||||
// Alice dismisses her own banner meanwhile.
|
||||
rebootRestoreRegistry.clear((owner) => owner === 'alice');
|
||||
|
||||
// Bob's restore finishes and hands his entry back. Alice's dismiss covered
|
||||
// her entries, not his, so his offer survives.
|
||||
rebootRestoreRegistry.releaseFlight('bob', bobsTaken);
|
||||
expect(rebootRestoreRegistry.list(() => true).map((e) => e.sessionId)).toEqual(['theirs']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Reboot-restore route tests (src/web/routes/reboot-restore-routes.ts) via
|
||||
* app.inject(), no live port.
|
||||
*
|
||||
* Every entry these tests put on offer names a workspace that does not exist, so
|
||||
* the route's click-time workspace check rejects it before any `Session` is
|
||||
* constructed. That keeps the tests on the route's own guards — taking, scoping,
|
||||
* single-flighting and re-checking — and leaves pane creation to
|
||||
* test/reboot-restore.test.ts, which drives a real `Session` for it.
|
||||
*
|
||||
* The routes read the process-wide `rebootRestoreRegistry` singleton, so every
|
||||
* test resets it; a leaked entry would bleed into the next one.
|
||||
*/
|
||||
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { registerRebootRestoreRoutes } from '../../src/web/routes/reboot-restore-routes.js';
|
||||
import { rebootRestoreRegistry } from '../../src/web/reboot-restore-registry.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { httpStatusForErrorCode, type ApiErrorCode } from '../../src/types.js';
|
||||
import { createMockRouteContext } from '../mocks/index.js';
|
||||
import type { RebootRestoreEntry } from '../../src/reboot-restore.js';
|
||||
import type { SessionState } from '../../src/types.js';
|
||||
|
||||
async function createHarness(authUser?: { username: string; role: 'admin' | 'user' }): Promise<FastifyInstance> {
|
||||
return createHarnessWithCtx(createMockRouteContext(), authUser);
|
||||
}
|
||||
|
||||
async function createHarnessWithCtx(
|
||||
ctx: ReturnType<typeof createMockRouteContext>,
|
||||
authUser?: { username: string; role: 'admin' | 'user' }
|
||||
): Promise<FastifyInstance> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
if (authUser) {
|
||||
app.addHook('onRequest', async (req) => {
|
||||
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
|
||||
});
|
||||
}
|
||||
registerRebootRestoreRoutes(app, ctx as never);
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
/** An entry whose workspace is deliberately absent, so no pane is ever created. */
|
||||
function offerEntry(sessionId: string, owner?: string): RebootRestoreEntry {
|
||||
return {
|
||||
sessionId,
|
||||
name: `session ${sessionId}`,
|
||||
workingDir: `/tmp/codeman-reboot-restore-missing/${sessionId}`,
|
||||
owner,
|
||||
mode: 'claude',
|
||||
resumeConversationId: `conv-${sessionId}`,
|
||||
state: {
|
||||
id: sessionId,
|
||||
pid: null,
|
||||
status: 'idle',
|
||||
workingDir: `/tmp/codeman-reboot-restore-missing/${sessionId}`,
|
||||
currentTaskId: null,
|
||||
createdAt: 1_760_000_000_000,
|
||||
mode: 'claude',
|
||||
owner,
|
||||
} as SessionState,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
rebootRestoreRegistry.reset();
|
||||
});
|
||||
|
||||
describe('GET /api/reboot-restore', () => {
|
||||
it('reports nothing when no reboot left anything behind', async () => {
|
||||
const app = await createHarness();
|
||||
const res = await app.inject({ method: 'GET', url: '/api/reboot-restore' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.sessions).toEqual([]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('names what is on offer, and says the scrollback is not coming back', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
|
||||
const app = await createHarness();
|
||||
const body = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(body.sessions.map((s: { id: string }) => s.id)).toEqual(['a', 'b']);
|
||||
expect(body.scrollbackRestored).toBe(false);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('never carries the persisted record itself to the browser', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a', 'alice')]);
|
||||
const app = await createHarness({ username: 'alice', role: 'admin' });
|
||||
const body = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(Object.keys(body.sessions[0]).sort()).toEqual(['id', 'mode', 'name', 'owner', 'workingDir']);
|
||||
expect(body.sessions[0].state).toBeUndefined();
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/reboot-restore/restore', () => {
|
||||
it('reports a workspace that is gone, and keeps offering it in case it comes back', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a')]);
|
||||
const app = await createHarness();
|
||||
|
||||
const first = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
|
||||
expect(first.restored).toEqual([]);
|
||||
expect(first.skipped).toEqual([{ sessionId: 'a', reason: 'workspace-missing' }]);
|
||||
|
||||
// Nothing was built, so the entry goes back: a repo can be restored from a
|
||||
// backup between two clicks, and losing the offer would be unrecoverable.
|
||||
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(left.sessions.map((s: { id: string }) => s.id)).toEqual(['a']);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('never re-offers a conversation that is already open', async () => {
|
||||
const entry = offerEntry('a');
|
||||
rebootRestoreRegistry.set([entry]);
|
||||
const app = await createHarness();
|
||||
const ctx = createMockRouteContext({ sessionId: entry.sessionId });
|
||||
// A session with that id is live, which is what the Resume list would produce.
|
||||
const liveApp = await createHarnessWithCtx(ctx);
|
||||
|
||||
const res = (await liveApp.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
|
||||
expect(res.skipped).toEqual([{ sessionId: 'a', reason: 'already-live' }]);
|
||||
|
||||
// Unlike a missing workspace, this one is dropped: it cannot stop being true.
|
||||
const left = (await liveApp.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(left.sessions).toEqual([]);
|
||||
await liveApp.close();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('spends only the sessions the click named', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
|
||||
const app = await createHarness();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/reboot-restore/restore',
|
||||
payload: { sessionIds: ['b'] },
|
||||
});
|
||||
expect(res.json().data.skipped).toEqual([{ sessionId: 'b', reason: 'workspace-missing' }]);
|
||||
|
||||
// 'a' was never taken, and 'b' came back because no pane was built for it.
|
||||
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(left.sessions.map((s: { id: string }) => s.id).sort()).toEqual(['a', 'b']);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('refuses a body it does not recognise rather than guessing', async () => {
|
||||
const app = await createHarness();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/reboot-restore/restore',
|
||||
payload: { sessionIds: 'not-an-array' },
|
||||
});
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('turns a second concurrent restore away rather than interleaving it', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a')]);
|
||||
// Claimed by a restore already in flight for this same owner (undefined in
|
||||
// single-user mode, which is what the harness runs as).
|
||||
expect(rebootRestoreRegistry.beginSpending(undefined)).toBe(true);
|
||||
const app = await createHarness();
|
||||
const res = await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
|
||||
expect(res.statusCode).toBe(409);
|
||||
rebootRestoreRegistry.endSpending(undefined);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/reboot-restore/restore: multi-user workspace confinement', () => {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
let realDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
saved.CODEMAN_MULTIUSER = process.env.CODEMAN_MULTIUSER;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
// This branch sits AFTER the existsSync check, so the workspace has to be
|
||||
// real for the confinement rule to be the thing that rejects the entry.
|
||||
realDir = mkdtempSync(join(tmpdir(), 'codeman-reboot-restore-real-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (saved.CODEMAN_MULTIUSER === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = saved.CODEMAN_MULTIUSER;
|
||||
rmSync(realDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("refuses a workspace outside the OWNER's case space, and leaves it on offer", async () => {
|
||||
const entry = offerEntry('a', 'alice');
|
||||
entry.workingDir = realDir;
|
||||
(entry.state as { workingDir: string }).workingDir = realDir;
|
||||
rebootRestoreRegistry.set([entry]);
|
||||
|
||||
// An admin does the clicking. The confinement is still resolved against
|
||||
// alice, the entry's OWNER: `isWorkingDirAllowed` waves an admin through, so
|
||||
// reading the caller here would hand an admin the power to rebuild another
|
||||
// user's session anywhere on the box.
|
||||
const app = await createHarness({ username: 'root-user', role: 'admin' });
|
||||
const res = await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.restored).toEqual([]);
|
||||
expect(res.json().data.skipped).toEqual([{ sessionId: 'a', reason: 'workspace-forbidden' }]);
|
||||
|
||||
// A withdrawn grant can be given back, so unlike `already-live` this is not
|
||||
// the permanent kind of refusal and the entry stays claimable.
|
||||
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(left.sessions.map((s: { id: string }) => s.id)).toEqual(['a']);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/reboot-restore/dismiss', () => {
|
||||
it('drops the offer and leaves the banner with nothing to show', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
|
||||
const app = await createHarness();
|
||||
|
||||
const res = await app.inject({ method: 'POST', url: '/api/reboot-restore/dismiss', payload: {} });
|
||||
expect(res.json().data.dismissed).toBe(2);
|
||||
|
||||
const after = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
expect(after.sessions).toEqual([]);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @fileoverview PUT /api/sessions/:id/name hands the name to the user (#376).
|
||||
*
|
||||
* A rename flips `nameSource` to `manual`, persists it and broadcasts it, so
|
||||
* auto-naming can never overwrite a name a person chose, on this server or
|
||||
* on the one that restores the session after a restart.
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { Session } from '../../src/session.js';
|
||||
import { SseEvent } from '../../src/web/sse-events.js';
|
||||
|
||||
describe('PUT /api/sessions/:id/name', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let session: Session;
|
||||
const updateSessionName = vi.fn(() => true);
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createRouteTestHarness(registerSessionRoutes);
|
||||
// A REAL session, since the ownership flag lives on the class, not the mock.
|
||||
session = new Session({ id: 'name-route-test', workingDir: '/tmp', name: 'w1-demo' });
|
||||
harness.ctx.sessions.set(session.id, session as never);
|
||||
(harness.ctx.mux as Record<string, unknown>).updateSessionName = updateSessionName;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
it('flips a placeholder to manual, then persists and broadcasts the ownership', async () => {
|
||||
expect(session.nameSource).toBe('placeholder');
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${session.id}/name`,
|
||||
payload: { name: 'my window' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
// The harness registers the bare route; the {success,data} envelope is a server-level hook.
|
||||
expect(res.json()).toMatchObject({ name: 'my window' });
|
||||
expect(session.name).toBe('my window');
|
||||
expect(session.nameSource).toBe('manual');
|
||||
expect(session.applyAutoName('w1-demo: fix it')).toBe(false);
|
||||
expect(session.name).toBe('my window');
|
||||
|
||||
expect(updateSessionName).toHaveBeenCalledWith(session.id, 'my window');
|
||||
expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(session);
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
|
||||
SseEvent.SessionUpdated,
|
||||
expect.objectContaining({ id: session.id, name: 'my window', nameSource: 'manual' })
|
||||
);
|
||||
// What the restore path will read back: the persisted state carries the flag.
|
||||
expect(session.toState().nameSource).toBe('manual');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* @fileoverview Auto-naming a session after its first prompt (#376).
|
||||
*
|
||||
* The tracker sits on the raw keystroke stream, so most of these pin the
|
||||
* per-key rules that a review of the first cut found missing: a bare Esc ate
|
||||
* the next prompt's first character, a wheel report mid-word dropped half the
|
||||
* prompt, pasted newlines counted as Enter, and every prompt renamed the tab.
|
||||
*
|
||||
* Port: N/A (no server needed)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import {
|
||||
SubmittedPromptTracker,
|
||||
deriveAutoSessionName,
|
||||
composeAutoSessionName,
|
||||
isGeneratedSessionName,
|
||||
} from '../src/session-auto-name.js';
|
||||
|
||||
describe('SubmittedPromptTracker', () => {
|
||||
it('reports the draft on Enter across arbitrary chunks, honouring backspace', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
expect(tracker.feed('fix the')).toEqual([]);
|
||||
expect(tracker.feed(' login bugs\x7f')).toEqual([]);
|
||||
expect(tracker.feed('\r')).toEqual(['fix the login bug']);
|
||||
expect(tracker.feed('\r')).toEqual([]);
|
||||
expect(tracker.feed('修复登录跳转\x08问题\r')).toEqual(['修复登录跳问题']);
|
||||
});
|
||||
|
||||
it('treats a bare Esc as the Esc key, not the start of a sequence', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
tracker.feed('\x1b');
|
||||
expect(tracker.feed('fix the login bug\r')).toEqual(['fix the login bug']);
|
||||
tracker.feed('\x1b');
|
||||
expect(tracker.feed('修复登录\r')).toEqual(['修复登录']);
|
||||
// Esc then digits and punctuation used to grow the escape buffer without bound.
|
||||
tracker.feed('\x1b');
|
||||
expect(tracker.feed('12345, ok?\r')).toEqual(['12345, ok?']);
|
||||
// A double Esc is two Esc keys, each its own write (in ONE chunk, `ESC s`
|
||||
// is Alt+s by the terminal's own encoding and stays swallowed).
|
||||
tracker.feed('\x1b');
|
||||
tracker.feed('\x1b');
|
||||
expect(tracker.feed('still here\r')).toEqual(['still here']);
|
||||
});
|
||||
|
||||
it('swallows Alt chords and turns Alt+Enter into a newline in the draft', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
expect(tracker.feed('fix\x1bb the\x1b\rbug\r')).toEqual(['fix the bug']);
|
||||
});
|
||||
|
||||
it('ignores cursor keys, mouse and focus reports, Shift+Tab and Tab', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
expect(tracker.feed('fix the \x1b[<64;10;5M\x1b[<65;10;5mlogin bug\r')).toEqual(['fix the login bug']);
|
||||
expect(tracker.feed('look at @src/ses\tsion.ts and fix it\r')).toEqual(['look at @src/session.ts and fix it']);
|
||||
expect(tracker.feed('typo\x1b[D\x1b[C\x1b[H\x1b[F\x1b[3~\x1b[Z\x1b[I\x1b[O\x1bOC fixed\r')).toEqual(['typo fixed']);
|
||||
expect(tracker.feed('mod\x1b[1;5D\x1b[1;2Cifiers\r')).toEqual(['modifiers']);
|
||||
});
|
||||
|
||||
it('taints the draft on history recall so Enter submits nothing rather than a fragment', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
expect(tracker.feed('old text\x1b[A and more\r')).toEqual([]);
|
||||
expect(tracker.feed('\x1bOB\r')).toEqual([]);
|
||||
expect(tracker.feed('\x1b[1;5A\r')).toEqual([]);
|
||||
expect(tracker.feed('\x10x\r')).toEqual([]);
|
||||
expect(tracker.feed('\x12search\r')).toEqual([]);
|
||||
expect(tracker.feed('fresh prompt\r')).toEqual(['fresh prompt']);
|
||||
// Ctrl+C empties the composer, which also clears the taint.
|
||||
expect(tracker.feed('stale\x1b[A\x03typed after\r')).toEqual(['typed after']);
|
||||
});
|
||||
|
||||
it('keeps bracketed-paste newlines inside the draft', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
expect(tracker.feed('\x1b[200~line one\nline two\r\nline three\x1b[201~ plus typed\r')).toEqual([
|
||||
'line one line two line three plus typed',
|
||||
]);
|
||||
// A paste split across chunks stays a paste.
|
||||
tracker.feed('\x1b[200~first\r');
|
||||
expect(tracker.feed('second\x1b[201~\r')).toEqual(['first second']);
|
||||
});
|
||||
|
||||
it('joins a Shift+Enter / Ctrl+J newline with a space', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
tracker.feed('Fix the login bug');
|
||||
tracker.feed('\n');
|
||||
expect(tracker.feed('Also add tests.\r')).toEqual(['Fix the login bug Also add tests.']);
|
||||
});
|
||||
|
||||
it('mirrors Ctrl+W, Ctrl+U and Ctrl+C', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
expect(tracker.feed('fix the bugs\x17bug\r')).toEqual(['fix the bug']);
|
||||
expect(tracker.feed('discarded\x15kept\r')).toEqual(['kept']);
|
||||
expect(tracker.feed('discarded\x03kept\r')).toEqual(['kept']);
|
||||
});
|
||||
|
||||
it('keeps the HEAD of an over-long draft', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
const [prompt] = tracker.feed(`${'a'.repeat(9000)}\r`);
|
||||
expect(prompt).toHaveLength(8192);
|
||||
// Backspaces past the cap consume the overflow before the kept text.
|
||||
const [again] = tracker.feed(`${'b'.repeat(8200)}${'\x7f'.repeat(10)}\r`);
|
||||
expect(again).toHaveLength(8190);
|
||||
});
|
||||
|
||||
it('abandons a malformed escape without eating the text, and taints on an over-long one', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
expect(tracker.feed('\x1b[修复\r')).toEqual(['修复']);
|
||||
expect(tracker.feed('\x1b]0;window title\x07hello\r')).toEqual(['hello']);
|
||||
// Nothing a terminal sends runs past 64 bytes; the tail is garbage, not a title.
|
||||
expect(tracker.feed(`\x1b]${'x'.repeat(80)}after\r`)).toEqual([]);
|
||||
expect(tracker.feed('next prompt\r')).toEqual(['next prompt']);
|
||||
});
|
||||
|
||||
it('resumes a CSI split across chunks', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
tracker.feed('abc\x1b[');
|
||||
expect(tracker.feed('Ddef\r')).toEqual(['abcdef']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveAutoSessionName', () => {
|
||||
it('takes the first sentence, drops the full stop, and bounds the length', () => {
|
||||
expect(deriveAutoSessionName('Fix the login bug. Also add tests.')).toBe('Fix the login bug');
|
||||
expect(deriveAutoSessionName(' 修复登录跳转问题。\n不要改数据库')).toBe('修复登录跳转问题');
|
||||
expect(deriveAutoSessionName('Why does this crash? It worked before')).toBe('Why does this crash?');
|
||||
expect(deriveAutoSessionName('Run v2.0 tests. Then deploy')).toBe('Run v2.0 tests');
|
||||
expect(Array.from(deriveAutoSessionName('a'.repeat(200)) ?? '')).toHaveLength(72);
|
||||
const cut = deriveAutoSessionName('word '.repeat(40).trim()) ?? '';
|
||||
expect(cut.endsWith('…')).toBe(true);
|
||||
expect(cut).toMatch(/^(word )+word…$/);
|
||||
});
|
||||
|
||||
it('does not cut on an abbreviation early in the prompt', () => {
|
||||
expect(deriveAutoSessionName('e.g. fix this now')).toBe('e.g. fix this now');
|
||||
expect(deriveAutoSessionName('Ok. Fix the login bug')).toBe('Ok. Fix the login bug');
|
||||
});
|
||||
|
||||
it('returns null for commands and empties, but not for paths', () => {
|
||||
expect(deriveAutoSessionName('/clear')).toBeNull();
|
||||
expect(deriveAutoSessionName('/model opus')).toBeNull();
|
||||
expect(deriveAutoSessionName('/ralph-loop:ralph-loop')).toBeNull();
|
||||
expect(deriveAutoSessionName('! npm test')).toBeNull();
|
||||
expect(deriveAutoSessionName(' ')).toBeNull();
|
||||
expect(deriveAutoSessionName('/home/me/notes.txt what is this')).toBe('/home/me/notes.txt what is this');
|
||||
});
|
||||
|
||||
it('strips control bytes and ANSI before the title is persisted', () => {
|
||||
expect(deriveAutoSessionName('\x1b[31m整理项目文档\x1b[0m')).toBe('整理项目文档');
|
||||
expect(deriveAutoSessionName('a\x00b\tc')).toBe('a b c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeAutoSessionName', () => {
|
||||
it('keeps the placeholder as a prefix so the case and the counter survive', () => {
|
||||
expect(composeAutoSessionName('w3-myapp', 'fix the login bug')).toBe('w3-myapp: fix the login bug');
|
||||
expect(composeAutoSessionName('', 'fix the login bug')).toBe('fix the login bug');
|
||||
});
|
||||
|
||||
it('honours the rename cap in UTF-16 units', () => {
|
||||
const name = composeAutoSessionName('w3-myapp', '😀'.repeat(100), 40);
|
||||
expect(name.length).toBeLessThanOrEqual(40);
|
||||
expect(name.startsWith('w3-myapp: ')).toBe(true);
|
||||
expect(name.endsWith('…')).toBe(true);
|
||||
expect(composeAutoSessionName('x'.repeat(127), 'title', 128)).toBe('x'.repeat(127));
|
||||
});
|
||||
|
||||
it('recognises only the generated w/s + number + case form', () => {
|
||||
expect(isGeneratedSessionName('w12-my_case-2')).toBe(true);
|
||||
expect(isGeneratedSessionName('s1-shell')).toBe(true);
|
||||
expect(isGeneratedSessionName('w1-case: fix it')).toBe(false);
|
||||
expect(isGeneratedSessionName('alpha')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session name ownership', () => {
|
||||
it('infers placeholder vs manual from the name and persists the source', () => {
|
||||
const placeholder = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(placeholder.nameSource).toBe('placeholder');
|
||||
expect(placeholder.toState().nameSource).toBe('placeholder');
|
||||
expect(new Session({ workingDir: '/tmp' }).nameSource).toBe('placeholder');
|
||||
expect(new Session({ workingDir: '/tmp', name: 'my window' }).nameSource).toBe('manual');
|
||||
expect(new Session({ workingDir: '/tmp', name: 'w1-demo: fix it' }).nameSource).toBe('manual');
|
||||
// The boot restore passes the persisted source, which outranks the inference.
|
||||
const recovered = new Session({ workingDir: '/tmp', name: 'w1-demo: fix it', nameSource: 'auto' });
|
||||
expect(recovered.nameSource).toBe('auto');
|
||||
expect(recovered.applyAutoName('w1-demo: other')).toBe(false);
|
||||
});
|
||||
|
||||
it('names once: the first prompt takes it, later prompts and renames do not', () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(session.applyAutoName('w1-demo: fix the login bug')).toBe(true);
|
||||
expect(session.name).toBe('w1-demo: fix the login bug');
|
||||
expect(session.nameSource).toBe('auto');
|
||||
expect(session.applyAutoName('w1-demo: 1')).toBe(false);
|
||||
expect(session.name).toBe('w1-demo: fix the login bug');
|
||||
|
||||
session.name = 'mine';
|
||||
expect(session.nameSource).toBe('manual');
|
||||
expect(session.applyAutoName('other')).toBe(false);
|
||||
expect(session.name).toBe('mine');
|
||||
});
|
||||
|
||||
it('consumes the first prompt even when the composed name is unchanged', () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(session.applyAutoName('w1-demo')).toBe(false);
|
||||
expect(session.nameSource).toBe('auto');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session promptSubmitted', () => {
|
||||
function withFakePty(session: Session): ReturnType<typeof vi.fn> {
|
||||
const write = vi.fn();
|
||||
(session as unknown as { ptyProcess: { write: typeof write } }).ptyProcess = { write };
|
||||
return write;
|
||||
}
|
||||
|
||||
it('emits for user input only, after the bytes reached the PTY', () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
const prompts: string[] = [];
|
||||
session.on('promptSubmitted', (p: string) => prompts.push(p));
|
||||
|
||||
// No PTY yet: the write fails and nothing is reported.
|
||||
expect(session.write('lost\r', { fromUser: true })).toBe(false);
|
||||
expect(prompts).toEqual([]);
|
||||
|
||||
const write = withFakePty(session);
|
||||
expect(session.write('Read @ralph_prompt.md and follow the instructions.\r')).toBe(true);
|
||||
expect(prompts).toEqual([]);
|
||||
expect(session.write('fix the ', { fromUser: true })).toBe(true);
|
||||
expect(session.write('login bug\r', { fromUser: true })).toBe(true);
|
||||
expect(prompts).toEqual(['fix the login bug']);
|
||||
expect(write).toHaveBeenCalledTimes(3);
|
||||
// The pane's last-Enter stamp is kept for EVERY write, user or not.
|
||||
expect(session.lastSubmitAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('never feeds the tracker for a shell session', () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 's1-demo', mode: 'shell' });
|
||||
const prompts: string[] = [];
|
||||
session.on('promptSubmitted', (p: string) => prompts.push(p));
|
||||
withFakePty(session);
|
||||
expect(session.write('ls -la\r', { fromUser: true })).toBe(true);
|
||||
session.trackUserInput('cd src\r');
|
||||
expect(prompts).toEqual([]);
|
||||
});
|
||||
|
||||
it('feeds the send-key line feed so a two-line prompt keeps its separator', () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
const prompts: string[] = [];
|
||||
session.on('promptSubmitted', (p: string) => prompts.push(p));
|
||||
withFakePty(session);
|
||||
session.write('Fix the login bug', { fromUser: true });
|
||||
session.trackUserInput('\n');
|
||||
session.write('Also add tests.\r', { fromUser: true });
|
||||
expect(prompts).toEqual(['Fix the login bug Also add tests.']);
|
||||
});
|
||||
|
||||
it('reports through writeViaMux only when the mux accepted the input', async () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
const prompts: string[] = [];
|
||||
session.on('promptSubmitted', (p: string) => prompts.push(p));
|
||||
const sendInput = vi.fn(async () => false);
|
||||
(session as unknown as { _mux: unknown; _muxSession: unknown })._mux = { sendInput };
|
||||
(session as unknown as { _mux: unknown; _muxSession: unknown })._muxSession = { sessionId: session.id };
|
||||
|
||||
expect(await session.writeViaMux('dropped\r', { fromUser: true })).toBe(false);
|
||||
expect(prompts).toEqual([]);
|
||||
sendInput.mockResolvedValue(true);
|
||||
expect(await session.writeViaMux('delivered\r', { fromUser: true })).toBe(true);
|
||||
expect(prompts).toEqual(['delivered']);
|
||||
expect(await session.writeViaMux('/clear\r')).toBe(true);
|
||||
expect(prompts).toEqual(['delivered']);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { createSessionListeners } from '../src/web/session-listener-wiring.js';
|
||||
import { SseEvent } from '../src/web/sse-events.js';
|
||||
|
||||
describe('session listener wiring', () => {
|
||||
it('forwards the attachment request source through registerAttachment', async () => {
|
||||
@@ -20,4 +21,70 @@ describe('session listener wiring', () => {
|
||||
);
|
||||
expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external');
|
||||
});
|
||||
|
||||
/** The listener reads the setting asynchronously; let its promise chain settle. */
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
function autoNameDeps(session: Session, enabled: boolean) {
|
||||
const deps = {
|
||||
updateSessionName: vi.fn(() => true),
|
||||
persistSessionState: vi.fn(),
|
||||
broadcast: vi.fn(),
|
||||
getSessionStateWithRespawn: vi.fn(() => session.toState()),
|
||||
isAutoNameEnabled: vi.fn(async () => enabled),
|
||||
};
|
||||
return {
|
||||
deps,
|
||||
refs: createSessionListeners(session, deps as unknown as Parameters<typeof createSessionListeners>[1]),
|
||||
};
|
||||
}
|
||||
|
||||
it('names a placeholder tab after its first real prompt, in the prefix form, once', async () => {
|
||||
const session = new Session({ id: 'wiring-auto-name-test', workingDir: '/tmp', name: 'w1-demo' });
|
||||
const { deps, refs } = autoNameDeps(session, true);
|
||||
|
||||
// A slash command yields no title and leaves the session eligible; the
|
||||
// setting is not even read for it.
|
||||
refs.promptSubmitted('/clear');
|
||||
await flush();
|
||||
expect(deps.isAutoNameEnabled).not.toHaveBeenCalled();
|
||||
expect(session.name).toBe('w1-demo');
|
||||
|
||||
refs.promptSubmitted('整理登录模块并补充测试');
|
||||
await flush();
|
||||
expect(session.name).toBe('w1-demo: 整理登录模块并补充测试');
|
||||
expect(session.nameSource).toBe('auto');
|
||||
expect(deps.updateSessionName).toHaveBeenCalledWith('wiring-auto-name-test', 'w1-demo: 整理登录模块并补充测试');
|
||||
expect(deps.persistSessionState).toHaveBeenCalledWith(session);
|
||||
expect(deps.broadcast).toHaveBeenCalledWith(
|
||||
SseEvent.SessionUpdated,
|
||||
expect.objectContaining({ name: 'w1-demo: 整理登录模块并补充测试', nameSource: 'auto' })
|
||||
);
|
||||
|
||||
// The second prompt never reaches the setting: the tab is named.
|
||||
refs.promptSubmitted('1');
|
||||
await flush();
|
||||
expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
|
||||
expect(session.name).toBe('w1-demo: 整理登录模块并补充测试');
|
||||
});
|
||||
|
||||
it('leaves the tab alone while the setting is off, and never touches a manual name', async () => {
|
||||
const session = new Session({ id: 'wiring-auto-name-off', workingDir: '/tmp', name: 'w1-demo' });
|
||||
const { deps, refs } = autoNameDeps(session, false);
|
||||
|
||||
refs.promptSubmitted('fix the login bug');
|
||||
await flush();
|
||||
expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
|
||||
expect(session.name).toBe('w1-demo');
|
||||
// Still a placeholder: flipping the setting on names the NEXT prompt.
|
||||
expect(session.nameSource).toBe('placeholder');
|
||||
expect(deps.updateSessionName).not.toHaveBeenCalled();
|
||||
|
||||
session.name = '人工命名';
|
||||
refs.promptSubmitted('新的任务不能覆盖人工命名');
|
||||
await flush();
|
||||
expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
|
||||
expect(session.name).toBe('人工命名');
|
||||
expect(deps.persistSessionState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
/**
|
||||
* @fileoverview Regression tests for the buffer-load flush path (COD-144).
|
||||
* @fileoverview Regression tests for the buffer-load flush path: what becomes of
|
||||
* the live terminal events queued while a buffer load runs, once the load ends.
|
||||
*
|
||||
* 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.
|
||||
* Two rules, each from a real bug.
|
||||
*
|
||||
* 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.
|
||||
* COD-144: newly launched Shell sessions rendered BLANK until a tab-switch. The
|
||||
* load path (`selectSession` → `_beginBufferLoad`/`_finishBufferLoad`) queues
|
||||
* live events while `_isLoadingBuffer` is true and used to DISCARD the queue on
|
||||
* completion. Right for a buffer built from the server's byte history (the
|
||||
* queued output is already in it, so replaying it duplicates Ink redraws),
|
||||
* wrong for a brand-new shell whose fetch resolves BEFORE the PTY emits its
|
||||
* prompt: the prompt arrived only as a queued event and was thrown away. A
|
||||
* caller that knows the load painted nothing passes `{ flushQueued: true }`
|
||||
* and the queue is REPLAYED through `batchTerminalWrite()` after
|
||||
* `_isLoadingBuffer` is cleared, so the events write through normally.
|
||||
*
|
||||
* #436: a tmux pane capture is current only as of the instant `capture-pane`
|
||||
* ran, so everything the CLI printed between the capture and the end of the
|
||||
* chunked write was queued and dropped, and its next partial redraw landed on
|
||||
* a frame the terminal never received. Queue entries now carry their arrival
|
||||
* time and `_finishBufferLoad` takes a `since` cutoff, so a capture load
|
||||
* replays exactly the tail that arrived after the response headers. All four
|
||||
* fetch-and-write paths take that policy from one helper,
|
||||
* `_bufferLoadFinishOpts`, and a static scan below pins each of them to it,
|
||||
* because the same fix had already been written into one path out of four,
|
||||
* twice. A path that replays and then restores a scroll position re-takes the
|
||||
* sticky-scroll baseline (`_syncStickyScrollBaseline`), pinned the same way.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
|
||||
* box; see connection-indicator.test.ts). We extract the REAL
|
||||
@@ -56,10 +67,10 @@ type BufferLoadApp = {
|
||||
_bufferLoadSeq: number;
|
||||
_bufferLoadOwner: string | null;
|
||||
_isLoadingBuffer: boolean;
|
||||
_loadBufferQueue: string[] | null;
|
||||
_loadBufferQueue: { at: number; data: string }[] | null;
|
||||
batchTerminalWrite: (data: string) => void;
|
||||
_beginBufferLoad: (owner?: string) => string;
|
||||
_finishBufferLoad: (owner?: string, opts?: { flushQueued?: boolean }) => boolean;
|
||||
_finishBufferLoad: (owner?: string, opts?: { flushQueued?: boolean; since?: number }) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -84,10 +95,57 @@ function makeApp() {
|
||||
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);
|
||||
/**
|
||||
* A stub carrying the REAL `batchTerminalWrite` on top of the real begin/finish
|
||||
* methods, so a replay samples the sticky-scroll baseline exactly as it does in
|
||||
* the browser. The terminal is a fake whose `buffer.active` the test moves by
|
||||
* hand, which is what a caller's `scrollToLine` does to a real one.
|
||||
*/
|
||||
function makeScrollApp() {
|
||||
const buffer = { viewportY: 0, baseY: 100 };
|
||||
const app = {
|
||||
buffer,
|
||||
terminal: { buffer: { active: buffer } },
|
||||
sessions: new Map(),
|
||||
activeSessionId: null,
|
||||
pendingWrites: [] as string[],
|
||||
writeFrameScheduled: false,
|
||||
_wasAtBottomBeforeWrite: false,
|
||||
_bufferLoadSeq: 0,
|
||||
_bufferLoadOwner: null as string | null,
|
||||
_isLoadingBuffer: false,
|
||||
_loadBufferQueue: null as { at: number; data: string }[] | null,
|
||||
_scheduleTerminalWriteFlush: vi.fn(),
|
||||
batchTerminalWrite: mixin.batchTerminalWrite as (data: string) => void,
|
||||
isTerminalAtBottom: mixin.isTerminalAtBottom as () => boolean,
|
||||
_syncStickyScrollBaseline: mixin._syncStickyScrollBaseline as () => void,
|
||||
_beginBufferLoad: mixin._beginBufferLoad as BufferLoadApp['_beginBufferLoad'],
|
||||
_finishBufferLoad: mixin._finishBufferLoad as BufferLoadApp['_finishBufferLoad'],
|
||||
};
|
||||
return app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice one class method out of app.js, from its header to the next method's.
|
||||
*
|
||||
* Bounding the slice matters: the two methods checked below are not followed by
|
||||
* a JSDoc block, so a scan for the next comment would run on into unrelated
|
||||
* code and match its scroll calls instead of theirs.
|
||||
*/
|
||||
function methodBody(source: string, method: string): string {
|
||||
const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
|
||||
expect(start, `${method} not found in app.js`).toBeGreaterThan(-1);
|
||||
const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1));
|
||||
return next ? source.slice(start, start + 1 + next.index) : source.slice(start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a live SSE event arriving while a buffer load is in progress.
|
||||
* Mirrors batchTerminalWrite's queue branch, which stamps each entry with its
|
||||
* arrival time so a flush can replay only the tail (see the `since` tests).
|
||||
*/
|
||||
function pushWhileLoading(app: BufferLoadApp, data: string, at = performance.now()) {
|
||||
if (app._isLoadingBuffer && app._loadBufferQueue) app._loadBufferQueue.push({ at, data });
|
||||
}
|
||||
|
||||
describe('buffer-load flush (COD-144)', () => {
|
||||
@@ -153,11 +211,165 @@ describe('buffer-load flush (COD-144)', () => {
|
||||
// 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._loadBufferQueue).toEqual([{ at: expect.any(Number), data: 'queued' }]);
|
||||
expect(app.batchTerminalWrite).not.toHaveBeenCalled();
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
// ── The tmux-capture tail: `since` ──
|
||||
//
|
||||
// A pane capture is a point-in-time frame taken part-way through the fetch, so
|
||||
// it holds what arrived BEFORE the capture and nothing after. selectSession
|
||||
// passes the response's arrival time as `since`, which splits the queue at
|
||||
// exactly that line: pre-capture events are already painted and must stay
|
||||
// dropped, post-capture events exist nowhere else and must be replayed.
|
||||
|
||||
it('flushes only the entries at or after `since`', () => {
|
||||
const { app, writes } = makeApp();
|
||||
const owner = app._beginBufferLoad('load-since');
|
||||
pushWhileLoading(app, 'already-in-the-capture', 100);
|
||||
pushWhileLoading(app, 'arrived-at-the-headers', 200);
|
||||
pushWhileLoading(app, 'arrived-after-the-headers', 300);
|
||||
|
||||
app._finishBufferLoad(owner, { flushQueued: true, since: 200 });
|
||||
|
||||
// The pre-capture event stays dropped; the boundary entry counts as after.
|
||||
expect(writes).toEqual(['arrived-at-the-headers', 'arrived-after-the-headers']);
|
||||
});
|
||||
|
||||
it('flushQueued without `since` still replays the whole queue', () => {
|
||||
// The COD-144 path: a brand-new session's first prompt predates the
|
||||
// response, so cutting the queue would drop the only content it has.
|
||||
const { app, writes } = makeApp();
|
||||
const owner = app._beginBufferLoad('load-no-since');
|
||||
pushWhileLoading(app, 'prompt', 10);
|
||||
pushWhileLoading(app, 'more', 20);
|
||||
|
||||
app._finishBufferLoad(owner, { flushQueued: true });
|
||||
|
||||
expect(writes).toEqual(['prompt', 'more']);
|
||||
});
|
||||
|
||||
it('a `since` past every entry flushes nothing', () => {
|
||||
const { app, writes } = makeApp();
|
||||
const owner = app._beginBufferLoad('load-since-late');
|
||||
pushWhileLoading(app, 'old', 10);
|
||||
|
||||
app._finishBufferLoad(owner, { flushQueued: true, since: 999 });
|
||||
|
||||
expect(writes).toEqual([]);
|
||||
expect(app.batchTerminalWrite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Re-entering one load ──
|
||||
//
|
||||
// `selectSession` opens the load before its fetch, and `chunkedTerminalWrite`
|
||||
// opens it again under the SAME owner when it starts writing. A reset on that
|
||||
// second call would silently throw away everything queued during the fetch,
|
||||
// which on the capture path is output no buffer holds.
|
||||
|
||||
it('re-entering the same load keeps what the queue already holds', () => {
|
||||
const { app, writes } = makeApp();
|
||||
const owner = app._beginBufferLoad('load-reenter');
|
||||
pushWhileLoading(app, 'arrived-during-the-fetch', 100);
|
||||
|
||||
// chunkedTerminalWrite re-opens the load it was handed.
|
||||
app._beginBufferLoad(owner);
|
||||
pushWhileLoading(app, 'arrived-during-the-write', 200);
|
||||
|
||||
app._finishBufferLoad(owner, { flushQueued: true, since: 50 });
|
||||
|
||||
expect(writes).toEqual(['arrived-during-the-fetch', 'arrived-during-the-write']);
|
||||
});
|
||||
|
||||
it('a genuinely different load still starts with an empty queue', () => {
|
||||
const { app, writes } = makeApp();
|
||||
app._beginBufferLoad('load-first');
|
||||
pushWhileLoading(app, 'belongs-to-the-abandoned-load', 100);
|
||||
|
||||
// A tab switch starts a new load under a new owner. Its events are not ours.
|
||||
const second = app._beginBufferLoad('load-second');
|
||||
pushWhileLoading(app, 'belongs-to-this-load', 200);
|
||||
|
||||
app._finishBufferLoad(second, { flushQueued: true, since: 0 });
|
||||
|
||||
expect(writes).toEqual(['belongs-to-this-load']);
|
||||
});
|
||||
|
||||
// ── The sticky-scroll baseline across a replay ──
|
||||
//
|
||||
// `batchTerminalWrite` samples `_wasAtBottomBeforeWrite` before queueing, and
|
||||
// `flushPendingWrites` scrolls to the bottom off that sample. The replay runs
|
||||
// inside `chunkedTerminalWrite` before its promise resolves, with the terminal
|
||||
// freshly reset and rewritten, so the sample is always true. A caller that
|
||||
// then restores the reader's position would have that restore undone.
|
||||
|
||||
it('the replay latches the baseline true, and the viewport restore re-takes it', () => {
|
||||
const app = makeScrollApp();
|
||||
const owner = app._beginBufferLoad('load-scroll');
|
||||
pushWhileLoading(app as unknown as BufferLoadApp, 'output-after-the-capture', 100);
|
||||
|
||||
// The load ends with the terminal reset and rewritten, so it reads as bottom.
|
||||
app.buffer.viewportY = app.buffer.baseY;
|
||||
app._finishBufferLoad(owner, { flushQueued: true, since: 0 });
|
||||
expect(app._wasAtBottomBeforeWrite).toBe(true);
|
||||
|
||||
// The caller now puts the reader back where they were reading.
|
||||
app.buffer.viewportY = 40;
|
||||
app._syncStickyScrollBaseline();
|
||||
|
||||
// The next flush must leave them there.
|
||||
expect(app._wasAtBottomBeforeWrite).toBe(false);
|
||||
});
|
||||
|
||||
it('a restore that lands back at the bottom keeps sticky scroll armed', () => {
|
||||
const app = makeScrollApp();
|
||||
const owner = app._beginBufferLoad('load-scroll-bottom');
|
||||
pushWhileLoading(app as unknown as BufferLoadApp, 'output-after-the-capture', 100);
|
||||
|
||||
app.buffer.viewportY = app.buffer.baseY;
|
||||
app._finishBufferLoad(owner, { flushQueued: true, since: 0 });
|
||||
app._syncStickyScrollBaseline();
|
||||
|
||||
// A reader who was already at the bottom still wants to be carried along.
|
||||
expect(app._wasAtBottomBeforeWrite).toBe(true);
|
||||
});
|
||||
|
||||
it('both callers that restore a scroll position re-take the baseline', () => {
|
||||
// The wiring lives in app.js, outside this file's vm harness. Without it the
|
||||
// two methods below restore the viewport and the next flush undoes it.
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
|
||||
for (const method of ['_onSessionNeedsRefresh', '_maybeRefetchFullHistory']) {
|
||||
const body = methodBody(source, method);
|
||||
const restoreAt = body.lastIndexOf('scrollToLine(');
|
||||
const syncAt = body.indexOf('this._syncStickyScrollBaseline()');
|
||||
expect(restoreAt, `${method} no longer restores a scroll position`).toBeGreaterThan(-1);
|
||||
expect(syncAt, `${method} never re-takes the baseline`).toBeGreaterThan(-1);
|
||||
expect(syncAt, `${method} re-takes the baseline before its restore`).toBeGreaterThan(restoreAt);
|
||||
}
|
||||
});
|
||||
|
||||
it('every path that fetches a terminal buffer and writes it asks the shared helper', () => {
|
||||
// Drift guard. The first version of this fix covered one of the four paths,
|
||||
// and a later pass found it still covering one of four. Nothing else in the
|
||||
// gate stops a fifth path, or an inlined `{ flushQueued: true }`, from
|
||||
// splitting the policy up again; the browser suite that would notice does
|
||||
// not run in CI.
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
|
||||
for (const method of [
|
||||
'selectSession',
|
||||
'_onSessionNeedsRefresh',
|
||||
'_onSessionClearTerminal',
|
||||
'_maybeRefetchFullHistory',
|
||||
]) {
|
||||
expect(methodBody(source, method), `${method} decides the flush policy itself`).toContain(
|
||||
'this._bufferLoadFinishOpts('
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('empty queue + flushQueued is a no-op (no throw, no writes)', () => {
|
||||
const { app, writes } = makeApp();
|
||||
const owner = app._beginBufferLoad('load-empty');
|
||||
|
||||
@@ -49,6 +49,39 @@ function loadTerminalUiHarness(mode: string) {
|
||||
return { app, writes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap in a terminal whose write() parses ASYNCHRONOUSLY, the way xterm.js does.
|
||||
*
|
||||
* The real renderer queues the chunk and applies it later, firing the write
|
||||
* callback once it has been parsed; a redraw that addresses a row past the
|
||||
* viewport (Codex's status line) drags the viewport to the live bottom at that
|
||||
* point, not when write() returns. `parse()` runs that pending work.
|
||||
*/
|
||||
function attachAsyncParsingTerminal(app: any, opts: { viewportY: number; baseY: number }) {
|
||||
const buffer = { viewportY: opts.viewportY, baseY: opts.baseY };
|
||||
const pending: Array<() => void> = [];
|
||||
app.terminal.buffer = { active: buffer };
|
||||
app.terminal.write = vi.fn((_data: string, callback?: () => void) => {
|
||||
pending.push(() => {
|
||||
buffer.viewportY = buffer.baseY; // the redraw lands
|
||||
callback?.();
|
||||
});
|
||||
});
|
||||
app.terminal.scrollToLine = vi.fn((line: number) => {
|
||||
buffer.viewportY = line;
|
||||
});
|
||||
app.terminal.scrollToBottom = vi.fn(() => {
|
||||
buffer.viewportY = buffer.baseY;
|
||||
});
|
||||
return {
|
||||
buffer,
|
||||
parse: () => {
|
||||
const queued = pending.splice(0, pending.length);
|
||||
for (const run of queued) run();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadAppHarness() {
|
||||
const dir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const fetchMock = vi.fn();
|
||||
@@ -322,6 +355,51 @@ describe('terminal flush budget', () => {
|
||||
expect(app._bufferLoadOwner).toBe(null);
|
||||
});
|
||||
|
||||
// ── Which payloads end their load by replaying the queue ──
|
||||
//
|
||||
// A pane capture is current only up to capture time, so the tail that arrived
|
||||
// after the response exists nowhere else and has to be replayed. The server's
|
||||
// accumulated byte history is current up to the response, so replaying on top
|
||||
// of it would duplicate output. `_bufferLoadFinishOpts` is the one place that
|
||||
// decides this, for all four paths that fetch a terminal buffer and write it.
|
||||
|
||||
it('replays the tail for a visible-pane capture', () => {
|
||||
const { CodemanApp } = loadAppHarness();
|
||||
const app = Object.create(CodemanApp.prototype) as any;
|
||||
|
||||
expect(app._bufferLoadFinishOpts({ source: 'mux-visible' }, 1234)).toEqual({
|
||||
flushQueued: true,
|
||||
since: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it('replays the tail for a full-history capture', () => {
|
||||
const { CodemanApp } = loadAppHarness();
|
||||
const app = Object.create(CodemanApp.prototype) as any;
|
||||
|
||||
expect(app._bufferLoadFinishOpts({ source: 'mux-full-history' }, 1234)).toEqual({
|
||||
flushQueued: true,
|
||||
since: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it('discards the queue for the accumulated byte history', () => {
|
||||
const { CodemanApp } = loadAppHarness();
|
||||
const app = Object.create(CodemanApp.prototype) as any;
|
||||
|
||||
expect(app._bufferLoadFinishOpts({ source: 'history' }, 1234).flushQueued).toBe(false);
|
||||
});
|
||||
|
||||
it('discards the queue for a payload that names no source', () => {
|
||||
// Fails toward the safe answer: a duplicated Ink redraw corrupts the screen,
|
||||
// while a dropped tail is repaired by the CLI's next full repaint.
|
||||
const { CodemanApp } = loadAppHarness();
|
||||
const app = Object.create(CodemanApp.prototype) as any;
|
||||
|
||||
expect(app._bufferLoadFinishOpts({}, 1234).flushQueued).toBe(false);
|
||||
expect(app._bufferLoadFinishOpts(undefined, 1234).flushQueued).toBe(false);
|
||||
});
|
||||
|
||||
it('does not snap back to bottom during Codex Working redraws right after the user scrolls up', () => {
|
||||
const { app } = loadTerminalUiHarness('codex');
|
||||
const scrollToBottom = vi.fn();
|
||||
@@ -337,20 +415,111 @@ describe('terminal flush budget', () => {
|
||||
|
||||
it('restores the user scroll position when Codex Working redraws move the viewport', () => {
|
||||
const { app } = loadTerminalUiHarness('codex');
|
||||
const buffer = { viewportY: 40, baseY: 100 };
|
||||
app.terminal.buffer = { active: buffer };
|
||||
app.terminal.write = vi.fn(() => {
|
||||
buffer.viewportY = buffer.baseY;
|
||||
});
|
||||
app.terminal.scrollToLine = vi.fn((line: number) => {
|
||||
buffer.viewportY = line;
|
||||
});
|
||||
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
||||
app._wasAtBottomBeforeWrite = true;
|
||||
app._lastUserScrollUpAt = 0;
|
||||
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
||||
|
||||
app.flushPendingWrites();
|
||||
parse();
|
||||
|
||||
expect(buffer.viewportY).toBe(40);
|
||||
});
|
||||
|
||||
// Issue #358. xterm.js parses on its own schedule, so the buffer still holds
|
||||
// the pre-write viewport the instant write() returns: restoring there compared
|
||||
// the anchor against itself, did nothing, and left the redraw free to drag the
|
||||
// viewport to the live bottom a tick later. The previous regression passed
|
||||
// because its write mock moved the viewport synchronously, which real xterm
|
||||
// never does. These drive the callback explicitly instead.
|
||||
it('restores the history anchor only AFTER xterm has parsed the write (#358)', () => {
|
||||
const { app } = loadTerminalUiHarness('codex');
|
||||
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
||||
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
||||
|
||||
app.flushPendingWrites();
|
||||
// Nothing has parsed yet, so nothing may have been restored yet either.
|
||||
expect(app.terminal.scrollToLine).not.toHaveBeenCalled();
|
||||
expect(buffer.viewportY).toBe(40);
|
||||
|
||||
parse();
|
||||
|
||||
expect(app.terminal.scrollToLine).toHaveBeenCalledWith(40);
|
||||
expect(buffer.viewportY).toBe(40);
|
||||
});
|
||||
|
||||
it('holds the anchor across consecutive Codex redraws', () => {
|
||||
const { app } = loadTerminalUiHarness('codex');
|
||||
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
||||
|
||||
for (const frame of ['\x1b[55;1H\x1b[2m• Working (6s)', '\x1b[55;1H\x1b[2m• Working (7s)']) {
|
||||
app.pendingWrites.push(frame);
|
||||
app.flushPendingWrites();
|
||||
parse();
|
||||
expect(buffer.viewportY).toBe(40);
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the anchor across a chunked write whose remainder is deferred', () => {
|
||||
const { app } = loadTerminalUiHarness('codex');
|
||||
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
||||
// Over the 32KB codex frame budget, so the flush defers a remainder and the
|
||||
// second chunk goes out from the write callback's reschedule.
|
||||
app.pendingWrites.push('x'.repeat(40000));
|
||||
|
||||
app.flushPendingWrites();
|
||||
parse();
|
||||
expect(buffer.viewportY).toBe(40);
|
||||
|
||||
app.flushPendingWrites();
|
||||
parse();
|
||||
expect(buffer.viewportY).toBe(40);
|
||||
expect(app.pendingWrites).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drops the anchor when the user switched sessions before the write parsed', () => {
|
||||
// The anchor indexes the buffer it came from. selectSession() resets the
|
||||
// terminal and chunk-loads a different scrollback, so replaying row 40 into
|
||||
// that one is a jump to an arbitrary place, not a restore. Only reachable now
|
||||
// that the restore runs a parse later than the write.
|
||||
const { app } = loadTerminalUiHarness('codex');
|
||||
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
||||
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
||||
|
||||
app.flushPendingWrites();
|
||||
app.activeSessionId = 'session-2'; // the user clicked another tab
|
||||
parse();
|
||||
|
||||
expect(app.terminal.scrollToLine).not.toHaveBeenCalled();
|
||||
expect(buffer.viewportY).toBe(buffer.baseY);
|
||||
});
|
||||
|
||||
it('drops the anchor while a buffer load is replaying history', () => {
|
||||
const { app } = loadTerminalUiHarness('codex');
|
||||
const { parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
||||
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
||||
|
||||
app.flushPendingWrites();
|
||||
app._isLoadingBuffer = true; // chunkedTerminalWrite owns the viewport now
|
||||
parse();
|
||||
|
||||
expect(app.terminal.scrollToLine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not bounce off the bottom when the sticky flag and an anchor disagree', () => {
|
||||
// _wasAtBottomBeforeWrite is captured at the frame's first batchTerminalWrite
|
||||
// and the anchor at flush time, so a scroll-up in between leaves both live.
|
||||
// The anchor wins: scrolling to the bottom and back would be a visible jump.
|
||||
const { app } = loadTerminalUiHarness('codex');
|
||||
const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
|
||||
app._wasAtBottomBeforeWrite = true;
|
||||
app._lastUserScrollUpAt = 0;
|
||||
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
|
||||
|
||||
app.flushPendingWrites();
|
||||
parse();
|
||||
|
||||
expect(app.terminal.scrollToBottom).not.toHaveBeenCalled();
|
||||
expect(buffer.viewportY).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
* (stopPropagation, not stopImmediatePropagation) does not silence it;
|
||||
* - a `composed: true` insertText preceded by a keydown — the shape Chrome on
|
||||
* Android delivers — is dropped by xterm and recovered by us, exactly once;
|
||||
* - a keystroke xterm DOES handle is delivered exactly once, not twice.
|
||||
* - a keystroke xterm DOES handle is delivered exactly once, not twice;
|
||||
* - a character committed in the SAME page task as Enter reaches the send
|
||||
* path ahead of the `\r`, which is the ordering the zero-delay timer
|
||||
* alone cannot produce.
|
||||
*
|
||||
* Browser-driven, so it is excluded from `npm run test:ci` like the other
|
||||
* Playwright suites. Run locally:
|
||||
@@ -146,6 +149,84 @@ describe('orphaned terminal input recovery wiring', () => {
|
||||
expect(second.sent.join('')).toBe('z');
|
||||
});
|
||||
|
||||
/**
|
||||
* The batched shape an Android soft keyboard actually delivers when the user
|
||||
* taps the last character and then Enter: the character's keydown, its
|
||||
* `composed: true` insertText, and Enter's keydown all land in ONE page task,
|
||||
* before any zero-delay timer can run.
|
||||
*
|
||||
* This is the ordering half of the fix, and the half the unit harness cannot
|
||||
* reach: the unit tests prove WHICH candidate is forwarded, this proves WHEN.
|
||||
* Resolving the pending candidate only on its 0 ms timer loses the character
|
||||
* outright here, because by the time that timer runs xterm has already
|
||||
* emitted the `\r` and bumped the canonical counter past the candidate's
|
||||
* snapshot, so it stands down. Draining at the next keydown, from xterm's
|
||||
* custom key handler (which runs before xterm processes that key), puts the
|
||||
* character on the wire ahead of the `\r`.
|
||||
*/
|
||||
async function batchedCommitThenEnter(data: string) {
|
||||
return page.evaluate(async (text) => {
|
||||
const app = (window as any).app;
|
||||
const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement;
|
||||
const originalSessionId = app.activeSessionId;
|
||||
const originalLocalEcho = app._localEchoEnabled;
|
||||
const originalSendInput = app._sendInputAsync;
|
||||
const originalPendingInput = app._pendingInput;
|
||||
const originalLastKeystrokeTime = app._lastKeystrokeTime;
|
||||
const sent: string[] = [];
|
||||
|
||||
try {
|
||||
app.activeSessionId = 'cod388-browser-batched';
|
||||
app._localEchoEnabled = false;
|
||||
app._pendingInput = '';
|
||||
app._lastKeystrokeTime = 0;
|
||||
app._sendInputAsync = (_sessionId: string, chunk: string) => sent.push(chunk);
|
||||
textarea.focus();
|
||||
|
||||
// One task, no awaits between the three dispatches.
|
||||
const charDown = new KeyboardEvent('keydown', {
|
||||
key: 'Unidentified',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
Object.defineProperties(charDown, { keyCode: { value: 65 }, which: { value: 65 } });
|
||||
textarea.dispatchEvent(charDown);
|
||||
|
||||
textarea.value = text;
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('input', { data: text, inputType: 'insertText', bubbles: true, composed: true })
|
||||
);
|
||||
|
||||
const enterDown = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
Object.defineProperties(enterDown, { keyCode: { value: 13 }, which: { value: 13 } });
|
||||
textarea.dispatchEvent(enterDown);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
return { wire: sent.join('') };
|
||||
} finally {
|
||||
app.activeSessionId = originalSessionId;
|
||||
app._localEchoEnabled = originalLocalEcho;
|
||||
app._sendInputAsync = originalSendInput;
|
||||
app._pendingInput = originalPendingInput;
|
||||
app._lastKeystrokeTime = originalLastKeystrokeTime;
|
||||
textarea.value = '';
|
||||
}
|
||||
}, data);
|
||||
}
|
||||
|
||||
it('delivers a character committed in the same task as Enter BEFORE the carriage return', async () => {
|
||||
const { wire } = await batchedCommitThenEnter('o');
|
||||
// Not '\r' (character lost, the defect) and not '\ro' (recovered too late).
|
||||
expect(wire).toBe('o\r');
|
||||
});
|
||||
|
||||
it('sends nothing for a keydown that produces no input event', async () => {
|
||||
const { sent } = await keystroke({ data: 'q', dispatchInput: false, keyCode: 65 });
|
||||
expect(sent).toEqual([]);
|
||||
|
||||
@@ -218,6 +218,52 @@ describe('orphaned terminal input recovery', () => {
|
||||
expect(reads).toEqual([]);
|
||||
});
|
||||
|
||||
it('delivers the last character BEFORE the Enter that submits it (defect 4)', () => {
|
||||
// Android soft keyboards commit the last character and send the Enter key in
|
||||
// ONE InputConnection transaction, so the `input` event and the Enter keydown
|
||||
// are processed before any zero-delay timer runs. Two things then went wrong
|
||||
// with a candidate that only resolved on its timer:
|
||||
//
|
||||
// 1. ORDER — xterm emits '\r' synchronously from the Enter keydown, and the
|
||||
// local-echo composer submits `pendingText` right there. The recovered
|
||||
// character arrived one macrotask too late to be part of the prompt.
|
||||
// 2. LOSS — that '\r' bumps the canonical counter, so by the time the
|
||||
// candidate resolved, `canonicalCount > snapshot` read as "xterm spoke
|
||||
// for this keystroke" and stood the recovery down. The character was
|
||||
// dropped outright: every message sent from the phone lost its last
|
||||
// character.
|
||||
//
|
||||
// Resolving pending candidates synchronously at the NEXT keydown fixes both:
|
||||
// the counter still holds the value it had when that candidate was created,
|
||||
// and the byte reaches the composer ahead of the Enter.
|
||||
const h = harness();
|
||||
h.keydown();
|
||||
h.input('o');
|
||||
expect(h.emitted).toEqual([]);
|
||||
|
||||
h.keydown({ key: 'Enter' });
|
||||
expect(h.emitted).toEqual(['o']);
|
||||
|
||||
// xterm now emits '\r' for the Enter. The already-resolved candidate must
|
||||
// not fire a second time when its timer is flushed.
|
||||
h.controller.notifyCanonicalData();
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual(['o']);
|
||||
expect(h.pendingTimers()).toBe(0);
|
||||
});
|
||||
|
||||
it('still stands down at the next keydown when xterm spoke for the candidate', () => {
|
||||
// The synchronous resolve must not become a "forward everything" path: a
|
||||
// keystroke xterm delivered itself is still a duplicate if recovered.
|
||||
const h = harness();
|
||||
h.keydown();
|
||||
h.input('x');
|
||||
h.controller.notifyCanonicalData();
|
||||
h.keydown({ key: 'Enter' });
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores input events that are not committed text', () => {
|
||||
const h = harness();
|
||||
for (const inputType of ['insertCompositionText', 'deleteContentBackward', 'insertLineBreak', 'insertFromPaste']) {
|
||||
|
||||
Reference in New Issue
Block a user