mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
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:
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user