diff --git a/packages/extension/package.json b/packages/extension/package.json index 1df87a40a..a1eae76db 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -39,6 +39,7 @@ "@endo/patterns": "^1.4.4", "@endo/promise-kit": "^1.1.6", "@metamask/snaps-utils": "^8.3.0", + "@metamask/superstruct": "^3.1.0", "@metamask/utils": "^9.3.0", "@ocap/errors": "workspace:^", "@ocap/kernel": "workspace:^", diff --git a/packages/extension/src/kernel/handle-panel-message.test.ts b/packages/extension/src/kernel/handle-panel-message.test.ts new file mode 100644 index 000000000..e486d1dd5 --- /dev/null +++ b/packages/extension/src/kernel/handle-panel-message.test.ts @@ -0,0 +1,370 @@ +import '../../../test-utils/src/env/mock-endo.ts'; +import { define, literal, object } from '@metamask/superstruct'; +import type { Kernel, KernelCommand, VatId } from '@ocap/kernel'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { KernelControlCommand } from './messages.js'; + +// Mock logger +vi.mock('@ocap/utils', () => ({ + makeLogger: () => ({ + error: vi.fn(), + }), +})); + +// Mock kernel validation functions +vi.mock('@ocap/kernel', () => ({ + isKernelCommand: () => true, + isVatId: () => true, + VatIdStruct: define('VatId', () => true), + KernelSendMessageStruct: object({ + id: literal('v0'), + payload: object({ + method: literal('ping'), + params: literal(null), + }), + }), +})); + +describe('handlePanelMessage', () => { + let mockKernel: Kernel; + + beforeEach(() => { + vi.resetModules(); + + // Create mock kernel + mockKernel = { + launchVat: vi.fn().mockResolvedValue(undefined), + restartVat: vi.fn().mockResolvedValue(undefined), + terminateVat: vi.fn().mockResolvedValue(undefined), + terminateAllVats: vi.fn().mockResolvedValue(undefined), + getVatIds: vi.fn().mockReturnValue(['v0', 'v1']), + sendMessage: vi.fn((id: VatId, _message: KernelCommand) => { + if (id === 'v0') { + return 'success'; + } + return { error: 'Unknown vat ID' }; + }), + kvGet: vi.fn((key: string) => { + if (key === 'testKey') { + return 'value'; + } + return undefined; + }), + kvSet: vi.fn(), + } as unknown as Kernel; + }); + + describe('vat management commands', () => { + it('should handle launchVat command', async () => { + const { handlePanelMessage } = await import('./handle-panel-message'); + const message: KernelControlCommand = { + method: 'launchVat', + params: { id: 'v0' }, + }; + + const response = await handlePanelMessage(mockKernel, message); + + expect(mockKernel.launchVat).toHaveBeenCalledWith({ id: 'v0' }); + expect(response).toStrictEqual({ + method: 'launchVat', + params: null, + }); + }); + + it('should handle invalid vat ID', async () => { + const { handlePanelMessage } = await import('./handle-panel-message'); + const kernel = await import('@ocap/kernel'); + const isVatIdSpy = vi.spyOn(kernel, 'isVatId'); + isVatIdSpy.mockReturnValue(false); + + const message: KernelControlCommand = { + method: 'launchVat', + params: { id: 'invalid' as VatId }, + }; + + const response = await handlePanelMessage(mockKernel, message); + + expect(response).toStrictEqual({ + method: 'launchVat', + params: { error: 'Valid vat id required' }, + }); + }); + + it('should handle restartVat command', async () => { + const { handlePanelMessage } = await import('./handle-panel-message'); + const message: KernelControlCommand = { + method: 'restartVat', + params: { id: 'v0' }, + }; + + const response = await handlePanelMessage(mockKernel, message); + + expect(mockKernel.restartVat).toHaveBeenCalledWith('v0'); + expect(response).toStrictEqual({ + method: 'restartVat', + params: null, + }); + }); + + it('should handle invalid vat ID for restartVat command', async () => { + const { handlePanelMessage } = await import('./handle-panel-message'); + + const kernel = await import('@ocap/kernel'); + const isVatIdSpy = vi.spyOn(kernel, 'isVatId'); + isVatIdSpy.mockReturnValue(false); + + const message: KernelControlCommand = { + method: 'restartVat', + params: { id: 'invalid' as VatId }, + }; + + const response = await handlePanelMessage(mockKernel, message); + + expect(response).toStrictEqual({ + method: 'restartVat', + params: { error: 'Valid vat id required' }, + }); + }); + + it('should handle terminateVat command', async () => { + const { handlePanelMessage } = await import('./handle-panel-message'); + const message: KernelControlCommand = { + method: 'terminateVat', + params: { id: 'v0' }, + }; + + const response = await handlePanelMessage(mockKernel, message); + + expect(mockKernel.terminateVat).toHaveBeenCalledWith('v0'); + expect(response).toStrictEqual({ + method: 'terminateVat', + params: null, + }); + }); + + it('should handle invalid vat ID for terminateVat command', async () => { + const { handlePanelMessage } = await import('./handle-panel-message'); + const kernel = await import('@ocap/kernel'); + const isVatIdSpy = vi.spyOn(kernel, 'isVatId'); + isVatIdSpy.mockReturnValue(false); + + const message: KernelControlCommand = { + method: 'terminateVat', + params: { id: 'invalid' as VatId }, + }; + + const response = await handlePanelMessage(mockKernel, message); + + expect(response).toStrictEqual({ + method: 'terminateVat', + params: { error: 'Valid vat id required' }, + }); + }); + + it('should handle terminateAllVats command', async () => { + const { handlePanelMessage } = await import('./handle-panel-message'); + const message: KernelControlCommand = { + method: 'terminateAllVats', + params: null, + }; + + const response = await handlePanelMessage(mockKernel, message); + + expect(mockKernel.terminateAllVats).toHaveBeenCalled(); + expect(response).toStrictEqual({ + method: 'terminateAllVats', + params: null, + }); + }); + }); + + describe('status command', () => { + it('should handle getStatus command', async () => { + const { handlePanelMessage } = await import('./handle-panel-message'); + const message: KernelControlCommand = { + method: 'getStatus', + params: null, + }; + + const response = await handlePanelMessage(mockKernel, message); + + expect(mockKernel.getVatIds).toHaveBeenCalled(); + expect(response).toStrictEqual({ + method: 'getStatus', + params: { + isRunning: true, + activeVats: ['v0', 'v1'], + }, + }); + }); + }); + + describe('sendMessage command', () => { + it('should handle kvGet command', async () => { + const { handlePanelMessage } = await import('./handle-panel-message'); + const message: KernelControlCommand = { + method: 'sendMessage', + params: { + payload: { method: 'kvGet', params: 'testKey' }, + }, + }; + + const response = await handlePanelMessage(mockKernel, message); + + expect(mockKernel.kvGet).toHaveBeenCalledWith('testKey'); + expect(response).toStrictEqual({ + method: 'sendMessage', + params: { result: 'value' }, + }); + }); + + it('should handle kvGet command when key not found', async () => { + const { handlePanelMessage } = await import('./handle-panel-message'); + + const message: KernelControlCommand = { + method: 'sendMessage', + params: { + payload: { method: 'kvGet', params: 'nonexistentKey' }, + }, + }; + + const response = await handlePanelMessage(mockKernel, message); + + expect(mockKernel.kvGet).toHaveBeenCalledWith('nonexistentKey'); + expect(response).toStrictEqual({ + method: 'sendMessage', + params: { error: 'Key not found' }, + }); + }); + + it('should handle kvSet command', async () => { + const { handlePanelMessage } = await import('./handle-panel-message'); + const message: KernelControlCommand = { + method: 'sendMessage', + params: { + payload: { + method: 'kvSet', + params: { key: 'testKey', value: 'testValue' }, + }, + }, + }; + + const response = await handlePanelMessage(mockKernel, message); + + expect(mockKernel.kvSet).toHaveBeenCalledWith('testKey', 'testValue'); + expect(response).toStrictEqual({ + method: 'sendMessage', + params: { key: 'testKey', value: 'testValue' }, + }); + }); + + it('should handle vat messages', async () => { + const { handlePanelMessage } = await import('./handle-panel-message'); + const message: KernelControlCommand = { + method: 'sendMessage', + params: { + id: 'v0', + payload: { method: 'ping', params: null }, + }, + }; + + const response = await handlePanelMessage(mockKernel, message); + + expect(mockKernel.sendMessage).toHaveBeenCalledWith('v0', { + method: 'ping', + params: null, + }); + expect(response).toStrictEqual({ + method: 'sendMessage', + params: { result: 'success' }, + }); + }); + + it('should handle invalid command payload', async () => { + const { handlePanelMessage } = await import('./handle-panel-message'); + const kernel = await import('@ocap/kernel'); + const kernelSpy = vi.spyOn(kernel, 'isKernelCommand'); + kernelSpy.mockReturnValue(false); + + const message: KernelControlCommand = { + method: 'sendMessage', + params: { + payload: { invalid: 'command' }, + }, + }; + + const response = await handlePanelMessage(mockKernel, message); + + expect(response).toStrictEqual({ + method: 'sendMessage', + params: { error: 'Invalid command payload' }, + }); + }); + + it('should handle missing vat ID', async () => { + const { handlePanelMessage } = await import('./handle-panel-message'); + const kernel = await import('@ocap/kernel'); + const isVatIdSpy = vi.spyOn(kernel, 'isVatId'); + isVatIdSpy.mockReturnValue(false); + + const message: KernelControlCommand = { + method: 'sendMessage', + params: { + payload: { method: 'ping', params: null }, + }, + }; + + const response = await handlePanelMessage(mockKernel, message); + + expect(response).toStrictEqual({ + method: 'sendMessage', + params: { error: 'Vat ID required for this command' }, + }); + }); + }); + + describe('error handling', () => { + it('should handle unknown method', async () => { + const { handlePanelMessage } = await import('./handle-panel-message'); + const message: KernelControlCommand = { + method: 'unknownMethod', + params: null, + } as unknown as KernelControlCommand; + + const response = await handlePanelMessage(mockKernel, message); + + expect(response).toStrictEqual({ + method: 'unknownMethod', + params: { error: 'Unknown method' }, + }); + }); + + it('should handle kernel errors', async () => { + const { handlePanelMessage } = await import('./handle-panel-message'); + const error = new Error('Kernel error'); + vi.mocked(mockKernel.launchVat).mockRejectedValue(error); + + const message: KernelControlCommand = { + method: 'launchVat', + params: { id: 'v0' }, + }; + + const response = await handlePanelMessage(mockKernel, message); + + expect(response).toStrictEqual({ + method: 'launchVat', + params: { error: 'Kernel error' }, + }); + + vi.mocked(mockKernel.launchVat).mockRejectedValue('error'); + + const response2 = await handlePanelMessage(mockKernel, message); + + expect(response2).toStrictEqual({ + method: 'launchVat', + params: { error: 'error' }, + }); + }); + }); +}); diff --git a/packages/extension/src/kernel/handle-panel-message.ts b/packages/extension/src/kernel/handle-panel-message.ts new file mode 100644 index 000000000..c6e027e0d --- /dev/null +++ b/packages/extension/src/kernel/handle-panel-message.ts @@ -0,0 +1,125 @@ +import { assert } from '@metamask/superstruct'; +import type { Json } from '@metamask/utils'; +import { + Kernel, + isKernelCommand, + KernelSendMessageStruct, + isVatId, +} from '@ocap/kernel'; +import { makeLogger } from '@ocap/utils'; + +import type { KernelControlReply, KernelControlCommand } from './messages.js'; +import { KernelControlMethod } from './messages.js'; + +const logger = makeLogger('[kernel panel messages]'); + +/** + * Handles a message from the panel. + * + * @param kernel - The kernel instance. + * @param message - The message to handle. + * @returns The reply to the message. + */ +export async function handlePanelMessage( + kernel: Kernel, + message: KernelControlCommand, +): Promise { + try { + switch (message.method) { + case KernelControlMethod.launchVat: { + if (!isVatId(message.params.id)) { + throw new Error('Valid vat id required'); + } + await kernel.launchVat({ id: message.params.id }); + return { method: KernelControlMethod.launchVat, params: null }; + } + + case KernelControlMethod.restartVat: { + if (!isVatId(message.params.id)) { + throw new Error('Valid vat id required'); + } + await kernel.restartVat(message.params.id); + return { method: KernelControlMethod.restartVat, params: null }; + } + + case KernelControlMethod.terminateVat: { + if (!isVatId(message.params.id)) { + throw new Error('Valid vat id required'); + } + await kernel.terminateVat(message.params.id); + return { method: KernelControlMethod.terminateVat, params: null }; + } + + case KernelControlMethod.terminateAllVats: { + await kernel.terminateAllVats(); + return { method: KernelControlMethod.terminateAllVats, params: null }; + } + + case KernelControlMethod.getStatus: { + return { + method: KernelControlMethod.getStatus, + params: { + isRunning: true, // TODO: Track actual kernel state + activeVats: kernel.getVatIds(), + }, + }; + } + + case KernelControlMethod.sendMessage: { + if (!isKernelCommand(message.params.payload)) { + throw new Error('Invalid command payload'); + } + + if (message.params.payload.method === 'kvGet') { + const result = kernel.kvGet(message.params.payload.params); + if (!result) { + throw new Error('Key not found'); + } + return { + method: KernelControlMethod.sendMessage, + params: { result } as Json, + }; + } + + if (message.params.payload.method === 'kvSet') { + kernel.kvSet( + message.params.payload.params.key, + message.params.payload.params.value, + ); + return { + method: KernelControlMethod.sendMessage, + params: message.params.payload.params, + }; + } + + if (!isVatId(message.params.id)) { + throw new Error('Vat ID required for this command'); + } + + assert(message.params, KernelSendMessageStruct); + + const result = await kernel.sendMessage( + message.params.id, + message.params.payload, + ); + + return { + method: KernelControlMethod.sendMessage, + params: { result } as Json, + }; + } + + default: { + throw new Error('Unknown method'); + } + } + } catch (error) { + logger.error('Error handling message:', error); + return { + method: message.method, + params: { + error: error instanceof Error ? error.message : String(error), + }, + } as KernelControlReply; + } +} diff --git a/packages/extension/src/kernel/kernel-worker.ts b/packages/extension/src/kernel/kernel-worker.ts index f5c99ebc9..7c27dca6d 100644 --- a/packages/extension/src/kernel/kernel-worker.ts +++ b/packages/extension/src/kernel/kernel-worker.ts @@ -1,79 +1,74 @@ -import type { NonEmptyArray } from '@metamask/utils'; -import type { KernelCommand, KernelCommandReply, VatId } from '@ocap/kernel'; -import { Kernel, VatCommandMethod } from '@ocap/kernel'; -import { MessagePortDuplexStream, receiveMessagePort } from '@ocap/streams'; +import type { KernelCommand, KernelCommandReply } from '@ocap/kernel'; +import { Kernel } from '@ocap/kernel'; +import { + MessagePortDuplexStream, + receiveMessagePort, + StreamMultiplexer, +} from '@ocap/streams'; +import type { MultiplexEnvelope } from '@ocap/streams'; +import { makeLogger } from '@ocap/utils'; +import { handlePanelMessage } from './handle-panel-message.js'; +import type { KernelControlCommand, KernelControlReply } from './messages.js'; +import { runVatLifecycle } from './run-vat-lifecycle.js'; import { makeSQLKVStore } from './sqlite-kv-store.js'; import { ExtensionVatWorkerClient } from './VatWorkerClient.js'; -main().catch(console.error); +const logger = makeLogger('[kernel worker]'); + +main().catch(logger.error); /** - * The main function for the kernel worker. + * */ async function main(): Promise { - const kernelStream = await receiveMessagePort( + const port = await receiveMessagePort( (listener) => globalThis.addEventListener('message', listener), (listener) => globalThis.removeEventListener('message', listener), - ).then(async (port) => - MessagePortDuplexStream.make(port), ); + const baseStream = await MessagePortDuplexStream.make< + MultiplexEnvelope, + MultiplexEnvelope + >(port); + + const multiplexer = new StreamMultiplexer( + baseStream, + 'KernelWorkerMultiplexer', + ); + + // Initialize kernel dependencies const vatWorkerClient = new ExtensionVatWorkerClient( (message) => globalThis.postMessage(message), (listener) => globalThis.addEventListener('message', listener), ); - - // Initialize kernel store. const kvStore = await makeSQLKVStore(); - // Create and start kernel. + // Create kernel channel for kernel commands + const kernelStream = multiplexer.addChannel< + KernelCommand, + KernelCommandReply + >('kernel', () => { + // The kernel will handle commands through its own drain method + }); + + // Create and initialize kernel const kernel = new Kernel(kernelStream, vatWorkerClient, kvStore); await kernel.init(); - // Handle the lifecycle of multiple vats. - await runVatLifecycle(kernel, ['v1', 'v2', 'v3']); - - // Add default vat. - await kernel.launchVat({ id: 'v0' }); -} - -/** - * Runs the full lifecycle of an array of vats, including their creation, - * restart, message passing, and termination. - * - * @param kernel The kernel instance. - * @param vats An array of VatIds to be managed. - */ -async function runVatLifecycle( - kernel: Kernel, - vats: NonEmptyArray, -): Promise { - console.time(`Created vats: ${vats.join(', ')}`); - await Promise.all(vats.map(async (id) => kernel.launchVat({ id }))); - console.timeEnd(`Created vats: ${vats.join(', ')}`); - - console.log('Kernel vats:', kernel.getVatIds().join(', ')); - - // Restart a randomly selected vat from the array. - const vatToRestart = vats[Math.floor(Math.random() * vats.length)] as VatId; - console.time(`Vat "${vatToRestart}" restart`); - await kernel.restartVat(vatToRestart); - console.timeEnd(`Vat "${vatToRestart}" restart`); - - // Send a "Ping" message to a randomly selected vat. - const vatToPing = vats[Math.floor(Math.random() * vats.length)] as VatId; - console.time(`Ping Vat "${vatToPing}"`); - await kernel.sendMessage(vatToPing, { - method: VatCommandMethod.ping, - params: null, + // Create panel channel for panel control messages + const panelStream = multiplexer.addChannel< + KernelControlCommand, + KernelControlReply + >('panel', async (message) => { + const reply = await handlePanelMessage(kernel, message); + await panelStream.write(reply); }); - console.timeEnd(`Ping Vat "${vatToPing}"`); - const vatIds = kernel.getVatIds().join(', '); - console.time(`Terminated vats: ${vatIds}`); - await kernel.terminateAllVats(); - console.timeEnd(`Terminated vats: ${vatIds}`); + // Run default kernel lifecycle + await runVatLifecycle(kernel, ['v1', 'v2', 'v3']); + await kernel.launchVat({ id: 'v0' }); - console.log(`Kernel has ${kernel.getVatIds().length} vats`); + // Start multiplexer + await multiplexer.drainAll(); } diff --git a/packages/extension/src/kernel/messages.ts b/packages/extension/src/kernel/messages.ts new file mode 100644 index 000000000..2d76bc6e0 --- /dev/null +++ b/packages/extension/src/kernel/messages.ts @@ -0,0 +1,108 @@ +import { + object, + union, + literal, + boolean, + array, + type, + is, + string, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import type { Json } from '@metamask/utils'; +import { UnsafeJsonStruct } from '@metamask/utils'; +import type { VatId } from '@ocap/kernel'; +import { VatIdStruct } from '@ocap/kernel'; +import type { TypeGuard } from '@ocap/utils'; + +export const KernelControlMethod = { + launchVat: 'launchVat', + restartVat: 'restartVat', + terminateVat: 'terminateVat', + terminateAllVats: 'terminateAllVats', + getStatus: 'getStatus', + sendMessage: 'sendMessage', +} as const; + +export type KernelStatus = { + isRunning: boolean; + activeVats: VatId[]; +}; + +const KernelStatusStruct = type({ + isRunning: boolean(), + activeVats: array(VatIdStruct), +}); + +export const isKernelStatus: TypeGuard = ( + value, +): value is KernelStatus => is(value, KernelStatusStruct); + +const KernelControlCommandStruct = union([ + object({ + method: literal(KernelControlMethod.launchVat), + params: object({ id: VatIdStruct }), + }), + object({ + method: literal(KernelControlMethod.restartVat), + params: object({ id: VatIdStruct }), + }), + object({ + method: literal(KernelControlMethod.terminateVat), + params: object({ id: VatIdStruct }), + }), + object({ + method: literal(KernelControlMethod.terminateAllVats), + params: literal(null), + }), + object({ + method: literal(KernelControlMethod.getStatus), + params: literal(null), + }), + object({ + method: literal(KernelControlMethod.sendMessage), + params: object({ + id: union([VatIdStruct, literal(undefined)]), + payload: UnsafeJsonStruct, + }), + }), +]); + +const KernelControlReplyStruct = union([ + object({ + method: literal(KernelControlMethod.launchVat), + params: union([literal(null), object({ error: string() })]), + }), + object({ + method: literal(KernelControlMethod.restartVat), + params: union([literal(null), object({ error: string() })]), + }), + object({ + method: literal(KernelControlMethod.terminateVat), + params: union([literal(null), object({ error: string() })]), + }), + object({ + method: literal(KernelControlMethod.terminateAllVats), + params: union([literal(null), object({ error: string() })]), + }), + object({ + method: literal(KernelControlMethod.getStatus), + params: union([KernelStatusStruct, object({ error: string() })]), + }), + object({ + method: literal(KernelControlMethod.sendMessage), + params: UnsafeJsonStruct, + }), +]); + +export type KernelControlCommand = Infer & + Json; +export type KernelControlReply = Infer & Json; + +export const isKernelControlCommand: TypeGuard = ( + value: unknown, +): value is KernelControlCommand => is(value, KernelControlCommandStruct); + +export const isKernelControlReply: TypeGuard = ( + value: unknown, +): value is KernelControlReply => is(value, KernelControlReplyStruct); diff --git a/packages/extension/src/kernel/run-vat-lifecycle.test.ts b/packages/extension/src/kernel/run-vat-lifecycle.test.ts new file mode 100644 index 000000000..e18a4f78c --- /dev/null +++ b/packages/extension/src/kernel/run-vat-lifecycle.test.ts @@ -0,0 +1,77 @@ +import '../../../test-utils/src/env/mock-endo.ts'; +import { define } from '@metamask/superstruct'; +import type { NonEmptyArray } from '@metamask/utils'; +import { Kernel, VatCommandMethod } from '@ocap/kernel'; +import type { Vat, VatId } from '@ocap/kernel'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { runVatLifecycle } from './run-vat-lifecycle'; + +// Mock kernel validation functions +vi.mock('@ocap/kernel', () => ({ + isVatId: () => true, + VatIdStruct: define('VatId', () => true), + VatCommandMethod: { + ping: 'ping', + }, +})); + +describe('runVatLifecycle', () => { + // Properly type the mock kernel with Vi.Mock types + const mockKernel = { + launchVat: vi.fn(() => ({}) as Vat), + restartVat: vi.fn(() => undefined), + sendMessage: vi.fn(), + terminateAllVats: vi.fn(() => undefined), + getVatIds: vi.fn(() => ['v1', 'v2']), + } as unknown as Kernel; + + // Define test vats with correct VatId format + const testVats: NonEmptyArray = ['v1', 'v2']; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'time').mockImplementation(() => undefined); + vi.spyOn(console, 'timeEnd').mockImplementation(() => undefined); + }); + + it('should execute the complete vat lifecycle', async () => { + const consoleSpy = vi.spyOn(console, 'log'); + // Make Math.random return 0 for predictable vat selection + vi.spyOn(Math, 'random').mockReturnValue(0); + + await runVatLifecycle(mockKernel, testVats); + + // Verify vat creation + expect(mockKernel.launchVat).toHaveBeenCalledTimes(2); + expect(mockKernel.launchVat).toHaveBeenCalledWith({ id: 'v1' }); + expect(mockKernel.launchVat).toHaveBeenCalledWith({ id: 'v2' }); + + // Verify vat restart + expect(mockKernel.restartVat).toHaveBeenCalledWith('v1'); + + // Verify ping message + expect(mockKernel.sendMessage).toHaveBeenCalledWith('v1', { + method: VatCommandMethod.ping, + params: null, + }); + + // Verify vat termination + expect(mockKernel.terminateAllVats).toHaveBeenCalled(); + + // Verify logger calls + expect(consoleSpy).toHaveBeenCalledWith('Kernel vats:', 'v1, v2'); + expect(consoleSpy).toHaveBeenCalledWith('Kernel has 2 vats'); + }); + + it('should handle errors during vat lifecycle', async () => { + // Mock an error during vat launch + vi.mocked(mockKernel.launchVat).mockRejectedValue( + new Error('Launch failed'), + ); + + await expect(runVatLifecycle(mockKernel, testVats)).rejects.toThrow( + 'Launch failed', + ); + }); +}); diff --git a/packages/extension/src/kernel/run-vat-lifecycle.ts b/packages/extension/src/kernel/run-vat-lifecycle.ts new file mode 100644 index 000000000..f0cd4f4e9 --- /dev/null +++ b/packages/extension/src/kernel/run-vat-lifecycle.ts @@ -0,0 +1,42 @@ +import type { NonEmptyArray } from '@metamask/utils'; +import { Kernel, VatCommandMethod } from '@ocap/kernel'; +import type { VatId } from '@ocap/kernel'; + +/** + * Runs the full lifecycle of an array of vats + * + * @param kernel - The kernel instance. + * @param vats - The vats to run the lifecycle for. + */ +export async function runVatLifecycle( + kernel: Kernel, + vats: NonEmptyArray, +): Promise { + console.time(`Created vats: ${vats.join(', ')}`); + await Promise.all(vats.map(async (id) => kernel.launchVat({ id }))); + console.timeEnd(`Created vats: ${vats.join(', ')}`); + + console.log('Kernel vats:', kernel.getVatIds().join(', ')); + + // Restart a randomly selected vat from the array. + const vatToRestart = vats[Math.floor(Math.random() * vats.length)] as VatId; + console.time(`Vat "${vatToRestart}" restart`); + await kernel.restartVat(vatToRestart); + console.timeEnd(`Vat "${vatToRestart}" restart`); + + // Send a "Ping" message to a randomly selected vat. + const vatToPing = vats[Math.floor(Math.random() * vats.length)] as VatId; + console.time(`Ping Vat "${vatToPing}"`); + await kernel.sendMessage(vatToPing, { + method: VatCommandMethod.ping, + params: null, + }); + console.timeEnd(`Ping Vat "${vatToPing}"`); + + const vatIds = kernel.getVatIds().join(', '); + console.time(`Terminated vats: ${vatIds}`); + await kernel.terminateAllVats(); + console.timeEnd(`Terminated vats: ${vatIds}`); + + console.log(`Kernel has ${kernel.getVatIds().length} vats`); +} diff --git a/packages/extension/src/manifest.json b/packages/extension/src/manifest.json index e0da97b7e..7c7e6b5a2 100644 --- a/packages/extension/src/manifest.json +++ b/packages/extension/src/manifest.json @@ -7,7 +7,9 @@ "service_worker": "background.js", "type": "module" }, - "action": {}, + "action": { + "default_popup": "popup.html" + }, "permissions": ["offscreen", "unlimitedStorage"], "sandbox": { "pages": ["iframe.html"] diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index cb0f7e1ac..7b18626a6 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -1,111 +1,190 @@ -import { isKernelCommand, isKernelCommandReply } from '@ocap/kernel'; +import { isKernelCommandReply } from '@ocap/kernel'; import type { KernelCommandReply, KernelCommand } from '@ocap/kernel'; import { ChromeRuntimeTarget, initializeMessageChannel, ChromeRuntimeDuplexStream, MessagePortDuplexStream, + StreamMultiplexer, } from '@ocap/streams'; +import type { HandledDuplexStream, MultiplexEnvelope } from '@ocap/streams'; import { makeLogger } from '@ocap/utils'; import { makeIframeVatWorker } from './kernel/iframe-vat-worker.js'; +import { isKernelControlReply } from './kernel/messages.js'; +import type { + KernelControlCommand, + KernelControlReply, +} from './kernel/messages.js'; import { ExtensionVatWorkerServer } from './kernel/VatWorkerServer.js'; -const logger = makeLogger('[ocap glue]'); +const logger = makeLogger('[offscreen]'); main().catch(logger.error); /** - * The main function for the offscreen script. + * Main function to initialize the offscreen document. */ async function main(): Promise { // Without this delay, sending messages via the chrome.runtime API can fail. await new Promise((resolve) => setTimeout(resolve, 50)); - const backgroundStream = await ChromeRuntimeDuplexStream.make( + // Create stream for messages from the background script + const backgroundStream = await ChromeRuntimeDuplexStream.make< + KernelCommand, + KernelCommandReply + >( chrome.runtime, ChromeRuntimeTarget.Offscreen, ChromeRuntimeTarget.Background, ); - const kernelWorker = await makeKernelWorker(); + const workerStream = await setupKernelWorker(); - /** - * Reply to a command from the background script. - * - * @param commandReply - The reply to send. - */ - const replyToBackground = async ( - commandReply: KernelCommandReply, - ): Promise => { - await backgroundStream.write(commandReply); - }; + // Create multiplexer for worker communication + const multiplexer = new StreamMultiplexer( + workerStream, + 'OffscreenMultiplexer', + ); - // Handle messages from the background service worker and the kernel SQLite worker. - await Promise.all([ - kernelWorker.receiveMessages(), - (async () => { - for await (const message of backgroundStream) { - if (!isKernelCommand(message)) { - logger.error('Offscreen received unexpected message', message); - continue; - } - - await kernelWorker.sendMessage(message); + // Add kernel channel + const kernelChannel = multiplexer.addChannel< + KernelCommandReply, + KernelCommand + >( + 'kernel', + async (reply) => { + await backgroundStream.write(reply); + }, + isKernelCommandReply, + ); + let popupStream: ChromeRuntimeDuplexStream< + KernelControlCommand, + KernelControlReply + > | null = null; + + // Add panel channel + const panelChannel = multiplexer.addChannel< + KernelControlReply, + KernelControlCommand + >( + 'panel', + async (reply) => { + if (popupStream) { + await popupStream.write(reply); } - })(), + }, + isKernelControlReply, + ); + // Setup popup communication + setupPopupStream(panelChannel, (stream) => { + popupStream = stream; + }); + + // Handle messages from the background script and the multiplexer + await Promise.all([ + multiplexer.drainAll(), + backgroundStream.drain(async (message) => { + await kernelChannel.write(message); + }), ]); +} - /** - * Make the SQLite kernel worker. - * - * @returns An object with methods to send and receive messages from the kernel worker. - */ - async function makeKernelWorker(): Promise<{ - sendMessage: (message: KernelCommand) => Promise; - receiveMessages: () => Promise; - }> { - const worker = new Worker('kernel-worker.js', { type: 'module' }); - - const workerStream = await initializeMessageChannel((message, transfer) => - worker.postMessage(message, transfer), - ).then(async (port) => - MessagePortDuplexStream.make(port), - ); +/** + * Creates and initializes the kernel worker. + * + * @returns The message port stream for worker communication + */ +async function setupKernelWorker(): Promise< + MessagePortDuplexStream +> { + const worker = new Worker('kernel-worker.js', { type: 'module' }); - const vatWorkerServer = new ExtensionVatWorkerServer( - (message, transfer?) => - transfer - ? worker.postMessage(message, transfer) - : worker.postMessage(message), - (listener) => worker.addEventListener('message', listener), - (vatId) => makeIframeVatWorker(vatId, initializeMessageChannel), - ); + const port = await initializeMessageChannel((message, transfer) => + worker.postMessage(message, transfer), + ); - vatWorkerServer.start(); + const workerStream = await MessagePortDuplexStream.make< + MultiplexEnvelope, + MultiplexEnvelope + >(port); + + const vatWorkerServer = new ExtensionVatWorkerServer( + (message, transfer?) => + transfer + ? worker.postMessage(message, transfer) + : worker.postMessage(message), + (listener) => worker.addEventListener('message', listener), + (vatId) => makeIframeVatWorker(vatId, initializeMessageChannel), + ); - const receiveMessages = async (): Promise => { - // For the time being, the only messages that come from the kernel worker are replies to actions - // initiated from the console, so just forward these replies to the console. This will need to - // change once this offscreen script is providing services to the kernel worker that don't - // involve the user. - for await (const message of workerStream) { - if (!isKernelCommandReply(message)) { - logger.error('Kernel sent unexpected reply', message); - continue; - } + vatWorkerServer.start(); - await replyToBackground(message); - } - }; + return workerStream; +} - const sendMessage = async (message: KernelCommand): Promise => { - await workerStream.write(message); - }; +/** + * Sets up the popup communication stream. + * + * @param panelChannel - The panel channel from the multiplexer + * @param onStreamCreated - Callback to handle the created stream + */ +function setupPopupStream( + panelChannel: HandledDuplexStream, + onStreamCreated: ( + stream: ChromeRuntimeDuplexStream< + KernelControlCommand, + KernelControlReply + > | null, + ) => void, +): void { + chrome.runtime.onConnect.addListener((port) => { + if (port.name !== 'popup') { + return; + } + + // Handle stream creation + handlePopupConnection(port, panelChannel, onStreamCreated).catch( + (error) => { + logger.error(error); + onStreamCreated(null); + }, + ); + }); +} - return { - sendMessage, - receiveMessages, - }; - } +/** + * Handles the popup connection. + * + * @param port - The port to connect to the popup. + * @param panelChannel - The panel channel from the multiplexer. + * @param onStreamCreated - Callback to handle the created stream. + */ +async function handlePopupConnection( + port: chrome.runtime.Port, + panelChannel: HandledDuplexStream, + onStreamCreated: ( + stream: ChromeRuntimeDuplexStream< + KernelControlCommand, + KernelControlReply + > | null, + ) => void, +): Promise { + const stream = await ChromeRuntimeDuplexStream.make< + KernelControlCommand, + KernelControlReply + >(chrome.runtime, ChromeRuntimeTarget.Offscreen, ChromeRuntimeTarget.Popup); + + // Setup cleanup for when popup closes + port.onDisconnect.addListener(() => { + stream.return().catch(console.error); + onStreamCreated(null); + }); + + onStreamCreated(stream); + + // Start handling messages + await stream.drain(async (message) => { + await panelChannel.write(message); + }); } diff --git a/packages/extension/src/panel/buttons.test.ts b/packages/extension/src/panel/buttons.test.ts new file mode 100644 index 000000000..66e1a2abd --- /dev/null +++ b/packages/extension/src/panel/buttons.test.ts @@ -0,0 +1,82 @@ +import '../../../test-utils/src/env/mock-endo.ts'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { setupPanelDOM } from '../../test/panel-utils.js'; + +describe('buttons', () => { + beforeEach(async () => { + vi.resetModules(); + await setupPanelDOM(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe('button commands', () => { + it('should generate correct launch vat command', async () => { + const { buttons, newVatId } = await import('./buttons'); + newVatId.value = 'v1'; + const command = buttons.launchVat?.command(); + + expect(command).toStrictEqual({ + method: 'launchVat', + params: { id: 'v1' }, + }); + }); + + it('should generate correct restart vat command', async () => { + const { buttons, vatDropdown } = await import('./buttons'); + vatDropdown.value = 'v0'; + const command = buttons.restartVat?.command(); + + expect(command).toStrictEqual({ + method: 'restartVat', + params: { id: 'v0' }, + }); + }); + + it('should generate correct terminate vat command', async () => { + const { buttons, vatDropdown } = await import('./buttons'); + vatDropdown.value = 'v0'; + const command = buttons.terminateVat?.command(); + + expect(command).toStrictEqual({ + method: 'terminateVat', + params: { id: 'v0' }, + }); + }); + + it('should generate correct terminate all vats command', async () => { + const { buttons } = await import('./buttons'); + const command = buttons.terminateAllVats?.command(); + + expect(command).toStrictEqual({ + method: 'terminateAllVats', + params: null, + }); + }); + }); + + describe('setupButtonHandlers', () => { + it('should set up click handlers for all buttons', async () => { + const sendMessage = vi.fn().mockResolvedValue(undefined); + const { buttons, newVatId, vatDropdown, setupButtonHandlers } = + await import('./buttons'); + newVatId.value = 'v1'; + vatDropdown.value = 'v1'; + + setupButtonHandlers(sendMessage); + + // Test each button click + await Promise.all( + Object.values(buttons).map(async (button) => { + button.element.click(); + expect(sendMessage).toHaveBeenCalledWith(button.command()); + }), + ); + + expect(sendMessage).toHaveBeenCalledTimes(Object.keys(buttons).length); + }); + }); +}); diff --git a/packages/extension/src/panel/buttons.ts b/packages/extension/src/panel/buttons.ts new file mode 100644 index 000000000..1737f51ce --- /dev/null +++ b/packages/extension/src/panel/buttons.ts @@ -0,0 +1,63 @@ +import type { VatId } from '@ocap/kernel'; + +import { logger } from './shared.js'; +import type { KernelControlCommand } from '../kernel/messages.js'; + +export const vatDropdown = document.getElementById( + 'vat-dropdown', +) as HTMLSelectElement; +export const newVatId = document.getElementById( + 'new-vat-id', +) as HTMLInputElement; + +export const buttons: Record< + string, + { + element: HTMLButtonElement; + command: () => KernelControlCommand; + } +> = { + launchVat: { + element: document.getElementById('launch-vat') as HTMLButtonElement, + command: () => ({ + method: 'launchVat', + params: { id: newVatId.value as VatId }, + }), + }, + restartVat: { + element: document.getElementById('restart-vat') as HTMLButtonElement, + command: () => ({ + method: 'restartVat', + params: { id: vatDropdown.value as VatId }, + }), + }, + terminateVat: { + element: document.getElementById('terminate-vat') as HTMLButtonElement, + command: () => ({ + method: 'terminateVat', + params: { id: vatDropdown.value as VatId }, + }), + }, + terminateAllVats: { + element: document.getElementById('terminate-all') as HTMLButtonElement, + command: () => ({ + method: 'terminateAllVats', + params: null, + }), + }, +}; + +/** + * Setup button handlers for the kernel panel. + * + * @param sendMessage - The function to send messages to the kernel. + */ +export function setupButtonHandlers( + sendMessage: (message: KernelControlCommand) => Promise, +): void { + Object.values(buttons).forEach((button) => { + button.element.addEventListener('click', () => { + sendMessage(button.command()).catch(logger.error); + }); + }); +} diff --git a/packages/extension/src/panel/messages.test.ts b/packages/extension/src/panel/messages.test.ts new file mode 100644 index 000000000..a0c3f5445 --- /dev/null +++ b/packages/extension/src/panel/messages.test.ts @@ -0,0 +1,287 @@ +import '../../../test-utils/src/env/mock-endo.ts'; +import { define } from '@metamask/superstruct'; +import type { VatId } from '@ocap/kernel'; +import { stringify } from '@ocap/utils'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { setupPanelDOM } from '../../test/panel-utils.js'; +import type { KernelControlReply } from '../kernel/messages.js'; + +const isVatId = vi.fn( + (input: unknown): input is VatId => typeof input === 'string', +); + +vi.mock('./status', () => ({ + updateStatusDisplay: vi.fn(), +})); + +// Mock kernel imports +vi.mock('@ocap/kernel', () => ({ + isVatId, + VatCommandMethod: { + ping: 'ping', + evaluate: 'evaluate', + }, + KernelCommandMethod: { + kvSet: 'kvSet', + kvGet: 'kvGet', + }, + VatIdStruct: define('VatId', isVatId), +})); + +describe('messages', () => { + beforeEach(async () => { + vi.resetModules(); + await setupPanelDOM(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe('showOutput', () => { + it('should display error messages correctly', async () => { + const { showOutput } = await import('./messages'); + const errorMessage = 'Test error message'; + + showOutput(errorMessage, 'error'); + + const output = document.getElementById('message-output'); + const outputBox = document.getElementById('output-box'); + + expect(output?.textContent).toBe(errorMessage); + expect(output?.className).toBe('error'); + expect(outputBox?.style.display).toBe('block'); + }); + + it('should hide output box when message is empty', async () => { + const { showOutput } = await import('./messages'); + + showOutput(''); + + const outputBox = document.getElementById('output-box'); + expect(outputBox?.style.display).toBe('none'); + }); + + it('should properly reset all properties when message is empty', async () => { + const { showOutput } = await import('./messages'); + + showOutput(''); + + const output = document.getElementById('message-output'); + const outputBox = document.getElementById('output-box'); + + expect(output?.textContent).toBe(''); + expect(output?.className).toBe('info'); + expect(outputBox?.style.display).toBe('none'); + }); + }); + + describe('setupTemplateHandlers', () => { + it('should create template buttons with correct messages', async () => { + const { setupTemplateHandlers, commonMessages } = await import( + './messages' + ); + const sendMessage = vi.fn().mockResolvedValue(undefined); + + setupTemplateHandlers(sendMessage); + + const templates = document.querySelectorAll('.template'); + expect(templates).toHaveLength(Object.keys(commonMessages).length); + + // Check if each template button exists + Object.keys(commonMessages).forEach((templateName) => { + const button = Array.from(templates).find( + (el) => el.textContent === templateName, + ); + expect(button).not.toBeNull(); + }); + }); + + it('should update message content when template button is clicked', async () => { + const { + setupTemplateHandlers, + commonMessages, + messageContent, + sendButton, + } = await import('./messages'); + const sendMessage = vi.fn().mockResolvedValue(undefined); + + setupTemplateHandlers(sendMessage); + + const firstTemplateName = Object.keys(commonMessages)[0] as string; + const firstTemplate = document.querySelector( + '.template', + ) as HTMLButtonElement; + + firstTemplate.dispatchEvent(new Event('click')); + + expect(messageContent.value).toBe( + stringify(commonMessages[firstTemplateName], 0), + ); + expect(sendButton.disabled).toBe(false); + }); + + it('should send message when send button is clicked', async () => { + const { setupTemplateHandlers, messageContent, sendButton } = + await import('./messages'); + const { vatDropdown } = await import('./buttons.js'); + const sendMessage = vi.fn().mockResolvedValue(undefined); + isVatId.mockReturnValue(true); + + setupTemplateHandlers(sendMessage); + + // Setup test data + messageContent.value = '{"method":"ping","params":null}'; + vatDropdown.value = 'v0'; + + sendButton.dispatchEvent(new Event('click')); + + expect(isVatId).toHaveBeenCalledWith('v0'); + + expect(sendMessage).toHaveBeenCalledWith({ + method: 'sendMessage', + params: { + id: 'v0', + payload: { method: 'ping', params: null }, + }, + }); + }); + + it('should send message without vat id when send button is clicked', async () => { + const { setupTemplateHandlers, messageContent, sendButton } = + await import('./messages'); + const { vatDropdown } = await import('./buttons.js'); + const sendMessage = vi.fn().mockResolvedValue(undefined); + isVatId.mockReturnValue(false); + + setupTemplateHandlers(sendMessage); + + messageContent.value = + '{"method":"kvSet","params":{"key":"test","value":"test"}}'; + vatDropdown.value = ''; + + sendButton.dispatchEvent(new Event('click')); + + expect(isVatId).toHaveBeenCalledWith(''); + + expect(sendMessage).toHaveBeenCalledWith({ + method: 'sendMessage', + params: { + payload: { method: 'kvSet', params: { key: 'test', value: 'test' } }, + }, + }); + }); + + it('should handle send button state based on message content', async () => { + const { setupTemplateHandlers, messageContent, sendButton } = + await import('./messages'); + const sendMessage = vi.fn().mockResolvedValue(undefined); + + setupTemplateHandlers(sendMessage); + + // Empty content should disable button + messageContent.value = ''; + messageContent.dispatchEvent(new Event('input')); + expect(sendButton.disabled).toBe(true); + + // Non-empty content should enable button + messageContent.value = '{"method":"ping","params":null}'; + messageContent.dispatchEvent(new Event('input')); + expect(sendButton.disabled).toBe(false); + }); + + it('should update send button text based on vat selection', async () => { + const { setupTemplateHandlers } = await import('./messages'); + const { vatDropdown } = await import('./buttons'); + const sendMessage = vi.fn().mockResolvedValue(undefined); + + setupTemplateHandlers(sendMessage); + + const sendButton = document.getElementById( + 'send-message', + ) as HTMLButtonElement; + + // With vat selected + vatDropdown.value = 'v0'; + vatDropdown.dispatchEvent(new Event('change')); + expect(sendButton.textContent).toBe('Send to Vat'); + + // Without vat selected + vatDropdown.value = ''; + vatDropdown.dispatchEvent(new Event('change')); + expect(sendButton.textContent).toBe('Send'); + }); + + it('should handle send errors correctly', async () => { + const { setupTemplateHandlers, messageContent, sendButton } = + await import('./messages'); + const error = new Error('Test error'); + const sendMessage = vi.fn().mockRejectedValue(error); + + setupTemplateHandlers(sendMessage); + + messageContent.value = '{"method":"ping","params":null}'; + sendButton.dispatchEvent(new Event('click')); + + // Wait for error handling + await new Promise(process.nextTick); + + const output = document.getElementById('message-output'); + expect(output?.textContent).toBe(error.toString()); + expect(output?.className).toBe('error'); + }); + }); + + describe('handleKernelMessage', () => { + it('should ignore invalid kernel control replies', async () => { + const { handleKernelMessage } = await import('./messages'); + const invalidMessage = { method: 'invalid' }; + handleKernelMessage(invalidMessage as KernelControlReply); + const output = document.getElementById('message-output'); + expect(output?.textContent).toBe(''); + }); + + it('should handle kernel status updates', async () => { + const { handleKernelMessage } = await import('./messages'); + const { updateStatusDisplay } = await import('./status'); + const statusMessage: KernelControlReply = { + method: 'getStatus', + params: { + isRunning: true, + activeVats: ['v0'], + }, + }; + handleKernelMessage(statusMessage); + expect(updateStatusDisplay).toHaveBeenCalledWith(statusMessage.params); + }); + + it('should display error responses from sendMessage', async () => { + const { handleKernelMessage } = await import('./messages'); + const errorMessage: KernelControlReply = { + method: 'sendMessage', + params: { + error: 'Test error message', + }, + }; + handleKernelMessage(errorMessage); + const output = document.getElementById('message-output'); + expect(output?.textContent).toBe('"Test error message"'); + expect(output?.className).toBe('error'); + }); + + it('should display successful responses from sendMessage', async () => { + const { handleKernelMessage } = await import('./messages'); + const successMessage: KernelControlReply = { + method: 'sendMessage', + params: { + result: 'Success', + }, + }; + handleKernelMessage(successMessage); + const output = document.getElementById('message-output'); + expect(output?.textContent).toBe('{\n "result": "Success"\n}'); + expect(output?.className).toBe('info'); + }); + }); +}); diff --git a/packages/extension/src/panel/messages.ts b/packages/extension/src/panel/messages.ts new file mode 100644 index 000000000..fb7c717e7 --- /dev/null +++ b/packages/extension/src/panel/messages.ts @@ -0,0 +1,137 @@ +import { KernelCommandMethod, VatCommandMethod, isVatId } from '@ocap/kernel'; +import type { KernelCommand } from '@ocap/kernel'; +import { stringify } from '@ocap/utils'; + +import { vatDropdown } from './buttons.js'; +import { updateStatusDisplay } from './status.js'; +import { + KernelControlMethod, + isKernelControlReply, + isKernelStatus, +} from '../kernel/messages.js'; +import type { + KernelControlCommand, + KernelControlReply, +} from '../kernel/messages.js'; + +const outputBox = document.getElementById('output-box') as HTMLElement; +const messageOutput = document.getElementById( + 'message-output', +) as HTMLPreElement; +export const messageContent = document.getElementById( + 'message-content', +) as HTMLInputElement; +const messageTemplates = document.getElementById( + 'message-templates', +) as HTMLElement; +export const sendButton = document.getElementById( + 'send-message', +) as HTMLButtonElement; + +export const commonMessages: Record = { + Ping: { method: VatCommandMethod.ping, params: null }, + Evaluate: { + method: VatCommandMethod.evaluate, + params: `[1,2,3].join(',')`, + }, + KVSet: { + method: KernelCommandMethod.kvSet, + params: { key: 'foo', value: 'bar' }, + }, + KVGet: { method: KernelCommandMethod.kvGet, params: 'foo' }, +}; + +/** + * Show an output message in the message output box. + * + * @param message - The message to display. + * @param type - The type of message to display. + */ +export function showOutput( + message: string, + type: 'error' | 'success' | 'info' = 'info', +): void { + messageOutput.textContent = message; + messageOutput.className = type; + outputBox.style.display = message ? 'block' : 'none'; +} + +/** + * Setup handlers for template buttons. + * + * @param sendMessage - The function to send messages to the kernel. + */ +export function setupTemplateHandlers( + sendMessage: (message: KernelControlCommand) => Promise, +): void { + Object.keys(commonMessages).forEach((templateName) => { + const button = document.createElement('button'); + button.className = 'text-button template'; + button.textContent = templateName; + + button.addEventListener('click', () => { + messageContent.value = stringify(commonMessages[templateName], 0); + sendButton.disabled = false; + }); + + messageTemplates.appendChild(button); + }); + + sendButton.addEventListener('click', () => { + (async () => { + const command: KernelControlCommand = { + method: KernelControlMethod.sendMessage, + params: { + payload: JSON.parse(messageContent.value), + ...(isVatId(vatDropdown.value) ? { id: vatDropdown.value } : {}), + }, + }; + await sendMessage(command); + })().catch((error) => showOutput(String(error), 'error')); + }); + + messageContent.addEventListener('input', () => { + sendButton.disabled = !messageContent.value.trim(); + }); + + vatDropdown.addEventListener('change', () => { + sendButton.textContent = vatDropdown.value ? 'Send to Vat' : 'Send'; + }); +} + +/** + * Handle a kernel message. + * + * @param message - The message to handle. + */ +export function handleKernelMessage(message: KernelControlReply): void { + if (!isKernelControlReply(message) || message.params === null) { + showOutput(''); + return; + } + + if (isKernelStatus(message.params)) { + updateStatusDisplay(message.params); + return; + } + + if (isErrorResponse(message.params)) { + showOutput(stringify(message.params.error, 0), 'error'); + } else { + showOutput(stringify(message.params, 2), 'info'); + } +} + +type ErrorResponse = { + error: unknown; +}; + +/** + * Checks if a value is an error response. + * + * @param value - The value to check. + * @returns Whether the value is an error response. + */ +function isErrorResponse(value: unknown): value is ErrorResponse { + return typeof value === 'object' && value !== null && 'error' in value; +} diff --git a/packages/extension/src/panel/shared.ts b/packages/extension/src/panel/shared.ts new file mode 100644 index 000000000..d0c7785b3 --- /dev/null +++ b/packages/extension/src/panel/shared.ts @@ -0,0 +1,3 @@ +import { makeLogger } from '@ocap/utils'; + +export const logger = makeLogger('[Kernel Panel]'); diff --git a/packages/extension/src/panel/status.test.ts b/packages/extension/src/panel/status.test.ts new file mode 100644 index 000000000..fb09840ae --- /dev/null +++ b/packages/extension/src/panel/status.test.ts @@ -0,0 +1,301 @@ +import '../../../test-utils/src/env/mock-endo.js'; +import { define } from '@metamask/superstruct'; +import type { VatId } from '@ocap/kernel'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { setupPanelDOM } from '../../test/panel-utils.js'; + +const isVatId = vi.fn( + (input: unknown): input is VatId => typeof input === 'string', +); + +vi.mock('@ocap/kernel', () => ({ + isVatId, + VatIdStruct: define('VatId', isVatId), +})); + +describe('status', () => { + beforeEach(async () => { + vi.resetAllMocks(); + vi.resetModules(); + await setupPanelDOM(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.useRealTimers(); + }); + + describe('setupStatusPolling', () => { + it('should start polling for status', async () => { + const { setupStatusPolling } = await import('./status'); + const sendMessage = vi.fn().mockResolvedValue(undefined); + vi.useFakeTimers(); + + const pollingPromise = setupStatusPolling(sendMessage); + + // First immediate call + expect(sendMessage).toHaveBeenCalledWith({ + method: 'getStatus', + params: null, + }); + + // Advance timer to trigger next poll + await vi.advanceTimersByTimeAsync(1000); + + expect(sendMessage).toHaveBeenCalledTimes(2); + + await pollingPromise; + }); + }); + + describe('updateStatusDisplay', () => { + it('should display running status with active vats', async () => { + const { updateStatusDisplay, statusDisplay } = await import('./status'); + + const activeVats: VatId[] = ['v0', 'v1', 'v2']; + + updateStatusDisplay({ + isRunning: true, + activeVats, + }); + + expect(statusDisplay?.textContent).toBe( + `Active Vats (3): ["v0","v1","v2"]`, + ); + }); + + it('should display not running status', async () => { + const { updateStatusDisplay, statusDisplay } = await import('./status'); + + updateStatusDisplay({ + isRunning: false, + activeVats: [], + }); + + expect(statusDisplay?.textContent).toBe('Kernel is not running'); + }); + + it('should update vat select options', async () => { + const { updateStatusDisplay } = await import('./status'); + const { vatDropdown } = await import('./buttons'); + const activeVats: VatId[] = ['v0', 'v1']; + + updateStatusDisplay({ + isRunning: true, + activeVats, + }); + + expect(vatDropdown.options).toHaveLength(3); // Including empty option + expect(vatDropdown.options[1]?.value).toBe('v0'); + expect(vatDropdown.options[2]?.value).toBe('v1'); + }); + + it('should preserve selected vat if still active', async () => { + const { updateStatusDisplay } = await import('./status'); + const { vatDropdown } = await import('./buttons'); + // First update + updateStatusDisplay({ + isRunning: true, + activeVats: ['v0', 'v1'], + }); + vatDropdown.value = 'v1'; + + // Second update with same vat still active + updateStatusDisplay({ + isRunning: true, + activeVats: ['v0', 'v1', 'v2'], + }); + + expect(vatDropdown.value).toBe('v1'); + }); + + it('should clear selection if selected vat becomes inactive', async () => { + const { updateStatusDisplay } = await import('./status'); + const { vatDropdown } = await import('./buttons'); + + // First update and selection + updateStatusDisplay({ + isRunning: true, + activeVats: ['v0', 'v1'], + }); + vatDropdown.value = 'v1'; + + // Second update with selected vat removed + updateStatusDisplay({ + isRunning: true, + activeVats: ['v0'], + }); + + expect(vatDropdown.value).toBe(''); + }); + + it('should skip vat select update if vats have not changed', async () => { + const { updateStatusDisplay } = await import('./status'); + const { vatDropdown } = await import('./buttons'); + + const activeVats: VatId[] = ['v0', 'v1']; + + // First update + updateStatusDisplay({ + isRunning: true, + activeVats, + }); + + // Store original options for comparison + const originalOptions = Array.from(vatDropdown.options); + + // Update with same vats in same order + updateStatusDisplay({ + isRunning: true, + activeVats: ['v0', 'v1'], + }); + + // Compare options after update + const newOptions = Array.from(vatDropdown.options); + expect(newOptions).toStrictEqual(originalOptions); + + // Verify the options are the actual same DOM elements (not just equal) + newOptions.forEach((option, index) => { + expect(option).toBe(originalOptions[index]); + }); + }); + + it('should update vat select if vats are same but in different order', async () => { + const { updateStatusDisplay } = await import('./status'); + const { vatDropdown } = await import('./buttons'); + + // First update + updateStatusDisplay({ + isRunning: true, + activeVats: ['v0', 'v1'], + }); + + // Store original options for comparison + const originalOptions = Array.from(vatDropdown.options); + + // Update with same vats in different order + updateStatusDisplay({ + isRunning: true, + activeVats: ['v1', 'v0'], + }); + + // Compare options after update + const newOptions = Array.from(vatDropdown.options); + expect(newOptions).not.toStrictEqual(originalOptions); + expect(vatDropdown.options[1]?.value).toBe('v1'); + expect(vatDropdown.options[2]?.value).toBe('v0'); + }); + }); + + describe('setupVatListeners', () => { + it('should update button states on vat id input', async () => { + const { setupVatListeners } = await import('./status'); + const { buttons, newVatId } = await import('./buttons'); + + setupVatListeners(); + + // Empty input + newVatId.value = ''; + newVatId.dispatchEvent(new Event('input')); + expect(buttons.launchVat?.element.disabled).toBe(true); + + // Non-empty input + newVatId.value = 'v3'; + newVatId.dispatchEvent(new Event('input')); + expect(buttons.launchVat?.element.disabled).toBe(false); + }); + + it('should update button states on vat selection change', async () => { + const { setupVatListeners } = await import('./status'); + const { buttons, vatDropdown } = await import('./buttons'); + + setupVatListeners(); + + // No selection + vatDropdown.value = ''; + vatDropdown.dispatchEvent(new Event('change')); + expect(buttons.restartVat?.element.disabled).toBe(true); + expect(buttons.terminateVat?.element.disabled).toBe(true); + + // With selection + vatDropdown.value = 'v0'; + vatDropdown.dispatchEvent(new Event('change')); + expect(buttons.restartVat?.element.disabled).toBe(false); + expect(buttons.terminateVat?.element.disabled).toBe(false); + }); + }); + + describe('updateButtonStates', () => { + it('should disable launch button when new vat ID is empty', async () => { + const { updateButtonStates } = await import('./status'); + const { buttons, newVatId } = await import('./buttons'); + newVatId.value = ''; + updateButtonStates(true); + expect(buttons.launchVat?.element.disabled).toBe(true); + }); + + it('should enable launch button when new vat ID is non-empty', async () => { + const { updateButtonStates } = await import('./status'); + const { buttons, newVatId } = await import('./buttons'); + newVatId.value = 'test-vat'; + updateButtonStates(true); + expect(buttons.launchVat?.element.disabled).toBe(false); + }); + + it('should disable restart and terminate buttons based on vat selection', async () => { + const { updateButtonStates } = await import('./status'); + const { buttons, vatDropdown } = await import('./buttons'); + + vatDropdown.value = ''; + updateButtonStates(true); + expect(buttons.restartVat?.element.disabled).toBe(true); + expect(buttons.terminateVat?.element.disabled).toBe(true); + }); + + it('should enable restart and terminate buttons based on vat selection', async () => { + const { updateButtonStates } = await import('./status'); + const { buttons, vatDropdown } = await import('./buttons'); + + const option = document.createElement('option'); + option.value = 'v1'; + option.text = 'v1'; + vatDropdown.add(option); + + vatDropdown.value = 'v1'; + updateButtonStates(true); + expect(buttons.restartVat?.element.disabled).toBe(false); + expect(buttons.terminateVat?.element.disabled).toBe(false); + }); + + it('should disable terminate all button when no vats exist', async () => { + const { updateButtonStates } = await import('./status'); + const { buttons } = await import('./buttons'); + updateButtonStates(false); + expect(buttons.terminateAllVats?.element.disabled).toBe(true); + }); + + it('should enable terminate all button when vats exist', async () => { + const { updateButtonStates } = await import('./status'); + const { buttons } = await import('./buttons'); + updateButtonStates(true); + expect(buttons.terminateAllVats?.element.disabled).toBe(false); + }); + + it('should handle missing buttons', async () => { + const { updateButtonStates } = await import('./status'); + const { buttons } = await import('./buttons'); + + // @ts-expect-error - testing undefined state + buttons.launchVat = undefined; + // @ts-expect-error - testing undefined state + buttons.restartVat = undefined; + // @ts-expect-error - testing undefined state + buttons.terminateVat = undefined; + // @ts-expect-error - testing undefined state + buttons.terminateAllVats = undefined; + + expect(() => updateButtonStates(true)).not.toThrow(); + }); + }); +}); diff --git a/packages/extension/src/panel/status.ts b/packages/extension/src/panel/status.ts new file mode 100644 index 000000000..12a50fdf6 --- /dev/null +++ b/packages/extension/src/panel/status.ts @@ -0,0 +1,127 @@ +import type { VatId } from '@ocap/kernel'; +import { stringify } from '@ocap/utils'; + +import { buttons, vatDropdown, newVatId } from './buttons.js'; +import { logger } from './shared.js'; +import type { KernelControlCommand, KernelStatus } from '../kernel/messages.js'; + +export const statusDisplay = document.getElementById( + 'status-display', +) as HTMLElement; + +/** + * Setup status polling. + * + * @param sendMessage - A function for sending messages. + */ +export async function setupStatusPolling( + sendMessage: (message: KernelControlCommand) => Promise, +): Promise { + const fetchStatus = async (): Promise => { + await sendMessage({ + method: 'getStatus', + params: null, + }); + + setTimeout(() => { + fetchStatus().catch(logger.error); + }, 1000); + }; + + await fetchStatus(); +} + +/** + * Update the status display with the current status. + * + * @param status - The current status. + */ +export function updateStatusDisplay(status: KernelStatus): void { + const { isRunning, activeVats } = status; + statusDisplay.textContent = isRunning + ? `Active Vats (${activeVats.length}): ${stringify(activeVats, 0)}` + : 'Kernel is not running'; + + updatevatDropdown(activeVats); +} + +/** + * Setup listeners for vat ID input and change events. + */ +export function setupVatListeners(): void { + newVatId.addEventListener('input', () => { + updateButtonStates(vatDropdown.options.length > 1); + }); + + vatDropdown.addEventListener('change', () => { + updateButtonStates(vatDropdown.options.length > 1); + }); +} + +/** + * Updates the vat selection dropdown with active vats + * + * @param activeVats - Array of active vat IDs + */ +function updatevatDropdown(activeVats: VatId[]): void { + // Compare current options with new vats + const currentVats = Array.from(vatDropdown.options) + .slice(1) // Skip the default empty option + .map((option) => option.value as VatId); + + // Skip update if vats haven't changed + if (JSON.stringify(currentVats) === JSON.stringify(activeVats)) { + return; + } + + // Store current selection + const currentSelection = vatDropdown.value; + + // Clear existing options except the default one + while (vatDropdown.options.length > 1) { + vatDropdown.remove(1); + } + + // Add new options + activeVats.forEach((id) => { + const option = document.createElement('option'); + option.value = id; + option.text = id; + vatDropdown.add(option); + }); + + // Restore selection if it still exists + if (activeVats.includes(currentSelection as VatId)) { + vatDropdown.value = currentSelection; + } else { + vatDropdown.value = ''; + } + + // Update button states + updateButtonStates(activeVats.length > 0); +} + +/** + * Updates button states based on selections and vat existence + * + * @param hasVats - Whether any vats exist + */ +export function updateButtonStates(hasVats: boolean): void { + // Launch button - enabled only when new vat ID is not empty + if (buttons.launchVat) { + buttons.launchVat.element.disabled = !newVatId.value.trim(); + } + + // Restart and terminate buttons - enabled when a vat is selected + if (buttons.restartVat) { + buttons.restartVat.element.disabled = !vatDropdown.value; + } + if (buttons.terminateVat) { + buttons.terminateVat.element.disabled = !vatDropdown.value; + } + + // Terminate all - enabled only when vats exist + if (buttons.terminateAllVats) { + buttons.terminateAllVats.element.disabled = !hasVats; + } +} diff --git a/packages/extension/src/panel/stream.ts b/packages/extension/src/panel/stream.ts new file mode 100644 index 000000000..2a7ce1a40 --- /dev/null +++ b/packages/extension/src/panel/stream.ts @@ -0,0 +1,52 @@ +import { ChromeRuntimeDuplexStream, ChromeRuntimeTarget } from '@ocap/streams'; + +import { handleKernelMessage } from './messages.js'; +import { logger } from './shared.js'; +import { isKernelControlReply } from '../kernel/messages.js'; +import type { + KernelControlCommand, + KernelControlReply, +} from '../kernel/messages.js'; + +/** + * Setup the stream for sending and receiving messages. + * + * @returns A function for sending messages. + */ +export async function setupStream(): Promise< + (message: KernelControlCommand) => Promise +> { + // Connect to the offscreen script + const port = chrome.runtime.connect({ name: 'popup' }); + + // Create the stream + const offscreenStream = await ChromeRuntimeDuplexStream.make< + KernelControlReply, + KernelControlCommand + >( + chrome.runtime, + ChromeRuntimeTarget.Popup, + ChromeRuntimeTarget.Offscreen, + isKernelControlReply, + ); + + // Cleanup stream on disconnect + const cleanup = (): void => { + offscreenStream.return().catch(logger.error); + }; + port.onDisconnect.addListener(cleanup); + window.addEventListener('unload', cleanup); + + // Send messages to the offscreen script + const sendMessage = async (message: KernelControlCommand): Promise => { + logger.log('sending message', message); + await offscreenStream.write(message); + }; + + // Handle messages from the offscreen script + offscreenStream.drain(handleKernelMessage).catch((error) => { + logger.error('error draining offscreen stream', error); + }); + + return sendMessage; +} diff --git a/packages/extension/src/panel/styles.css b/packages/extension/src/panel/styles.css new file mode 100644 index 000000000..7450ec9ef --- /dev/null +++ b/packages/extension/src/panel/styles.css @@ -0,0 +1,204 @@ +:root { + /* Colors */ + --color-white: #fff; + --color-black: #333; + --color-gray-100: #f5f5f5; + --color-gray-200: #f0f0f0; + --color-gray-300: #ccc; + --color-primary: #4956f9; + --color-success: #4caf50; + --color-error: #f44336; + --color-warning: #ffeb3b; + + /* Spacing */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 12px; + --spacing-lg: 14px; + --spacing-xl: 16px; + --spacing-xxl: 30px; + + /* Typography */ + --font-size-xs: 12px; + --font-size-sm: 14px; + + /* Other */ + --border-radius: 3px; + --input-height: 36px; + --transition-speed: 0.1s; + --select-arrow-size: 8px; + --message-output-max-height: 200px; +} + +body * { + box-sizing: border-box; +} + +.kernel-panel { + padding: var(--spacing-xl); + font-family: + system-ui, + -apple-system, + sans-serif; +} + +.vat-controls { + display: flex; + align-items: center; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-xl); +} + +button, +select, +input { + height: var(--input-height); + padding: 0 var(--spacing-lg); + border-radius: var(--border-radius); + border: 1px solid var(--color-gray-300); + font-size: var(--font-size-sm); + margin: var(--spacing-xs); + background-color: var(--color-white); + transition: background-color var(--transition-speed); +} + +button { + white-space: nowrap; + cursor: pointer; + background-color: var(--color-gray-200); +} + +button:hover:not(:disabled) { + background-color: var(--color-gray-300); +} + +button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +button.green { + background-color: var(--color-success); + color: var(--color-white); + border: none; +} + +button.red { + background-color: var(--color-error); + color: var(--color-white); + border: none; +} + +button.yellow { + background-color: var(--color-warning); + border: none; +} + +button.red:hover:not(:disabled) { + color: var(--color-white); + background-color: var(--color-black); +} + +select { + min-width: 120px; + cursor: pointer; + appearance: none; + background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23131313%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.4-12.8z%22%2F%3E%3C%2Fsvg%3E'); + background-repeat: no-repeat; + background-position: right var(--spacing-md) center; + background-size: var(--select-arrow-size) auto; + padding-right: var(--spacing-xxl); +} + +#new-vat-id { + width: 80px; +} + +#status-display { + background: var(--color-gray-100); + padding: var(--spacing-md) var(--spacing-lg); + border-radius: var(--border-radius); + font-size: var(--font-size-xs); +} + +h3 { + margin: var(--spacing-xs); +} + +h4 { + margin: 0 0 var(--spacing-sm); +} + +.kernel-status { + margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xl); +} + +#vat-select { + width: 60px; +} + +.message-panel { + margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xl); +} + +#message-templates { + display: flex; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); +} + +.message-input-row { + display: flex; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); +} + +#message-content { + flex: 1; + margin: 0; +} + +#send-message { + margin: 0; +} + +button.text-button { + padding: 0; + border: 0; + cursor: pointer; + height: auto; + background: transparent; + font-size: var(--font-size-xs); + color: var(--color-primary); + text-decoration: underline; + margin: 0; +} + +button.text-button:hover { + color: var(--color-black); + text-decoration: none; + background-color: transparent; +} + +#output-box { + margin-top: var(--spacing-sm); +} + +#message-output { + background: var(--color-gray-100); + padding: var(--spacing-md) var(--spacing-lg); + border-radius: var(--border-radius); + font-size: var(--font-size-xs); + max-height: var(--message-output-max-height); + overflow-y: auto; + white-space: pre-wrap; + margin-top: 0; +} + +.error { + color: var(--color-error); +} + +.success { + color: var(--color-success); +} diff --git a/packages/extension/src/popup.html b/packages/extension/src/popup.html new file mode 100644 index 000000000..ca6df703c --- /dev/null +++ b/packages/extension/src/popup.html @@ -0,0 +1,53 @@ + + + + + Kernel Panel + + + + +
+
+
+

