diff --git a/constraints.pro b/constraints.pro index 64f16b016..bfe1280c9 100644 --- a/constraints.pro +++ b/constraints.pro @@ -208,10 +208,12 @@ gen_enforced_field(WorkspaceCwd, 'module', './dist/index.mjs') :- \+ workspace_field(WorkspaceCwd, 'private', true). % Non-published packages must not specify an entrypoint. gen_enforced_field(WorkspaceCwd, 'main', null) :- - WorkspaceCwd \= 'packages/shims'. + WorkspaceCwd \= 'packages/shims', + WorkspaceCwd \= 'packages/streams', workspace_field(WorkspaceCwd, 'private', true). gen_enforced_field(WorkspaceCwd, 'module', null) :- - WorkspaceCwd \= 'packages/shims'. + WorkspaceCwd \= 'packages/shims', + WorkspaceCwd \= 'packages/streams', workspace_field(WorkspaceCwd, 'private', true). % The type definitions entrypoint for all publishable packages must be the same. @@ -219,7 +221,8 @@ gen_enforced_field(WorkspaceCwd, 'types', './dist/index.d.cts') :- \+ workspace_field(WorkspaceCwd, 'private', true). % Non-published packages must not specify a type definitions entrypoint. gen_enforced_field(WorkspaceCwd, 'types', null) :- - WorkspaceCwd \= 'packages/shims'. + WorkspaceCwd \= 'packages/shims', + WorkspaceCwd \= 'packages/streams', workspace_field(WorkspaceCwd, 'private', true). % The exports for all published packages must be the same. @@ -246,7 +249,8 @@ gen_enforced_field(WorkspaceCwd, 'sideEffects', false) :- \+ workspace_field(WorkspaceCwd, 'private', true). % Non-published packages must not specify side effects. gen_enforced_field(WorkspaceCwd, 'sideEffects', null) :- - WorkspaceCwd \= 'packages/shims'. + WorkspaceCwd \= 'packages/shims', + WorkspaceCwd \= 'packages/streams', workspace_field(WorkspaceCwd, 'private', true). % The list of files included in published packages must only include files diff --git a/package.json b/package.json index 3b413a28d..7a56debe7 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "prettier-plugin-packagejson": "^2.3.0", "rimraf": "^6.0.1", "typedoc": "^0.24.8", - "typescript": "~5.5.4", + "typescript": "~4.9.5", "vite": "^5.3.5", "vite-tsconfig-paths": "^4.3.2", "vitest": "^2.0.5" diff --git a/packages/extension/package.json b/packages/extension/package.json index 192af388f..3fcdf03ac 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -31,6 +31,7 @@ "@metamask/snaps-utils": "^7.8.0", "@metamask/utils": "^9.1.0", "@ocap/shims": "workspace:^", + "@ocap/streams": "workspace:^", "ses": "^1.7.0" }, "devDependencies": { @@ -46,7 +47,7 @@ "rimraf": "^6.0.1", "typedoc": "^0.24.8", "typedoc-plugin-missing-exports": "^2.0.0", - "typescript": "~5.5.4", + "typescript": "~4.9.5", "vite": "^5.3.5", "vite-plugin-static-copy": "^1.0.6", "vitest": "^2.0.5" diff --git a/packages/extension/src/iframe-manager.test.ts b/packages/extension/src/iframe-manager.test.ts index c2a00c5e5..6c3d4c9a1 100644 --- a/packages/extension/src/iframe-manager.test.ts +++ b/packages/extension/src/iframe-manager.test.ts @@ -1,54 +1,21 @@ -import type { makePromiseKit } from '@endo/promise-kit'; import * as snapsUtils from '@metamask/snaps-utils'; -import { vi, beforeEach, describe, it, expect } from 'vitest'; +import { delay, makePromiseKitMock } from '@ocap/test-utils'; +import { vi, describe, it, expect } from 'vitest'; +import { IframeManager } from './iframe-manager.js'; import { Command } from './shared.js'; -vi.mock('@endo/promise-kit', () => ({ - makePromiseKit: (): ReturnType => { - let resolve: (value: unknown) => void, reject: (reason?: unknown) => void; - const promise = new Promise((_resolve, _reject) => { - resolve = _resolve; - reject = _reject; - }); - // @ts-expect-error We have in fact assigned resolve and reject. - return { promise, resolve, reject }; - }, -})); +vi.mock('@endo/promise-kit', () => makePromiseKitMock()); vi.mock('@metamask/snaps-utils', () => ({ createWindow: vi.fn(), })); describe('IframeManager', () => { - // eslint-disable-next-line @typescript-eslint/consistent-type-imports - let IframeManager: typeof import('./iframe-manager.js').IframeManager; - - beforeEach(async () => { - vi.resetModules(); - IframeManager = (await import('./iframe-manager.js')).IframeManager; - }); - - describe('getInstance', () => { - it('is a singleton', () => { - expect(IframeManager.getInstance()).toBe(IframeManager.getInstance()); - }); - - it('sets up event listener on construction', () => { - const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); - let manager = IframeManager.getInstance(); - - expect(manager).toBeInstanceOf(IframeManager); - expect(addEventListenerSpy).toHaveBeenCalledOnce(); - expect(addEventListenerSpy).toHaveBeenCalledWith( - 'message', - expect.any(Function), - ); - - manager = IframeManager.getInstance(); - expect(addEventListenerSpy).toHaveBeenCalledOnce(); - }); - }); + const makeGetPort = + (port: MessagePort = new MessageChannel().port1) => + async (_window: Window): Promise => + Promise.resolve(port); describe('create', () => { it('creates a new iframe', async () => { @@ -56,12 +23,11 @@ describe('IframeManager', () => { vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce( mockWindow as Window, ); - - const manager = IframeManager.getInstance(); + const manager = new IframeManager(); const sendMessageSpy = vi .spyOn(manager, 'sendMessage') .mockImplementation(vi.fn()); - const [newWindow, id] = await manager.create(); + const [newWindow, id] = await manager.create({ getPort: makeGetPort() }); expect(newWindow).toBe(mockWindow); expect(id).toBeTypeOf('string'); @@ -78,12 +44,15 @@ describe('IframeManager', () => { mockWindow as Window, ); - const manager = IframeManager.getInstance(); + const manager = new IframeManager(); const sendMessageSpy = vi .spyOn(manager, 'sendMessage') .mockImplementation(vi.fn()); const id = 'foo'; - const [newWindow, returnedId] = await manager.create(id); + const [newWindow, returnedId] = await manager.create({ + id, + getPort: makeGetPort(), + }); expect(newWindow).toBe(mockWindow); expect(returnedId).toBe(id); @@ -93,6 +62,36 @@ describe('IframeManager', () => { data: null, }); }); + + it('creates a new iframe with the default getPort function', async () => { + vi.resetModules(); + vi.doMock('@ocap/streams', () => ({ + initializeMessageChannel: vi.fn(), + makeMessagePortStreamPair: vi.fn(() => ({ reader: {}, writer: {} })), + MessagePortReader: class Mock1 {}, + MessagePortWriter: class Mock2 {}, + })); + const IframeManager2 = (await import('./iframe-manager.js')) + .IframeManager; + + const mockWindow = {}; + vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce( + mockWindow as Window, + ); + const manager = new IframeManager2(); + const sendMessageSpy = vi + .spyOn(manager, 'sendMessage') + .mockImplementation(vi.fn()); + const [newWindow, id] = await manager.create(); + + expect(newWindow).toBe(mockWindow); + expect(id).toBeTypeOf('string'); + expect(sendMessageSpy).toHaveBeenCalledOnce(); + expect(sendMessageSpy).toHaveBeenCalledWith(id, { + type: 'ping', + data: null, + }); + }); }); describe('delete', () => { @@ -107,22 +106,22 @@ describe('IframeManager', () => { return iframe.contentWindow as Window; }); - const manager = IframeManager.getInstance(); + const manager = new IframeManager(); vi.spyOn(manager, 'sendMessage').mockImplementation(vi.fn()); - await manager.create(id); - manager.delete(id); + await manager.create({ id, getPort: makeGetPort() }); + await manager.delete(id); expect(removeSpy).toHaveBeenCalledOnce(); }); it('ignores attempt to delete unrecognized iframe', async () => { const id = 'foo'; - const manager = IframeManager.getInstance(); + const manager = new IframeManager(); const iframe = document.createElement('iframe'); const removeSpy = vi.spyOn(iframe, 'remove'); - manager.delete(id); + await manager.delete(id); expect(removeSpy).not.toHaveBeenCalled(); }); @@ -130,72 +129,107 @@ describe('IframeManager', () => { describe('sendMessage', () => { it('sends a message to an iframe', async () => { - const iframeWindow = { postMessage: vi.fn() }; - vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce( - iframeWindow as unknown as Window, - ); + vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce({} as Window); - const manager = IframeManager.getInstance(); + const manager = new IframeManager(); const sendMessageSpy = vi.spyOn(manager, 'sendMessage'); + // Intercept the ping message in create() sendMessageSpy.mockImplementationOnce(async () => Promise.resolve()); + const { port1, port2 } = new MessageChannel(); + const portPostMessageSpy = vi.spyOn(port1, 'postMessage'); const id = 'foo'; - await manager.create(id); + await manager.create({ id, getPort: makeGetPort(port1) }); const message = { type: Command.Evaluate, data: '2+2' }; const messagePromise = manager.sendMessage(id, message); const messageId: string | undefined = - iframeWindow.postMessage.mock.lastCall?.[0]?.id; + portPostMessageSpy.mock.lastCall?.[0]?.value?.id; expect(messageId).toBeTypeOf('string'); - window.dispatchEvent( - new MessageEvent('message', { - data: { - id: messageId, - message: { - type: Command.Evaluate, - data: '4', - }, + port2.postMessage({ + done: false, + value: { + id: messageId, + message: { + type: Command.Evaluate, + data: '4', }, - }), - ); + }, + }); - expect(iframeWindow.postMessage).toHaveBeenCalledOnce(); - expect(iframeWindow.postMessage).toHaveBeenCalledWith( - { id: messageId, message }, - '*', - ); + expect(portPostMessageSpy).toHaveBeenCalledOnce(); + expect(portPostMessageSpy).toHaveBeenCalledWith({ + done: false, + value: { + id: messageId, + message, + }, + }); expect(await messagePromise).toBe('4'); }); it('throws if iframe not found', async () => { - const manager = IframeManager.getInstance(); + const manager = new IframeManager(); const id = 'foo'; const message = { type: Command.Ping, data: null }; await expect(manager.sendMessage(id, message)).rejects.toThrow( - `No iframe with id "${id}"`, + `No vat with id "${id}"`, ); }); }); - describe('warnings', () => { - it('calls console.warn when receiving unexpected message', () => { - // Initialize manager - IframeManager.getInstance(); + describe('miscellaneous', () => { + it('calls console.warn when receiving unexpected message', async () => { + vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce({} as Window); + + const manager = new IframeManager(); const warnSpy = vi.spyOn(console, 'warn'); + const sendMessageSpy = vi.spyOn(manager, 'sendMessage'); + // Intercept the ping message in create() + sendMessageSpy.mockImplementationOnce(async () => Promise.resolve()); - window.dispatchEvent( - new MessageEvent('message', { - data: 'foo', - }), - ); + const { port1, port2 } = new MessageChannel(); + await manager.create({ getPort: makeGetPort(port1) }); + + port2.postMessage({ done: false, value: 'foo' }); + await delay(10); expect(warnSpy).toHaveBeenCalledWith( 'Offscreen received message with unexpected format', 'foo', ); }); + + it('calls console.error when receiving message with unknown id', async () => { + vi.mocked(snapsUtils.createWindow).mockResolvedValueOnce({} as Window); + + const manager = new IframeManager(); + const errorSpy = vi.spyOn(console, 'error'); + const sendMessageSpy = vi.spyOn(manager, 'sendMessage'); + // Intercept the ping message in create() + sendMessageSpy.mockImplementationOnce(async () => Promise.resolve()); + + const { port1, port2 } = new MessageChannel(); + await manager.create({ getPort: makeGetPort(port1) }); + + port2.postMessage({ + done: false, + value: { + id: 'foo', + message: { + type: Command.Evaluate, + data: '"bar"', + }, + }, + }); + await delay(10); + + expect(errorSpy).toHaveBeenCalledWith( + 'No unresolved message with id "foo".', + ); + }); }); }); diff --git a/packages/extension/src/iframe-manager.ts b/packages/extension/src/iframe-manager.ts index 4ccfd0f80..5e5c1866d 100644 --- a/packages/extension/src/iframe-manager.ts +++ b/packages/extension/src/iframe-manager.ts @@ -1,99 +1,102 @@ import type { PromiseKit } from '@endo/promise-kit'; import { makePromiseKit } from '@endo/promise-kit'; import { createWindow } from '@metamask/snaps-utils'; +import type { MessagePortReader, MessagePortStreamPair } from '@ocap/streams'; +import { + initializeMessageChannel, + makeMessagePortStreamPair, +} from '@ocap/streams'; -import type { IframeMessage } from './shared.js'; +import type { IframeMessage, WrappedIframeMessage } from './shared.js'; import { Command, isWrappedIframeMessage } from './shared.js'; const IFRAME_URI = 'iframe.html'; -// The actual