diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts index 97e98d8ec..f48d2f9cd 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts @@ -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) => diff --git a/packages/kernel-browser-runtime/src/rpc-handlers/evaluate-vat.test.ts b/packages/kernel-browser-runtime/src/rpc-handlers/evaluate-vat.test.ts new file mode 100644 index 000000000..ef5a48e69 --- /dev/null +++ b/packages/kernel-browser-runtime/src/rpc-handlers/evaluate-vat.test.ts @@ -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); + }); +}); diff --git a/packages/kernel-browser-runtime/src/rpc-handlers/evaluate-vat.ts b/packages/kernel-browser-runtime/src/rpc-handlers/evaluate-vat.ts new file mode 100644 index 000000000..b7d1e84e9 --- /dev/null +++ b/packages/kernel-browser-runtime/src/rpc-handlers/evaluate-vat.ts @@ -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, + EvaluateVatHooks +>; + +export const evaluateVatHandler: EvaluateVatHandler = { + ...evaluateVatSpec, + hooks: { kernel: true }, + implementation: async ({ kernel }, params): Promise => { + return kernel.evaluateVat(params.id, params.code); + }, +} as const as EvaluateVatHandler; diff --git a/packages/kernel-browser-runtime/src/rpc-handlers/index.test.ts b/packages/kernel-browser-runtime/src/rpc-handlers/index.test.ts index 9388d6f49..c0df7283e 100644 --- a/packages/kernel-browser-runtime/src/rpc-handlers/index.test.ts +++ b/packages/kernel-browser-runtime/src/rpc-handlers/index.test.ts @@ -5,6 +5,7 @@ import { collectGarbageHandler, collectGarbageSpec, } from './collect-garbage.ts'; +import { evaluateVatHandler, evaluateVatSpec } from './evaluate-vat.ts'; import { executeDBQueryHandler, executeDBQuerySpec, @@ -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, @@ -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, diff --git a/packages/kernel-browser-runtime/src/rpc-handlers/index.ts b/packages/kernel-browser-runtime/src/rpc-handlers/index.ts index a08fb9ab4..14b1a96f1 100644 --- a/packages/kernel-browser-runtime/src/rpc-handlers/index.ts +++ b/packages/kernel-browser-runtime/src/rpc-handlers/index.ts @@ -3,6 +3,7 @@ import { collectGarbageHandler, collectGarbageSpec, } from './collect-garbage.ts'; +import { evaluateVatHandler, evaluateVatSpec } from './evaluate-vat.ts'; import { executeDBQueryHandler, executeDBQuerySpec, @@ -37,6 +38,7 @@ import { terminateVatHandler, terminateVatSpec } from './terminate-vat.ts'; */ export const rpcHandlers = { clearState: clearStateHandler, + evaluateVat: evaluateVatHandler, executeDBQuery: executeDBQueryHandler, getStatus: getStatusHandler, pingVat: pingVatHandler, @@ -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; @@ -74,6 +77,7 @@ export const rpcHandlers = { */ export const rpcMethodSpecs = { clearState: clearStateSpec, + evaluateVat: evaluateVatSpec, executeDBQuery: executeDBQuerySpec, getStatus: getStatusSpec, pingVat: pingVatSpec, @@ -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; diff --git a/packages/kernel-ui/src/App.tsx b/packages/kernel-ui/src/App.tsx index c0a7c1fca..1d30286d5 100644 --- a/packages/kernel-ui/src/App.tsx +++ b/packages/kernel-ui/src/App.tsx @@ -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'; @@ -40,6 +41,11 @@ const tabs: NonEmptyArray<{ value: 'remote-comms', component: , }, + { + label: 'Vat REPL', + value: 'repl', + component: , + }, ]; export const App: React.FC = () => { diff --git a/packages/kernel-ui/src/components/VatRepl.test.tsx b/packages/kernel-ui/src/components/VatRepl.test.tsx new file mode 100644 index 000000000..70b37d344 --- /dev/null +++ b/packages/kernel-ui/src/components/VatRepl.test.tsx @@ -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(); + 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(); + 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(); + 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(); + + 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(); + + 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(); + + 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(); + const selector = screen.getByTestId('vat-selector'); + // Only the disabled placeholder option + expect(selector.querySelectorAll('option')).toHaveLength(1); + }); +}); diff --git a/packages/kernel-ui/src/components/VatRepl.tsx b/packages/kernel-ui/src/components/VatRepl.tsx new file mode 100644 index 000000000..788640207 --- /dev/null +++ b/packages/kernel-ui/src/components/VatRepl.tsx @@ -0,0 +1,150 @@ +import { + Button, + ButtonVariant, + ButtonSize, + Box, + Text as TextComponent, + TextVariant, + TextColor, + FontWeight, +} from '@metamask/design-system-react'; +import type { EvaluateResult } from '@metamask/ocap-kernel/rpc'; +import { useState, useCallback } from 'react'; + +import { usePanelContext } from '../context/PanelContext.tsx'; +import { useEvaluate } from '../hooks/useEvaluate.ts'; + +/** + * @returns The VatRepl component. + */ +export const VatRepl: React.FC = () => { + const { status, logMessage } = usePanelContext(); + const { evaluateVat } = useEvaluate(); + const [selectedVat, setSelectedVat] = useState(''); + const [code, setCode] = useState(''); + const [result, setResult] = useState(null); + const [isEvaluating, setIsEvaluating] = useState(false); + + const vats = status?.vats ?? []; + + const onEvaluate = useCallback(() => { + if (!selectedVat || !code.trim()) { + return; + } + setIsEvaluating(true); + setResult(null); + evaluateVat(selectedVat, code) + .then((evalResult: EvaluateResult) => { + setResult(evalResult); + if (evalResult.success) { + return logMessage( + `Evaluated in ${selectedVat}: ${JSON.stringify(evalResult.value)}`, + 'success', + ); + } + return logMessage( + `Evaluation error in ${selectedVat}: ${evalResult.error}`, + 'error', + ); + }) + .catch((error: Error) => { + logMessage(`Failed to evaluate: ${error.message}`, 'error'); + }) + .finally(() => { + setIsEvaluating(false); + }); + }, [selectedVat, code, evaluateVat, logMessage]); + + return ( + + + + + + Select Vat + + + + + + + + + Code + +