agent-ecosystem/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx

360 lines
10 KiB
TypeScript

import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useTmuxInstallerBanner } from '../useTmuxInstallerBanner';
import type { TmuxInstallerSnapshot, TmuxStatus } from '@features/tmux-installer/contracts';
type HookResult = ReturnType<typeof useTmuxInstallerBanner>;
const baseStatus: TmuxStatus = {
platform: 'darwin',
nativeSupported: true,
checkedAt: new Date().toISOString(),
host: {
available: false,
version: null,
binaryPath: null,
error: null,
},
effective: {
available: false,
location: null,
version: null,
binaryPath: null,
runtimeReady: false,
detail: 'tmux is optional. Install it only if you want pane transport.',
},
error: null,
autoInstall: {
supported: true,
strategy: 'homebrew',
packageManagerLabel: 'Homebrew',
requiresTerminalInput: false,
requiresAdmin: false,
requiresRestart: false,
mayOpenExternalWindow: false,
reasonIfUnsupported: null,
manualHints: [],
},
};
const idleSnapshot: TmuxInstallerSnapshot = {
phase: 'idle',
strategy: null,
message: null,
detail: null,
error: null,
canCancel: false,
acceptsInput: false,
inputPrompt: null,
inputSecret: false,
logs: [],
updatedAt: new Date().toISOString(),
};
let capturedHook: HookResult | null = null;
let progressListener: ((event: unknown, progress: TmuxInstallerSnapshot) => void) | null = null;
const { mockApi } = vi.hoisted(() => ({
mockApi: {
isElectronMode: vi.fn(() => true),
tmux: {
getStatus: vi.fn<() => Promise<TmuxStatus>>(),
getInstallerSnapshot: vi.fn<() => Promise<TmuxInstallerSnapshot>>(),
install: vi.fn<() => Promise<void>>(),
cancelInstall: vi.fn<() => Promise<void>>(),
submitInstallerInput: vi.fn<(input: string) => Promise<void>>(),
onProgress:
vi.fn<
(callback: (event: unknown, progress: TmuxInstallerSnapshot) => void) => () => void
>(),
},
openExternal: vi.fn<(url: string) => Promise<void>>(),
},
}));
vi.mock('@renderer/api', () => ({
api: mockApi,
isElectronMode: mockApi.isElectronMode,
}));
function Harness(): React.JSX.Element | null {
capturedHook = useTmuxInstallerBanner();
return null;
}
describe('useTmuxInstallerBanner', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
capturedHook = null;
progressListener = null;
mockApi.isElectronMode.mockReturnValue(true);
mockApi.tmux.getStatus.mockResolvedValue(baseStatus);
mockApi.tmux.getInstallerSnapshot.mockResolvedValue(idleSnapshot);
mockApi.tmux.install.mockResolvedValue(undefined);
mockApi.tmux.cancelInstall.mockResolvedValue(undefined);
mockApi.tmux.submitInstallerInput.mockResolvedValue(undefined);
mockApi.openExternal.mockResolvedValue(undefined);
mockApi.tmux.onProgress.mockImplementation((callback) => {
progressListener = callback;
return () => {
if (progressListener === callback) {
progressListener = null;
}
};
});
});
afterEach(() => {
vi.clearAllMocks();
progressListener = null;
capturedHook = null;
document.body.innerHTML = '';
});
it('loads tmux status immediately on mount', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
await Promise.resolve();
});
expect(mockApi.tmux.getStatus).toHaveBeenCalledTimes(1);
expect(mockApi.tmux.getInstallerSnapshot).toHaveBeenCalledTimes(1);
expect(capturedHook?.viewModel.visible).toBe(true);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('stays idle and hidden outside Electron mode', async () => {
mockApi.isElectronMode.mockReturnValue(false);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
await Promise.resolve();
});
expect(mockApi.tmux.getStatus).not.toHaveBeenCalled();
expect(mockApi.tmux.getInstallerSnapshot).not.toHaveBeenCalled();
expect(capturedHook?.viewModel.visible).toBe(false);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('refreshes tmux status again after error and cancelled progress events', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
await Promise.resolve();
});
mockApi.tmux.getStatus.mockClear();
mockApi.tmux.getInstallerSnapshot.mockClear();
await act(async () => {
progressListener?.(null, {
...idleSnapshot,
phase: 'error',
error: 'tmux install failed',
});
await Promise.resolve();
await Promise.resolve();
});
expect(mockApi.tmux.getStatus).toHaveBeenCalledTimes(1);
expect(mockApi.tmux.getInstallerSnapshot).toHaveBeenCalledTimes(1);
mockApi.tmux.getStatus.mockClear();
mockApi.tmux.getInstallerSnapshot.mockClear();
await act(async () => {
progressListener?.(null, {
...idleSnapshot,
phase: 'cancelled',
message: 'tmux installation cancelled',
});
await Promise.resolve();
await Promise.resolve();
});
expect(mockApi.tmux.getStatus).toHaveBeenCalledTimes(1);
expect(mockApi.tmux.getInstallerSnapshot).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps the banner visible during background refreshes after installer progress updates', async () => {
let resolveStatus: ((value: TmuxStatus) => void) | null = null;
let resolveSnapshot: ((value: TmuxInstallerSnapshot) => void) | null = null;
mockApi.tmux.getStatus.mockResolvedValueOnce(baseStatus).mockImplementationOnce(
() =>
new Promise<TmuxStatus>((resolve) => {
resolveStatus = resolve;
})
);
mockApi.tmux.getInstallerSnapshot.mockResolvedValueOnce(idleSnapshot).mockImplementationOnce(
() =>
new Promise<TmuxInstallerSnapshot>((resolve) => {
resolveSnapshot = resolve;
})
);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
await Promise.resolve();
});
expect(capturedHook?.viewModel.visible).toBe(true);
await act(async () => {
progressListener?.(null, {
...idleSnapshot,
phase: 'waiting_for_external_step',
message: 'Finish Ubuntu setup in WSL',
});
await Promise.resolve();
});
expect(capturedHook?.viewModel.visible).toBe(true);
expect(capturedHook?.viewModel.phase).toBe('waiting_for_external_step');
await act(async () => {
resolveStatus?.(baseStatus);
resolveSnapshot?.({
...idleSnapshot,
phase: 'waiting_for_external_step',
message: 'Finish Ubuntu setup in WSL',
});
await Promise.resolve();
await Promise.resolve();
});
expect(capturedHook?.viewModel.visible).toBe(true);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not let an older refreshed snapshot overwrite newer live progress', async () => {
let resolveStatus: ((value: TmuxStatus) => void) | null = null;
let resolveSnapshot: ((value: TmuxInstallerSnapshot) => void) | null = null;
const olderSnapshot = {
...idleSnapshot,
phase: 'idle' as const,
updatedAt: '2099-04-14T10:00:00.000Z',
};
const newerProgress = {
...idleSnapshot,
phase: 'waiting_for_external_step' as const,
message: 'Finish Ubuntu setup in WSL',
updatedAt: '2099-04-14T10:00:05.000Z',
};
mockApi.tmux.getStatus.mockResolvedValueOnce(baseStatus).mockImplementationOnce(
() =>
new Promise<TmuxStatus>((resolve) => {
resolveStatus = resolve;
})
);
mockApi.tmux.getInstallerSnapshot.mockResolvedValueOnce(idleSnapshot).mockImplementationOnce(
() =>
new Promise<TmuxInstallerSnapshot>((resolve) => {
resolveSnapshot = resolve;
})
);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
progressListener?.(null, newerProgress);
await Promise.resolve();
});
expect(capturedHook?.viewModel.phase).toBe('waiting_for_external_step');
await act(async () => {
resolveStatus?.({
...baseStatus,
checkedAt: '2099-04-14T10:00:00.000Z',
});
resolveSnapshot?.(olderSnapshot);
await Promise.resolve();
await Promise.resolve();
});
expect(capturedHook?.viewModel.phase).toBe('waiting_for_external_step');
expect(capturedHook?.viewModel.title).toBe('Finish Ubuntu setup in WSL');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('stores action errors instead of letting rejected installer calls disappear', async () => {
mockApi.tmux.install.mockRejectedValueOnce(new Error('bridge failed'));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
await capturedHook?.install();
await Promise.resolve();
});
expect(capturedHook?.viewModel.error).toBe('bridge failed');
expect(capturedHook?.viewModel.body).toBe('bridge failed');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});