Files
spoon/apps/next/tests/component/machine-shell.test.tsx
T

212 lines
5.6 KiB
TypeScript

import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MachineShell } from '../../src/components/machine/machine-shell';
const { mockUseQuery, fetchSpy } = vi.hoisted(() => ({
mockUseQuery: vi.fn(),
fetchSpy: vi.fn(),
}));
vi.mock('convex/react', () => ({
useQuery: mockUseQuery,
}));
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
vi.mock('sonner', () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
vi.mock('@/components/agent-workspace/xterm-session', () => ({
XtermSession: () => <div>terminal</div>,
}));
vi.mock('@/components/agent-workspace/code-editor', () => ({
CodeEditor: () => <div>editor</div>,
}));
const stoppedStatus = {
running: false,
image: 'spoon/devbox:latest',
startedAt: null,
memoryLimitBytes: null,
containerName: 'spoon-home-gib',
};
const runningStatus = {
...stoppedStatus,
running: true,
startedAt: new Date().toISOString(),
};
// Point the status endpoint at a running box so the Stop/Restart controls render.
const useRunningStatus = () => {
fetchSpy.mockImplementation((input: RequestInfo | URL) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input.url;
if (url.startsWith('/api/box/status')) {
return Promise.resolve(jsonResponse({ status: runningStatus }));
}
if (url.startsWith('/api/box/tree')) {
return Promise.resolve(jsonResponse({ tree }));
}
if (url.startsWith('/api/box/lifecycle')) {
return Promise.resolve(jsonResponse({ status: stoppedStatus }));
}
return Promise.resolve(jsonResponse({}));
});
};
const lifecycleCalls = () =>
fetchSpy.mock.calls.filter(([input]) => {
const url = typeof input === 'string' ? input : '';
return url.startsWith('/api/box/lifecycle');
});
const tree = {
name: '',
path: '',
type: 'directory',
children: [{ name: 'README.md', path: 'README.md', type: 'file' }],
};
const jsonResponse = (body: unknown) => ({
ok: true,
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
});
beforeEach(() => {
mockUseQuery.mockReturnValue({
username: 'gib',
firstName: 'Gib',
enabled: true,
});
fetchSpy.mockImplementation((input: RequestInfo | URL) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input.url;
if (url.startsWith('/api/box/status')) {
return Promise.resolve(jsonResponse({ status: stoppedStatus }));
}
if (url.startsWith('/api/box/tree')) {
return Promise.resolve(jsonResponse({ tree }));
}
if (url.startsWith('/api/box/lifecycle')) {
return Promise.resolve(
jsonResponse({
status: {
...stoppedStatus,
running: true,
startedAt: new Date().toISOString(),
},
}),
);
}
return Promise.resolve(jsonResponse({}));
});
vi.stubGlobal('fetch', fetchSpy);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe('MachineShell', () => {
it('renders a stopped status card with a Start button', async () => {
render(<MachineShell />);
expect(await screen.findByText('Stopped')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument();
});
it('starts the box when Start is clicked', async () => {
render(<MachineShell />);
const startButton = await screen.findByRole('button', { name: 'Start' });
fireEvent.click(startButton);
await waitFor(() =>
expect(fetchSpy).toHaveBeenCalledWith(
'/api/box/lifecycle',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ action: 'start' }),
}),
),
);
});
it('renders the home file tree', async () => {
render(<MachineShell />);
expect(await screen.findByText('README.md')).toBeInTheDocument();
});
it('renders the terminal', async () => {
render(<MachineShell />);
expect(await screen.findByText('terminal')).toBeInTheDocument();
});
it('does not stop the box until the confirmation is accepted', async () => {
useRunningStatus();
render(<MachineShell />);
const stopButton = await screen.findByRole('button', { name: 'Stop' });
fireEvent.click(stopButton);
// The confirmation dialog intervenes: no lifecycle POST fires on the click.
const confirm = await screen.findByRole('button', { name: 'Stop machine' });
expect(lifecycleCalls()).toHaveLength(0);
fireEvent.click(confirm);
await waitFor(() =>
expect(fetchSpy).toHaveBeenCalledWith(
'/api/box/lifecycle',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ action: 'stop' }),
}),
),
);
});
it('does not restart the box until the confirmation is accepted', async () => {
useRunningStatus();
render(<MachineShell />);
const restartButton = await screen.findByRole('button', {
name: 'Restart',
});
fireEvent.click(restartButton);
const confirm = await screen.findByRole('button', {
name: 'Restart machine',
});
expect(lifecycleCalls()).toHaveLength(0);
fireEvent.click(confirm);
await waitFor(() =>
expect(fetchSpy).toHaveBeenCalledWith(
'/api/box/lifecycle',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ action: 'restart' }),
}),
),
);
});
});