From 0fdec871134d768ca083f86008728fcce57e3df2 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 29 Oct 2024 21:52:48 +0100 Subject: [PATCH 01/39] Add basic devtools panel and kernel manager --- packages/extension/src/BaseKernelManager.ts | 61 +++++++++++++++++++ .../src/devtools/ChromeKernelManager.ts | 9 +++ packages/extension/src/devtools/devtools.html | 7 +++ packages/extension/src/devtools/devtools.ts | 8 +++ packages/extension/src/devtools/panel.html | 61 +++++++++++++++++++ packages/extension/src/devtools/panel.ts | 56 +++++++++++++++++ packages/extension/src/manifest.json | 3 +- packages/extension/src/offscreen.ts | 2 +- packages/extension/vite.config.ts | 2 + packages/kernel/tsconfig.json | 8 ++- 10 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 packages/extension/src/BaseKernelManager.ts create mode 100644 packages/extension/src/devtools/ChromeKernelManager.ts create mode 100644 packages/extension/src/devtools/devtools.html create mode 100644 packages/extension/src/devtools/devtools.ts create mode 100644 packages/extension/src/devtools/panel.html create mode 100644 packages/extension/src/devtools/panel.ts diff --git a/packages/extension/src/BaseKernelManager.ts b/packages/extension/src/BaseKernelManager.ts new file mode 100644 index 000000000..f6c713301 --- /dev/null +++ b/packages/extension/src/BaseKernelManager.ts @@ -0,0 +1,61 @@ +import type { Kernel, VatId } from '@ocap/kernel'; + +export class BaseKernelManager { + protected kernel: Kernel | undefined; + + async initKernel(): Promise { + if (this.kernel) { + throw new Error('Kernel already initialized'); + } + // Implementation will be provided by platform-specific managers + } + + async shutdownKernel(): Promise { + if (!this.kernel) { + throw new Error('Kernel not initialized'); + } + await this.terminateAllVats(); + this.kernel = undefined; + } + + async getKernelStatus(): Promise<{ + isRunning: boolean; + activeVats: VatId[]; + }> { + return { + isRunning: Boolean(this.kernel), + activeVats: this.kernel?.getVatIds() ?? [], + }; + } + + async launchVat(id: VatId): Promise { + if (!this.kernel) { + throw new Error('Kernel not initialized'); + } + await this.kernel.launchVat({ id }); + } + + async restartVat(id: VatId): Promise { + if (!this.kernel) { + throw new Error('Kernel not initialized'); + } + await this.terminateVat(id); + await this.launchVat(id); + } + + async terminateVat(id: VatId): Promise { + if (!this.kernel) { + throw new Error('Kernel not initialized'); + } + await this.kernel.deleteVat(id); + } + + async terminateAllVats(): Promise { + if (!this.kernel) { + throw new Error('Kernel not initialized'); + } + await Promise.all( + this.kernel.getVatIds().map(async (id) => this.terminateVat(id)), + ); + } +} diff --git a/packages/extension/src/devtools/ChromeKernelManager.ts b/packages/extension/src/devtools/ChromeKernelManager.ts new file mode 100644 index 000000000..9bcca2823 --- /dev/null +++ b/packages/extension/src/devtools/ChromeKernelManager.ts @@ -0,0 +1,9 @@ +import { BaseKernelManager } from '../BaseKernelManager.js'; + +export class ChromeKernelManager extends BaseKernelManager { + async initKernel(): Promise { + await super.initKernel(); + + // TODO: Initialize kernel with Chrome-specific setup + } +} diff --git a/packages/extension/src/devtools/devtools.html b/packages/extension/src/devtools/devtools.html new file mode 100644 index 000000000..fa08beb6f --- /dev/null +++ b/packages/extension/src/devtools/devtools.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/extension/src/devtools/devtools.ts b/packages/extension/src/devtools/devtools.ts new file mode 100644 index 000000000..c35e58a12 --- /dev/null +++ b/packages/extension/src/devtools/devtools.ts @@ -0,0 +1,8 @@ +chrome.devtools.panels.create( + 'Kernel', + 'icon.png', + 'devtools/panel.html', + () => { + console.log('Kernel DevTools panel created'); + }, +); diff --git a/packages/extension/src/devtools/panel.html b/packages/extension/src/devtools/panel.html new file mode 100644 index 000000000..d458d0acd --- /dev/null +++ b/packages/extension/src/devtools/panel.html @@ -0,0 +1,61 @@ + + + + + Kernel DevTools + + + + +
+
+
+ + +
+ +
+ + + + + +
+ +
+

Kernel Status

+

+        
+
+
+ + diff --git a/packages/extension/src/devtools/panel.ts b/packages/extension/src/devtools/panel.ts new file mode 100644 index 000000000..b6c6b7e72 --- /dev/null +++ b/packages/extension/src/devtools/panel.ts @@ -0,0 +1,56 @@ +import type { VatId } from '@ocap/kernel'; + +import { ChromeKernelManager } from './ChromeKernelManager.js'; + +const kernelManager = new ChromeKernelManager(); + +const getVatId = (): VatId => + ((document.getElementById('vat-id') as HTMLInputElement).value || + 'v0') as VatId; + +const attachEventListeners = (): void => { + document.getElementById('init-kernel')?.addEventListener('click', () => { + kernelManager.initKernel().catch(console.error); + }); + + document.getElementById('shutdown-kernel')?.addEventListener('click', () => { + kernelManager.shutdownKernel().catch(console.error); + }); + + document.getElementById('launch-vat')?.addEventListener('click', () => { + kernelManager.launchVat(getVatId()).catch(console.error); + }); + + document.getElementById('restart-vat')?.addEventListener('click', () => { + kernelManager.restartVat(getVatId()).catch(console.error); + }); + + document.getElementById('terminate-vat')?.addEventListener('click', () => { + kernelManager.terminateVat(getVatId()).catch(console.error); + }); + + document.getElementById('terminate-all')?.addEventListener('click', () => { + kernelManager.terminateAllVats().catch(console.error); + }); +}; + +const updateStatus = async (): Promise => { + const statusDisplay = document.getElementById('status-display'); + if (!statusDisplay) { + return; + } + + const status = await kernelManager.getKernelStatus(); + statusDisplay.textContent = JSON.stringify(status, null, 2); + + // Update every second + setTimeout(() => { + updateStatus().catch(console.error); + }, 1000); +}; + +// Initialize panel when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + attachEventListeners(); + updateStatus().catch(console.error); +}); diff --git a/packages/extension/src/manifest.json b/packages/extension/src/manifest.json index e0da97b7e..2a9149461 100644 --- a/packages/extension/src/manifest.json +++ b/packages/extension/src/manifest.json @@ -8,7 +8,8 @@ "type": "module" }, "action": {}, - "permissions": ["offscreen", "unlimitedStorage"], + "permissions": ["offscreen", "unlimitedStorage", "devtools"], + "devtools_page": "devtools/devtools.html", "sandbox": { "pages": ["iframe.html"] }, diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index cb0f7e1ac..1e6c45723 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -65,7 +65,7 @@ async function main(): Promise { sendMessage: (message: KernelCommand) => Promise; receiveMessages: () => Promise; }> { - const worker = new Worker('kernel-worker.js', { type: 'module' }); + const worker = new Worker('kernel/kernel-worker.js', { type: 'module' }); const workerStream = await initializeMessageChannel((message, transfer) => worker.postMessage(message, transfer), diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index cee44567b..142ba60c9 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -41,6 +41,8 @@ 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'), + 'devtools/devtools': path.resolve(sourceDir, 'devtools/devtools.html'), + 'devtools/panel': path.resolve(sourceDir, 'devtools/panel.html'), }, output: { entryFileNames: '[name].js', diff --git a/packages/kernel/tsconfig.json b/packages/kernel/tsconfig.json index 3b0ea663d..cad37494b 100644 --- a/packages/kernel/tsconfig.json +++ b/packages/kernel/tsconfig.json @@ -8,5 +8,11 @@ { "path": "../streams" }, { "path": "../utils" } ], - "include": ["./src", "./test", "./vite.config.ts", "./vitest.config.ts"] + "include": [ + "./src", + "./test", + "./vite.config.ts", + "./vitest.config.ts", + "../extension/src/BaseKernelManager.ts" + ] } From 8dee5ed9f62ed851d1c7592babbc5e23bc29df2c Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 31 Oct 2024 16:42:31 +0100 Subject: [PATCH 02/39] connecting devtools offscreen and worker --- packages/extension/src/BaseKernelManager.ts | 61 -------- .../src/devtools/ChromeKernelManager.ts | 9 -- packages/extension/src/devtools/panel.ts | 101 ++++++++----- .../extension/src/kernel/kernel-worker.ts | 135 +++++++++++++++--- packages/extension/src/kernel/messages.ts | 55 +++++++ .../extension/src/kernel/stream-envelope.ts | 55 +++++++ packages/extension/src/offscreen.ts | 59 +++++++- packages/kernel/src/index.ts | 1 + packages/kernel/tsconfig.json | 2 +- 9 files changed, 350 insertions(+), 128 deletions(-) delete mode 100644 packages/extension/src/BaseKernelManager.ts delete mode 100644 packages/extension/src/devtools/ChromeKernelManager.ts create mode 100644 packages/extension/src/kernel/messages.ts create mode 100644 packages/extension/src/kernel/stream-envelope.ts diff --git a/packages/extension/src/BaseKernelManager.ts b/packages/extension/src/BaseKernelManager.ts deleted file mode 100644 index f6c713301..000000000 --- a/packages/extension/src/BaseKernelManager.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { Kernel, VatId } from '@ocap/kernel'; - -export class BaseKernelManager { - protected kernel: Kernel | undefined; - - async initKernel(): Promise { - if (this.kernel) { - throw new Error('Kernel already initialized'); - } - // Implementation will be provided by platform-specific managers - } - - async shutdownKernel(): Promise { - if (!this.kernel) { - throw new Error('Kernel not initialized'); - } - await this.terminateAllVats(); - this.kernel = undefined; - } - - async getKernelStatus(): Promise<{ - isRunning: boolean; - activeVats: VatId[]; - }> { - return { - isRunning: Boolean(this.kernel), - activeVats: this.kernel?.getVatIds() ?? [], - }; - } - - async launchVat(id: VatId): Promise { - if (!this.kernel) { - throw new Error('Kernel not initialized'); - } - await this.kernel.launchVat({ id }); - } - - async restartVat(id: VatId): Promise { - if (!this.kernel) { - throw new Error('Kernel not initialized'); - } - await this.terminateVat(id); - await this.launchVat(id); - } - - async terminateVat(id: VatId): Promise { - if (!this.kernel) { - throw new Error('Kernel not initialized'); - } - await this.kernel.deleteVat(id); - } - - async terminateAllVats(): Promise { - if (!this.kernel) { - throw new Error('Kernel not initialized'); - } - await Promise.all( - this.kernel.getVatIds().map(async (id) => this.terminateVat(id)), - ); - } -} diff --git a/packages/extension/src/devtools/ChromeKernelManager.ts b/packages/extension/src/devtools/ChromeKernelManager.ts deleted file mode 100644 index 9bcca2823..000000000 --- a/packages/extension/src/devtools/ChromeKernelManager.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BaseKernelManager } from '../BaseKernelManager.js'; - -export class ChromeKernelManager extends BaseKernelManager { - async initKernel(): Promise { - await super.initKernel(); - - // TODO: Initialize kernel with Chrome-specific setup - } -} diff --git a/packages/extension/src/devtools/panel.ts b/packages/extension/src/devtools/panel.ts index b6c6b7e72..dfee0ae13 100644 --- a/packages/extension/src/devtools/panel.ts +++ b/packages/extension/src/devtools/panel.ts @@ -1,56 +1,89 @@ +import type { Json } from '@metamask/utils'; import type { VatId } from '@ocap/kernel'; +import { ChromeRuntimeDuplexStream, ChromeRuntimeTarget } from '@ocap/streams'; -import { ChromeKernelManager } from './ChromeKernelManager.js'; +import type { KernelStatus } from '../kernel/messages'; -const kernelManager = new ChromeKernelManager(); +// Initialize and start the UI +main().catch(console.error); -const getVatId = (): VatId => - ((document.getElementById('vat-id') as HTMLInputElement).value || - 'v0') as VatId; +/** + * The main function for the devtools panel. + */ +async function main(): Promise { + const stream = await ChromeRuntimeDuplexStream.make( + chrome.runtime, + ChromeRuntimeTarget.Devtools, + ChromeRuntimeTarget.Offscreen, + ); + + const sendMessage = async (message: Json): Promise => { + await stream.write(message); + }; + + const getVatId = (): VatId => + (document.getElementById('vat-id') as HTMLInputElement).value as VatId; -const attachEventListeners = (): void => { document.getElementById('init-kernel')?.addEventListener('click', () => { - kernelManager.initKernel().catch(console.error); + sendMessage({ + method: 'initKernel', + }).catch(console.error); }); document.getElementById('shutdown-kernel')?.addEventListener('click', () => { - kernelManager.shutdownKernel().catch(console.error); + sendMessage({ + method: 'shutdownKernel', + }).catch(console.error); }); document.getElementById('launch-vat')?.addEventListener('click', () => { - kernelManager.launchVat(getVatId()).catch(console.error); + sendMessage({ + method: 'launchVat', + params: { id: getVatId() }, + }).catch(console.error); }); document.getElementById('restart-vat')?.addEventListener('click', () => { - kernelManager.restartVat(getVatId()).catch(console.error); + sendMessage({ + method: 'restartVat', + params: { id: getVatId() }, + }).catch(console.error); }); document.getElementById('terminate-vat')?.addEventListener('click', () => { - kernelManager.terminateVat(getVatId()).catch(console.error); + sendMessage({ + method: 'terminateVat', + params: { id: getVatId() }, + }).catch(console.error); }); document.getElementById('terminate-all')?.addEventListener('click', () => { - kernelManager.terminateAllVats().catch(console.error); + sendMessage({ + method: 'terminateAllVats', + }).catch(console.error); }); -}; - -const updateStatus = async (): Promise => { - const statusDisplay = document.getElementById('status-display'); - if (!statusDisplay) { - return; - } - - const status = await kernelManager.getKernelStatus(); - statusDisplay.textContent = JSON.stringify(status, null, 2); - - // Update every second - setTimeout(() => { - updateStatus().catch(console.error); - }, 1000); -}; - -// Initialize panel when DOM is ready -document.addEventListener('DOMContentLoaded', () => { - attachEventListeners(); - updateStatus().catch(console.error); -}); + + /** + * Update the status display. + */ + const updateStatus = async (): Promise => { + const statusDisplay = document.getElementById('status-display'); + if (!statusDisplay) { + return; + } + + const { status } = (await stream.write({ + method: 'getStatus', + })) as unknown as { status: KernelStatus }; + + // Write the status to the display + statusDisplay.textContent = JSON.stringify(status, null, 2); + + // Update every second + setTimeout(() => { + updateStatus().catch(console.error); + }, 1000); + }; + + await updateStatus(); +} diff --git a/packages/extension/src/kernel/kernel-worker.ts b/packages/extension/src/kernel/kernel-worker.ts index f5c99ebc9..30f554884 100644 --- a/packages/extension/src/kernel/kernel-worker.ts +++ b/packages/extension/src/kernel/kernel-worker.ts @@ -2,48 +2,151 @@ 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 { DuplexStream } from '@ocap/streams'; +import { makeLogger, stringify } from '@ocap/utils'; +import type { KernelControlCommand, KernelControlReply } from './messages.js'; import { makeSQLKVStore } from './sqlite-kv-store.js'; +import type { StreamEnvelope, StreamEnvelopeReply } from './stream-envelope.js'; +import { + makeStreamEnvelopeHandler, + wrapKernelReply, + wrapControlReply, + EnvelopeLabel, +} from './stream-envelope.js'; import { ExtensionVatWorkerClient } from './VatWorkerClient.js'; -main().catch(console.error); +const logger = makeLogger('[kernel worker]'); + +main().catch((error) => logger.error('Kernel worker error:', error)); /** - * The main function for the kernel worker. + * */ async function main(): Promise { - const kernelStream = await receiveMessagePort( + // Initialize stream + const stream = await receiveMessagePort( (listener) => globalThis.addEventListener('message', listener), (listener) => globalThis.removeEventListener('message', listener), ).then(async (port) => - MessagePortDuplexStream.make(port), + MessagePortDuplexStream.make(port), ); + // 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 wrapped stream for kernel that only sees KernelCommand/Reply + const kernelStream: DuplexStream = { + write: async (message) => { + await stream.write(wrapKernelReply(message)); + return { done: true, value: undefined }; + }, + async *[Symbol.asyncIterator]() { + for await (const envelope of stream) { + if (envelope.label === EnvelopeLabel.Kernel) { + yield envelope.content; + } + } + }, + }; + + // 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']); + // Create stream envelope handler for all messages + const streamEnvelopeHandler = makeStreamEnvelopeHandler( + { + // Kernel messages are already handled by kernelStream + kernel: async () => undefined, + // Handle control messages + control: async (message) => { + try { + const reply = await handleControlMessage(kernel, message); + await stream.write(wrapControlReply(reply)); + } catch (error) { + logger.error('Error handling control message', { error, message }); + } + }, + }, + (error) => logger.error('Stream envelope handler error:', error), + ); - // Add default vat. + // Run default kernel lifecycle + await runVatLifecycle(kernel, ['v1', 'v2', 'v3']); await kernel.launchVat({ id: 'v0' }); + + // Handle messages using stream envelope handler + await stream + .drain(async (value) => { + await streamEnvelopeHandler.handle(value); + }) + .catch((error) => { + logger.error('Unexpected read error from kernel worker', error); + throw error; + }); +} + +/** + * Handle a control message and return the appropriate reply. + * + * @param kernel - The kernel instance. + * @param message - The control message to handle. + * @returns The reply to the control message. + */ +async function handleControlMessage( + kernel: Kernel, + message: KernelControlCommand, +): Promise { + switch (message.method) { + case 'initKernel': + await kernel.init(); + return { method: 'initKernel', params: null }; + + case 'shutdownKernel': + // TODO: Implement proper shutdown sequence + return { method: 'shutdownKernel', params: null }; + + case 'launchVat': + await kernel.launchVat({ id: message.params.id }); + return { method: 'launchVat', params: null }; + + case 'restartVat': + await kernel.restartVat(message.params.id); + return { method: 'restartVat', params: null }; + + case 'terminateVat': + await kernel.terminateVat(message.params.id); + return { method: 'terminateVat', params: null }; + + case 'terminateAllVats': + await kernel.terminateAllVats(); + return { method: 'terminateAllVats', params: null }; + + case 'getStatus': + return { + method: 'getStatus', + params: { + isRunning: true, // TODO: Track actual kernel state + activeVats: kernel.getVatIds(), + }, + }; + + default: + logger.error('Unknown control message method', message); + throw new Error(`Unknown control message: ${stringify(message)}`); + } } /** - * Runs the full lifecycle of an array of vats, including their creation, - * restart, message passing, and termination. + * Runs the full lifecycle of an array of vats * - * @param kernel The kernel instance. - * @param vats An array of VatIds to be managed. + * @param kernel - The kernel instance. + * @param vats - The vats to run the lifecycle for. */ async function runVatLifecycle( kernel: Kernel, @@ -53,7 +156,7 @@ async function runVatLifecycle( await Promise.all(vats.map(async (id) => kernel.launchVat({ id }))); console.timeEnd(`Created vats: ${vats.join(', ')}`); - console.log('Kernel vats:', kernel.getVatIds().join(', ')); + logger.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; @@ -75,5 +178,5 @@ async function runVatLifecycle( await kernel.terminateAllVats(); console.timeEnd(`Terminated vats: ${vatIds}`); - console.log(`Kernel has ${kernel.getVatIds().length} vats`); + logger.log(`Kernel has ${kernel.getVatIds().length} vats`); } diff --git a/packages/extension/src/kernel/messages.ts b/packages/extension/src/kernel/messages.ts new file mode 100644 index 000000000..32ed3b2ad --- /dev/null +++ b/packages/extension/src/kernel/messages.ts @@ -0,0 +1,55 @@ +import { isObject } from '@metamask/utils'; +import type { VatId } from '@ocap/kernel'; +import { makeMessageKit, messageType, isVatId } from '@ocap/kernel'; +import type { TypeGuard } from '@ocap/utils'; + +export type KernelStatus = { + isRunning: boolean; + activeVats: VatId[]; +}; + +const kernelControlCommand = { + InitKernel: messageType( + (send) => send === null, + (reply) => reply === null, + ), + ShutdownKernel: messageType( + (send) => send === null, + (reply) => reply === null, + ), + LaunchVat: messageType<{ id: VatId }, null>( + (send) => isObject(send) && isVatId(send.id), + (reply) => reply === null, + ), + RestartVat: messageType<{ id: VatId }, null>( + (send) => isObject(send) && isVatId(send.id), + (reply) => reply === null, + ), + TerminateVat: messageType<{ id: VatId }, null>( + (send) => isObject(send) && isVatId(send.id), + (reply) => reply === null, + ), + TerminateAllVats: messageType( + (send) => send === null, + (reply) => reply === null, + ), + GetStatus: messageType( + (send) => send === null, + (reply) => + isObject(reply) && + typeof reply.isRunning === 'boolean' && + Array.isArray(reply.activeVats) && + reply.activeVats.every((id) => isVatId(id)), + ), +}; + +const kernelControlKit = makeMessageKit(kernelControlCommand); + +export const isKernelControlCommand: TypeGuard = + kernelControlKit.sendGuard; + +export const isKernelControlReply: TypeGuard = + kernelControlKit.replyGuard; + +export type KernelControlCommand = typeof kernelControlKit.send; +export type KernelControlReply = typeof kernelControlKit.reply; diff --git a/packages/extension/src/kernel/stream-envelope.ts b/packages/extension/src/kernel/stream-envelope.ts new file mode 100644 index 000000000..d57287996 --- /dev/null +++ b/packages/extension/src/kernel/stream-envelope.ts @@ -0,0 +1,55 @@ +import type { KernelCommand, KernelCommandReply } from '@ocap/kernel'; +import { isKernelCommand, isKernelCommandReply } from '@ocap/kernel'; +import { makeStreamEnvelopeKit } from '@ocap/streams'; +import type { ExtractGuardType } from '@ocap/utils'; + +import type { KernelControlCommand, KernelControlReply } from './messages.js'; +import { isKernelControlCommand, isKernelControlReply } from './messages.js'; + +export enum EnvelopeLabel { + Kernel = 'kernel', + Control = 'control', +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const envelopeLabels = Object.values(EnvelopeLabel); + +// Envelope kit for initial sends +const envelopeKit = makeStreamEnvelopeKit< + typeof envelopeLabels, + { + kernel: KernelCommand; + control: KernelControlCommand; + } +>({ + kernel: isKernelCommand, + control: isKernelControlCommand, +}); + +// Envelope kit for replies +const envelopeReplyKit = makeStreamEnvelopeKit< + typeof envelopeLabels, + { + kernel: KernelCommandReply; + control: KernelControlReply; + } +>({ + kernel: isKernelCommandReply, + control: isKernelControlReply, +}); + +export type StreamEnvelope = ExtractGuardType< + typeof envelopeKit.isStreamEnvelope +>; +export type StreamEnvelopeReply = ExtractGuardType< + typeof envelopeReplyKit.isStreamEnvelope +>; + +export const wrapKernelCommand = envelopeKit.streamEnveloper.kernel.wrap; +export const wrapControlCommand = envelopeKit.streamEnveloper.control.wrap; +export const wrapKernelReply = envelopeReplyKit.streamEnveloper.kernel.wrap; +export const wrapControlReply = envelopeReplyKit.streamEnveloper.control.wrap; + +export const { makeStreamEnvelopeHandler } = envelopeKit; +export const { makeStreamEnvelopeHandler: makeStreamEnvelopeReplyHandler } = + envelopeReplyKit; diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 1e6c45723..07fb2a261 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -9,6 +9,14 @@ import { import { makeLogger } from '@ocap/utils'; import { makeIframeVatWorker } from './kernel/iframe-vat-worker.js'; +import { + isKernelControlCommand, + isKernelControlReply, +} from './kernel/messages.js'; +import type { + KernelControlCommand, + KernelControlReply, +} from './kernel/messages.js'; import { ExtensionVatWorkerServer } from './kernel/VatWorkerServer.js'; const logger = makeLogger('[ocap glue]'); @@ -28,6 +36,12 @@ async function main(): Promise { ChromeRuntimeTarget.Background, ); + const devtoolsStream = await ChromeRuntimeDuplexStream.make( + chrome.runtime, + ChromeRuntimeTarget.Offscreen, + ChromeRuntimeTarget.Devtools, + ); + const kernelWorker = await makeKernelWorker(); /** @@ -41,6 +55,17 @@ async function main(): Promise { await backgroundStream.write(commandReply); }; + /** + * Reply to a command from the devtools page. + * + * @param commandReply - The reply to send. + */ + const replyToDevtools = async ( + commandReply: KernelControlReply, + ): Promise => { + await devtoolsStream.write(commandReply); + }; + // Handle messages from the background service worker and the kernel SQLite worker. await Promise.all([ kernelWorker.receiveMessages(), @@ -51,6 +76,16 @@ async function main(): Promise { continue; } + await kernelWorker.sendMessage(message); + } + })(), + (async () => { + for await (const message of devtoolsStream) { + if (!isKernelControlCommand(message)) { + logger.error('Offscreen received unexpected message', message); + continue; + } + await kernelWorker.sendMessage(message); } })(), @@ -62,7 +97,9 @@ async function main(): Promise { * @returns An object with methods to send and receive messages from the kernel worker. */ async function makeKernelWorker(): Promise<{ - sendMessage: (message: KernelCommand) => Promise; + sendMessage: ( + message: KernelCommand | KernelControlCommand, + ) => Promise; receiveMessages: () => Promise; }> { const worker = new Worker('kernel/kernel-worker.js', { type: 'module' }); @@ -70,7 +107,10 @@ async function main(): Promise { const workerStream = await initializeMessageChannel((message, transfer) => worker.postMessage(message, transfer), ).then(async (port) => - MessagePortDuplexStream.make(port), + MessagePortDuplexStream.make< + KernelCommandReply | KernelControlReply, + KernelCommand | KernelControlCommand + >(port), ); const vatWorkerServer = new ExtensionVatWorkerServer( @@ -90,16 +130,21 @@ async function main(): Promise { // 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; + if (isKernelCommandReply(message)) { + await replyToBackground(message); + return; + } else if (isKernelControlReply(message)) { + await replyToDevtools(message); + return; } - await replyToBackground(message); + logger.error('Kernel sent unexpected reply', message); } }; - const sendMessage = async (message: KernelCommand): Promise => { + const sendMessage = async ( + message: KernelCommand | KernelControlCommand, + ): Promise => { await workerStream.write(message); }; diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index 6d4566618..d2c7e4522 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 } from './types.js'; diff --git a/packages/kernel/tsconfig.json b/packages/kernel/tsconfig.json index cad37494b..ab1b2c19c 100644 --- a/packages/kernel/tsconfig.json +++ b/packages/kernel/tsconfig.json @@ -13,6 +13,6 @@ "./test", "./vite.config.ts", "./vitest.config.ts", - "../extension/src/BaseKernelManager.ts" + "../extension/src/kernel/BaseKernelManager.ts" ] } From 9eedd1422ea5c601e939229af487958268af35cb Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 31 Oct 2024 16:45:39 +0100 Subject: [PATCH 03/39] fix type --- packages/extension/src/devtools/panel.ts | 2 +- packages/extension/src/kernel/kernel-worker.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/devtools/panel.ts b/packages/extension/src/devtools/panel.ts index dfee0ae13..131b4d5ba 100644 --- a/packages/extension/src/devtools/panel.ts +++ b/packages/extension/src/devtools/panel.ts @@ -2,7 +2,7 @@ import type { Json } from '@metamask/utils'; import type { VatId } from '@ocap/kernel'; import { ChromeRuntimeDuplexStream, ChromeRuntimeTarget } from '@ocap/streams'; -import type { KernelStatus } from '../kernel/messages'; +import type { KernelStatus } from '../kernel/messages.js'; // Initialize and start the UI main().catch(console.error); diff --git a/packages/extension/src/kernel/kernel-worker.ts b/packages/extension/src/kernel/kernel-worker.ts index 30f554884..4d5436b50 100644 --- a/packages/extension/src/kernel/kernel-worker.ts +++ b/packages/extension/src/kernel/kernel-worker.ts @@ -52,7 +52,7 @@ async function main(): Promise { } } }, - }; + } as DuplexStream; // Create and initialize kernel const kernel = new Kernel(kernelStream, vatWorkerClient, kvStore); From d0e4f66647af7a6974cf99a673eeb1133e1245f0 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 31 Oct 2024 16:48:22 +0100 Subject: [PATCH 04/39] clean --- packages/kernel/tsconfig.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/kernel/tsconfig.json b/packages/kernel/tsconfig.json index ab1b2c19c..3b0ea663d 100644 --- a/packages/kernel/tsconfig.json +++ b/packages/kernel/tsconfig.json @@ -8,11 +8,5 @@ { "path": "../streams" }, { "path": "../utils" } ], - "include": [ - "./src", - "./test", - "./vite.config.ts", - "./vitest.config.ts", - "../extension/src/kernel/BaseKernelManager.ts" - ] + "include": ["./src", "./test", "./vite.config.ts", "./vitest.config.ts"] } From 7e6b20d7cfe95d472531b99f837df998d514e99e Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Mon, 4 Nov 2024 19:01:57 +0100 Subject: [PATCH 05/39] testing streams --- packages/extension/src/devtools/devtools.ts | 9 +- packages/extension/src/devtools/panel.html | 8 +- packages/extension/src/devtools/panel.ts | 72 ++++++--- .../extension/src/kernel/kernel-worker.ts | 145 ++++++------------ packages/extension/src/manifest.json | 4 +- packages/extension/src/offscreen.ts | 84 ++++------ packages/streams/src/ChromeRuntimeStream.ts | 13 ++ 7 files changed, 148 insertions(+), 187 deletions(-) diff --git a/packages/extension/src/devtools/devtools.ts b/packages/extension/src/devtools/devtools.ts index c35e58a12..20aa9f462 100644 --- a/packages/extension/src/devtools/devtools.ts +++ b/packages/extension/src/devtools/devtools.ts @@ -1,8 +1 @@ -chrome.devtools.panels.create( - 'Kernel', - 'icon.png', - 'devtools/panel.html', - () => { - console.log('Kernel DevTools panel created'); - }, -); +chrome.devtools.panels.create('🤖 Ocap Kernel', '', 'devtools/panel.html'); diff --git a/packages/extension/src/devtools/panel.html b/packages/extension/src/devtools/panel.html index d458d0acd..a441aba1d 100644 --- a/packages/extension/src/devtools/panel.html +++ b/packages/extension/src/devtools/panel.html @@ -20,6 +20,12 @@ button { margin: 4px; padding: 8px 16px; + cursor: pointer; + } + + button:disabled { + opacity: 0.5; + cursor: not-allowed; } input { @@ -41,6 +47,7 @@
+
@@ -48,7 +55,6 @@ -
diff --git a/packages/extension/src/devtools/panel.ts b/packages/extension/src/devtools/panel.ts index 131b4d5ba..da94a4017 100644 --- a/packages/extension/src/devtools/panel.ts +++ b/packages/extension/src/devtools/panel.ts @@ -1,8 +1,25 @@ -import type { Json } from '@metamask/utils'; import type { VatId } from '@ocap/kernel'; import { ChromeRuntimeDuplexStream, ChromeRuntimeTarget } from '@ocap/streams'; -import type { KernelStatus } from '../kernel/messages.js'; +import type { KernelControlCommand } from '../kernel/messages.js'; + +// This will redirect logs to both the panel's console and the DevTools-for-DevTools console +const originalConsole = { ...console }; +Object.assign(console, { + log: (...args: unknown[]) => { + originalConsole.log('[Devtools Panel]', ...args); + // Also log to the DevTools-for-DevTools console + chrome.devtools.inspectedWindow.eval( + `console.log("[Devtools Panel]", ${JSON.stringify(args)})`, + ); + }, + error: (...args: unknown[]) => { + originalConsole.error('[Devtools Panel]', ...args); + chrome.devtools.inspectedWindow.eval( + `console.error("[Devtools Panel]", ${JSON.stringify(args)})`, + ); + }, +}); // Initialize and start the UI main().catch(console.error); @@ -11,14 +28,16 @@ main().catch(console.error); * The main function for the devtools panel. */ async function main(): Promise { - const stream = await ChromeRuntimeDuplexStream.make( + const offscreenStream = await ChromeRuntimeDuplexStream.make( chrome.runtime, ChromeRuntimeTarget.Devtools, ChromeRuntimeTarget.Offscreen, ); + console.log('devtools <-> offscreen stream created'); - const sendMessage = async (message: Json): Promise => { - await stream.write(message); + const sendMessage = async (message: KernelControlCommand): Promise => { + console.log('sending devtools message', message); + await offscreenStream.write(message); }; const getVatId = (): VatId => @@ -27,12 +46,14 @@ async function main(): Promise { document.getElementById('init-kernel')?.addEventListener('click', () => { sendMessage({ method: 'initKernel', + params: null, }).catch(console.error); }); document.getElementById('shutdown-kernel')?.addEventListener('click', () => { sendMessage({ method: 'shutdownKernel', + params: null, }).catch(console.error); }); @@ -60,30 +81,33 @@ async function main(): Promise { document.getElementById('terminate-all')?.addEventListener('click', () => { sendMessage({ method: 'terminateAllVats', + params: null, }).catch(console.error); }); /** * Update the status display. */ - const updateStatus = async (): Promise => { - const statusDisplay = document.getElementById('status-display'); - if (!statusDisplay) { - return; - } - - const { status } = (await stream.write({ - method: 'getStatus', - })) as unknown as { status: KernelStatus }; - - // Write the status to the display - statusDisplay.textContent = JSON.stringify(status, null, 2); - - // Update every second - setTimeout(() => { - updateStatus().catch(console.error); - }, 1000); - }; + // const updateStatus = async (): Promise => { + // const statusDisplay = document.getElementById('status-display'); + // if (!statusDisplay) { + // return; + // } + + // await stream.write({ method: 'getStatus' }); + + // // Write the status to the display + // statusDisplay.textContent = JSON.stringify(status, null, 2); + + // // Update every second + // setTimeout(() => { + // updateStatus().catch(console.error); + // }, 1000); + // }; + + // await updateStatus(); - await updateStatus(); + for await (const message of offscreenStream) { + console.log('received devtools message', message); + } } diff --git a/packages/extension/src/kernel/kernel-worker.ts b/packages/extension/src/kernel/kernel-worker.ts index 4d5436b50..02acb8756 100644 --- a/packages/extension/src/kernel/kernel-worker.ts +++ b/packages/extension/src/kernel/kernel-worker.ts @@ -2,18 +2,9 @@ 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 { DuplexStream } from '@ocap/streams'; -import { makeLogger, stringify } from '@ocap/utils'; +import { makeLogger } from '@ocap/utils'; -import type { KernelControlCommand, KernelControlReply } from './messages.js'; import { makeSQLKVStore } from './sqlite-kv-store.js'; -import type { StreamEnvelope, StreamEnvelopeReply } from './stream-envelope.js'; -import { - makeStreamEnvelopeHandler, - wrapKernelReply, - wrapControlReply, - EnvelopeLabel, -} from './stream-envelope.js'; import { ExtensionVatWorkerClient } from './VatWorkerClient.js'; const logger = makeLogger('[kernel worker]'); @@ -24,12 +15,11 @@ main().catch((error) => logger.error('Kernel worker error:', error)); * */ async function main(): Promise { - // Initialize stream - const stream = await receiveMessagePort( + const kernelStream = await receiveMessagePort( (listener) => globalThis.addEventListener('message', listener), (listener) => globalThis.removeEventListener('message', listener), ).then(async (port) => - MessagePortDuplexStream.make(port), + MessagePortDuplexStream.make(port), ); // Initialize kernel dependencies @@ -39,56 +29,13 @@ async function main(): Promise { ); const kvStore = await makeSQLKVStore(); - // Create wrapped stream for kernel that only sees KernelCommand/Reply - const kernelStream: DuplexStream = { - write: async (message) => { - await stream.write(wrapKernelReply(message)); - return { done: true, value: undefined }; - }, - async *[Symbol.asyncIterator]() { - for await (const envelope of stream) { - if (envelope.label === EnvelopeLabel.Kernel) { - yield envelope.content; - } - } - }, - } as DuplexStream; - // Create and initialize kernel const kernel = new Kernel(kernelStream, vatWorkerClient, kvStore); await kernel.init(); - // Create stream envelope handler for all messages - const streamEnvelopeHandler = makeStreamEnvelopeHandler( - { - // Kernel messages are already handled by kernelStream - kernel: async () => undefined, - // Handle control messages - control: async (message) => { - try { - const reply = await handleControlMessage(kernel, message); - await stream.write(wrapControlReply(reply)); - } catch (error) { - logger.error('Error handling control message', { error, message }); - } - }, - }, - (error) => logger.error('Stream envelope handler error:', error), - ); - // Run default kernel lifecycle await runVatLifecycle(kernel, ['v1', 'v2', 'v3']); await kernel.launchVat({ id: 'v0' }); - - // Handle messages using stream envelope handler - await stream - .drain(async (value) => { - await streamEnvelopeHandler.handle(value); - }) - .catch((error) => { - logger.error('Unexpected read error from kernel worker', error); - throw error; - }); } /** @@ -98,49 +45,49 @@ async function main(): Promise { * @param message - The control message to handle. * @returns The reply to the control message. */ -async function handleControlMessage( - kernel: Kernel, - message: KernelControlCommand, -): Promise { - switch (message.method) { - case 'initKernel': - await kernel.init(); - return { method: 'initKernel', params: null }; - - case 'shutdownKernel': - // TODO: Implement proper shutdown sequence - return { method: 'shutdownKernel', params: null }; - - case 'launchVat': - await kernel.launchVat({ id: message.params.id }); - return { method: 'launchVat', params: null }; - - case 'restartVat': - await kernel.restartVat(message.params.id); - return { method: 'restartVat', params: null }; - - case 'terminateVat': - await kernel.terminateVat(message.params.id); - return { method: 'terminateVat', params: null }; - - case 'terminateAllVats': - await kernel.terminateAllVats(); - return { method: 'terminateAllVats', params: null }; - - case 'getStatus': - return { - method: 'getStatus', - params: { - isRunning: true, // TODO: Track actual kernel state - activeVats: kernel.getVatIds(), - }, - }; - - default: - logger.error('Unknown control message method', message); - throw new Error(`Unknown control message: ${stringify(message)}`); - } -} +// async function handleControlMessage( +// kernel: Kernel, +// message: KernelControlCommand, +// ): Promise { +// switch (message.method) { +// case 'initKernel': +// await kernel.init(); +// return { method: 'initKernel', params: null }; + +// case 'shutdownKernel': +// // TODO: Implement proper shutdown sequence +// return { method: 'shutdownKernel', params: null }; + +// case 'launchVat': +// await kernel.launchVat({ id: message.params.id }); +// return { method: 'launchVat', params: null }; + +// case 'restartVat': +// await kernel.restartVat(message.params.id); +// return { method: 'restartVat', params: null }; + +// case 'terminateVat': +// await kernel.terminateVat(message.params.id); +// return { method: 'terminateVat', params: null }; + +// case 'terminateAllVats': +// await kernel.terminateAllVats(); +// return { method: 'terminateAllVats', params: null }; + +// case 'getStatus': +// return { +// method: 'getStatus', +// params: { +// isRunning: true, // TODO: Track actual kernel state +// activeVats: kernel.getVatIds(), +// }, +// }; + +// default: +// logger.error('Unknown control message method', message); +// throw new Error(`Unknown control message: ${stringify(message)}`); +// } +// } /** * Runs the full lifecycle of an array of vats diff --git a/packages/extension/src/manifest.json b/packages/extension/src/manifest.json index 2a9149461..e452b3a65 100644 --- a/packages/extension/src/manifest.json +++ b/packages/extension/src/manifest.json @@ -8,13 +8,13 @@ "type": "module" }, "action": {}, - "permissions": ["offscreen", "unlimitedStorage", "devtools"], + "permissions": ["offscreen", "unlimitedStorage"], "devtools_page": "devtools/devtools.html", "sandbox": { "pages": ["iframe.html"] }, "content_security_policy": { - "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'none'; frame-ancestors 'none';", + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'none'; frame-ancestors 'self' devtools://devtools;", "sandbox": "sandbox allow-scripts; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; default-src 'none'; connect-src *;" } } diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 07fb2a261..89e0191f0 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -9,14 +9,7 @@ import { import { makeLogger } from '@ocap/utils'; import { makeIframeVatWorker } from './kernel/iframe-vat-worker.js'; -import { - isKernelControlCommand, - isKernelControlReply, -} from './kernel/messages.js'; -import type { - KernelControlCommand, - KernelControlReply, -} from './kernel/messages.js'; +import type { KernelControlReply } from './kernel/messages.js'; import { ExtensionVatWorkerServer } from './kernel/VatWorkerServer.js'; const logger = makeLogger('[ocap glue]'); @@ -36,12 +29,6 @@ async function main(): Promise { ChromeRuntimeTarget.Background, ); - const devtoolsStream = await ChromeRuntimeDuplexStream.make( - chrome.runtime, - ChromeRuntimeTarget.Offscreen, - ChromeRuntimeTarget.Devtools, - ); - const kernelWorker = await makeKernelWorker(); /** @@ -55,17 +42,6 @@ async function main(): Promise { await backgroundStream.write(commandReply); }; - /** - * Reply to a command from the devtools page. - * - * @param commandReply - The reply to send. - */ - const replyToDevtools = async ( - commandReply: KernelControlReply, - ): Promise => { - await devtoolsStream.write(commandReply); - }; - // Handle messages from the background service worker and the kernel SQLite worker. await Promise.all([ kernelWorker.receiveMessages(), @@ -76,41 +52,37 @@ async function main(): Promise { continue; } - await kernelWorker.sendMessage(message); - } - })(), - (async () => { - for await (const message of devtoolsStream) { - if (!isKernelControlCommand(message)) { - logger.error('Offscreen received unexpected message', message); - continue; - } - await kernelWorker.sendMessage(message); } })(), ]); + // Handle messages from the devtools page + ChromeRuntimeDuplexStream.make( + chrome.runtime, + ChromeRuntimeTarget.Offscreen, + ChromeRuntimeTarget.Devtools, + ) + .then(async (stream) => stream.drain(handleDevtoolsStream)) + .catch((error) => + logger.error('Unexpected error from devtools stream', error), + ); + /** * 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 | KernelControlCommand, - ) => Promise; + sendMessage: (message: KernelCommand) => Promise; receiveMessages: () => Promise; }> { - const worker = new Worker('kernel/kernel-worker.js', { type: 'module' }); + const worker = new Worker('kernel-worker.js', { type: 'module' }); const workerStream = await initializeMessageChannel((message, transfer) => worker.postMessage(message, transfer), ).then(async (port) => - MessagePortDuplexStream.make< - KernelCommandReply | KernelControlReply, - KernelCommand | KernelControlCommand - >(port), + MessagePortDuplexStream.make(port), ); const vatWorkerServer = new ExtensionVatWorkerServer( @@ -130,21 +102,16 @@ async function main(): Promise { // 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)) { - await replyToBackground(message); - return; - } else if (isKernelControlReply(message)) { - await replyToDevtools(message); - return; + if (!isKernelCommandReply(message)) { + logger.error('Kernel sent unexpected reply', message); + continue; } - logger.error('Kernel sent unexpected reply', message); + await replyToBackground(message); } }; - const sendMessage = async ( - message: KernelCommand | KernelControlCommand, - ): Promise => { + const sendMessage = async (message: KernelCommand): Promise => { await workerStream.write(message); }; @@ -153,4 +120,15 @@ async function main(): Promise { receiveMessages, }; } + + /** + * Handle messages from the devtools page. + * + * @param value - The value to handle. + */ + async function handleDevtoolsStream( + value: KernelControlReply, + ): Promise { + console.log('Offscreen received devtools message', value); + } } diff --git a/packages/streams/src/ChromeRuntimeStream.ts b/packages/streams/src/ChromeRuntimeStream.ts index e6665fee4..a59699a80 100644 --- a/packages/streams/src/ChromeRuntimeStream.ts +++ b/packages/streams/src/ChromeRuntimeStream.ts @@ -119,10 +119,19 @@ export class ChromeRuntimeReader extends BaseReader { } if (message.target !== this.#target || message.source !== this.#source) { +<<<<<<< HEAD console.debug( `ChromeRuntimeReader received message with incorrect target or source: ${stringify(message)}`, `Expected target: ${this.#target}`, `Expected source: ${this.#source}`, +======= + console.log( + `ChromeRuntimeReader received unexpected target/source: ${stringify( + message, + )}`, + this.#target, + this.#source, +>>>>>>> 8d74ef6 (testing streams) ); return; } @@ -234,7 +243,11 @@ export class ChromeRuntimeDuplexStream< remoteTarget, validateInput, ); + console.log( + `ChromeRuntimeDuplexStream created for ${localTarget} <-> ${remoteTarget}`, + ); await stream.synchronize(); + console.log(`stream synchronized for ${localTarget} <-> ${remoteTarget}`); return stream; } } From efb1c36ae54cb2a77218b2cc54a4c7f02732ff91 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Mon, 4 Nov 2024 21:02:32 +0100 Subject: [PATCH 06/39] fix chrome duplex --- packages/extension/src/devtools/panel.ts | 32 +++++++-------------- packages/extension/src/offscreen.ts | 25 +++++++++------- packages/streams/src/BaseDuplexStream.ts | 8 ++++++ packages/streams/src/ChromeRuntimeStream.ts | 3 ++ 4 files changed, 36 insertions(+), 32 deletions(-) diff --git a/packages/extension/src/devtools/panel.ts b/packages/extension/src/devtools/panel.ts index da94a4017..2849f4d8a 100644 --- a/packages/extension/src/devtools/panel.ts +++ b/packages/extension/src/devtools/panel.ts @@ -3,24 +3,6 @@ import { ChromeRuntimeDuplexStream, ChromeRuntimeTarget } from '@ocap/streams'; import type { KernelControlCommand } from '../kernel/messages.js'; -// This will redirect logs to both the panel's console and the DevTools-for-DevTools console -const originalConsole = { ...console }; -Object.assign(console, { - log: (...args: unknown[]) => { - originalConsole.log('[Devtools Panel]', ...args); - // Also log to the DevTools-for-DevTools console - chrome.devtools.inspectedWindow.eval( - `console.log("[Devtools Panel]", ${JSON.stringify(args)})`, - ); - }, - error: (...args: unknown[]) => { - originalConsole.error('[Devtools Panel]', ...args); - chrome.devtools.inspectedWindow.eval( - `console.error("[Devtools Panel]", ${JSON.stringify(args)})`, - ); - }, -}); - // Initialize and start the UI main().catch(console.error); @@ -28,15 +10,21 @@ main().catch(console.error); * The main function for the devtools panel. */ async function main(): Promise { + chrome.devtools.inspectedWindow.eval(`console.log("[Devtools Panel] INIT")`); const offscreenStream = await ChromeRuntimeDuplexStream.make( chrome.runtime, ChromeRuntimeTarget.Devtools, ChromeRuntimeTarget.Offscreen, ); - console.log('devtools <-> offscreen stream created'); + // Log to the DevTools-for-DevTools console + chrome.devtools.inspectedWindow.eval( + `console.log("[Devtools Panel] devtools <-> offscreen stream created")`, + ); const sendMessage = async (message: KernelControlCommand): Promise => { - console.log('sending devtools message', message); + chrome.devtools.inspectedWindow.eval( + `console.log("[Devtools Panel] sending devtools message", ${JSON.stringify(message)})`, + ); await offscreenStream.write(message); }; @@ -108,6 +96,8 @@ async function main(): Promise { // await updateStatus(); for await (const message of offscreenStream) { - console.log('received devtools message', message); + chrome.devtools.inspectedWindow.eval( + `console.log("[Devtools Panel] received devtools message", ${JSON.stringify(message)})`, + ); } } diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 89e0191f0..ebeca9564 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -29,6 +29,20 @@ async function main(): Promise { ChromeRuntimeTarget.Background, ); + // Handle messages from the devtools page + ChromeRuntimeDuplexStream.make( + chrome.runtime, + ChromeRuntimeTarget.Offscreen, + ChromeRuntimeTarget.Devtools, + ) + .then(async (stream) => { + console.log('[Offscreen] offscreen <-> devtools stream created'); + return stream.drain(handleDevtoolsStream); + }) + .catch((error) => + logger.error('Unexpected error from devtools stream', error), + ); + const kernelWorker = await makeKernelWorker(); /** @@ -57,17 +71,6 @@ async function main(): Promise { })(), ]); - // Handle messages from the devtools page - ChromeRuntimeDuplexStream.make( - chrome.runtime, - ChromeRuntimeTarget.Offscreen, - ChromeRuntimeTarget.Devtools, - ) - .then(async (stream) => stream.drain(handleDevtoolsStream)) - .catch((error) => - logger.error('Unexpected error from devtools stream', error), - ); - /** * Make the SQLite kernel worker. * diff --git a/packages/streams/src/BaseDuplexStream.ts b/packages/streams/src/BaseDuplexStream.ts index 3400297f8..c60d092e5 100644 --- a/packages/streams/src/BaseDuplexStream.ts +++ b/packages/streams/src/BaseDuplexStream.ts @@ -176,11 +176,15 @@ export abstract class BaseDuplexStream< while (this.#synchronizationStatus !== SynchronizationStatus.Complete) { const result = await this.#reader.next(); + console.log('waiting for synchronization', result); if (isAck(result.value) || result.done) { + console.log('synchronization complete'); this.#synchronizationStatus = SynchronizationStatus.Complete; resolve(); } else if (isSyn(result.value)) { + console.log('received SYN message during synchronization'); if (receivedSyn) { + console.log('received duplicate SYN message during synchronization'); reject( new Error('Received duplicate SYN message during synchronization'), ); @@ -190,6 +194,10 @@ export abstract class BaseDuplexStream< // @ts-expect-error See docstring. await this.#writer.next(makeAck()); } else { + console.log( + 'received unexpected message during synchronization', + result, + ); reject( new Error( `Received unexpected message during synchronization: ${stringify(result)}`, diff --git a/packages/streams/src/ChromeRuntimeStream.ts b/packages/streams/src/ChromeRuntimeStream.ts index a59699a80..5c15634aa 100644 --- a/packages/streams/src/ChromeRuntimeStream.ts +++ b/packages/streams/src/ChromeRuntimeStream.ts @@ -119,6 +119,7 @@ export class ChromeRuntimeReader extends BaseReader { } if (message.target !== this.#target || message.source !== this.#source) { +<<<<<<< HEAD <<<<<<< HEAD console.debug( `ChromeRuntimeReader received message with incorrect target or source: ${stringify(message)}`, @@ -133,6 +134,8 @@ export class ChromeRuntimeReader extends BaseReader { this.#source, >>>>>>> 8d74ef6 (testing streams) ); +======= +>>>>>>> 7c50684 (fix chrome duplex) return; } From 59939008d940412836c4305c7b0ac8ab12421611 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 5 Nov 2024 15:59:49 +0100 Subject: [PATCH 07/39] Move to popup from devtools --- packages/extension/src/devtools/devtools.html | 7 -- packages/extension/src/devtools/devtools.ts | 1 - packages/extension/src/devtools/panel.ts | 103 --------------- packages/extension/src/kernel/messages.ts | 14 ++- packages/extension/src/manifest.json | 7 +- packages/extension/src/offscreen.ts | 91 ++++++++++---- .../src/{devtools/panel.html => popup.html} | 23 +++- packages/extension/src/popup.ts | 118 ++++++++++++++++++ packages/extension/vite.config.ts | 3 +- packages/streams/src/BaseDuplexStream.ts | 8 -- packages/streams/src/ChromeRuntimeStream.ts | 19 +-- 11 files changed, 215 insertions(+), 179 deletions(-) delete mode 100644 packages/extension/src/devtools/devtools.html delete mode 100644 packages/extension/src/devtools/devtools.ts delete mode 100644 packages/extension/src/devtools/panel.ts rename packages/extension/src/{devtools/panel.html => popup.html} (70%) create mode 100644 packages/extension/src/popup.ts diff --git a/packages/extension/src/devtools/devtools.html b/packages/extension/src/devtools/devtools.html deleted file mode 100644 index fa08beb6f..000000000 --- a/packages/extension/src/devtools/devtools.html +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/packages/extension/src/devtools/devtools.ts b/packages/extension/src/devtools/devtools.ts deleted file mode 100644 index 20aa9f462..000000000 --- a/packages/extension/src/devtools/devtools.ts +++ /dev/null @@ -1 +0,0 @@ -chrome.devtools.panels.create('🤖 Ocap Kernel', '', 'devtools/panel.html'); diff --git a/packages/extension/src/devtools/panel.ts b/packages/extension/src/devtools/panel.ts deleted file mode 100644 index 2849f4d8a..000000000 --- a/packages/extension/src/devtools/panel.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { VatId } from '@ocap/kernel'; -import { ChromeRuntimeDuplexStream, ChromeRuntimeTarget } from '@ocap/streams'; - -import type { KernelControlCommand } from '../kernel/messages.js'; - -// Initialize and start the UI -main().catch(console.error); - -/** - * The main function for the devtools panel. - */ -async function main(): Promise { - chrome.devtools.inspectedWindow.eval(`console.log("[Devtools Panel] INIT")`); - const offscreenStream = await ChromeRuntimeDuplexStream.make( - chrome.runtime, - ChromeRuntimeTarget.Devtools, - ChromeRuntimeTarget.Offscreen, - ); - // Log to the DevTools-for-DevTools console - chrome.devtools.inspectedWindow.eval( - `console.log("[Devtools Panel] devtools <-> offscreen stream created")`, - ); - - const sendMessage = async (message: KernelControlCommand): Promise => { - chrome.devtools.inspectedWindow.eval( - `console.log("[Devtools Panel] sending devtools message", ${JSON.stringify(message)})`, - ); - await offscreenStream.write(message); - }; - - const getVatId = (): VatId => - (document.getElementById('vat-id') as HTMLInputElement).value as VatId; - - document.getElementById('init-kernel')?.addEventListener('click', () => { - sendMessage({ - method: 'initKernel', - params: null, - }).catch(console.error); - }); - - document.getElementById('shutdown-kernel')?.addEventListener('click', () => { - sendMessage({ - method: 'shutdownKernel', - params: null, - }).catch(console.error); - }); - - document.getElementById('launch-vat')?.addEventListener('click', () => { - sendMessage({ - method: 'launchVat', - params: { id: getVatId() }, - }).catch(console.error); - }); - - document.getElementById('restart-vat')?.addEventListener('click', () => { - sendMessage({ - method: 'restartVat', - params: { id: getVatId() }, - }).catch(console.error); - }); - - document.getElementById('terminate-vat')?.addEventListener('click', () => { - sendMessage({ - method: 'terminateVat', - params: { id: getVatId() }, - }).catch(console.error); - }); - - document.getElementById('terminate-all')?.addEventListener('click', () => { - sendMessage({ - method: 'terminateAllVats', - params: null, - }).catch(console.error); - }); - - /** - * Update the status display. - */ - // const updateStatus = async (): Promise => { - // const statusDisplay = document.getElementById('status-display'); - // if (!statusDisplay) { - // return; - // } - - // await stream.write({ method: 'getStatus' }); - - // // Write the status to the display - // statusDisplay.textContent = JSON.stringify(status, null, 2); - - // // Update every second - // setTimeout(() => { - // updateStatus().catch(console.error); - // }, 1000); - // }; - - // await updateStatus(); - - for await (const message of offscreenStream) { - chrome.devtools.inspectedWindow.eval( - `console.log("[Devtools Panel] received devtools message", ${JSON.stringify(message)})`, - ); - } -} diff --git a/packages/extension/src/kernel/messages.ts b/packages/extension/src/kernel/messages.ts index 32ed3b2ad..593fe53c5 100644 --- a/packages/extension/src/kernel/messages.ts +++ b/packages/extension/src/kernel/messages.ts @@ -8,6 +8,14 @@ export type KernelStatus = { activeVats: VatId[]; }; +export const isKernelStatus: TypeGuard = ( + value, +): value is KernelStatus => + isObject(value) && + typeof value.isRunning === 'boolean' && + Array.isArray(value.activeVats) && + value.activeVats.every((id) => isVatId(id)); + const kernelControlCommand = { InitKernel: messageType( (send) => send === null, @@ -35,11 +43,7 @@ const kernelControlCommand = { ), GetStatus: messageType( (send) => send === null, - (reply) => - isObject(reply) && - typeof reply.isRunning === 'boolean' && - Array.isArray(reply.activeVats) && - reply.activeVats.every((id) => isVatId(id)), + isKernelStatus, ), }; diff --git a/packages/extension/src/manifest.json b/packages/extension/src/manifest.json index e452b3a65..1b5a14d87 100644 --- a/packages/extension/src/manifest.json +++ b/packages/extension/src/manifest.json @@ -7,14 +7,15 @@ "service_worker": "background.js", "type": "module" }, - "action": {}, + "action": { + "default_popup": "popup.html" + }, "permissions": ["offscreen", "unlimitedStorage"], - "devtools_page": "devtools/devtools.html", "sandbox": { "pages": ["iframe.html"] }, "content_security_policy": { - "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'none'; frame-ancestors 'self' devtools://devtools;", + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'none'; frame-ancestors 'self'", "sandbox": "sandbox allow-scripts; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; default-src 'none'; connect-src *;" } } diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index ebeca9564..f0021552c 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -9,7 +9,11 @@ import { import { makeLogger } from '@ocap/utils'; import { makeIframeVatWorker } from './kernel/iframe-vat-worker.js'; -import type { KernelControlReply } from './kernel/messages.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]'); @@ -29,22 +33,10 @@ async function main(): Promise { ChromeRuntimeTarget.Background, ); - // Handle messages from the devtools page - ChromeRuntimeDuplexStream.make( - chrome.runtime, - ChromeRuntimeTarget.Offscreen, - ChromeRuntimeTarget.Devtools, - ) - .then(async (stream) => { - console.log('[Offscreen] offscreen <-> devtools stream created'); - return stream.drain(handleDevtoolsStream); - }) - .catch((error) => - logger.error('Unexpected error from devtools stream', error), - ); - const kernelWorker = await makeKernelWorker(); + const replyToPopup = setupPopupStream(); + /** * Reply to a command from the background script. * @@ -77,7 +69,9 @@ async function main(): Promise { * @returns An object with methods to send and receive messages from the kernel worker. */ async function makeKernelWorker(): Promise<{ - sendMessage: (message: KernelCommand) => Promise; + sendMessage: ( + message: KernelCommand | KernelControlCommand, + ) => Promise; receiveMessages: () => Promise; }> { const worker = new Worker('kernel-worker.js', { type: 'module' }); @@ -85,7 +79,10 @@ async function main(): Promise { const workerStream = await initializeMessageChannel((message, transfer) => worker.postMessage(message, transfer), ).then(async (port) => - MessagePortDuplexStream.make(port), + MessagePortDuplexStream.make< + KernelCommandReply | KernelControlReply, + KernelCommand | KernelControlCommand + >(port), ); const vatWorkerServer = new ExtensionVatWorkerServer( @@ -105,16 +102,21 @@ async function main(): Promise { // 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); + if (isKernelCommandReply(message)) { + await replyToBackground(message); + continue; + } else if (isKernelControlReply(message)) { + await replyToPopup(message); continue; } - await replyToBackground(message); + logger.error('Kernel sent unexpected reply', message); } }; - const sendMessage = async (message: KernelCommand): Promise => { + const sendMessage = async ( + message: KernelCommand | KernelControlCommand, + ): Promise => { await workerStream.write(message); }; @@ -125,13 +127,48 @@ async function main(): Promise { } /** - * Handle messages from the devtools page. + * Set up the popup stream. * - * @param value - The value to handle. + * @returns A function that sends messages to the popup. */ - async function handleDevtoolsStream( - value: KernelControlReply, - ): Promise { - console.log('Offscreen received devtools message', value); + function setupPopupStream(): (message: KernelControlReply) => Promise { + let sendToPopup = async (message: KernelControlReply): Promise => { + logger.log('Offscreen sending message to popup before setup:', message); + }; + + // Set up the stream to the popup every time the popup shows. + // This is necessary because the stream is closed when the popup is closed. + chrome.runtime.onConnect.addListener((port) => { + if (port.name === 'popup') { + ChromeRuntimeDuplexStream.make< + KernelControlCommand, + KernelControlReply + >( + chrome.runtime, + ChromeRuntimeTarget.Offscreen, + ChromeRuntimeTarget.Popup, + ) + .then(async (stream) => { + // Close the stream when the popup is closed + port.onDisconnect.addListener(() => { + // eslint-disable-next-line promise/no-nesting + stream.return().catch(console.error); + }); + + sendToPopup = async (message) => { + logger.log('Offscreen sending message to popup:', message); + await stream.write(message); + }; + + return stream.drain(async (message) => { + console.log('Offscreen received message from popup:', message); + await kernelWorker.sendMessage(message); + }); + }) + .catch(logger.error); + } + }); + + return sendToPopup; } } diff --git a/packages/extension/src/devtools/panel.html b/packages/extension/src/popup.html similarity index 70% rename from packages/extension/src/devtools/panel.html rename to packages/extension/src/popup.html index a441aba1d..72cf3421b 100644 --- a/packages/extension/src/devtools/panel.html +++ b/packages/extension/src/popup.html @@ -2,7 +2,7 @@ - Kernel DevTools + Kernel Panel - +
+

Kernel Panel

- - - + + +
diff --git a/packages/extension/src/popup.ts b/packages/extension/src/popup.ts new file mode 100644 index 000000000..21897100e --- /dev/null +++ b/packages/extension/src/popup.ts @@ -0,0 +1,118 @@ +import type { VatId } from '@ocap/kernel'; +import { ChromeRuntimeDuplexStream, ChromeRuntimeTarget } from '@ocap/streams'; +import { makeLogger } from '@ocap/utils'; + +import { isKernelStatus } from './kernel/messages.js'; +import type { + KernelControlCommand, + KernelControlReply, + KernelStatus, +} from './kernel/messages.js'; + +const logger = makeLogger('[Kernel Panel]'); + +// DOM Elements +const vatId = document.getElementById('vat-id') as HTMLInputElement; +const statusDisplay = document.getElementById('status-display') as HTMLElement; +const buttons: Record< + string, + { + element: HTMLButtonElement; + command: KernelControlCommand; + } +> = { + initKernel: { + element: document.getElementById('init-kernel') as HTMLButtonElement, + command: { method: 'initKernel', params: null }, + }, + shutdownKernel: { + element: document.getElementById('shutdown-kernel') as HTMLButtonElement, + command: { method: 'shutdownKernel', params: null }, + }, + launchVat: { + element: document.getElementById('launch-vat') as HTMLButtonElement, + command: { method: 'launchVat', params: { id: vatId.value as VatId } }, + }, + restartVat: { + element: document.getElementById('restart-vat') as HTMLButtonElement, + command: { method: 'restartVat', params: { id: vatId.value as VatId } }, + }, + terminateVat: { + element: document.getElementById('terminate-vat') as HTMLButtonElement, + command: { method: 'terminateVat', params: { id: vatId.value as VatId } }, + }, + terminateAllVats: { + element: document.getElementById('terminate-all') as HTMLButtonElement, + command: { method: 'terminateAllVats', params: null }, + }, +}; + +// Initialize and start the UI +main().catch(logger.error); + +/** + * The main function for the popup script. + */ +async function main(): Promise { + chrome.runtime.connect({ name: 'popup' }); + + const offscreenStream = await ChromeRuntimeDuplexStream.make< + KernelControlReply, + KernelControlCommand + >(chrome.runtime, ChromeRuntimeTarget.Popup, ChromeRuntimeTarget.Offscreen); + logger.log('devtools <-> offscreen stream created'); + + const sendMessage = async (message: KernelControlCommand): Promise => { + logger.log('sending devtools message', message); + await offscreenStream.write(message); + }; + + // Setup all button handlers + Object.values(buttons).forEach((button) => { + button.element.addEventListener('click', () => { + sendMessage(button.command).catch(logger.error); + }); + }); + + // Update the status display + const updateStatusDisplay = (status: KernelStatus): void => { + const { isRunning, activeVats } = status; + statusDisplay.textContent = isRunning + ? `Active Vats: ${activeVats.join(', ')}` + : 'Kernel is not running'; + + if (buttons.shutdownKernel?.element) { + buttons.shutdownKernel.element.style.display = isRunning + ? 'block' + : 'none'; + } + if (buttons.initKernel?.element) { + buttons.initKernel.element.style.display = isRunning ? 'none' : 'block'; + } + }; + + // Handle messages from the offscreen script + const handleOffscreenMessage = (message: KernelControlReply): void => { + if (isKernelStatus(message)) { + updateStatusDisplay(message); + } + }; + + // Drain the offscreen stream + offscreenStream.drain(handleOffscreenMessage).catch((error) => { + logger.error('error draining offscreen stream', error); + }); + + // Fetch the status periodically + const fetchStatus = async (): Promise => { + await sendMessage({ + method: 'getStatus', + params: null, + }); + + setTimeout(() => { + fetchStatus().catch(logger.error); + }, 1000); + }; + await fetchStatus(); +} diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index 142ba60c9..c0e442a09 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -41,8 +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'), - 'devtools/devtools': path.resolve(sourceDir, 'devtools/devtools.html'), - 'devtools/panel': path.resolve(sourceDir, 'devtools/panel.html'), + popup: path.resolve(sourceDir, 'popup.html'), }, output: { entryFileNames: '[name].js', diff --git a/packages/streams/src/BaseDuplexStream.ts b/packages/streams/src/BaseDuplexStream.ts index c60d092e5..3400297f8 100644 --- a/packages/streams/src/BaseDuplexStream.ts +++ b/packages/streams/src/BaseDuplexStream.ts @@ -176,15 +176,11 @@ export abstract class BaseDuplexStream< while (this.#synchronizationStatus !== SynchronizationStatus.Complete) { const result = await this.#reader.next(); - console.log('waiting for synchronization', result); if (isAck(result.value) || result.done) { - console.log('synchronization complete'); this.#synchronizationStatus = SynchronizationStatus.Complete; resolve(); } else if (isSyn(result.value)) { - console.log('received SYN message during synchronization'); if (receivedSyn) { - console.log('received duplicate SYN message during synchronization'); reject( new Error('Received duplicate SYN message during synchronization'), ); @@ -194,10 +190,6 @@ export abstract class BaseDuplexStream< // @ts-expect-error See docstring. await this.#writer.next(makeAck()); } else { - console.log( - 'received unexpected message during synchronization', - result, - ); reject( new Error( `Received unexpected message during synchronization: ${stringify(result)}`, diff --git a/packages/streams/src/ChromeRuntimeStream.ts b/packages/streams/src/ChromeRuntimeStream.ts index 5c15634aa..b67f3cf6e 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 = { @@ -119,23 +119,11 @@ export class ChromeRuntimeReader extends BaseReader { } if (message.target !== this.#target || message.source !== this.#source) { -<<<<<<< HEAD -<<<<<<< HEAD console.debug( `ChromeRuntimeReader received message with incorrect target or source: ${stringify(message)}`, `Expected target: ${this.#target}`, `Expected source: ${this.#source}`, -======= - console.log( - `ChromeRuntimeReader received unexpected target/source: ${stringify( - message, - )}`, - this.#target, - this.#source, ->>>>>>> 8d74ef6 (testing streams) ); -======= ->>>>>>> 7c50684 (fix chrome duplex) return; } @@ -239,18 +227,13 @@ export class ChromeRuntimeDuplexStream< if (localTarget === remoteTarget) { throw new Error('localTarget and remoteTarget must be different'); } - const stream = new ChromeRuntimeDuplexStream( runtime, localTarget, remoteTarget, validateInput, ); - console.log( - `ChromeRuntimeDuplexStream created for ${localTarget} <-> ${remoteTarget}`, - ); await stream.synchronize(); - console.log(`stream synchronized for ${localTarget} <-> ${remoteTarget}`); return stream; } } From 7b23f70a0deb4fab6063772cbce5335274792afa Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 5 Nov 2024 18:26:43 +0100 Subject: [PATCH 08/39] fix UI --- .../extension/src/kernel/kernel-worker.ts | 109 +++++++------- packages/extension/src/kernel/messages.ts | 8 -- packages/extension/src/offscreen.ts | 55 +++---- packages/extension/src/popup.html | 90 +++++++++--- packages/extension/src/popup.ts | 134 ++++++++++++++---- 5 files changed, 256 insertions(+), 140 deletions(-) diff --git a/packages/extension/src/kernel/kernel-worker.ts b/packages/extension/src/kernel/kernel-worker.ts index 02acb8756..00595fada 100644 --- a/packages/extension/src/kernel/kernel-worker.ts +++ b/packages/extension/src/kernel/kernel-worker.ts @@ -2,8 +2,10 @@ 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 { makeLogger } from '@ocap/utils'; +import { makeLogger, stringify } from '@ocap/utils'; +import { isKernelControlCommand } from './messages.js'; +import type { KernelControlCommand, KernelControlReply } from './messages.js'; import { makeSQLKVStore } from './sqlite-kv-store.js'; import { ExtensionVatWorkerClient } from './VatWorkerClient.js'; @@ -36,58 +38,18 @@ async function main(): Promise { // Run default kernel lifecycle await runVatLifecycle(kernel, ['v1', 'v2', 'v3']); await kernel.launchVat({ id: 'v0' }); -} -/** - * Handle a control message and return the appropriate reply. - * - * @param kernel - The kernel instance. - * @param message - The control message to handle. - * @returns The reply to the control message. - */ -// async function handleControlMessage( -// kernel: Kernel, -// message: KernelControlCommand, -// ): Promise { -// switch (message.method) { -// case 'initKernel': -// await kernel.init(); -// return { method: 'initKernel', params: null }; - -// case 'shutdownKernel': -// // TODO: Implement proper shutdown sequence -// return { method: 'shutdownKernel', params: null }; - -// case 'launchVat': -// await kernel.launchVat({ id: message.params.id }); -// return { method: 'launchVat', params: null }; - -// case 'restartVat': -// await kernel.restartVat(message.params.id); -// return { method: 'restartVat', params: null }; - -// case 'terminateVat': -// await kernel.terminateVat(message.params.id); -// return { method: 'terminateVat', params: null }; - -// case 'terminateAllVats': -// await kernel.terminateAllVats(); -// return { method: 'terminateAllVats', params: null }; - -// case 'getStatus': -// return { -// method: 'getStatus', -// params: { -// isRunning: true, // TODO: Track actual kernel state -// activeVats: kernel.getVatIds(), -// }, -// }; - -// default: -// logger.error('Unknown control message method', message); -// throw new Error(`Unknown control message: ${stringify(message)}`); -// } -// } + // Handle Kernel Panel messages + // TODO: This is a temporary solution to allow the kernel worker to send replies back to the + // offscreen script. This should be replaced with MultiplexStream once it's implemented. + globalThis.addEventListener('message', (event) => { + if (isKernelControlCommand(event.data)) { + handleControlMessage(kernel, event.data) + .then((reply) => globalThis.postMessage(reply)) + .catch(logger.error); + } + }); +} /** * Runs the full lifecycle of an array of vats @@ -127,3 +89,46 @@ async function runVatLifecycle( logger.log(`Kernel has ${kernel.getVatIds().length} vats`); } + +/** + * Handle a control message and return the appropriate reply. + * + * @param kernel - The kernel instance. + * @param message - The control message to handle. + * @returns The reply to the control message. + */ +async function handleControlMessage( + kernel: Kernel, + message: KernelControlCommand, +): Promise { + switch (message.method) { + case 'launchVat': + await kernel.launchVat({ id: message.params.id }); + return { method: 'launchVat', params: null }; + + case 'restartVat': + await kernel.restartVat(message.params.id); + return { method: 'restartVat', params: null }; + + case 'terminateVat': + await kernel.terminateVat(message.params.id); + return { method: 'terminateVat', params: null }; + + case 'terminateAllVats': + await kernel.terminateAllVats(); + return { method: 'terminateAllVats', params: null }; + + case 'getStatus': + return { + method: 'getStatus', + params: { + isRunning: true, // TODO: Track actual kernel state + activeVats: kernel.getVatIds(), + }, + }; + + default: + logger.error('Unknown control message method', message); + throw new Error(`Unknown control message: ${stringify(message)}`); + } +} diff --git a/packages/extension/src/kernel/messages.ts b/packages/extension/src/kernel/messages.ts index 593fe53c5..b838cc915 100644 --- a/packages/extension/src/kernel/messages.ts +++ b/packages/extension/src/kernel/messages.ts @@ -17,14 +17,6 @@ export const isKernelStatus: TypeGuard = ( value.activeVats.every((id) => isVatId(id)); const kernelControlCommand = { - InitKernel: messageType( - (send) => send === null, - (reply) => reply === null, - ), - ShutdownKernel: messageType( - (send) => send === null, - (reply) => reply === null, - ), LaunchVat: messageType<{ id: VatId }, null>( (send) => isObject(send) && isVatId(send.id), (reply) => reply === null, diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index f0021552c..2ea0ab9b3 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -16,7 +16,7 @@ import type { } from './kernel/messages.js'; import { ExtensionVatWorkerServer } from './kernel/VatWorkerServer.js'; -const logger = makeLogger('[ocap glue]'); +const logger = makeLogger('[offscreen]'); main().catch(logger.error); @@ -35,7 +35,7 @@ async function main(): Promise { const kernelWorker = await makeKernelWorker(); - const replyToPopup = setupPopupStream(); + setupPopupStream(); /** * Reply to a command from the background script. @@ -69,20 +69,16 @@ async function main(): Promise { * @returns An object with methods to send and receive messages from the kernel worker. */ async function makeKernelWorker(): Promise<{ - sendMessage: ( - message: KernelCommand | KernelControlCommand, - ) => Promise; + sendMessage: (message: KernelCommand) => Promise; receiveMessages: () => Promise; + worker: Worker; }> { const worker = new Worker('kernel-worker.js', { type: 'module' }); const workerStream = await initializeMessageChannel((message, transfer) => worker.postMessage(message, transfer), ).then(async (port) => - MessagePortDuplexStream.make< - KernelCommandReply | KernelControlReply, - KernelCommand | KernelControlCommand - >(port), + MessagePortDuplexStream.make(port), ); const vatWorkerServer = new ExtensionVatWorkerServer( @@ -102,40 +98,30 @@ async function main(): Promise { // 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)) { - await replyToBackground(message); - continue; - } else if (isKernelControlReply(message)) { - await replyToPopup(message); + if (!isKernelCommandReply(message)) { + logger.error('Kernel sent unexpected reply', message); continue; } - logger.error('Kernel sent unexpected reply', message); + await replyToBackground(message); } }; - const sendMessage = async ( - message: KernelCommand | KernelControlCommand, - ): Promise => { + const sendMessage = async (message: KernelCommand): Promise => { await workerStream.write(message); }; return { sendMessage, receiveMessages, + worker, }; } /** * Set up the popup stream. - * - * @returns A function that sends messages to the popup. */ - function setupPopupStream(): (message: KernelControlReply) => Promise { - let sendToPopup = async (message: KernelControlReply): Promise => { - logger.log('Offscreen sending message to popup before setup:', message); - }; - + function setupPopupStream(): void { // Set up the stream to the popup every time the popup shows. // This is necessary because the stream is closed when the popup is closed. chrome.runtime.onConnect.addListener((port) => { @@ -149,26 +135,27 @@ async function main(): Promise { ChromeRuntimeTarget.Popup, ) .then(async (stream) => { + const replyToPopup = (event: MessageEvent): void => { + if (isKernelControlReply(event.data)) { + stream.write(event.data).catch(logger.error); + } + }; + + kernelWorker.worker.addEventListener('message', replyToPopup); + // Close the stream when the popup is closed port.onDisconnect.addListener(() => { // eslint-disable-next-line promise/no-nesting stream.return().catch(console.error); + kernelWorker.worker.removeEventListener('message', replyToPopup); }); - sendToPopup = async (message) => { - logger.log('Offscreen sending message to popup:', message); - await stream.write(message); - }; - return stream.drain(async (message) => { - console.log('Offscreen received message from popup:', message); - await kernelWorker.sendMessage(message); + kernelWorker.worker.postMessage(message); }); }) .catch(logger.error); } }); - - return sendToPopup; } } diff --git a/packages/extension/src/popup.html b/packages/extension/src/popup.html index 72cf3421b..e088b4449 100644 --- a/packages/extension/src/popup.html +++ b/packages/extension/src/popup.html @@ -5,6 +5,7 @@ Kernel Panel
-

Kernel Panel

-
- - - +
+

Kernel Status

+

         
- + +
+ +
+
-
-

Kernel Status

-

+        
+
diff --git a/packages/extension/src/popup.ts b/packages/extension/src/popup.ts index 21897100e..758d8ff83 100644 --- a/packages/extension/src/popup.ts +++ b/packages/extension/src/popup.ts @@ -1,8 +1,8 @@ import type { VatId } from '@ocap/kernel'; import { ChromeRuntimeDuplexStream, ChromeRuntimeTarget } from '@ocap/streams'; -import { makeLogger } from '@ocap/utils'; +import { makeLogger, stringify } from '@ocap/utils'; -import { isKernelStatus } from './kernel/messages.js'; +import { isKernelControlReply, isKernelStatus } from './kernel/messages.js'; import type { KernelControlCommand, KernelControlReply, @@ -12,44 +12,117 @@ import type { const logger = makeLogger('[Kernel Panel]'); // DOM Elements -const vatId = document.getElementById('vat-id') as HTMLInputElement; +const vatId = document.getElementById('vat-id') as HTMLSelectElement; +const newVatId = document.getElementById('new-vat-id') as HTMLInputElement; const statusDisplay = document.getElementById('status-display') as HTMLElement; const buttons: Record< string, { element: HTMLButtonElement; - command: KernelControlCommand; + command: () => KernelControlCommand; } > = { - initKernel: { - element: document.getElementById('init-kernel') as HTMLButtonElement, - command: { method: 'initKernel', params: null }, - }, - shutdownKernel: { - element: document.getElementById('shutdown-kernel') as HTMLButtonElement, - command: { method: 'shutdownKernel', params: null }, - }, launchVat: { element: document.getElementById('launch-vat') as HTMLButtonElement, - command: { method: 'launchVat', params: { id: vatId.value as VatId } }, + command: () => ({ + method: 'launchVat', + params: { id: newVatId.value as VatId }, + }), }, restartVat: { element: document.getElementById('restart-vat') as HTMLButtonElement, - command: { method: 'restartVat', params: { id: vatId.value as VatId } }, + command: () => ({ + method: 'restartVat', + params: { id: vatId.value as VatId }, + }), }, terminateVat: { element: document.getElementById('terminate-vat') as HTMLButtonElement, - command: { method: 'terminateVat', params: { id: vatId.value as VatId } }, + command: () => ({ + method: 'terminateVat', + params: { id: vatId.value as VatId }, + }), }, terminateAllVats: { element: document.getElementById('terminate-all') as HTMLButtonElement, - command: { method: 'terminateAllVats', params: null }, + command: () => ({ + method: 'terminateAllVats', + params: null, + }), }, }; // Initialize and start the UI main().catch(logger.error); +/** + * Updates the vat selection dropdown with active vats + * + * @param activeVats - Array of active vat IDs + */ +function updateVatSelect(activeVats: VatId[]): void { + // Compare current options with new vats + const currentVats = Array.from(vatId.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 = vatId.value; + + // Clear existing options except the default one + while (vatId.options.length > 1) { + vatId.remove(1); + } + + // Add new options + activeVats.forEach((id) => { + const option = document.createElement('option'); + option.value = id; + option.text = id; + vatId.add(option); + }); + + // Restore selection if it still exists + if (activeVats.includes(currentSelection as VatId)) { + vatId.value = currentSelection; + } else { + vatId.value = ''; + } + + // Update button states + updateButtonStates(activeVats.length > 0); +} + +/** + * Updates button states based on selections and vat existence + * + * @param hasVats - Whether any vats exist + */ +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; + } + + // Restart and terminate buttons - enabled when a vat is selected + if (buttons.restartVat) { + buttons.restartVat.element.disabled = !vatId.value; + } + if (buttons.terminateVat) { + buttons.terminateVat.element.disabled = !vatId.value; + } + + // Terminate all - enabled only when vats exist + if (buttons.terminateAllVats) { + buttons.terminateAllVats.element.disabled = !hasVats; + } +} + /** * The main function for the popup script. */ @@ -60,17 +133,25 @@ async function main(): Promise { KernelControlReply, KernelControlCommand >(chrome.runtime, ChromeRuntimeTarget.Popup, ChromeRuntimeTarget.Offscreen); - logger.log('devtools <-> offscreen stream created'); const sendMessage = async (message: KernelControlCommand): Promise => { - logger.log('sending devtools message', message); + logger.log('sending message', message); await offscreenStream.write(message); }; + // Setup input change handlers + newVatId.addEventListener('input', () => { + updateButtonStates(vatId.options.length > 1); + }); + + vatId.addEventListener('change', () => { + updateButtonStates(vatId.options.length > 1); + }); + // Setup all button handlers Object.values(buttons).forEach((button) => { button.element.addEventListener('click', () => { - sendMessage(button.command).catch(logger.error); + sendMessage(button.command()).catch(logger.error); }); }); @@ -78,23 +159,16 @@ async function main(): Promise { const updateStatusDisplay = (status: KernelStatus): void => { const { isRunning, activeVats } = status; statusDisplay.textContent = isRunning - ? `Active Vats: ${activeVats.join(', ')}` + ? `Active Vats (${activeVats.length}): ${stringify(activeVats, 0)}` : 'Kernel is not running'; - if (buttons.shutdownKernel?.element) { - buttons.shutdownKernel.element.style.display = isRunning - ? 'block' - : 'none'; - } - if (buttons.initKernel?.element) { - buttons.initKernel.element.style.display = isRunning ? 'none' : 'block'; - } + updateVatSelect(activeVats); }; // Handle messages from the offscreen script const handleOffscreenMessage = (message: KernelControlReply): void => { - if (isKernelStatus(message)) { - updateStatusDisplay(message); + if (isKernelControlReply(message) && isKernelStatus(message.params)) { + updateStatusDisplay(message.params); } }; From da1006515375dd2602795389ef7209442687b5ef Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Tue, 5 Nov 2024 18:37:01 +0100 Subject: [PATCH 09/39] cleanup --- .../extension/src/kernel/stream-envelope.ts | 55 ------------------- packages/extension/src/offscreen.ts | 1 + 2 files changed, 1 insertion(+), 55 deletions(-) delete mode 100644 packages/extension/src/kernel/stream-envelope.ts diff --git a/packages/extension/src/kernel/stream-envelope.ts b/packages/extension/src/kernel/stream-envelope.ts deleted file mode 100644 index d57287996..000000000 --- a/packages/extension/src/kernel/stream-envelope.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { KernelCommand, KernelCommandReply } from '@ocap/kernel'; -import { isKernelCommand, isKernelCommandReply } from '@ocap/kernel'; -import { makeStreamEnvelopeKit } from '@ocap/streams'; -import type { ExtractGuardType } from '@ocap/utils'; - -import type { KernelControlCommand, KernelControlReply } from './messages.js'; -import { isKernelControlCommand, isKernelControlReply } from './messages.js'; - -export enum EnvelopeLabel { - Kernel = 'kernel', - Control = 'control', -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const envelopeLabels = Object.values(EnvelopeLabel); - -// Envelope kit for initial sends -const envelopeKit = makeStreamEnvelopeKit< - typeof envelopeLabels, - { - kernel: KernelCommand; - control: KernelControlCommand; - } ->({ - kernel: isKernelCommand, - control: isKernelControlCommand, -}); - -// Envelope kit for replies -const envelopeReplyKit = makeStreamEnvelopeKit< - typeof envelopeLabels, - { - kernel: KernelCommandReply; - control: KernelControlReply; - } ->({ - kernel: isKernelCommandReply, - control: isKernelControlReply, -}); - -export type StreamEnvelope = ExtractGuardType< - typeof envelopeKit.isStreamEnvelope ->; -export type StreamEnvelopeReply = ExtractGuardType< - typeof envelopeReplyKit.isStreamEnvelope ->; - -export const wrapKernelCommand = envelopeKit.streamEnveloper.kernel.wrap; -export const wrapControlCommand = envelopeKit.streamEnveloper.control.wrap; -export const wrapKernelReply = envelopeReplyKit.streamEnveloper.kernel.wrap; -export const wrapControlReply = envelopeReplyKit.streamEnveloper.control.wrap; - -export const { makeStreamEnvelopeHandler } = envelopeKit; -export const { makeStreamEnvelopeHandler: makeStreamEnvelopeReplyHandler } = - envelopeReplyKit; diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 2ea0ab9b3..547199238 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -137,6 +137,7 @@ async function main(): Promise { .then(async (stream) => { const replyToPopup = (event: MessageEvent): void => { if (isKernelControlReply(event.data)) { + // eslint-disable-next-line promise/no-nesting stream.write(event.data).catch(logger.error); } }; From 2550c38801b3bbf4b038edb4f793a13aaab47591 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 6 Nov 2024 12:07:56 +0100 Subject: [PATCH 10/39] clean --- packages/extension/src/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/src/manifest.json b/packages/extension/src/manifest.json index 1b5a14d87..13ec4b778 100644 --- a/packages/extension/src/manifest.json +++ b/packages/extension/src/manifest.json @@ -15,7 +15,7 @@ "pages": ["iframe.html"] }, "content_security_policy": { - "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'none'; frame-ancestors 'self'", + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'none'; frame-ancestors 'none'", "sandbox": "sandbox allow-scripts; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; default-src 'none'; connect-src *;" } } From b88796666df4de9c1f68ef73181f79f461a7a741 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 6 Nov 2024 12:08:45 +0100 Subject: [PATCH 11/39] revert --- packages/extension/src/manifest.json | 2 +- packages/streams/src/ChromeRuntimeStream.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/extension/src/manifest.json b/packages/extension/src/manifest.json index 13ec4b778..7c7e6b5a2 100644 --- a/packages/extension/src/manifest.json +++ b/packages/extension/src/manifest.json @@ -15,7 +15,7 @@ "pages": ["iframe.html"] }, "content_security_policy": { - "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'none'; frame-ancestors 'none'", + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'none'; frame-ancestors 'none';", "sandbox": "sandbox allow-scripts; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; default-src 'none'; connect-src *;" } } diff --git a/packages/streams/src/ChromeRuntimeStream.ts b/packages/streams/src/ChromeRuntimeStream.ts index b67f3cf6e..c4c946be1 100644 --- a/packages/streams/src/ChromeRuntimeStream.ts +++ b/packages/streams/src/ChromeRuntimeStream.ts @@ -227,6 +227,7 @@ export class ChromeRuntimeDuplexStream< if (localTarget === remoteTarget) { throw new Error('localTarget and remoteTarget must be different'); } + const stream = new ChromeRuntimeDuplexStream( runtime, localTarget, From 452b432917cb59a0614a040ff6ca588a89971a2f Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 6 Nov 2024 18:20:47 +0100 Subject: [PATCH 12/39] refactor and add send message --- .../src/kernel/handle-panel-messages.ts | 118 +++++++++++ .../extension/src/kernel/kernel-worker.ts | 59 +----- packages/extension/src/kernel/messages.ts | 10 +- packages/extension/src/offscreen.ts | 1 + packages/extension/src/panel/buttons.ts | 64 ++++++ packages/extension/src/panel/messages.ts | 91 ++++++++ packages/extension/src/panel/shared.ts | 3 + packages/extension/src/panel/status.ts | 104 +++++++++ packages/extension/src/panel/stream.ts | 79 +++++++ packages/extension/src/panel/styles.css | 174 +++++++++++++++ packages/extension/src/popup.html | 121 ++--------- packages/extension/src/popup.ts | 198 ++---------------- 12 files changed, 677 insertions(+), 345 deletions(-) create mode 100644 packages/extension/src/kernel/handle-panel-messages.ts create mode 100644 packages/extension/src/panel/buttons.ts create mode 100644 packages/extension/src/panel/messages.ts create mode 100644 packages/extension/src/panel/shared.ts create mode 100644 packages/extension/src/panel/status.ts create mode 100644 packages/extension/src/panel/stream.ts create mode 100644 packages/extension/src/panel/styles.css diff --git a/packages/extension/src/kernel/handle-panel-messages.ts b/packages/extension/src/kernel/handle-panel-messages.ts new file mode 100644 index 000000000..11806085f --- /dev/null +++ b/packages/extension/src/kernel/handle-panel-messages.ts @@ -0,0 +1,118 @@ +import type { Json } from '@metamask/utils'; +import { Kernel, isVatId, isKernelCommand } from '@ocap/kernel'; +import type { VatCommand } from '@ocap/kernel'; +import { makeLogger } from '@ocap/utils'; + +import { isKernelControlCommand } from './messages.js'; +import type { KernelControlReply, KernelControlCommand } from './messages.js'; + +const logger = makeLogger('[kernel panel messages]'); +/** + * Handle messages from the Kernel Panel. + * + * @param kernel - The kernel instance. + */ +export function handlePanelMessages(kernel: Kernel): void { + // Handle Kernel Panel messages + // TODO: This is a temporary solution to allow the kernel worker to send replies back to the + // offscreen script. This should be replaced with MultiplexStream once it's implemented. + globalThis.addEventListener('message', (event) => { + if (isKernelControlCommand(event.data)) { + handleMessage(kernel, event.data) + .then((reply) => globalThis.postMessage(reply)) + .catch(logger.error); + } + }); +} + +/** + * Handle a control message and return the appropriate reply. + * + * @param kernel - The kernel instance. + * @param message - The control message to handle. + * @returns The reply to the control message. + */ +async function handleMessage( + kernel: Kernel, + message: KernelControlCommand, +): Promise { + switch (message.method) { + case 'launchVat': + await kernel.launchVat({ id: message.params.id }); + return { method: 'launchVat', params: null }; + + case 'restartVat': + await kernel.restartVat(message.params.id); + return { method: 'restartVat', params: null }; + + case 'terminateVat': + await kernel.terminateVat(message.params.id); + return { method: 'terminateVat', params: null }; + + case 'terminateAllVats': + await kernel.terminateAllVats(); + return { method: 'terminateAllVats', params: null }; + + case 'getStatus': + return { + method: 'getStatus', + params: { + isRunning: true, // TODO: Track actual kernel state + activeVats: kernel.getVatIds(), + }, + }; + + case 'sendMessage': + try { + if ( + isVatId(message.params.id) && + !['kVGet', 'kVSet'].includes(message.params.payload.method) + ) { + const result = await kernel.sendMessage( + message.params.id, + message.params.payload as VatCommand['payload'], + ); + return { method: 'sendMessage', params: { result } as Json }; + } + + if (isKernelCommand(message.params.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: 'sendMessage', + params: { key: message.params.payload.params, result } as Json, + }; + } else if (message.params.payload.method === 'kVSet') { + kernel.kvSet( + message.params.payload.params.key, + message.params.payload.params.value, + ); + return { + method: 'sendMessage', + params: message.params.payload.params, + }; + } + } + + if (['ping', 'evaluate'].includes(message.params.payload.method)) { + throw new Error('Specify Vat ID to send this command'); + } + + throw new Error('Unknown command'); + } catch (error) { + return { + method: 'sendMessage', + params: { + error: error instanceof Error ? error.message : error, + } as Json, + }; + } + + default: + logger.error('Unknown control message method', message); + throw new Error(`Unknown control message: ${JSON.stringify(message)}`); + } +} diff --git a/packages/extension/src/kernel/kernel-worker.ts b/packages/extension/src/kernel/kernel-worker.ts index 00595fada..cc1df64e5 100644 --- a/packages/extension/src/kernel/kernel-worker.ts +++ b/packages/extension/src/kernel/kernel-worker.ts @@ -2,10 +2,9 @@ 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 { makeLogger, stringify } from '@ocap/utils'; +import { makeLogger } from '@ocap/utils'; -import { isKernelControlCommand } from './messages.js'; -import type { KernelControlCommand, KernelControlReply } from './messages.js'; +import { handlePanelMessages } from './handle-panel-messages.js'; import { makeSQLKVStore } from './sqlite-kv-store.js'; import { ExtensionVatWorkerClient } from './VatWorkerClient.js'; @@ -39,16 +38,7 @@ async function main(): Promise { await runVatLifecycle(kernel, ['v1', 'v2', 'v3']); await kernel.launchVat({ id: 'v0' }); - // Handle Kernel Panel messages - // TODO: This is a temporary solution to allow the kernel worker to send replies back to the - // offscreen script. This should be replaced with MultiplexStream once it's implemented. - globalThis.addEventListener('message', (event) => { - if (isKernelControlCommand(event.data)) { - handleControlMessage(kernel, event.data) - .then((reply) => globalThis.postMessage(reply)) - .catch(logger.error); - } - }); + handlePanelMessages(kernel); } /** @@ -89,46 +79,3 @@ async function runVatLifecycle( logger.log(`Kernel has ${kernel.getVatIds().length} vats`); } - -/** - * Handle a control message and return the appropriate reply. - * - * @param kernel - The kernel instance. - * @param message - The control message to handle. - * @returns The reply to the control message. - */ -async function handleControlMessage( - kernel: Kernel, - message: KernelControlCommand, -): Promise { - switch (message.method) { - case 'launchVat': - await kernel.launchVat({ id: message.params.id }); - return { method: 'launchVat', params: null }; - - case 'restartVat': - await kernel.restartVat(message.params.id); - return { method: 'restartVat', params: null }; - - case 'terminateVat': - await kernel.terminateVat(message.params.id); - return { method: 'terminateVat', params: null }; - - case 'terminateAllVats': - await kernel.terminateAllVats(); - return { method: 'terminateAllVats', params: null }; - - case 'getStatus': - return { - method: 'getStatus', - params: { - isRunning: true, // TODO: Track actual kernel state - activeVats: kernel.getVatIds(), - }, - }; - - default: - logger.error('Unknown control message method', message); - throw new Error(`Unknown control message: ${stringify(message)}`); - } -} diff --git a/packages/extension/src/kernel/messages.ts b/packages/extension/src/kernel/messages.ts index b838cc915..8ad89b377 100644 --- a/packages/extension/src/kernel/messages.ts +++ b/packages/extension/src/kernel/messages.ts @@ -1,5 +1,6 @@ import { isObject } from '@metamask/utils'; -import type { VatId } from '@ocap/kernel'; +import type { Json } from '@metamask/utils'; +import type { KernelCommand, VatId } from '@ocap/kernel'; import { makeMessageKit, messageType, isVatId } from '@ocap/kernel'; import type { TypeGuard } from '@ocap/utils'; @@ -37,6 +38,13 @@ const kernelControlCommand = { (send) => send === null, isKernelStatus, ), + SendMessage: messageType<{ id?: VatId; payload: KernelCommand }, Json>( + (send) => + isObject(send) && + (send.id === undefined || isVatId(send.id)) && + isObject(send.payload), + (reply) => isObject(reply), + ), }; const kernelControlKit = makeMessageKit(kernelControlCommand); diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 547199238..de8f22d46 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -152,6 +152,7 @@ async function main(): Promise { }); return stream.drain(async (message) => { + logger.log('sending message to kernel from popup', message); kernelWorker.worker.postMessage(message); }); }) diff --git a/packages/extension/src/panel/buttons.ts b/packages/extension/src/panel/buttons.ts new file mode 100644 index 000000000..2088f5fd1 --- /dev/null +++ b/packages/extension/src/panel/buttons.ts @@ -0,0 +1,64 @@ +import type { VatId } from '@ocap/kernel'; +import type { KernelControlCommand } from 'src/kernel/messages.js'; + +import { logger } from './shared.js'; + +export const vatId = document.getElementById('vat-id') as HTMLSelectElement; +export const newVatId = document.getElementById( + 'new-vat-id', +) as HTMLInputElement; + +export const buttons: Record< + string, + { + element: HTMLButtonElement; + command: () => KernelControlCommand | undefined; + } +> = { + 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: vatId.value as VatId }, + }), + }, + terminateVat: { + element: document.getElementById('terminate-vat') as HTMLButtonElement, + command: () => ({ + method: 'terminateVat', + params: { id: vatId.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', () => { + const message = button.command(); + if (message) { + sendMessage(message).catch(logger.error); + } + }); + }); +} diff --git a/packages/extension/src/panel/messages.ts b/packages/extension/src/panel/messages.ts new file mode 100644 index 000000000..0d454ddd2 --- /dev/null +++ b/packages/extension/src/panel/messages.ts @@ -0,0 +1,91 @@ +import { ClusterCommandMethod, isVatId } from '@ocap/kernel'; +import type { KernelCommand } from '@ocap/kernel'; +import { stringify } from '@ocap/utils'; + +import { vatId } from './buttons.js'; +import type { KernelControlCommand } from '../kernel/messages.js'; + +const outputBox = document.getElementById('output-box') as HTMLElement; +const messageOutput = document.getElementById( + 'message-output', +) as HTMLPreElement; +const messageContent = document.getElementById( + 'message-content', +) as HTMLInputElement; +const messageTemplates = document.getElementById( + 'message-templates', +) as HTMLElement; +const sendButton = document.getElementById('send-message') as HTMLButtonElement; + +export const commonMessages: Record = { + Ping: { method: ClusterCommandMethod.Ping, params: null }, + Evaluate: { + method: ClusterCommandMethod.Evaluate, + params: `[1,2,3].join(',')`, + }, + KVSet: { + method: ClusterCommandMethod.KVSet, + params: { key: 'foo', value: 'bar' }, + }, + KVGet: { method: ClusterCommandMethod.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 params: KernelControlCommand['params'] = { + payload: JSON.parse(messageContent.value), + }; + if (isVatId(vatId.value)) { + params.id = vatId.value; + } + await sendMessage({ + method: 'sendMessage', + params, + }); + })().catch((error) => showOutput(String(error), 'error')); + }); + + messageContent.addEventListener('input', () => { + sendButton.disabled = !messageContent.value.trim(); + }); + + vatId.addEventListener('change', () => { + sendButton.textContent = vatId.value ? 'Send to Vat' : 'Send'; + }); +} 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.ts b/packages/extension/src/panel/status.ts new file mode 100644 index 000000000..7ae414bbe --- /dev/null +++ b/packages/extension/src/panel/status.ts @@ -0,0 +1,104 @@ +import type { VatId } from '@ocap/kernel'; +import { stringify } from '@ocap/utils'; + +import { buttons } from './buttons.js'; +import type { KernelStatus } from '../kernel/messages.js'; + +const statusDisplay = document.getElementById('status-display') as HTMLElement; +const vatId = document.getElementById('vat-id') as HTMLSelectElement; +const newVatId = document.getElementById('new-vat-id') as HTMLInputElement; + +/** + * 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'; + + updateVatSelect(activeVats); +} + +/** + * Setup listeners for vat ID input and change events. + */ +export function setupVatListeners(): void { + newVatId.addEventListener('input', () => { + updateButtonStates(vatId.options.length > 1); + }); + + vatId.addEventListener('change', () => { + updateButtonStates(vatId.options.length > 1); + }); +} + +/** + * Updates the vat selection dropdown with active vats + * + * @param activeVats - Array of active vat IDs + */ +function updateVatSelect(activeVats: VatId[]): void { + // Compare current options with new vats + const currentVats = Array.from(vatId.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 = vatId.value; + + // Clear existing options except the default one + while (vatId.options.length > 1) { + vatId.remove(1); + } + + // Add new options + activeVats.forEach((id) => { + const option = document.createElement('option'); + option.value = id; + option.text = id; + vatId.add(option); + }); + + // Restore selection if it still exists + if (activeVats.includes(currentSelection as VatId)) { + vatId.value = currentSelection; + } else { + vatId.value = ''; + } + + // Update button states + updateButtonStates(activeVats.length > 0); +} + +/** + * Updates button states based on selections and vat existence + * + * @param hasVats - Whether any vats exist + */ +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 = !vatId.value; + } + if (buttons.terminateVat) { + buttons.terminateVat.element.disabled = !vatId.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..2ca2712bb --- /dev/null +++ b/packages/extension/src/panel/stream.ts @@ -0,0 +1,79 @@ +import { ChromeRuntimeDuplexStream, ChromeRuntimeTarget } from '@ocap/streams'; +import { stringify } from '@ocap/utils'; + +import { showOutput } from './messages.js'; +import { logger } from './shared.js'; +import { updateStatusDisplay } from './status.js'; +import { isKernelControlReply, isKernelStatus } 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 +> { + chrome.runtime.connect({ name: 'popup' }); + + const offscreenStream = await ChromeRuntimeDuplexStream.make< + KernelControlReply, + KernelControlCommand + >(chrome.runtime, ChromeRuntimeTarget.Popup, ChromeRuntimeTarget.Offscreen); + + const sendMessage = async (message: KernelControlCommand): Promise => { + logger.log('sending message', message); + await offscreenStream.write(message); + }; + + offscreenStream + .drain((message) => { + if (!isKernelControlReply(message) || message.params === null) { + return; + } + + if (isKernelStatus(message.params)) { + updateStatusDisplay(message.params); + return; + } + + if (message.method === 'sendMessage') { + if (typeof message.params === 'object' && 'error' in message.params) { + showOutput(stringify(message.params.error, 0), 'error'); + } else { + showOutput(stringify(message.params, 2), 'info'); + } + } + }) + .catch((error) => { + logger.error('error draining offscreen stream', error); + }); + + return sendMessage; +} + +/** + * 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(); +} diff --git a/packages/extension/src/panel/styles.css b/packages/extension/src/panel/styles.css new file mode 100644 index 000000000..b14259da5 --- /dev/null +++ b/packages/extension/src/panel/styles.css @@ -0,0 +1,174 @@ +body * { + box-sizing: border-box; +} + +.kernel-panel { + padding: 16px; + font-family: + system-ui, + -apple-system, + sans-serif; +} + +.vat-controls { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 16px; +} + +button, +select, +input { + height: 36px; + padding: 0 14px; + border-radius: 3px; + border: 1px solid #ccc; + font-size: 14px; + margin: 4px; + background-color: white; + transition: background-color 0.1s; +} + +button { + white-space: nowrap; + cursor: pointer; + background-color: #f0f0f0; +} + +button:hover:not(:disabled) { + background-color: #ccc; +} + +button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +button.green { + background-color: #4caf50; + color: white; + border: none; +} + +button.red { + background-color: #f44336; + color: white; + border: none; +} + +button.yellow { + background-color: #ffeb3b; + border: none; +} + +button.red:hover:not(:disabled) { + color: white; + background-color: #333; +} + +select { + min-width: 120px; + cursor: pointer; + -webkit-appearance: none; + -moz-appearance: none; + 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 12px center; + background-size: 8px auto; + padding-right: 30px; +} + +#new-vat-id { + width: 80px; +} + +#status-display { + background: #f5f5f5; + padding: 12px 14px; + border-radius: 3px; + font-size: 12px; +} + +h3 { + margin: 4px; +} + +h4 { + margin: 0 0 8px; +} + +.kernel-status { + margin: 4px 4px 16px; +} + +#vat-id { + width: 60px; +} + +.message-panel { + margin: 4px 4px 16px; +} + +#message-templates { + display: flex; + gap: 8px; + margin-bottom: 8px; +} + +.message-input-row { + display: flex; + gap: 8px; + margin-bottom: 8px; +} + +#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: 12px; + color: #4956f9; + text-decoration: underline; + margin: 0; +} + +button.text-button:hover { + color: #333; + text-decoration: none; + background-color: transparent; +} + +#output-box { + margin-top: 8px; +} + +#message-output { + background: #f5f5f5; + padding: 12px 14px; + border-radius: 3px; + font-size: 12px; + max-height: 200px; + overflow-y: auto; + white-space: pre-wrap; + margin-top: 0; +} + +.error { + color: #f44336; +} + +.success { + color: #4caf50; +} diff --git a/packages/extension/src/popup.html b/packages/extension/src/popup.html index e088b4449..5c0c9bccd 100644 --- a/packages/extension/src/popup.html +++ b/packages/extension/src/popup.html @@ -3,109 +3,7 @@ Kernel Panel - + @@ -129,6 +27,23 @@

Kernel Status

+
+

Send Message

+
+
+ + +
+ +
+
diff --git a/packages/extension/src/popup.ts b/packages/extension/src/popup.ts index 758d8ff83..ed847b58c 100644 --- a/packages/extension/src/popup.ts +++ b/packages/extension/src/popup.ts @@ -1,192 +1,20 @@ -import type { VatId } from '@ocap/kernel'; -import { ChromeRuntimeDuplexStream, ChromeRuntimeTarget } from '@ocap/streams'; -import { makeLogger, stringify } from '@ocap/utils'; - -import { isKernelControlReply, isKernelStatus } from './kernel/messages.js'; -import type { - KernelControlCommand, - KernelControlReply, - KernelStatus, -} from './kernel/messages.js'; - -const logger = makeLogger('[Kernel Panel]'); - -// DOM Elements -const vatId = document.getElementById('vat-id') as HTMLSelectElement; -const newVatId = document.getElementById('new-vat-id') as HTMLInputElement; -const statusDisplay = document.getElementById('status-display') as HTMLElement; -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: vatId.value as VatId }, - }), - }, - terminateVat: { - element: document.getElementById('terminate-vat') as HTMLButtonElement, - command: () => ({ - method: 'terminateVat', - params: { id: vatId.value as VatId }, - }), - }, - terminateAllVats: { - element: document.getElementById('terminate-all') as HTMLButtonElement, - command: () => ({ - method: 'terminateAllVats', - params: null, - }), - }, -}; - -// Initialize and start the UI -main().catch(logger.error); - -/** - * Updates the vat selection dropdown with active vats - * - * @param activeVats - Array of active vat IDs - */ -function updateVatSelect(activeVats: VatId[]): void { - // Compare current options with new vats - const currentVats = Array.from(vatId.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 = vatId.value; - - // Clear existing options except the default one - while (vatId.options.length > 1) { - vatId.remove(1); - } - - // Add new options - activeVats.forEach((id) => { - const option = document.createElement('option'); - option.value = id; - option.text = id; - vatId.add(option); - }); - - // Restore selection if it still exists - if (activeVats.includes(currentSelection as VatId)) { - vatId.value = currentSelection; - } else { - vatId.value = ''; - } - - // Update button states - updateButtonStates(activeVats.length > 0); -} - -/** - * Updates button states based on selections and vat existence - * - * @param hasVats - Whether any vats exist - */ -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; - } - - // Restart and terminate buttons - enabled when a vat is selected - if (buttons.restartVat) { - buttons.restartVat.element.disabled = !vatId.value; - } - if (buttons.terminateVat) { - buttons.terminateVat.element.disabled = !vatId.value; - } - - // Terminate all - enabled only when vats exist - if (buttons.terminateAllVats) { - buttons.terminateAllVats.element.disabled = !hasVats; - } -} +import { setupButtonHandlers } from './panel/buttons.js'; +import { setupTemplateHandlers } from './panel/messages.js'; +import { logger } from './panel/shared.js'; +import { setupVatListeners } from './panel/status.js'; +import { setupStream, setupStatusPolling } from './panel/stream.js'; /** - * The main function for the popup script. + * Main function to initialize the popup. */ async function main(): Promise { - chrome.runtime.connect({ name: 'popup' }); - - const offscreenStream = await ChromeRuntimeDuplexStream.make< - KernelControlReply, - KernelControlCommand - >(chrome.runtime, ChromeRuntimeTarget.Popup, ChromeRuntimeTarget.Offscreen); - - const sendMessage = async (message: KernelControlCommand): Promise => { - logger.log('sending message', message); - await offscreenStream.write(message); - }; - - // Setup input change handlers - newVatId.addEventListener('input', () => { - updateButtonStates(vatId.options.length > 1); - }); + const sendMessage = await setupStream(); - vatId.addEventListener('change', () => { - updateButtonStates(vatId.options.length > 1); - }); + setupVatListeners(); + setupButtonHandlers(sendMessage); + setupTemplateHandlers(sendMessage); - // Setup all button handlers - Object.values(buttons).forEach((button) => { - button.element.addEventListener('click', () => { - sendMessage(button.command()).catch(logger.error); - }); - }); - - // Update the status display - const updateStatusDisplay = (status: KernelStatus): void => { - const { isRunning, activeVats } = status; - statusDisplay.textContent = isRunning - ? `Active Vats (${activeVats.length}): ${stringify(activeVats, 0)}` - : 'Kernel is not running'; - - updateVatSelect(activeVats); - }; - - // Handle messages from the offscreen script - const handleOffscreenMessage = (message: KernelControlReply): void => { - if (isKernelControlReply(message) && isKernelStatus(message.params)) { - updateStatusDisplay(message.params); - } - }; - - // Drain the offscreen stream - offscreenStream.drain(handleOffscreenMessage).catch((error) => { - logger.error('error draining offscreen stream', error); - }); - - // Fetch the status periodically - const fetchStatus = async (): Promise => { - await sendMessage({ - method: 'getStatus', - params: null, - }); - - setTimeout(() => { - fetchStatus().catch(logger.error); - }, 1000); - }; - await fetchStatus(); + await setupStatusPolling(sendMessage); } + +main().catch(logger.error); From 62526c3bc1f84ba336efe60ee99d6c9789e0a797 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 6 Nov 2024 18:27:35 +0100 Subject: [PATCH 13/39] fix import --- packages/extension/src/panel/buttons.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/src/panel/buttons.ts b/packages/extension/src/panel/buttons.ts index 2088f5fd1..e17c8062c 100644 --- a/packages/extension/src/panel/buttons.ts +++ b/packages/extension/src/panel/buttons.ts @@ -1,7 +1,7 @@ import type { VatId } from '@ocap/kernel'; -import type { KernelControlCommand } from 'src/kernel/messages.js'; import { logger } from './shared.js'; +import type { KernelControlCommand } from '../kernel/messages.js'; export const vatId = document.getElementById('vat-id') as HTMLSelectElement; export const newVatId = document.getElementById( From 251a3d75d6ad7e3b29229863a5237cb6b2511004 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Mon, 11 Nov 2024 18:03:03 +0000 Subject: [PATCH 14/39] apply multiplexer --- packages/extension/package.json | 1 + .../src/kernel/handle-panel-message.ts | 112 ++++++++ .../src/kernel/handle-panel-messages.ts | 118 -------- .../extension/src/kernel/kernel-worker.ts | 45 +++- packages/extension/src/kernel/messages.ts | 138 ++++++---- packages/extension/src/offscreen.ts | 253 ++++++++++-------- packages/extension/src/panel/messages.ts | 27 +- packages/kernel/src/index.test.ts | 2 + packages/kernel/src/index.ts | 2 +- yarn.lock | 1 + 10 files changed, 396 insertions(+), 303 deletions(-) create mode 100644 packages/extension/src/kernel/handle-panel-message.ts delete mode 100644 packages/extension/src/kernel/handle-panel-messages.ts 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.ts b/packages/extension/src/kernel/handle-panel-message.ts new file mode 100644 index 000000000..7922e1269 --- /dev/null +++ b/packages/extension/src/kernel/handle-panel-message.ts @@ -0,0 +1,112 @@ +import type { Json } from '@metamask/utils'; +import { Kernel, isVatId, isKernelCommand, isVatCommand } from '@ocap/kernel'; +import { makeLogger, stringify } 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: { + await kernel.launchVat({ id: message.params.id }); + return { method: KernelControlMethod.launchVat, params: null }; + } + + case KernelControlMethod.restartVat: { + await kernel.restartVat(message.params.id); + return { method: KernelControlMethod.restartVat, params: null }; + } + + case KernelControlMethod.terminateVat: { + 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'); + } + + if (!isVatCommand(message.params)) { + throw new Error(`Invalid vat command: ${stringify(message.params)}`); + } + + 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: KernelControlMethod.sendMessage, + params: { + error: error instanceof Error ? error.message : String(error), + } as Json, + }; + } +} diff --git a/packages/extension/src/kernel/handle-panel-messages.ts b/packages/extension/src/kernel/handle-panel-messages.ts deleted file mode 100644 index 11806085f..000000000 --- a/packages/extension/src/kernel/handle-panel-messages.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { Json } from '@metamask/utils'; -import { Kernel, isVatId, isKernelCommand } from '@ocap/kernel'; -import type { VatCommand } from '@ocap/kernel'; -import { makeLogger } from '@ocap/utils'; - -import { isKernelControlCommand } from './messages.js'; -import type { KernelControlReply, KernelControlCommand } from './messages.js'; - -const logger = makeLogger('[kernel panel messages]'); -/** - * Handle messages from the Kernel Panel. - * - * @param kernel - The kernel instance. - */ -export function handlePanelMessages(kernel: Kernel): void { - // Handle Kernel Panel messages - // TODO: This is a temporary solution to allow the kernel worker to send replies back to the - // offscreen script. This should be replaced with MultiplexStream once it's implemented. - globalThis.addEventListener('message', (event) => { - if (isKernelControlCommand(event.data)) { - handleMessage(kernel, event.data) - .then((reply) => globalThis.postMessage(reply)) - .catch(logger.error); - } - }); -} - -/** - * Handle a control message and return the appropriate reply. - * - * @param kernel - The kernel instance. - * @param message - The control message to handle. - * @returns The reply to the control message. - */ -async function handleMessage( - kernel: Kernel, - message: KernelControlCommand, -): Promise { - switch (message.method) { - case 'launchVat': - await kernel.launchVat({ id: message.params.id }); - return { method: 'launchVat', params: null }; - - case 'restartVat': - await kernel.restartVat(message.params.id); - return { method: 'restartVat', params: null }; - - case 'terminateVat': - await kernel.terminateVat(message.params.id); - return { method: 'terminateVat', params: null }; - - case 'terminateAllVats': - await kernel.terminateAllVats(); - return { method: 'terminateAllVats', params: null }; - - case 'getStatus': - return { - method: 'getStatus', - params: { - isRunning: true, // TODO: Track actual kernel state - activeVats: kernel.getVatIds(), - }, - }; - - case 'sendMessage': - try { - if ( - isVatId(message.params.id) && - !['kVGet', 'kVSet'].includes(message.params.payload.method) - ) { - const result = await kernel.sendMessage( - message.params.id, - message.params.payload as VatCommand['payload'], - ); - return { method: 'sendMessage', params: { result } as Json }; - } - - if (isKernelCommand(message.params.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: 'sendMessage', - params: { key: message.params.payload.params, result } as Json, - }; - } else if (message.params.payload.method === 'kVSet') { - kernel.kvSet( - message.params.payload.params.key, - message.params.payload.params.value, - ); - return { - method: 'sendMessage', - params: message.params.payload.params, - }; - } - } - - if (['ping', 'evaluate'].includes(message.params.payload.method)) { - throw new Error('Specify Vat ID to send this command'); - } - - throw new Error('Unknown command'); - } catch (error) { - return { - method: 'sendMessage', - params: { - error: error instanceof Error ? error.message : error, - } as Json, - }; - } - - default: - logger.error('Unknown control message method', message); - throw new Error(`Unknown control message: ${JSON.stringify(message)}`); - } -} diff --git a/packages/extension/src/kernel/kernel-worker.ts b/packages/extension/src/kernel/kernel-worker.ts index cc1df64e5..b362eff6f 100644 --- a/packages/extension/src/kernel/kernel-worker.ts +++ b/packages/extension/src/kernel/kernel-worker.ts @@ -1,10 +1,16 @@ 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 { + MessagePortDuplexStream, + receiveMessagePort, + StreamMultiplexer, +} from '@ocap/streams'; +import type { MultiplexEnvelope } from '@ocap/streams'; import { makeLogger } from '@ocap/utils'; -import { handlePanelMessages } from './handle-panel-messages.js'; +import { handlePanelMessage } from './handle-panel-message.js'; +import type { KernelControlCommand, KernelControlReply } from './messages.js'; import { makeSQLKVStore } from './sqlite-kv-store.js'; import { ExtensionVatWorkerClient } from './VatWorkerClient.js'; @@ -16,11 +22,18 @@ main().catch((error) => logger.error('Kernel worker error:', error)); * */ 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 @@ -30,15 +43,35 @@ async function main(): Promise { ); const kvStore = await makeSQLKVStore(); + // Create kernel channel for kernel commands + const kernelStream = multiplexer.addChannel< + KernelCommand, + KernelCommandReply + >('kernel', async (command) => { + // The kernel will handle commands through its own drain method + logger.log('Kernel received command:', command); + }); + // Create and initialize kernel const kernel = new Kernel(kernelStream, vatWorkerClient, kvStore); await kernel.init(); + // Create panel channel for panel control messages + const panelStream = multiplexer.addChannel< + KernelControlCommand, + KernelControlReply + >('panel', async (message) => { + logger.log('Kernel received panel message:', message); + const reply = await handlePanelMessage(kernel, message); + await panelStream.write(reply); + }); + // Run default kernel lifecycle await runVatLifecycle(kernel, ['v1', 'v2', 'v3']); await kernel.launchVat({ id: 'v0' }); - handlePanelMessages(kernel); + // Start multiplexer + await multiplexer.drainAll(); } /** diff --git a/packages/extension/src/kernel/messages.ts b/packages/extension/src/kernel/messages.ts index 8ad89b377..ac4c94ca5 100644 --- a/packages/extension/src/kernel/messages.ts +++ b/packages/extension/src/kernel/messages.ts @@ -1,59 +1,107 @@ -import { isObject } from '@metamask/utils'; +import { + object, + union, + literal, + boolean, + array, + type, + is, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; -import type { KernelCommand, VatId } from '@ocap/kernel'; -import { makeMessageKit, messageType, isVatId } from '@ocap/kernel'; +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 => - isObject(value) && - typeof value.isRunning === 'boolean' && - Array.isArray(value.activeVats) && - value.activeVats.every((id) => isVatId(id)); - -const kernelControlCommand = { - LaunchVat: messageType<{ id: VatId }, null>( - (send) => isObject(send) && isVatId(send.id), - (reply) => reply === null, - ), - RestartVat: messageType<{ id: VatId }, null>( - (send) => isObject(send) && isVatId(send.id), - (reply) => reply === null, - ), - TerminateVat: messageType<{ id: VatId }, null>( - (send) => isObject(send) && isVatId(send.id), - (reply) => reply === null, - ), - TerminateAllVats: messageType( - (send) => send === null, - (reply) => reply === null, - ), - GetStatus: messageType( - (send) => send === null, - isKernelStatus, - ), - SendMessage: messageType<{ id?: VatId; payload: KernelCommand }, Json>( - (send) => - isObject(send) && - (send.id === undefined || isVatId(send.id)) && - isObject(send.payload), - (reply) => isObject(reply), - ), -}; +): 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 kernelControlKit = makeMessageKit(kernelControlCommand); +const KernelControlReplyStruct = union([ + object({ + method: literal(KernelControlMethod.launchVat), + params: literal(null), + }), + object({ + method: literal(KernelControlMethod.restartVat), + params: literal(null), + }), + object({ + method: literal(KernelControlMethod.terminateVat), + params: literal(null), + }), + object({ + method: literal(KernelControlMethod.terminateAllVats), + params: literal(null), + }), + object({ + method: literal(KernelControlMethod.getStatus), + params: KernelStatusStruct, + }), + object({ + method: literal(KernelControlMethod.sendMessage), + params: UnsafeJsonStruct, + }), +]); -export const isKernelControlCommand: TypeGuard = - kernelControlKit.sendGuard; +export type KernelControlCommand = Infer & + Json; +export type KernelControlReply = Infer & Json; -export const isKernelControlReply: TypeGuard = - kernelControlKit.replyGuard; +export const isKernelControlCommand: TypeGuard = ( + value: unknown, +): value is KernelControlCommand => is(value, KernelControlCommandStruct); -export type KernelControlCommand = typeof kernelControlKit.send; -export type KernelControlReply = typeof kernelControlKit.reply; +export const isKernelControlReply: TypeGuard = ( + value: unknown, +): value is KernelControlReply => is(value, KernelControlReplyStruct); diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index de8f22d46..8246ba84b 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -5,7 +5,9 @@ import { 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'; @@ -21,143 +23,156 @@ const logger = makeLogger('[offscreen]'); main().catch(logger.error); /** - * The main function for the offscreen script. + * Main function to initialize the offscreen page. */ 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( - chrome.runtime, - ChromeRuntimeTarget.Offscreen, - ChromeRuntimeTarget.Background, - ); + const backgroundStream = await setupBackgroundStream(); - const kernelWorker = await makeKernelWorker(); + const workerStream = await setupKernelWorker(); - setupPopupStream(); - - /** - * 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. + // Add kernel channel + const kernelChannel = multiplexer.addChannel< + KernelCommandReply, + KernelCommand + >('kernel', async (reply) => { + if (isKernelCommandReply(reply)) { + await backgroundStream.write(reply); + } + }); + + let popupStream: ChromeRuntimeDuplexStream< + KernelControlCommand, + KernelControlReply + > | null = null; + + // Add panel channel + const panelChannel = multiplexer.addChannel< + KernelControlReply, + KernelControlCommand + >('panel', async (reply) => { + if (isKernelControlReply(reply) && popupStream) { + await popupStream.write(reply); + } + }); + + // Setup popup communication + setupPopupStream(panelChannel, (stream) => { + popupStream = stream; + }); + + // Handle messages from the background script and the multiplexer await Promise.all([ - kernelWorker.receiveMessages(), + multiplexer.drainAll(), (async () => { for await (const message of backgroundStream) { if (!isKernelCommand(message)) { logger.error('Offscreen received unexpected message', message); continue; } - - await kernelWorker.sendMessage(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; - worker: Worker; - }> { - 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), - ); - - const vatWorkerServer = new ExtensionVatWorkerServer( - (message, transfer?) => - transfer - ? worker.postMessage(message, transfer) - : worker.postMessage(message), - (listener) => worker.addEventListener('message', listener), - (vatId) => makeIframeVatWorker(vatId, initializeMessageChannel), - ); - - vatWorkerServer.start(); - - 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; - } +/** + * Creates and sets up communication with the background script. + * + * @returns A duplex stream for background communication + */ +async function setupBackgroundStream(): Promise< + ChromeRuntimeDuplexStream +> { + return ChromeRuntimeDuplexStream.make( + chrome.runtime, + ChromeRuntimeTarget.Offscreen, + ChromeRuntimeTarget.Background, + ); +} - await replyToBackground(message); - } - }; - - const sendMessage = async (message: KernelCommand): Promise => { - await workerStream.write(message); - }; - - return { - sendMessage, - receiveMessages, - worker, - }; - } - - /** - * Set up the popup stream. - */ - function setupPopupStream(): void { - // Set up the stream to the popup every time the popup shows. - // This is necessary because the stream is closed when the popup is closed. - chrome.runtime.onConnect.addListener((port) => { - if (port.name === 'popup') { - ChromeRuntimeDuplexStream.make< - KernelControlCommand, - KernelControlReply - >( - chrome.runtime, - ChromeRuntimeTarget.Offscreen, - ChromeRuntimeTarget.Popup, - ) - .then(async (stream) => { - const replyToPopup = (event: MessageEvent): void => { - if (isKernelControlReply(event.data)) { - // eslint-disable-next-line promise/no-nesting - stream.write(event.data).catch(logger.error); - } - }; - - kernelWorker.worker.addEventListener('message', replyToPopup); - - // Close the stream when the popup is closed - port.onDisconnect.addListener(() => { - // eslint-disable-next-line promise/no-nesting - stream.return().catch(console.error); - kernelWorker.worker.removeEventListener('message', replyToPopup); - }); - - return stream.drain(async (message) => { - logger.log('sending message to kernel from popup', message); - kernelWorker.worker.postMessage(message); - }); - }) - .catch(logger.error); - } - }); - } +/** + * 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 port = await initializeMessageChannel((message, transfer) => + worker.postMessage(message, transfer), + ); + + 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), + ); + + vatWorkerServer.start(); + + return workerStream; +} + +/** + * 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') { + ChromeRuntimeDuplexStream.make( + chrome.runtime, + ChromeRuntimeTarget.Offscreen, + ChromeRuntimeTarget.Popup, + ) + .then(async (stream) => { + // Close the stream when the popup is closed + port.onDisconnect.addListener(() => { + // eslint-disable-next-line promise/no-nesting + stream.return().catch(console.error); + onStreamCreated(null); + }); + + onStreamCreated(stream); + + return stream.drain(async (message) => { + logger.log('sending message to kernel from popup', message); + await panelChannel.write(message); + }); + }) + .catch((error) => { + logger.error(error); + onStreamCreated(null); + }); + } + }); } diff --git a/packages/extension/src/panel/messages.ts b/packages/extension/src/panel/messages.ts index 0d454ddd2..2479955be 100644 --- a/packages/extension/src/panel/messages.ts +++ b/packages/extension/src/panel/messages.ts @@ -1,9 +1,10 @@ -import { ClusterCommandMethod, isVatId } from '@ocap/kernel'; +import { KernelCommandMethod, VatCommandMethod, isVatId } from '@ocap/kernel'; import type { KernelCommand } from '@ocap/kernel'; import { stringify } from '@ocap/utils'; import { vatId } from './buttons.js'; import type { KernelControlCommand } from '../kernel/messages.js'; +import { KernelControlMethod } from '../kernel/messages.js'; const outputBox = document.getElementById('output-box') as HTMLElement; const messageOutput = document.getElementById( @@ -18,16 +19,16 @@ const messageTemplates = document.getElementById( const sendButton = document.getElementById('send-message') as HTMLButtonElement; export const commonMessages: Record = { - Ping: { method: ClusterCommandMethod.Ping, params: null }, + Ping: { method: VatCommandMethod.ping, params: null }, Evaluate: { - method: ClusterCommandMethod.Evaluate, + method: VatCommandMethod.evaluate, params: `[1,2,3].join(',')`, }, KVSet: { - method: ClusterCommandMethod.KVSet, + method: KernelCommandMethod.kvSet, params: { key: 'foo', value: 'bar' }, }, - KVGet: { method: ClusterCommandMethod.KVGet, params: 'foo' }, + KVGet: { method: KernelCommandMethod.kvGet, params: 'foo' }, }; /** @@ -68,16 +69,14 @@ export function setupTemplateHandlers( sendButton.addEventListener('click', () => { (async () => { - const params: KernelControlCommand['params'] = { - payload: JSON.parse(messageContent.value), + const command: KernelControlCommand = { + method: KernelControlMethod.sendMessage, + params: { + payload: JSON.parse(messageContent.value), + ...(isVatId(vatId.value) ? { id: vatId.value } : {}), + }, }; - if (isVatId(vatId.value)) { - params.id = vatId.value; - } - await sendMessage({ - method: 'sendMessage', - params, - }); + await sendMessage(command); })().catch((error) => showOutput(String(error), 'error')); }); diff --git a/packages/kernel/src/index.test.ts b/packages/kernel/src/index.test.ts index 543c6417f..5ab874042 100644 --- a/packages/kernel/src/index.test.ts +++ b/packages/kernel/src/index.test.ts @@ -11,11 +11,13 @@ describe('index', () => { '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 d2c7e4522..8402a0ec6 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -4,4 +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 } from './types.js'; +export { isVatId, VatIdStruct } from './types.js'; 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:^" From 71d8cf8f0053071198387c8a022e70072e498a22 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Mon, 11 Nov 2024 18:10:30 +0000 Subject: [PATCH 15/39] cleaning --- .../extension/src/kernel/kernel-worker.ts | 2 +- packages/extension/src/offscreen.ts | 29 +++++++++---------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/extension/src/kernel/kernel-worker.ts b/packages/extension/src/kernel/kernel-worker.ts index b362eff6f..1c8c0e026 100644 --- a/packages/extension/src/kernel/kernel-worker.ts +++ b/packages/extension/src/kernel/kernel-worker.ts @@ -16,7 +16,7 @@ import { ExtensionVatWorkerClient } from './VatWorkerClient.js'; const logger = makeLogger('[kernel worker]'); -main().catch((error) => logger.error('Kernel worker error:', error)); +main().catch(logger.error); /** * diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 8246ba84b..4ebdb0d44 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -84,21 +84,6 @@ async function main(): Promise { ]); } -/** - * Creates and sets up communication with the background script. - * - * @returns A duplex stream for background communication - */ -async function setupBackgroundStream(): Promise< - ChromeRuntimeDuplexStream -> { - return ChromeRuntimeDuplexStream.make( - chrome.runtime, - ChromeRuntimeTarget.Offscreen, - ChromeRuntimeTarget.Background, - ); -} - /** * Creates and initializes the kernel worker. * @@ -132,6 +117,20 @@ async function setupKernelWorker(): Promise< return workerStream; } +/** + * Creates and sets up communication with the background script. + * + * @returns A duplex stream for background communication + */ +async function setupBackgroundStream(): Promise< + ChromeRuntimeDuplexStream +> { + return ChromeRuntimeDuplexStream.make( + chrome.runtime, + ChromeRuntimeTarget.Offscreen, + ChromeRuntimeTarget.Background, + ); +} /** * Sets up the popup communication stream. * From 12ca7ae80140725c21c86314f13d5efe41434fbc Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Mon, 11 Nov 2024 18:44:04 +0000 Subject: [PATCH 16/39] assert params on send message --- .../src/kernel/handle-panel-message.ts | 14 +++++++---- packages/extension/src/offscreen.ts | 24 +++++++------------ packages/kernel/src/index.test.ts | 1 + packages/kernel/src/messages/index.ts | 1 + packages/kernel/src/messages/kernel.ts | 11 +++++++++ packages/kernel/src/messages/vat.ts | 2 +- 6 files changed, 32 insertions(+), 21 deletions(-) diff --git a/packages/extension/src/kernel/handle-panel-message.ts b/packages/extension/src/kernel/handle-panel-message.ts index 7922e1269..1216246e2 100644 --- a/packages/extension/src/kernel/handle-panel-message.ts +++ b/packages/extension/src/kernel/handle-panel-message.ts @@ -1,6 +1,12 @@ +import { assert } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; -import { Kernel, isVatId, isKernelCommand, isVatCommand } from '@ocap/kernel'; -import { makeLogger, stringify } from '@ocap/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'; @@ -81,9 +87,7 @@ export async function handlePanelMessage( throw new Error('Vat ID required for this command'); } - if (!isVatCommand(message.params)) { - throw new Error(`Invalid vat command: ${stringify(message.params)}`); - } + assert(message.params, KernelSendMessageStruct); const result = await kernel.sendMessage( message.params.id, diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 4ebdb0d44..a3821296a 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -29,7 +29,15 @@ 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 setupBackgroundStream(); + // Create stream for messages from the background script + const backgroundStream = await ChromeRuntimeDuplexStream.make< + KernelCommand, + KernelCommandReply + >( + chrome.runtime, + ChromeRuntimeTarget.Offscreen, + ChromeRuntimeTarget.Background, + ); const workerStream = await setupKernelWorker(); @@ -117,20 +125,6 @@ async function setupKernelWorker(): Promise< return workerStream; } -/** - * Creates and sets up communication with the background script. - * - * @returns A duplex stream for background communication - */ -async function setupBackgroundStream(): Promise< - ChromeRuntimeDuplexStream -> { - return ChromeRuntimeDuplexStream.make( - chrome.runtime, - ChromeRuntimeTarget.Offscreen, - ChromeRuntimeTarget.Background, - ); -} /** * Sets up the popup communication stream. * diff --git a/packages/kernel/src/index.test.ts b/packages/kernel/src/index.test.ts index 5ab874042..a2e8955b0 100644 --- a/packages/kernel/src/index.test.ts +++ b/packages/kernel/src/index.test.ts @@ -8,6 +8,7 @@ describe('index', () => { expect(Object.keys(indexModule).sort()).toStrictEqual([ 'Kernel', 'KernelCommandMethod', + 'KernelSendMessageStruct', 'Supervisor', 'Vat', 'VatCommandMethod', 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), From 93166f5268317e70f8b2ea903ff556bffa1c2a97 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Mon, 11 Nov 2024 18:57:00 +0000 Subject: [PATCH 17/39] rmeove logs --- packages/extension/src/kernel/kernel-worker.ts | 4 +--- packages/extension/src/offscreen.ts | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/extension/src/kernel/kernel-worker.ts b/packages/extension/src/kernel/kernel-worker.ts index 1c8c0e026..0af7533c0 100644 --- a/packages/extension/src/kernel/kernel-worker.ts +++ b/packages/extension/src/kernel/kernel-worker.ts @@ -47,9 +47,8 @@ async function main(): Promise { const kernelStream = multiplexer.addChannel< KernelCommand, KernelCommandReply - >('kernel', async (command) => { + >('kernel', async () => { // The kernel will handle commands through its own drain method - logger.log('Kernel received command:', command); }); // Create and initialize kernel @@ -61,7 +60,6 @@ async function main(): Promise { KernelControlCommand, KernelControlReply >('panel', async (message) => { - logger.log('Kernel received panel message:', message); const reply = await handlePanelMessage(kernel, message); await panelStream.write(reply); }); diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index a3821296a..7448de117 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -158,7 +158,6 @@ function setupPopupStream( onStreamCreated(stream); return stream.drain(async (message) => { - logger.log('sending message to kernel from popup', message); await panelChannel.write(message); }); }) From 584a6d8cae9e74bf016182335606dcc71d90277f Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Mon, 11 Nov 2024 18:59:31 +0000 Subject: [PATCH 18/39] clean --- packages/extension/src/kernel/kernel-worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/src/kernel/kernel-worker.ts b/packages/extension/src/kernel/kernel-worker.ts index 0af7533c0..a40e95881 100644 --- a/packages/extension/src/kernel/kernel-worker.ts +++ b/packages/extension/src/kernel/kernel-worker.ts @@ -47,7 +47,7 @@ async function main(): Promise { const kernelStream = multiplexer.addChannel< KernelCommand, KernelCommandReply - >('kernel', async () => { + >('kernel', () => { // The kernel will handle commands through its own drain method }); From 22768ebb116bcb9ffcde2ffc827a78cadc381a43 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Mon, 11 Nov 2024 21:48:31 +0000 Subject: [PATCH 19/39] rewrite response --- .../extension/src/kernel/kernel-worker.ts | 1 + packages/extension/src/panel/stream.ts | 27 ++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/extension/src/kernel/kernel-worker.ts b/packages/extension/src/kernel/kernel-worker.ts index a40e95881..7d98cabf2 100644 --- a/packages/extension/src/kernel/kernel-worker.ts +++ b/packages/extension/src/kernel/kernel-worker.ts @@ -31,6 +31,7 @@ async function main(): Promise { MultiplexEnvelope, MultiplexEnvelope >(port); + const multiplexer = new StreamMultiplexer( baseStream, 'KernelWorkerMultiplexer', diff --git a/packages/extension/src/panel/stream.ts b/packages/extension/src/panel/stream.ts index 2ca2712bb..c4ace85fc 100644 --- a/packages/extension/src/panel/stream.ts +++ b/packages/extension/src/panel/stream.ts @@ -42,11 +42,16 @@ export async function setupStream(): Promise< } if (message.method === 'sendMessage') { - if (typeof message.params === 'object' && 'error' in message.params) { - showOutput(stringify(message.params.error, 0), 'error'); - } else { - showOutput(stringify(message.params, 2), 'info'); + const { params } = message; + + // Handle error responses + if (isErrorResponse(params)) { + showOutput(stringify(params.error, 0), 'error'); + return; } + + // Handle successful responses + showOutput(stringify(params, 2), 'info'); } }) .catch((error) => { @@ -77,3 +82,17 @@ export async function setupStatusPolling( await fetchStatus(); } + +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; +} From 94c05f1c18bef7fe3425791c3bd65f46d69f20c3 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Mon, 11 Nov 2024 22:08:30 +0000 Subject: [PATCH 20/39] Cleanup popup stream on disconnect --- packages/extension/src/panel/stream.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/extension/src/panel/stream.ts b/packages/extension/src/panel/stream.ts index c4ace85fc..f92232872 100644 --- a/packages/extension/src/panel/stream.ts +++ b/packages/extension/src/panel/stream.ts @@ -18,18 +18,29 @@ import type { export async function setupStream(): Promise< (message: KernelControlCommand) => Promise > { - chrome.runtime.connect({ name: 'popup' }); + // 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); + // 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((message) => { if (!isKernelControlReply(message) || message.params === null) { From 36fa34eaf576006dbb47f27c2fa0ae1cbb2c00cf Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Mon, 11 Nov 2024 22:17:27 +0000 Subject: [PATCH 21/39] split setup Popup Stream --- packages/extension/src/offscreen.ts | 70 +++++++++++++++++++---------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 7448de117..14f73d15d 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -141,30 +141,52 @@ function setupPopupStream( ) => void, ): void { chrome.runtime.onConnect.addListener((port) => { - if (port.name === 'popup') { - ChromeRuntimeDuplexStream.make( - chrome.runtime, - ChromeRuntimeTarget.Offscreen, - ChromeRuntimeTarget.Popup, - ) - .then(async (stream) => { - // Close the stream when the popup is closed - port.onDisconnect.addListener(() => { - // eslint-disable-next-line promise/no-nesting - stream.return().catch(console.error); - onStreamCreated(null); - }); - - onStreamCreated(stream); - - return stream.drain(async (message) => { - await panelChannel.write(message); - }); - }) - .catch((error) => { - logger.error(error); - onStreamCreated(null); - }); + if (port.name !== 'popup') { + return; } + + // Handle stream creation + handlePopupConnection(port, panelChannel, onStreamCreated).catch( + (error) => { + logger.error(error); + onStreamCreated(null); + }, + ); + }); +} + +/** + * 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); }); } From 2b1ccc9680f1b23d65527e16900a5adb5d4b8f41 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Mon, 11 Nov 2024 22:22:22 +0000 Subject: [PATCH 22/39] move setupStatusPolling --- packages/extension/src/panel/status.ts | 25 ++++++++++++++++++++++++- packages/extension/src/panel/stream.ts | 22 ---------------------- packages/extension/src/popup.ts | 4 ++-- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/packages/extension/src/panel/status.ts b/packages/extension/src/panel/status.ts index 7ae414bbe..7c4ee67ce 100644 --- a/packages/extension/src/panel/status.ts +++ b/packages/extension/src/panel/status.ts @@ -2,12 +2,35 @@ import type { VatId } from '@ocap/kernel'; import { stringify } from '@ocap/utils'; import { buttons } from './buttons.js'; -import type { KernelStatus } from '../kernel/messages.js'; +import { logger } from './shared.js'; +import type { KernelControlCommand, KernelStatus } from '../kernel/messages.js'; const statusDisplay = document.getElementById('status-display') as HTMLElement; const vatId = document.getElementById('vat-id') as HTMLSelectElement; const newVatId = document.getElementById('new-vat-id') as HTMLInputElement; +/** + * 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. * diff --git a/packages/extension/src/panel/stream.ts b/packages/extension/src/panel/stream.ts index f92232872..1aa56e20a 100644 --- a/packages/extension/src/panel/stream.ts +++ b/packages/extension/src/panel/stream.ts @@ -72,28 +72,6 @@ export async function setupStream(): Promise< return sendMessage; } -/** - * 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(); -} - type ErrorResponse = { error: unknown; }; diff --git a/packages/extension/src/popup.ts b/packages/extension/src/popup.ts index ed847b58c..d812f41eb 100644 --- a/packages/extension/src/popup.ts +++ b/packages/extension/src/popup.ts @@ -1,8 +1,8 @@ import { setupButtonHandlers } from './panel/buttons.js'; import { setupTemplateHandlers } from './panel/messages.js'; import { logger } from './panel/shared.js'; -import { setupVatListeners } from './panel/status.js'; -import { setupStream, setupStatusPolling } from './panel/stream.js'; +import { setupStatusPolling, setupVatListeners } from './panel/status.js'; +import { setupStream } from './panel/stream.js'; /** * Main function to initialize the popup. From 4a169011b6e7f750f1f2b0b6282df13dfe085637 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 14 Nov 2024 12:59:14 +0000 Subject: [PATCH 23/39] add tests --- packages/extension/src/panel/buttons.test.ts | 84 +++++++ packages/extension/src/panel/messages.test.ts | 218 ++++++++++++++++++ packages/extension/src/panel/messages.ts | 6 +- packages/extension/test/panel-utils.ts | 18 ++ 4 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 packages/extension/src/panel/buttons.test.ts create mode 100644 packages/extension/src/panel/messages.test.ts create mode 100644 packages/extension/test/panel-utils.ts diff --git a/packages/extension/src/panel/buttons.test.ts b/packages/extension/src/panel/buttons.test.ts new file mode 100644 index 000000000..2210095c1 --- /dev/null +++ b/packages/extension/src/panel/buttons.test.ts @@ -0,0 +1,84 @@ +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.resetAllMocks(); + 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, vatId } = await import('./buttons'); + vatId.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, vatId } = await import('./buttons'); + vatId.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, vatId, setupButtonHandlers } = await import( + './buttons' + ); + newVatId.value = 'v1'; + vatId.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/messages.test.ts b/packages/extension/src/panel/messages.test.ts new file mode 100644 index 000000000..333552a3c --- /dev/null +++ b/packages/extension/src/panel/messages.test.ts @@ -0,0 +1,218 @@ +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'; + +const isVatId = vi.fn( + (input: unknown): input is VatId => typeof input === 'string', +); + +// 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.resetAllMocks(); + 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'); + }); + }); + + 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 { vatId } = await import('./buttons.js'); + const sendMessage = vi.fn().mockResolvedValue(undefined); + isVatId.mockReturnValue(true); + + setupTemplateHandlers(sendMessage); + + // Setup test data + messageContent.value = '{"method":"ping","params":null}'; + vatId.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 { vatId } = await import('./buttons.js'); + const sendMessage = vi.fn().mockResolvedValue(undefined); + isVatId.mockReturnValue(false); + + setupTemplateHandlers(sendMessage); + + messageContent.value = + '{"method":"kvSet","params":{"key":"test","value":"test"}}'; + vatId.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 sendMessage = vi.fn().mockResolvedValue(undefined); + + setupTemplateHandlers(sendMessage); + + const vatId = document.getElementById('vat-id') as HTMLSelectElement; + const sendButton = document.getElementById( + 'send-message', + ) as HTMLButtonElement; + + // With vat selected + vatId.value = 'v0'; + vatId.dispatchEvent(new Event('change')); + expect(sendButton.textContent).toBe('Send to Vat'); + + // Without vat selected + vatId.value = ''; + vatId.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'); + }); + }); +}); diff --git a/packages/extension/src/panel/messages.ts b/packages/extension/src/panel/messages.ts index 2479955be..f8ff690a9 100644 --- a/packages/extension/src/panel/messages.ts +++ b/packages/extension/src/panel/messages.ts @@ -10,13 +10,15 @@ const outputBox = document.getElementById('output-box') as HTMLElement; const messageOutput = document.getElementById( 'message-output', ) as HTMLPreElement; -const messageContent = document.getElementById( +export const messageContent = document.getElementById( 'message-content', ) as HTMLInputElement; const messageTemplates = document.getElementById( 'message-templates', ) as HTMLElement; -const sendButton = document.getElementById('send-message') as HTMLButtonElement; +export const sendButton = document.getElementById( + 'send-message', +) as HTMLButtonElement; export const commonMessages: Record = { Ping: { method: VatCommandMethod.ping, params: null }, diff --git a/packages/extension/test/panel-utils.ts b/packages/extension/test/panel-utils.ts new file mode 100644 index 000000000..477a41411 --- /dev/null +++ b/packages/extension/test/panel-utils.ts @@ -0,0 +1,18 @@ +import fs from 'fs/promises'; +import path from 'path'; + +/** + * Setup the DOM for the tests. + */ +export async function setupPanelDOM(): Promise { + const htmlPath = path.resolve(__dirname, '../src/popup.html'); + const html = await fs.readFile(htmlPath, 'utf-8'); + document.body.innerHTML = html; + + // Add test option to select + const vatSelect = document.getElementById('vat-id') as HTMLSelectElement; + const option = document.createElement('option'); + option.value = 'v0'; + option.text = 'v0'; + vatSelect.appendChild(option); +} From 7cb9dff1fa65a1140444c8db0a1a147c8ad1ce5c Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 14 Nov 2024 17:54:35 +0000 Subject: [PATCH 24/39] add more tests --- .../src/kernel/handle-panel-message.test.ts | 313 ++++++++++++++++++ .../extension/src/kernel/kernel-worker.ts | 45 +-- .../src/kernel/run-vat-lifecycle.test.ts | 77 +++++ .../extension/src/kernel/run-vat-lifecycle.ts | 42 +++ packages/extension/src/panel/status.test.ts | 233 +++++++++++++ packages/extension/src/panel/stream.ts | 2 + 6 files changed, 670 insertions(+), 42 deletions(-) create mode 100644 packages/extension/src/kernel/handle-panel-message.test.ts create mode 100644 packages/extension/src/kernel/run-vat-lifecycle.test.ts create mode 100644 packages/extension/src/kernel/run-vat-lifecycle.ts create mode 100644 packages/extension/src/panel/status.test.ts 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..4d0001e1c --- /dev/null +++ b/packages/extension/src/kernel/handle-panel-message.test.ts @@ -0,0 +1,313 @@ +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.resetAllMocks(); + 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 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 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 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: 'sendMessage', + 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: 'sendMessage', + params: { error: 'Kernel error' }, + }); + + vi.mocked(mockKernel.launchVat).mockRejectedValue('error'); + + const response2 = await handlePanelMessage(mockKernel, message); + + expect(response2).toStrictEqual({ + method: 'sendMessage', + params: { error: 'error' }, + }); + }); + }); +}); diff --git a/packages/extension/src/kernel/kernel-worker.ts b/packages/extension/src/kernel/kernel-worker.ts index 7d98cabf2..7c27dca6d 100644 --- a/packages/extension/src/kernel/kernel-worker.ts +++ b/packages/extension/src/kernel/kernel-worker.ts @@ -1,6 +1,5 @@ -import type { NonEmptyArray } from '@metamask/utils'; -import type { KernelCommand, KernelCommandReply, VatId } from '@ocap/kernel'; -import { Kernel, VatCommandMethod } from '@ocap/kernel'; +import type { KernelCommand, KernelCommandReply } from '@ocap/kernel'; +import { Kernel } from '@ocap/kernel'; import { MessagePortDuplexStream, receiveMessagePort, @@ -11,6 +10,7 @@ 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'; @@ -72,42 +72,3 @@ async function main(): Promise { // Start multiplexer await multiplexer.drainAll(); } - -/** - * Runs the full lifecycle of an array of vats - * - * @param kernel - The kernel instance. - * @param vats - The vats to run the lifecycle for. - */ -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(', ')}`); - - logger.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}`); - - logger.log(`Kernel has ${kernel.getVatIds().length} vats`); -} 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/panel/status.test.ts b/packages/extension/src/panel/status.test.ts new file mode 100644 index 000000000..9e1e1baef --- /dev/null +++ b/packages/extension/src/panel/status.test.ts @@ -0,0 +1,233 @@ +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 } = await import('./status'); + const activeVats: VatId[] = ['v0', 'v1', 'v2']; + + updateStatusDisplay({ + isRunning: true, + activeVats, + }); + + const statusDisplay = document.getElementById('status-display'); + expect(statusDisplay?.textContent).toBe( + `Active Vats (3): ["v0","v1","v2"]`, + ); + }); + + it('should display not running status', async () => { + const { updateStatusDisplay } = await import('./status'); + + updateStatusDisplay({ + isRunning: false, + activeVats: [], + }); + + const statusDisplay = document.getElementById('status-display'); + expect(statusDisplay?.textContent).toBe('Kernel is not running'); + }); + + it('should update vat select options', async () => { + const { updateStatusDisplay } = await import('./status'); + const activeVats: VatId[] = ['v0', 'v1']; + + updateStatusDisplay({ + isRunning: true, + activeVats, + }); + + const vatSelect = document.getElementById('vat-id') as HTMLSelectElement; + expect(vatSelect.options).toHaveLength(3); // Including empty option + expect(vatSelect.options[1]?.value).toBe('v0'); + expect(vatSelect.options[2]?.value).toBe('v1'); + }); + + it('should preserve selected vat if still active', async () => { + const { updateStatusDisplay } = await import('./status'); + const vatSelect = document.getElementById('vat-id') as HTMLSelectElement; + + // First update + updateStatusDisplay({ + isRunning: true, + activeVats: ['v0', 'v1'], + }); + vatSelect.value = 'v1'; + + // Second update with same vat still active + updateStatusDisplay({ + isRunning: true, + activeVats: ['v0', 'v1', 'v2'], + }); + + expect(vatSelect.value).toBe('v1'); + }); + + it('should clear selection if selected vat becomes inactive', async () => { + const { updateStatusDisplay } = await import('./status'); + const vatSelect = document.getElementById('vat-id') as HTMLSelectElement; + + // First update and selection + updateStatusDisplay({ + isRunning: true, + activeVats: ['v0', 'v1'], + }); + vatSelect.value = 'v1'; + + // Second update with selected vat removed + updateStatusDisplay({ + isRunning: true, + activeVats: ['v0'], + }); + + expect(vatSelect.value).toBe(''); + }); + + it('should skip vat select update if vats have not changed', async () => { + const { updateStatusDisplay } = await import('./status'); + const vatSelect = document.getElementById('vat-id') as HTMLSelectElement; + const activeVats: VatId[] = ['v0', 'v1']; + + // First update + updateStatusDisplay({ + isRunning: true, + activeVats, + }); + + // Store original options for comparison + const originalOptions = Array.from(vatSelect.options); + + // Update with same vats in same order + updateStatusDisplay({ + isRunning: true, + activeVats: ['v0', 'v1'], + }); + + // Compare options after update + const newOptions = Array.from(vatSelect.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 vatSelect = document.getElementById('vat-id') as HTMLSelectElement; + + // First update + updateStatusDisplay({ + isRunning: true, + activeVats: ['v0', 'v1'], + }); + + // Store original options for comparison + const originalOptions = Array.from(vatSelect.options); + + // Update with same vats in different order + updateStatusDisplay({ + isRunning: true, + activeVats: ['v1', 'v0'], + }); + + // Compare options after update + const newOptions = Array.from(vatSelect.options); + expect(newOptions).not.toStrictEqual(originalOptions); + expect(vatSelect.options[1]?.value).toBe('v1'); + expect(vatSelect.options[2]?.value).toBe('v0'); + }); + }); + + describe('setupVatListeners', () => { + it('should update button states on vat id input', async () => { + const { setupVatListeners } = await import('./status'); + const { buttons } = await import('./buttons'); + const newVatId = document.getElementById( + 'new-vat-id', + ) as HTMLInputElement; + + 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 } = await import('./buttons'); + const vatSelect = document.getElementById('vat-id') as HTMLSelectElement; + + setupVatListeners(); + + // No selection + vatSelect.value = ''; + vatSelect.dispatchEvent(new Event('change')); + expect(buttons.restartVat?.element.disabled).toBe(true); + expect(buttons.terminateVat?.element.disabled).toBe(true); + + // With selection + vatSelect.value = 'v0'; + vatSelect.dispatchEvent(new Event('change')); + expect(buttons.restartVat?.element.disabled).toBe(false); + expect(buttons.terminateVat?.element.disabled).toBe(false); + }); + }); +}); diff --git a/packages/extension/src/panel/stream.ts b/packages/extension/src/panel/stream.ts index 1aa56e20a..b1d2dfdc2 100644 --- a/packages/extension/src/panel/stream.ts +++ b/packages/extension/src/panel/stream.ts @@ -27,6 +27,8 @@ export async function setupStream(): Promise< KernelControlCommand >(chrome.runtime, ChromeRuntimeTarget.Popup, ChromeRuntimeTarget.Offscreen); + console.log('offscreenStream', offscreenStream); + // Cleanup stream on disconnect const cleanup = (): void => { offscreenStream.return().catch(logger.error); From b5fa6f54e76b4403d71b9bd9de60ffdc2b169800 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 14 Nov 2024 18:06:24 +0000 Subject: [PATCH 25/39] cleanup --- packages/extension/src/panel/stream.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/extension/src/panel/stream.ts b/packages/extension/src/panel/stream.ts index b1d2dfdc2..1aa56e20a 100644 --- a/packages/extension/src/panel/stream.ts +++ b/packages/extension/src/panel/stream.ts @@ -27,8 +27,6 @@ export async function setupStream(): Promise< KernelControlCommand >(chrome.runtime, ChromeRuntimeTarget.Popup, ChromeRuntimeTarget.Offscreen); - console.log('offscreenStream', offscreenStream); - // Cleanup stream on disconnect const cleanup = (): void => { offscreenStream.return().catch(logger.error); From 9d7e425590cbf1c73026066bb53d39439d99e989 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 20 Nov 2024 12:23:49 +0000 Subject: [PATCH 26/39] replace dirname --- packages/extension/test/panel-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/test/panel-utils.ts b/packages/extension/test/panel-utils.ts index 477a41411..dfb52dcdc 100644 --- a/packages/extension/test/panel-utils.ts +++ b/packages/extension/test/panel-utils.ts @@ -5,7 +5,7 @@ import path from 'path'; * Setup the DOM for the tests. */ export async function setupPanelDOM(): Promise { - const htmlPath = path.resolve(__dirname, '../src/popup.html'); + const htmlPath = path.resolve(import.meta.dirname, '../src/popup.html'); const html = await fs.readFile(htmlPath, 'utf-8'); document.body.innerHTML = html; From 6fd4dd761a8577098c5b7997bfbacf060703ae52 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 20 Nov 2024 12:27:52 +0000 Subject: [PATCH 27/39] fix styles and thresholds --- packages/extension/src/panel/styles.css | 126 +++++++++++++++--------- vitest.config.ts | 12 +-- 2 files changed, 84 insertions(+), 54 deletions(-) diff --git a/packages/extension/src/panel/styles.css b/packages/extension/src/panel/styles.css index b14259da5..b96176450 100644 --- a/packages/extension/src/panel/styles.css +++ b/packages/extension/src/panel/styles.css @@ -1,9 +1,41 @@ +: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: 16px; + padding: var(--spacing-xl); font-family: system-ui, -apple-system, @@ -13,31 +45,31 @@ body * { .vat-controls { display: flex; align-items: center; - gap: 4px; - margin-bottom: 16px; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-xl); } button, select, input { - height: 36px; - padding: 0 14px; - border-radius: 3px; - border: 1px solid #ccc; - font-size: 14px; - margin: 4px; - background-color: white; - transition: background-color 0.1s; + 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: #f0f0f0; + background-color: var(--color-gray-200); } button:hover:not(:disabled) { - background-color: #ccc; + background-color: var(--color-gray-300); } button:disabled { @@ -46,38 +78,36 @@ button:disabled { } button.green { - background-color: #4caf50; - color: white; + background-color: var(--color-success); + color: var(--color-white); border: none; } button.red { - background-color: #f44336; - color: white; + background-color: var(--color-error); + color: var(--color-white); border: none; } button.yellow { - background-color: #ffeb3b; + background-color: var(--color-warning); border: none; } button.red:hover:not(:disabled) { - color: white; - background-color: #333; + color: var(--color-white); + background-color: var(--color-black); } select { min-width: 120px; cursor: pointer; - -webkit-appearance: none; - -moz-appearance: none; 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 12px center; - background-size: 8px auto; - padding-right: 30px; + background-position: right var(--spacing-md) center; + background-size: var(--select-arrow-size) auto; + padding-right: var(--spacing-xxl); } #new-vat-id { @@ -85,22 +115,22 @@ select { } #status-display { - background: #f5f5f5; - padding: 12px 14px; - border-radius: 3px; - font-size: 12px; + 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: 4px; + margin: var(--spacing-xs); } h4 { - margin: 0 0 8px; + margin: 0 0 var(--spacing-sm); } .kernel-status { - margin: 4px 4px 16px; + margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xl); } #vat-id { @@ -108,19 +138,19 @@ h4 { } .message-panel { - margin: 4px 4px 16px; + margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xl); } #message-templates { display: flex; - gap: 8px; - margin-bottom: 8px; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); } .message-input-row { display: flex; - gap: 8px; - margin-bottom: 8px; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); } #message-content { @@ -138,37 +168,37 @@ button.text-button { cursor: pointer; height: auto; background: transparent; - font-size: 12px; - color: #4956f9; + font-size: var(--font-size-xs); + color: var(--color-primary); text-decoration: underline; margin: 0; } button.text-button:hover { - color: #333; + color: var(--color-black); text-decoration: none; background-color: transparent; } #output-box { - margin-top: 8px; + margin-top: var(--spacing-sm); } #message-output { - background: #f5f5f5; - padding: 12px 14px; - border-radius: 3px; - font-size: 12px; - max-height: 200px; + 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: #f44336; + color: var(--color-error); } .success { - color: #4caf50; + color: var(--color-success); } diff --git a/vitest.config.ts b/vitest.config.ts index ed31b3ca2..7d10d34ac 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: 53.78, + functions: 41.74, + branches: 56.03, + lines: 53.82, }, 'packages/kernel/**': { - statements: 83.92, + statements: 83.97, functions: 90, branches: 69.66, - lines: 83.92, + lines: 83.97, }, 'packages/shims/**': { statements: 0, From 180eedd0942e1a9d6226d47217511c532fe3c366 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 20 Nov 2024 13:57:07 +0000 Subject: [PATCH 28/39] better structure and fix tests --- .../src/kernel/handle-panel-message.test.ts | 1 - packages/extension/src/offscreen.ts | 2 +- packages/extension/src/panel/buttons.test.ts | 1 - packages/extension/src/panel/buttons.ts | 8 +- packages/extension/src/panel/messages.test.ts | 58 +++++++++- packages/extension/src/panel/messages.ts | 48 ++++++++- packages/extension/src/panel/status.test.ts | 101 +++++++++++++++--- packages/extension/src/panel/status.ts | 34 +++--- packages/extension/src/panel/stream.ts | 49 +-------- 9 files changed, 212 insertions(+), 90 deletions(-) diff --git a/packages/extension/src/kernel/handle-panel-message.test.ts b/packages/extension/src/kernel/handle-panel-message.test.ts index 4d0001e1c..94585480b 100644 --- a/packages/extension/src/kernel/handle-panel-message.test.ts +++ b/packages/extension/src/kernel/handle-panel-message.test.ts @@ -30,7 +30,6 @@ describe('handlePanelMessage', () => { let mockKernel: Kernel; beforeEach(() => { - vi.resetAllMocks(); vi.resetModules(); // Create mock kernel diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 14f73d15d..cfde015a8 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -23,7 +23,7 @@ const logger = makeLogger('[offscreen]'); main().catch(logger.error); /** - * Main function to initialize the offscreen page. + * Main function to initialize the offscreen document. */ async function main(): Promise { // Without this delay, sending messages via the chrome.runtime API can fail. diff --git a/packages/extension/src/panel/buttons.test.ts b/packages/extension/src/panel/buttons.test.ts index 2210095c1..d4ec3f9f6 100644 --- a/packages/extension/src/panel/buttons.test.ts +++ b/packages/extension/src/panel/buttons.test.ts @@ -5,7 +5,6 @@ import { setupPanelDOM } from '../../test/panel-utils.js'; describe('buttons', () => { beforeEach(async () => { - vi.resetAllMocks(); vi.resetModules(); await setupPanelDOM(); }); diff --git a/packages/extension/src/panel/buttons.ts b/packages/extension/src/panel/buttons.ts index e17c8062c..0bd65a749 100644 --- a/packages/extension/src/panel/buttons.ts +++ b/packages/extension/src/panel/buttons.ts @@ -4,6 +4,7 @@ import { logger } from './shared.js'; import type { KernelControlCommand } from '../kernel/messages.js'; export const vatId = document.getElementById('vat-id') as HTMLSelectElement; +export const vatSelect = document.getElementById('vat-id') as HTMLSelectElement; export const newVatId = document.getElementById( 'new-vat-id', ) as HTMLInputElement; @@ -12,7 +13,7 @@ export const buttons: Record< string, { element: HTMLButtonElement; - command: () => KernelControlCommand | undefined; + command: () => KernelControlCommand; } > = { launchVat: { @@ -55,10 +56,7 @@ export function setupButtonHandlers( ): void { Object.values(buttons).forEach((button) => { button.element.addEventListener('click', () => { - const message = button.command(); - if (message) { - sendMessage(message).catch(logger.error); - } + sendMessage(button.command()).catch(logger.error); }); }); } diff --git a/packages/extension/src/panel/messages.test.ts b/packages/extension/src/panel/messages.test.ts index 333552a3c..282b83783 100644 --- a/packages/extension/src/panel/messages.test.ts +++ b/packages/extension/src/panel/messages.test.ts @@ -5,11 +5,16 @@ 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, @@ -26,7 +31,6 @@ vi.mock('@ocap/kernel', () => ({ describe('messages', () => { beforeEach(async () => { - vi.resetAllMocks(); vi.resetModules(); await setupPanelDOM(); }); @@ -215,4 +219,56 @@ describe('messages', () => { 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 index f8ff690a9..e93061e94 100644 --- a/packages/extension/src/panel/messages.ts +++ b/packages/extension/src/panel/messages.ts @@ -3,8 +3,16 @@ import type { KernelCommand } from '@ocap/kernel'; import { stringify } from '@ocap/utils'; import { vatId } from './buttons.js'; -import type { KernelControlCommand } from '../kernel/messages.js'; -import { KernelControlMethod } from '../kernel/messages.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( @@ -90,3 +98,39 @@ export function setupTemplateHandlers( sendButton.textContent = vatId.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) { + 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/status.test.ts b/packages/extension/src/panel/status.test.ts index 9e1e1baef..b768edabb 100644 --- a/packages/extension/src/panel/status.test.ts +++ b/packages/extension/src/panel/status.test.ts @@ -16,7 +16,6 @@ vi.mock('@ocap/kernel', () => ({ describe('status', () => { beforeEach(async () => { - vi.resetAllMocks(); vi.resetModules(); await setupPanelDOM(); }); @@ -51,7 +50,8 @@ describe('status', () => { describe('updateStatusDisplay', () => { it('should display running status with active vats', async () => { - const { updateStatusDisplay } = await import('./status'); + const { updateStatusDisplay, statusDisplay } = await import('./status'); + const activeVats: VatId[] = ['v0', 'v1', 'v2']; updateStatusDisplay({ @@ -59,26 +59,25 @@ describe('status', () => { activeVats, }); - const statusDisplay = document.getElementById('status-display'); expect(statusDisplay?.textContent).toBe( `Active Vats (3): ["v0","v1","v2"]`, ); }); it('should display not running status', async () => { - const { updateStatusDisplay } = await import('./status'); + const { updateStatusDisplay, statusDisplay } = await import('./status'); updateStatusDisplay({ isRunning: false, activeVats: [], }); - const statusDisplay = document.getElementById('status-display'); expect(statusDisplay?.textContent).toBe('Kernel is not running'); }); it('should update vat select options', async () => { const { updateStatusDisplay } = await import('./status'); + const { vatSelect } = await import('./buttons'); const activeVats: VatId[] = ['v0', 'v1']; updateStatusDisplay({ @@ -86,7 +85,6 @@ describe('status', () => { activeVats, }); - const vatSelect = document.getElementById('vat-id') as HTMLSelectElement; expect(vatSelect.options).toHaveLength(3); // Including empty option expect(vatSelect.options[1]?.value).toBe('v0'); expect(vatSelect.options[2]?.value).toBe('v1'); @@ -94,8 +92,7 @@ describe('status', () => { it('should preserve selected vat if still active', async () => { const { updateStatusDisplay } = await import('./status'); - const vatSelect = document.getElementById('vat-id') as HTMLSelectElement; - + const { vatSelect } = await import('./buttons'); // First update updateStatusDisplay({ isRunning: true, @@ -114,7 +111,7 @@ describe('status', () => { it('should clear selection if selected vat becomes inactive', async () => { const { updateStatusDisplay } = await import('./status'); - const vatSelect = document.getElementById('vat-id') as HTMLSelectElement; + const { vatSelect } = await import('./buttons'); // First update and selection updateStatusDisplay({ @@ -134,7 +131,8 @@ describe('status', () => { it('should skip vat select update if vats have not changed', async () => { const { updateStatusDisplay } = await import('./status'); - const vatSelect = document.getElementById('vat-id') as HTMLSelectElement; + const { vatSelect } = await import('./buttons'); + const activeVats: VatId[] = ['v0', 'v1']; // First update @@ -164,7 +162,7 @@ describe('status', () => { it('should update vat select if vats are same but in different order', async () => { const { updateStatusDisplay } = await import('./status'); - const vatSelect = document.getElementById('vat-id') as HTMLSelectElement; + const { vatSelect } = await import('./buttons'); // First update updateStatusDisplay({ @@ -192,10 +190,7 @@ describe('status', () => { describe('setupVatListeners', () => { it('should update button states on vat id input', async () => { const { setupVatListeners } = await import('./status'); - const { buttons } = await import('./buttons'); - const newVatId = document.getElementById( - 'new-vat-id', - ) as HTMLInputElement; + const { buttons, newVatId } = await import('./buttons'); setupVatListeners(); @@ -212,8 +207,7 @@ describe('status', () => { it('should update button states on vat selection change', async () => { const { setupVatListeners } = await import('./status'); - const { buttons } = await import('./buttons'); - const vatSelect = document.getElementById('vat-id') as HTMLSelectElement; + const { buttons, vatSelect } = await import('./buttons'); setupVatListeners(); @@ -230,4 +224,77 @@ describe('status', () => { 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, vatSelect } = await import('./buttons'); + + vatSelect.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, vatSelect } = await import('./buttons'); + + const option = document.createElement('option'); + option.value = 'v1'; + option.text = 'v1'; + vatSelect.add(option); + + vatSelect.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 index 7c4ee67ce..88ffc1281 100644 --- a/packages/extension/src/panel/status.ts +++ b/packages/extension/src/panel/status.ts @@ -1,13 +1,13 @@ import type { VatId } from '@ocap/kernel'; import { stringify } from '@ocap/utils'; -import { buttons } from './buttons.js'; +import { buttons, vatSelect, newVatId } from './buttons.js'; import { logger } from './shared.js'; import type { KernelControlCommand, KernelStatus } from '../kernel/messages.js'; -const statusDisplay = document.getElementById('status-display') as HTMLElement; -const vatId = document.getElementById('vat-id') as HTMLSelectElement; -const newVatId = document.getElementById('new-vat-id') as HTMLInputElement; +export const statusDisplay = document.getElementById( + 'status-display', +) as HTMLElement; /** * Setup status polling. @@ -50,11 +50,11 @@ export function updateStatusDisplay(status: KernelStatus): void { */ export function setupVatListeners(): void { newVatId.addEventListener('input', () => { - updateButtonStates(vatId.options.length > 1); + updateButtonStates(vatSelect.options.length > 1); }); - vatId.addEventListener('change', () => { - updateButtonStates(vatId.options.length > 1); + vatSelect.addEventListener('change', () => { + updateButtonStates(vatSelect.options.length > 1); }); } @@ -65,7 +65,7 @@ export function setupVatListeners(): void { */ function updateVatSelect(activeVats: VatId[]): void { // Compare current options with new vats - const currentVats = Array.from(vatId.options) + const currentVats = Array.from(vatSelect.options) .slice(1) // Skip the default empty option .map((option) => option.value as VatId); @@ -75,11 +75,11 @@ function updateVatSelect(activeVats: VatId[]): void { } // Store current selection - const currentSelection = vatId.value; + const currentSelection = vatSelect.value; // Clear existing options except the default one - while (vatId.options.length > 1) { - vatId.remove(1); + while (vatSelect.options.length > 1) { + vatSelect.remove(1); } // Add new options @@ -87,14 +87,14 @@ function updateVatSelect(activeVats: VatId[]): void { const option = document.createElement('option'); option.value = id; option.text = id; - vatId.add(option); + vatSelect.add(option); }); // Restore selection if it still exists if (activeVats.includes(currentSelection as VatId)) { - vatId.value = currentSelection; + vatSelect.value = currentSelection; } else { - vatId.value = ''; + vatSelect.value = ''; } // Update button states @@ -106,7 +106,7 @@ function updateVatSelect(activeVats: VatId[]): void { * * @param hasVats - Whether any vats exist */ -function updateButtonStates(hasVats: boolean): void { +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(); @@ -114,10 +114,10 @@ function updateButtonStates(hasVats: boolean): void { // Restart and terminate buttons - enabled when a vat is selected if (buttons.restartVat) { - buttons.restartVat.element.disabled = !vatId.value; + buttons.restartVat.element.disabled = !vatSelect.value; } if (buttons.terminateVat) { - buttons.terminateVat.element.disabled = !vatId.value; + buttons.terminateVat.element.disabled = !vatSelect.value; } // Terminate all - enabled only when vats exist diff --git a/packages/extension/src/panel/stream.ts b/packages/extension/src/panel/stream.ts index 1aa56e20a..ae850c4a8 100644 --- a/packages/extension/src/panel/stream.ts +++ b/packages/extension/src/panel/stream.ts @@ -1,10 +1,7 @@ import { ChromeRuntimeDuplexStream, ChromeRuntimeTarget } from '@ocap/streams'; -import { stringify } from '@ocap/utils'; -import { showOutput } from './messages.js'; +import { handleKernelMessage } from './messages.js'; import { logger } from './shared.js'; -import { updateStatusDisplay } from './status.js'; -import { isKernelControlReply, isKernelStatus } from '../kernel/messages.js'; import type { KernelControlCommand, KernelControlReply, @@ -41,47 +38,9 @@ export async function setupStream(): Promise< }; // Handle messages from the offscreen script - offscreenStream - .drain((message) => { - if (!isKernelControlReply(message) || message.params === null) { - return; - } - - if (isKernelStatus(message.params)) { - updateStatusDisplay(message.params); - return; - } - - if (message.method === 'sendMessage') { - const { params } = message; - - // Handle error responses - if (isErrorResponse(params)) { - showOutput(stringify(params.error, 0), 'error'); - return; - } - - // Handle successful responses - showOutput(stringify(params, 2), 'info'); - } - }) - .catch((error) => { - logger.error('error draining offscreen stream', error); - }); + offscreenStream.drain(handleKernelMessage).catch((error) => { + logger.error('error draining offscreen stream', error); + }); return sendMessage; } - -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; -} From c96223b50550fb667d0e582b5536c412dbd93fcc Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 20 Nov 2024 14:04:05 +0000 Subject: [PATCH 29/39] fix esm dirname --- packages/extension/test/panel-utils.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/extension/test/panel-utils.ts b/packages/extension/test/panel-utils.ts index dfb52dcdc..196c72913 100644 --- a/packages/extension/test/panel-utils.ts +++ b/packages/extension/test/panel-utils.ts @@ -1,11 +1,15 @@ import fs from 'fs/promises'; -import path from 'path'; +import path, { dirname } from 'path'; +import { fileURLToPath } from 'url'; /** * Setup the DOM for the tests. */ export async function setupPanelDOM(): Promise { - const htmlPath = path.resolve(import.meta.dirname, '../src/popup.html'); + const htmlPath = path.resolve( + dirname(fileURLToPath(import.meta.url)), + '../src/popup.html', + ); const html = await fs.readFile(htmlPath, 'utf-8'); document.body.innerHTML = html; From ec5523a9d41d6178199d2aae3565c97ce5ee8a0f Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 20 Nov 2024 14:10:40 +0000 Subject: [PATCH 30/39] rename dropdown element --- packages/extension/src/panel/buttons.test.ts | 15 +++-- packages/extension/src/panel/buttons.ts | 9 +-- packages/extension/src/panel/messages.test.ts | 18 +++--- packages/extension/src/panel/messages.ts | 8 +-- packages/extension/src/panel/status.test.ts | 56 +++++++++---------- packages/extension/src/panel/status.ts | 30 +++++----- packages/extension/src/panel/styles.css | 2 +- packages/extension/src/popup.html | 2 +- packages/extension/test/panel-utils.ts | 6 +- 9 files changed, 74 insertions(+), 72 deletions(-) diff --git a/packages/extension/src/panel/buttons.test.ts b/packages/extension/src/panel/buttons.test.ts index d4ec3f9f6..66e1a2abd 100644 --- a/packages/extension/src/panel/buttons.test.ts +++ b/packages/extension/src/panel/buttons.test.ts @@ -26,8 +26,8 @@ describe('buttons', () => { }); it('should generate correct restart vat command', async () => { - const { buttons, vatId } = await import('./buttons'); - vatId.value = 'v0'; + const { buttons, vatDropdown } = await import('./buttons'); + vatDropdown.value = 'v0'; const command = buttons.restartVat?.command(); expect(command).toStrictEqual({ @@ -37,8 +37,8 @@ describe('buttons', () => { }); it('should generate correct terminate vat command', async () => { - const { buttons, vatId } = await import('./buttons'); - vatId.value = 'v0'; + const { buttons, vatDropdown } = await import('./buttons'); + vatDropdown.value = 'v0'; const command = buttons.terminateVat?.command(); expect(command).toStrictEqual({ @@ -61,11 +61,10 @@ describe('buttons', () => { describe('setupButtonHandlers', () => { it('should set up click handlers for all buttons', async () => { const sendMessage = vi.fn().mockResolvedValue(undefined); - const { buttons, newVatId, vatId, setupButtonHandlers } = await import( - './buttons' - ); + const { buttons, newVatId, vatDropdown, setupButtonHandlers } = + await import('./buttons'); newVatId.value = 'v1'; - vatId.value = 'v1'; + vatDropdown.value = 'v1'; setupButtonHandlers(sendMessage); diff --git a/packages/extension/src/panel/buttons.ts b/packages/extension/src/panel/buttons.ts index 0bd65a749..1737f51ce 100644 --- a/packages/extension/src/panel/buttons.ts +++ b/packages/extension/src/panel/buttons.ts @@ -3,8 +3,9 @@ import type { VatId } from '@ocap/kernel'; import { logger } from './shared.js'; import type { KernelControlCommand } from '../kernel/messages.js'; -export const vatId = document.getElementById('vat-id') as HTMLSelectElement; -export const vatSelect = document.getElementById('vat-id') as HTMLSelectElement; +export const vatDropdown = document.getElementById( + 'vat-dropdown', +) as HTMLSelectElement; export const newVatId = document.getElementById( 'new-vat-id', ) as HTMLInputElement; @@ -27,14 +28,14 @@ export const buttons: Record< element: document.getElementById('restart-vat') as HTMLButtonElement, command: () => ({ method: 'restartVat', - params: { id: vatId.value as VatId }, + params: { id: vatDropdown.value as VatId }, }), }, terminateVat: { element: document.getElementById('terminate-vat') as HTMLButtonElement, command: () => ({ method: 'terminateVat', - params: { id: vatId.value as VatId }, + params: { id: vatDropdown.value as VatId }, }), }, terminateAllVats: { diff --git a/packages/extension/src/panel/messages.test.ts b/packages/extension/src/panel/messages.test.ts index 282b83783..83bf7c7ef 100644 --- a/packages/extension/src/panel/messages.test.ts +++ b/packages/extension/src/panel/messages.test.ts @@ -112,7 +112,7 @@ describe('messages', () => { it('should send message when send button is clicked', async () => { const { setupTemplateHandlers, messageContent, sendButton } = await import('./messages'); - const { vatId } = await import('./buttons.js'); + const { vatDropdown } = await import('./buttons.js'); const sendMessage = vi.fn().mockResolvedValue(undefined); isVatId.mockReturnValue(true); @@ -120,7 +120,7 @@ describe('messages', () => { // Setup test data messageContent.value = '{"method":"ping","params":null}'; - vatId.value = 'v0'; + vatDropdown.value = 'v0'; sendButton.dispatchEvent(new Event('click')); @@ -138,7 +138,7 @@ describe('messages', () => { it('should send message without vat id when send button is clicked', async () => { const { setupTemplateHandlers, messageContent, sendButton } = await import('./messages'); - const { vatId } = await import('./buttons.js'); + const { vatDropdown } = await import('./buttons.js'); const sendMessage = vi.fn().mockResolvedValue(undefined); isVatId.mockReturnValue(false); @@ -146,7 +146,7 @@ describe('messages', () => { messageContent.value = '{"method":"kvSet","params":{"key":"test","value":"test"}}'; - vatId.value = ''; + vatDropdown.value = ''; sendButton.dispatchEvent(new Event('click')); @@ -180,23 +180,23 @@ describe('messages', () => { 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 vatId = document.getElementById('vat-id') as HTMLSelectElement; const sendButton = document.getElementById( 'send-message', ) as HTMLButtonElement; // With vat selected - vatId.value = 'v0'; - vatId.dispatchEvent(new Event('change')); + vatDropdown.value = 'v0'; + vatDropdown.dispatchEvent(new Event('change')); expect(sendButton.textContent).toBe('Send to Vat'); // Without vat selected - vatId.value = ''; - vatId.dispatchEvent(new Event('change')); + vatDropdown.value = ''; + vatDropdown.dispatchEvent(new Event('change')); expect(sendButton.textContent).toBe('Send'); }); diff --git a/packages/extension/src/panel/messages.ts b/packages/extension/src/panel/messages.ts index e93061e94..399a310ed 100644 --- a/packages/extension/src/panel/messages.ts +++ b/packages/extension/src/panel/messages.ts @@ -2,7 +2,7 @@ import { KernelCommandMethod, VatCommandMethod, isVatId } from '@ocap/kernel'; import type { KernelCommand } from '@ocap/kernel'; import { stringify } from '@ocap/utils'; -import { vatId } from './buttons.js'; +import { vatDropdown } from './buttons.js'; import { updateStatusDisplay } from './status.js'; import { KernelControlMethod, @@ -83,7 +83,7 @@ export function setupTemplateHandlers( method: KernelControlMethod.sendMessage, params: { payload: JSON.parse(messageContent.value), - ...(isVatId(vatId.value) ? { id: vatId.value } : {}), + ...(isVatId(vatDropdown.value) ? { id: vatDropdown.value } : {}), }, }; await sendMessage(command); @@ -94,8 +94,8 @@ export function setupTemplateHandlers( sendButton.disabled = !messageContent.value.trim(); }); - vatId.addEventListener('change', () => { - sendButton.textContent = vatId.value ? 'Send to Vat' : 'Send'; + vatDropdown.addEventListener('change', () => { + sendButton.textContent = vatDropdown.value ? 'Send to Vat' : 'Send'; }); } diff --git a/packages/extension/src/panel/status.test.ts b/packages/extension/src/panel/status.test.ts index b768edabb..b2ccf8ddd 100644 --- a/packages/extension/src/panel/status.test.ts +++ b/packages/extension/src/panel/status.test.ts @@ -77,7 +77,7 @@ describe('status', () => { it('should update vat select options', async () => { const { updateStatusDisplay } = await import('./status'); - const { vatSelect } = await import('./buttons'); + const { vatDropdown } = await import('./buttons'); const activeVats: VatId[] = ['v0', 'v1']; updateStatusDisplay({ @@ -85,20 +85,20 @@ describe('status', () => { activeVats, }); - expect(vatSelect.options).toHaveLength(3); // Including empty option - expect(vatSelect.options[1]?.value).toBe('v0'); - expect(vatSelect.options[2]?.value).toBe('v1'); + 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 { vatSelect } = await import('./buttons'); + const { vatDropdown } = await import('./buttons'); // First update updateStatusDisplay({ isRunning: true, activeVats: ['v0', 'v1'], }); - vatSelect.value = 'v1'; + vatDropdown.value = 'v1'; // Second update with same vat still active updateStatusDisplay({ @@ -106,19 +106,19 @@ describe('status', () => { activeVats: ['v0', 'v1', 'v2'], }); - expect(vatSelect.value).toBe('v1'); + expect(vatDropdown.value).toBe('v1'); }); it('should clear selection if selected vat becomes inactive', async () => { const { updateStatusDisplay } = await import('./status'); - const { vatSelect } = await import('./buttons'); + const { vatDropdown } = await import('./buttons'); // First update and selection updateStatusDisplay({ isRunning: true, activeVats: ['v0', 'v1'], }); - vatSelect.value = 'v1'; + vatDropdown.value = 'v1'; // Second update with selected vat removed updateStatusDisplay({ @@ -126,12 +126,12 @@ describe('status', () => { activeVats: ['v0'], }); - expect(vatSelect.value).toBe(''); + expect(vatDropdown.value).toBe(''); }); it('should skip vat select update if vats have not changed', async () => { const { updateStatusDisplay } = await import('./status'); - const { vatSelect } = await import('./buttons'); + const { vatDropdown } = await import('./buttons'); const activeVats: VatId[] = ['v0', 'v1']; @@ -142,7 +142,7 @@ describe('status', () => { }); // Store original options for comparison - const originalOptions = Array.from(vatSelect.options); + const originalOptions = Array.from(vatDropdown.options); // Update with same vats in same order updateStatusDisplay({ @@ -151,7 +151,7 @@ describe('status', () => { }); // Compare options after update - const newOptions = Array.from(vatSelect.options); + const newOptions = Array.from(vatDropdown.options); expect(newOptions).toStrictEqual(originalOptions); // Verify the options are the actual same DOM elements (not just equal) @@ -162,7 +162,7 @@ describe('status', () => { it('should update vat select if vats are same but in different order', async () => { const { updateStatusDisplay } = await import('./status'); - const { vatSelect } = await import('./buttons'); + const { vatDropdown } = await import('./buttons'); // First update updateStatusDisplay({ @@ -171,7 +171,7 @@ describe('status', () => { }); // Store original options for comparison - const originalOptions = Array.from(vatSelect.options); + const originalOptions = Array.from(vatDropdown.options); // Update with same vats in different order updateStatusDisplay({ @@ -180,10 +180,10 @@ describe('status', () => { }); // Compare options after update - const newOptions = Array.from(vatSelect.options); + const newOptions = Array.from(vatDropdown.options); expect(newOptions).not.toStrictEqual(originalOptions); - expect(vatSelect.options[1]?.value).toBe('v1'); - expect(vatSelect.options[2]?.value).toBe('v0'); + expect(vatDropdown.options[1]?.value).toBe('v1'); + expect(vatDropdown.options[2]?.value).toBe('v0'); }); }); @@ -207,19 +207,19 @@ describe('status', () => { it('should update button states on vat selection change', async () => { const { setupVatListeners } = await import('./status'); - const { buttons, vatSelect } = await import('./buttons'); + const { buttons, vatDropdown } = await import('./buttons'); setupVatListeners(); // No selection - vatSelect.value = ''; - vatSelect.dispatchEvent(new Event('change')); + vatDropdown.value = ''; + vatDropdown.dispatchEvent(new Event('change')); expect(buttons.restartVat?.element.disabled).toBe(true); expect(buttons.terminateVat?.element.disabled).toBe(true); // With selection - vatSelect.value = 'v0'; - vatSelect.dispatchEvent(new Event('change')); + vatDropdown.value = 'v0'; + vatDropdown.dispatchEvent(new Event('change')); expect(buttons.restartVat?.element.disabled).toBe(false); expect(buttons.terminateVat?.element.disabled).toBe(false); }); @@ -244,9 +244,9 @@ describe('status', () => { it('should disable restart and terminate buttons based on vat selection', async () => { const { updateButtonStates } = await import('./status'); - const { buttons, vatSelect } = await import('./buttons'); + const { buttons, vatDropdown } = await import('./buttons'); - vatSelect.value = ''; + vatDropdown.value = ''; updateButtonStates(true); expect(buttons.restartVat?.element.disabled).toBe(true); expect(buttons.terminateVat?.element.disabled).toBe(true); @@ -254,14 +254,14 @@ describe('status', () => { it('should enable restart and terminate buttons based on vat selection', async () => { const { updateButtonStates } = await import('./status'); - const { buttons, vatSelect } = await import('./buttons'); + const { buttons, vatDropdown } = await import('./buttons'); const option = document.createElement('option'); option.value = 'v1'; option.text = 'v1'; - vatSelect.add(option); + vatDropdown.add(option); - vatSelect.value = 'v1'; + vatDropdown.value = 'v1'; updateButtonStates(true); expect(buttons.restartVat?.element.disabled).toBe(false); expect(buttons.terminateVat?.element.disabled).toBe(false); diff --git a/packages/extension/src/panel/status.ts b/packages/extension/src/panel/status.ts index 88ffc1281..12a50fdf6 100644 --- a/packages/extension/src/panel/status.ts +++ b/packages/extension/src/panel/status.ts @@ -1,7 +1,7 @@ import type { VatId } from '@ocap/kernel'; import { stringify } from '@ocap/utils'; -import { buttons, vatSelect, newVatId } from './buttons.js'; +import { buttons, vatDropdown, newVatId } from './buttons.js'; import { logger } from './shared.js'; import type { KernelControlCommand, KernelStatus } from '../kernel/messages.js'; @@ -42,7 +42,7 @@ export function updateStatusDisplay(status: KernelStatus): void { ? `Active Vats (${activeVats.length}): ${stringify(activeVats, 0)}` : 'Kernel is not running'; - updateVatSelect(activeVats); + updatevatDropdown(activeVats); } /** @@ -50,11 +50,11 @@ export function updateStatusDisplay(status: KernelStatus): void { */ export function setupVatListeners(): void { newVatId.addEventListener('input', () => { - updateButtonStates(vatSelect.options.length > 1); + updateButtonStates(vatDropdown.options.length > 1); }); - vatSelect.addEventListener('change', () => { - updateButtonStates(vatSelect.options.length > 1); + vatDropdown.addEventListener('change', () => { + updateButtonStates(vatDropdown.options.length > 1); }); } @@ -63,9 +63,9 @@ export function setupVatListeners(): void { * * @param activeVats - Array of active vat IDs */ -function updateVatSelect(activeVats: VatId[]): void { +function updatevatDropdown(activeVats: VatId[]): void { // Compare current options with new vats - const currentVats = Array.from(vatSelect.options) + const currentVats = Array.from(vatDropdown.options) .slice(1) // Skip the default empty option .map((option) => option.value as VatId); @@ -75,11 +75,11 @@ function updateVatSelect(activeVats: VatId[]): void { } // Store current selection - const currentSelection = vatSelect.value; + const currentSelection = vatDropdown.value; // Clear existing options except the default one - while (vatSelect.options.length > 1) { - vatSelect.remove(1); + while (vatDropdown.options.length > 1) { + vatDropdown.remove(1); } // Add new options @@ -87,14 +87,14 @@ function updateVatSelect(activeVats: VatId[]): void { const option = document.createElement('option'); option.value = id; option.text = id; - vatSelect.add(option); + vatDropdown.add(option); }); // Restore selection if it still exists if (activeVats.includes(currentSelection as VatId)) { - vatSelect.value = currentSelection; + vatDropdown.value = currentSelection; } else { - vatSelect.value = ''; + vatDropdown.value = ''; } // Update button states @@ -114,10 +114,10 @@ export function updateButtonStates(hasVats: boolean): void { // Restart and terminate buttons - enabled when a vat is selected if (buttons.restartVat) { - buttons.restartVat.element.disabled = !vatSelect.value; + buttons.restartVat.element.disabled = !vatDropdown.value; } if (buttons.terminateVat) { - buttons.terminateVat.element.disabled = !vatSelect.value; + buttons.terminateVat.element.disabled = !vatDropdown.value; } // Terminate all - enabled only when vats exist diff --git a/packages/extension/src/panel/styles.css b/packages/extension/src/panel/styles.css index b96176450..7450ec9ef 100644 --- a/packages/extension/src/panel/styles.css +++ b/packages/extension/src/panel/styles.css @@ -133,7 +133,7 @@ h4 { margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xl); } -#vat-id { +#vat-select { width: 60px; } diff --git a/packages/extension/src/popup.html b/packages/extension/src/popup.html index 5c0c9bccd..ca6df703c 100644 --- a/packages/extension/src/popup.html +++ b/packages/extension/src/popup.html @@ -20,7 +20,7 @@

Kernel Status

- diff --git a/packages/extension/test/panel-utils.ts b/packages/extension/test/panel-utils.ts index 196c72913..c3f3bf049 100644 --- a/packages/extension/test/panel-utils.ts +++ b/packages/extension/test/panel-utils.ts @@ -14,9 +14,11 @@ export async function setupPanelDOM(): Promise { document.body.innerHTML = html; // Add test option to select - const vatSelect = document.getElementById('vat-id') as HTMLSelectElement; + const vatDropdown = document.getElementById( + 'vat-dropdown', + ) as HTMLSelectElement; const option = document.createElement('option'); option.value = 'v0'; option.text = 'v0'; - vatSelect.appendChild(option); + vatDropdown.appendChild(option); } From 63879a949de1c04097f89675ec89be7839878678 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 20 Nov 2024 14:15:14 +0000 Subject: [PATCH 31/39] add stream input validator --- packages/extension/src/panel/stream.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/extension/src/panel/stream.ts b/packages/extension/src/panel/stream.ts index ae850c4a8..2a7ce1a40 100644 --- a/packages/extension/src/panel/stream.ts +++ b/packages/extension/src/panel/stream.ts @@ -2,6 +2,7 @@ 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, @@ -22,7 +23,12 @@ export async function setupStream(): Promise< const offscreenStream = await ChromeRuntimeDuplexStream.make< KernelControlReply, KernelControlCommand - >(chrome.runtime, ChromeRuntimeTarget.Popup, ChromeRuntimeTarget.Offscreen); + >( + chrome.runtime, + ChromeRuntimeTarget.Popup, + ChromeRuntimeTarget.Offscreen, + isKernelControlReply, + ); // Cleanup stream on disconnect const cleanup = (): void => { From 19dc6876973e0e6a8be485e9ab6e19526d83f814 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 20 Nov 2024 14:16:09 +0000 Subject: [PATCH 32/39] fix thresholds --- vitest.config.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index 7d10d34ac..a3027fa34 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -40,10 +40,10 @@ export default defineConfig({ lines: 100, }, 'packages/extension/**': { - statements: 53.78, - functions: 41.74, - branches: 56.03, - lines: 53.82, + statements: 56.55, + functions: 45.63, + branches: 70.53, + lines: 56.62, }, 'packages/kernel/**': { statements: 83.97, From 64a7e91b9f309bb4da7980c5d4c99ac40e55e14e Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 20 Nov 2024 14:46:24 +0000 Subject: [PATCH 33/39] gst --- .../src/kernel/handle-panel-message.test.ts | 64 ++++++++++++++++++- .../src/kernel/handle-panel-message.ts | 15 ++++- packages/extension/src/kernel/messages.ts | 11 ++-- 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/packages/extension/src/kernel/handle-panel-message.test.ts b/packages/extension/src/kernel/handle-panel-message.test.ts index 94585480b..2c96c39ce 100644 --- a/packages/extension/src/kernel/handle-panel-message.test.ts +++ b/packages/extension/src/kernel/handle-panel-message.test.ts @@ -72,6 +72,25 @@ describe('handlePanelMessage', () => { }); }); + 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: 'Vat ID is invalid' }, + }); + }); + it('should handle restartVat command', async () => { const { handlePanelMessage } = await import('./handle-panel-message'); const message: KernelControlCommand = { @@ -88,6 +107,26 @@ describe('handlePanelMessage', () => { }); }); + 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: 'Vat ID is required' }, + }); + }); + it('should handle terminateVat command', async () => { const { handlePanelMessage } = await import('./handle-panel-message'); const message: KernelControlCommand = { @@ -104,6 +143,25 @@ describe('handlePanelMessage', () => { }); }); + 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: 'Vat ID is required' }, + }); + }); + it('should handle terminateAllVats command', async () => { const { handlePanelMessage } = await import('./handle-panel-message'); const message: KernelControlCommand = { @@ -277,7 +335,7 @@ describe('handlePanelMessage', () => { const response = await handlePanelMessage(mockKernel, message); expect(response).toStrictEqual({ - method: 'sendMessage', + method: 'unknownMethod', params: { error: 'Unknown method' }, }); }); @@ -295,7 +353,7 @@ describe('handlePanelMessage', () => { const response = await handlePanelMessage(mockKernel, message); expect(response).toStrictEqual({ - method: 'sendMessage', + method: 'launchVat', params: { error: 'Kernel error' }, }); @@ -304,7 +362,7 @@ describe('handlePanelMessage', () => { const response2 = await handlePanelMessage(mockKernel, message); expect(response2).toStrictEqual({ - method: 'sendMessage', + 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 index 1216246e2..869b88c4c 100644 --- a/packages/extension/src/kernel/handle-panel-message.ts +++ b/packages/extension/src/kernel/handle-panel-message.ts @@ -27,16 +27,25 @@ export async function handlePanelMessage( try { switch (message.method) { case KernelControlMethod.launchVat: { + if (!isVatId(message.params.id)) { + throw new Error('Vat ID is invalid'); + } await kernel.launchVat({ id: message.params.id }); return { method: KernelControlMethod.launchVat, params: null }; } case KernelControlMethod.restartVat: { + if (!isVatId(message.params.id)) { + throw new Error('Vat ID is required'); + } await kernel.restartVat(message.params.id); return { method: KernelControlMethod.restartVat, params: null }; } case KernelControlMethod.terminateVat: { + if (!isVatId(message.params.id)) { + throw new Error('Vat ID is required'); + } await kernel.terminateVat(message.params.id); return { method: KernelControlMethod.terminateVat, params: null }; } @@ -107,10 +116,10 @@ export async function handlePanelMessage( } catch (error) { logger.error('Error handling message:', error); return { - method: KernelControlMethod.sendMessage, + method: message.method, params: { error: error instanceof Error ? error.message : String(error), - } as Json, - }; + }, + } as KernelControlReply; } } diff --git a/packages/extension/src/kernel/messages.ts b/packages/extension/src/kernel/messages.ts index ac4c94ca5..2d76bc6e0 100644 --- a/packages/extension/src/kernel/messages.ts +++ b/packages/extension/src/kernel/messages.ts @@ -6,6 +6,7 @@ import { array, type, is, + string, } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; @@ -70,23 +71,23 @@ const KernelControlCommandStruct = union([ const KernelControlReplyStruct = union([ object({ method: literal(KernelControlMethod.launchVat), - params: literal(null), + params: union([literal(null), object({ error: string() })]), }), object({ method: literal(KernelControlMethod.restartVat), - params: literal(null), + params: union([literal(null), object({ error: string() })]), }), object({ method: literal(KernelControlMethod.terminateVat), - params: literal(null), + params: union([literal(null), object({ error: string() })]), }), object({ method: literal(KernelControlMethod.terminateAllVats), - params: literal(null), + params: union([literal(null), object({ error: string() })]), }), object({ method: literal(KernelControlMethod.getStatus), - params: KernelStatusStruct, + params: union([KernelStatusStruct, object({ error: string() })]), }), object({ method: literal(KernelControlMethod.sendMessage), From 02c0fb324b3e39e5f48c51f89c02db40c961f083 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 20 Nov 2024 14:48:09 +0000 Subject: [PATCH 34/39] trying to fix test --- packages/extension/src/panel/status.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/extension/src/panel/status.test.ts b/packages/extension/src/panel/status.test.ts index b2ccf8ddd..fb09840ae 100644 --- a/packages/extension/src/panel/status.test.ts +++ b/packages/extension/src/panel/status.test.ts @@ -16,6 +16,7 @@ vi.mock('@ocap/kernel', () => ({ describe('status', () => { beforeEach(async () => { + vi.resetAllMocks(); vi.resetModules(); await setupPanelDOM(); }); From 93cec35dd062ca80778d9acffda2ed4e142462a7 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 20 Nov 2024 14:53:10 +0000 Subject: [PATCH 35/39] reset text on command --- packages/extension/src/panel/messages.test.ts | 13 +++++++++++++ packages/extension/src/panel/messages.ts | 1 + 2 files changed, 14 insertions(+) diff --git a/packages/extension/src/panel/messages.test.ts b/packages/extension/src/panel/messages.test.ts index 83bf7c7ef..a0c3f5445 100644 --- a/packages/extension/src/panel/messages.test.ts +++ b/packages/extension/src/panel/messages.test.ts @@ -62,6 +62,19 @@ describe('messages', () => { 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', () => { diff --git a/packages/extension/src/panel/messages.ts b/packages/extension/src/panel/messages.ts index 399a310ed..fb7c717e7 100644 --- a/packages/extension/src/panel/messages.ts +++ b/packages/extension/src/panel/messages.ts @@ -106,6 +106,7 @@ export function setupTemplateHandlers( */ export function handleKernelMessage(message: KernelControlReply): void { if (!isKernelControlReply(message) || message.params === null) { + showOutput(''); return; } From 9ac9b80b87b0a558d2606c8615b8a9a2809333d1 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 20 Nov 2024 15:01:05 +0000 Subject: [PATCH 36/39] use drain --- packages/extension/src/offscreen.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index cfde015a8..57a3e3def 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -1,4 +1,4 @@ -import { isKernelCommand, isKernelCommandReply } from '@ocap/kernel'; +import { isKernelCommandReply } from '@ocap/kernel'; import type { KernelCommandReply, KernelCommand } from '@ocap/kernel'; import { ChromeRuntimeTarget, @@ -80,15 +80,9 @@ async function main(): Promise { // Handle messages from the background script and the multiplexer await Promise.all([ multiplexer.drainAll(), - (async () => { - for await (const message of backgroundStream) { - if (!isKernelCommand(message)) { - logger.error('Offscreen received unexpected message', message); - continue; - } - await kernelChannel.write(message); - } - })(), + backgroundStream.drain(async (message) => { + await kernelChannel.write(message); + }), ]); } From ba1753a5c9f7c50eb6e822b25e552fd383753a6b Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 20 Nov 2024 15:02:13 +0000 Subject: [PATCH 37/39] update thresholds --- vitest.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index a3027fa34..531f14672 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -40,10 +40,10 @@ export default defineConfig({ lines: 100, }, 'packages/extension/**': { - statements: 56.55, + statements: 57.9, functions: 45.63, - branches: 70.53, - lines: 56.62, + branches: 73.27, + lines: 57.98, }, 'packages/kernel/**': { statements: 83.97, From 8ff4662d11b3c633a63d0b7a0bcdd136f1b178b5 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 20 Nov 2024 16:16:12 +0000 Subject: [PATCH 38/39] fix message an stream validation --- .../src/kernel/handle-panel-message.test.ts | 6 ++--- .../src/kernel/handle-panel-message.ts | 6 ++--- packages/extension/src/offscreen.ts | 26 +++++++++++-------- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/extension/src/kernel/handle-panel-message.test.ts b/packages/extension/src/kernel/handle-panel-message.test.ts index 2c96c39ce..e486d1dd5 100644 --- a/packages/extension/src/kernel/handle-panel-message.test.ts +++ b/packages/extension/src/kernel/handle-panel-message.test.ts @@ -87,7 +87,7 @@ describe('handlePanelMessage', () => { expect(response).toStrictEqual({ method: 'launchVat', - params: { error: 'Vat ID is invalid' }, + params: { error: 'Valid vat id required' }, }); }); @@ -123,7 +123,7 @@ describe('handlePanelMessage', () => { expect(response).toStrictEqual({ method: 'restartVat', - params: { error: 'Vat ID is required' }, + params: { error: 'Valid vat id required' }, }); }); @@ -158,7 +158,7 @@ describe('handlePanelMessage', () => { expect(response).toStrictEqual({ method: 'terminateVat', - params: { error: 'Vat ID is required' }, + params: { error: 'Valid vat id required' }, }); }); diff --git a/packages/extension/src/kernel/handle-panel-message.ts b/packages/extension/src/kernel/handle-panel-message.ts index 869b88c4c..c6e027e0d 100644 --- a/packages/extension/src/kernel/handle-panel-message.ts +++ b/packages/extension/src/kernel/handle-panel-message.ts @@ -28,7 +28,7 @@ export async function handlePanelMessage( switch (message.method) { case KernelControlMethod.launchVat: { if (!isVatId(message.params.id)) { - throw new Error('Vat ID is invalid'); + throw new Error('Valid vat id required'); } await kernel.launchVat({ id: message.params.id }); return { method: KernelControlMethod.launchVat, params: null }; @@ -36,7 +36,7 @@ export async function handlePanelMessage( case KernelControlMethod.restartVat: { if (!isVatId(message.params.id)) { - throw new Error('Vat ID is required'); + throw new Error('Valid vat id required'); } await kernel.restartVat(message.params.id); return { method: KernelControlMethod.restartVat, params: null }; @@ -44,7 +44,7 @@ export async function handlePanelMessage( case KernelControlMethod.terminateVat: { if (!isVatId(message.params.id)) { - throw new Error('Vat ID is required'); + throw new Error('Valid vat id required'); } await kernel.terminateVat(message.params.id); return { method: KernelControlMethod.terminateVat, params: null }; diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 57a3e3def..7b18626a6 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -51,12 +51,13 @@ async function main(): Promise { const kernelChannel = multiplexer.addChannel< KernelCommandReply, KernelCommand - >('kernel', async (reply) => { - if (isKernelCommandReply(reply)) { + >( + 'kernel', + async (reply) => { await backgroundStream.write(reply); - } - }); - + }, + isKernelCommandReply, + ); let popupStream: ChromeRuntimeDuplexStream< KernelControlCommand, KernelControlReply @@ -66,12 +67,15 @@ async function main(): Promise { const panelChannel = multiplexer.addChannel< KernelControlReply, KernelControlCommand - >('panel', async (reply) => { - if (isKernelControlReply(reply) && popupStream) { - await popupStream.write(reply); - } - }); - + >( + 'panel', + async (reply) => { + if (popupStream) { + await popupStream.write(reply); + } + }, + isKernelControlReply, + ); // Setup popup communication setupPopupStream(panelChannel, (stream) => { popupStream = stream; From 9391efeba59679e8004d8c58c94fd7549c5c1d84 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 20 Nov 2024 16:26:24 +0000 Subject: [PATCH 39/39] update thresholds --- vitest.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index 531f14672..25ce51e65 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -40,10 +40,10 @@ export default defineConfig({ lines: 100, }, 'packages/extension/**': { - statements: 57.9, + statements: 58.05, functions: 45.63, - branches: 73.27, - lines: 57.98, + branches: 75.89, + lines: 58.13, }, 'packages/kernel/**': { statements: 83.97,