From dfbb09d9234b41c3ae547e4dc67c45c2d934dfdb Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:24:15 -0800 Subject: [PATCH 1/2] feat(ocap-kernel): add evaluate RPC method for vat REPL Add an `evaluate` RPC method to VatSupervisor that evaluates code in a vat's isolated compartment, enabling REPL functionality while maintaining security isolation from the supervisor. Key changes: - New evaluate.ts RPC spec and handler - VatSupervisor creates a separate eval compartment with vat exports in scope - VatHandle.evaluate() method for kernel-side API - Result serialization for JSON-RPC transport (handles functions, symbols, bigints) - Errors return as { success: false, error } without crashing the vat Co-Authored-By: Claude Opus 4.5 --- .../ocap-kernel/src/rpc/vat/evaluate.test.ts | 57 ++++++++++++ packages/ocap-kernel/src/rpc/vat/evaluate.ts | 67 ++++++++++++++ packages/ocap-kernel/src/rpc/vat/index.ts | 12 +++ packages/ocap-kernel/src/vats/VatHandle.ts | 15 +++- .../src/vats/VatSupervisor.test.ts | 20 +++++ .../ocap-kernel/src/vats/VatSupervisor.ts | 89 +++++++++++++++++++ 6 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 packages/ocap-kernel/src/rpc/vat/evaluate.test.ts create mode 100644 packages/ocap-kernel/src/rpc/vat/evaluate.ts diff --git a/packages/ocap-kernel/src/rpc/vat/evaluate.test.ts b/packages/ocap-kernel/src/rpc/vat/evaluate.test.ts new file mode 100644 index 000000000..cdb47d3ee --- /dev/null +++ b/packages/ocap-kernel/src/rpc/vat/evaluate.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { evaluateHandler } from './evaluate.ts'; +import type { HandleEvaluate } from './evaluate.ts'; + +describe('evaluateHandler', () => { + it('calls handleEvaluate with the code parameter', () => { + const handleEvaluate = vi.fn(() => ({ + success: true as const, + value: 42, + })); + const result = evaluateHandler.implementation( + { handleEvaluate }, + { code: '1 + 1' }, + ); + expect(result).toStrictEqual({ success: true, value: 42 }); + expect(handleEvaluate).toHaveBeenCalledWith('1 + 1'); + }); + + it('returns success result with value', () => { + const handleEvaluate: HandleEvaluate = () => ({ + success: true, + value: { foo: 'bar' }, + }); + const result = evaluateHandler.implementation( + { handleEvaluate }, + { code: 'test' }, + ); + expect(result).toStrictEqual({ success: true, value: { foo: 'bar' } }); + }); + + it('returns success result without value for undefined', () => { + const handleEvaluate: HandleEvaluate = () => ({ + success: true, + }); + const result = evaluateHandler.implementation( + { handleEvaluate }, + { code: 'undefined' }, + ); + expect(result).toStrictEqual({ success: true }); + }); + + it('returns error result for failures', () => { + const handleEvaluate: HandleEvaluate = () => ({ + success: false, + error: 'SyntaxError: Unexpected token', + }); + const result = evaluateHandler.implementation( + { handleEvaluate }, + { code: 'invalid{code' }, + ); + expect(result).toStrictEqual({ + success: false, + error: 'SyntaxError: Unexpected token', + }); + }); +}); diff --git a/packages/ocap-kernel/src/rpc/vat/evaluate.ts b/packages/ocap-kernel/src/rpc/vat/evaluate.ts new file mode 100644 index 000000000..3af29b686 --- /dev/null +++ b/packages/ocap-kernel/src/rpc/vat/evaluate.ts @@ -0,0 +1,67 @@ +import type { Handler, MethodSpec } from '@metamask/kernel-rpc-methods'; +import { + object, + string, + literal, + union, + exactOptional, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { UnsafeJsonStruct } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; + +const EvaluateParamsStruct = object({ + code: string(), +}); + +type EvaluateParams = Infer; + +const EvaluateSuccessResultStruct = object({ + success: literal(true), + value: exactOptional(UnsafeJsonStruct), +}); + +const EvaluateErrorResultStruct = object({ + success: literal(false), + error: string(), +}); + +const EvaluateResultStruct = union([ + EvaluateSuccessResultStruct, + EvaluateErrorResultStruct, +]); + +export type EvaluateResult = + | { success: true; value?: Json } + | { success: false; error: string }; + +export type EvaluateSpec = MethodSpec< + 'evaluate', + EvaluateParams, + EvaluateResult +>; + +export const evaluateSpec = { + method: 'evaluate', + params: EvaluateParamsStruct, + result: EvaluateResultStruct, +} as const as EvaluateSpec; + +export type HandleEvaluate = (code: string) => EvaluateResult; + +type EvaluateHooks = { + handleEvaluate: HandleEvaluate; +}; + +export type EvaluateHandler = Handler< + 'evaluate', + EvaluateParams, + EvaluateResult, + EvaluateHooks +>; + +export const evaluateHandler: EvaluateHandler = { + ...evaluateSpec, + hooks: { handleEvaluate: true }, + implementation: ({ handleEvaluate }, params) => handleEvaluate(params.code), +} as const; diff --git a/packages/ocap-kernel/src/rpc/vat/index.ts b/packages/ocap-kernel/src/rpc/vat/index.ts index a7aa1a01a..f021a71ce 100644 --- a/packages/ocap-kernel/src/rpc/vat/index.ts +++ b/packages/ocap-kernel/src/rpc/vat/index.ts @@ -2,6 +2,12 @@ import type { Infer } from '@metamask/superstruct'; import { deliverSpec, deliverHandler } from './deliver.ts'; import type { DeliverSpec, DeliverHandler } from './deliver.ts'; +import { evaluateSpec, evaluateHandler } from './evaluate.ts'; +import type { + EvaluateSpec, + EvaluateHandler, + EvaluateResult, +} from './evaluate.ts'; import { initVatSpec, initVatHandler } from './initVat.ts'; import type { InitVatSpec, InitVatHandler } from './initVat.ts'; import { pingSpec, pingHandler } from './ping.ts'; @@ -12,20 +18,24 @@ import type { PingSpec, PingHandler } from './ping.ts'; export const vatHandlers = { deliver: deliverHandler, + evaluate: evaluateHandler, initVat: initVatHandler, ping: pingHandler, } as { deliver: DeliverHandler; + evaluate: EvaluateHandler; initVat: InitVatHandler; ping: PingHandler; }; export const vatMethodSpecs = { deliver: deliverSpec, + evaluate: evaluateSpec, initVat: initVatSpec, ping: pingSpec, } as { deliver: DeliverSpec; + evaluate: EvaluateSpec; initVat: InitVatSpec; ping: PingSpec; }; @@ -35,3 +45,5 @@ type Handlers = (typeof vatHandlers)[keyof typeof vatHandlers]; export type VatMethod = Handlers['method']; export type PingVatResult = Infer; + +export type { EvaluateResult }; diff --git a/packages/ocap-kernel/src/vats/VatHandle.ts b/packages/ocap-kernel/src/vats/VatHandle.ts index 2b3c97635..8c2b7b60c 100644 --- a/packages/ocap-kernel/src/vats/VatHandle.ts +++ b/packages/ocap-kernel/src/vats/VatHandle.ts @@ -18,7 +18,7 @@ import type { JsonRpcNotification, JsonRpcResponse } from '@metamask/utils'; import type { KernelQueue } from '../KernelQueue.ts'; import { kser, makeError } from '../liveslots/kernel-marshal.ts'; import { vatMethodSpecs, vatSyscallHandlers } from '../rpc/index.ts'; -import type { PingVatResult, VatMethod } from '../rpc/index.ts'; +import type { EvaluateResult, PingVatResult, VatMethod } from '../rpc/index.ts'; import type { KernelStore } from '../store/index.ts'; import type { Message, @@ -184,6 +184,19 @@ export class VatHandle implements EndpointHandle { }); } + /** + * Evaluate code in the vat's REPL compartment. + * + * @param code - The code to evaluate. + * @returns A promise that resolves to the evaluation result. + */ + async evaluate(code: string): Promise { + return await this.sendVatCommand({ + method: 'evaluate', + params: { code }, + }); + } + /** * Handle a message from the vat. * diff --git a/packages/ocap-kernel/src/vats/VatSupervisor.test.ts b/packages/ocap-kernel/src/vats/VatSupervisor.test.ts index 566fe9666..8d6a7bc96 100644 --- a/packages/ocap-kernel/src/vats/VatSupervisor.test.ts +++ b/packages/ocap-kernel/src/vats/VatSupervisor.test.ts @@ -124,6 +124,26 @@ describe('VatSupervisor', () => { }), ); }); + + it('handles "evaluate" requests before vat initialization', async () => { + const dispatch = vi.fn(); + const { stream } = await makeVatSupervisor({ dispatch }); + + await stream.receiveInput({ + id: 'v0:1', + method: 'evaluate', + params: { code: '1 + 1' }, + jsonrpc: '2.0', + }); + await delay(10); + + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'v0:1', + result: { success: false, error: 'Vat not initialized' }, + }), + ); + }); }); describe('terminate', () => { diff --git a/packages/ocap-kernel/src/vats/VatSupervisor.ts b/packages/ocap-kernel/src/vats/VatSupervisor.ts index c8ddb25ce..a93ebca58 100644 --- a/packages/ocap-kernel/src/vats/VatSupervisor.ts +++ b/packages/ocap-kernel/src/vats/VatSupervisor.ts @@ -25,6 +25,7 @@ import { isJsonRpcRequest, isJsonRpcResponse, } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; import type { PlatformFactory } from '@ocap/kernel-platforms'; import { loadBundle } from './bundle-loader.ts'; @@ -37,6 +38,7 @@ import type { GCTools, } from '../liveslots/types.ts'; import { vatSyscallMethodSpecs, vatHandlers } from '../rpc/index.ts'; +import type { EvaluateResult } from '../rpc/index.ts'; import { makeVatKVStore } from '../store/vat-kv-store.ts'; import type { VatConfig, VatDeliveryResult, VatId } from '../types.ts'; import { isVatConfig, coerceVatSyscallObject } from '../types.ts'; @@ -105,6 +107,9 @@ export class VatSupervisor { /** Options to pass to the makePlatform function. */ readonly #platformOptions: Record; + /** Compartment for REPL evaluation with vat exports in scope. */ + #evalCompartment: { evaluate: (code: string) => unknown } | null = null; + /** * Construct a new VatSupervisor instance. * @@ -151,6 +156,7 @@ export class VatSupervisor { this.#rpcServer = new RpcService(vatHandlers, { initVat: this.#initVat.bind(this), handleDelivery: this.#deliver.bind(this), + handleEvaluate: this.#evaluate.bind(this), }); Promise.all([ @@ -250,6 +256,81 @@ export class VatSupervisor { return [this.#vatKVStore!.checkpoint(), deliveryError]; } + /** + * Evaluate code in the vat's REPL compartment. + * + * @param code - The code to evaluate. + * @returns The result of the evaluation. + */ + #evaluate(code: string): EvaluateResult { + if (!this.#evalCompartment) { + return { success: false, error: 'Vat not initialized' }; + } + try { + const result = this.#evalCompartment.evaluate(code); + const serialized = this.#serializeResult(result); + if (serialized === undefined) { + return { success: true }; + } + return { success: true, value: serialized }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Serialize a value for JSON-RPC transport. + * Non-serializable values (functions, symbols, etc.) are converted to string representation. + * + * @param value - The value to serialize. + * @returns A JSON-serializable value. + */ + #serializeResult(value: unknown): Json | undefined { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + const type = typeof value; + if (type === 'string') { + return value as string; + } + if (type === 'number') { + return value as number; + } + if (type === 'boolean') { + return value as boolean; + } + if (type === 'bigint') { + return `${String(value as bigint)}n`; + } + if (type === 'symbol') { + return (value as symbol).toString(); + } + if (type === 'function') { + return `[Function: ${(value as { name?: string }).name ?? 'anonymous'}]`; + } + if (Array.isArray(value)) { + return value.map((item) => this.#serializeResult(item) ?? null) as Json[]; + } + if (type === 'object') { + // Handle objects - try to serialize their properties + const result: Record = {}; + for (const key of Object.keys(value as object)) { + result[key] = + this.#serializeResult((value as Record)[key]) ?? + null; + } + return result; + } + // Fallback for any other types - unlikely to reach here + return `[${type}]`; + } + /** * Initialize the vat by loading its user code bundle and creating a liveslots * instance to manage it. @@ -357,6 +438,14 @@ export class VatSupervisor { endowments, inescapableGlobalProperties, }); + + // Create a separate compartment for REPL evaluation. + // Vat exports become endowments, so they're directly in scope. + this.#evalCompartment = new Compartment({ + harden: globalThis.harden, + ...vatNS, + }); + return vatNS; }; From 6a5a0f4c4534117b7c6e54f8d3a3a0f9c776c7e3 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 6 Feb 2026 14:58:42 -0800 Subject: [PATCH 2/2] feat: expose evaluateVat RPC through extension and UI Surface the vat REPL evaluate capability through the full stack: - Add evaluateVat to VatManager, Kernel, and KernelFacet - Add evaluateVat RPC handler in kernel-browser-runtime - Add Vat REPL tab to kernel-ui with vat selector and code input - Add useEvaluate hook for the UI Co-Authored-By: Claude Opus 4.6 --- .../kernel-worker/captp/kernel-captp.test.ts | 1 + .../src/rpc-handlers/evaluate-vat.test.ts | 35 ++++ .../src/rpc-handlers/evaluate-vat.ts | 39 +++++ .../src/rpc-handlers/index.test.ts | 3 + .../src/rpc-handlers/index.ts | 5 + packages/kernel-ui/src/App.tsx | 6 + .../kernel-ui/src/components/VatRepl.test.tsx | 152 ++++++++++++++++++ packages/kernel-ui/src/components/VatRepl.tsx | 150 +++++++++++++++++ .../kernel-ui/src/hooks/useEvaluate.test.ts | 55 +++++++ packages/kernel-ui/src/hooks/useEvaluate.ts | 28 ++++ packages/ocap-kernel/src/Kernel.test.ts | 41 +++++ packages/ocap-kernel/src/Kernel.ts | 13 +- packages/ocap-kernel/src/kernel-facet.test.ts | 3 + packages/ocap-kernel/src/kernel-facet.ts | 1 + .../ocap-kernel/src/vats/VatManager.test.ts | 19 +++ packages/ocap-kernel/src/vats/VatManager.ts | 14 +- 16 files changed, 563 insertions(+), 2 deletions(-) create mode 100644 packages/kernel-browser-runtime/src/rpc-handlers/evaluate-vat.test.ts create mode 100644 packages/kernel-browser-runtime/src/rpc-handlers/evaluate-vat.ts create mode 100644 packages/kernel-ui/src/components/VatRepl.test.tsx create mode 100644 packages/kernel-ui/src/components/VatRepl.tsx create mode 100644 packages/kernel-ui/src/hooks/useEvaluate.test.ts create mode 100644 packages/kernel-ui/src/hooks/useEvaluate.ts 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 + +