Merge remote-tracking branch 'origin/master' into relprep/pr-510

Brings the 1.33.2 based adoption feature up to master. Conflicts were
resolved by keeping master's intent and re-applying the feature on top:
both sides kept for imports, the script tag and the stylesheet blocks; the
trust-dialog guard keeps master's stopped check plus the adopted refusal;
the Ralph and image watchers keep master's remote branch plus the adopted
skip; the close confirmation keeps master's worktree option plus the
adopted wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 02:51:00 +01:00
commit 57b2dcc466
858 files changed
+173626 -7078

No files matched your search

+9 -1
View File
@@ -7,5 +7,13 @@
export { MockSession, createMockSession, terminalOutputs } from './mock-session.js';
export { MockStateStore } from './mock-state-store.js';
export { waitForEvent, createDeferred, safeRmHomeTree, isUnderTestHome } from './test-helpers.js';
export {
waitForEvent,
createDeferred,
safeRmHomeTree,
isUnderTestHome,
isSandboxedHome,
assertSandboxedHome,
TEST_HOME_PREFIX,
} from './test-helpers.js';
export { createMockRouteContext, type MockRouteContext } from './mock-route-context.js';
+11
View File
@@ -56,8 +56,15 @@ export function createMockRouteContext(options?: {
runSessionDeletion: vi.fn(async (_removed, action) => action()),
runStaleSessionCleanup: vi.fn(async (_activeIds, action) => action(new Set())),
reconcileAfterRestoration: vi.fn(async () => {}),
caseDefaultGroup: vi.fn(async () => ({ groupId: null, groups: [] })),
setCaseDefaultGroup: vi.fn(async (_owner: string, _caseName: string, groupId: string | null) => ({
groupId,
groups: [],
})),
forgetCaseDefaultGroup: vi.fn(async () => {}),
},
cleanupSession: vi.fn(async () => {}),
forgetEditHistory: vi.fn(),
setupSessionListeners: vi.fn(async () => {}),
persistSessionState: vi.fn(),
persistSessionStateNow: vi.fn(),
@@ -83,6 +90,9 @@ export function createMockRouteContext(options?: {
restoreRespawnController: vi.fn(),
saveRespawnConfig: vi.fn(),
// -- KeepWarmPort --
keepWarm: { schedule: vi.fn(), rescheduleAll: vi.fn(), remove: vi.fn() },
// -- ConfigPort --
store: {
getConfig: vi.fn(() => ({ ralphEnabled: false, maxConcurrentSessions: 5 })),
@@ -164,6 +174,7 @@ export function createMockRouteContext(options?: {
scheduledRuns: new Map(),
teamWatcher: { getTeams: vi.fn(() => []), getTeamTasks: vi.fn(() => []), hasActiveTeammates: vi.fn(() => false) },
tunnelManager: null,
allowUnauthenticatedTunnel: false,
pushStore: null,
startScheduledRun: vi.fn(),
stopScheduledRun: vi.fn(),
+96 -6
View File
@@ -4,7 +4,14 @@
*/
import { EventEmitter } from 'node:events';
import { vi } from 'vitest';
import type { SessionAttachmentHistoryItem, SessionStatus, SessionRemote } from '../../src/types.js';
import type {
KeepWarmStatus,
PromptCacheStatus,
SessionAttachmentHistoryItem,
SessionStatus,
SessionRemote,
} from '../../src/types.js';
import type { GitFetchStatus } from '../../src/git-fetch.js';
/**
* Enhanced mock session for testing RespawnController.
@@ -48,6 +55,44 @@ export class MockSession extends EventEmitter {
* need to be able to set it.
*/
watching: string | null = null;
/** Mirrors Session.promptCache / setPromptCache (fed by POST /api/status-telemetry). */
promptCache: PromptCacheStatus | null = null;
setPromptCache(status: PromptCacheStatus): boolean {
const changed = JSON.stringify(status) !== JSON.stringify(this.promptCache);
this.promptCache = status;
return changed;
}
/** Mirrors the prompt-cache keep-warm surface on Session (prompt-cache-keepwarm.ts). */
keepAliveTurnActive = false;
endKeepAliveTurn(): void {
this.keepAliveTurnActive = false;
}
keepWarmEnabled: boolean | undefined = undefined;
setKeepWarmEnabled(enabled: boolean | undefined): void {
this.keepWarmEnabled = enabled;
}
keepWarm: KeepWarmStatus | null = null;
setKeepWarmStatus(status: KeepWarmStatus): boolean {
const changed = JSON.stringify(status) !== JSON.stringify(this.keepWarm);
this.keepWarm = status;
return changed;
}
/**
* Mirrors the Git fetch interval surface on Session (src/git-fetch.ts): the case's own override, `undefined`
* for either half meaning the app-wide setting applies, and what the scheduler last reported.
*/
gitFetchEnabled: boolean | undefined = undefined;
gitFetchIntervalSeconds: number | undefined = undefined;
setGitFetchSettings(enabled: boolean | undefined, intervalSeconds: number | undefined): void {
this.gitFetchEnabled = enabled;
this.gitFetchIntervalSeconds = intervalSeconds;
}
gitFetch: GitFetchStatus | null = null;
setGitFetchStatus(status: GitFetchStatus): boolean {
const changed = JSON.stringify(status) !== JSON.stringify(this.gitFetch);
this.gitFetch = status;
return changed;
}
private _activeChildProcesses: { pid: number; command: string }[] = [];
ralphTracker: null = null;
writeBuffer: string[] = [];
@@ -60,6 +105,13 @@ export class MockSession extends EventEmitter {
claudeSessionIdIsFirstHand: boolean = false;
/** Mirrors Session.claudeSessionChain — oldest first, current last. */
claudeSessionChain: string[] = [];
/** The per-session CLAUDE_CONFIG_DIR override that Session.claudeConfigDir() reads. */
claudeConfigDirOverride: string | undefined = undefined;
/** Mirrors Session.claudeConfigDir: the override, trimmed; undefined when unset. */
claudeConfigDir(): string | undefined {
return this.claudeConfigDirOverride?.trim() || undefined;
}
/** Mirrors Session.adoptClaudeSessionId, including the first-hand chain rule. */
adoptClaudeSessionId(newId: string, options: { firstHand?: boolean } = {}): void {
@@ -76,14 +128,36 @@ export class MockSession extends EventEmitter {
this.claudeSessionId = newId;
}
/** Mirrors Session.markPromptSubmitted. */
markPromptSubmitted(): void {
/** Mirrors Session.markPromptSubmitted; records the hook's prompt text it was handed. */
submittedPrompts: Array<string | undefined> = [];
markPromptSubmitted(prompt?: string): void {
this.submittedPrompts.push(prompt);
this.lastSubmitAt = Date.now();
}
/** Mirrors Session.trackUserInput (the send-key route feeds it around the write path). */
trackUserInput(_data: string): void {}
async performAuxiliaryInput(operation: () => Promise<void>): Promise<void> {
await operation();
}
/** Mirrors Session.guardedInputHeld; a route test sets it to stand in for a guarded submit's lease. */
guardedInputHeld = false;
/** Input held behind that lease, in arrival order (see releaseGuardedLease). */
heldGuardedInput: Array<() => void | Promise<unknown>> = [];
/** Mirrors Session.holdForGuardedLease, without the cap (a test replaces it to model a full queue). */
holdForGuardedLease(run: () => void | Promise<unknown>, _bytes = 0, _data?: string): 'free' | 'held' | 'full' {
if (!this.guardedInputHeld) return 'free';
this.heldGuardedInput.push(run);
return 'held';
}
/** Ends the stand-in lease and runs the held input in arrival order, as Session does. */
releaseGuardedLease(): void {
this.guardedInputHeld = false;
for (const run of this.heldGuardedInput.splice(0)) void run();
}
private _muxName: string | null = null;
constructor(id: string = 'mock-session-id') {
@@ -107,9 +181,14 @@ export class MockSession extends EventEmitter {
/** Write via mux (used by respawn controller) */
async writeViaMux(data: string): Promise<boolean> {
if (this.failWrites) return false;
this.writeBuffer.push(data);
return true;
const write = (): boolean => {
if (this.failWrites) return false;
this.writeBuffer.push(data);
return true;
};
// Like Session.writeViaMux, it waits its turn behind a stand-in guarded lease.
if (!this.guardedInputHeld) return write();
return new Promise<boolean>((resolve) => this.heldGuardedInput.push(() => resolve(write())));
}
/**
@@ -401,7 +480,18 @@ export class MockSession extends EventEmitter {
}
);
restartCli = vi.fn(async () => true);
/** Mirrors Session.envOverrideValue; no override map here, so the custom-model
* real-home lookup defaults to "session says nothing" (overridable per test). */
envOverrideValue = vi.fn((_key: string): string | undefined => undefined);
getCustomModelForPersist = vi.fn(() => this._mockCustomModel);
/** Mirrors Session.noteReportedModel: the statusline route records the CLI's model. */
reportedModel: { model: string; source: 'statusline' | 'screen' } | null = null;
noteReportedModel = vi.fn((source: 'statusline' | 'screen', raw: unknown): boolean => {
if (typeof raw !== 'string' || !raw.trim()) return false;
if (this.reportedModel?.model === raw && this.reportedModel.source === source) return false;
this.reportedModel = { model: raw, source };
return true;
});
/** Stub for sendInput */
sendInput = vi.fn();
+231
View File
@@ -0,0 +1,231 @@
/**
* @fileoverview Fakes for driving a real TerminalTile (terminal-tile.js) in a
* `vm` context: its WebSocket, its xterm and the fit addon. A test puts them in
* the context as `WebSocket`, `Terminal` and `FitAddon.FitAddon`, and runs
* `connect()` and the socket handlers for real.
*/
import { vi } from 'vitest';
export type Frame = { t: string; d?: string; seq?: number; cid?: string; c?: number; r?: number };
/** A WebSocket the test opens, feeds and closes by hand; `sent` holds every frame the tile sent. */
export class FakeSocket {
static OPEN = 1;
static instances: FakeSocket[] = [];
readyState = 0;
sent: Frame[] = [];
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onclose: ((ev?: { code: number }) => void) | null = null;
onerror: (() => void) | null = null;
constructor(public url: string) {
FakeSocket.instances.push(this);
}
send(data: string) {
this.sent.push(JSON.parse(data) as Frame);
}
close = vi.fn(() => {
this.readyState = 3;
});
open() {
this.readyState = 1;
this.onopen?.();
}
receive(msg: object) {
this.onmessage?.({ data: JSON.stringify(msg) });
}
inputFrames() {
return this.sent.filter((f) => f.t === 'i');
}
/** The connection drops: closed, and the tile hears `code`. */
drop(code = 1006) {
this.readyState = 3;
this.onclose?.({ code });
}
}
/** The fit addon: proposes `FakeFit.proposed` and, like the real one, resizes to it (NaN = hidden pane). */
export class FakeFit {
static proposed = { cols: 80, rows: 24 };
term: FakeTerminal | null = null;
fit() {
const { cols, rows } = FakeFit.proposed;
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
this.term?.resize(cols, rows);
}
proposeDimensions() {
return { ...FakeFit.proposed };
}
}
/** An xterm that records writes, resizes and its handlers; `type()` feeds onData like a keystroke. */
export class FakeTerminal {
static last: FakeTerminal | null = null;
/**
* Opt-in, set by a test BEFORE the tile connects: the buffer's rows follow
* what is written, as in xterm. Every `\n` adds a line, `baseY` is the lines
* beyond the screen, a clear or an in-stream reset (RIS, `\x1bc`) leaves one
* line, and a resize recomputes it
* (a row-shrinking fit pushes rows above the screen, a growing one pulls them
* back). The viewport follows the bottom. Off, `baseY` stays where a test
* puts it.
*/
static emulateScroll = false;
/**
* Opt-in, set by a test BEFORE the tile connects (and reset after): extra
* fields merged into `_core`, e.g. xterm's `_compositionHelper` for the
* keyCode-229 controller, which reads it once when the tile creates it.
*/
static coreFactory: ((term: FakeTerminal) => Record<string, unknown>) | null = null;
options: Record<string, unknown>;
cols = 80;
rows = 24;
dataCb: ((data: string) => void) | null = null;
buffer = { active: { type: 'normal', viewportY: 0, baseY: 0, length: 24 } };
/** xterm's own mouse-tracking mode (DECSET 1000 and friends); 'none' while no app asked for the mouse. */
modes = { mouseTrackingMode: 'none' };
/** Lines in the buffer while emulating (the cursor line counts). */
lineCount = 1;
emulate = FakeTerminal.emulateScroll;
/** Called after an emulated resize, so a test can stand in for a reflow. */
afterResize: ((cols: number, rows: number) => void) | null = null;
/** Where the screen sits and how big a cell renders, for the click-to-cell math. */
screenRect = { left: 10, top: 20 };
element = {
querySelector: (sel: string) =>
sel === '.xterm-screen' ? { getBoundingClientRect: () => ({ ...this.screenRect }) } : null,
};
_core: Record<string, unknown> = {
_renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } },
...(FakeTerminal.coreFactory?.(this) ?? {}),
};
constructor(options: Record<string, unknown>) {
this.options = { ...options };
FakeTerminal.last = this;
}
/** Re-derives baseY (and a viewport following the bottom) from the emulated line count. */
settleRows() {
const active = this.buffer.active;
active.baseY = Math.max(0, this.lineCount - this.rows);
active.viewportY = active.baseY;
active.length = Math.max(this.lineCount, this.rows);
}
loadAddon(addon: FakeFit) {
addon.term = this;
}
open() {}
onData(cb: (data: string) => void) {
this.dataCb = cb;
}
keyHandler: ((ev: Record<string, unknown>) => boolean) | null = null;
focusListeners: Array<() => void> = [];
/** Every other textarea listener, with the capture flag it was added with (the keyCode-229 controller's). */
textareaListeners: Array<{ type: string; fn: (ev: Record<string, unknown>) => void; capture: unknown }> = [];
textarea = {
/** The helper textarea's text, which xterm's keyCode-229 diff (and the controller's) reads. */
value: '',
addEventListener: (type: string, fn: (ev?: Record<string, unknown>) => void, capture?: unknown) => {
if (type === 'focus') this.focusListeners.push(fn as () => void);
else this.textareaListeners.push({ type, fn, capture });
},
removeEventListener: (type: string, fn: (ev?: Record<string, unknown>) => void, capture?: unknown) => {
if (type === 'focus') this.focusListeners = this.focusListeners.filter((f) => f !== fn);
else {
this.textareaListeners = this.textareaListeners.filter(
(l) => !(l.type === type && l.fn === fn && Boolean(l.capture) === Boolean(capture))
);
}
},
/** Delivers `ev` to the textarea's listeners of `type`, in registration order. */
fire: (type: string, ev: Record<string, unknown> = {}) => {
for (const l of this.textareaListeners.filter((x) => x.type === type)) l.fn({ type, ...ev });
},
};
focusTextarea() {
for (const fn of this.focusListeners) fn();
}
attachCustomKeyEventHandler(fn: (ev: Record<string, unknown>) => boolean) {
this.keyHandler = fn;
}
registerLinkProvider() {}
writes: string[] = [];
/**
* Set by a test: write callbacks do not run, as while xterm is still parsing
* (or never, on a disposed xterm). They wait in `heldParses` until `parse()`.
*/
holdParse = false;
heldParses: Array<() => void> = [];
/** xterm catches up: runs every write callback held so far, in order. */
parse() {
for (const cb of this.heldParses.splice(0)) cb();
}
/** Set by a test: the next write of exactly this data throws, as xterm's WriteBuffer does past 50M. */
throwOnWrite: string | null = null;
write(data: string, cb?: () => void) {
if (this.throwOnWrite !== null && data === this.throwOnWrite) {
this.throwOnWrite = null;
throw new Error('write data discarded, use flow control to avoid losing data');
}
// An empty write puts nothing on screen; the replay queues one only to hear
// (its callback) that everything before it has been parsed.
if (data) this.writes.push(data);
if (data && this.emulate) {
// A replay's reset (RIS) empties the buffer, as clear() does.
const reset = data.lastIndexOf('\x1bc');
if (reset !== -1) this.lineCount = 1;
this.lineCount += data.slice(reset === -1 ? 0 : reset + 2).split('\n').length - 1;
this.settleRows();
}
if (!cb) return;
if (this.holdParse) this.heldParses.push(cb);
else cb();
}
clear() {
this.writes.push('<CLEAR>');
if (this.emulate) {
this.lineCount = 1;
this.settleRows();
}
}
resizes: Array<[number, number]> = [];
resize(cols: number, rows: number) {
this.resizes.push([cols, rows]);
this.cols = cols;
this.rows = rows;
if (this.emulate) {
this.settleRows();
this.afterResize?.(cols, rows);
}
}
scrollToLine() {}
scrollToTop() {}
/** Every scrollLines() amount; the viewport moves within [0, baseY], as in xterm. */
scrolledLines: number[] = [];
scrollLines(amount: number) {
this.scrolledLines.push(amount);
const active = this.buffer.active;
active.viewportY = Math.max(0, Math.min(active.baseY, active.viewportY + amount));
}
/** Times scrollToBottom() was called; the viewport moves to the live screen, as in xterm. */
scrolledToBottom = 0;
scrollToBottom() {
this.scrolledToBottom++;
this.buffer.active.viewportY = this.buffer.active.baseY;
}
dispose() {}
type(data: string) {
this.dataCb?.(data);
}
/** What a drag selected; '' is no selection. */
selection = '';
hasSelection() {
return this.selection !== '';
}
getSelection() {
return this.selection;
}
clearSelection = vi.fn(() => {
this.selection = '';
});
focus = vi.fn();
}
+56 -24
View File
@@ -3,7 +3,7 @@
*/
import { rmSync } from 'node:fs';
import { resolve } from 'node:path';
import { basename, resolve } from 'node:path';
/** Wait for an EventEmitter to emit a specific event, with timeout */
export function waitForEvent(
@@ -39,37 +39,69 @@ export function createDeferred<T = void>(): {
}
/**
* Delete a directory tree, but ONLY when it lives inside the test HOME.
*
* SAFETY (2026-08-29): `test/setup.ts` redirects `process.env.HOME` to a
* throwaway dir and `os.homedir()` follows it, so a `rmSync(CASES_DIR,
* recursive)` normally lands inside the fixture. This gate is defense in depth
* for the day that stops being true (a test that runs outside setup.ts, an
* env override that anchors a path elsewhere): it refuses to delete anything
* not under the redirected `process.env.HOME`, so the failure mode is a
* leftover temp dir rather than a deleted PRODUCTION `~/codeman-cases`.
* Lexical `resolve()` is used because the leaf often does not exist and
* `realpathSync` would throw.
* Prefix of the throwaway HOME `test/setup.ts` creates (`mkdtempSync(join(tmpdir(), 'codeman-vitest-'))`).
* Pinned by test/test-env-isolation.test.ts, so renaming one without the other fails there.
*/
export function safeRmHomeTree(path: string): void {
export const TEST_HOME_PREFIX = 'codeman-vitest-';
/**
* True only when `process.env.HOME` is the throwaway directory `test/setup.ts` made.
*
* ⚠️ This is what the helpers below hang on, and it exists because "is the path under HOME"
* is a vacuous question when setup never ran: a bare `vitest` invocation has no setupFiles,
* HOME is the developer's REAL home, and every real file is then "under the test HOME". That is
* how a bare run on 2026-10-10 deleted the real linked-cases registry through a guard that was
* written to prevent exactly that. The temp directory's name is the only thing a real home
* cannot have.
*/
export function isSandboxedHome(): boolean {
const home = process.env.HOME;
if (!home) return;
const target = resolve(path);
const root = resolve(home);
if (target === root || target.startsWith(root + '/')) {
rmSync(target, { recursive: true, force: true });
return !!home && basename(resolve(home)).startsWith(TEST_HOME_PREFIX);
}
/**
* Throw unless the suite is running under `test/setup.ts`'s temp HOME. The shared route harness
* (`createRouteTestHarness`) calls this, so a route test built on it refuses to start against a real
* `~/.codeman`. A route test that builds its own app from `createMockRouteContext` is not covered.
*/
export function assertSandboxedHome(what = 'this test'): void {
if (!isSandboxedHome()) {
throw new Error(
`${what} reads and writes Codeman's data files and must run under test/setup.ts's temp HOME ` +
`(HOME=${process.env.HOME ?? '(unset)'}). Run it with \`npm test\` or ` +
`\`npx vitest run --config config/vitest.ci.config.ts <file>\`, never with a bare vitest.`
);
}
}
/**
* True when `path` resolves strictly inside `process.env.HOME` (or to it).
* Same rationale as `safeRmHomeTree`; use for guarded non-recursive deletes
* (single files like `linked-cases.json`) so they can never touch prod state.
* Delete a directory tree, but ONLY when it lives inside the test HOME.
*
* SAFETY (2026-08-29, tightened 2026-10-12): `test/setup.ts` redirects `process.env.HOME` to a
* throwaway dir and `os.homedir()` follows it, so a `rmSync(CASES_DIR, recursive)` normally
* lands inside the fixture. This gate is defense in depth for the day that stops being true
* (a test that runs outside setup.ts, an env override that anchors a path elsewhere): it
* refuses to delete anything unless HOME IS the throwaway dir ({@link isSandboxedHome}) and the
* target is under it, so the failure mode is a leftover temp dir rather than a deleted
* PRODUCTION `~/codeman-cases`. The first version only checked "under HOME", which a run
* without setup.ts satisfied for the user's real home.
* Lexical `resolve()` is used because the leaf often does not exist and
* `realpathSync` would throw.
*/
export function safeRmHomeTree(path: string): void {
if (!isUnderTestHome(path)) return;
rmSync(resolve(path), { recursive: true, force: true });
}
/**
* True when `path` resolves strictly inside the throwaway test HOME (or to it), and false
* whenever HOME is anything else. Same rationale as `safeRmHomeTree`; use for guarded
* non-recursive deletes (single files like `linked-cases.json`) so they can never touch
* prod state.
*/
export function isUnderTestHome(path: string): boolean {
const home = process.env.HOME;
if (!home) return false;
if (!isSandboxedHome()) return false;
const root = resolve(process.env.HOME as string);
const target = resolve(path);
const root = resolve(home);
return target === root || target.startsWith(root + '/');
}
+396
View File
@@ -0,0 +1,396 @@
/**
* @fileoverview A `vm` harness for the tile grid (tile-grid.js) with the real
* app around it: constants.js + app.js + terminal-ui.js + terminal-split.js +
* tile-grid.js + mobile-overview.js (the session state classifier the grid's
* ranking reuses) in one context, a small fake DOM (just what the grid touches) and a fake
* TerminalTile that records what the grid asks of it.
*
* `makeGridApp()` returns an app instance with everything around the grid that
* a test does not exercise stubbed as `vi.fn()`; a test overrides what it needs
* (or deletes a stub to run the real method from the prototype).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { vi } from 'vitest';
/** What has the keyboard: the last FakeEl focused (document.activeElement). */
let focusedEl: unknown = null;
export const activeElement = () => focusedEl;
/** Just enough DOM for tile-grid.js: elements with classes, children, styles and listeners. */
export class FakeEl {
id = '';
className = '';
dataset: Record<string, string> = {};
/** Inline style; `setProperty` (custom properties) writes into it too. */
style: Record<string, string> = Object.defineProperty({} as Record<string, string>, 'setProperty', {
value(this: Record<string, string>, name: string, value: string) {
this[name] = value;
},
enumerable: false,
});
inert = false;
children: FakeEl[] = [];
parentElement: FakeEl | null = null;
attrs: Record<string, string> = {};
listeners: Record<string, Array<(ev: unknown) => void>> = {};
classList = {
add: (...names: string[]) => names.forEach((n) => this._setClass(n, true)),
remove: (...names: string[]) => names.forEach((n) => this._setClass(n, false)),
toggle: (n: string, on?: boolean) => this._setClass(n, on ?? !this.classList.contains(n)),
contains: (n: string) => this.className.split(/\s+/).includes(n),
};
_setClass(name: string, on: boolean) {
const set = new Set(this.className.split(/\s+/).filter(Boolean));
if (on) set.add(name);
else set.delete(name);
this.className = [...set].join(' ');
return on;
}
append(...children: FakeEl[]) {
for (const child of children) this.appendChild(child);
}
replaceWith(other: FakeEl) {
const parent = this.parentElement;
if (!parent) return;
other.remove();
const i = parent.children.indexOf(this);
parent.children.splice(i, 1, other);
other.parentElement = parent;
this.parentElement = null;
}
title = '';
hidden = false;
disabled = false;
checked = false;
contains(other: FakeEl | null): boolean {
for (let n: FakeEl | null = other; n; n = n.parentElement) if (n === this) return true;
return false;
}
textContent = '';
value = '';
type = '';
/** Records itself as `document.activeElement` (see activeElement()). */
focus = vi.fn(() => {
focusedEl = this;
});
select = vi.fn();
setPointerCapture = vi.fn();
releasePointerCapture = vi.fn();
removeEventListener(type: string, fn: (ev: unknown) => void) {
this.listeners[type] = (this.listeners[type] ?? []).filter((f) => f !== fn);
}
appendChild(child: FakeEl) {
child.remove();
child.parentElement = this;
this.children.push(child);
return child;
}
insertBefore(child: FakeEl, ref: FakeEl | null) {
child.remove();
child.parentElement = this;
const i = ref ? this.children.indexOf(ref) : -1;
if (i === -1) this.children.push(child);
else this.children.splice(i, 0, child);
return child;
}
get firstChild() {
return this.children[0] ?? null;
}
get lastElementChild() {
return this.children.at(-1) ?? null;
}
/** In the fake document: its ancestors end at `main` or `body`. */
get isConnected(): boolean {
let n: FakeEl = this;
while (n.parentElement) n = n.parentElement;
return n === main || n === body;
}
/** A copy of this element and (deep) its subtree: classes, data, style, attributes, text; no listeners. */
cloneNode(deep = false): FakeEl {
const copy = new FakeEl();
copy.id = this.id;
copy.className = this.className;
copy.dataset = { ...this.dataset };
for (const [k, v] of Object.entries(this.style)) copy.style[k] = v;
copy.attrs = { ...this.attrs };
copy.textContent = this.textContent;
copy.title = this.title;
copy.hidden = this.hidden;
if (deep) for (const child of this.children) copy.appendChild(child.cloneNode(true));
return copy;
}
get nextSibling() {
const siblings = this.parentElement?.children ?? [];
return siblings[siblings.indexOf(this) + 1] ?? null;
}
remove() {
if (!this.parentElement) return;
const siblings = this.parentElement.children;
const i = siblings.indexOf(this);
if (i !== -1) siblings.splice(i, 1);
this.parentElement = null;
}
setAttribute(k: string, v: string) {
this.attrs[k] = v;
}
getAttribute(k: string) {
return this.attrs[k] ?? null;
}
removeAttribute(k: string) {
delete this.attrs[k];
if (k === 'title') this.title = '';
}
/** Per event type, whether each listener was registered for the capture phase. */
captureFlags: Record<string, boolean[]> = {};
addEventListener(type: string, fn: (ev: unknown) => void, opts?: boolean | { capture?: boolean }) {
(this.listeners[type] ||= []).push(fn);
(this.captureFlags[type] ||= []).push(opts === true || (typeof opts === 'object' && !!opts?.capture));
}
dispatch(type: string, ev: unknown = {}) {
for (const fn of this.listeners[type] ?? []) fn(ev);
}
getBoundingClientRect() {
return { width: 2400, height: 1200, top: 0, left: 0, right: 2400, bottom: 1200 };
}
/** `.class` selectors only: the first descendant carrying that class. */
querySelector(sel: string): FakeEl | null {
if (!sel.startsWith('.') || /[\s[>:]/.test(sel)) return null;
const cls = sel.slice(1);
for (const child of this.children) {
if (child.classList.contains(cls)) return child;
const deeper = child.querySelector(sel);
if (deeper) return deeper;
}
return null;
}
}
/** A TerminalTile stand-in: records what the grid asks of it. */
export class FakeTile {
static all: FakeTile[] = [];
_wsReady = false;
_stoppedCode: number | null = null;
_destroyed = false;
fontSize: number | null;
terminal = { focus: vi.fn(), options: { fontSize: 0 } as Record<string, unknown> };
connect = vi.fn(async () => {});
reconnectNow = vi.fn();
fit = vi.fn();
localFit = vi.fn();
paneStarted = vi.fn();
destroy = vi.fn(() => {
this._destroyed = true;
});
onExit: ((code: number) => void) | null;
constructor(
public sessionId: string,
public mountEl: FakeEl,
public opts: Record<string, unknown>
) {
this.fontSize = (opts.fontSize as number) ?? null;
this.onExit = (opts.onExit as (code: number) => void) ?? null;
FakeTile.all.push(this);
}
}
export const main = new FakeEl();
main.className = 'main';
/** The main terminal's `.terminal-wrap` (Pane A while a split is open). */
export const wrap = new FakeEl();
wrap.className = 'terminal-wrap';
main.appendChild(wrap);
export const section = new FakeEl();
section.id = 'tileGrid';
section.className = 'tile-grid';
main.appendChild(section);
/** The grid's element for one tiled session (its `.tile`), found by its data-session-id. */
export const tileEl = (id: string) => section.children.find((el) => el.dataset.sessionId === id) as FakeEl;
/** Extra elements `document.querySelector` finds, by exact selector (e.g. '.btn-split'). */
export const bySelector = new Map<string, FakeEl>();
export const body = new FakeEl();
/** The context's `fetch`; a test sets what it answers. */
export const fetchSpy = vi.fn(async (..._args: unknown[]) => ({ ok: true, json: async () => ({}) }));
/** What `document.elementFromPoint` answers (the gesture overlay's hit test); a test sets it. */
export const pointHits: { at: ((x: number, y: number) => FakeEl | null) | null } = { at: null };
/** `document.addEventListener`, so a test can find a listener the app installed. */
export const documentAddEventListener = vi.fn();
export const documentRemoveEventListener = vi.fn();
export const localStore = new Map<string, string>();
/** The clock behind `performance.now` inside the context; tests move it with advanceClock(). */
let clock = 100_000;
export function advanceClock(ms: number) {
clock += ms;
}
export const clockNow = () => clock;
/** Every callback the code under test handed a PerformanceObserver, newest last. */
export const perfObserverCallbacks: Array<(list: { getEntries(): unknown[] }) => void> = [];
/** Animation-frame callbacks the code under test queued (id = index + 1); a test runs them. */
export const rafCallbacks: Array<() => void> = [];
/**
* Runs every queued animation frame, and the frames those queue in turn (the
* grid builds its tiles' terminals one per frame), until none is left.
*/
export function flushFrames(limit = 100) {
for (let n = 0; n < limit && rafCallbacks.length; n++) for (const cb of rafCallbacks.splice(0)) cb();
}
/** What the code under test deferred with requestIdleCallback; a test runs them. */
export const idleCallbacks: Array<() => void> = [];
export const windowStub: Record<string, unknown> = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
CodemanBase: { base: '' },
innerWidth: 2400,
innerHeight: 1200,
};
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../../src/web/public/${f}`), 'utf8');
const context = vm.createContext({
console: { ...console, log: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
performance: { now: () => clock },
PerformanceObserver: class {
constructor(cb: (list: { getEntries(): unknown[] }) => void) {
perfObserverCallbacks.push(cb);
}
observe() {}
disconnect() {}
},
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
requestAnimationFrame: (cb: () => void) => rafCallbacks.push(cb),
cancelAnimationFrame: (id: number) => {
if (id > 0) rafCallbacks[id - 1] = () => {};
},
requestIdleCallback: (cb: () => void) => idleCallbacks.push(cb),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: (...args: unknown[]) => fetchSpy(...args),
navigator: { onLine: true },
location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' },
history: { replaceState: vi.fn(), state: null },
document: {
addEventListener: documentAddEventListener,
removeEventListener: documentRemoveEventListener,
documentElement: { dataset: {} },
createElement: () => new FakeEl(),
createElementNS: () => new FakeEl(),
get activeElement() {
return focusedEl;
},
getElementById: (id: string) => (id === 'tileGrid' ? section : (bySelector.get(`#${id}`) ?? null)),
elementFromPoint: (x: number, y: number) => pointHits.at?.(x, y) ?? null,
body,
querySelector: (sel: string) =>
bySelector.get(sel) ?? (sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : null),
querySelectorAll: () => [],
},
localStorage: {
getItem: (k: string) => localStore.get(k) ?? null,
setItem: (k: string, v: string) => localStore.set(k, String(v)),
removeItem: (k: string) => localStore.delete(k),
},
window: windowStub,
VoiceInput: { cleanup: vi.fn() },
MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
});
vm.runInContext(
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-split.js')}\n` +
`${read('tile-grid.js')}\n${read('mobile-overview.js')}\n${read('image-input.js')}\n` +
'globalThis.__CodemanApp = CodemanApp;',
context
);
windowStub.TerminalTile = FakeTile;
windowStub.TileLoadQueue = class {
schedule(_t: unknown, _k: string, run: () => Promise<void>) {
return run();
}
drop() {}
};
export const CodemanApp = (context as unknown as { __CodemanApp: { prototype: Record<string, unknown> } }).__CodemanApp;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type GridApp = Record<string, any>;
/** Stubbed around the grid: panels, tabs, the main terminal's own select path. */
const STUBS = [
'_cleanupPreviousSession',
'hideWelcome',
'showWelcome',
'markIdleAlertSeen',
'renderSessionTabs',
'renderRalphStatePanel',
'renderProjectInsightsPanel',
'_updateActiveTabImmediate',
'_refreshSessionPanels',
'_updateSseSubscription',
'_updateConnectionIndicator',
'_activateFileBrowserSession',
'_hideWebviewLayer',
'closeSessionSidebarOnHandheld',
'updateAttachmentHistoryBadge',
'refreshHostWakeBanner',
'_connectWs',
'sendResize',
'showToast',
'_cleanupSessionData',
'stopSystemStatsPolling',
];
/**
* An app with sessions `ids` plus one more (`s-other`, FIRST in sessionOrder and
* never tiled), `s-a` active, nothing open.
*/
export function makeGridApp(ids: string[] = ['s-a', 's-b', 's-c']): GridApp {
const app = Object.create(CodemanApp.prototype) as GridApp;
app.sessions = new Map(
[...ids, 's-other'].map((id) => [id, { id, name: id, mode: 'claude', pid: 1, workingDir: '/w' }])
);
app.sessionOrder = ['s-other', ...ids];
app.detachedSessions = new Set();
app.isSoloWindow = false;
app.activeSessionId = ids[0];
app._selectGeneration = 0;
app._xtermSnapshots = new Map();
app.terminalBufferCache = new Map();
app._pendingDeliveries = new Map();
app._closingSessions = new Set();
app.pendingHooks = new Map();
app.terminal = { writeln: vi.fn(), clear: vi.fn(), focus: vi.fn(), options: { fontSize: 14 } };
for (const name of STUBS) app[name] = vi.fn();
app.loadAppSettingsFromStorage = () => ({});
return app;
}
/** Resets the shared fake DOM and tile registry between tests. */
export function resetGridHarness() {
FakeTile.all = [];
// A test's prefers-reduced-motion answer (window.matchMedia) goes with it.
delete windowStub.matchMedia;
focusedEl = null;
idleCallbacks.length = 0;
rafCallbacks.length = 0;
localStore.clear();
windowStub.innerWidth = 2400;
// The CLI catalog the server injects (labels for the harness logos); a test sets its own.
delete windowStub.__codemanCliCatalog;
section.children = [];
// The grid binds its file-drop guard on the section once per app; an earlier
// test's app must not still be listening there.
section.listeners = {};
section.captureFlags = {};
main.className = 'main';
// A split a test left open moved .terminal-wrap into its container, with
// Pane A's header strip in it.
main.children = [];
wrap.children = [];
main.appendChild(wrap);
main.appendChild(section);
bySelector.clear();
body.children = [];
pointHits.at = null;
}