From 7d61d5826bfc2430233237b7b9893ed3e72b0802 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Mon, 29 Jul 2024 13:49:54 +0200 Subject: [PATCH 01/20] feat: Add streams package --- constraints.pro | 5 +++ packages/streams/CHANGELOG.md | 10 +++++ packages/streams/LICENSE | 20 ++++++++++ packages/streams/README.md | 7 ++++ packages/streams/jest.config.js | 26 +++++++++++++ packages/streams/package.json | 57 ++++++++++++++++++++++++++++ packages/streams/src/index.test.ts | 9 +++++ packages/streams/src/index.ts | 9 +++++ packages/streams/tsconfig.build.json | 10 +++++ packages/streams/tsconfig.json | 8 ++++ packages/streams/typedoc.json | 7 ++++ tsconfig.build.json | 3 +- tsconfig.json | 3 +- yarn.lock | 18 +++++++++ 14 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 packages/streams/CHANGELOG.md create mode 100644 packages/streams/LICENSE create mode 100644 packages/streams/README.md create mode 100644 packages/streams/jest.config.js create mode 100644 packages/streams/package.json create mode 100644 packages/streams/src/index.test.ts create mode 100644 packages/streams/src/index.ts create mode 100644 packages/streams/tsconfig.build.json create mode 100644 packages/streams/tsconfig.json create mode 100644 packages/streams/typedoc.json diff --git a/constraints.pro b/constraints.pro index be0347393..ac69cd94a 100644 --- a/constraints.pro +++ b/constraints.pro @@ -209,9 +209,11 @@ gen_enforced_field(WorkspaceCwd, 'module', './dist/index.mjs') :- % Non-published packages must not specify an entrypoint. gen_enforced_field(WorkspaceCwd, 'main', null) :- WorkspaceCwd \= 'packages/shims', + WorkspaceCwd \= 'packages/streams', workspace_field(WorkspaceCwd, 'private', true). gen_enforced_field(WorkspaceCwd, 'module', null) :- WorkspaceCwd \= 'packages/shims', + WorkspaceCwd \= 'packages/streams', workspace_field(WorkspaceCwd, 'private', true). % The type definitions entrypoint for all publishable packages must be the same. @@ -220,6 +222,7 @@ gen_enforced_field(WorkspaceCwd, 'types', './dist/index.d.cts') :- % Non-published packages must not specify a type definitions entrypoint. gen_enforced_field(WorkspaceCwd, 'types', null) :- WorkspaceCwd \= 'packages/shims', + WorkspaceCwd \= 'packages/streams', workspace_field(WorkspaceCwd, 'private', true). % The exports for all published packages must be the same. @@ -239,6 +242,7 @@ gen_enforced_field(WorkspaceCwd, 'exports["./package.json"]', './package.json') % Non-published packages must not specify exports. gen_enforced_field(WorkspaceCwd, 'exports', null) :- WorkspaceCwd \= 'packages/shims', + WorkspaceCwd \= 'packages/streams', workspace_field(WorkspaceCwd, 'private', true). % Published packages must not have side effects. @@ -247,6 +251,7 @@ gen_enforced_field(WorkspaceCwd, 'sideEffects', false) :- % Non-published packages must not specify side effects. gen_enforced_field(WorkspaceCwd, 'sideEffects', null) :- 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/packages/streams/CHANGELOG.md b/packages/streams/CHANGELOG.md new file mode 100644 index 000000000..0c82cb1ed --- /dev/null +++ b/packages/streams/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/ocap-kernel/ diff --git a/packages/streams/LICENSE b/packages/streams/LICENSE new file mode 100644 index 000000000..6f8bff03f --- /dev/null +++ b/packages/streams/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2024 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/streams/README.md b/packages/streams/README.md new file mode 100644 index 000000000..70dd9c634 --- /dev/null +++ b/packages/streams/README.md @@ -0,0 +1,7 @@ +# `streams` + +Ocap Kernel streams, compatible with `@endo/stream`. + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/ocap-kernel#readme). diff --git a/packages/streams/jest.config.js b/packages/streams/jest.config.js new file mode 100644 index 000000000..ca0841333 --- /dev/null +++ b/packages/streams/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/streams/package.json b/packages/streams/package.json new file mode 100644 index 000000000..d65740b8e --- /dev/null +++ b/packages/streams/package.json @@ -0,0 +1,57 @@ +{ + "name": "@ocap/streams", + "version": "0.0.0", + "private": true, + "description": "Ocap Kernel streams", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/ocap-kernel.git" + }, + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.cts", + "files": [ + "dist" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --clean", + "build:docs": "typedoc", + "changelog:validate": "../../scripts/validate-changelog.sh streams", + "publish:preview": "yarn npm publish --tag preview", + "test": "jest --reporters=jest-silent-reporter", + "test:clean": "jest --clearCache", + "test:dev": "jest --verbose --coverage false", + "test:verbose": "jest --verbose", + "test:watch": "jest --watch" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.15.3", + "@metamask/auto-changelog": "^3.4.4", + "@ts-bridge/cli": "^0.1.4", + "@ts-bridge/shims": "^0.1.1", + "@types/jest": "^28.1.6", + "deepmerge": "^4.3.1", + "jest": "^28.1.3", + "ts-jest": "^28.0.7", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~4.9.5" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/streams/src/index.test.ts b/packages/streams/src/index.test.ts new file mode 100644 index 000000000..bc062d369 --- /dev/null +++ b/packages/streams/src/index.test.ts @@ -0,0 +1,9 @@ +import greeter from '.'; + +describe('Test', () => { + it('greets', () => { + const name = 'Huey'; + const result = greeter(name); + expect(result).toBe('Hello, Huey!'); + }); +}); diff --git a/packages/streams/src/index.ts b/packages/streams/src/index.ts new file mode 100644 index 000000000..6972c1172 --- /dev/null +++ b/packages/streams/src/index.ts @@ -0,0 +1,9 @@ +/** + * Example function that returns a greeting for the given name. + * + * @param name - The name to greet. + * @returns The greeting. + */ +export default function greeter(name: string): string { + return `Hello, ${name}!`; +} diff --git a/packages/streams/tsconfig.build.json b/packages/streams/tsconfig.build.json new file mode 100644 index 000000000..c622f4091 --- /dev/null +++ b/packages/streams/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [], + "include": ["./src"] +} diff --git a/packages/streams/tsconfig.json b/packages/streams/tsconfig.json new file mode 100644 index 000000000..6f1d89de4 --- /dev/null +++ b/packages/streams/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [], + "include": ["./src"] +} diff --git a/packages/streams/typedoc.json b/packages/streams/typedoc.json new file mode 100644 index 000000000..c9da015db --- /dev/null +++ b/packages/streams/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 5107ebe6e..34acb2854 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,6 +3,7 @@ "include": [], "references": [ { "path": "./packages/extension/tsconfig.build.json" }, - { "path": "./packages/shims/tsconfig.build.json" } + { "path": "./packages/shims/tsconfig.build.json" }, + { "path": "./packages/streams/tsconfig.build.json" } ] } diff --git a/tsconfig.json b/tsconfig.json index 1e0c7163e..f96c091b3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "include": [], "references": [ { "path": "./packages/extension" }, - { "path": "./packages/shims" } + { "path": "./packages/shims" }, + { "path": "./packages/streams" } ] } diff --git a/yarn.lock b/yarn.lock index a75f7d43b..28160caf3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1299,6 +1299,24 @@ __metadata: languageName: unknown linkType: soft +"@ocap/streams@workspace:packages/streams": + version: 0.0.0-use.local + resolution: "@ocap/streams@workspace:packages/streams" + dependencies: + "@arethetypeswrong/cli": "npm:^0.15.3" + "@metamask/auto-changelog": "npm:^3.4.4" + "@ts-bridge/cli": "npm:^0.1.4" + "@ts-bridge/shims": "npm:^0.1.1" + "@types/jest": "npm:^28.1.6" + deepmerge: "npm:^4.3.1" + jest: "npm:^28.1.3" + ts-jest: "npm:^28.0.7" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~4.9.5" + languageName: unknown + linkType: soft + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" From a0a8b3b688b223e52c0b1a1589103d4053340401 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Wed, 31 Jul 2024 16:17:57 +0100 Subject: [PATCH 02/20] feat(streams): Add streams and message-channel modules --- packages/streams/package.json | 5 ++ packages/streams/src/message-channel.ts | 98 +++++++++++++++++++++++++ packages/streams/src/streams.ts | 92 +++++++++++++++++++++++ packages/streams/tsconfig.build.json | 1 + packages/streams/tsconfig.json | 3 +- yarn.lock | 14 ++++ 6 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 packages/streams/src/message-channel.ts create mode 100644 packages/streams/src/streams.ts diff --git a/packages/streams/package.json b/packages/streams/package.json index d65740b8e..03b2e092e 100644 --- a/packages/streams/package.json +++ b/packages/streams/package.json @@ -38,6 +38,11 @@ "test:verbose": "jest --verbose", "test:watch": "jest --watch" }, + "dependencies": { + "@endo/promise-kit": "^1.1.2", + "@endo/stream": "^1.2.2", + "@metamask/utils": "^9.1.0" + }, "devDependencies": { "@arethetypeswrong/cli": "^0.15.3", "@metamask/auto-changelog": "^3.4.4", diff --git a/packages/streams/src/message-channel.ts b/packages/streams/src/message-channel.ts new file mode 100644 index 000000000..d02cd8b03 --- /dev/null +++ b/packages/streams/src/message-channel.ts @@ -0,0 +1,98 @@ +import { makePromiseKit } from '@endo/promise-kit'; +import { isObject } from '@metamask/utils'; + +// This module establishes a simple protocol for establishing a MessageChannel between +// a parent window and its iframe, as follows: +// 1. The parent window creates an iframe and appends it to the DOM. The iframe must be +// loaded and the `contentWindow` property must be accessible. +// 2. The iframe calls `receiveMessagePort()` on startup in one of its scripts. The script +// element in question should not have the `async` attribute. +// 3. The parent window calls `initializeMessageChannel()` which sends a message port to +// the iframe. When the returned promise resolves, the parent window and the iframe have +// established a message channel. + +enum MessageType { + Initialize = 'INIT_MESSAGE_CHANNEL', + Acknowledge = 'ACK_MESSAGE_CHANNEL', +} + +type InitializeMessage = { type: MessageType.Initialize; port: MessagePort }; +type AcknowledgeMessage = { type: MessageType.Acknowledge }; + +const isInitMessage = (value: unknown): value is InitializeMessage => + isObject(value) && + value.type === MessageType.Initialize && + value.port instanceof MessagePort; + +const isAckMessage = (value: unknown): value is AcknowledgeMessage => + isObject(value) && value.type === MessageType.Acknowledge; + +const stringify = (value: unknown) => JSON.stringify(value, null, 2); + +/** + * Creates a message channel and sends one of the ports to the target window. The iframe + * associated with the target window must be loaded, and it must have called + * {@link receiveMessagePort} to receive the remote message port. Rejects if the first + * message received over the channel is not an {@link AcknowledgeMessage}. + * @param targetWindow - The iframe window to send the message port to. + * @returns A promise that resolves with the local message port, once the target window + * has acknowledged its receipt of the remote port. + */ +export async function initializeMessageChannel( + targetWindow: Window, +): Promise { + const { port1, port2 } = new MessageChannel(); + + const { promise, resolve, reject } = makePromiseKit(); + // Assigning to the `onmessage` property initializes the port's message queue. + port1.onmessage = (message: MessageEvent) => { + if (!isAckMessage(message.data)) { + reject( + new Error( + `Received unexpected message via message port:\n${stringify( + message.data, + )}`, + ), + ); + return; + } + + resolve(port1); + }; + promise.catch(() => port1.close()).finally(() => (port1.onmessage = null)); + + const initMessage: InitializeMessage = { + type: MessageType.Initialize, + port: port2, + }; + targetWindow.postMessage(initMessage, '*'); + return promise; +} + +/** + * Receives a message port from the parent window, and sends an {@link AcknowledgeMessage} + * over the port. Should be called in a script _without_ the `async` attribute on startup. + * The parent window must call {@link initializeMessageChannel} to send the message port + * after this iframe has loaded. Ignores any message events dispatched on the local + * `window` that are not an {@link InitializeMessage}. + * @returns A promise that resolves with a message port that can be used to communicate + * with the parent window. + */ +export async function receiveMessagePort(): Promise { + const { promise, resolve } = makePromiseKit(); + + const listener = (message: MessageEvent) => { + if (!isInitMessage(message.data)) { + return; + } + + const { port } = message.data; + const ackMessage: AcknowledgeMessage = { type: MessageType.Acknowledge }; + port.postMessage(ackMessage); + resolve(port); + }; + + window.addEventListener('message', listener); + promise.finally(() => window.removeEventListener('message', listener)); + return promise; +} diff --git a/packages/streams/src/streams.ts b/packages/streams/src/streams.ts new file mode 100644 index 000000000..899bd1daf --- /dev/null +++ b/packages/streams/src/streams.ts @@ -0,0 +1,92 @@ +import { makePromiseKit } from '@endo/promise-kit'; +import type { Reader, Writer } from '@endo/stream'; + +type Resolve = (value: unknown) => void; + +export class MessagePortReader implements Reader { + #port: MessagePort; + + #messageQueue: MessageEvent[]; + + #resolveQueue: Resolve[]; + + constructor(port: MessagePort) { + this.#port = port; + this.#messageQueue = []; + this.#resolveQueue = []; + + // Assigning to the `onmessage` property initializes the port's message queue. + this.#port.onmessage = this.#handleMessage.bind(this); + } + + [Symbol.asyncIterator]() { + return this; + } + + #handleMessage(message: MessageEvent): void { + if (this.#resolveQueue.length > 0) { + const resolve = this.#resolveQueue.shift() as Resolve; + resolve({ done: false, value: message.data }); + } else { + this.#messageQueue.push(message); + } + } + + async next(_value: never): Promise> { + const { promise, resolve } = makePromiseKit(); + if (this.#messageQueue.length > 0) { + const message = this.#messageQueue.shift() as MessageEvent; + resolve({ done: false, value: message.data }); + } else { + this.#resolveQueue.push(resolve); + } + return promise as Promise>; + } + + async return(_value: never): Promise> { + return this.#teardown(); + } + + async throw(_error: never): Promise> { + return this.#teardown(); + } + + #teardown(): IteratorResult { + this.#port.close(); + this.#port.onmessage = null; + return { done: true, value: undefined }; + } +} + +export class MessagePortWriter implements Writer { + #port: MessagePort; + + constructor(port: MessagePort) { + this.#port = port; + } + + [Symbol.asyncIterator]() { + return this; + } + + async next(value: Yield): Promise> { + this.#port.postMessage(value); + return { done: false, value: undefined }; + } + + async return(_value: never): Promise> { + return this.#teardown(); + } + + async throw(error: Error): Promise> { + return this.#teardown(error); + } + + #teardown(error?: Error): IteratorResult { + if (error !== undefined) { + this.#port.dispatchEvent(new ErrorEvent(error.message)); + } + this.#port.close(); + return { done: true, value: undefined }; + } +} diff --git a/packages/streams/tsconfig.build.json b/packages/streams/tsconfig.build.json index c622f4091..11526979b 100644 --- a/packages/streams/tsconfig.build.json +++ b/packages/streams/tsconfig.build.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.packages.build.json", "compilerOptions": { "baseUrl": "./", + "lib": ["DOM", "ES2020"], "outDir": "./dist", "rootDir": "./src" }, diff --git a/packages/streams/tsconfig.json b/packages/streams/tsconfig.json index 6f1d89de4..a8e0e0fd7 100644 --- a/packages/streams/tsconfig.json +++ b/packages/streams/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../tsconfig.packages.json", "compilerOptions": { - "baseUrl": "./" + "baseUrl": "./", + "lib": ["DOM", "ES2020"] }, "references": [], "include": ["./src"] diff --git a/yarn.lock b/yarn.lock index 28160caf3..19f8de4b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -346,6 +346,17 @@ __metadata: languageName: node linkType: hard +"@endo/stream@npm:^1.2.2": + version: 1.2.2 + resolution: "@endo/stream@npm:1.2.2" + dependencies: + "@endo/eventual-send": "npm:^1.2.2" + "@endo/promise-kit": "npm:^1.1.2" + ses: "npm:^1.5.0" + checksum: 10/2c0ebea133c7a184672679dd1597903fd14df1d6ddd3103f44ef476d9b86a58a8c04ecf42f960a79c8ba85f138e2020bf86ae1cd9a483633326bfc2d874473a4 + languageName: node + linkType: hard + "@es-joy/jsdoccomment@npm:~0.36.1": version: 0.36.1 resolution: "@es-joy/jsdoccomment@npm:0.36.1" @@ -1304,7 +1315,10 @@ __metadata: resolution: "@ocap/streams@workspace:packages/streams" dependencies: "@arethetypeswrong/cli": "npm:^0.15.3" + "@endo/promise-kit": "npm:^1.1.2" + "@endo/stream": "npm:^1.2.2" "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/utils": "npm:^9.1.0" "@ts-bridge/cli": "npm:^0.1.4" "@ts-bridge/shims": "npm:^0.1.1" "@types/jest": "npm:^28.1.6" From 0943a4aa1519d89521753b753f27c2b313f39315 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Thu, 1 Aug 2024 11:38:27 +0100 Subject: [PATCH 03/20] test(streams): Add message-channel unit tests --- packages/extension/tsconfig.json | 2 +- packages/streams/package.json | 21 +- packages/streams/src/message-channel.test.ts | 196 +++++++++++++++++++ packages/streams/src/message-channel.ts | 2 +- packages/streams/tsconfig.json | 3 +- yarn.lock | 23 ++- 6 files changed, 233 insertions(+), 14 deletions(-) create mode 100644 packages/streams/src/message-channel.test.ts diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json index ce25d4837..3c6441afd 100644 --- a/packages/extension/tsconfig.json +++ b/packages/extension/tsconfig.json @@ -7,7 +7,7 @@ "lib": ["DOM", "ES2020"], "noEmit": true, "skipLibCheck": true, - "types": ["chrome", "ses", "vitest"] + "types": ["chrome", "ses", "vitest", "vitest/jsdom"] }, "include": ["./src/**/*.ts", "./src/dev-console.mjs"] } diff --git a/packages/streams/package.json b/packages/streams/package.json index 03b2e092e..65767302e 100644 --- a/packages/streams/package.json +++ b/packages/streams/package.json @@ -32,11 +32,11 @@ "build:docs": "typedoc", "changelog:validate": "../../scripts/validate-changelog.sh streams", "publish:preview": "yarn npm publish --tag preview", - "test": "jest --reporters=jest-silent-reporter", - "test:clean": "jest --clearCache", - "test:dev": "jest --verbose --coverage false", - "test:verbose": "jest --verbose", - "test:watch": "jest --watch" + "test": "vitest run --config vitest.config.mts", + "test:clean": "yarn test --no-cache --coverage.clean", + "test:dev": "yarn test --coverage false", + "test:verbose": "yarn test --reporter verbose", + "test:watch": "vitest --config vitest.config.mts" }, "dependencies": { "@endo/promise-kit": "^1.1.2", @@ -46,15 +46,18 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.15.3", "@metamask/auto-changelog": "^3.4.4", + "@ocap/test-utils": "workspace:^", "@ts-bridge/cli": "^0.1.4", "@ts-bridge/shims": "^0.1.1", - "@types/jest": "^28.1.6", + "@types/jsdom": "^21.1.7", + "@vitest/coverage-v8": "^2.0.5", "deepmerge": "^4.3.1", - "jest": "^28.1.3", - "ts-jest": "^28.0.7", + "jsdom": "^24.1.1", "typedoc": "^0.24.8", "typedoc-plugin-missing-exports": "^2.0.0", - "typescript": "~4.9.5" + "typescript": "~4.9.5", + "vite": "^5.3.5", + "vitest": "^2.0.5" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/streams/src/message-channel.test.ts b/packages/streams/src/message-channel.test.ts new file mode 100644 index 000000000..127f0cded --- /dev/null +++ b/packages/streams/src/message-channel.test.ts @@ -0,0 +1,196 @@ +import { JSDOM } from 'jsdom'; +import { vi, describe, it, beforeEach, afterEach, beforeAll } from 'vitest'; + +import { + initializeMessageChannel, + MessageType, + receiveMessagePort, +} from './message-channel'; + +vi.mock('@endo/promise-kit', () => ({ + makePromiseKit: () => { + 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 }; + }, +})); + +describe.concurrent('initializeMessageChannel', () => { + it('calls targetWindow.postMessage', async ({ expect }) => { + const targetWindow = new JSDOM().window; + const postMessageSpy = vi.spyOn(targetWindow, 'postMessage'); + // We intentionally let this one go. It will never settle. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + initializeMessageChannel(targetWindow as unknown as Window); + + expect(postMessageSpy).toHaveBeenCalledOnce(); + expect(postMessageSpy).toHaveBeenCalledWith( + { + type: MessageType.Initialize, + port: expect.any(MessagePort), + }, + '*', + ); + }); + + it('resolves a port with no message handler once sent acknowledgment via message channel', async ({ + expect, + }) => { + const targetWindow = new JSDOM().window; + const postMessageSpy = vi.spyOn(targetWindow, 'postMessage'); + const messageChannelP = initializeMessageChannel( + targetWindow as unknown as Window, + ); + + const remotePort: MessagePort = postMessageSpy.mock.lastCall?.[0].port; + remotePort.postMessage({ type: MessageType.Acknowledge }); + + const resolvedValue = await messageChannelP; + expect(resolvedValue).toBeInstanceOf(MessagePort); + expect(resolvedValue.onmessage).toBe(null); + }); + + it.for([ + { type: MessageType.Initialize }, + { type: 'foo' }, + { foo: 'bar' }, + {}, + [], + 'foo', + 400, + null, + undefined, + ])( + 'rejects if sent unexpected message via message channel: %#', + async (unexpectedMessage, { expect }) => { + const targetWindow = new JSDOM().window; + const postMessageSpy = vi.spyOn(targetWindow, 'postMessage'); + const messageChannelP = initializeMessageChannel( + targetWindow as unknown as Window, + ); + + const remotePort: MessagePort = postMessageSpy.mock.lastCall?.[0].port; + remotePort.postMessage(unexpectedMessage); + + await expect(messageChannelP).rejects.toThrow( + /^Received unexpected message via message port/u, + ); + }, + ); +}); + +describe('receiveMessagePort', () => { + let messageEventListeners: [string, EventListenerOrEventListenerObject][] = + []; + let originalAddEventListener: typeof window.addEventListener; + + beforeAll(() => { + originalAddEventListener = window.addEventListener; + }); + + beforeEach(() => { + // JSDOM apparently affords no way to clear all event listeners between test runs, + // so we have to do it manually. + window.addEventListener = ( + ...args: Parameters + ) => { + messageEventListeners.push([args[0], args[1]]); + originalAddEventListener.call(window, ...args); + }; + }); + + afterEach(() => { + messageEventListeners.forEach(([messageType, listener]) => { + window.removeEventListener(messageType, listener); + }); + messageEventListeners = []; + window.addEventListener = originalAddEventListener; + }); + + it('receives and acknowledges a message port', async ({ expect }) => { + const messagePortP = receiveMessagePort(); + + const { port2 } = new MessageChannel(); + const portPostMessageSpy = vi.spyOn(port2, 'postMessage'); + + window.dispatchEvent( + new MessageEvent('message', { + data: { type: MessageType.Initialize, port: port2 }, + }), + ); + + const resolvedValue = await messagePortP; + + expect(resolvedValue).toBe(port2); + expect(portPostMessageSpy).toHaveBeenCalledOnce(); + expect(portPostMessageSpy).toHaveBeenCalledWith({ + type: MessageType.Acknowledge, + }); + }); + + it('cleans up event listeners', async ({ expect }) => { + const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); + + const messagePortP = receiveMessagePort(); + + const { port2 } = new MessageChannel(); + window.dispatchEvent( + new MessageEvent('message', { + data: { type: MessageType.Initialize, port: port2 }, + }), + ); + + await messagePortP; + + expect(addEventListenerSpy).toHaveBeenCalledOnce(); + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'message', + expect.any(Function), + ); + expect(removeEventListenerSpy).toHaveBeenCalledOnce(); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'message', + expect.any(Function), + ); + }); + + it.for([ + { type: MessageType.Acknowledge }, + { type: 'foo' }, + { foo: 'bar' }, + {}, + [], + 'foo', + 400, + null, + undefined, + ])( + 'ignores unexpected message events dispatched on window: %#', + async (unexpectedMessage, { expect }) => { + const messagePortP = receiveMessagePort(); + + const { port2 } = new MessageChannel(); + const portPostMessageSpy = vi.spyOn(port2, 'postMessage'); + + const fulfillmentDetector = vi.fn(); + messagePortP.finally(fulfillmentDetector); + + window.dispatchEvent( + new MessageEvent('message', { + data: unexpectedMessage, + }), + ); + + // eslint-disable-next-line @typescript-eslint/await-thenable + await null; + + expect(fulfillmentDetector).not.toHaveBeenCalled(); + expect(portPostMessageSpy).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/streams/src/message-channel.ts b/packages/streams/src/message-channel.ts index d02cd8b03..0d655729b 100644 --- a/packages/streams/src/message-channel.ts +++ b/packages/streams/src/message-channel.ts @@ -11,7 +11,7 @@ import { isObject } from '@metamask/utils'; // the iframe. When the returned promise resolves, the parent window and the iframe have // established a message channel. -enum MessageType { +export enum MessageType { Initialize = 'INIT_MESSAGE_CHANNEL', Acknowledge = 'ACK_MESSAGE_CHANNEL', } diff --git a/packages/streams/tsconfig.json b/packages/streams/tsconfig.json index a8e0e0fd7..c46d44711 100644 --- a/packages/streams/tsconfig.json +++ b/packages/streams/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.packages.json", "compilerOptions": { "baseUrl": "./", - "lib": ["DOM", "ES2020"] + "lib": ["DOM", "ES2020"], + "types": ["vitest", "vitest/jsdom"] }, "references": [], "include": ["./src"] diff --git a/yarn.lock b/yarn.lock index 19f8de4b8..03ee3f17e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1321,7 +1321,8 @@ __metadata: "@metamask/utils": "npm:^9.1.0" "@ts-bridge/cli": "npm:^0.1.4" "@ts-bridge/shims": "npm:^0.1.1" - "@types/jest": "npm:^28.1.6" + "@types/jsdom": "npm:^21.1.7" + "@vitest/coverage-v8": "npm:^2.0.4" deepmerge: "npm:^4.3.1" jest: "npm:^28.1.3" ts-jest: "npm:^28.0.7" @@ -1613,6 +1614,17 @@ __metadata: languageName: node linkType: hard +"@types/jsdom@npm:^21.1.7": + version: 21.1.7 + resolution: "@types/jsdom@npm:21.1.7" + dependencies: + "@types/node": "npm:*" + "@types/tough-cookie": "npm:*" + parse5: "npm:^7.0.0" + checksum: 10/a5ee54aec813ac928ef783f69828213af4d81325f584e1fe7573a9ae139924c40768d1d5249237e62d51b9a34ed06bde059c86c6b0248d627457ec5e5d532dfa + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.9": version: 7.0.11 resolution: "@types/json-schema@npm:7.0.11" @@ -1664,6 +1676,13 @@ __metadata: languageName: node linkType: hard +"@types/tough-cookie@npm:*": + version: 4.0.5 + resolution: "@types/tough-cookie@npm:4.0.5" + checksum: 10/01fd82efc8202670865928629697b62fe9bf0c0dcbc5b1c115831caeb073a2c0abb871ff393d7df1ae94ea41e256cb87d2a5a91fd03cdb1b0b4384e08d4ee482 + languageName: node + linkType: hard + "@typescript-eslint/eslint-plugin@npm:^5.43.0": version: 5.43.0 resolution: "@typescript-eslint/eslint-plugin@npm:5.43.0" @@ -5715,7 +5734,7 @@ __metadata: languageName: node linkType: hard -"parse5@npm:^7.1.2": +"parse5@npm:^7.0.0, parse5@npm:^7.1.2": version: 7.1.2 resolution: "parse5@npm:7.1.2" dependencies: From 34d0ce1524ca4df02529f4452123512f77602d11 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Mon, 29 Jul 2024 13:49:54 +0200 Subject: [PATCH 04/20] test: Add internal test-utils package --- constraints.pro | 7 +++ package.json | 2 +- packages/extension/package.json | 6 +- packages/extension/src/iframe-manager.test.ts | 13 +---- packages/extension/tsconfig.json | 1 + packages/shims/package.json | 4 +- packages/streams/package.json | 1 - packages/streams/src/message-channel.test.ts | 13 +---- packages/streams/tsconfig.build.json | 3 +- packages/streams/tsconfig.json | 4 +- packages/test-utils/README.md | 7 +++ packages/test-utils/package.json | 21 +++++++ packages/test-utils/src/env/mock-endo.ts | 7 +++ packages/test-utils/src/index.ts | 2 + packages/test-utils/src/mocks.ts | 15 +++++ packages/test-utils/src/utils.ts | 2 + packages/test-utils/tsconfig.build.json | 10 ++++ packages/test-utils/tsconfig.json | 9 +++ tsconfig.json | 3 +- tsconfig.packages.json | 3 +- yarn.lock | 55 +++++++++++-------- 21 files changed, 132 insertions(+), 56 deletions(-) create mode 100644 packages/test-utils/README.md create mode 100644 packages/test-utils/package.json create mode 100644 packages/test-utils/src/env/mock-endo.ts create mode 100644 packages/test-utils/src/index.ts create mode 100644 packages/test-utils/src/mocks.ts create mode 100644 packages/test-utils/src/utils.ts create mode 100644 packages/test-utils/tsconfig.build.json create mode 100644 packages/test-utils/tsconfig.json diff --git a/constraints.pro b/constraints.pro index ac69cd94a..973aece47 100644 --- a/constraints.pro +++ b/constraints.pro @@ -243,6 +243,7 @@ gen_enforced_field(WorkspaceCwd, 'exports["./package.json"]', './package.json') gen_enforced_field(WorkspaceCwd, 'exports', null) :- WorkspaceCwd \= 'packages/shims', WorkspaceCwd \= 'packages/streams', + WorkspaceCwd \= 'packages/test-utils', workspace_field(WorkspaceCwd, 'private', true). % Published packages must not have side effects. @@ -273,6 +274,7 @@ gen_enforced_field(WorkspaceCwd, 'files', []) :- % All packages except the root and extension must have the same "build:docs" script. gen_enforced_field(WorkspaceCwd, 'scripts.build:docs', 'typedoc') :- WorkspaceCwd \= 'packages/extension', + WorkspaceCwd \= 'packages/test-utils', WorkspaceCwd \= '.'. % All published packages must have the same "publish:preview" script. @@ -306,22 +308,27 @@ gen_enforced_field(WorkspaceCwd, 'scripts.changelog:update', CorrectChangelogUpd % All non-root packages must have the same "test" script. gen_enforced_field(WorkspaceCwd, 'scripts.test', 'vitest run --config vitest.config.mts') :- WorkspaceCwd \= 'packages/shims', + WorkspaceCwd \= 'packages/test-utils', WorkspaceCwd \= '.'. % All non-root packages must have the same "test:clean" script. gen_enforced_field(WorkspaceCwd, 'scripts.test:clean', 'yarn test --no-cache --coverage.clean') :- + WorkspaceCwd \= 'packages/test-utils', WorkspaceCwd \= '.'. % All non-root packages must have the same "test:dev" script. gen_enforced_field(WorkspaceCwd, 'scripts.test:dev', 'yarn test --coverage false') :- + WorkspaceCwd \= 'packages/test-utils', WorkspaceCwd \= '.'. % All non-root packages must have the same "test:verbose" script. gen_enforced_field(WorkspaceCwd, 'scripts.test:verbose', 'yarn test --reporter verbose') :- + WorkspaceCwd \= 'packages/test-utils', WorkspaceCwd \= '.'. % All non-root packages must have the same "test:watch" script. gen_enforced_field(WorkspaceCwd, 'scripts.test:watch', 'vitest --config vitest.config.mts') :- + WorkspaceCwd \= 'packages/test-utils', WorkspaceCwd \= '.'. % All dependency ranges must be recognizable (this makes it possible to apply diff --git a/package.json b/package.json index 978c610e4..16137322d 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "typedoc": "^0.24.8", "typescript": "~4.9.5", "vite": "^5.3.5", - "vitest": "^2.0.4" + "vitest": "^2.0.5" }, "packageManager": "yarn@4.2.2", "engines": { diff --git a/packages/extension/package.json b/packages/extension/package.json index 08af11d5b..ea4ff7e99 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -29,13 +29,13 @@ "@metamask/snaps-utils": "^7.8.0", "@metamask/utils": "^9.1.0", "@ocap/shims": "^0.0.0", - "ses": "^1.5.0" + "ses": "^1.7.0" }, "devDependencies": { "@arethetypeswrong/cli": "^0.15.3", "@metamask/auto-changelog": "^3.4.4", "@types/chrome": "^0.0.268", - "@vitest/coverage-v8": "^2.0.4", + "@vitest/coverage-v8": "^2.0.5", "deepmerge": "^4.3.1", "jsdom": "^24.1.1", "typedoc": "^0.24.8", @@ -43,7 +43,7 @@ "typescript": "~4.9.5", "vite": "^5.3.5", "vite-plugin-static-copy": "^1.0.6", - "vitest": "^2.0.4" + "vitest": "^2.0.5" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/extension/src/iframe-manager.test.ts b/packages/extension/src/iframe-manager.test.ts index a5c621570..6ff420403 100644 --- a/packages/extension/src/iframe-manager.test.ts +++ b/packages/extension/src/iframe-manager.test.ts @@ -1,19 +1,10 @@ import * as snapsUtils from '@metamask/snaps-utils'; +import { makePromiseKitMock } from '@ocap/test-utils/mocks'; import { vi, beforeEach, describe, it, expect } from 'vitest'; import { Command } from './shared'; -vi.mock('@endo/promise-kit', () => ({ - makePromiseKit: () => { - 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(), diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json index 3c6441afd..8f0d1116f 100644 --- a/packages/extension/tsconfig.json +++ b/packages/extension/tsconfig.json @@ -9,5 +9,6 @@ "skipLibCheck": true, "types": ["chrome", "ses", "vitest", "vitest/jsdom"] }, + "references": [{ "path": "../test-utils" }], "include": ["./src/**/*.ts", "./src/dev-console.mjs"] } diff --git a/packages/shims/package.json b/packages/shims/package.json index 7d4cdb1ad..5db0145dc 100644 --- a/packages/shims/package.json +++ b/packages/shims/package.json @@ -34,14 +34,14 @@ "dependencies": { "@endo/eventual-send": "^1.2.2", "@endo/lockdown": "^1.0.7", - "ses": "^1.5.0" + "ses": "^1.7.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "deepmerge": "^4.3.1", "mkdirp": "^3.0.1", "rimraf": "^6.0.1", - "vitest": "^2.0.4" + "vitest": "^2.0.5" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/streams/package.json b/packages/streams/package.json index 65767302e..ed13826c7 100644 --- a/packages/streams/package.json +++ b/packages/streams/package.json @@ -56,7 +56,6 @@ "typedoc": "^0.24.8", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~4.9.5", - "vite": "^5.3.5", "vitest": "^2.0.5" }, "engines": { diff --git a/packages/streams/src/message-channel.test.ts b/packages/streams/src/message-channel.test.ts index 127f0cded..ac914f967 100644 --- a/packages/streams/src/message-channel.test.ts +++ b/packages/streams/src/message-channel.test.ts @@ -1,3 +1,4 @@ +import { makePromiseKitMock } from '@ocap/test-utils/mocks'; import { JSDOM } from 'jsdom'; import { vi, describe, it, beforeEach, afterEach, beforeAll } from 'vitest'; @@ -7,17 +8,7 @@ import { receiveMessagePort, } from './message-channel'; -vi.mock('@endo/promise-kit', () => ({ - makePromiseKit: () => { - 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()); describe.concurrent('initializeMessageChannel', () => { it('calls targetWindow.postMessage', async ({ expect }) => { diff --git a/packages/streams/tsconfig.build.json b/packages/streams/tsconfig.build.json index 11526979b..b1e6f4092 100644 --- a/packages/streams/tsconfig.build.json +++ b/packages/streams/tsconfig.build.json @@ -4,7 +4,8 @@ "baseUrl": "./", "lib": ["DOM", "ES2020"], "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "types": ["ses"] }, "references": [], "include": ["./src"] diff --git a/packages/streams/tsconfig.json b/packages/streams/tsconfig.json index c46d44711..076246c94 100644 --- a/packages/streams/tsconfig.json +++ b/packages/streams/tsconfig.json @@ -3,8 +3,8 @@ "compilerOptions": { "baseUrl": "./", "lib": ["DOM", "ES2020"], - "types": ["vitest", "vitest/jsdom"] + "types": ["ses", "vitest", "vitest/jsdom"] }, - "references": [], + "references": [{ "path": "../test-utils" }], "include": ["./src"] } diff --git a/packages/test-utils/README.md b/packages/test-utils/README.md new file mode 100644 index 000000000..98623a7db --- /dev/null +++ b/packages/test-utils/README.md @@ -0,0 +1,7 @@ +# `test-utils` + +Internal testing utilities. + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/ocap-kernel#readme). diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json new file mode 100644 index 000000000..4e9023e1b --- /dev/null +++ b/packages/test-utils/package.json @@ -0,0 +1,21 @@ +{ + "name": "@ocap/test-utils", + "version": "0.0.0", + "private": true, + "description": "Internal testing utilities", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/ocap-kernel.git" + }, + "exports": { + ".": "./src/index.ts" + }, + "devDependencies": { + "ses": "^1.7.0", + "typescript": "~4.9.5", + "vitest": "^2.0.5" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/test-utils/src/env/mock-endo.ts b/packages/test-utils/src/env/mock-endo.ts new file mode 100644 index 000000000..b5139f0b3 --- /dev/null +++ b/packages/test-utils/src/env/mock-endo.ts @@ -0,0 +1,7 @@ +// eslint-disable-next-line spaced-comment +/// + +import { vi } from 'vitest'; + +globalThis.lockdown = vi.fn(() => undefined); +globalThis.harden = vi.fn((value: Value) => value); diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts new file mode 100644 index 000000000..d89538700 --- /dev/null +++ b/packages/test-utils/src/index.ts @@ -0,0 +1,2 @@ +export * from './mocks'; +export * from './utils'; diff --git a/packages/test-utils/src/mocks.ts b/packages/test-utils/src/mocks.ts new file mode 100644 index 000000000..f2bc54532 --- /dev/null +++ b/packages/test-utils/src/mocks.ts @@ -0,0 +1,15 @@ +/** + * Create a module mock for `@endo/promise-kit`. + * @returns The mock. + */ +export const makePromiseKitMock = () => ({ + makePromiseKit: () => { + 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 }; + }, +}); diff --git a/packages/test-utils/src/utils.ts b/packages/test-utils/src/utils.ts new file mode 100644 index 000000000..56678a158 --- /dev/null +++ b/packages/test-utils/src/utils.ts @@ -0,0 +1,2 @@ +export const delay = async (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/packages/test-utils/tsconfig.build.json b/packages/test-utils/tsconfig.build.json new file mode 100644 index 000000000..c622f4091 --- /dev/null +++ b/packages/test-utils/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [], + "include": ["./src"] +} diff --git a/packages/test-utils/tsconfig.json b/packages/test-utils/tsconfig.json new file mode 100644 index 000000000..b61928cb9 --- /dev/null +++ b/packages/test-utils/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [], + "include": ["./src"], + "types": ["ses", "vitest", "vitest/jsdom"] +} diff --git a/tsconfig.json b/tsconfig.json index f96c091b3..c9a1a117b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "references": [ { "path": "./packages/extension" }, { "path": "./packages/shims" }, - { "path": "./packages/streams" } + { "path": "./packages/streams" }, + { "path": "./packages/test-utils" } ] } diff --git a/tsconfig.packages.json b/tsconfig.packages.json index 11ff16e94..3ee06764d 100644 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -13,7 +13,8 @@ * uncompiled source code for packages that live in this repo. */ "paths": { - "@ocap/*": ["../*/src"] + "@ocap/*": ["../*/src"], + "@ocap/test-utils/*": ["../test-utils/src/*"] }, "resolveJsonModule": true, "strict": true, diff --git a/yarn.lock b/yarn.lock index 03ee3f17e..5fa61b118 100644 --- a/yarn.lock +++ b/yarn.lock @@ -312,10 +312,10 @@ __metadata: languageName: node linkType: hard -"@endo/env-options@npm:^1.1.4": - version: 1.1.4 - resolution: "@endo/env-options@npm:1.1.4" - checksum: 10/718c15ae91b5e3d00c8b90f64d07930cc6bc58297cbda55097c339121a8c63045cd909f4bd7a4c39635354d228fa68f6182eb43ac0b0eaa62b05618484ec8209 +"@endo/env-options@npm:^1.1.4, @endo/env-options@npm:^1.1.5": + version: 1.1.5 + resolution: "@endo/env-options@npm:1.1.5" + checksum: 10/ce4cb29ecf387f52f7d1c9e7e43b0a1064326587ebac62e7c239bf2df71aa4c3296d2a05cf169d1efcd8c1ddf73aeede8afd86e7b5c9387b80e8e0939d1af0f6 languageName: node linkType: hard @@ -1246,16 +1246,16 @@ __metadata: "@metamask/utils": "npm:^9.1.0" "@ocap/shims": "npm:^0.0.0" "@types/chrome": "npm:^0.0.268" - "@vitest/coverage-v8": "npm:^2.0.4" + "@vitest/coverage-v8": "npm:^2.0.5" deepmerge: "npm:^4.3.1" jsdom: "npm:^24.1.1" - ses: "npm:^1.5.0" + ses: "npm:^1.7.0" typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~4.9.5" vite: "npm:^5.3.5" vite-plugin-static-copy: "npm:^1.0.6" - vitest: "npm:^2.0.4" + vitest: "npm:^2.0.5" languageName: unknown linkType: soft @@ -1291,7 +1291,7 @@ __metadata: typedoc: "npm:^0.24.8" typescript: "npm:~4.9.5" vite: "npm:^5.3.5" - vitest: "npm:^2.0.4" + vitest: "npm:^2.0.5" languageName: unknown linkType: soft @@ -1305,8 +1305,8 @@ __metadata: deepmerge: "npm:^4.3.1" mkdirp: "npm:^3.0.1" rimraf: "npm:^6.0.1" - ses: "npm:^1.5.0" - vitest: "npm:^2.0.4" + ses: "npm:^1.7.0" + vitest: "npm:^2.0.5" languageName: unknown linkType: soft @@ -1322,13 +1322,24 @@ __metadata: "@ts-bridge/cli": "npm:^0.1.4" "@ts-bridge/shims": "npm:^0.1.1" "@types/jsdom": "npm:^21.1.7" - "@vitest/coverage-v8": "npm:^2.0.4" + "@vitest/coverage-v8": "npm:^2.0.5" deepmerge: "npm:^4.3.1" jest: "npm:^28.1.3" ts-jest: "npm:^28.0.7" typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~4.9.5" + vitest: "npm:^2.0.5" + languageName: unknown + linkType: soft + +"@ocap/test-utils@workspace:packages/test-utils": + version: 0.0.0-use.local + resolution: "@ocap/test-utils@workspace:packages/test-utils" + dependencies: + ses: "npm:^1.7.0" + typescript: "npm:~4.9.5" + vitest: "npm:^2.0.5" languageName: unknown linkType: soft @@ -1870,9 +1881,9 @@ __metadata: languageName: node linkType: hard -"@vitest/coverage-v8@npm:^2.0.4": - version: 2.0.4 - resolution: "@vitest/coverage-v8@npm:2.0.4" +"@vitest/coverage-v8@npm:^2.0.5": + version: 2.0.5 + resolution: "@vitest/coverage-v8@npm:2.0.5" dependencies: "@ampproject/remapping": "npm:^2.3.0" "@bcoe/v8-coverage": "npm:^0.2.3" @@ -1887,8 +1898,8 @@ __metadata: test-exclude: "npm:^7.0.1" tinyrainbow: "npm:^1.2.0" peerDependencies: - vitest: 2.0.4 - checksum: 10/de23ca9c8e7cd704d889475af9a8282a1d29e4ca05909edc28df3f55b65a10cba344ab350ab255d0ad1b8a3dc6d98ebb12d5c2614bc8d92b03b645f15912ae61 + vitest: 2.0.5 + checksum: 10/bb774d1a52b85adf94dcf62dc9684c59bd6aba6f8d43ce4d4afa06e3ca85651ec217f74842c0c4a81ea0158f029e484055207869e5d741cfbc3119257399fb83 languageName: node linkType: hard @@ -6334,12 +6345,12 @@ __metadata: languageName: node linkType: hard -"ses@npm:^1.1.0, ses@npm:^1.5.0": - version: 1.5.0 - resolution: "ses@npm:1.5.0" +"ses@npm:^1.1.0, ses@npm:^1.5.0, ses@npm:^1.7.0": + version: 1.7.0 + resolution: "ses@npm:1.7.0" dependencies: - "@endo/env-options": "npm:^1.1.4" - checksum: 10/6f2cd8f3607f98838d6458cc4b77e6c1cffe8fc30b923212c1dde73c89e9a0e1eaa8f44ac38811d73a6961560e5be4155a0e58879f6dfe69d3319433251891da + "@endo/env-options": "npm:^1.1.5" + checksum: 10/8d1227fadcd06653d1b49083c067ae07e55164af984c9e8b393238fbbd315f47216472e3ac65a78638955f3f1a2537e9c9865f0ab142639a6862b902cb1cf6f2 languageName: node linkType: hard @@ -7278,7 +7289,7 @@ __metadata: languageName: node linkType: hard -"vitest@npm:^2.0.4": +"vitest@npm:^2.0.5": version: 2.0.5 resolution: "vitest@npm:2.0.5" dependencies: From 4bd50fcb334e0c90a42b7b6ceee1c1c0fc5f6e16 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Fri, 2 Aug 2024 16:40:18 +0100 Subject: [PATCH 05/20] test(streams): Add streams.test.ts Also fix some behavioral issues with the streams. --- packages/extension/package.json | 1 + packages/extension/src/iframe-manager.test.ts | 2 +- packages/extension/src/shared.test.ts | 4 +- packages/streams/package.json | 1 + packages/streams/src/message-channel.test.ts | 5 +- packages/streams/src/streams.test.ts | 139 ++++++++++++++++++ packages/streams/src/streams.ts | 101 ++++++++++--- packages/streams/vitest.config.mts | 13 +- packages/test-utils/src/utils.ts | 7 +- tsconfig.packages.json | 3 +- vitest.config.packages.mjs | 1 + yarn.lock | 5 +- 12 files changed, 248 insertions(+), 34 deletions(-) create mode 100644 packages/streams/src/streams.test.ts diff --git a/packages/extension/package.json b/packages/extension/package.json index ea4ff7e99..0ee4d3122 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -34,6 +34,7 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.15.3", "@metamask/auto-changelog": "^3.4.4", + "@ocap/test-utils": "^0.0.0", "@types/chrome": "^0.0.268", "@vitest/coverage-v8": "^2.0.5", "deepmerge": "^4.3.1", diff --git a/packages/extension/src/iframe-manager.test.ts b/packages/extension/src/iframe-manager.test.ts index 6ff420403..72608ae82 100644 --- a/packages/extension/src/iframe-manager.test.ts +++ b/packages/extension/src/iframe-manager.test.ts @@ -1,5 +1,5 @@ import * as snapsUtils from '@metamask/snaps-utils'; -import { makePromiseKitMock } from '@ocap/test-utils/mocks'; +import { makePromiseKitMock } from '@ocap/test-utils'; import { vi, beforeEach, describe, it, expect } from 'vitest'; import { Command } from './shared'; diff --git a/packages/extension/src/shared.test.ts b/packages/extension/src/shared.test.ts index 1616a8f0c..5eb4175f2 100644 --- a/packages/extension/src/shared.test.ts +++ b/packages/extension/src/shared.test.ts @@ -1,3 +1,4 @@ +import { delay } from '@ocap/test-utils'; import { vi, describe, it, expect } from 'vitest'; import { isWrappedIframeMessage, makeHandledCallback } from './shared'; @@ -52,8 +53,7 @@ describe('shared', () => { // eslint-disable-next-line n/callback-return callback(); - // eslint-disable-next-line @typescript-eslint/await-thenable - await null; + await delay(); expect(consoleErrorSpy).toHaveBeenCalledOnce(); expect(consoleErrorSpy).toHaveBeenCalledWith( diff --git a/packages/streams/package.json b/packages/streams/package.json index ed13826c7..65767302e 100644 --- a/packages/streams/package.json +++ b/packages/streams/package.json @@ -56,6 +56,7 @@ "typedoc": "^0.24.8", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~4.9.5", + "vite": "^5.3.5", "vitest": "^2.0.5" }, "engines": { diff --git a/packages/streams/src/message-channel.test.ts b/packages/streams/src/message-channel.test.ts index ac914f967..6d75e3171 100644 --- a/packages/streams/src/message-channel.test.ts +++ b/packages/streams/src/message-channel.test.ts @@ -1,4 +1,4 @@ -import { makePromiseKitMock } from '@ocap/test-utils/mocks'; +import { delay, makePromiseKitMock } from '@ocap/test-utils'; import { JSDOM } from 'jsdom'; import { vi, describe, it, beforeEach, afterEach, beforeAll } from 'vitest'; @@ -177,8 +177,7 @@ describe('receiveMessagePort', () => { }), ); - // eslint-disable-next-line @typescript-eslint/await-thenable - await null; + await delay(); expect(fulfillmentDetector).not.toHaveBeenCalled(); expect(portPostMessageSpy).not.toHaveBeenCalled(); diff --git a/packages/streams/src/streams.test.ts b/packages/streams/src/streams.test.ts new file mode 100644 index 000000000..8713a5e53 --- /dev/null +++ b/packages/streams/src/streams.test.ts @@ -0,0 +1,139 @@ +import { delay, makePromiseKitMock } from '@ocap/test-utils'; +import { describe, expect, it, vi } from 'vitest'; + +import { MessagePortReader, MessagePortWriter } from './streams'; + +vi.mock('@endo/promise-kit', () => makePromiseKitMock()); + +describe.concurrent('MessagePortReader', () => { + it('constructs a MessagePortReader', () => { + const { port1 } = new MessageChannel(); + const reader = new MessagePortReader(port1); + + expect(reader).toBeInstanceOf(MessagePortReader); + expect(reader[Symbol.asyncIterator]()).toBe(reader); + expect(port1.onmessage).toBeInstanceOf(Function); + }); + + it('emits message port message received before next()', async () => { + const { port1, port2 } = new MessageChannel(); + const reader = new MessagePortReader(port1); + + const message = { foo: 'bar' }; + port2.postMessage(message); + await delay(100); + + await expect(reader.next()).resolves.toEqual({ + done: false, + value: message, + }); + }); + + it('emits message port message received after next()', async () => { + const { port1, port2 } = new MessageChannel(); + const reader = new MessagePortReader(port1); + const nextP = reader.next(); + + const message = { foo: 'bar' }; + port2.postMessage(message); + + await expect(nextP).resolves.toEqual({ done: false, value: message }); + }); + + it('iterates over multiple port messages', async () => { + const { port1, port2 } = new MessageChannel(); + const reader = new MessagePortReader(port1); + + const messages = [{ foo: 'bar' }, { bar: 'baz' }, { baz: 'qux' }]; + messages.forEach((message) => port2.postMessage(message)); + + for (const message of messages) { + await expect(reader.next()).resolves.toEqual({ + done: false, + value: message, + }); + } + }); + + it('ends after returning', async () => { + const { port1 } = new MessageChannel(); + const reader = new MessagePortReader(port1); + + const result = reader.return(); + await expect(result).resolves.toEqual({ done: true, value: undefined }); + expect(port1.onmessage).toBe(null); + }); + + it('resolves pending promises after returning', async () => { + const { port1 } = new MessageChannel(); + const reader = new MessagePortReader(port1); + + const nextP = reader.next(); + const returnP = reader.return(); + + await expect(nextP).resolves.toEqual({ done: true, value: undefined }); + await expect(returnP).resolves.toEqual({ done: true, value: undefined }); + }); + + it('ends after throwing', async () => { + const { port1 } = new MessageChannel(); + const reader = new MessagePortReader(port1); + + const result = reader.throw(new Error()); + await expect(result).resolves.toEqual({ done: true, value: undefined }); + expect(port1.onmessage).toBe(null); + }); + + it('rejects pending promises after throwing', async () => { + const { port1 } = new MessageChannel(); + const reader = new MessagePortReader(port1); + + const nextP = reader.next(); + const returnP = reader.throw(new Error('end')); + + await expect(nextP).rejects.toThrow('end'); + await expect(returnP).resolves.toEqual({ done: true, value: undefined }); + }); +}); + +describe.concurrent('MessagePortWriter', () => { + it('constructs a MessagePortWriter', () => { + const { port1 } = new MessageChannel(); + const writer = new MessagePortWriter(port1); + + expect(writer).toBeInstanceOf(MessagePortWriter); + expect(writer[Symbol.asyncIterator]()).toBe(writer); + }); + + it('posts messages to the port', async () => { + const { port1, port2 } = new MessageChannel(); + const writer = new MessagePortWriter(port1); + + const message = { foo: 'bar' }; + const messageP = new Promise((resolve) => { + port2.onmessage = (messageEvent) => resolve(messageEvent.data); + }); + const nextP = writer.next(message); + + await expect(nextP).resolves.toEqual({ done: false, value: undefined }); + await expect(messageP).resolves.toEqual(message); + }); + + it('ends after returning', async () => { + const { port1 } = new MessageChannel(); + const writer = new MessagePortWriter(port1); + + const result = writer.return(); + await expect(result).resolves.toEqual({ done: true, value: undefined }); + expect(port1.onmessage).toBe(null); + }); + + it('ends after throwing', async () => { + const { port1 } = new MessageChannel(); + const writer = new MessagePortWriter(port1); + + const result = writer.throw(); + await expect(result).resolves.toEqual({ done: true, value: undefined }); + expect(port1.onmessage).toBe(null); + }); +}); diff --git a/packages/streams/src/streams.ts b/packages/streams/src/streams.ts index 899bd1daf..486ea4c11 100644 --- a/packages/streams/src/streams.ts +++ b/packages/streams/src/streams.ts @@ -1,22 +1,38 @@ import { makePromiseKit } from '@endo/promise-kit'; import type { Reader, Writer } from '@endo/stream'; -type Resolve = (value: unknown) => void; +type PromiseCallbacks = { + resolve: (value: unknown) => void; + reject: (reason: unknown) => void; +}; +const makeDoneResult = () => + ({ done: true, value: undefined } as { done: true; value: undefined }); + +/** + * A readable stream over a {@link MessagePort}. + */ export class MessagePortReader implements Reader { #port: MessagePort; + /** + * For buffering messages to manage backpressure. + */ #messageQueue: MessageEvent[]; - #resolveQueue: Resolve[]; + /** + * For buffering reads to manage drain. + */ + #readQueue: PromiseCallbacks[]; constructor(port: MessagePort) { this.#port = port; this.#messageQueue = []; - this.#resolveQueue = []; + this.#readQueue = []; // Assigning to the `onmessage` property initializes the port's message queue. this.#port.onmessage = this.#handleMessage.bind(this); + harden(this); } [Symbol.asyncIterator]() { @@ -24,69 +40,108 @@ export class MessagePortReader implements Reader { } #handleMessage(message: MessageEvent): void { - if (this.#resolveQueue.length > 0) { - const resolve = this.#resolveQueue.shift() as Resolve; + if (this.#readQueue.length > 0) { + const { resolve } = this.#readQueue.shift() as PromiseCallbacks; resolve({ done: false, value: message.data }); } else { this.#messageQueue.push(message); } } - async next(_value: never): Promise> { - const { promise, resolve } = makePromiseKit(); + /** + * Reads the next message from the port. + * @returns The next message from the port. + */ + async next(): Promise> { + const { promise, resolve, reject } = makePromiseKit(); if (this.#messageQueue.length > 0) { const message = this.#messageQueue.shift() as MessageEvent; resolve({ done: false, value: message.data }); } else { - this.#resolveQueue.push(resolve); + this.#readQueue.push({ resolve, reject }); } return promise as Promise>; } - async return(_value: never): Promise> { - return this.#teardown(); + /** + * Closes the underlying port and returns. + * @returns The final result for this stream. + */ + async return(): Promise> { + while (this.#readQueue.length > 0) { + const { resolve } = this.#readQueue.shift() as PromiseCallbacks; + resolve(makeDoneResult()); + } + return this.#end(); } - async throw(_error: never): Promise> { - return this.#teardown(); + /** + * Rejects all pending reads from this stream with the specified error, closes + * the underlying port, and returns. + * @param error - The error to throw. + * @returns The final result for this stream. + */ + async throw(error: Error): Promise> { + while (this.#readQueue.length > 0) { + const { reject } = this.#readQueue.shift() as PromiseCallbacks; + reject(error); + } + return this.#end(); } - #teardown(): IteratorResult { + #end(): IteratorResult { this.#port.close(); this.#port.onmessage = null; - return { done: true, value: undefined }; + return makeDoneResult(); } } +harden(MessagePortReader); +/** + * A writable stream over a {@link MessagePort}. + */ export class MessagePortWriter implements Writer { #port: MessagePort; constructor(port: MessagePort) { this.#port = port; + harden(this); } [Symbol.asyncIterator]() { return this; } + /** + * Writes the next message to the port. + * @param value - The next message to write to the port. + * @returns The result of writing the message. + */ async next(value: Yield): Promise> { this.#port.postMessage(value); return { done: false, value: undefined }; } - async return(_value: never): Promise> { - return this.#teardown(); + /** + * Closes the underlying port and returns. + * @returns The final result for this stream. + */ + async return(): Promise> { + return this.#end(); } - async throw(error: Error): Promise> { - return this.#teardown(error); + /** + * Essentially an alias for `return()`. Errors intended for the other side are + * delegated to a higher level of abstraction. + * @returns The final result for this stream. + */ + async throw(): Promise> { + return this.#end(); } - #teardown(error?: Error): IteratorResult { - if (error !== undefined) { - this.#port.dispatchEvent(new ErrorEvent(error.message)); - } + #end(): IteratorResult { this.#port.close(); - return { done: true, value: undefined }; + return makeDoneResult(); } } +harden(MessagePortWriter); diff --git a/packages/streams/vitest.config.mts b/packages/streams/vitest.config.mts index 8cf26eb08..2548b743e 100644 --- a/packages/streams/vitest.config.mts +++ b/packages/streams/vitest.config.mts @@ -1,6 +1,17 @@ // eslint-disable-next-line spaced-comment /// +import { defineConfig, mergeConfig } from 'vite'; + import { getDefaultConfig } from '../../vitest.config.packages.mjs'; -export default getDefaultConfig(); +const defaultConfig = getDefaultConfig(); + +export default mergeConfig( + defaultConfig, + defineConfig({ + test: { + setupFiles: '../test-utils/src/env/mock-endo.ts', + }, + }), +); diff --git a/packages/test-utils/src/utils.ts b/packages/test-utils/src/utils.ts index 56678a158..35adf6fad 100644 --- a/packages/test-utils/src/utils.ts +++ b/packages/test-utils/src/utils.ts @@ -1,2 +1,7 @@ -export const delay = async (ms: number) => +/** + * Delay execution by the specified number of milliseconds. + * @param ms - The number of milliseconds to delay. + * @returns A promise that resolves after the specified delay. + */ +export const delay = async (ms = 1) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/tsconfig.packages.json b/tsconfig.packages.json index 3ee06764d..11ff16e94 100644 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -13,8 +13,7 @@ * uncompiled source code for packages that live in this repo. */ "paths": { - "@ocap/*": ["../*/src"], - "@ocap/test-utils/*": ["../test-utils/src/*"] + "@ocap/*": ["../*/src"] }, "resolveJsonModule": true, "strict": true, diff --git a/vitest.config.packages.mjs b/vitest.config.packages.mjs index 209023248..12cadc19e 100644 --- a/vitest.config.packages.mjs +++ b/vitest.config.packages.mjs @@ -29,5 +29,6 @@ export const getDefaultConfig = (projectRoot = './src') => }, reporters: ['basic'], silent: true, + testTimeout: 2000, }, }); diff --git a/yarn.lock b/yarn.lock index 5fa61b118..ce7bff0e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1245,6 +1245,7 @@ __metadata: "@metamask/snaps-utils": "npm:^7.8.0" "@metamask/utils": "npm:^9.1.0" "@ocap/shims": "npm:^0.0.0" + "@ocap/test-utils": "npm:^0.0.0" "@types/chrome": "npm:^0.0.268" "@vitest/coverage-v8": "npm:^2.0.5" deepmerge: "npm:^4.3.1" @@ -1319,6 +1320,7 @@ __metadata: "@endo/stream": "npm:^1.2.2" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/utils": "npm:^9.1.0" + "@ocap/test-utils": "npm:^0.0.0" "@ts-bridge/cli": "npm:^0.1.4" "@ts-bridge/shims": "npm:^0.1.1" "@types/jsdom": "npm:^21.1.7" @@ -1329,11 +1331,12 @@ __metadata: typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~4.9.5" + vite: "npm:^5.3.5" vitest: "npm:^2.0.5" languageName: unknown linkType: soft -"@ocap/test-utils@workspace:packages/test-utils": +"@ocap/test-utils@npm:^0.0.0, @ocap/test-utils@workspace:packages/test-utils": version: 0.0.0-use.local resolution: "@ocap/test-utils@workspace:packages/test-utils" dependencies: From abf2b36802b5ebdc32960bff441cacb6a364ebbb Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Fri, 2 Aug 2024 17:00:28 +0100 Subject: [PATCH 06/20] fix: Add stub test script to test-utils --- packages/test-utils/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 4e9023e1b..d446df9b4 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -10,6 +10,9 @@ "exports": { ".": "./src/index.ts" }, + "scripts": { + "test": "echo 'No tests.' && exit 0" + }, "devDependencies": { "ses": "^1.7.0", "typescript": "~4.9.5", From 3327802964d051ce82c88359e1af1c19d73a8378 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Sat, 3 Aug 2024 11:22:28 +0100 Subject: [PATCH 07/20] refactor(streams): Remove all error-related responsibilities, improve docs --- packages/streams/src/streams.test.ts | 8 ++--- packages/streams/src/streams.ts | 53 +++++++++++++++++++++------- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/packages/streams/src/streams.test.ts b/packages/streams/src/streams.test.ts index 8713a5e53..1eb3588d7 100644 --- a/packages/streams/src/streams.test.ts +++ b/packages/streams/src/streams.test.ts @@ -79,19 +79,19 @@ describe.concurrent('MessagePortReader', () => { const { port1 } = new MessageChannel(); const reader = new MessagePortReader(port1); - const result = reader.throw(new Error()); + const result = reader.throw(); await expect(result).resolves.toEqual({ done: true, value: undefined }); expect(port1.onmessage).toBe(null); }); - it('rejects pending promises after throwing', async () => { + it('resolves pending promises after throwing', async () => { const { port1 } = new MessageChannel(); const reader = new MessagePortReader(port1); const nextP = reader.next(); - const returnP = reader.throw(new Error('end')); + const returnP = reader.throw(); - await expect(nextP).rejects.toThrow('end'); + await expect(nextP).resolves.toEqual({ done: true, value: undefined }); await expect(returnP).resolves.toEqual({ done: true, value: undefined }); }); }); diff --git a/packages/streams/src/streams.ts b/packages/streams/src/streams.ts index 486ea4c11..8e0a76036 100644 --- a/packages/streams/src/streams.ts +++ b/packages/streams/src/streams.ts @@ -10,7 +10,21 @@ const makeDoneResult = () => ({ done: true, value: undefined } as { done: true; value: undefined }); /** - * A readable stream over a {@link MessagePort}. + * A readable stream over a {@link MessagePort}. See also {@link MessagePortWriter}. + * + * This class is an extremely naive passthrough mechanism for data over a pair of + * linked message ports. Because there is no ergonomic way to detect the closure of a + * message port at the time of writing, closure must be handled at a higher level of + * abstraction. The lifetime of the underlying message port is expected to be coextensive + * with "the other side". + * + * In addition, the message port mechanism is assumed to be 100% reliable, and this class + * therefore has no concept of errors or error handling. This is instead also delegated + * to a higher level of abstraction. + * + * Regarding limitations around detecting MessagePort closure, see: + * - https://github.com/fergald/explainer-messageport-close + * - https://github.com/whatwg/html/issues/10201 */ export class MessagePortReader implements Reader { #port: MessagePort; @@ -76,17 +90,14 @@ export class MessagePortReader implements Reader { } /** - * Rejects all pending reads from this stream with the specified error, closes - * the underlying port, and returns. - * @param error - The error to throw. + * Alias for {@link return}. + * @deprecated This method only exists for interface conformance. Due to limitations + * of the underlying communication mechanism, this class has no concept of errors. + * Use {@link return} instead. * @returns The final result for this stream. */ - async throw(error: Error): Promise> { - while (this.#readQueue.length > 0) { - const { reject } = this.#readQueue.shift() as PromiseCallbacks; - reject(error); - } - return this.#end(); + async throw(): Promise> { + return this.return(); } #end(): IteratorResult { @@ -98,7 +109,21 @@ export class MessagePortReader implements Reader { harden(MessagePortReader); /** - * A writable stream over a {@link MessagePort}. + * A writable stream over a {@link MessagePort}. See also {@link MessagePortReader}. + * + * This class is an extremely naive passthrough mechanism for data over a pair of + * linked message ports. Because there is no ergonomic way to detect the closure of a + * message port at the time of writing, closure must be handled at a higher level of + * abstraction. The lifetime of the underlying message port is expected to be coextensive + * with "the other side". + * + * In addition, the message port mechanism is assumed to be 100% reliable, and this class + * therefore has no concept of errors or error handling. This is instead also delegated + * to a higher level of abstraction. + * + * Regarding limitations around detecting MessagePort closure, see: + * - https://github.com/fergald/explainer-messageport-close + * - https://github.com/whatwg/html/issues/10201 */ export class MessagePortWriter implements Writer { #port: MessagePort; @@ -131,8 +156,10 @@ export class MessagePortWriter implements Writer { } /** - * Essentially an alias for `return()`. Errors intended for the other side are - * delegated to a higher level of abstraction. + * Alias for {@link return}. + * @deprecated This method only exists for interface conformance. Due to limitations + * of the underlying communication mechanism, this class has no concept of errors. + * Use {@link return} instead. * @returns The final result for this stream. */ async throw(): Promise> { From 836bb71d1ee3d6e1863492aa73d9f005a87bef55 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Sat, 3 Aug 2024 21:13:41 +0100 Subject: [PATCH 08/20] fix(streams): Reimplement index.ts --- packages/streams/src/index.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/streams/src/index.ts b/packages/streams/src/index.ts index 6972c1172..81bd4c0ba 100644 --- a/packages/streams/src/index.ts +++ b/packages/streams/src/index.ts @@ -1,9 +1,2 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} +export * from './message-channel'; +export * from './streams'; From 8db69749bce88b55c5a1ca2e6f3f41b16e28beec Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Sat, 3 Aug 2024 21:14:21 +0100 Subject: [PATCH 09/20] docs(streams): Update TypeDoc --- .eslintrc.js | 8 ++++ packages/streams/src/message-channel.ts | 23 +++++----- packages/streams/src/streams.ts | 60 ++++++++++++++----------- packages/streams/typedoc.json | 2 +- 4 files changed, 56 insertions(+), 37 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 31d833a3a..9eab34737 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -83,6 +83,14 @@ module.exports = { }, }, + { + files: ['*'], + rules: { + // This prevents pretty formatting of comments with multi-line lists entries. + 'jsdoc/check-indentation': 'off', + }, + }, + { files: ['*.d.ts'], rules: { diff --git a/packages/streams/src/message-channel.ts b/packages/streams/src/message-channel.ts index 0d655729b..a790dfc63 100644 --- a/packages/streams/src/message-channel.ts +++ b/packages/streams/src/message-channel.ts @@ -1,16 +1,19 @@ +/** + * This module establishes a simple protocol for establishing a MessageChannel between + * a parent window and its iframe, as follows: + * 1. The parent window creates an iframe and appends it to the DOM. The iframe must be + * loaded and the `contentWindow` property must be accessible. + * 2. The iframe calls `receiveMessagePort()` on startup in one of its scripts. The script + * element in question should not have the `async` attribute. + * 3. The parent window calls `initializeMessageChannel()` which sends a message port to + * the iframe. When the returned promise resolves, the parent window and the iframe have + * established a message channel. + * @module MessageChannel utilities + */ + import { makePromiseKit } from '@endo/promise-kit'; import { isObject } from '@metamask/utils'; -// This module establishes a simple protocol for establishing a MessageChannel between -// a parent window and its iframe, as follows: -// 1. The parent window creates an iframe and appends it to the DOM. The iframe must be -// loaded and the `contentWindow` property must be accessible. -// 2. The iframe calls `receiveMessagePort()` on startup in one of its scripts. The script -// element in question should not have the `async` attribute. -// 3. The parent window calls `initializeMessageChannel()` which sends a message port to -// the iframe. When the returned promise resolves, the parent window and the iframe have -// established a message channel. - export enum MessageType { Initialize = 'INIT_MESSAGE_CHANNEL', Acknowledge = 'ACK_MESSAGE_CHANNEL', diff --git a/packages/streams/src/streams.ts b/packages/streams/src/streams.ts index 8e0a76036..ffd5d7b4a 100644 --- a/packages/streams/src/streams.ts +++ b/packages/streams/src/streams.ts @@ -1,3 +1,21 @@ +/** + * This module provides a pair of classes for creating readable and writable streams + * over a [MessagePort](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort). + * The classes are naive passthrough mechanisms for data. Because there is no ergonomic + * way to detect the closure of a message port at the time of writing, closure must be + * handled at a higher level of abstraction. The lifetime of the underlying message port + * is expected to be coextensive with "the other side". + * + * In addition, the message port mechanism is assumed to be 100% reliable, and the classes + * therefore have no concept of errors or error handling. This is instead also delegated + * to a higher level of abstraction. + * + * Regarding limitations around detecting `MessagePort` closure, see: + * - https://github.com/fergald/explainer-messageport-close + * - https://github.com/whatwg/html/issues/10201 + * @module MessagePort streams + */ + import { makePromiseKit } from '@endo/promise-kit'; import type { Reader, Writer } from '@endo/stream'; @@ -10,21 +28,16 @@ const makeDoneResult = () => ({ done: true, value: undefined } as { done: true; value: undefined }); /** - * A readable stream over a {@link MessagePort}. See also {@link MessagePortWriter}. + * A readable stream over a {@link MessagePort}. * - * This class is an extremely naive passthrough mechanism for data over a pair of - * linked message ports. Because there is no ergonomic way to detect the closure of a - * message port at the time of writing, closure must be handled at a higher level of - * abstraction. The lifetime of the underlying message port is expected to be coextensive - * with "the other side". + * This class is a naive passthrough mechanism for data over a pair of linked message + * ports. The message port mechanism is assumed to be completely reliable, and this + * class therefore has no concept of errors or error handling. Errors and closure + * are expected to be handled at a higher level of abstraction. * - * In addition, the message port mechanism is assumed to be 100% reliable, and this class - * therefore has no concept of errors or error handling. This is instead also delegated - * to a higher level of abstraction. - * - * Regarding limitations around detecting MessagePort closure, see: - * - https://github.com/fergald/explainer-messageport-close - * - https://github.com/whatwg/html/issues/10201 + * @see + * - {@link MessagePortWriter} for the corresponding writable stream. + * - The module-level documentation for more details. */ export class MessagePortReader implements Reader { #port: MessagePort; @@ -109,21 +122,16 @@ export class MessagePortReader implements Reader { harden(MessagePortReader); /** - * A writable stream over a {@link MessagePort}. See also {@link MessagePortReader}. + * A writable stream over a {@link MessagePort}. * - * This class is an extremely naive passthrough mechanism for data over a pair of - * linked message ports. Because there is no ergonomic way to detect the closure of a - * message port at the time of writing, closure must be handled at a higher level of - * abstraction. The lifetime of the underlying message port is expected to be coextensive - * with "the other side". + * This class is a naive passthrough mechanism for data over a pair of linked message + * ports. The message port mechanism is assumed to be completely reliable, and this + * class therefore has no concept of errors or error handling. Errors and closure + * are expected to be handled at a higher level of abstraction. * - * In addition, the message port mechanism is assumed to be 100% reliable, and this class - * therefore has no concept of errors or error handling. This is instead also delegated - * to a higher level of abstraction. - * - * Regarding limitations around detecting MessagePort closure, see: - * - https://github.com/fergald/explainer-messageport-close - * - https://github.com/whatwg/html/issues/10201 + * @see + * - {@link MessagePortReader} for the corresponding readable stream. + * - The module-level documentation for more details. */ export class MessagePortWriter implements Writer { #port: MessagePort; diff --git a/packages/streams/typedoc.json b/packages/streams/typedoc.json index c9da015db..3d60644d9 100644 --- a/packages/streams/typedoc.json +++ b/packages/streams/typedoc.json @@ -1,5 +1,5 @@ { - "entryPoints": ["./src/index.ts"], + "entryPoints": ["./src/streams.ts", "./src/message-channel.ts"], "excludePrivate": true, "hideGenerator": true, "out": "docs", From ad23fdc6729c10432bc8bbd7e9628d67b945d25e Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Sat, 3 Aug 2024 22:27:38 +0100 Subject: [PATCH 10/20] chore: Synchronize workspace package dependencies Accomplished by copying over sections of the Snaps monorepo constraints, which allow the `workspace:^` dependency version. In this way, we will never accidentally pull a workspace package from npm. --- constraints.pro | 29 ++++++++++++----------------- packages/extension/package.json | 5 +++-- packages/extension/tsconfig.json | 2 +- yarn.lock | 13 +++++++------ 4 files changed, 23 insertions(+), 26 deletions(-) diff --git a/constraints.pro b/constraints.pro index 973aece47..ff0fbd4f0 100644 --- a/constraints.pro +++ b/constraints.pro @@ -337,29 +337,24 @@ gen_enforced_dependency(WorkspaceCwd, DependencyIdent, 'a range optionally start workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType), \+ is_valid_version_range(DependencyRange). -% All version ranges used to reference one workspace package in another -% workspace package's `dependencies` or `devDependencies` must be the same. -% Among all references to the same dependency across the monorepo, the one with -% the smallest version range will win. (We handle `peerDependencies` in another -% constraint, as it has slightly different logic.) +% All dependency ranges for a package must be synchronized across the monorepo +% (the least version range wins), regardless of which "*dependencies" field +% where the package appears. gen_enforced_dependency(WorkspaceCwd, DependencyIdent, OtherDependencyRange, DependencyType) :- workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType), workspace_has_dependency(OtherWorkspaceCwd, DependencyIdent, OtherDependencyRange, OtherDependencyType), WorkspaceCwd \= OtherWorkspaceCwd, DependencyRange \= OtherDependencyRange, - npm_version_range_out_of_sync(DependencyRange, OtherDependencyRange), - DependencyType \= 'peerDependencies', - OtherDependencyType \= 'peerDependencies'. - -% All version ranges used to reference one workspace package in another -% workspace package's `dependencies` or `devDependencies` must match the current -% version of that package. (We handle `peerDependencies` in another rule.) -gen_enforced_dependency(WorkspaceCwd, DependencyIdent, CorrectDependencyRange, DependencyType) :- - DependencyType \= 'peerDependencies', + npm_version_range_out_of_sync(DependencyRange, OtherDependencyRange). + +% If a dependency is listed under "dependencies", it should not be listed under +% "devDependencies". We match on the same dependency range so that if a +% dependency is listed under both lists, their versions are synchronized and +% then this constraint will apply and remove the "right" duplicate. +gen_enforced_dependency(WorkspaceCwd, DependencyIdent, null, DependencyType) :- + workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, 'dependencies'), workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType), - workspace_ident(OtherWorkspaceCwd, DependencyIdent), - workspace_version(OtherWorkspaceCwd, OtherWorkspaceVersion), - atomic_list_concat(['^', OtherWorkspaceVersion], CorrectDependencyRange). + DependencyType == 'devDependencies'. % If a workspace package is listed under another workspace package's % `dependencies`, it should not also be listed under its `devDependencies`. diff --git a/packages/extension/package.json b/packages/extension/package.json index 0ee4d3122..5de165752 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -28,13 +28,14 @@ "@endo/promise-kit": "^1.1.2", "@metamask/snaps-utils": "^7.8.0", "@metamask/utils": "^9.1.0", - "@ocap/shims": "^0.0.0", + "@ocap/shims": "workspace:^", + "@ocap/streams": "workspace:^", "ses": "^1.7.0" }, "devDependencies": { "@arethetypeswrong/cli": "^0.15.3", "@metamask/auto-changelog": "^3.4.4", - "@ocap/test-utils": "^0.0.0", + "@ocap/test-utils": "workspace:^", "@types/chrome": "^0.0.268", "@vitest/coverage-v8": "^2.0.5", "deepmerge": "^4.3.1", diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json index 8f0d1116f..ae8becee7 100644 --- a/packages/extension/tsconfig.json +++ b/packages/extension/tsconfig.json @@ -9,6 +9,6 @@ "skipLibCheck": true, "types": ["chrome", "ses", "vitest", "vitest/jsdom"] }, - "references": [{ "path": "../test-utils" }], + "references": [{ "path": "../streams" }, { "path": "../test-utils" }], "include": ["./src/**/*.ts", "./src/dev-console.mjs"] } diff --git a/yarn.lock b/yarn.lock index ce7bff0e3..98e1d3013 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1244,8 +1244,9 @@ __metadata: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/snaps-utils": "npm:^7.8.0" "@metamask/utils": "npm:^9.1.0" - "@ocap/shims": "npm:^0.0.0" - "@ocap/test-utils": "npm:^0.0.0" + "@ocap/shims": "workspace:^" + "@ocap/streams": "workspace:^" + "@ocap/test-utils": "workspace:^" "@types/chrome": "npm:^0.0.268" "@vitest/coverage-v8": "npm:^2.0.5" deepmerge: "npm:^4.3.1" @@ -1296,7 +1297,7 @@ __metadata: languageName: unknown linkType: soft -"@ocap/shims@npm:^0.0.0, @ocap/shims@workspace:packages/shims": +"@ocap/shims@workspace:^, @ocap/shims@workspace:packages/shims": version: 0.0.0-use.local resolution: "@ocap/shims@workspace:packages/shims" dependencies: @@ -1311,7 +1312,7 @@ __metadata: languageName: unknown linkType: soft -"@ocap/streams@workspace:packages/streams": +"@ocap/streams@workspace:^, @ocap/streams@workspace:packages/streams": version: 0.0.0-use.local resolution: "@ocap/streams@workspace:packages/streams" dependencies: @@ -1320,7 +1321,7 @@ __metadata: "@endo/stream": "npm:^1.2.2" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/utils": "npm:^9.1.0" - "@ocap/test-utils": "npm:^0.0.0" + "@ocap/test-utils": "workspace:^" "@ts-bridge/cli": "npm:^0.1.4" "@ts-bridge/shims": "npm:^0.1.1" "@types/jsdom": "npm:^21.1.7" @@ -1336,7 +1337,7 @@ __metadata: languageName: unknown linkType: soft -"@ocap/test-utils@npm:^0.0.0, @ocap/test-utils@workspace:packages/test-utils": +"@ocap/test-utils@workspace:^, @ocap/test-utils@workspace:packages/test-utils": version: 0.0.0-use.local resolution: "@ocap/test-utils@workspace:packages/test-utils" dependencies: From 8662e42d86db674578bdc9367656e4ea4790379d Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Sat, 3 Aug 2024 23:59:52 +0100 Subject: [PATCH 11/20] test: Reimplement index.test.ts, fix equality checks --- packages/streams/src/index.test.ts | 19 ++++++---- packages/streams/src/index.ts | 7 ++-- packages/streams/src/streams.test.ts | 53 +++++++++++++++++++++------- 3 files changed, 58 insertions(+), 21 deletions(-) diff --git a/packages/streams/src/index.test.ts b/packages/streams/src/index.test.ts index bc062d369..8b0f1d490 100644 --- a/packages/streams/src/index.test.ts +++ b/packages/streams/src/index.test.ts @@ -1,9 +1,16 @@ -import greeter from '.'; +import { describe, it, expect } from 'vitest'; -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); +import * as indexModule from '.'; + +describe('index', () => { + it('has the expected exports', () => { + expect(Object.keys(indexModule)).toStrictEqual( + expect.arrayContaining([ + 'MessagePortReader', + 'MessagePortWriter', + 'initializeMessageChannel', + 'receiveMessagePort', + ]), + ); }); }); diff --git a/packages/streams/src/index.ts b/packages/streams/src/index.ts index 81bd4c0ba..f3f285c71 100644 --- a/packages/streams/src/index.ts +++ b/packages/streams/src/index.ts @@ -1,2 +1,5 @@ -export * from './message-channel'; -export * from './streams'; +export { + initializeMessageChannel, + receiveMessagePort, +} from './message-channel'; +export { MessagePortReader, MessagePortWriter } from './streams'; diff --git a/packages/streams/src/streams.test.ts b/packages/streams/src/streams.test.ts index 1eb3588d7..b35584675 100644 --- a/packages/streams/src/streams.test.ts +++ b/packages/streams/src/streams.test.ts @@ -23,7 +23,7 @@ describe.concurrent('MessagePortReader', () => { port2.postMessage(message); await delay(100); - await expect(reader.next()).resolves.toEqual({ + await expect(reader.next()).resolves.toStrictEqual({ done: false, value: message, }); @@ -37,7 +37,7 @@ describe.concurrent('MessagePortReader', () => { const message = { foo: 'bar' }; port2.postMessage(message); - await expect(nextP).resolves.toEqual({ done: false, value: message }); + await expect(nextP).resolves.toStrictEqual({ done: false, value: message }); }); it('iterates over multiple port messages', async () => { @@ -48,7 +48,7 @@ describe.concurrent('MessagePortReader', () => { messages.forEach((message) => port2.postMessage(message)); for (const message of messages) { - await expect(reader.next()).resolves.toEqual({ + await expect(reader.next()).resolves.toStrictEqual({ done: false, value: message, }); @@ -60,7 +60,10 @@ describe.concurrent('MessagePortReader', () => { const reader = new MessagePortReader(port1); const result = reader.return(); - await expect(result).resolves.toEqual({ done: true, value: undefined }); + await expect(result).resolves.toStrictEqual({ + done: true, + value: undefined, + }); expect(port1.onmessage).toBe(null); }); @@ -71,8 +74,14 @@ describe.concurrent('MessagePortReader', () => { const nextP = reader.next(); const returnP = reader.return(); - await expect(nextP).resolves.toEqual({ done: true, value: undefined }); - await expect(returnP).resolves.toEqual({ done: true, value: undefined }); + await expect(nextP).resolves.toStrictEqual({ + done: true, + value: undefined, + }); + await expect(returnP).resolves.toStrictEqual({ + done: true, + value: undefined, + }); }); it('ends after throwing', async () => { @@ -80,7 +89,10 @@ describe.concurrent('MessagePortReader', () => { const reader = new MessagePortReader(port1); const result = reader.throw(); - await expect(result).resolves.toEqual({ done: true, value: undefined }); + await expect(result).resolves.toStrictEqual({ + done: true, + value: undefined, + }); expect(port1.onmessage).toBe(null); }); @@ -91,8 +103,14 @@ describe.concurrent('MessagePortReader', () => { const nextP = reader.next(); const returnP = reader.throw(); - await expect(nextP).resolves.toEqual({ done: true, value: undefined }); - await expect(returnP).resolves.toEqual({ done: true, value: undefined }); + await expect(nextP).resolves.toStrictEqual({ + done: true, + value: undefined, + }); + await expect(returnP).resolves.toStrictEqual({ + done: true, + value: undefined, + }); }); }); @@ -115,8 +133,11 @@ describe.concurrent('MessagePortWriter', () => { }); const nextP = writer.next(message); - await expect(nextP).resolves.toEqual({ done: false, value: undefined }); - await expect(messageP).resolves.toEqual(message); + await expect(nextP).resolves.toStrictEqual({ + done: false, + value: undefined, + }); + await expect(messageP).resolves.toStrictEqual(message); }); it('ends after returning', async () => { @@ -124,7 +145,10 @@ describe.concurrent('MessagePortWriter', () => { const writer = new MessagePortWriter(port1); const result = writer.return(); - await expect(result).resolves.toEqual({ done: true, value: undefined }); + await expect(result).resolves.toStrictEqual({ + done: true, + value: undefined, + }); expect(port1.onmessage).toBe(null); }); @@ -133,7 +157,10 @@ describe.concurrent('MessagePortWriter', () => { const writer = new MessagePortWriter(port1); const result = writer.throw(); - await expect(result).resolves.toEqual({ done: true, value: undefined }); + await expect(result).resolves.toStrictEqual({ + done: true, + value: undefined, + }); expect(port1.onmessage).toBe(null); }); }); From 667d6bdaeef3e05214b50dc04985859b923a653b Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Sun, 4 Aug 2024 10:07:11 +0100 Subject: [PATCH 12/20] chore: Fixup after rebase on top of rekm/vitest --- packages/streams/jest.config.js | 26 --------------------- packages/streams/src/streams.test.ts | 34 ++++++++++++++-------------- yarn.lock | 3 +-- 3 files changed, 18 insertions(+), 45 deletions(-) delete mode 100644 packages/streams/jest.config.js diff --git a/packages/streams/jest.config.js b/packages/streams/jest.config.js deleted file mode 100644 index ca0841333..000000000 --- a/packages/streams/jest.config.js +++ /dev/null @@ -1,26 +0,0 @@ -/* - * For a detailed explanation regarding each configuration property and type check, visit: - * https://jestjs.io/docs/configuration - */ - -const merge = require('deepmerge'); -const path = require('path'); - -const baseConfig = require('../../jest.config.packages'); - -const displayName = path.basename(__dirname); - -module.exports = merge(baseConfig, { - // The display name when running multiple projects - displayName, - - // An object that configures minimum threshold enforcement for coverage results - coverageThreshold: { - global: { - branches: 100, - functions: 100, - lines: 100, - statements: 100, - }, - }, -}); diff --git a/packages/streams/src/streams.test.ts b/packages/streams/src/streams.test.ts index b35584675..36dde6062 100644 --- a/packages/streams/src/streams.test.ts +++ b/packages/streams/src/streams.test.ts @@ -23,7 +23,7 @@ describe.concurrent('MessagePortReader', () => { port2.postMessage(message); await delay(100); - await expect(reader.next()).resolves.toStrictEqual({ + expect(await reader.next()).toStrictEqual({ done: false, value: message, }); @@ -37,7 +37,7 @@ describe.concurrent('MessagePortReader', () => { const message = { foo: 'bar' }; port2.postMessage(message); - await expect(nextP).resolves.toStrictEqual({ done: false, value: message }); + expect(await nextP).toStrictEqual({ done: false, value: message }); }); it('iterates over multiple port messages', async () => { @@ -48,7 +48,7 @@ describe.concurrent('MessagePortReader', () => { messages.forEach((message) => port2.postMessage(message)); for (const message of messages) { - await expect(reader.next()).resolves.toStrictEqual({ + expect(await reader.next()).toStrictEqual({ done: false, value: message, }); @@ -60,11 +60,11 @@ describe.concurrent('MessagePortReader', () => { const reader = new MessagePortReader(port1); const result = reader.return(); - await expect(result).resolves.toStrictEqual({ + expect(await result).toStrictEqual({ done: true, value: undefined, }); - expect(port1.onmessage).toBe(null); + expect(port1.onmessage).toBeNull(); }); it('resolves pending promises after returning', async () => { @@ -74,11 +74,11 @@ describe.concurrent('MessagePortReader', () => { const nextP = reader.next(); const returnP = reader.return(); - await expect(nextP).resolves.toStrictEqual({ + expect(await nextP).toStrictEqual({ done: true, value: undefined, }); - await expect(returnP).resolves.toStrictEqual({ + expect(await returnP).toStrictEqual({ done: true, value: undefined, }); @@ -89,11 +89,11 @@ describe.concurrent('MessagePortReader', () => { const reader = new MessagePortReader(port1); const result = reader.throw(); - await expect(result).resolves.toStrictEqual({ + expect(await result).toStrictEqual({ done: true, value: undefined, }); - expect(port1.onmessage).toBe(null); + expect(port1.onmessage).toBeNull(); }); it('resolves pending promises after throwing', async () => { @@ -103,11 +103,11 @@ describe.concurrent('MessagePortReader', () => { const nextP = reader.next(); const returnP = reader.throw(); - await expect(nextP).resolves.toStrictEqual({ + expect(await nextP).toStrictEqual({ done: true, value: undefined, }); - await expect(returnP).resolves.toStrictEqual({ + expect(await returnP).toStrictEqual({ done: true, value: undefined, }); @@ -133,11 +133,11 @@ describe.concurrent('MessagePortWriter', () => { }); const nextP = writer.next(message); - await expect(nextP).resolves.toStrictEqual({ + expect(await nextP).toStrictEqual({ done: false, value: undefined, }); - await expect(messageP).resolves.toStrictEqual(message); + expect(await messageP).toStrictEqual(message); }); it('ends after returning', async () => { @@ -145,11 +145,11 @@ describe.concurrent('MessagePortWriter', () => { const writer = new MessagePortWriter(port1); const result = writer.return(); - await expect(result).resolves.toStrictEqual({ + expect(await result).toStrictEqual({ done: true, value: undefined, }); - expect(port1.onmessage).toBe(null); + expect(port1.onmessage).toBeNull(); }); it('ends after throwing', async () => { @@ -157,10 +157,10 @@ describe.concurrent('MessagePortWriter', () => { const writer = new MessagePortWriter(port1); const result = writer.throw(); - await expect(result).resolves.toStrictEqual({ + expect(await result).toStrictEqual({ done: true, value: undefined, }); - expect(port1.onmessage).toBe(null); + expect(port1.onmessage).toBeNull(); }); }); diff --git a/yarn.lock b/yarn.lock index 98e1d3013..f9ebbd613 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1327,8 +1327,7 @@ __metadata: "@types/jsdom": "npm:^21.1.7" "@vitest/coverage-v8": "npm:^2.0.5" deepmerge: "npm:^4.3.1" - jest: "npm:^28.1.3" - ts-jest: "npm:^28.0.7" + jsdom: "npm:^24.1.1" typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~4.9.5" From 243c45381f85418b26c97d983101d28480deedca Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Mon, 5 Aug 2024 10:47:11 +0100 Subject: [PATCH 13/20] build(extension): Externalize endoify.mjs in HTML files --- packages/extension/package.json | 2 + packages/extension/src/iframe.html | 4 +- packages/extension/src/offscreen.html | 3 +- packages/extension/vite.config.mts | 40 +++++++- yarn.lock | 138 +++++++++++++++++++++++++- 5 files changed, 180 insertions(+), 7 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index 5de165752..968e347e2 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -38,8 +38,10 @@ "@ocap/test-utils": "workspace:^", "@types/chrome": "^0.0.268", "@vitest/coverage-v8": "^2.0.5", + "cheerio": "^1.0.0-rc.12", "deepmerge": "^4.3.1", "jsdom": "^24.1.1", + "prettier": "^3.3.3", "typedoc": "^0.24.8", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~4.9.5", diff --git a/packages/extension/src/iframe.html b/packages/extension/src/iframe.html index 0f2fd2476..a90a1d487 100644 --- a/packages/extension/src/iframe.html +++ b/packages/extension/src/iframe.html @@ -5,7 +5,5 @@ Ocap Iframe - - + - diff --git a/packages/extension/src/offscreen.html b/packages/extension/src/offscreen.html index f9d736a9a..5e4d92428 100644 --- a/packages/extension/src/offscreen.html +++ b/packages/extension/src/offscreen.html @@ -5,7 +5,6 @@ Ocap Offscreen Document - - + diff --git a/packages/extension/vite.config.mts b/packages/extension/vite.config.mts index 37e41c83f..27affcda0 100644 --- a/packages/extension/vite.config.mts +++ b/packages/extension/vite.config.mts @@ -1,7 +1,10 @@ // eslint-disable-next-line spaced-comment /// +import { load as loadHtml } from 'cheerio'; import path from 'path'; +import { format as prettierFormat } from 'prettier'; +import type { Plugin } from 'vite'; import { defineConfig } from 'vite'; import { viteStaticCopy } from 'vite-plugin-static-copy'; @@ -9,7 +12,7 @@ const projectRoot = './src'; /** * Module specifiers that will be ignored by Rollup if imported, and therefore - * not transformed. + * not transformed. **Only applies to JavaScript and TypeScript files.** */ const externalModules: Readonly = [ './dev-console.mjs', @@ -55,9 +58,44 @@ export default defineConfig({ }, plugins: [ + endoifyHtmlFilesPlugin(), viteStaticCopy({ targets: staticCopyTargets.map((src) => ({ src, dest: './' })), watch: { reloadPageOnChange: true }, }), ], }); + +/** + * Vite plugin to insert the endoify script before the first script in the head element. + * @throws If the HTML document already references the endoify script or lacks the expected + * structure. + * @returns The Vite plugin. + */ +function endoifyHtmlFilesPlugin(): Plugin { + const endoifyElement = ''; + + return { + name: 'externalize-plugin', + async transformIndexHtml(htmlString) { + if (htmlString.includes('endoify.mjs')) { + throw new Error( + `HTML document already references endoify script:\n${htmlString}`, + ); + } + + const htmlDoc = loadHtml(htmlString); + if (htmlDoc('head').length !== 1 || htmlDoc('head script').length < 1) { + throw new Error( + `Expected HTML document with a single containing at least one