test(tiles): park-guards runs on the shared grid harness

tile-grid-park-guards.test.ts carried its own copy of the fake DOM, written
before test/mocks/tile-grid-vm.ts existed, including the remove() that spliced
the wrong element when a child was no longer listed (fixed in the shared copy
only). It now uses the shared harness, which gains what the guards need: a
settable clock behind performance.now, the PerformanceObserver callbacks the
code under test registers, and localFit on the fake tile. Same 35 cases.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-06 17:41:00 +02:00
parent 88447e6c2b
commit 13b2989886
2 changed files with 43 additions and 208 deletions
+17 -1
View File
@@ -101,6 +101,7 @@ export class FakeTile {
connect = vi.fn(async () => {}); connect = vi.fn(async () => {});
reconnectNow = vi.fn(); reconnectNow = vi.fn();
fit = vi.fn(); fit = vi.fn();
localFit = vi.fn();
destroy = vi.fn(() => { destroy = vi.fn(() => {
this._destroyed = true; this._destroyed = true;
}); });
@@ -132,6 +133,14 @@ export const body = new FakeEl();
/** `document.addEventListener`, so a test can find a listener the app installed. */ /** `document.addEventListener`, so a test can find a listener the app installed. */
export const documentAddEventListener = vi.fn(); export const documentAddEventListener = vi.fn();
export const localStore = new Map<string, string>(); 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> = [];
/** What the code under test deferred with requestIdleCallback; a test runs them. */ /** What the code under test deferred with requestIdleCallback; a test runs them. */
export const idleCallbacks: Array<() => void> = []; export const idleCallbacks: Array<() => void> = [];
export const windowStub: Record<string, unknown> = { export const windowStub: Record<string, unknown> = {
@@ -145,7 +154,14 @@ export const windowStub: Record<string, unknown> = {
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../../src/web/public/${f}`), 'utf8'); const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../../src/web/public/${f}`), 'utf8');
const context = vm.createContext({ const context = vm.createContext({
console: { ...console, log: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, console: { ...console, log: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
performance: { now: () => 100_000 }, performance: { now: () => clock },
PerformanceObserver: class {
constructor(cb: (list: { getEntries(): unknown[] }) => void) {
perfObserverCallbacks.push(cb);
}
observe() {}
disconnect() {}
},
setInterval: vi.fn(), setInterval: vi.fn(),
clearInterval: vi.fn(), clearInterval: vi.fn(),
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms), setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
+26 -207
View File
@@ -25,217 +25,41 @@
* tile destroyed, `_lastResizeDims` reset, the main terminal's cached content * tile destroyed, `_lastResizeDims` reset, the main terminal's cached content
* for EVERY tiled id invalidated, and the focused session replayed fresh. * for EVERY tiled id invalidated, and the focused session replayed fresh.
* *
* Real code: constants.js + app.js + terminal-ui.js + tile-grid.js in one `vm` * Real code: constants.js + app.js + terminal-ui.js + terminal-split.js +
* context, with a small fake DOM and a fake TerminalTile. Port: N/A. * tile-grid.js in one `vm` context, with the shared fake DOM and fake
* TerminalTile (test/mocks/tile-grid-vm.ts). Port: N/A.
*/ */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeTile,
advanceClock,
clockNow,
localStore,
main,
makeGridApp,
perfObserverCallbacks,
resetGridHarness,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
/** Just enough DOM for tile-grid.js: elements with classes, children, styles and listeners. */ type App = GridApp;
class FakeEl {
id = '';
className = '';
dataset: Record<string, string> = {};
style: Record<string, string> = {};
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;
}
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 nextSibling() {
const siblings = this.parentElement?.children ?? [];
return siblings[siblings.indexOf(this) + 1] ?? null;
}
remove() {
if (!this.parentElement) return;
const siblings = this.parentElement.children;
siblings.splice(siblings.indexOf(this), 1);
this.parentElement = null;
}
setAttribute(k: string, v: string) {
this.attrs[k] = v;
}
addEventListener(type: string, fn: (ev: unknown) => void) {
(this.listeners[type] ||= []).push(fn);
}
removeEventListener() {}
getBoundingClientRect() {
return { width: 2400, height: 1200 };
}
querySelector() {
return null;
}
}
const main = new FakeEl();
main.className = 'main';
const wrap = new FakeEl();
wrap.className = 'terminal-wrap';
main.appendChild(wrap);
const section = new FakeEl();
section.id = 'tileGrid';
section.className = 'tile-grid';
main.appendChild(section);
const documentStub = {
addEventListener: vi.fn(),
documentElement: { dataset: {} },
createElement: () => new FakeEl(),
getElementById: (id: string) => (id === 'tileGrid' ? section : null),
querySelector: (sel: string) => (sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : null),
querySelectorAll: () => [],
};
/** A TerminalTile stand-in: records what the grid asks of it. */
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();
destroy = vi.fn(() => {
this._destroyed = true;
});
constructor(
public sessionId: string,
public mountEl: FakeEl,
public opts: Record<string, unknown>
) {
this.fontSize = (opts.fontSize as number) ?? null;
FakeTile.all.push(this);
}
}
let observerCallback: ((list: { getEntries(): unknown[] }) => void) | null = null;
let clock = 100_000;
const localStore = new Map<string, string>();
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
const windowStub: Record<string, unknown> = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
CodemanBase: { base: '' },
innerWidth: 2400,
innerHeight: 1200,
};
const context = vm.createContext({
console: { ...console, log: vi.fn(), debug: vi.fn(), warn: vi.fn() },
performance: { now: () => clock },
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
requestAnimationFrame: vi.fn(),
requestIdleCallback: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
PerformanceObserver: class {
constructor(cb: (list: { getEntries(): unknown[] }) => void) {
observerCallback = cb;
}
observe() {}
disconnect() {}
},
fetch: vi.fn(),
navigator: { onLine: true },
location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' },
document: documentStub,
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('tile-grid.js')}\n` +
'globalThis.__CodemanApp = CodemanApp;',
context
);
windowStub.TerminalTile = FakeTile;
windowStub.TileLoadQueue = class {
schedule(_t: unknown, _k: string, run: () => Promise<void>) {
return run();
}
drop() {}
};
const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
type App = Record<string, any>;
const IDS = ['s-a', 's-b', 's-c']; const IDS = ['s-a', 's-b', 's-c'];
/**
* The shared app plus what these guards exercise: the REAL sendResize (the
* harness stubs it), and spies on the main terminal's own paths.
*/
function makeApp(): App { function makeApp(): App {
const app = Object.create(CodemanApp.prototype) as App; const app = makeGridApp(IDS);
app.sessions = new Map( delete app.sendResize;
[...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 = 's-a';
app._selectGeneration = 0;
app._initGeneration = 1; app._initGeneration = 1;
app._xtermSnapshots = new Map();
app.terminalBufferCache = new Map();
app._pendingDeliveries = new Map();
app._closingSessions = new Set();
app.pendingHooks = new Map();
app.isOnline = true; app.isOnline = true;
app._connectionStatus = 'connected'; app._connectionStatus = 'connected';
app._wsState = 'disconnected'; app._wsState = 'disconnected';
app._lastResizeDims = { cols: 100, rows: 30 }; app._lastResizeDims = { cols: 100, rows: 30 };
app.terminal = { writeln: vi.fn(), clear: vi.fn(), focus: vi.fn(), options: { fontSize: 14 } };
// Everything around the grid that is not under test here.
for (const name of [ for (const name of [
'_cleanupPreviousSession',
'hideWelcome',
'showWelcome',
'markIdleAlertSeen',
'renderSessionTabs',
'_updateActiveTabImmediate',
'_refreshSessionPanels',
'_updateSseSubscription',
'_updateConnectionIndicator',
'_activateFileBrowserSession',
'_hideWebviewLayer',
'closeSessionSidebarOnHandheld',
'updateAttachmentHistoryBadge',
'refreshHostWakeBanner',
'selectSession', 'selectSession',
'batchTerminalWrite', 'batchTerminalWrite',
'_connectWs', '_connectWs',
@@ -248,16 +72,11 @@ function makeApp(): App {
app[name] = vi.fn(); app[name] = vi.fn();
} }
app._fetchTerminalCapture = vi.fn(async () => ({ json: { data: {} }, headersAt: 0 })); app._fetchTerminalCapture = vi.fn(async () => ({ json: { data: {} }, headersAt: 0 }));
app.loadAppSettingsFromStorage = () => ({});
return app; return app;
} }
beforeEach(() => { beforeEach(() => {
FakeTile.all = []; resetGridHarness();
localStore.clear();
windowStub.innerWidth = 2400;
section.children = [];
main.className = 'main';
}); });
describe('parking the main terminal', () => { describe('parking the main terminal', () => {
@@ -441,9 +260,9 @@ describe('guards: each stands aside while tiles own the terminal, and acts other
app._disableWebGLSticky = vi.fn(); app._disableWebGLSticky = vi.fn();
app._scheduleTerminalRepaint = vi.fn(); app._scheduleTerminalRepaint = vi.fn();
app._installWebGLLongTaskGuard(); app._installWebGLLongTaskGuard();
clock += 60_000; // past the install grace period advanceClock(60_000); // past the install grace period
const longTasks = [0, 1, 2].map((i) => ({ duration: 400, startTime: clock - 100 * i })); const longTasks = [0, 1, 2].map((i) => ({ duration: 400, startTime: clockNow() - 100 * i }));
observerCallback?.({ getEntries: () => longTasks }); perfObserverCallbacks.at(-1)?.({ getEntries: () => longTasks });
expect(app._disableWebGLSticky).toHaveBeenCalledTimes(open ? 0 : 1); expect(app._disableWebGLSticky).toHaveBeenCalledTimes(open ? 0 : 1);
} }
); );