Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { CapTPMessage } from '../../types.ts';

describe('makeKernelCapTP', () => {
const mockKernel = {
evaluateVat: vi.fn(),
getPresence: vi
.fn()
.mockImplementation(async (kref: string, iface: string) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Kernel } from '@metamask/ocap-kernel';
import { describe, it, expect, vi, beforeEach } from 'vitest';

import { evaluateVatHandler } from './evaluate-vat.ts';

describe('evaluateVatHandler', () => {
let mockKernel: Kernel;

beforeEach(() => {
mockKernel = {
evaluateVat: vi.fn().mockResolvedValue({ success: true, value: 2 }),
} as unknown as Kernel;
});

it('evaluates code in a vat and returns result', async () => {
const params = { id: 'v0', code: '1 + 1' } as const;
const result = await evaluateVatHandler.implementation(
{ kernel: mockKernel },
params,
);

expect(mockKernel.evaluateVat).toHaveBeenCalledWith(params.id, params.code);
expect(result).toStrictEqual({ success: true, value: 2 });
});

it('propagates errors from kernel.evaluateVat', async () => {
const error = new Error('Evaluate failed');
vi.mocked(mockKernel.evaluateVat).mockRejectedValueOnce(error);

const params = { id: 'v0', code: 'bad code' } as const;
await expect(
evaluateVatHandler.implementation({ kernel: mockKernel }, params),
).rejects.toThrow(error);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Handler, MethodSpec } from '@metamask/kernel-rpc-methods';
import type { Kernel, VatId } from '@metamask/ocap-kernel';
import { VatIdStruct } from '@metamask/ocap-kernel';
import { vatMethodSpecs } from '@metamask/ocap-kernel/rpc';
import type { EvaluateResult } from '@metamask/ocap-kernel/rpc';
import { object, string } from '@metamask/superstruct';

export type EvaluateVatHooks = {
kernel: Kernel;
};

type EvaluateVatParams = { id: VatId; code: string };

export type EvaluateVatSpec = MethodSpec<
'evaluateVat',
EvaluateVatParams,
EvaluateResult
>;

export const evaluateVatSpec = {
method: 'evaluateVat',
params: object({ id: VatIdStruct, code: string() }),
result: vatMethodSpecs.evaluate.result,
} as const as EvaluateVatSpec;

export type EvaluateVatHandler = Handler<
'evaluateVat',
EvaluateVatParams,
Promise<EvaluateResult>,
EvaluateVatHooks
>;

export const evaluateVatHandler: EvaluateVatHandler = {
...evaluateVatSpec,
hooks: { kernel: true },
implementation: async ({ kernel }, params): Promise<EvaluateResult> => {
return kernel.evaluateVat(params.id, params.code);
},
} as const as EvaluateVatHandler;
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
collectGarbageHandler,
collectGarbageSpec,
} from './collect-garbage.ts';
import { evaluateVatHandler, evaluateVatSpec } from './evaluate-vat.ts';
import {
executeDBQueryHandler,
executeDBQuerySpec,
Expand Down Expand Up @@ -39,6 +40,7 @@ describe('handlers/index', () => {
it('should export all handler functions', () => {
expect(rpcHandlers).toStrictEqual({
clearState: clearStateHandler,
evaluateVat: evaluateVatHandler,
executeDBQuery: executeDBQueryHandler,
getStatus: getStatusHandler,
pingVat: pingVatHandler,
Expand Down Expand Up @@ -68,6 +70,7 @@ describe('handlers/index', () => {
it('should export all method specs', () => {
expect(rpcMethodSpecs).toStrictEqual({
clearState: clearStateSpec,
evaluateVat: evaluateVatSpec,
executeDBQuery: executeDBQuerySpec,
getStatus: getStatusSpec,
pingVat: pingVatSpec,
Expand Down
5 changes: 5 additions & 0 deletions packages/kernel-browser-runtime/src/rpc-handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
collectGarbageHandler,
collectGarbageSpec,
} from './collect-garbage.ts';
import { evaluateVatHandler, evaluateVatSpec } from './evaluate-vat.ts';
import {
executeDBQueryHandler,
executeDBQuerySpec,
Expand Down Expand Up @@ -37,6 +38,7 @@ import { terminateVatHandler, terminateVatSpec } from './terminate-vat.ts';
*/
export const rpcHandlers = {
clearState: clearStateHandler,
evaluateVat: evaluateVatHandler,
executeDBQuery: executeDBQueryHandler,
getStatus: getStatusHandler,
pingVat: pingVatHandler,
Expand All @@ -53,6 +55,7 @@ export const rpcHandlers = {
terminateSubcluster: terminateSubclusterHandler,
} as {
clearState: typeof clearStateHandler;
evaluateVat: typeof evaluateVatHandler;
executeDBQuery: typeof executeDBQueryHandler;
getStatus: typeof getStatusHandler;
pingVat: typeof pingVatHandler;
Expand All @@ -74,6 +77,7 @@ export const rpcHandlers = {
*/
export const rpcMethodSpecs = {
clearState: clearStateSpec,
evaluateVat: evaluateVatSpec,
executeDBQuery: executeDBQuerySpec,
getStatus: getStatusSpec,
pingVat: pingVatSpec,
Expand All @@ -90,6 +94,7 @@ export const rpcMethodSpecs = {
terminateSubcluster: terminateSubclusterSpec,
} as {
clearState: typeof clearStateSpec;
evaluateVat: typeof evaluateVatSpec;
executeDBQuery: typeof executeDBQuerySpec;
getStatus: typeof getStatusSpec;
pingVat: typeof pingVatSpec;
Expand Down
6 changes: 6 additions & 0 deletions packages/kernel-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { MessagePanel } from './components/MessagePanel.tsx';
import { ObjectRegistry } from './components/ObjectRegistry.tsx';
import { RemoteComms } from './components/RemoteComms.tsx';
import { Tabs } from './components/shared/Tabs.tsx';
import { VatRepl } from './components/VatRepl.tsx';
import { PanelProvider } from './context/PanelContext.tsx';
import { useDarkMode } from './hooks/useDarkMode.ts';
import { useStream } from './hooks/useStream.ts';
Expand Down Expand Up @@ -40,6 +41,11 @@ const tabs: NonEmptyArray<{
value: 'remote-comms',
component: <RemoteComms />,
},
{
label: 'Vat REPL',
value: 'repl',
component: <VatRepl />,
},
];

export const App: React.FC = () => {
Expand Down
152 changes: 152 additions & 0 deletions packages/kernel-ui/src/components/VatRepl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';

import { VatRepl } from './VatRepl.tsx';
import { usePanelContext } from '../context/PanelContext.tsx';
import type { PanelContextType } from '../context/PanelContext.tsx';
import { useEvaluate } from '../hooks/useEvaluate.ts';

vi.mock('../context/PanelContext.tsx', () => ({
usePanelContext: vi.fn(),
}));

vi.mock('../hooks/useEvaluate.ts', () => ({
useEvaluate: vi.fn(),
}));

describe('VatRepl Component', () => {
const mockLogMessage = vi.fn();
const mockEvaluateVat = vi.fn();
const mockCallKernelMethod = vi.fn();

const mockPanelContext: PanelContextType = {
callKernelMethod: mockCallKernelMethod,
status: {
vats: [
{
id: 'v1',
config: { sourceSpec: 'test.js' },
subclusterId: 's1',
},
{
id: 'v2',
config: { sourceSpec: 'test2.js' },
subclusterId: 's1',
},
],
subclusters: [],
remoteComms: { isInitialized: false },
},
logMessage: mockLogMessage,
messageContent: '',
setMessageContent: vi.fn(),
panelLogs: [],
clearLogs: vi.fn(),
isLoading: false,
objectRegistry: null,
setObjectRegistry: vi.fn(),
};

beforeEach(() => {
cleanup();
vi.clearAllMocks();
vi.mocked(usePanelContext).mockReturnValue(mockPanelContext);
vi.mocked(useEvaluate).mockReturnValue({
evaluateVat: mockEvaluateVat,
});
});

it('renders vat selector with available vats', () => {
render(<VatRepl />);
const selector = screen.getByTestId('vat-selector');
expect(selector).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'v1' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'v2' })).toBeInTheDocument();
});

it('renders code input and evaluate button', () => {
render(<VatRepl />);
expect(screen.getByTestId('code-input')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Evaluate' }),
).toBeInTheDocument();
});

it('disables evaluate button when no vat selected or no code', () => {
render(<VatRepl />);
const button = screen.getByRole('button', { name: 'Evaluate' });
expect(button).toBeDisabled();
});

it('evaluates code and displays success result', async () => {
mockEvaluateVat.mockResolvedValueOnce({ success: true, value: 42 });
render(<VatRepl />);

await userEvent.selectOptions(screen.getByTestId('vat-selector'), 'v1');
await userEvent.type(screen.getByTestId('code-input'), '21 * 2');
await userEvent.click(screen.getByRole('button', { name: 'Evaluate' }));

expect(mockEvaluateVat).toHaveBeenCalledWith('v1', '21 * 2');

await waitFor(() => {
expect(screen.getByTestId('result-display')).toBeInTheDocument();
expect(screen.getByTestId('result-display')).toHaveTextContent('42');
});

expect(mockLogMessage).toHaveBeenCalledWith(
'Evaluated in v1: 42',
'success',
);
});

it('evaluates code and displays error result', async () => {
mockEvaluateVat.mockResolvedValueOnce({
success: false,
error: 'ReferenceError: x is not defined',
});
render(<VatRepl />);

await userEvent.selectOptions(screen.getByTestId('vat-selector'), 'v1');
await userEvent.type(screen.getByTestId('code-input'), 'x');
await userEvent.click(screen.getByRole('button', { name: 'Evaluate' }));

await waitFor(() => {
expect(screen.getByTestId('result-display')).toHaveTextContent(
'ReferenceError: x is not defined',
);
});

expect(mockLogMessage).toHaveBeenCalledWith(
'Evaluation error in v1: ReferenceError: x is not defined',
'error',
);
});

it('logs error when evaluation promise rejects', async () => {
mockEvaluateVat.mockRejectedValueOnce(new Error('Network error'));
render(<VatRepl />);

await userEvent.selectOptions(screen.getByTestId('vat-selector'), 'v1');
await userEvent.type(screen.getByTestId('code-input'), '1 + 1');
await userEvent.click(screen.getByRole('button', { name: 'Evaluate' }));

await waitFor(() => {
expect(mockLogMessage).toHaveBeenCalledWith(
'Failed to evaluate: Network error',
'error',
);
});
});

it('renders empty vat list when no status', () => {
vi.mocked(usePanelContext).mockReturnValue({
...mockPanelContext,
status: undefined,
});
render(<VatRepl />);
const selector = screen.getByTestId('vat-selector');
// Only the disabled placeholder option
expect(selector.querySelectorAll('option')).toHaveLength(1);
});
});
Loading
Loading