From 08fae49cd346e48748b4a0e5cf63ee5ea21a08b7 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Fri, 2 May 2025 17:42:51 +0200 Subject: [PATCH 1/9] Remove sendCommand and queue messages from control panel --- .../src/kernel-integration/handlers/index.ts | 9 +- .../handlers/queue-message.test.ts | 79 ++++ .../handlers/queue-message.ts | 40 ++ .../handlers/send-vat-command.test.ts | 61 --- .../handlers/send-vat-command.ts | 50 -- packages/extension/src/ui/App.module.css | 64 ++- .../src/ui/components/ConfigEditor.test.tsx | 3 +- .../src/ui/components/ConfigEditor.tsx | 7 +- .../ui/components/DatabaseInspector.test.tsx | 20 +- .../src/ui/components/DatabaseInspector.tsx | 4 +- .../src/ui/components/KernelControls.test.tsx | 2 +- .../src/ui/components/LaunchVat.test.tsx | 2 +- .../src/ui/components/MessagePanel.test.tsx | 82 ++-- .../src/ui/components/MessagePanel.tsx | 29 +- .../src/ui/components/ObjectRegistry.test.tsx | 445 ++++++++++++------ .../src/ui/components/ObjectRegistry.tsx | 80 +--- .../ui/components/SendMessageForm.test.tsx | 313 ++++++++++++ .../src/ui/components/SendMessageForm.tsx | 146 ++++++ .../src/ui/components/VatTable.test.tsx | 6 - .../src/ui/context/PanelContext.test.tsx | 82 +++- .../extension/src/ui/context/PanelContext.tsx | 12 +- .../src/ui/hooks/useDatabase.test.ts | 68 +++ .../extension/src/ui/hooks/useDatabase.ts | 22 +- .../src/ui/hooks/useKernelActions.test.ts | 59 +-- .../src/ui/hooks/useKernelActions.ts | 49 +- .../extension/src/ui/hooks/useVats.test.ts | 40 -- packages/extension/src/ui/hooks/useVats.ts | 17 +- .../src/ui/services/db-parser.test.ts | 10 +- .../extension/src/ui/services/db-parser.ts | 60 +-- packages/extension/src/ui/types.ts | 56 +++ packages/kernel-test/src/exo.test.ts | 67 +-- .../src/garbage-collection.test.ts | 36 +- packages/kernel-test/src/utils.ts | 6 +- packages/ocap-kernel/src/Kernel.test.ts | 48 -- packages/ocap-kernel/src/Kernel.ts | 32 +- packages/ocap-kernel/src/index.test.ts | 1 + packages/ocap-kernel/src/index.ts | 1 + packages/ocap-kernel/src/rpc/index.test.ts | 1 - packages/ocap-kernel/src/rpc/vat/index.ts | 21 - 39 files changed, 1320 insertions(+), 810 deletions(-) create mode 100644 packages/extension/src/kernel-integration/handlers/queue-message.test.ts create mode 100644 packages/extension/src/kernel-integration/handlers/queue-message.ts delete mode 100644 packages/extension/src/kernel-integration/handlers/send-vat-command.test.ts delete mode 100644 packages/extension/src/kernel-integration/handlers/send-vat-command.ts create mode 100644 packages/extension/src/ui/components/SendMessageForm.test.tsx create mode 100644 packages/extension/src/ui/components/SendMessageForm.tsx diff --git a/packages/extension/src/kernel-integration/handlers/index.ts b/packages/extension/src/kernel-integration/handlers/index.ts index 69256f4d0..5558ca679 100644 --- a/packages/extension/src/kernel-integration/handlers/index.ts +++ b/packages/extension/src/kernel-integration/handlers/index.ts @@ -9,12 +9,9 @@ import { } from './execute-db-query.ts'; import { getStatusHandler, getStatusSpec } from './get-status.ts'; import { launchVatHandler, launchVatSpec } from './launch-vat.ts'; +import { queueMessageHandler, queueMessageSpec } from './queue-message.ts'; import { reloadConfigHandler, reloadConfigSpec } from './reload-config.ts'; import { restartVatHandler, restartVatSpec } from './restart-vat.ts'; -import { - sendVatCommandHandler, - sendVatCommandSpec, -} from './send-vat-command.ts'; import { terminateAllVatsHandler, terminateAllVatsSpec, @@ -35,7 +32,7 @@ export const handlers = { launchVat: launchVatHandler, reload: reloadConfigHandler, restartVat: restartVatHandler, - sendVatCommand: sendVatCommandHandler, + queueMessage: queueMessageHandler, terminateAllVats: terminateAllVatsHandler, collectGarbage: collectGarbageHandler, terminateVat: terminateVatHandler, @@ -52,7 +49,7 @@ export const methodSpecs = { launchVat: launchVatSpec, reload: reloadConfigSpec, restartVat: restartVatSpec, - sendVatCommand: sendVatCommandSpec, + queueMessage: queueMessageSpec, terminateAllVats: terminateAllVatsSpec, collectGarbage: collectGarbageSpec, terminateVat: terminateVatSpec, diff --git a/packages/extension/src/kernel-integration/handlers/queue-message.test.ts b/packages/extension/src/kernel-integration/handlers/queue-message.test.ts new file mode 100644 index 000000000..ca1a05a7a --- /dev/null +++ b/packages/extension/src/kernel-integration/handlers/queue-message.test.ts @@ -0,0 +1,79 @@ +import type { CapData } from '@endo/marshal'; +import type { Kernel } from '@ocap/kernel'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { queueMessageSpec, queueMessageHandler } from './queue-message.ts'; + +describe('queueMessageSpec', () => { + it('should define the correct method name', () => { + expect(queueMessageSpec.method).toBe('queueMessage'); + }); + + it('should define the correct parameter structure', () => { + // Valid parameters should pass validation + const validParams = [ + 'target123', + 'methodName', + [1, 'string', { key: 'value' }], + ]; + expect(() => queueMessageSpec.params.create(validParams)).not.toThrow(); + + // Invalid parameters should fail validation + const invalidParams = ['target123', 123, [1, 'string']]; + expect(() => queueMessageSpec.params.create(invalidParams)).toThrow( + 'Expected a string', + ); + }); + + it('should define the correct result structure', () => { + // Valid result should pass validation + const validResult: CapData = { body: 'result', slots: [] }; + expect(() => queueMessageSpec.result.create(validResult)).not.toThrow(); + + // Invalid result should fail validation + const invalidResult = 'not a CapData object'; + expect(() => queueMessageSpec.result.create(invalidResult)).toThrow( + 'Expected an object', + ); + }); +}); + +describe('queueMessageHandler', () => { + let mockKernel: Pick; + + beforeEach(() => { + mockKernel = { + queueMessage: vi.fn(), + }; + }); + + it('should correctly forward arguments to kernel.queueMessage', async () => { + const target = 'targetId'; + const method = 'methodName'; + const args = [1, 'string', { key: 'value' }]; + const expectedResult: CapData = { body: 'result', slots: [] }; + + vi.mocked(mockKernel.queueMessage).mockResolvedValueOnce(expectedResult); + + const result = await queueMessageHandler.implementation( + { kernel: mockKernel }, + [target, method, args], + ); + + expect(mockKernel.queueMessage).toHaveBeenCalledWith(target, method, args); + expect(result).toStrictEqual(expectedResult); + }); + + it('should propagate errors from kernel.queueMessage', async () => { + const error = new Error('Queue message failed'); + vi.mocked(mockKernel.queueMessage).mockRejectedValueOnce(error); + + await expect( + queueMessageHandler.implementation({ kernel: mockKernel }, [ + 'target', + 'method', + [], + ]), + ).rejects.toThrow('Queue message failed'); + }); +}); diff --git a/packages/extension/src/kernel-integration/handlers/queue-message.ts b/packages/extension/src/kernel-integration/handlers/queue-message.ts new file mode 100644 index 000000000..2b3e1f8f6 --- /dev/null +++ b/packages/extension/src/kernel-integration/handlers/queue-message.ts @@ -0,0 +1,40 @@ +import type { CapData } from '@endo/marshal'; +import { tuple, string, array } from '@metamask/superstruct'; +import { UnsafeJsonStruct } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; +import type { Kernel } from '@ocap/kernel'; +import { CapDataStruct } from '@ocap/kernel'; +import type { MethodSpec, Handler } from '@ocap/rpc-methods'; + +/** + * Enqueue a message to a vat via the kernel's crank queue. + */ +export const queueMessageSpec: MethodSpec< + 'queueMessage', + [string, string, Json[]], + CapData +> = { + method: 'queueMessage', + params: tuple([string(), string(), array(UnsafeJsonStruct)]), + result: CapDataStruct, +}; + +export type QueueMessageHooks = { + kernel: Pick; +}; + +export const queueMessageHandler: Handler< + 'queueMessage', + [string, string, Json[]], + Promise>, + QueueMessageHooks +> = { + ...queueMessageSpec, + hooks: { kernel: true }, + implementation: async ( + { kernel }: QueueMessageHooks, + [target, method, args], + ): Promise> => { + return kernel.queueMessage(target, method, args); + }, +}; diff --git a/packages/extension/src/kernel-integration/handlers/send-vat-command.test.ts b/packages/extension/src/kernel-integration/handlers/send-vat-command.test.ts deleted file mode 100644 index cacfa8fe4..000000000 --- a/packages/extension/src/kernel-integration/handlers/send-vat-command.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { VatId, Kernel } from '@metamask/ocap-kernel'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -import { sendVatCommandHandler } from './send-vat-command.ts'; - -describe('sendVatCommandHandler', () => { - let mockKernel: Kernel; - beforeEach(() => { - mockKernel = { - sendVatCommand: vi.fn(), - } as unknown as Kernel; - }); - - it('sends a command to a vat', async () => { - const vatId = 'vat1' as VatId; - vi.mocked(mockKernel.sendVatCommand).mockResolvedValueOnce('foo'); - - const result = await sendVatCommandHandler.implementation( - { kernel: mockKernel }, - { - id: vatId, - payload: { - id: 'test-id', - jsonrpc: '2.0', - method: 'ping', - params: [], - }, - }, - ); - - expect(mockKernel.sendVatCommand).toHaveBeenCalledWith(vatId, { - id: 'test-id', - jsonrpc: '2.0', - method: 'ping', - params: [], - }); - expect(result).toStrictEqual({ result: 'foo' }); - }); - - it('forwards errors from hooks', async () => { - const vatId = 'vat1' as VatId; - vi.mocked(mockKernel.sendVatCommand).mockRejectedValueOnce( - new Error('foo'), - ); - - await expect( - sendVatCommandHandler.implementation( - { kernel: mockKernel }, - { - id: vatId, - payload: { - id: 'test-id', - jsonrpc: '2.0', - method: 'ping', - params: [], - }, - }, - ), - ).rejects.toThrow('foo'); - }); -}); diff --git a/packages/extension/src/kernel-integration/handlers/send-vat-command.ts b/packages/extension/src/kernel-integration/handlers/send-vat-command.ts deleted file mode 100644 index a3d66057e..000000000 --- a/packages/extension/src/kernel-integration/handlers/send-vat-command.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { MethodSpec, Handler } from '@metamask/kernel-rpc-methods'; -import { VatIdStruct } from '@metamask/ocap-kernel'; -import type { Kernel, VatId } from '@metamask/ocap-kernel'; -import { UiMethodRequestStruct } from '@metamask/ocap-kernel/rpc'; -import type { UiMethodRequest } from '@metamask/ocap-kernel/rpc'; -import { object } from '@metamask/superstruct'; -import type { Infer } from '@metamask/superstruct'; -import { UnsafeJsonStruct } from '@metamask/utils'; -import type { Json } from '@metamask/utils'; - -export const sendVatCommandSpec: MethodSpec< - 'sendVatCommand', - { id: VatId; payload: UiMethodRequest }, - Promise<{ result: Json }> -> = { - method: 'sendVatCommand', - params: object({ id: VatIdStruct, payload: UiMethodRequestStruct }), - result: object({ result: UnsafeJsonStruct }), -}; - -export type SendVatCommandParams = Infer<(typeof sendVatCommandSpec)['params']>; - -export type SendVatCommandHooks = { - kernel: Pick; -}; - -export const sendVatCommandHandler: Handler< - 'sendVatCommand', - { id: VatId; payload: UiMethodRequest }, - Promise<{ result: Json }>, - SendVatCommandHooks -> = { - ...sendVatCommandSpec, - hooks: { kernel: true }, - implementation: async ({ kernel }, params): Promise<{ result: Json }> => { - const result = await kernel.sendVatCommand(params.id, params.payload); - return { result }; - }, -}; - -/** - * Asserts that the given params are valid for the `sendVatCommand` method. - * - * @param params - The params to assert. - */ -export function assertVatCommandParams( - params: unknown, -): asserts params is SendVatCommandParams { - sendVatCommandSpec.params.assert(params); -} diff --git a/packages/extension/src/ui/App.module.css b/packages/extension/src/ui/App.module.css index 4c29979e7..f14a54fc9 100644 --- a/packages/extension/src/ui/App.module.css +++ b/packages/extension/src/ui/App.module.css @@ -49,6 +49,24 @@ body > div { box-sizing: border-box; } +.container { +} + +h1, +h2, +h3, +h4, +h5, +h6 { + font-weight: 600; +} + +pre { + word-break: break-word; + white-space: normal; + line-height: 1.5; +} + /* Panel container */ .panel { padding: var(--spacing-xl); @@ -65,8 +83,10 @@ body > div { } /* Common form elements */ +input, .input, .button, +select, .select { height: var(--input-height); padding: 0 var(--spacing-lg); @@ -156,6 +176,11 @@ select, color: var(--color-white); } +.buttonBlack:hover:not(:disabled) { + background-color: var(--color-gray-600); + color: var(--color-white); +} + .textButton { padding: 0; border: 0; @@ -200,12 +225,6 @@ select, margin-bottom: var(--spacing-sm); } -.messageInputRow { - display: flex; - gap: var(--spacing-sm); - margin-bottom: var(--spacing-sm); -} - .messageContent { composes: input; flex: 1; @@ -338,16 +357,45 @@ div + .sent { } .messageInputSection { - border-top: 1px solid var(--color-gray-300); + border: 1px solid var(--color-gray-300); padding: var(--spacing-md); background: var(--color-gray-200); + border-radius: var(--border-radius); + margin-bottom: var(--spacing-xl); +} + +.messageInputSection h3 { + margin: 0 0 var(--spacing-md); } -.messageInputRow { +.horizontalForm { display: flex; gap: var(--spacing-sm); } +.horizontalForm > div { + display: flex; + flex-direction: column; + flex: 1; +} + +.horizontalForm > div > label { + margin-bottom: var(--spacing-xs); +} + +.messageResponse { + font-family: monospace; + font-size: var(--font-size-xs); +} + +.messageResponse h4 { + margin: var(--spacing-md) 0 var(--spacing-sm); +} + +.messageResponse pre { + margin: 0; +} + .table { width: 100%; border: 1px solid var(--color-gray-300); diff --git a/packages/extension/src/ui/components/ConfigEditor.test.tsx b/packages/extension/src/ui/components/ConfigEditor.test.tsx index 907f884e8..e2f89396f 100644 --- a/packages/extension/src/ui/components/ConfigEditor.test.tsx +++ b/packages/extension/src/ui/components/ConfigEditor.test.tsx @@ -33,6 +33,8 @@ const mockUsePanelContext = { setMessageContent: vi.fn(), setSelectedVatId: vi.fn(), status: mockStatus, + objectRegistry: null, + setObjectRegistry: vi.fn(), }; vi.mock('../hooks/useKernelActions.ts', () => ({ @@ -70,7 +72,6 @@ describe('ConfigEditor Component', () => { vi.mocked(useKernelActions).mockReturnValue({ updateClusterConfig: mockUpdateClusterConfig, reload: mockReload, - sendKernelCommand: vi.fn(), terminateAllVats: vi.fn(), clearState: vi.fn(), launchVat: vi.fn(), diff --git a/packages/extension/src/ui/components/ConfigEditor.tsx b/packages/extension/src/ui/components/ConfigEditor.tsx index 737e6719b..2b1bf1612 100644 --- a/packages/extension/src/ui/components/ConfigEditor.tsx +++ b/packages/extension/src/ui/components/ConfigEditor.tsx @@ -1,3 +1,4 @@ +import { stringify } from '@metamask/kernel-utils'; import type { ClusterConfig } from '@metamask/ocap-kernel'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -31,7 +32,7 @@ export const ConfigEditorInner: React.FC<{ status: KernelStatus }> = ({ const { updateClusterConfig, reload } = useKernelActions(); const { logMessage } = usePanelContext(); const clusterConfig = useMemo( - () => JSON.stringify(status.clusterConfig, null, 2), + () => stringify(status.clusterConfig, 2), [status], ); const [config, setConfig] = useState(clusterConfig); @@ -43,7 +44,7 @@ export const ConfigEditorInner: React.FC<{ status: KernelStatus }> = ({ setConfig(clusterConfig); setSelectedTemplate( availableConfigs.find( - (item) => JSON.stringify(item.config, null, 2) === clusterConfig, + (item) => stringify(item.config, 2) === clusterConfig, )?.name ?? '', ); }, [clusterConfig]); @@ -69,7 +70,7 @@ export const ConfigEditorInner: React.FC<{ status: KernelStatus }> = ({ (item) => item.name === configName, )?.config; if (selectedConfig) { - setConfig(JSON.stringify(selectedConfig, null, 2)); + setConfig(stringify(selectedConfig, 2)); setSelectedTemplate(configName); } }, []); diff --git a/packages/extension/src/ui/components/DatabaseInspector.test.tsx b/packages/extension/src/ui/components/DatabaseInspector.test.tsx index 00ec9e3f9..f3857b259 100644 --- a/packages/extension/src/ui/components/DatabaseInspector.test.tsx +++ b/packages/extension/src/ui/components/DatabaseInspector.test.tsx @@ -38,6 +38,8 @@ const mockUsePanelContext: PanelContextType = { panelLogs: [], clearLogs: vi.fn(), isLoading: false, + objectRegistry: null, + setObjectRegistry: vi.fn(), }; describe('DatabaseInspector Component', () => { @@ -55,6 +57,7 @@ describe('DatabaseInspector Component', () => { fetchTables: mockFetchTables, fetchTableData: mockFetchTableData, executeQuery: mockExecuteQuery, + fetchObjectRegistry: vi.fn(), }); }); @@ -173,21 +176,4 @@ describe('DatabaseInspector Component', () => { ); }); }); - - it('applies correct CSS classes', async () => { - mockFetchTables.mockResolvedValue(['table1']); - mockFetchTableData.mockResolvedValue([]); - render(); - await screen.findByRole('combobox'); - const select = screen.getByRole('combobox'); - expect(select).toHaveClass('select'); - expect(select.parentElement).toHaveClass('table-controls'); - const refreshBtn = screen.getByRole('button', { name: 'Refresh' }); - expect(refreshBtn).toHaveClass('button'); - const input = screen.getByPlaceholderText('Enter SQL query...'); - expect(input).toHaveClass('input'); - const execBtn = screen.getByRole('button', { name: 'Execute Query' }); - expect(execBtn).toHaveClass('button-primary'); - expect(screen.getByRole('table').parentElement).toHaveClass('table'); - }); }); diff --git a/packages/extension/src/ui/components/DatabaseInspector.tsx b/packages/extension/src/ui/components/DatabaseInspector.tsx index 3196150b8..0d04de137 100644 --- a/packages/extension/src/ui/components/DatabaseInspector.tsx +++ b/packages/extension/src/ui/components/DatabaseInspector.tsx @@ -77,7 +77,7 @@ export const DatabaseInspector: React.FC = () => { ))} - - ); }; diff --git a/packages/extension/src/ui/components/ObjectRegistry.test.tsx b/packages/extension/src/ui/components/ObjectRegistry.test.tsx index 13f0815a4..f4e5fa41b 100644 --- a/packages/extension/src/ui/components/ObjectRegistry.test.tsx +++ b/packages/extension/src/ui/components/ObjectRegistry.test.tsx @@ -1,216 +1,365 @@ -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 { ObjectRegistry } from './ObjectRegistry.tsx'; +import { + render, + screen, + fireEvent, + cleanup, + within, +} from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import type { PanelContextType } from '../context/PanelContext.tsx'; +import { usePanelContext } from '../context/PanelContext.tsx'; import { useDatabase } from '../hooks/useDatabase.ts'; -import type { ClusterSnapshot } from '../services/db-parser.ts'; -import * as dbParser from '../services/db-parser.ts'; +import type { ObjectRegistry as ObjectRegistryType } from '../types.ts'; +import { ObjectRegistry } from './ObjectRegistry.tsx'; -// Mock the hooks and services -vi.mock('../hooks/useDatabase.ts', () => ({ - useDatabase: vi.fn(), +vi.mock('../context/PanelContext.tsx', () => ({ + usePanelContext: vi.fn(), })); -vi.mock('../services/db-parser.ts', () => ({ - parseKernelDB: vi.fn(), +vi.mock('../hooks/useDatabase.ts', () => ({ + useDatabase: vi.fn(), })); -// Mock the CSS module -vi.mock('../App.module.css', () => ({ - default: { - container: 'container', - error: 'error', - headerSection: 'header-section', - noMargin: 'no-margin', - button: 'button', - noBorder: 'no-border', - table: 'table', - accordion: 'accordion', - accordionHeader: 'accordion-header', - accordionTitle: 'accordion-title', - accordionIndicator: 'accordion-indicator', - accordionContent: 'accordion-content', - tableContainer: 'table-container', - vatDetailsHeader: 'vat-details-header', - }, +vi.mock('./SendMessageForm.tsx', () => ({ + SendMessageForm: () => ( +
SendMessageForm
+ ), })); describe('ObjectRegistry Component', () => { - // Set up mock data - const mockData = { - gcActions: 'mockGcActions', - reapQueue: 'mockReapQueue', - terminatedVats: 'mockTerminatedVats', + const fetchObjectRegistry = vi.fn(); + + const mockRegistry: ObjectRegistryType = { + gcActions: 'test gc actions', + reapQueue: 'test reap queue', + terminatedVats: 'test terminated vats', vats: { vat1: { - overview: { - name: 'Test Vat 1', - }, + overview: { name: 'TestVat1', bundleSpec: '' }, ownedObjects: [ - { - kref: 'ko1', - eref: 'o+1', - refCount: 2, - toVats: ['vat2', 'vat3'], - }, + { kref: 'kref1', eref: 'eref1', refCount: '1', toVats: ['vat2'] }, + { kref: 'kref2', eref: 'eref2', refCount: '2', toVats: [] }, ], importedObjects: [ - { - kref: 'ko2', - eref: 'o-2', - refCount: 1, - fromVat: 'vat2', - }, + { kref: 'kref3', eref: 'eref3', refCount: '1', fromVat: 'vat2' }, ], importedPromises: [ { - kref: 'kp1', - eref: 'p-1', - state: 'fulfilled', + kref: 'promise1', + eref: 'eref-promise1', + state: 'pending', value: { - body: 'value1', - slots: [{ kref: 'ko3', eref: 'o+3' }], + body: '', + slots: [ + { kref: 'slot1', eref: 'eref-slot1', vat: 'vat2' }, + { kref: 'slot2', eref: '', vat: 'vat2' }, + { kref: 'slot3', eref: null, vat: 'vat2' }, + ], }, fromVat: 'vat2', }, ], exportedPromises: [ { - kref: 'kp2', - eref: 'p+2', - state: 'pending', - value: { body: '', slots: [] }, - toVats: ['vat3'], + kref: 'promise2', + eref: 'eref-promise2', + state: 'fulfilled', + value: { + body: 'value', + slots: [ + { kref: 'exported-slot1', eref: 'exported-eref1', vat: null }, + { kref: 'exported-slot2', eref: null, vat: null }, + ], + }, + toVats: ['vat2'], }, ], }, + vat2: { + overview: { name: 'TestVat2', bundleSpec: '' }, + ownedObjects: [], + importedObjects: [], + importedPromises: [], + exportedPromises: [], + }, }, }; - const mockExecuteQuery = vi.fn(); - beforeEach(() => { - cleanup(); - vi.clearAllMocks(); - vi.mocked(useDatabase).mockReturnValue({ - executeQuery: mockExecuteQuery, fetchTables: vi.fn(), fetchTableData: vi.fn(), + executeQuery: vi.fn(), + fetchObjectRegistry, }); - - vi.mocked(dbParser.parseKernelDB).mockReturnValue( - mockData as unknown as ClusterSnapshot, - ); + vi.mocked(usePanelContext).mockReturnValue({ + objectRegistry: mockRegistry, + } as unknown as PanelContextType); }); - it('renders loading state initially', () => { - // Set executeQuery to not resolve immediately to keep component in loading state - mockExecuteQuery.mockReturnValue( - new Promise(() => { - // do nothing - }), - ); - - render(); - - expect(screen.getByText('Loading cluster data...')).toBeInTheDocument(); + afterEach(() => { + cleanup(); }); - it('renders error state when database query fails', async () => { - const errorMessage = 'Failed to fetch data'; - mockExecuteQuery.mockRejectedValue(new Error(errorMessage)); - + it('fetches the object registry on mount', () => { render(); - - await waitFor(() => { - expect(screen.getByText(`Error: ${errorMessage}`)).toBeInTheDocument(); - }); + expect(fetchObjectRegistry).toHaveBeenCalledTimes(1); }); - it('renders error message when no data is returned', async () => { - mockExecuteQuery.mockResolvedValue([]); - vi.mocked(dbParser.parseKernelDB).mockReturnValue( - null as unknown as ClusterSnapshot, - ); + it('shows loading state when objectRegistry is null', () => { + vi.mocked(usePanelContext).mockReturnValue({ + objectRegistry: null, + } as unknown as PanelContextType); render(); - - await waitFor(() => { - expect( - screen.getByText('Error: No cluster data available'), - ).toBeInTheDocument(); - }); + expect(screen.getByText('Loading...')).toBeInTheDocument(); }); - it('renders cluster data when successfully fetched', async () => { - const mockDbResult = [{ key: 'key1', value: 'value1' }]; - mockExecuteQuery.mockResolvedValue(mockDbResult); - + it('renders kernel registry information correctly', () => { render(); - - await waitFor(() => { - expect(screen.getByText('Kernel Registry')).toBeInTheDocument(); - expect(screen.getByText('mockGcActions')).toBeInTheDocument(); - expect(screen.getByText('mockReapQueue')).toBeInTheDocument(); - expect(screen.getByText('mockTerminatedVats')).toBeInTheDocument(); - expect(screen.getByText(/Test Vat 1/u)).toBeInTheDocument(); - }); - - // Verify that executeQuery and parseKernelDB were called correctly - expect(mockExecuteQuery).toHaveBeenCalledWith('SELECT key, value FROM kv'); - expect(dbParser.parseKernelDB).toHaveBeenCalledWith(mockDbResult); + expect(screen.getByText('Kernel Registry')).toBeInTheDocument(); + expect(screen.getByText('GC Actions')).toBeInTheDocument(); + expect(screen.getByText('test gc actions')).toBeInTheDocument(); + expect(screen.getByText('Reap Queue')).toBeInTheDocument(); + expect(screen.getByText('test reap queue')).toBeInTheDocument(); + expect(screen.getByText('Terminated Vats')).toBeInTheDocument(); + expect(screen.getByText('test terminated vats')).toBeInTheDocument(); }); - it('expands vat details when clicked', async () => { - mockExecuteQuery.mockResolvedValue([{ key: 'key1', value: 'value1' }]); - - render(); + it('renders vat list with correct number of vats', () => { + const { container } = render(); + expect(screen.getByText('Vats')).toBeInTheDocument(); + const vatTitles = container.querySelectorAll('.accordion-title'); + expect(vatTitles.length).toBeGreaterThan(0); + const vat1Title = Array.from(vatTitles).find( + (el) => + el.textContent?.includes('TestVat1') && + el.textContent?.includes('vat1'), + ); + expect(vat1Title).toBeDefined(); + const vat2Title = Array.from(vatTitles).find( + (el) => + el.textContent?.includes('TestVat2') && + el.textContent?.includes('vat2'), + ); + expect(vat2Title).toBeDefined(); + }); - await waitFor(() => { - expect(screen.getByText(/Test Vat 1/u)).toBeInTheDocument(); - }); + it('displays correct vat details header', () => { + const { container } = render(); + const vatDetailsHeaders = container.querySelectorAll('.vat-details-header'); + expect(vatDetailsHeaders.length).toBeGreaterThan(0); + const vat1Header = Array.from(vatDetailsHeaders).find( + (el) => + el.textContent?.includes('3 objects') && + el.textContent?.includes('2 promises'), + ); + expect(vat1Header).toBeDefined(); + const vat2Header = Array.from(vatDetailsHeaders).find( + (el) => + el.textContent?.includes('0 objects') && + el.textContent?.includes('0 promises'), + ); + expect(vat2Header).toBeDefined(); + }); - // Initially, vat details should not be visible + it('toggles vat details when clicked', () => { + const { container } = render(); + // Initially, vat details should be collapsed expect(screen.queryByText('Owned Objects')).not.toBeInTheDocument(); - - // Click on vat header to expand it - await userEvent.click(screen.getByText(/Test Vat 1/u)); - - // After expanding, details should be visible + const accordionHeaders = container.querySelectorAll('.accordion-header'); + const vat1Header = Array.from(accordionHeaders).find( + (el) => + el.textContent?.includes('TestVat1') && + el.textContent?.includes('vat1'), + ); + expect(vat1Header).toBeDefined(); + // Ensure vat1Header is not undefined before clicking + expect(vat1Header).toBeInstanceOf(Element); + fireEvent.click(vat1Header as Element); + // Now details should be visible expect(screen.getByText('Owned Objects')).toBeInTheDocument(); expect(screen.getByText('Imported Objects')).toBeInTheDocument(); expect(screen.getByText('Imported Promises')).toBeInTheDocument(); expect(screen.getByText('Exported Promises')).toBeInTheDocument(); - - // Object details should be displayed - expect(screen.getByText('ko1')).toBeInTheDocument(); - expect(screen.getByText('o+1')).toBeInTheDocument(); - // Click again to collapse - await userEvent.click(screen.getByText(/Test Vat 1/u)); + fireEvent.click(vat1Header as Element); + // Details should be hidden again + expect(screen.queryByText('Owned Objects')).not.toBeInTheDocument(); + }); - // After collapsing, details should not be visible + it('renders empty state indicators for empty arrays', () => { + const { container } = render(); + // Find and click the Vat2 accordion header + const accordionHeaders = container.querySelectorAll('.accordion-header'); + const vat2Header = Array.from(accordionHeaders).find( + (el) => + el.textContent?.includes('TestVat2') && + el.textContent?.includes('vat2'), + ); + expect(vat2Header).toBeDefined(); + // Ensure vat2Header is not undefined before clicking + expect(vat2Header).toBeInstanceOf(Element); + fireEvent.click(vat2Header as Element); + // TestVat2 should not have any tables expect(screen.queryByText('Owned Objects')).not.toBeInTheDocument(); + expect(screen.queryByText('Imported Objects')).not.toBeInTheDocument(); + expect(screen.queryByText('Imported Promises')).not.toBeInTheDocument(); + expect(screen.queryByText('Exported Promises')).not.toBeInTheDocument(); }); - it('refreshes data when refresh button is clicked', async () => { - mockExecuteQuery.mockResolvedValue([{ key: 'key1', value: 'value1' }]); + it('renders object tables with correct data', () => { + const { container } = render(); + // Find and click the Vat1 accordion header + const accordionHeaders = container.querySelectorAll('.accordion-header'); + const vat1Header = Array.from(accordionHeaders).find( + (el) => + el.textContent?.includes('TestVat1') && + el.textContent?.includes('vat1'), + ); + expect(vat1Header).toBeDefined(); + // Ensure vat1Header is not undefined before clicking + expect(vat1Header).toBeInstanceOf(Element); + fireEvent.click(vat1Header as Element); + // Get tables by their headers + const ownedObjectsTable = getTableByHeading(container, 'Owned Objects'); + const importedObjectsTable = getTableByHeading( + container, + 'Imported Objects', + ); + const importedPromisesTable = getTableByHeading( + container, + 'Imported Promises', + ); + const exportedPromisesTable = getTableByHeading( + container, + 'Exported Promises', + ); + // Check owned objects table + within(ownedObjectsTable).getByText('kref1'); + within(ownedObjectsTable).getByText('eref1'); + within(ownedObjectsTable).getByText('vat2'); + within(ownedObjectsTable).getByText('kref2'); + within(ownedObjectsTable).getByText('eref2'); + // Check imported objects table + within(importedObjectsTable).getByText('kref3'); + within(importedObjectsTable).getByText('eref3'); + within(importedObjectsTable).getByText('vat2'); + + // Check imported promises table + within(importedPromisesTable).getByText('promise1'); + within(importedPromisesTable).getByText('eref-promise1'); + within(importedPromisesTable).getByText('pending'); + within(importedPromisesTable).getByText('vat2'); + + // Check exported promises table + within(exportedPromisesTable).getByText('promise2'); + within(exportedPromisesTable).getByText('eref-promise2'); + within(exportedPromisesTable).getByText('fulfilled'); + within(exportedPromisesTable).getByText('value'); + within(exportedPromisesTable).getByText('vat2'); + }); - render(); + it('properly formats slots with and without eref', () => { + const { container } = render(); - await waitFor(() => { - expect(screen.getByText('Kernel Registry')).toBeInTheDocument(); - }); + // Find and click the Vat1 accordion header to expand details + const accordionHeaders = container.querySelectorAll('.accordion-header'); + const vat1Header = Array.from(accordionHeaders).find( + (el) => + el.textContent?.includes('TestVat1') && + el.textContent?.includes('vat1'), + ); + expect(vat1Header).toBeDefined(); + expect(vat1Header).toBeInstanceOf(Element); + fireEvent.click(vat1Header as Element); + + // Get the tables from the expanded content + const importedPromisesTable = getTableByHeading( + container, + 'Imported Promises', + ); + const exportedPromisesTable = getTableByHeading( + container, + 'Exported Promises', + ); - // Clear mocks to verify the second call - mockExecuteQuery.mockClear(); + // Get the cells containing slot information (5th column, index 4) + const importedSlotsText = getCellTextByIndex(importedPromisesTable, 4); + const exportedSlotsText = getCellTextByIndex(exportedPromisesTable, 4); + + // Test imported promise slots formatting + expect(importedSlotsText).toContain('slot1 (eref-slot1)'); // With eref + expect(importedSlotsText).toContain('slot2'); // With empty eref + expect(importedSlotsText).toContain('slot3'); // With null eref + expect(importedSlotsText).not.toContain('slot2 ()'); // Empty eref shouldn't show parentheses + expect(importedSlotsText).not.toContain('slot3 ()'); // Null eref shouldn't show parentheses + + // Test exported promise slots formatting + expect(exportedSlotsText).toContain('exported-slot1 (exported-eref1)'); // With eref + expect(exportedSlotsText).toContain('exported-slot2'); // With null eref + expect(exportedSlotsText).not.toContain('exported-slot2 ()'); // Null eref shouldn't show parentheses + }); - // Click refresh button - await userEvent.click(screen.getByText('Refresh')); + it('refreshes registry when Refresh button is clicked', () => { + render(); + // fetchObjectRegistry should be called once on mount + expect(fetchObjectRegistry).toHaveBeenCalledTimes(1); + // Find and click the refresh button + const refreshButton = screen.getByTestId('refresh-registry-button'); + expect(refreshButton).toBeDefined(); + expect(refreshButton?.textContent).toBe('Refresh'); + // Ensure refreshButton is not null before clicking + expect(refreshButton).toBeInstanceOf(Element); + fireEvent.click(refreshButton as Element); + // fetchObjectRegistry should be called again + expect(fetchObjectRegistry).toHaveBeenCalledTimes(2); + }); - // Verify that executeQuery was called again - expect(mockExecuteQuery).toHaveBeenCalledWith('SELECT key, value FROM kv'); + it('displays the SendMessageForm component', () => { + render(); + const sendMessageForm = screen.queryByTestId('send-message-form'); + expect(sendMessageForm).toBeInTheDocument(); }); }); + +/** + * Helper function to find a table by its heading text + * + * @param container - The container element to search within + * @param heading - The heading text to find + * @returns The table element associated with the heading + */ +function getTableByHeading( + container: HTMLElement, + heading: string, +): HTMLElement { + const headings = Array.from(container.querySelectorAll('h4')); + const targetHeading = headings.find( + (element) => element.textContent === heading, + ); + expect(targetHeading).toBeDefined(); + // The table is in the tableContainer which is the parent element's only child + const tableContainer = targetHeading?.parentElement; + expect(tableContainer).toBeDefined(); + const table = tableContainer?.querySelector('table'); + expect(table).toBeDefined(); + return table as HTMLElement; +} + +/** + * Helper function to get the text content of a table cell by index + * + * @param table - The table element + * @param columnIndex - The index of the column to extract + * @returns The text content of the cell + */ +function getCellTextByIndex(table: HTMLElement, columnIndex: number): string { + const cells = within(table).getAllByRole('cell'); + const columnCells = Array.from(cells).filter( + (_, index) => index % 6 === columnIndex, + ); + expect(columnCells.length).toBeGreaterThan(0); + return columnCells[0]?.textContent ?? ''; +} diff --git a/packages/extension/src/ui/components/ObjectRegistry.tsx b/packages/extension/src/ui/components/ObjectRegistry.tsx index 86da574dd..d24aa4d2a 100644 --- a/packages/extension/src/ui/components/ObjectRegistry.tsx +++ b/packages/extension/src/ui/components/ObjectRegistry.tsx @@ -1,9 +1,10 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useEffect, useState } from 'react'; import styles from '../App.module.css'; +import { SendMessageForm } from './SendMessageForm.tsx'; +import { usePanelContext } from '../context/PanelContext.tsx'; import { useDatabase } from '../hooks/useDatabase.ts'; -import type { ClusterSnapshot, VatSnapshot } from '../services/db-parser.ts'; -import { parseKernelDB } from '../services/db-parser.ts'; +import type { VatSnapshot } from '../types.ts'; const VatDetailsHeader: React.FC<{ data: VatSnapshot }> = ({ data }) => { const objects = data.ownedObjects.length + data.importedObjects.length; @@ -17,36 +18,9 @@ const VatDetailsHeader: React.FC<{ data: VatSnapshot }> = ({ data }) => { }; export const ObjectRegistry: React.FC = () => { - const [clusterData, setClusterData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); + const { objectRegistry } = usePanelContext(); + const { fetchObjectRegistry } = useDatabase(); const [expandedVats, setExpandedVats] = useState>({}); - const { executeQuery } = useDatabase(); - - // Fetch the kernel data - const fetchKernelData = useCallback(async (): Promise => { - try { - setIsLoading(true); - const result = await executeQuery('SELECT key, value FROM kv'); - const parsedData = parseKernelDB( - result as { key: string; value: string }[], - ); - setClusterData(parsedData); - } catch (fetchError: unknown) { - setError( - fetchError instanceof Error ? fetchError.message : String(fetchError), - ); - } finally { - setIsLoading(false); - } - }, [executeQuery]); - - // On mount, fetch the kernel data - useEffect(() => { - fetchKernelData().catch(() => { - // already handled - }); - }, [fetchKernelData]); const toggleVat = (vatId: string): void => { setExpandedVats((prev) => ({ @@ -55,31 +29,25 @@ export const ObjectRegistry: React.FC = () => { })); }; - if (isLoading) { - return
Loading cluster data...
; - } + // Fetch the object registry when the component mounts + useEffect(() => { + fetchObjectRegistry(); + }, [fetchObjectRegistry]); - if (error || !clusterData) { - return ( -
-

- Error: {error ?? 'No cluster data available'} -

-
- ); + if (!objectRegistry) { + return

Loading...

; } return ( -
+
+ +

Kernel Registry

@@ -89,28 +57,28 @@ export const ObjectRegistry: React.FC = () => { GC Actions - {clusterData.gcActions ?? 'None'} + {objectRegistry.gcActions ?? 'None'} Reap Queue - {clusterData.reapQueue ?? 'Empty'} + {objectRegistry.reapQueue ?? 'Empty'} Terminated Vats - {clusterData.terminatedVats ?? 'None'} + {objectRegistry.terminatedVats ?? 'None'}

Vats

- {Object.entries(clusterData.vats).map(([vatId, vatData]) => ( + {Object.entries(objectRegistry.vats).map(([vatId, vatData]) => (
toggleVat(vatId)} > -
+
{vatData.overview.name} ({vatId}) -{' '}
diff --git a/packages/extension/src/ui/components/SendMessageForm.test.tsx b/packages/extension/src/ui/components/SendMessageForm.test.tsx new file mode 100644 index 000000000..ba07bc3d3 --- /dev/null +++ b/packages/extension/src/ui/components/SendMessageForm.test.tsx @@ -0,0 +1,313 @@ +import { setupOcapKernelMock } from '@ocap/test-utils'; +import { stringify } from '@ocap/utils'; +import { + render, + screen, + fireEvent, + waitFor, + cleanup, + within, +} from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import type { PanelContextType } from '../context/PanelContext.tsx'; +import { usePanelContext } from '../context/PanelContext.tsx'; +import { useDatabase } from '../hooks/useDatabase.ts'; +import type { ObjectRegistry } from '../types.ts'; +import { SendMessageForm } from './SendMessageForm.tsx'; + +setupOcapKernelMock(); + +vi.mock('../context/PanelContext.tsx', () => ({ + usePanelContext: vi.fn(), +})); + +vi.mock('../hooks/useDatabase.ts', () => ({ + useDatabase: vi.fn(), +})); + +vi.mock('@ocap/utils', () => ({ + stringify: vi.fn(), +})); + +describe('SendMessageForm Component', () => { + const callKernelMethod = vi.fn(); + const logMessage = vi.fn(); + const fetchObjectRegistry = vi.fn(); + + const mockObjectRegistry: ObjectRegistry = { + gcActions: '', + reapQueue: '', + terminatedVats: '', + vats: { + vat1: { + overview: { name: 'TestVat1', bundleSpec: '' }, + ownedObjects: [ + { kref: 'kref1', eref: 'eref1', refCount: '1', toVats: [] }, + { kref: 'kref2', eref: 'eref2', refCount: '1', toVats: [] }, + ], + importedObjects: [], + importedPromises: [], + exportedPromises: [], + }, + vat2: { + overview: { name: 'TestVat2', bundleSpec: '' }, + ownedObjects: [ + { kref: 'kref3', eref: 'eref3', refCount: '1', toVats: [] }, + ], + importedObjects: [ + { + kref: 'kref4', + eref: 'eref4', + refCount: '1', + fromVat: 'vat1', + }, + ], + importedPromises: [], + exportedPromises: [], + }, + }, + }; + + beforeEach(() => { + vi.mocked(stringify).mockImplementation((value) => + JSON.stringify(value, null, 2), + ); + + vi.mocked(useDatabase).mockReturnValue({ + fetchTables: vi.fn(), + fetchTableData: vi.fn(), + executeQuery: vi.fn(), + fetchObjectRegistry, + }); + + vi.mocked(usePanelContext).mockReturnValue({ + callKernelMethod, + logMessage, + objectRegistry: mockObjectRegistry, + } as unknown as PanelContextType); + + callKernelMethod.mockResolvedValue({ body: 'success', slots: [] }); + }); + + afterEach(() => { + cleanup(); + vi.resetModules(); + }); + + it('renders nothing when objectRegistry is null', async () => { + vi.mocked(usePanelContext).mockReturnValue({ + callKernelMethod, + logMessage, + objectRegistry: null, + } as unknown as PanelContextType); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders form with correct initial values when objectRegistry is available', async () => { + const { getByTestId } = render(); + + // Check form elements are rendered + expect(screen.getByText('Send Message')).toBeInTheDocument(); + expect(screen.getByLabelText('Target:')).toBeInTheDocument(); + expect(screen.getByLabelText('Method:')).toBeInTheDocument(); + expect(screen.getByLabelText('Params (JSON):')).toBeInTheDocument(); + expect(screen.getByText('Send')).toBeInTheDocument(); + + // Check initial values + expect(screen.getByDisplayValue('__getMethodNames__')).toBeInTheDocument(); + expect(screen.getByDisplayValue('[]')).toBeInTheDocument(); + + // Check target dropdown contains the expected options + const targetSelect = getByTestId('message-target'); + expect(targetSelect).toBeInTheDocument(); + expect(targetSelect).toHaveValue(''); + + const options = within(targetSelect).getAllByRole('option'); + expect(options).toHaveLength(5); // 1 placeholder + 4 options from mock registry + + // Check dropdown options include objects from the registry + expect(screen.getByText('kref1 (TestVat1)')).toBeInTheDocument(); + expect(screen.getByText('kref2 (TestVat1)')).toBeInTheDocument(); + expect(screen.getByText('kref3 (TestVat2)')).toBeInTheDocument(); + expect(screen.getByText('kref4 (TestVat1)')).toBeInTheDocument(); + }); + + it('updates form values when inputs change', async () => { + const { getByTestId } = render(); + + // Change target + const targetSelect = getByTestId('message-target'); + fireEvent.change(targetSelect, { target: { value: 'kref1' } }); + expect(targetSelect).toHaveValue('kref1'); + + // Change method + const methodInput = getByTestId('message-method'); + await userEvent.clear(methodInput); + await userEvent.type(methodInput, 'testMethod'); + expect(methodInput).toHaveValue('testMethod'); + + // Change params - using fireEvent.change instead of userEvent.type + const paramsInput = getByTestId('message-params'); + fireEvent.change(paramsInput, { target: { value: '["arg1", "arg2"]' } }); + expect(paramsInput).toHaveValue('["arg1", "arg2"]'); + }); + + it('disables Send button when target is empty', async () => { + const { getByTestId } = render(); + + // Initially button should be disabled (no target selected) + const sendButton = getByTestId('message-send-button'); + expect(sendButton).toBeDisabled(); + + // Select a target + const targetSelect = getByTestId('message-target'); + fireEvent.change(targetSelect, { target: { value: 'kref1' } }); + + // Button should now be enabled + expect(sendButton).not.toBeDisabled(); + + // Clear method + const methodInput = getByTestId('message-method'); + await userEvent.clear(methodInput); + + // Button should be disabled again + expect(sendButton).toBeDisabled(); + }); + + it('calls callKernelMethod with correct parameters when Send button is clicked', async () => { + const { getByTestId } = render(); + + // Set up form values + const targetSelect = getByTestId('message-target'); + fireEvent.change(targetSelect, { target: { value: 'kref1' } }); + + const methodInput = getByTestId('message-method'); + fireEvent.change(methodInput, { target: { value: 'testMethod' } }); + + const paramsInput = getByTestId('message-params'); + fireEvent.change(paramsInput, { target: { value: '["arg1", "arg2"]' } }); + + // Parse expected args to match what the component will do + const expectedArgs = ['arg1', 'arg2']; + + // Click send button + const sendButton = getByTestId('message-send-button'); + await userEvent.click(sendButton); + + // Check if callKernelMethod was called with correct parameters + expect(callKernelMethod).toHaveBeenCalledWith({ + method: 'queueMessage', + params: ['kref1', 'testMethod', expectedArgs], + }); + + // Check if fetchObjectRegistry was called + await waitFor(() => { + expect(fetchObjectRegistry).toHaveBeenCalled(); + }); + }); + + it('logs error when callKernelMethod fails', async () => { + const testError = new Error('Test error'); + callKernelMethod.mockRejectedValueOnce(testError); + + const { getByTestId } = render(); + + // Set up form values and submit + const targetSelect = getByTestId('message-target'); + fireEvent.change(targetSelect, { target: { value: 'kref1' } }); + + const sendButton = getByTestId('message-send-button'); + await userEvent.click(sendButton); + + // Check if error was logged + await waitFor(() => { + expect(logMessage).toHaveBeenCalledWith(String(testError), 'error'); + }); + }); + + it('displays response after successful submission', async () => { + const mockResponse = { body: 'Success response', slots: [] }; + callKernelMethod.mockResolvedValueOnce(mockResponse); + + // Mock stringify to return a consistent string + const mockResponseString = JSON.stringify(mockResponse, null, 2); + vi.mocked(stringify).mockReturnValueOnce(mockResponseString); + + const { getByTestId } = render(); + + // Set up form values and submit + const targetSelect = getByTestId('message-target'); + fireEvent.change(targetSelect, { target: { value: 'kref1' } }); + + const sendButton = getByTestId('message-send-button'); + await userEvent.click(sendButton); + + // Check if response section is displayed + await waitFor(() => { + const responseHeading = screen.getByText('Response:'); + expect(responseHeading).toBeInTheDocument(); + + // Find the pre element that contains the response + const preElement = responseHeading.parentElement?.querySelector('pre'); + expect(preElement).toBeInTheDocument(); + expect(preElement?.textContent).toContain('Success response'); + }); + }); + + it('handles invalid JSON in params input', async () => { + const { getByTestId } = render(); + + // Set up form values with invalid JSON + const targetSelect = getByTestId('message-target'); + fireEvent.change(targetSelect, { target: { value: 'kref1' } }); + + const paramsInput = getByTestId('message-params'); + fireEvent.change(paramsInput, { target: { value: 'invalid json' } }); + + // Click send button + const sendButton = getByTestId('message-send-button'); + await userEvent.click(sendButton); + + // Now the JSON parse error is caught by the component's catch handler + await waitFor(() => { + expect(logMessage).toHaveBeenCalledWith( + expect.stringContaining('SyntaxError'), + 'error', + ); + }); + }); + + it('triggers submission when Enter key is pressed in input fields', async () => { + const { getByTestId } = render(); + + // Set up form values - we need a valid target and valid JSON + const targetSelect = getByTestId('message-target'); + fireEvent.change(targetSelect, { target: { value: 'kref1' } }); + + // Ensure we have valid JSON in the params field + const paramsInput = getByTestId('message-params'); + fireEvent.change(paramsInput, { target: { value: '[]' } }); + + // Press Enter in method input - using fireEvent for better control + const methodInput = getByTestId('message-method'); + fireEvent.keyDown(methodInput, { key: 'Enter', code: 'Enter' }); + + // Wait for the async handleSend to be called + await waitFor(() => { + expect(callKernelMethod).toHaveBeenCalled(); + }); + + callKernelMethod.mockClear(); + + // Press Enter in params input + fireEvent.keyDown(paramsInput, { key: 'Enter', code: 'Enter' }); + + // Wait for the async handleSend to be called + await waitFor(() => { + expect(callKernelMethod).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/extension/src/ui/components/SendMessageForm.tsx b/packages/extension/src/ui/components/SendMessageForm.tsx new file mode 100644 index 000000000..9a8c4e580 --- /dev/null +++ b/packages/extension/src/ui/components/SendMessageForm.tsx @@ -0,0 +1,146 @@ +import type { Json } from '@metamask/utils'; +import { stringify } from '@ocap/utils'; +import { useState, useMemo } from 'react'; + +import styles from '../App.module.css'; +import { usePanelContext } from '../context/PanelContext.tsx'; +import { useDatabase } from '../hooks/useDatabase.ts'; + +/** + * Renders a form for users to queue a message to a vat. + * + * @returns JSX element for queue message form + */ +export const SendMessageForm: React.FC = () => { + const { callKernelMethod, logMessage, objectRegistry } = usePanelContext(); + const { fetchObjectRegistry } = useDatabase(); + const [target, setTarget] = useState(''); + const [method, setMethod] = useState('__getMethodNames__'); + const [paramsText, setParamsText] = useState('[]'); + const [result, setResult] = useState(null); + + // Build list of object KRef targets with their owner vat names + const targets = useMemo(() => { + if (!objectRegistry) { + return []; + } + + const seen = new Set(); + const list: { label: string; value: string }[] = []; + for (const [vatId, vat] of Object.entries(objectRegistry.vats)) { + const ownerName = vat.overview.name ?? vatId; + // Owned objects + for (const obj of vat.ownedObjects) { + if (!seen.has(obj.kref)) { + seen.add(obj.kref); + list.push({ label: `${obj.kref} (${ownerName})`, value: obj.kref }); + } + } + // Imported objects + for (const obj of vat.importedObjects) { + const originVat = obj.fromVat ?? vatId; + const originName = + objectRegistry.vats[originVat]?.overview.name ?? originVat; + if (!seen.has(obj.kref)) { + seen.add(obj.kref); + list.push({ label: `${obj.kref} (${originName})`, value: obj.kref }); + } + } + } + return list; + }, [objectRegistry]); + + const handleSend = (): void => { + Promise.resolve() + .then(() => JSON.parse(paramsText) as Json[]) + .then(async (args) => + callKernelMethod({ + method: 'queueMessage', + params: [target, method, args], + }), + ) + .then((response) => { + setResult(response); + logMessage(stringify(response), 'received'); + return fetchObjectRegistry(); + }) + .catch((error) => logMessage(String(error), 'error')); + }; + + const handleKeyDown = ( + event: React.KeyboardEvent, + ): void => { + if (event.key === 'Enter') { + handleSend(); + } + }; + + if (!objectRegistry) { + return <>; + } + + return ( +
+

Send Message

+
+
+ + +
+
+ + setMethod(event.target.value)} + placeholder="methodName" + onKeyDown={handleKeyDown} + data-testid="message-method" + /> +
+
+ + setParamsText(event.target.value)} + placeholder="[arg1, arg2]" + onKeyDown={handleKeyDown} + data-testid="message-params" + /> +
+
+ +
+
+ {result && ( +
+

Response:

+
{stringify(result, 0)}
+
+ )} +
+ ); +}; diff --git a/packages/extension/src/ui/components/VatTable.test.tsx b/packages/extension/src/ui/components/VatTable.test.tsx index a6c878c15..61b4db736 100644 --- a/packages/extension/src/ui/components/VatTable.test.tsx +++ b/packages/extension/src/ui/components/VatTable.test.tsx @@ -48,7 +48,6 @@ describe('VatTable Component', () => { it('renders nothing when no vats are present', () => { vi.mocked(useVats).mockReturnValue({ vats: [], - selectedVatId: undefined, ...mockActions, }); const { container } = render(); @@ -58,7 +57,6 @@ describe('VatTable Component', () => { it('renders table with correct headers when vats are present', () => { vi.mocked(useVats).mockReturnValue({ vats: mockVats, - selectedVatId: undefined, ...mockActions, }); render(); @@ -71,7 +69,6 @@ describe('VatTable Component', () => { it('renders correct vat data in table rows', () => { vi.mocked(useVats).mockReturnValue({ vats: mockVats, - selectedVatId: undefined, ...mockActions, }); render(); @@ -85,7 +82,6 @@ describe('VatTable Component', () => { it('renders action buttons for each vat', () => { vi.mocked(useVats).mockReturnValue({ vats: [mockVats[0] as VatRecord], - selectedVatId: undefined, ...mockActions, }); render(); @@ -99,7 +95,6 @@ describe('VatTable Component', () => { it('calls correct action handlers when buttons are clicked', async () => { vi.mocked(useVats).mockReturnValue({ vats: [mockVats[0] as VatRecord], - selectedVatId: undefined, ...mockActions, }); render(); @@ -114,7 +109,6 @@ describe('VatTable Component', () => { it('applies correct CSS classes', () => { vi.mocked(useVats).mockReturnValue({ vats: [mockVats[0] as VatRecord], - selectedVatId: undefined, ...mockActions, }); render(); diff --git a/packages/extension/src/ui/context/PanelContext.test.tsx b/packages/extension/src/ui/context/PanelContext.test.tsx index 99ae5f9a9..1494a6957 100644 --- a/packages/extension/src/ui/context/PanelContext.test.tsx +++ b/packages/extension/src/ui/context/PanelContext.test.tsx @@ -27,7 +27,10 @@ describe('PanelContext', () => { const { PanelProvider, usePanelContext } = await import( './PanelContext.tsx' ); - const payload = { test: 'data' }; + const payload = { + method: 'getStatus', + params: [], + }; const response = { success: true }; mockSendMessage.mockResolvedValueOnce(response); vi.mocked( @@ -40,7 +43,6 @@ describe('PanelContext', () => { ), }); - // @ts-expect-error - we are testing the callKernelMethod function const actualResponse = await result.current.callKernelMethod(payload); expect(mockSendMessage).toHaveBeenCalledWith(payload); expect(actualResponse).toBe(response); @@ -50,7 +52,10 @@ describe('PanelContext', () => { const { PanelProvider, usePanelContext } = await import( './PanelContext.tsx' ); - const payload = { test: 'data' }; + const payload = { + method: 'getStatus', + params: [], + }; const errorResponse = { error: 'Test error' }; mockSendMessage.mockResolvedValueOnce(errorResponse); vi.mocked( @@ -63,7 +68,6 @@ describe('PanelContext', () => { ), }); - // @ts-expect-error - we are testing the callKernelMethod function await expect(result.current.callKernelMethod(payload)).rejects.toThrow( JSON.stringify(errorResponse.error), ); @@ -79,7 +83,10 @@ describe('PanelContext', () => { const { PanelProvider, usePanelContext } = await import( './PanelContext.tsx' ); - const payload = { test: 'data' }; + const payload = { + method: 'getStatus', + params: [], + }; const error = new Error('Network error'); mockSendMessage.mockRejectedValueOnce(error); const { result } = renderHook(() => usePanelContext(), { @@ -89,7 +96,6 @@ describe('PanelContext', () => { ), }); - // @ts-expect-error - we are testing the callKernelMethod function await expect(result.current.callKernelMethod(payload)).rejects.toThrow( error, ); @@ -97,6 +103,51 @@ describe('PanelContext', () => { vi.mocked(await import('../services/logger.ts')).logger.error, ).toHaveBeenCalledWith(`Error: ${error.message}`, 'error'); }); + + it('should throw error when a request is already in progress', async () => { + const { PanelProvider, usePanelContext } = await import( + './PanelContext.tsx' + ); + const firstPayload = { + method: 'getStatus', + params: [], + }; + const secondPayload = { + method: 'getStatus', + params: [], + }; + + // Use a promise that we control to ensure the first request is still in progress + let resolveFirstRequest!: (value: { success: boolean }) => void; + const firstRequestPromise = new Promise<{ success: boolean }>( + (resolve) => { + resolveFirstRequest = resolve; + }, + ); + + mockSendMessage.mockReturnValueOnce(firstRequestPromise); + + const { result } = renderHook(() => usePanelContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Start the first request but don't await it + const firstRequestPromiseResult = + result.current.callKernelMethod(firstPayload); + + // Try to make a second request while the first is still processing + await expect( + result.current.callKernelMethod(secondPayload), + ).rejects.toThrow('A request is already in progress'); + + // Resolve the first request to clean up + resolveFirstRequest({ success: true }); + await firstRequestPromiseResult; + }); }); describe('clearLogs', () => { @@ -121,4 +172,23 @@ describe('PanelContext', () => { }); }); }); + + describe('usePanelContext', () => { + it('should throw error when used outside of PanelProvider', async () => { + const { usePanelContext } = await import('./PanelContext.tsx'); + + // Use a try-catch block to verify the error + let caughtError: Error | null = null; + try { + renderHook(() => usePanelContext()); + } catch (error) { + caughtError = error as Error; + } + + // Expect the error to be thrown + expect(caughtError).toStrictEqual( + new Error('usePanelContext must be used within a PanelProvider'), + ); + }); + }); }); diff --git a/packages/extension/src/ui/context/PanelContext.tsx b/packages/extension/src/ui/context/PanelContext.tsx index c341a99c0..706544d3b 100644 --- a/packages/extension/src/ui/context/PanelContext.tsx +++ b/packages/extension/src/ui/context/PanelContext.tsx @@ -13,6 +13,7 @@ import type { KernelStatus } from '../../kernel-integration/handlers/index.ts'; import { useStatusPolling } from '../hooks/useStatusPolling.ts'; import { logger } from '../services/logger.ts'; import type { CallKernelMethod } from '../services/stream.ts'; +import type { ObjectRegistry } from '../types.ts'; export type OutputType = 'sent' | 'received' | 'error' | 'success'; @@ -30,6 +31,8 @@ export type PanelContextType = { panelLogs: PanelLog[]; clearLogs: () => void; isLoading: boolean; + objectRegistry: ObjectRegistry | null; + setObjectRegistry: (objectRegistry: ObjectRegistry | null) => void; }; const PanelContext = createContext(undefined); @@ -38,10 +41,13 @@ export const PanelProvider: React.FC<{ children: ReactNode; callKernelMethod: CallKernelMethod; }> = ({ children, callKernelMethod }) => { + const isRequestInProgress = useRef(false); + const [isLoading, setIsLoading] = useState(false); const [panelLogs, setPanelLogs] = useState([]); const [messageContent, setMessageContent] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const isRequestInProgress = useRef(false); + const [objectRegistry, setObjectRegistry] = useState( + null, + ); const logMessage = useCallback( (message: string, type: OutputType = 'received'): void => { @@ -98,6 +104,8 @@ export const PanelProvider: React.FC<{ panelLogs, clearLogs, isLoading, + objectRegistry, + setObjectRegistry, }} > {children} diff --git a/packages/extension/src/ui/hooks/useDatabase.test.ts b/packages/extension/src/ui/hooks/useDatabase.test.ts index 86f65aa3a..fd4a7b952 100644 --- a/packages/extension/src/ui/hooks/useDatabase.test.ts +++ b/packages/extension/src/ui/hooks/useDatabase.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useDatabase } from './useDatabase.ts'; import { usePanelContext } from '../context/PanelContext.tsx'; +import { parseObjectRegistry } from '../services/db-parser.ts'; vi.mock('../context/PanelContext.tsx', () => ({ usePanelContext: vi.fn(), @@ -12,15 +13,21 @@ vi.mock('@metamask/kernel-utils', () => ({ stringify: JSON.stringify, })); +vi.mock('../services/db-parser.ts', () => ({ + parseObjectRegistry: vi.fn(), +})); + describe('useDatabase', () => { const mockCallKernelMethod = vi.fn(); const mockLogMessage = vi.fn(); + const mockSetObjectRegistry = vi.fn(); beforeEach(() => { vi.clearAllMocks(); vi.mocked(usePanelContext).mockReturnValue({ callKernelMethod: mockCallKernelMethod, logMessage: mockLogMessage, + setObjectRegistry: mockSetObjectRegistry, } as unknown as ReturnType); }); @@ -30,6 +37,7 @@ describe('useDatabase', () => { expect(result.current).toStrictEqual({ fetchTables: expect.any(Function), fetchTableData: expect.any(Function), + fetchObjectRegistry: expect.any(Function), executeQuery: expect.any(Function), }); }); @@ -134,4 +142,64 @@ describe('useDatabase', () => { ).rejects.toThrow('Invalid query'); }); }); + + describe('fetchObjectRegistry', () => { + it('should query the kv table and parse the result', async () => { + const { result } = renderHook(() => useDatabase()); + const mockKvData = [ + { key: 'obj1', value: '{"id":"obj1","type":"test"}' }, + { key: 'obj2', value: '{"id":"obj2","type":"test"}' }, + ]; + const mockParsedData = { + gcActions: '', + reapQueue: '', + terminatedVats: '', + vats: {}, + }; + + mockCallKernelMethod.mockResolvedValueOnce(mockKvData); + vi.mocked(parseObjectRegistry).mockReturnValueOnce(mockParsedData); + + result.current.fetchObjectRegistry(); + + await waitFor(() => { + expect(mockCallKernelMethod).toHaveBeenCalledWith({ + method: 'executeDBQuery', + params: { sql: 'SELECT key, value FROM kv' }, + }); + expect(parseObjectRegistry).toHaveBeenCalledWith(mockKvData); + expect(mockSetObjectRegistry).toHaveBeenCalledWith(mockParsedData); + }); + }); + + it('should log errors when fetching object registry fails', async () => { + const { result } = renderHook(() => useDatabase()); + const errorResponse = { error: 'Table not found' }; + mockCallKernelMethod.mockResolvedValueOnce(errorResponse); + + result.current.fetchObjectRegistry(); + + await waitFor(() => { + expect(mockLogMessage).toHaveBeenCalledWith( + 'Failed to fetch object registry: "Table not found"', + 'error', + ); + }); + }); + + it('should handle promise rejection when fetching object registry', async () => { + const { result } = renderHook(() => useDatabase()); + const error = new Error('Query failed'); + mockCallKernelMethod.mockRejectedValueOnce(error); + + result.current.fetchObjectRegistry(); + + await waitFor(() => { + expect(mockLogMessage).toHaveBeenCalledWith( + 'Failed to fetch object registry: Query failed', + 'error', + ); + }); + }); + }); }); diff --git a/packages/extension/src/ui/hooks/useDatabase.ts b/packages/extension/src/ui/hooks/useDatabase.ts index 907996b44..e7b356f62 100644 --- a/packages/extension/src/ui/hooks/useDatabase.ts +++ b/packages/extension/src/ui/hooks/useDatabase.ts @@ -3,6 +3,7 @@ import { hasProperty } from '@metamask/utils'; import { useCallback } from 'react'; import { usePanelContext } from '../context/PanelContext.tsx'; +import { parseObjectRegistry } from '../services/db-parser.ts'; /** * Hook for database actions. @@ -13,8 +14,9 @@ export function useDatabase(): { fetchTables: () => Promise; fetchTableData: (tableName: string) => Promise[]>; executeQuery: (sql: string) => Promise[]>; + fetchObjectRegistry: () => void; } { - const { callKernelMethod, logMessage } = usePanelContext(); + const { callKernelMethod, logMessage, setObjectRegistry } = usePanelContext(); // Execute a query and set the result as table data const executeQuery = useCallback( @@ -63,9 +65,27 @@ export function useDatabase(): { [logMessage, callKernelMethod], ); + // Fetch the kv db and parse it into an object registry + const fetchObjectRegistry = useCallback((): void => { + executeQuery('SELECT key, value FROM kv') + .then((result) => { + const parsedData = parseObjectRegistry( + result as { key: string; value: string }[], + ); + return setObjectRegistry(parsedData); + }) + .catch((error: Error) => + logMessage( + `Failed to fetch object registry: ${error.message}`, + 'error', + ), + ); + }, [executeQuery, logMessage, setObjectRegistry]); + return { fetchTables, fetchTableData, executeQuery, + fetchObjectRegistry, }; } diff --git a/packages/extension/src/ui/hooks/useKernelActions.test.ts b/packages/extension/src/ui/hooks/useKernelActions.test.ts index 07c4d6f87..bc7552b48 100644 --- a/packages/extension/src/ui/hooks/useKernelActions.test.ts +++ b/packages/extension/src/ui/hooks/useKernelActions.test.ts @@ -32,72 +32,63 @@ describe('useKernelActions', () => { panelLogs: [], clearLogs: vi.fn(), isLoading: false, + objectRegistry: null, + setObjectRegistry: vi.fn(), }); }); - describe('sendKernelCommand', () => { - it('sends message with payload', async () => { + describe('terminateAllVats', () => { + it('sends terminate all vats command', async () => { const { useKernelActions } = await import('./useKernelActions.ts'); const { result } = renderHook(() => useKernelActions()); + mockSendMessage.mockResolvedValueOnce({ success: true }); - result.current.sendKernelCommand(); + + result.current.terminateAllVats(); await waitFor(() => { expect(mockSendMessage).toHaveBeenCalledWith({ - method: 'sendVatCommand', - params: expect.objectContaining({ - id: 'v0', - payload: expect.any(Object), - }), + method: 'terminateAllVats', + params: [], }); }); + expect(mockLogMessage).toHaveBeenCalledWith( + 'All vats terminated', + 'success', + ); }); - it('logs success response', async () => { + it('logs error on failure', async () => { const { useKernelActions } = await import('./useKernelActions.ts'); const { result } = renderHook(() => useKernelActions()); - const response = { success: true }; - mockSendMessage.mockResolvedValueOnce(response); + mockSendMessage.mockRejectedValueOnce(new Error()); - result.current.sendKernelCommand(); + result.current.terminateAllVats(); await waitFor(() => { expect(mockLogMessage).toHaveBeenCalledWith( - JSON.stringify(response), - 'received', + 'Failed to terminate all vats', + 'error', ); }); }); - - it('logs error message on failure', async () => { - const { useKernelActions } = await import('./useKernelActions.ts'); - const { result } = renderHook(() => useKernelActions()); - const error = new Error('Test error'); - - mockSendMessage.mockRejectedValueOnce(error); - - result.current.sendKernelCommand(); - await waitFor(() => { - expect(mockLogMessage).toHaveBeenCalledWith(error.message, 'error'); - }); - }); }); - describe('terminateAllVats', () => { - it('sends terminate all vats command', async () => { + describe('collectGarbage', () => { + it('sends collect garbage command', async () => { const { useKernelActions } = await import('./useKernelActions.ts'); const { result } = renderHook(() => useKernelActions()); mockSendMessage.mockResolvedValueOnce({ success: true }); - result.current.terminateAllVats(); + result.current.collectGarbage(); await waitFor(() => { expect(mockSendMessage).toHaveBeenCalledWith({ - method: 'terminateAllVats', + method: 'collectGarbage', params: [], }); }); expect(mockLogMessage).toHaveBeenCalledWith( - 'All vats terminated', + 'Garbage collected', 'success', ); }); @@ -108,10 +99,10 @@ describe('useKernelActions', () => { mockSendMessage.mockRejectedValueOnce(new Error()); - result.current.terminateAllVats(); + result.current.collectGarbage(); await waitFor(() => { expect(mockLogMessage).toHaveBeenCalledWith( - 'Failed to terminate all vats', + 'Failed to collect garbage', 'error', ); }); diff --git a/packages/extension/src/ui/hooks/useKernelActions.ts b/packages/extension/src/ui/hooks/useKernelActions.ts index 045999555..45631f0a1 100644 --- a/packages/extension/src/ui/hooks/useKernelActions.ts +++ b/packages/extension/src/ui/hooks/useKernelActions.ts @@ -1,12 +1,7 @@ -import { stringify } from '@metamask/kernel-utils'; import type { ClusterConfig } from '@metamask/ocap-kernel'; -import { hasProperty, isObject } from '@metamask/utils'; import { useCallback } from 'react'; -import { assertVatCommandParams } from '../../kernel-integration/handlers/send-vat-command.ts'; -import type { SendVatCommandParams } from '../../kernel-integration/handlers/send-vat-command.ts'; import { usePanelContext } from '../context/PanelContext.tsx'; -import { nextMessageId } from '../utils.ts'; /** * Hook for handling kernel actions. @@ -14,7 +9,6 @@ import { nextMessageId } from '../utils.ts'; * @returns Kernel actions. */ export function useKernelActions(): { - sendKernelCommand: () => void; terminateAllVats: () => void; collectGarbage: () => void; clearState: () => void; @@ -22,19 +16,7 @@ export function useKernelActions(): { launchVat: (bundleUrl: string, vatName: string) => void; updateClusterConfig: (config: ClusterConfig) => Promise; } { - const { callKernelMethod, logMessage, messageContent } = usePanelContext(); - - /** - * Sends a kernel command. - */ - const sendKernelCommand = useCallback(() => { - callKernelMethod({ - method: 'sendVatCommand', - params: parseCommandParams(messageContent), - }) - .then((result) => logMessage(stringify(result, 0), 'received')) - .catch((error) => logMessage(error.message, 'error')); - }, [messageContent, callKernelMethod, logMessage]); + const { callKernelMethod, logMessage } = usePanelContext(); /** * Terminates all vats. @@ -120,7 +102,6 @@ export function useKernelActions(): { ); return { - sendKernelCommand, terminateAllVats, collectGarbage, clearState, @@ -129,31 +110,3 @@ export function useKernelActions(): { updateClusterConfig, }; } - -/** - * Parses sendVatCommand params to the expected format. Basically, turns the payload - * into a JSON-RPC request. - * - * @param rawParams - The raw, stringified params to parse. - * @returns The parsed params. - */ -function parseCommandParams(rawParams: string): SendVatCommandParams { - const params = JSON.parse(rawParams); - if ( - isObject(params) && - isObject(params.payload) && - hasProperty(params.payload, 'method') - ) { - const parsed = { - ...params, - payload: { - ...params.payload, - id: nextMessageId(), - jsonrpc: '2.0', - }, - }; - assertVatCommandParams(parsed); - return parsed; - } - throw new Error('Invalid command params'); -} diff --git a/packages/extension/src/ui/hooks/useVats.test.ts b/packages/extension/src/ui/hooks/useVats.test.ts index 80cf8a26c..e6e883bc4 100644 --- a/packages/extension/src/ui/hooks/useVats.test.ts +++ b/packages/extension/src/ui/hooks/useVats.test.ts @@ -83,46 +83,6 @@ describe('useVats', () => { ]); }); - describe('pingVat', () => { - it('should send ping message and log success', async () => { - const { useVats } = await import('./useVats.ts'); - const { result } = renderHook(() => useVats()); - - mockSendMessage.mockResolvedValueOnce({ success: true }); - result.current.pingVat(mockVatId); - await waitFor(() => { - expect(mockSendMessage).toHaveBeenCalledWith({ - method: 'sendVatCommand', - params: { - id: mockVatId, - payload: { - id: expect.any(String), - jsonrpc: '2.0', - method: 'ping', - params: [], - }, - }, - }); - }); - expect(mockLogMessage).toHaveBeenCalledWith( - '{"success":true}', - 'received', - ); - }); - - it('should handle ping error', async () => { - const { useVats } = await import('./useVats.ts'); - const { result } = renderHook(() => useVats()); - - const error = new Error('Ping failed'); - mockSendMessage.mockRejectedValueOnce(error); - result.current.pingVat(mockVatId); - await waitFor(() => { - expect(mockLogMessage).toHaveBeenCalledWith('Ping failed', 'error'); - }); - }); - }); - describe('restartVat', () => { it('should send restart message and log success', async () => { const { useVats } = await import('./useVats.ts'); diff --git a/packages/extension/src/ui/hooks/useVats.ts b/packages/extension/src/ui/hooks/useVats.ts index 06a2dd570..db2113e94 100644 --- a/packages/extension/src/ui/hooks/useVats.ts +++ b/packages/extension/src/ui/hooks/useVats.ts @@ -4,7 +4,6 @@ import { useCallback, useMemo } from 'react'; import { usePanelContext } from '../context/PanelContext.tsx'; import type { VatRecord } from '../types.ts'; -import { nextMessageId } from '../utils.ts'; /** * Hook to manage the vats state. @@ -48,22 +47,14 @@ export const useVats = (): { */ const pingVat = useCallback( (id: VatId) => { - callKernelMethod({ - method: 'sendVatCommand', - params: { - id, - payload: { - id: nextMessageId(), - jsonrpc: '2.0', - method: 'ping', - params: [], - }, - }, + // TODO: Implement ping + new Promise((_resolve, reject) => { + reject(new Error(`Cannot ping vat ${id}: Ping is not implemented`)); }) .then((result) => logMessage(stringify(result, 0), 'received')) .catch((error) => logMessage(error.message, 'error')); }, - [callKernelMethod, logMessage], + [logMessage], ); /** diff --git a/packages/extension/src/ui/services/db-parser.test.ts b/packages/extension/src/ui/services/db-parser.test.ts index c4e8216d7..3bff11289 100644 --- a/packages/extension/src/ui/services/db-parser.test.ts +++ b/packages/extension/src/ui/services/db-parser.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from 'vitest'; -import type { ClusterSnapshot } from './db-parser.ts'; -import { parseKernelDB } from './db-parser.ts'; +import type { ObjectRegistry } from '../types.ts'; +import { parseObjectRegistry } from './db-parser.ts'; -describe('parseKernelDB', () => { +describe('parseObjectRegistry', () => { it('should parse kernel DB entries into structured vat data', () => { const entries = [ { key: 'queue.run.head', value: '6' }, @@ -96,9 +96,9 @@ describe('parseKernelDB', () => { { key: 'v2.c.o-1', value: 'ko4' }, ]; - const result = parseKernelDB(entries); + const result = parseObjectRegistry(entries); - const expectedResult: ClusterSnapshot = { + const expectedResult: ObjectRegistry = { gcActions: '[]', reapQueue: '[]', terminatedVats: '[]', diff --git a/packages/extension/src/ui/services/db-parser.ts b/packages/extension/src/ui/services/db-parser.ts index f08c85dba..147754f9e 100644 --- a/packages/extension/src/ui/services/db-parser.ts +++ b/packages/extension/src/ui/services/db-parser.ts @@ -1,58 +1,4 @@ -/** - * A snapshot of the entire running cluster, keyed by Vat ID - */ -export type ClusterSnapshot = { - gcActions: string; - reapQueue: string; - terminatedVats: string; - vats: Record; -}; - -/** - * The full state and bindings for a single Vat - */ -export type VatSnapshot = { - overview: { name: string; bundleSpec: string }; - ownedObjects: ObjectBindingWithTargets[]; - importedObjects: ObjectBindingWithSource[]; - importedPromises: PromiseBindingWithSource[]; - exportedPromises: PromiseBindingWithTargets[]; -}; - -export type ObjectBinding = { - kref: string; - eref: string; - refCount: string; -}; - -export type ObjectBindingWithSource = { - fromVat: string | null; -} & ObjectBinding; - -export type ObjectBindingWithTargets = { - toVats: string[]; -} & ObjectBinding; - -export type PromiseBinding = { - kref: string; - eref: string; - state: string; - value: { body: string; slots: SlotInfo[] }; -}; - -export type PromiseBindingWithSource = { - fromVat: string | null; -} & PromiseBinding; - -export type PromiseBindingWithTargets = { - toVats: string[]; -} & PromiseBinding; - -export type SlotInfo = { - kref: string; - eref: string | null; - vat: string | null; -}; +import type { ObjectRegistry, VatSnapshot, SlotInfo } from '../types.ts'; /** * Parse a flat kernel DB dump into per-vat grouped info @@ -60,9 +6,9 @@ export type SlotInfo = { * @param entries - The flat kernel DB dump. * @returns A record of vat names to their KernelGroupedVat info. */ -export function parseKernelDB( +export function parseObjectRegistry( entries: { key: string; value: string }[], -): ClusterSnapshot { +): ObjectRegistry { // Raw metadata const koOwner: Record = {}; const koRefCount: Record = {}; diff --git a/packages/extension/src/ui/types.ts b/packages/extension/src/ui/types.ts index e3f4076ac..a741f7296 100644 --- a/packages/extension/src/ui/types.ts +++ b/packages/extension/src/ui/types.ts @@ -6,3 +6,59 @@ export type VatRecord = { parameters: string; creationOptions: string; }; + +/** + * A snapshot of the entire running cluster, keyed by Vat ID + */ +export type ObjectRegistry = { + gcActions: string; + reapQueue: string; + terminatedVats: string; + vats: Record; +}; + +/** + * The full state and bindings for a single Vat + */ +export type VatSnapshot = { + overview: { name: string; bundleSpec: string }; + ownedObjects: ObjectBindingWithTargets[]; + importedObjects: ObjectBindingWithSource[]; + importedPromises: PromiseBindingWithSource[]; + exportedPromises: PromiseBindingWithTargets[]; +}; + +export type ObjectBinding = { + kref: string; + eref: string; + refCount: string; +}; + +export type ObjectBindingWithSource = { + fromVat: string | null; +} & ObjectBinding; + +export type ObjectBindingWithTargets = { + toVats: string[]; +} & ObjectBinding; + +export type PromiseBinding = { + kref: string; + eref: string; + state: string; + value: { body: string; slots: SlotInfo[] }; +}; + +export type PromiseBindingWithSource = { + fromVat: string | null; +} & PromiseBinding; + +export type PromiseBindingWithTargets = { + toVats: string[]; +} & PromiseBinding; + +export type SlotInfo = { + kref: string; + eref: string | null; + vat: string | null; +}; diff --git a/packages/kernel-test/src/exo.test.ts b/packages/kernel-test/src/exo.test.ts index a9004f64a..b447a9c63 100644 --- a/packages/kernel-test/src/exo.test.ts +++ b/packages/kernel-test/src/exo.test.ts @@ -73,11 +73,7 @@ describe('virtual objects functionality', async () => { it('tests scalar store functionality', async () => { buffered = ''; - const storeResult = await kernel.queueMessageFromKernel( - 'ko1', - 'testScalarStore', - [], - ); + const storeResult = await kernel.queueMessage('ko1', 'testScalarStore', []); await waitUntilQuiescent(100); expect(kunser(storeResult)).toBe('scalar-store-tests-complete'); const vatLogs = extractVatLogs(buffered); @@ -92,44 +88,37 @@ describe('virtual objects functionality', async () => { it('can create and use objects through messaging', async () => { buffered = ''; - const counterResult = await kernel.queueMessageFromKernel( - 'ko1', - 'createCounter', - [42], - ); + const counterResult = await kernel.queueMessage('ko1', 'createCounter', [ + 42, + ]); await waitUntilQuiescent(); const counterRef = counterResult.slots[0] as KRef; - const incrementResult = await kernel.queueMessageFromKernel( - counterRef, - 'increment', - [5], - ); + const incrementResult = await kernel.queueMessage(counterRef, 'increment', [ + 5, + ]); // Verify the increment result expect(kunser(incrementResult)).toBe(47); await waitUntilQuiescent(); - const personResult = await kernel.queueMessageFromKernel( - 'ko1', - 'createPerson', - ['Dave', 35], - ); + const personResult = await kernel.queueMessage('ko1', 'createPerson', [ + 'Dave', + 35, + ]); await waitUntilQuiescent(); const personRef = personResult.slots[0] as KRef; - await kernel.queueMessageFromKernel('ko1', 'createOrUpdateInMap', [ + await kernel.queueMessage('ko1', 'createOrUpdateInMap', [ 'dave', personRef, ]); await waitUntilQuiescent(); // Get object from map store - const retrievedPerson = await kernel.queueMessageFromKernel( - 'ko1', - 'getFromMap', - ['dave'], - ); + const retrievedPerson = await kernel.queueMessage('ko1', 'getFromMap', [ + 'dave', + ]); await waitUntilQuiescent(); // Verify the retrieved person object expect(kunser(retrievedPerson)).toBe(personRef); - await kernel.queueMessageFromKernel('ko1', 'createOrUpdateInMap', [ + await kernel.queueMessage('ko1', 'createOrUpdateInMap', [ 'dave', personRef, ]); @@ -147,11 +136,7 @@ describe('virtual objects functionality', async () => { it('tests exoClass type validation and behavior', async () => { buffered = ''; - const exoClassResult = await kernel.queueMessageFromKernel( - 'ko1', - 'testExoClass', - [], - ); + const exoClassResult = await kernel.queueMessage('ko1', 'testExoClass', []); await waitUntilQuiescent(100); expect(kunser(exoClassResult)).toBe('exoClass-tests-complete'); const vatLogs = extractVatLogs(buffered); @@ -164,7 +149,7 @@ describe('virtual objects functionality', async () => { it('tests exoClassKit with multiple facets', async () => { buffered = ''; - const exoClassKitResult = await kernel.queueMessageFromKernel( + const exoClassKitResult = await kernel.queueMessage( 'ko1', 'testExoClassKit', [], @@ -182,39 +167,37 @@ describe('virtual objects functionality', async () => { it('tests temperature converter through messaging', async () => { buffered = ''; // Create a temperature converter starting at 100°C - const tempResult = await kernel.queueMessageFromKernel( - 'ko1', - 'createTemperature', - [100], - ); + const tempResult = await kernel.queueMessage('ko1', 'createTemperature', [ + 100, + ]); await waitUntilQuiescent(); // Get both facets from the result const tempKit = tempResult; const celsiusRef = tempKit.slots[0] as KRef; const fahrenheitRef = tempKit.slots[1] as KRef; // Get the celsius value - const celsiusResult = await kernel.queueMessageFromKernel( + const celsiusResult = await kernel.queueMessage( celsiusRef, 'getCelsius', [], ); expect(kunser(celsiusResult)).toBe(100); // Get the fahrenheit value - const fahrenheitResult = await kernel.queueMessageFromKernel( + const fahrenheitResult = await kernel.queueMessage( fahrenheitRef, 'getFahrenheit', [], ); expect(kunser(fahrenheitResult)).toBe(212); // Change the temperature using the fahrenheit facet - const setFahrenheitResult = await kernel.queueMessageFromKernel( + const setFahrenheitResult = await kernel.queueMessage( fahrenheitRef, 'setFahrenheit', [32], ); expect(kunser(setFahrenheitResult)).toBe(32); // Verify that the celsius value changed - const newCelsiusResult = await kernel.queueMessageFromKernel( + const newCelsiusResult = await kernel.queueMessage( celsiusRef, 'getCelsius', [], diff --git a/packages/kernel-test/src/garbage-collection.test.ts b/packages/kernel-test/src/garbage-collection.test.ts index 606fffe89..a70417cfb 100644 --- a/packages/kernel-test/src/garbage-collection.test.ts +++ b/packages/kernel-test/src/garbage-collection.test.ts @@ -75,7 +75,7 @@ describe('Garbage Collection', () => { it('objects are tracked with reference counts', async () => { const objectId = 'test-object'; // Create an object in the exporter vat - const createObjectData = await kernel.queueMessageFromKernel( + const createObjectData = await kernel.queueMessage( exporterKRef, 'createObject', [objectId], @@ -87,9 +87,7 @@ describe('Garbage Collection', () => { expect(initialRefCounts.recognizable).toBe(3); // Send the object to the importer vat const objectRef = kunser(createObjectData); - await kernel.queueMessageFromKernel(importerKRef, 'storeImport', [ - objectRef, - ]); + await kernel.queueMessage(importerKRef, 'storeImport', [objectRef]); await waitUntilQuiescent(); // Check that the object is reachable from the exporter vat const exporterReachable = kernelStore.getReachableFlag( @@ -102,11 +100,7 @@ describe('Garbage Collection', () => { expect(kernelStore.hasCListEntry(importerVatId, importerKref)).toBe(true); expect(kernelStore.getRefCount(importerKref)).toBe(1); // Use the object - const useResult = await kernel.queueMessageFromKernel( - importerKRef, - 'useImport', - [], - ); + const useResult = await kernel.queueMessage(importerKRef, 'useImport', []); await waitUntilQuiescent(); expect(parseReplyBody(useResult.body)).toBe(objectId); }); @@ -114,7 +108,7 @@ describe('Garbage Collection', () => { it('should trigger GC syscalls through bringOutYourDead', async () => { // Create an object in the exporter vat with a known ID const objectId = 'test-object'; - const createObjectData = await kernel.queueMessageFromKernel( + const createObjectData = await kernel.queueMessage( exporterKRef, 'createObject', [objectId], @@ -128,21 +122,21 @@ describe('Garbage Collection', () => { // Store the reference in the importer vat const objectRef = kunser(createObjectData); - await kernel.queueMessageFromKernel(importerKRef, 'storeImport', [ + await kernel.queueMessage(importerKRef, 'storeImport', [ objectRef, objectId, ]); await waitUntilQuiescent(); // Verify object is tracked in both vats - const importerHasObject = await kernel.queueMessageFromKernel( + const importerHasObject = await kernel.queueMessage( importerKRef, 'listImportedObjects', [], ); expect(parseReplyBody(importerHasObject.body)).toContain(objectId); - const exporterHasObject = await kernel.queueMessageFromKernel( + const exporterHasObject = await kernel.queueMessage( exporterKRef, 'isObjectPresent', [objectId], @@ -151,7 +145,7 @@ describe('Garbage Collection', () => { // Make a weak reference to the object in the importer vat // This should eventually trigger dropImports when GC runs - await kernel.queueMessageFromKernel(importerKRef, 'makeWeak', [objectId]); + await kernel.queueMessage(importerKRef, 'makeWeak', [objectId]); await waitUntilQuiescent(); // Schedule reap to trigger bringOutYourDead on next crank @@ -159,7 +153,7 @@ describe('Garbage Collection', () => { // Run 3 cranks to allow bringOutYourDead to be processed for (let i = 0; i < 3; i++) { - await kernel.queueMessageFromKernel(importerKRef, 'noop', []); + await kernel.queueMessage(importerKRef, 'noop', []); await waitUntilQuiescent(500); } @@ -170,14 +164,14 @@ describe('Garbage Collection', () => { // Now completely forget the import in the importer vat // This should trigger retireImports when GC runs - await kernel.queueMessageFromKernel(importerKRef, 'forgetImport', []); + await kernel.queueMessage(importerKRef, 'forgetImport', []); await waitUntilQuiescent(); // Schedule another reap kernel.reapVats((vatId) => vatId === importerVatId); for (let i = 0; i < 3; i++) { - await kernel.queueMessageFromKernel(importerKRef, 'noop', []); + await kernel.queueMessage(importerKRef, 'noop', []); await waitUntilQuiescent(500); } @@ -188,20 +182,18 @@ describe('Garbage Collection', () => { // Now forget the object in the exporter vat // This should trigger retireExports when GC runs - await kernel.queueMessageFromKernel(exporterKRef, 'forgetObject', [ - objectId, - ]); + await kernel.queueMessage(exporterKRef, 'forgetObject', [objectId]); await waitUntilQuiescent(); // Schedule a final reap kernel.reapVats((vatId) => vatId === exporterVatId); // Run a crank to ensure GC completes - await kernel.queueMessageFromKernel(exporterKRef, 'noop', []); + await kernel.queueMessage(exporterKRef, 'noop', []); await waitUntilQuiescent(50); // Verify the object has been completely removed - const exporterFinalCheck = await kernel.queueMessageFromKernel( + const exporterFinalCheck = await kernel.queueMessage( exporterKRef, 'isObjectPresent', [objectId], diff --git a/packages/kernel-test/src/utils.ts b/packages/kernel-test/src/utils.ts index 5dda1ebbe..46cfb2490 100644 --- a/packages/kernel-test/src/utils.ts +++ b/packages/kernel-test/src/utils.ts @@ -56,11 +56,7 @@ export async function runResume( kernel: Kernel, rootRef: string, ): Promise { - const resumeResultRaw = await kernel.queueMessageFromKernel( - rootRef, - 'resume', - [], - ); + const resumeResultRaw = await kernel.queueMessage(rootRef, 'resume', []); return kunser(resumeResultRaw); } diff --git a/packages/ocap-kernel/src/Kernel.test.ts b/packages/ocap-kernel/src/Kernel.test.ts index 5cbfd3b90..f1fa322ec 100644 --- a/packages/ocap-kernel/src/Kernel.test.ts +++ b/packages/ocap-kernel/src/Kernel.test.ts @@ -290,54 +290,6 @@ describe('Kernel', () => { }); }); - describe('sendVatCommand()', () => { - it('sends a message to the vat without errors when the vat exists', async () => { - const kernel = await Kernel.make( - mockStream, - mockWorkerService, - mockKernelDatabase, - ); - await kernel.launchVat(makeMockVatConfig()); - vatHandles[0]?.sendVatCommand.mockResolvedValueOnce('test'); - const result = await kernel.sendVatCommand('v1', { - method: 'ping', - params: [], - }); - expect(result).toBe('test'); - }); - - it('throws an error when sending a message to the vat that does not exist in the kernel', async () => { - const kernel = await Kernel.make( - mockStream, - mockWorkerService, - mockKernelDatabase, - ); - const nonExistentVatId: VatId = 'v9'; - await expect(async () => - kernel.sendVatCommand(nonExistentVatId, { - method: 'ping', - params: [], - }), - ).rejects.toThrow(VatNotFoundError); - }); - - it('throws an error when sending a message to the vat throws', async () => { - const kernel = await Kernel.make( - mockStream, - mockWorkerService, - mockKernelDatabase, - ); - await kernel.launchVat(makeMockVatConfig()); - vatHandles[0]?.sendVatCommand.mockRejectedValueOnce('error'); - await expect(async () => - kernel.sendVatCommand('v1', { - method: 'ping', - params: [], - }), - ).rejects.toThrow('error'); - }); - }); - describe('constructor()', () => { it('initializes the kernel without errors', async () => { expect( diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 1a7752c28..d970bbd03 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -5,10 +5,6 @@ import { VatNotFoundError, } from '@metamask/kernel-errors'; import { RpcService } from '@metamask/kernel-rpc-methods'; -import type { - ExtractParams, - ExtractResult, -} from '@metamask/kernel-rpc-methods'; import type { KernelDatabase } from '@metamask/kernel-store'; import type { JsonRpcCall } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; @@ -20,7 +16,6 @@ import type { JsonRpcResponse } from '@metamask/utils'; import { KernelQueue } from './KernelQueue.ts'; import { KernelRouter } from './KernelRouter.ts'; import { kernelHandlers } from './rpc/index.ts'; -import type { VatMethod, vatMethodSpecs } from './rpc/index.ts'; import type { SlotValue } from './services/kernel-marshal.ts'; import { kslot } from './services/kernel-marshal.ts'; import { makeKernelStore } from './store/index.ts'; @@ -232,7 +227,7 @@ export class Kernel { * * @returns a promise for the (CapData encoded) result of the message invocation. */ - async queueMessageFromKernel( + async queueMessage( target: KRef, method: string, args: unknown[], @@ -264,7 +259,7 @@ export class Kernel { if (config.bootstrap) { const bootstrapRoot = rootIds[config.bootstrap]; if (bootstrapRoot) { - return this.queueMessageFromKernel(bootstrapRoot, 'bootstrap', [roots]); + return this.queueMessage(bootstrapRoot, 'bootstrap', [roots]); } } return undefined; @@ -328,29 +323,6 @@ export class Kernel { this.#kernelStore.clear(); } - /** - * Send a command to a vat. - * - * @param id - The id of the vat to send the command to. - * @param command - The command to send. - * @param command.method - The method to call. - * @param command.params - The parameters to pass to the method. - * @returns A promise that resolves the response to the command. - */ - async sendVatCommand( - id: VatId, - { - method, - params, - }: { - method: Method; - params: ExtractParams; - }, - ): Promise> { - const vat = this.#getVat(id); - return vat.sendVatCommand({ method, params }); - } - /** * Get a vat. * diff --git a/packages/ocap-kernel/src/index.test.ts b/packages/ocap-kernel/src/index.test.ts index c353bc8ce..ad66338ec 100644 --- a/packages/ocap-kernel/src/index.test.ts +++ b/packages/ocap-kernel/src/index.test.ts @@ -5,6 +5,7 @@ import * as indexModule from './index.ts'; describe('index', () => { it('has the expected exports', () => { expect(Object.keys(indexModule).sort()).toStrictEqual([ + 'CapDataStruct', 'ClusterConfigStruct', 'Kernel', 'VatConfigStruct', diff --git a/packages/ocap-kernel/src/index.ts b/packages/ocap-kernel/src/index.ts index a2a5d6e17..692661c01 100644 --- a/packages/ocap-kernel/src/index.ts +++ b/packages/ocap-kernel/src/index.ts @@ -15,6 +15,7 @@ export { isVatConfig, VatConfigStruct, ClusterConfigStruct, + CapDataStruct, } from './types.ts'; export { kunser, kser } from './services/kernel-marshal.ts'; export { makeKernelStore } from './store/index.ts'; diff --git a/packages/ocap-kernel/src/rpc/index.test.ts b/packages/ocap-kernel/src/rpc/index.test.ts index b29843d60..21e773f69 100644 --- a/packages/ocap-kernel/src/rpc/index.test.ts +++ b/packages/ocap-kernel/src/rpc/index.test.ts @@ -5,7 +5,6 @@ import * as indexModule from './index.ts'; describe('index', () => { it('has the expected exports', () => { expect(Object.keys(indexModule).sort()).toStrictEqual([ - 'UiMethodRequestStruct', 'kernelHandlers', 'kernelMethodSpecs', 'vatHandlers', diff --git a/packages/ocap-kernel/src/rpc/vat/index.ts b/packages/ocap-kernel/src/rpc/vat/index.ts index 319e10e13..73a153bfa 100644 --- a/packages/ocap-kernel/src/rpc/vat/index.ts +++ b/packages/ocap-kernel/src/rpc/vat/index.ts @@ -1,7 +1,3 @@ -import type { MethodRequest } from '@metamask/kernel-rpc-methods'; -import { is, refine, Struct } from '@metamask/superstruct'; -import { JsonRpcRequestStruct } from '@metamask/utils'; - import { deliverSpec, deliverHandler } from './deliver.ts'; import type { DeliverSpec, DeliverHandler } from './deliver.ts'; import { initVatSpec, initVatHandler } from './initVat.ts'; @@ -35,20 +31,3 @@ export const vatMethodSpecs = { type Handlers = (typeof vatHandlers)[keyof typeof vatHandlers]; export type VatMethod = Handlers['method']; - -export type VatUiMethod = - | (typeof vatMethodSpecs)['deliver'] - | (typeof vatMethodSpecs)['ping']; - -export type UiMethodRequest = MethodRequest; - -export const UiMethodRequestStruct = refine( - JsonRpcRequestStruct, - 'UiMethodRequest', - (value) => { - return ( - (value.method === 'ping' && is(value.params, pingSpec.params)) || - (value.method === 'deliver' && is(value.params, deliverSpec.params)) - ); - }, -) as Struct; From 5ccfe2dbcfcf82f3186707adeb62413f5aaceb3a Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Fri, 2 May 2025 18:09:42 +0200 Subject: [PATCH 2/9] update e2e tests --- .../src/ui/components/SendMessageForm.tsx | 2 +- .../extension/test/e2e/control-panel.test.ts | 54 +++++++++++-------- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/packages/extension/src/ui/components/SendMessageForm.tsx b/packages/extension/src/ui/components/SendMessageForm.tsx index 9a8c4e580..ce75558c7 100644 --- a/packages/extension/src/ui/components/SendMessageForm.tsx +++ b/packages/extension/src/ui/components/SendMessageForm.tsx @@ -136,7 +136,7 @@ export const SendMessageForm: React.FC = () => {
{result && ( -
+

Response:

{stringify(result, 0)}
diff --git a/packages/extension/test/e2e/control-panel.test.ts b/packages/extension/test/e2e/control-panel.test.ts index 503fe7aae..3ea1db457 100644 --- a/packages/extension/test/e2e/control-panel.test.ts +++ b/packages/extension/test/e2e/control-panel.test.ts @@ -116,7 +116,8 @@ test.describe('Control Panel', () => { await expect(popupPage.locator('table tr')).toHaveCount(3); }); - test('should send a message to a vat', async () => { + // TODO: Implement this test once the ping method is implemented + test.skip('should ping a vat', async () => { await expect( popupPage.locator('td button:text("Ping")').first(), ).toBeVisible(); @@ -204,33 +205,44 @@ test.describe('Control Panel', () => { .toBeTruthy(); }); - test('should send a message from the message panel', async () => { + test('should send a message to a vat', async () => { const clearLogsButton = popupPage.locator( '[data-testid="clear-logs-button"]', ); await clearLogsButton.click(); - const input = popupPage.locator('[data-testid="send-command-input"]'); - await input.fill( - `{ - "id": "v1", - "payload": { - "method": "ping", - "params": [] - } - }`, + await popupPage.click('button:text("Object Registry")'); + await expect(popupPage.locator('#root')).toContainText( + 'Alice (v1) - 3 objects, 3 promises', ); - await popupPage.click('button:text("Send")'); - await expect(messageOutput).toContainText('"method": "ping",'); - await expect(messageOutput).toContainText('{"result":"pong"}'); - // Test deliver command + const targetSelect = popupPage.locator('[data-testid="message-target"]'); + await expect(targetSelect).toBeVisible(); + const options = targetSelect.locator('option:not([value=""])'); + await expect(options).toHaveCount(await options.count()); + expect(await options.count()).toBeGreaterThan(0); + await targetSelect.selectOption({ index: 1 }); + await expect(targetSelect).not.toHaveValue(''); + const methodInput = popupPage.locator('[data-testid="message-method"]'); + await expect(methodInput).toHaveValue('__getMethodNames__'); + const paramsInput = popupPage.locator('[data-testid="message-params"]'); + await expect(paramsInput).toHaveValue('[]'); + await popupPage.click('[data-testid="message-send-button"]'); + const messageResponse = popupPage.locator( + '[data-testid="message-response"]', + ); + await expect(messageResponse).toBeVisible(); + await expect(messageResponse).toContainText( + '"body":"#[\\"__getMethodNames__\\",\\"bootstrap\\",\\"hello\\"]"', + ); + await expect(messageResponse).toContainText('"slots":[]'); await clearLogsButton.click(); - await input.fill( - `{ "id": "v1", "payload": { "method": "deliver", "params": ["bringOutYourDead"] } }`, + await methodInput.fill('hello'); + await paramsInput.fill('[]'); + await popupPage.click('[data-testid="message-send-button"]'); + await expect(messageResponse).toContainText('"body":"#\\"vat Alice got'); + await expect(messageResponse).toContainText('"slots":['); + await expect(popupPage.locator('#root')).toContainText( + 'Alice (v1) - 3 objects, 5 promises', ); - await popupPage.click('button:text("Send")'); - await expect(messageOutput).toContainText('"method": "deliver",'); - await expect(messageOutput).toContainText('"bringOutYourDead"'); - await expect(messageOutput).toContainText('"result":[[],[]]}'); }); test('should reload kernel state and load default vats', async () => { From 68c8c1d617feb2c37861f431baca39b3f3e48e6b Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Fri, 2 May 2025 18:13:26 +0200 Subject: [PATCH 3/9] update thresholds --- vitest.config.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index 12331fe79..7155f6d78 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -86,10 +86,10 @@ export default defineConfig({ lines: 98.63, }, 'packages/extension/**': { - statements: 82.68, - functions: 81.69, - branches: 77.4, - lines: 82.5, + statements: 83.61, + functions: 82.96, + branches: 79.12, + lines: 83.59, }, 'packages/ocap-kernel/**': { statements: 90.43, From 893f76460180912059314e64759cf5f2488e9411 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Fri, 2 May 2025 19:05:09 +0200 Subject: [PATCH 4/9] update e2e tests --- .../extension/test/e2e/control-panel.test.ts | 2 +- packages/nodejs/test/e2e/kernel-worker.test.ts | 17 ++++------------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/extension/test/e2e/control-panel.test.ts b/packages/extension/test/e2e/control-panel.test.ts index 3ea1db457..031e7861f 100644 --- a/packages/extension/test/e2e/control-panel.test.ts +++ b/packages/extension/test/e2e/control-panel.test.ts @@ -116,7 +116,7 @@ test.describe('Control Panel', () => { await expect(popupPage.locator('table tr')).toHaveCount(3); }); - // TODO: Implement this test once the ping method is implemented + // TODO: Fix this test once the ping method is implemented test.skip('should ping a vat', async () => { await expect( popupPage.locator('td button:text("Ping")').first(), diff --git a/packages/nodejs/test/e2e/kernel-worker.test.ts b/packages/nodejs/test/e2e/kernel-worker.test.ts index 27d65512f..4b50ffda3 100644 --- a/packages/nodejs/test/e2e/kernel-worker.test.ts +++ b/packages/nodejs/test/e2e/kernel-worker.test.ts @@ -1,7 +1,7 @@ import '@metamask/kernel-shims/endoify'; import { Kernel } from '@metamask/ocap-kernel'; -import type { VatConfig, VatId } from '@metamask/ocap-kernel'; +import type { VatConfig } from '@metamask/ocap-kernel'; import { MessageChannel as NodeMessageChannel, MessagePort as NodePort, @@ -71,17 +71,8 @@ describe('Kernel Worker', () => { expect(kernel.getVatIds()).toHaveLength(0); }); - it('pings vats', async () => { - await launchTestVats(); - await Promise.all( - testVatIds.map( - async (vatId: VatId) => - await kernel.sendVatCommand(vatId, { - method: 'ping', - params: [], - }), - ), - ); - expect(true).toBe(true); + // TODO: Fix this test once the ping method is implemented + it.todo('pings vats', async () => { + // silence is golden }); }); From d19c0168a1582b3869d0566cbcab26bb6c909d48 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Sat, 3 May 2025 00:09:55 +0200 Subject: [PATCH 5/9] Fix imports from rebase --- .../kernel-integration/handlers/queue-message.ts | 6 +++--- .../src/ui/components/SendMessageForm.test.tsx | 4 ++-- .../src/ui/components/SendMessageForm.tsx | 2 +- vitest.config.ts | 16 ++++++++-------- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/extension/src/kernel-integration/handlers/queue-message.ts b/packages/extension/src/kernel-integration/handlers/queue-message.ts index 2b3e1f8f6..f98b97ff5 100644 --- a/packages/extension/src/kernel-integration/handlers/queue-message.ts +++ b/packages/extension/src/kernel-integration/handlers/queue-message.ts @@ -1,10 +1,10 @@ import type { CapData } from '@endo/marshal'; +import type { MethodSpec, Handler } from '@metamask/kernel-rpc-methods'; +import type { Kernel } from '@metamask/ocap-kernel'; +import { CapDataStruct } from '@metamask/ocap-kernel'; import { tuple, string, array } from '@metamask/superstruct'; import { UnsafeJsonStruct } from '@metamask/utils'; import type { Json } from '@metamask/utils'; -import type { Kernel } from '@ocap/kernel'; -import { CapDataStruct } from '@ocap/kernel'; -import type { MethodSpec, Handler } from '@ocap/rpc-methods'; /** * Enqueue a message to a vat via the kernel's crank queue. diff --git a/packages/extension/src/ui/components/SendMessageForm.test.tsx b/packages/extension/src/ui/components/SendMessageForm.test.tsx index ba07bc3d3..40211948b 100644 --- a/packages/extension/src/ui/components/SendMessageForm.test.tsx +++ b/packages/extension/src/ui/components/SendMessageForm.test.tsx @@ -1,5 +1,5 @@ +import { stringify } from '@metamask/kernel-utils'; import { setupOcapKernelMock } from '@ocap/test-utils'; -import { stringify } from '@ocap/utils'; import { render, screen, @@ -27,7 +27,7 @@ vi.mock('../hooks/useDatabase.ts', () => ({ useDatabase: vi.fn(), })); -vi.mock('@ocap/utils', () => ({ +vi.mock('@metamask/kernel-utils', () => ({ stringify: vi.fn(), })); diff --git a/packages/extension/src/ui/components/SendMessageForm.tsx b/packages/extension/src/ui/components/SendMessageForm.tsx index ce75558c7..18af3060e 100644 --- a/packages/extension/src/ui/components/SendMessageForm.tsx +++ b/packages/extension/src/ui/components/SendMessageForm.tsx @@ -1,5 +1,5 @@ +import { stringify } from '@metamask/kernel-utils'; import type { Json } from '@metamask/utils'; -import { stringify } from '@ocap/utils'; import { useState, useMemo } from 'react'; import styles from '../App.module.css'; diff --git a/vitest.config.ts b/vitest.config.ts index 7155f6d78..a78f57841 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -80,10 +80,10 @@ export default defineConfig({ lines: 100, }, 'packages/errors/**': { - statements: 98.63, - functions: 95.23, - branches: 92, - lines: 98.63, + statements: 0, + functions: 0, + branches: 0, + lines: 0, }, 'packages/extension/**': { statements: 83.61, @@ -92,10 +92,10 @@ export default defineConfig({ lines: 83.59, }, 'packages/ocap-kernel/**': { - statements: 90.43, - functions: 92.18, - branches: 79.89, - lines: 90.41, + statements: 90.47, + functions: 92.51, + branches: 80.45, + lines: 90.45, }, 'packages/logger/**': { statements: 97.29, From cc6c462369497d45f3d3b857a62139276e48164e Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Sat, 3 May 2025 15:47:13 +0200 Subject: [PATCH 6/9] fix import --- .../src/kernel-integration/handlers/queue-message.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/src/kernel-integration/handlers/queue-message.test.ts b/packages/extension/src/kernel-integration/handlers/queue-message.test.ts index ca1a05a7a..9553efa4a 100644 --- a/packages/extension/src/kernel-integration/handlers/queue-message.test.ts +++ b/packages/extension/src/kernel-integration/handlers/queue-message.test.ts @@ -1,5 +1,5 @@ import type { CapData } from '@endo/marshal'; -import type { Kernel } from '@ocap/kernel'; +import type { Kernel } from '@metamask/ocap-kernel'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { queueMessageSpec, queueMessageHandler } from './queue-message.ts'; From 42e80f1a537f31251e7a597b761483ba06d88bc2 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Mon, 5 May 2025 11:27:30 +0200 Subject: [PATCH 7/9] fix thresholds --- vitest.config.ts | 50 ++++++++++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index a78f57841..163aae7e3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -79,35 +79,17 @@ export default defineConfig({ branches: 100, lines: 100, }, - 'packages/errors/**': { - statements: 0, - functions: 0, - branches: 0, - lines: 0, - }, 'packages/extension/**': { statements: 83.61, functions: 82.96, branches: 79.12, lines: 83.59, }, - 'packages/ocap-kernel/**': { - statements: 90.47, - functions: 92.51, - branches: 80.45, - lines: 90.45, - }, - 'packages/logger/**': { - statements: 97.29, - functions: 92.3, - branches: 95.45, - lines: 100, - }, - 'packages/nodejs/**': { - statements: 72.91, - functions: 83.33, - branches: 63.63, - lines: 72.91, + 'packages/kernel-errors/**': { + statements: 98.63, + functions: 95.23, + branches: 92, + lines: 98.63, }, 'packages/kernel-rpc-methods/**': { statements: 100, @@ -127,13 +109,31 @@ export default defineConfig({ branches: 84.78, lines: 92.39, }, - 'packages/streams/**': { + 'packages/kernel-utils/**': { statements: 100, functions: 100, branches: 100, lines: 100, }, - 'packages/kernel-utils/**': { + 'packages/logger/**': { + statements: 97.29, + functions: 92.3, + branches: 95.45, + lines: 100, + }, + 'packages/nodejs/**': { + statements: 72.91, + functions: 83.33, + branches: 63.63, + lines: 72.91, + }, + 'packages/ocap-kernel/**': { + statements: 90.47, + functions: 92.51, + branches: 80.45, + lines: 90.45, + }, + 'packages/streams/**': { statements: 100, functions: 100, branches: 100, From ef4676988b4d39cf0572bae41b883b08daa6445e Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 6 May 2025 00:45:58 +0200 Subject: [PATCH 8/9] apply comments --- packages/extension/src/ui/App.module.css | 10 +++++++--- packages/extension/src/ui/components/ConfigEditor.tsx | 2 +- .../extension/src/ui/components/SendMessageForm.tsx | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/extension/src/ui/App.module.css b/packages/extension/src/ui/App.module.css index f14a54fc9..02e0c32dd 100644 --- a/packages/extension/src/ui/App.module.css +++ b/packages/extension/src/ui/App.module.css @@ -49,9 +49,6 @@ body > div { box-sizing: border-box; } -.container { -} - h1, h2, h3, @@ -646,3 +643,10 @@ table.table { font-weight: 400; margin-left: var(--spacing-xs); } + +@media (min-width: 1200px) { + .horizontalForm .formFieldTarget { + width: 150px; + flex: none; + } +} diff --git a/packages/extension/src/ui/components/ConfigEditor.tsx b/packages/extension/src/ui/components/ConfigEditor.tsx index 2b1bf1612..e419a17dd 100644 --- a/packages/extension/src/ui/components/ConfigEditor.tsx +++ b/packages/extension/src/ui/components/ConfigEditor.tsx @@ -32,7 +32,7 @@ export const ConfigEditorInner: React.FC<{ status: KernelStatus }> = ({ const { updateClusterConfig, reload } = useKernelActions(); const { logMessage } = usePanelContext(); const clusterConfig = useMemo( - () => stringify(status.clusterConfig, 2), + () => stringify(status.clusterConfig), [status], ); const [config, setConfig] = useState(clusterConfig); diff --git a/packages/extension/src/ui/components/SendMessageForm.tsx b/packages/extension/src/ui/components/SendMessageForm.tsx index 18af3060e..ba4d37534 100644 --- a/packages/extension/src/ui/components/SendMessageForm.tsx +++ b/packages/extension/src/ui/components/SendMessageForm.tsx @@ -83,7 +83,7 @@ export const SendMessageForm: React.FC = () => {

Send Message

-
+