mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
commit
57b2dcc466
858 files changed
+173626
-7078
No files matched your search
+9
-1
@@ -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';
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 + '/');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user