Files
openchamber/packages/ui/src/hooks/useTerminalSessionKeepalive.test.tsx
T
Matt Visnovsky 4e0eed717d fix: Project action terminal lifecycle (#3287)
* fix(terminal): make command sessions own action lifecycle

* fix(ui): reconcile project action terminal state

* feat(ui): show running project actions in terminal tabs

* feat(ui): run project actions from linked worktrees

* fix(ui): guard project action reconciliation

* fix(ui): scope project action preview fallback

* fix(ui): default project actions to worktrees

* fix(ui): reveal project action terminals

* fix(ui): retain terminal output after snapshot replay

* fix(ui): restore running action terminals on revisit
2026-09-05 12:04:36 +03:00

187 lines
6.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Window } from 'happy-dom';
import { useTerminalStore } from '@/stores/useTerminalStore';
const touchCalls: string[][] = [];
const terminal = {
touchSessions: async (sessionIds: string[]) => {
touchCalls.push(sessionIds);
},
};
mock.module('@/hooks/useRuntimeAPIs', () => ({
useRuntimeAPIs: () => ({ terminal }),
}));
const { useTerminalSessionKeepalive } = await import('./useTerminalSessionKeepalive');
const HookHarness = () => {
useTerminalSessionKeepalive();
return null;
};
describe('useTerminalSessionKeepalive', () => {
let windowInstance: Window;
let host: HTMLDivElement;
let root: Root;
const originalSetInterval = globalThis.setInterval;
const originalClearInterval = globalThis.clearInterval;
const scheduledIntervals = new Map<number, { delay: number; callback: () => void }>();
let nextIntervalId = 1;
beforeEach(() => {
windowInstance = new Window({ url: 'http://localhost/' });
scheduledIntervals.clear();
nextIntervalId = 1;
Object.assign(globalThis, {
window: windowInstance,
document: windowInstance.document,
navigator: windowInstance.navigator,
HTMLElement: windowInstance.HTMLElement,
Element: windowInstance.Element,
Node: windowInstance.Node,
Event: windowInstance.Event,
IS_REACT_ACT_ENVIRONMENT: true,
});
const windowSetInterval = windowInstance.setInterval.bind(windowInstance);
const windowClearInterval = windowInstance.clearInterval.bind(windowInstance);
const fakeSetInterval = (callback: TimerHandler, delay = 0, ...args: unknown[]) => {
const liveHandle = windowSetInterval(() => undefined, 0);
windowClearInterval(liveHandle);
const id = nextIntervalId;
nextIntervalId += 1;
const callbackFn = () => {
if (callback instanceof Function) {
callback(...args);
return;
}
new Function(String(callback))();
};
scheduledIntervals.set(id, {
delay,
callback: callbackFn,
});
return id;
};
const fakeClearInterval = (intervalId: number) => {
scheduledIntervals.delete(intervalId);
};
Object.defineProperty(globalThis, 'setInterval', {
configurable: true,
writable: true,
value: fakeSetInterval,
});
Object.defineProperty(globalThis, 'clearInterval', {
configurable: true,
writable: true,
value: fakeClearInterval,
});
host = document.createElement('div');
document.body.appendChild(host);
root = createRoot(host);
touchCalls.length = 0;
useTerminalStore.getState().clearAll();
const interactiveTabId = useTerminalStore.getState().createTab('/repo');
useTerminalStore.getState().setTabSessionId('/repo', interactiveTabId, 'interactive-session');
useTerminalStore.getState().setTabLifecycle('/repo', interactiveTabId, 'running');
const runningActionTabId = useTerminalStore.getState().createTab('/repo');
useTerminalStore.getState().setTabPurpose('/repo', runningActionTabId, {
type: 'project-action',
actionId: 'build',
executionId: 'exec-1',
});
useTerminalStore.getState().setTabSessionId('/repo', runningActionTabId, 'action-session');
useTerminalStore.getState().setTabLifecycle('/repo', runningActionTabId, 'running');
const exitedTabId = useTerminalStore.getState().createTab('/repo');
useTerminalStore.getState().setTabSessionId('/repo', exitedTabId, 'exited-session');
useTerminalStore.getState().setTabLifecycle('/repo', exitedTabId, 'exited');
});
afterEach(async () => {
await act(async () => root.unmount());
Object.defineProperty(globalThis, 'setInterval', {
configurable: true,
writable: true,
value: originalSetInterval,
});
Object.defineProperty(globalThis, 'clearInterval', {
configurable: true,
writable: true,
value: originalClearInterval,
});
useTerminalStore.getState().clearAll();
});
test('touches non-exited tab sessions immediately and on the interval, without a TerminalView mount', async () => {
await act(async () => {
root.render(React.createElement(HookHarness));
});
expect(touchCalls).toEqual([['interactive-session', 'action-session']]);
expect([...scheduledIntervals.values()].map((entry) => entry.delay)).toEqual([10 * 60 * 1000]);
await act(async () => {
[...scheduledIntervals.values()][0]?.callback();
});
expect(touchCalls).toEqual([
['interactive-session', 'action-session'],
['interactive-session', 'action-session'],
]);
});
test('skips touches while offline and clears the interval on unmount', async () => {
Object.defineProperty(globalThis.navigator, 'onLine', {
configurable: true,
get: () => false,
});
await act(async () => {
root.render(React.createElement(HookHarness));
});
expect(touchCalls).toEqual([]);
expect(scheduledIntervals.size).toBe(1);
await act(async () => {
root.unmount();
});
expect(scheduledIntervals.size).toBe(0);
});
});
/**
* Every app root that renders terminals must own keepalive itself: the server
* reaps sessions with no attached socket after 30 idle minutes, and the loop
* no longer lives in TerminalView. The mobile shell runs its own root and
* mounts neither desktop layout, so a missing call there silently reintroduces
* background PTY reaping (reviewer finding on the revisit fix).
*/
describe('terminal keepalive root coverage', () => {
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootSources: Array<[string, string]> = [
['MainLayout', join(__dirname, '..', 'components', 'layout', 'MainLayout.tsx')],
['VSCodeLayout', join(__dirname, '..', 'components', 'layout', 'VSCodeLayout.tsx')],
['MobileApp shell', join(__dirname, '..', 'apps', 'MobileApp.tsx')],
];
for (const [rootName, sourcePath] of rootSources) {
test(`${rootName} mounts useTerminalSessionKeepalive`, () => {
const source = readFileSync(sourcePath, 'utf-8');
expect(source).toContain('useTerminalSessionKeepalive()');
});
}
});