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
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
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()');
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user