Kernel Status

+

+        
+ +
+ + +
+ +
+ + + +
+ +
+

Send Message

+
+
+ + +
+ +
+ +
+ +
+
+
+ + diff --git a/packages/extension/src/popup.ts b/packages/extension/src/popup.ts new file mode 100644 index 000000000..d812f41eb --- /dev/null +++ b/packages/extension/src/popup.ts @@ -0,0 +1,20 @@ +import { setupButtonHandlers } from './panel/buttons.js'; +import { setupTemplateHandlers } from './panel/messages.js'; +import { logger } from './panel/shared.js'; +import { setupStatusPolling, setupVatListeners } from './panel/status.js'; +import { setupStream } from './panel/stream.js'; + +/** + * Main function to initialize the popup. + */ +async function main(): Promise { + const sendMessage = await setupStream(); + + setupVatListeners(); + setupButtonHandlers(sendMessage); + setupTemplateHandlers(sendMessage); + + await setupStatusPolling(sendMessage); +} + +main().catch(logger.error); diff --git a/packages/extension/test/panel-utils.ts b/packages/extension/test/panel-utils.ts new file mode 100644 index 000000000..c3f3bf049 --- /dev/null +++ b/packages/extension/test/panel-utils.ts @@ -0,0 +1,24 @@ +import fs from 'fs/promises'; +import path, { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +/** + * Setup the DOM for the tests. + */ +export async function setupPanelDOM(): Promise { + const htmlPath = path.resolve( + dirname(fileURLToPath(import.meta.url)), + '../src/popup.html', + ); + const html = await fs.readFile(htmlPath, 'utf-8'); + document.body.innerHTML = html; + + // Add test option to select + const vatDropdown = document.getElementById( + 'vat-dropdown', + ) as HTMLSelectElement; + const option = document.createElement('option'); + option.value = 'v0'; + option.text = 'v0'; + vatDropdown.appendChild(option); +} diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index cee44567b..c0e442a09 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -41,6 +41,7 @@ export default defineConfig(({ mode }) => ({ 'kernel-worker': path.resolve(sourceDir, 'kernel/kernel-worker.ts'), offscreen: path.resolve(sourceDir, 'offscreen.html'), iframe: path.resolve(sourceDir, 'iframe.html'), + popup: path.resolve(sourceDir, 'popup.html'), }, output: { entryFileNames: '[name].js', diff --git a/packages/kernel/src/index.test.ts b/packages/kernel/src/index.test.ts index 543c6417f..a2e8955b0 100644 --- a/packages/kernel/src/index.test.ts +++ b/packages/kernel/src/index.test.ts @@ -8,14 +8,17 @@ describe('index', () => { expect(Object.keys(indexModule).sort()).toStrictEqual([ 'Kernel', 'KernelCommandMethod', + 'KernelSendMessageStruct', 'Supervisor', 'Vat', 'VatCommandMethod', + 'VatIdStruct', 'VatWorkerServiceCommandMethod', 'isKernelCommand', 'isKernelCommandReply', 'isVatCommand', 'isVatCommandReply', + 'isVatId', 'isVatWorkerServiceCommand', 'isVatWorkerServiceCommandReply', ]); diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index 6d4566618..8402a0ec6 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -4,3 +4,4 @@ export type { KVStore } from './kernel-store.js'; export { Vat } from './Vat.js'; export { Supervisor } from './Supervisor.js'; export type { VatId, VatWorkerService } from './types.js'; +export { isVatId, VatIdStruct } from './types.js'; diff --git a/packages/kernel/src/messages/index.ts b/packages/kernel/src/messages/index.ts index 7cb1fb05b..777e02d92 100644 --- a/packages/kernel/src/messages/index.ts +++ b/packages/kernel/src/messages/index.ts @@ -4,6 +4,7 @@ export { KernelCommandMethod, isKernelCommand, isKernelCommandReply, + KernelSendMessageStruct, } from './kernel.js'; export type { CapTpPayload, diff --git a/packages/kernel/src/messages/kernel.ts b/packages/kernel/src/messages/kernel.ts index 6edba84b5..221e7117a 100644 --- a/packages/kernel/src/messages/kernel.ts +++ b/packages/kernel/src/messages/kernel.ts @@ -11,10 +11,12 @@ import { UnsafeJsonStruct } from '@metamask/utils'; import type { TypeGuard } from '@ocap/utils'; import { + VatMethodStructs, VatTestCommandMethod, VatTestMethodStructs, VatTestReplyStructs, } from './vat.js'; +import { VatIdStruct } from '../types.js'; export const KernelCommandMethod = { evaluate: VatTestCommandMethod.evaluate, @@ -75,3 +77,12 @@ export const isKernelCommand: TypeGuard = ( export const isKernelCommandReply: TypeGuard = ( value: unknown, ): value is KernelCommandReply => is(value, KernelCommandReplyStruct); + +export const KernelSendMessageStruct = object({ + id: VatIdStruct, + payload: union([ + VatMethodStructs.evaluate, + VatMethodStructs.ping, + VatMethodStructs.capTpInit, + ]), +}); diff --git a/packages/kernel/src/messages/vat.ts b/packages/kernel/src/messages/vat.ts index d6f704866..619f8ae55 100644 --- a/packages/kernel/src/messages/vat.ts +++ b/packages/kernel/src/messages/vat.ts @@ -41,7 +41,7 @@ export const VatTestMethodStructs = { }), } as const; -const VatMethodStructs = { +export const VatMethodStructs = { ...VatTestMethodStructs, [VatCommandMethod.capTpInit]: object({ method: literal(VatCommandMethod.capTpInit), diff --git a/packages/streams/src/ChromeRuntimeStream.ts b/packages/streams/src/ChromeRuntimeStream.ts index e6665fee4..c4c946be1 100644 --- a/packages/streams/src/ChromeRuntimeStream.ts +++ b/packages/streams/src/ChromeRuntimeStream.ts @@ -35,7 +35,7 @@ import type { Dispatchable } from './utils.js'; export enum ChromeRuntimeStreamTarget { Background = 'background', Offscreen = 'offscreen', - Devtools = 'devtools', + Popup = 'popup', } export type MessageEnvelope = { diff --git a/vitest.config.ts b/vitest.config.ts index ed31b3ca2..25ce51e65 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -40,16 +40,16 @@ export default defineConfig({ lines: 100, }, 'packages/extension/**': { - statements: 35.08, - functions: 22.03, - branches: 50.9, - lines: 35.26, + statements: 58.05, + functions: 45.63, + branches: 75.89, + lines: 58.13, }, 'packages/kernel/**': { - statements: 83.92, + statements: 83.97, functions: 90, branches: 69.66, - lines: 83.92, + lines: 83.97, }, 'packages/shims/**': { statements: 0, diff --git a/yarn.lock b/yarn.lock index adf8165dc..2ee430d58 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1580,6 +1580,7 @@ __metadata: "@metamask/eslint-config-nodejs": "npm:^14.0.0" "@metamask/eslint-config-typescript": "npm:^14.0.0" "@metamask/snaps-utils": "npm:^8.3.0" + "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^9.3.0" "@ocap/errors": "workspace:^" "@ocap/kernel": "workspace:^"