diff --git a/packages/extension/src/kernel-integration/handle-panel-message.test.ts b/packages/extension/src/kernel-integration/handle-panel-message.test.ts index acd7a843a..c325749b3 100644 --- a/packages/extension/src/kernel-integration/handle-panel-message.test.ts +++ b/packages/extension/src/kernel-integration/handle-panel-message.test.ts @@ -1,5 +1,4 @@ import '@ocap/test-utils/mock-endoify'; -import { define, literal, object } from '@metamask/superstruct'; import type { Kernel, KernelCommand, @@ -7,6 +6,7 @@ import type { VatConfig, KVStore, } from '@ocap/kernel'; +import { setupOcapKernelMock } from '@ocap/test-utils'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { KernelControlCommand } from './messages.js'; @@ -19,25 +19,7 @@ vi.mock('@ocap/utils', () => ({ }), })); -let isVatConfigMock = true; -let isVatIdMock = true; - -// Mock kernel validation functions -// because vitest needs to extend Error stack and under SES it fails -vi.mock('@ocap/kernel', () => ({ - isKernelCommand: () => true, - isVatId: () => isVatIdMock, - isVatConfig: () => isVatConfigMock, - VatIdStruct: define('VatId', () => isVatIdMock), - VatConfigStruct: define('VatConfig', () => isVatConfigMock), - KernelSendVatCommandStruct: object({ - id: literal('v0'), - payload: object({ - method: literal('ping'), - params: literal(null), - }), - }), -})); +const { setMockBehavior, resetMocks } = setupOcapKernelMock(); describe('handlePanelMessage', () => { let mockKernel: Kernel; @@ -45,9 +27,8 @@ describe('handlePanelMessage', () => { beforeEach(() => { vi.resetModules(); + resetMocks(); - isVatConfigMock = true; - isVatIdMock = true; mockKVStore = { get: vi.fn(), getRequired: vi.fn(), @@ -117,7 +98,7 @@ describe('handlePanelMessage', () => { it('should handle invalid vat configuration', async () => { const { handlePanelMessage } = await import('./handle-panel-message'); - isVatConfigMock = false; + setMockBehavior({ isVatConfig: false }); const message: KernelControlCommand = { id: 'test-2', @@ -173,7 +154,7 @@ describe('handlePanelMessage', () => { it('should handle invalid vat ID for restartVat command', async () => { const { handlePanelMessage } = await import('./handle-panel-message'); - isVatIdMock = false; + setMockBehavior({ isVatId: false }); const message: KernelControlCommand = { id: 'test-4', @@ -277,6 +258,7 @@ describe('handlePanelMessage', () => { payload: { method: 'getStatus', params: { + clusterConfig: undefined, vats: [ { id: 'v0', diff --git a/packages/extension/src/kernel-integration/handlers/get-status.test.ts b/packages/extension/src/kernel-integration/handlers/get-status.test.ts index 7b429874e..069bf8ba9 100644 --- a/packages/extension/src/kernel-integration/handlers/get-status.test.ts +++ b/packages/extension/src/kernel-integration/handlers/get-status.test.ts @@ -3,6 +3,7 @@ import type { Kernel, KVStore } from '@ocap/kernel'; import { describe, it, expect, vi } from 'vitest'; import { getStatusHandler } from './get-status.js'; +import clusterConfig from '../../vats/default-cluster.json'; describe('getStatusHandler', () => { const mockVats = [ @@ -11,6 +12,7 @@ describe('getStatusHandler', () => { ]; const mockKernel = { + clusterConfig, getVats: vi.fn(() => mockVats), } as unknown as Kernel; @@ -24,13 +26,13 @@ describe('getStatusHandler', () => { expect(getStatusHandler.schema).toBeDefined(); }); - it('should return vats status', async () => { + it('should return vats status and cluster config', async () => { const result = await getStatusHandler.implementation( mockKernel, mockKVStore, null, ); expect(mockKernel.getVats).toHaveBeenCalledOnce(); - expect(result).toStrictEqual({ vats: mockVats }); + expect(result).toStrictEqual({ vats: mockVats, clusterConfig }); }); }); diff --git a/packages/extension/src/kernel-integration/handlers/get-status.ts b/packages/extension/src/kernel-integration/handlers/get-status.ts index b0f1e3a3f..f91e049a8 100644 --- a/packages/extension/src/kernel-integration/handlers/get-status.ts +++ b/packages/extension/src/kernel-integration/handlers/get-status.ts @@ -15,6 +15,7 @@ export const getStatusHandler: CommandHandler = { implementation: async (kernel: Kernel): Promise => { return { vats: kernel.getVats(), - }; + clusterConfig: kernel.clusterConfig, + } as Json; }, }; diff --git a/packages/extension/src/kernel-integration/handlers/index.ts b/packages/extension/src/kernel-integration/handlers/index.ts index e6de62f2d..90cb6953b 100644 --- a/packages/extension/src/kernel-integration/handlers/index.ts +++ b/packages/extension/src/kernel-integration/handlers/index.ts @@ -7,6 +7,7 @@ import { restartVatHandler } from './restart-vat.js'; import { sendVatCommandHandler } from './send-vat-command.js'; import { terminateAllVatsHandler } from './terminate-all-vats.js'; import { terminateVatHandler } from './terminate-vat.js'; +import { updateClusterConfigHandler } from './update-cluster-config.js'; export const handlers = [ getStatusHandler, @@ -18,4 +19,5 @@ export const handlers = [ restartVatHandler, terminateVatHandler, terminateAllVatsHandler, + updateClusterConfigHandler, ] as const; diff --git a/packages/extension/src/kernel-integration/handlers/reload-config.test.ts b/packages/extension/src/kernel-integration/handlers/reload-config.test.ts new file mode 100644 index 000000000..b6d91a1ee --- /dev/null +++ b/packages/extension/src/kernel-integration/handlers/reload-config.test.ts @@ -0,0 +1,49 @@ +import '@ocap/test-utils/mock-endoify'; +import type { Kernel, KVStore } from '@ocap/kernel'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { reloadConfigHandler } from './reload-config.js'; + +describe('reloadConfigHandler', () => { + const mockKernel = { + reload: vi.fn().mockResolvedValue(undefined), + } as Partial; + + const mockKVStore = {} as KVStore; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call kernel.reload() and return null', async () => { + const result = await reloadConfigHandler.implementation( + mockKernel as Kernel, + mockKVStore, + null, + ); + + expect(mockKernel.reload).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); + }); + + it('should use the correct method name', () => { + expect(reloadConfigHandler.method).toBe('reload'); + }); + + it('should use the clearState schema for params', () => { + expect(reloadConfigHandler.schema).toBeDefined(); + }); + + it('should propagate errors from kernel.reload()', async () => { + const error = new Error('Reload failed'); + vi.mocked(mockKernel.reload)?.mockRejectedValueOnce(error); + + await expect( + reloadConfigHandler.implementation( + mockKernel as Kernel, + mockKVStore, + null, + ), + ).rejects.toThrow(error); + }); +}); diff --git a/packages/extension/src/kernel-integration/handlers/reload-config.ts b/packages/extension/src/kernel-integration/handlers/reload-config.ts index eaa1a8d2d..d7d3c3e32 100644 --- a/packages/extension/src/kernel-integration/handlers/reload-config.ts +++ b/packages/extension/src/kernel-integration/handlers/reload-config.ts @@ -13,7 +13,7 @@ export const reloadConfigHandler: CommandHandler = { method: KernelControlMethod.reload, schema: KernelCommandPayloadStructs.clearState.schema.params, implementation: async (kernel: Kernel): Promise => { - await kernel.reset(); + await kernel.reload(); return null; }, }; diff --git a/packages/extension/src/kernel-integration/handlers/send-vat-command.ts b/packages/extension/src/kernel-integration/handlers/send-vat-command.ts index 3f8c5d40c..223aa43a0 100644 --- a/packages/extension/src/kernel-integration/handlers/send-vat-command.ts +++ b/packages/extension/src/kernel-integration/handlers/send-vat-command.ts @@ -1,6 +1,5 @@ -import { assert } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; -import { isKernelCommand, KernelSendVatCommandStruct } from '@ocap/kernel'; +import { isKernelCommand } from '@ocap/kernel'; import type { Kernel, KVStore } from '@ocap/kernel'; import type { CommandHandler, CommandParams } from '../command-registry.js'; @@ -27,7 +26,6 @@ export const sendVatCommandHandler: CommandHandler = { throw new Error('Vat ID required for this command'); } - assert(params, KernelSendVatCommandStruct); const result = await kernel.sendVatCommand(params.id, params.payload); return { result }; }, diff --git a/packages/extension/src/kernel-integration/handlers/update-cluster-config.test.ts b/packages/extension/src/kernel-integration/handlers/update-cluster-config.test.ts new file mode 100644 index 000000000..3c46adbdd --- /dev/null +++ b/packages/extension/src/kernel-integration/handlers/update-cluster-config.test.ts @@ -0,0 +1,41 @@ +import '@ocap/test-utils/mock-endoify'; +import type { Kernel, KVStore } from '@ocap/kernel'; +import { describe, it, expect } from 'vitest'; + +import { updateClusterConfigHandler } from './update-cluster-config.js'; + +describe('updateClusterConfigHandler', () => { + const mockKernel = { + clusterConfig: null, + } as Partial; + + const mockKvStore = {} as KVStore; + + const testConfig = { + bootstrap: 'testVat', + vats: { + testVat: { + sourceSpec: 'test-source', + }, + }, + }; + + it('should update kernel cluster config', async () => { + const result = await updateClusterConfigHandler.implementation( + mockKernel as Kernel, + mockKvStore, + { config: testConfig }, + ); + + expect(mockKernel.clusterConfig).toStrictEqual(testConfig); + expect(result).toBeNull(); + }); + + it('should use the correct method name', () => { + expect(updateClusterConfigHandler.method).toBe('updateClusterConfig'); + }); + + it('should validate the config using the correct schema', () => { + expect(updateClusterConfigHandler.schema).toBeDefined(); + }); +}); diff --git a/packages/extension/src/kernel-integration/handlers/update-cluster-config.ts b/packages/extension/src/kernel-integration/handlers/update-cluster-config.ts new file mode 100644 index 000000000..0ec016cb8 --- /dev/null +++ b/packages/extension/src/kernel-integration/handlers/update-cluster-config.ts @@ -0,0 +1,23 @@ +import type { Json } from '@metamask/utils'; +import type { ClusterConfig, Kernel, KVStore } from '@ocap/kernel'; + +import type { CommandHandler } from '../command-registry.js'; +import { + KernelCommandPayloadStructs, + KernelControlMethod, +} from '../messages.js'; + +export const updateClusterConfigHandler: CommandHandler< + typeof KernelControlMethod.updateClusterConfig +> = { + method: KernelControlMethod.updateClusterConfig, + schema: KernelCommandPayloadStructs.updateClusterConfig.schema.params, + implementation: async ( + kernel: Kernel, + _kvStore: KVStore, + params: { config: ClusterConfig }, + ): Promise => { + kernel.clusterConfig = params.config; + return null; + }, +}; diff --git a/packages/extension/src/kernel-integration/kernel-worker.ts b/packages/extension/src/kernel-integration/kernel-worker.ts index 3779003e5..30eb9c8f6 100644 --- a/packages/extension/src/kernel-integration/kernel-worker.ts +++ b/packages/extension/src/kernel-integration/kernel-worker.ts @@ -1,46 +1,18 @@ import type { + ClusterConfig, KernelCommand, KernelCommandReply, - ClusterConfig, } from '@ocap/kernel'; -import { isKernelCommand, Kernel } from '@ocap/kernel'; +import { ClusterConfigStruct, isKernelCommand, Kernel } from '@ocap/kernel'; import type { PostMessageTarget } from '@ocap/streams'; import { MessagePortDuplexStream, receiveMessagePort } from '@ocap/streams'; -import { makeLogger } from '@ocap/utils'; +import { fetchValidatedJson, makeLogger } from '@ocap/utils'; import { handlePanelMessage } from './handle-panel-message.js'; import { makeSQLKVStore } from './sqlite-kv-store.js'; import { receiveUiConnections } from './ui-connections.js'; import { ExtensionVatWorkerClient } from './VatWorkerClient.js'; -const bundleHost = 'http://localhost:3000'; // XXX placeholder -const sampleBundle = 'sample-vat.bundle'; -const bundleURL = `${bundleHost}/${sampleBundle}`; - -const defaultSubcluster: ClusterConfig = { - bootstrap: 'alice', - vats: { - alice: { - bundleSpec: bundleURL, - parameters: { - name: 'Alice', - }, - }, - bob: { - bundleSpec: bundleURL, - parameters: { - name: 'Bob', - }, - }, - carol: { - bundleSpec: bundleURL, - parameters: { - name: 'Carol', - }, - }, - }, -}; - const logger = makeLogger('[kernel worker]'); main().catch(logger.error); @@ -65,13 +37,24 @@ async function main(): Promise { ); const kvStore = await makeSQLKVStore(); - const kernel = new Kernel(kernelStream, vatWorkerClient, kvStore); + const kernel = new Kernel(kernelStream, vatWorkerClient, kvStore, { + // XXX Warning: Clearing storage here is a hack to aid development + // debugging, wherein extension reloads are almost exclusively used for + // retrying after tweaking some fix. The following line will prevent + // the accumulation of long term kernel state. + resetStorage: true, + }); receiveUiConnections( async (message) => handlePanelMessage(kernel, kvStore, message), logger, ); await kernel.init(); + const defaultSubcluster = await fetchValidatedJson( + new URL('../vats/default-cluster.json', import.meta.url).href, + ClusterConfigStruct, + ); + await Promise.all([ vatWorkerClient.start(), // XXX We are mildly concerned that there's a small chance that a race here diff --git a/packages/extension/src/kernel-integration/messages.test.ts b/packages/extension/src/kernel-integration/messages.test.ts index b2b251f6d..40f138410 100644 --- a/packages/extension/src/kernel-integration/messages.test.ts +++ b/packages/extension/src/kernel-integration/messages.test.ts @@ -7,6 +7,7 @@ import { isKernelControlReply, isKernelStatus, } from './messages'; +import clusterConfig from '../vats/default-cluster.json'; describe('KernelControlMethod', () => { it('should have all expected methods', () => { @@ -20,6 +21,7 @@ describe('KernelControlMethod', () => { 'sendVatCommand', 'clearState', 'executeDBQuery', + 'updateClusterConfig', ]); }); }); @@ -179,6 +181,7 @@ describe('isKernelControlReply', () => { payload: { method: KernelControlMethod.getStatus, params: { + clusterConfig, vats: [ { id: 'v0', @@ -235,6 +238,7 @@ describe('isKernelStatus', () => { [ 'valid kernel status', { + clusterConfig, vats: [ { id: 'v0', @@ -244,14 +248,33 @@ describe('isKernelStatus', () => { }, true, ], - ['empty vats array', { vats: [] }, true], + ['empty vats array', { vats: [], clusterConfig }, true], ['null value', null, false], ['undefined value', undefined, false], ['empty object', {}, false], - ['null vats', { vats: null }, false], - ['invalid vats type', { vats: 'invalid' }, false], - ['invalid vat object', { vats: [{ invalid: true }] }, false], - ['invalid vat id type', { vats: [{ id: 123, config: {} }] }, false], + ['null vats', { vats: null, clusterConfig }, false], + ['invalid vats type', { vats: 'invalid', clusterConfig }, false], + ['invalid vat object', { vats: [{ invalid: true }], clusterConfig }, false], + [ + 'invalid vat id type', + { vats: [{ id: 123, config: {} }], clusterConfig }, + false, + ], + ['invalid cluster config', { vats: [], clusterConfig: 'invalid' }, false], + ['invalid cluster config type', { vats: [], clusterConfig: 123 }, false], + ['invalid cluster config object', { vats: [], clusterConfig: {} }, false], + ['invalid cluster config array', { vats: [], clusterConfig: [] }, false], + [ + 'invalid cluster config boolean', + { vats: [], clusterConfig: true }, + false, + ], + ['invalid cluster config number', { vats: [], clusterConfig: 123 }, false], + [ + 'invalid cluster config string', + { vats: [], clusterConfig: 'test' }, + false, + ], ])('should validate %s', (_, status, expected) => { expect(isKernelStatus(status)).toBe(expected); }); diff --git a/packages/extension/src/kernel-integration/messages.ts b/packages/extension/src/kernel-integration/messages.ts index 99d24a518..acbc1e730 100644 --- a/packages/extension/src/kernel-integration/messages.ts +++ b/packages/extension/src/kernel-integration/messages.ts @@ -11,8 +11,11 @@ import { import type { Infer } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; import { UnsafeJsonStruct } from '@metamask/utils'; -import type { VatConfig, VatId } from '@ocap/kernel'; -import { VatConfigStruct, VatIdStruct } from '@ocap/kernel'; +import { + ClusterConfigStruct, + VatConfigStruct, + VatIdStruct, +} from '@ocap/kernel'; import type { TypeGuard } from '@ocap/utils'; export const KernelControlMethod = { @@ -25,18 +28,13 @@ export const KernelControlMethod = { sendVatCommand: 'sendVatCommand', clearState: 'clearState', executeDBQuery: 'executeDBQuery', + updateClusterConfig: 'updateClusterConfig', } as const; export type KernelMethods = keyof typeof KernelControlMethod; -export type KernelStatus = { - vats: { - id: VatId; - config: VatConfig; - }[]; -}; - const KernelStatusStruct = type({ + clusterConfig: ClusterConfigStruct, vats: array( object({ id: VatIdStruct, @@ -45,6 +43,8 @@ const KernelStatusStruct = type({ ), }); +export type KernelStatus = Infer; + // Command payload structs export const KernelCommandPayloadStructs = { [KernelControlMethod.launchVat]: object({ @@ -88,6 +88,12 @@ export const KernelCommandPayloadStructs = { sql: string(), }), }), + [KernelControlMethod.updateClusterConfig]: object({ + method: literal(KernelControlMethod.updateClusterConfig), + params: object({ + config: ClusterConfigStruct, + }), + }), } as const; export const KernelReplyPayloadStructs = { @@ -130,6 +136,10 @@ export const KernelReplyPayloadStructs = { object({ error: string() }), ]), }), + [KernelControlMethod.updateClusterConfig]: object({ + method: literal(KernelControlMethod.updateClusterConfig), + params: literal(null), + }), } as const; const KernelControlCommandStruct = object({ @@ -144,6 +154,7 @@ const KernelControlCommandStruct = object({ KernelCommandPayloadStructs.sendVatCommand, KernelCommandPayloadStructs.clearState, KernelCommandPayloadStructs.executeDBQuery, + KernelCommandPayloadStructs.updateClusterConfig, ]), }); @@ -159,6 +170,7 @@ const KernelControlReplyStruct = object({ KernelReplyPayloadStructs.sendVatCommand, KernelReplyPayloadStructs.clearState, KernelReplyPayloadStructs.executeDBQuery, + KernelReplyPayloadStructs.updateClusterConfig, ]), }); diff --git a/packages/extension/src/kernel-integration/ui-connections.test.ts b/packages/extension/src/kernel-integration/ui-connections.test.ts index 4b408b3ab..fe9cc1420 100644 --- a/packages/extension/src/kernel-integration/ui-connections.test.ts +++ b/packages/extension/src/kernel-integration/ui-connections.test.ts @@ -10,6 +10,7 @@ import { receiveUiConnections, UI_CONTROL_CHANNEL_NAME, } from './ui-connections.js'; +import clusterConfig from '../vats/default-cluster.json'; vi.mock('nanoid', () => ({ nanoid: vi.fn(() => 'test-id'), @@ -166,7 +167,7 @@ describe('ui-connections', () => { id: 'foo', payload: { method: 'getStatus', - params: { vats: [] }, + params: { vats: [], clusterConfig }, }, }), ); diff --git a/packages/extension/src/ui/App.module.css b/packages/extension/src/ui/App.module.css index c168cc121..3425ab4ad 100644 --- a/packages/extension/src/ui/App.module.css +++ b/packages/extension/src/ui/App.module.css @@ -6,6 +6,7 @@ --color-gray-200: #f0f0f0; --color-gray-300: #ccc; --color-gray-600: #666; + --color-gray-800: #333; --color-primary: #4956f9; --color-success: #4caf50; --color-error: #f44336; @@ -143,11 +144,17 @@ h6 { border: none; } -.button:hover:not(:disabled) { +.buttonBlack { + composes: button; background-color: var(--color-black); color: var(--color-white); + border: none; } +.button:hover:not(:disabled) { + background-color: var(--color-gray-800); + color: var(--color-white); +} .textButton { padding: 0; border: 0; @@ -330,13 +337,6 @@ div + .sent { background: var(--color-gray-200); } -.messageTemplates { - display: flex; - gap: var(--spacing-sm); - margin-bottom: var(--spacing-sm); - flex-wrap: wrap; -} - .messageInputRow { display: flex; gap: var(--spacing-sm); @@ -472,3 +472,25 @@ div + .sent { display: flex; gap: var(--spacing-sm); } + +.configEditor { + margin: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.configTextarea { + font-family: monospace; + padding: var(--spacing-sm); + border: 1px solid var(--color-gray-300); + border-radius: var(--border-radius); + resize: vertical; + min-height: 200px; +} + +.configEditorButtons { + display: flex; + gap: var(--spacing-sm); + justify-content: flex-end; +} diff --git a/packages/extension/src/ui/App.test.tsx b/packages/extension/src/ui/App.test.tsx index 059e3b89e..5c0a64379 100644 --- a/packages/extension/src/ui/App.test.tsx +++ b/packages/extension/src/ui/App.test.tsx @@ -1,29 +1,11 @@ import '@ocap/test-utils/mock-endoify'; -import { define } from '@metamask/superstruct'; -import type { VatId, VatConfig } from '@ocap/kernel'; +import { setupOcapKernelMock } from '@ocap/test-utils'; import { render, screen, cleanup } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { StreamState } from './hooks/useStream.js'; -const isVatId = vi.fn( - (input: unknown): input is VatId => typeof input === 'string', -); - -const isVatConfig = vi.fn( - (input: unknown): input is VatConfig => typeof input === 'object', -); - -vi.mock('@ocap/kernel', () => ({ - isVatId, - isVatConfig, - VatCommandMethod: { - ping: 'ping', - }, - KernelCommandMethod: {}, - VatIdStruct: define('VatId', isVatId), - VatConfigStruct: define('VatConfig', isVatConfig), -})); +setupOcapKernelMock(); vi.mock('./hooks/useStream.js', () => ({ useStream: vi.fn(), diff --git a/packages/extension/src/ui/components/ConfigEditor.test.tsx b/packages/extension/src/ui/components/ConfigEditor.test.tsx new file mode 100644 index 000000000..a36e8df1d --- /dev/null +++ b/packages/extension/src/ui/components/ConfigEditor.test.tsx @@ -0,0 +1,179 @@ +import { + render, + screen, + cleanup, + waitFor, + fireEvent, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { ConfigEditor } from './ConfigEditor.js'; +import type { KernelStatus } from '../../kernel-integration/messages.js'; +import clusterConfig from '../../vats/default-cluster.json'; +import { usePanelContext } from '../context/PanelContext.js'; +import { useKernelActions } from '../hooks/useKernelActions.js'; + +const mockStatus = { + clusterConfig, + vats: [], +}; + +const mockLogMessage = vi.fn(); + +const mockUsePanelContext = { + status: mockStatus, + logMessage: mockLogMessage, + messageContent: '', + setMessageContent: vi.fn(), + panelLogs: [], + clearLogs: vi.fn(), + sendMessage: vi.fn(), + selectedVatId: '1', + setSelectedVatId: vi.fn(), +}; + +vi.mock('../hooks/useKernelActions.js', () => ({ + useKernelActions: vi.fn(), +})); + +vi.mock('../context/PanelContext.js', () => ({ + usePanelContext: vi.fn(), +})); + +// Mock the CSS module +vi.mock('../App.module.css', () => ({ + default: { + configEditor: 'config-editor', + configTextarea: 'config-textarea', + configEditorButtons: 'config-editor-buttons', + buttonPrimary: 'button-primary', + buttonBlack: 'button-black', + }, +})); + +describe('ConfigEditor Component', () => { + const mockUpdateClusterConfig = vi.fn(); + const mockReload = vi.fn(); + + beforeEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.mocked(useKernelActions).mockReturnValue({ + updateClusterConfig: mockUpdateClusterConfig, + reload: mockReload, + sendKernelCommand: vi.fn(), + terminateAllVats: vi.fn(), + clearState: vi.fn(), + launchVat: vi.fn(), + }); + vi.mocked(usePanelContext).mockReturnValue(mockUsePanelContext); + }); + + it('renders nothing when status is not available', () => { + vi.mocked(usePanelContext).mockReturnValue({ + ...mockUsePanelContext, + status: undefined, + }); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the config editor with initial config', () => { + render(); + expect(screen.getByText('Cluster Config')).toBeInTheDocument(); + const textarea = screen.getByRole('textbox'); + expect(textarea).toHaveValue( + JSON.stringify(mockStatus.clusterConfig, null, 2), + ); + expect( + screen.getByRole('button', { name: 'Update Config' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Update and Reload' }), + ).toBeInTheDocument(); + }); + + it('updates textarea value when user types', async () => { + render(); + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: 'test' } }); + expect(textarea).toHaveValue('test'); + }); + + it('updates config when "Update Config" is clicked', async () => { + mockUpdateClusterConfig.mockResolvedValue(undefined); + render(); + const updateButton = screen.getByTestId('update-config'); + await userEvent.click(updateButton); + expect(mockUpdateClusterConfig).toHaveBeenCalledWith( + mockStatus.clusterConfig, + ); + expect(mockReload).not.toHaveBeenCalled(); + }); + + it('updates config and reloads when "Update and Reload" is clicked', async () => { + mockUpdateClusterConfig.mockResolvedValue(undefined); + render(); + const updateButton = screen.getByTestId('update-and-restart'); + await userEvent.click(updateButton); + expect(mockUpdateClusterConfig).toHaveBeenCalledWith( + mockStatus.clusterConfig, + ); + expect(mockReload).toHaveBeenCalled(); + }); + + it('logs error when invalid JSON is submitted', async () => { + mockUpdateClusterConfig.mockRejectedValueOnce(new Error('Invalid JSON')); + render(); + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: 'test' } }); + const updateButton = screen.getByTestId('update-config'); + await userEvent.click(updateButton); + expect(mockLogMessage).toHaveBeenCalledWith( + `SyntaxError: Unexpected token 'e', "test" is not valid JSON`, + 'error', + ); + expect(mockReload).not.toHaveBeenCalled(); + }); + + it('logs error when update fails', async () => { + const error = new Error('Update failed'); + mockUpdateClusterConfig.mockRejectedValueOnce(error); + render(); + await userEvent.click(screen.getByTestId('update-config')); + + await waitFor(() => { + expect(mockLogMessage).toHaveBeenCalledWith(error.toString(), 'error'); + }); + }); + + it('updates textarea when status changes', async () => { + const { rerender } = render(); + const textarea = screen.getByRole('textbox'); + expect(textarea).toHaveValue( + JSON.stringify(mockStatus.clusterConfig, null, 2), + ); + + const newStatus: KernelStatus = { + clusterConfig: { + ...clusterConfig, + bootstrap: 'updated-config', + }, + vats: [], + }; + + vi.mocked(usePanelContext).mockReturnValue({ + ...mockUsePanelContext, + status: newStatus, + }); + + rerender(); + + await waitFor(() => { + expect(textarea).toHaveValue( + JSON.stringify(newStatus.clusterConfig, null, 2), + ); + }); + }); +}); diff --git a/packages/extension/src/ui/components/ConfigEditor.tsx b/packages/extension/src/ui/components/ConfigEditor.tsx new file mode 100644 index 000000000..9103067c6 --- /dev/null +++ b/packages/extension/src/ui/components/ConfigEditor.tsx @@ -0,0 +1,90 @@ +import type { ClusterConfig } from '@ocap/kernel'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import type { KernelStatus } from '../../kernel-integration/messages.js'; +import styles from '../App.module.css'; +import { usePanelContext } from '../context/PanelContext.js'; +import { useKernelActions } from '../hooks/useKernelActions.js'; + +/** + * Component for editing the kernel cluster configuration. + * + * @param options - The component options + * @param options.status - The kernel status + * @returns A React component for editing the kernel cluster configuration. + */ +export const ConfigEditorInner: React.FC<{ status: KernelStatus }> = ({ + status, +}) => { + const { updateClusterConfig, reload } = useKernelActions(); + const { logMessage } = usePanelContext(); + const clusterConfig = useMemo( + () => JSON.stringify(status.clusterConfig, null, 2), + [status], + ); + const [config, setConfig] = useState(clusterConfig); + + // Update the config when the status changes + useEffect(() => { + setConfig(clusterConfig); + }, [clusterConfig]); + + const handleUpdate = useCallback( + (reloadKernel = false) => { + try { + const parsedConfig: ClusterConfig = JSON.parse(config); + updateClusterConfig(parsedConfig) + .then(() => reloadKernel && reload()) + .catch((error) => { + logMessage(String(error), 'error'); + }); + } catch (error) { + logMessage(String(error), 'error'); + } + }, + [config, updateClusterConfig], + ); + + if (!config) { + return null; + } + + return ( +
+

Cluster Config

+