diff --git a/packages/messenger/CHANGELOG.md b/packages/messenger/CHANGELOG.md index b6bf73975e6..60fabcc6039 100644 --- a/packages/messenger/CHANGELOG.md +++ b/packages/messenger/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `delegateAll` method for exhaustive delegation with compile-time checking ([#8338](https://github.com/MetaMask/core/pull/8338)) + - Unlike `delegate`, this method requires all external actions and events to be listed, producing a TypeScript error showing exactly which items are missing. +- Add `MessengerNamespace` utility type to extract the namespace from a Messenger type ([#8338](https://github.com/MetaMask/core/pull/8338)) + ## [2.0.0] ### Added diff --git a/packages/messenger/jest.config.js b/packages/messenger/jest.config.js index c108bad51d7..9ad52b2640b 100644 --- a/packages/messenger/jest.config.js +++ b/packages/messenger/jest.config.js @@ -14,6 +14,9 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + // Exclude TSTyche type test files from coverage collection + collectCoverageFrom: ['./src/**/*.ts', '!**/*.tst.ts'], + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { diff --git a/packages/messenger/package.json b/packages/messenger/package.json index d7feadb0eb4..303fe99cb27 100644 --- a/packages/messenger/package.json +++ b/packages/messenger/package.json @@ -47,8 +47,10 @@ "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", "since-latest-release": "../../scripts/since-latest-release.sh", - "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test": "yarn test:unit && yarn test:types", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:types": "tstyche", + "test:unit": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, @@ -63,6 +65,7 @@ "immer": "^9.0.6", "jest": "^30.4.2", "ts-jest": "^29.4.11", + "tstyche": "5.0.2", "tsx": "^4.20.5", "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", diff --git a/packages/messenger/src/Messenger.test.ts b/packages/messenger/src/Messenger.test.ts index 92efbaaf146..3658cecaf9b 100644 --- a/packages/messenger/src/Messenger.test.ts +++ b/packages/messenger/src/Messenger.test.ts @@ -1931,6 +1931,77 @@ describe('Messenger', () => { }); }); + describe('delegateAll', () => { + it('delegates all listed actions and events', () => { + type SourceAction = { + type: 'Source:getValue'; + handler: () => number; + }; + type ChildOwnAction = { + type: 'Child:doStuff'; + handler: () => void; + }; + type SourceEvent = { + type: 'Source:stateChange'; + payload: [{ value: number }]; + }; + + const sourceMessenger = new Messenger< + 'Source', + SourceAction | ChildOwnAction, + SourceEvent + >({ namespace: 'Source' }); + + const childMessenger = new Messenger< + 'Child', + SourceAction | ChildOwnAction, + SourceEvent + >({ namespace: 'Child' }); + + sourceMessenger.registerActionHandler('Source:getValue', () => 42); + + sourceMessenger.delegateAll({ + messenger: childMessenger, + actions: ['Source:getValue'], + events: ['Source:stateChange'], + }); + + // Child can now call the delegated action + expect(childMessenger.call('Source:getValue')).toBe(42); + + // Child can now subscribe to the delegated event + const subscriber = jest.fn(); + // eslint-disable-next-line no-restricted-syntax + childMessenger.subscribe('Source:stateChange', subscriber); + sourceMessenger.publish('Source:stateChange', { value: 1 }); + expect(subscriber).toHaveBeenCalledWith({ value: 1 }); + }); + + it('delegates actions with an empty events array', () => { + type SourceAction = { + type: 'Source:getValue'; + handler: () => number; + }; + + const sourceMessenger = new Messenger<'Source', SourceAction, never>({ + namespace: 'Source', + }); + const childMessenger = new Messenger<'Child', SourceAction, never>({ + namespace: 'Child', + }); + + sourceMessenger.registerActionHandler('Source:getValue', () => 99); + + sourceMessenger.delegateAll({ + messenger: childMessenger, + actions: ['Source:getValue'], + events: [], + }); + + expect(childMessenger.call('Source:getValue')).toBe(99); + }); + }); + describe('revoke', () => { it('throws when attempting to revoke from parent', () => { type ExampleEvent = { diff --git a/packages/messenger/src/Messenger.ts b/packages/messenger/src/Messenger.ts index 6bf8095fa4a..ba63f9ab405 100644 --- a/packages/messenger/src/Messenger.ts +++ b/packages/messenger/src/Messenger.ts @@ -94,6 +94,47 @@ export type MessengerEvents< ? Event : never; +/** + * Extract the namespace from a Messenger type. + * + * @template Subject - The messenger type to extract from. + */ +export type MessengerNamespace< + Subject extends Messenger, +> = + Subject extends Messenger + ? N + : never; + +/** + * Validate that all members of a union are present in a tuple. + * + * When all required members are present, evaluates to the input tuple unchanged. + * When members are missing, evaluates to a branded intersection type that + * produces a clear compile error showing exactly which items are missing via + * the `__MISSING_DELEGATIONS__` property. + * + * @template Required - The union of all required string types. + * @template Provided - The readonly tuple of provided string types. + * @example + * ```typescript + * // OK — all required items present + * type T1 = RequireExhaustive<'A' | 'B', readonly ['A', 'B']>; + * // => readonly ['A', 'B'] + * + * // Error — 'C' is missing + * type T2 = RequireExhaustive<'A' | 'B' | 'C', readonly ['A', 'B']>; + * // => readonly ['A', 'B'] & { __MISSING_DELEGATIONS__: 'C' } + * ``` + */ +type RequireExhaustive< + Required extends string, + Provided extends readonly string[], +> = [Exclude] extends [never] + ? Provided + : // eslint-disable-next-line @typescript-eslint/naming-convention + Provided & { __MISSING_DELEGATIONS__: Exclude }; + /** * Messenger namespace checks can be disabled by using this as the `namespace` constructor * parameter, and using `MockAnyNamespace` as the Namespace type parameter. @@ -1129,6 +1170,65 @@ export class Messenger< } } + /** + * Delegate all external actions and events to another messenger, with + * compile-time exhaustiveness checking. + * + * Unlike {@link delegate}, which accepts a partial list of actions/events, + * this method requires that **every** action and event the delegatee needs + * from outside its own namespace is included. If any are missing, TypeScript + * produces a type error showing the missing items. + * + * The source messenger's action/event types must include every required + * external item. Items the source cannot provide still appear in the + * missing set, so incomplete source typing fails loudly instead of being + * silently skipped. + * + * Use this when a single source messenger provides all external + * actions/events for a child messenger (the common pattern in controller + * initialisation). + * + * @param args - Arguments. + * @param args.actions - The action types to delegate. Must include every + * action type defined on the delegatee that is **not** under its own + * namespace. + * @param args.events - The event types to delegate. Must include every event + * type defined on the delegatee that is **not** under its own namespace. + * @param args.messenger - The messenger to delegate to. + * @template Delegatee - The messenger the actions/events are delegated to. + * @template DelegatedActions - An array of delegated action type strings. + * @template DelegatedEvents - An array of delegated event type strings. + */ + delegateAll< + Delegatee extends Messenger, + DelegatedActions extends (MessengerActions['type'] & + Action['type'])[], + DelegatedEvents extends (MessengerEvents['type'] & + Event['type'])[], + >({ + actions, + events, + messenger, + }: { + messenger: Delegatee; + actions: RequireExhaustive< + NotNamespacedBy< + MessengerNamespace, + MessengerActions['type'] + >, + DelegatedActions + >; + events: RequireExhaustive< + NotNamespacedBy< + MessengerNamespace, + MessengerEvents['type'] + >, + DelegatedEvents + >; + }): void { + this.delegate({ actions, events, messenger }); + } + /** * Revoke delegated actions and/or events from another messenger. * diff --git a/packages/messenger/src/Messenger.tst.ts b/packages/messenger/src/Messenger.tst.ts new file mode 100644 index 00000000000..757abe21991 --- /dev/null +++ b/packages/messenger/src/Messenger.tst.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from 'tstyche'; + +import { Messenger } from './Messenger.js'; + +describe('Messenger', () => { + describe('delegateAll', () => { + type ActionA = { type: 'A:getValue'; handler: () => number }; + type ActionB = { type: 'B:getName'; handler: () => string }; + type ChildOwnAction = { type: 'Child:doStuff'; handler: () => void }; + type EventA = { + type: 'A:stateChange'; + payload: [{ value: number }]; + }; + type EventB = { + type: 'B:nameChange'; + payload: [{ name: string }]; + }; + + test('accepts a complete list of external actions and events', () => { + const source = new Messenger< + 'Source', + ActionA | ActionB | ChildOwnAction, + EventA | EventB + >({ namespace: 'Source' }); + const child = new Messenger< + 'Child', + ActionA | ActionB | ChildOwnAction, + EventA | EventB + >({ namespace: 'Child' }); + + expect( + source.delegateAll({ + messenger: child, + actions: ['A:getValue', 'B:getName'], + events: ['A:stateChange', 'B:nameChange'], + }), + ).type.not.toRaiseError(); + }); + + test('excludes the delegatee own-namespace actions from the exhaustiveness check', () => { + const source = new Messenger<'Source', ActionA | ChildOwnAction, never>({ + namespace: 'Source', + }); + const child = new Messenger<'Child', ActionA | ChildOwnAction, never>({ + namespace: 'Child', + }); + + expect( + source.delegateAll({ + messenger: child, + actions: ['A:getValue'], + events: [], + }), + ).type.not.toRaiseError(); + }); + + test('raises a type error when an external action is missing', () => { + const source = new Messenger<'Source', ActionA | ActionB, never>({ + namespace: 'Source', + }); + const child = new Messenger<'Child', ActionA | ActionB, never>({ + namespace: 'Child', + }); + + expect( + source.delegateAll({ + messenger: child, + actions: ['A:getValue'], + events: [], + }), + ).type.toRaiseError(); + }); + + test('raises a type error when an external event is missing', () => { + const source = new Messenger<'Source', never, EventA | EventB>({ + namespace: 'Source', + }); + const child = new Messenger<'Child', never, EventA | EventB>({ + namespace: 'Child', + }); + + expect( + source.delegateAll({ + messenger: child, + actions: [], + events: ['A:stateChange'], + }), + ).type.toRaiseError(); + }); + + test('raises a type error when the source cannot provide a required external action', () => { + const source = new Messenger<'Source', ActionA, never>({ + namespace: 'Source', + }); + const child = new Messenger<'Child', ActionA | ActionB, never>({ + namespace: 'Child', + }); + + expect( + source.delegateAll({ + messenger: child, + actions: ['A:getValue'], + events: [], + }), + ).type.toRaiseError(); + }); + }); +}); diff --git a/packages/messenger/src/index.ts b/packages/messenger/src/index.ts index 345e6615af6..f7d78fca57a 100644 --- a/packages/messenger/src/index.ts +++ b/packages/messenger/src/index.ts @@ -10,6 +10,7 @@ export type { EventConstraint, MessengerActions, MessengerEvents, + MessengerNamespace, MockAnyNamespace, NamespacedBy, NotNamespacedBy, diff --git a/packages/messenger/tsconfig.build.json b/packages/messenger/tsconfig.build.json index 02a0eea03fe..12ff215ad3b 100644 --- a/packages/messenger/tsconfig.build.json +++ b/packages/messenger/tsconfig.build.json @@ -6,5 +6,6 @@ "rootDir": "./src" }, "references": [], - "include": ["../../types", "./src"] + "include": ["../../types", "./src"], + "exclude": ["**/*.test.ts", "**/*.tst.ts"] } diff --git a/packages/messenger/tsconfig.lint.json b/packages/messenger/tsconfig.lint.json index 378ed1dce55..672c3683956 100644 --- a/packages/messenger/tsconfig.lint.json +++ b/packages/messenger/tsconfig.lint.json @@ -3,5 +3,6 @@ "compilerOptions": { "outDir": "./.tsc-lint-cache", "tsBuildInfoFile": "./.tsc-lint-cache/tsconfig.tsbuildinfo" - } + }, + "exclude": ["**/*.tst.ts"] } diff --git a/yarn.config.cjs b/yarn.config.cjs index c3bf8a25688..724034ff43a 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -180,7 +180,14 @@ module.exports = defineConfig({ // @metamask/wallet-cli prepends a better-sqlite3 prebuild fetch to its // "test" script because the native addon isn't built during // `yarn install` (Yarn runs with `enableScripts: false`). - if (workspace.ident !== '@metamask/wallet-cli') { + // @metamask/messenger also runs TSTyche compile-time tests after Jest. + if (workspace.ident === '@metamask/messenger') { + expectWorkspaceField( + workspace, + 'scripts.test', + 'yarn test:unit && yarn test:types', + ); + } else if (workspace.ident !== '@metamask/wallet-cli') { expectWorkspaceField( workspace, 'scripts.test', diff --git a/yarn.lock b/yarn.lock index fada7228f74..ef05b9b0cc4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7725,6 +7725,7 @@ __metadata: immer: "npm:^9.0.6" jest: "npm:^30.4.2" ts-jest: "npm:^29.4.11" + tstyche: "npm:5.0.2" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -26497,6 +26498,20 @@ __metadata: languageName: node linkType: hard +"tstyche@npm:5.0.2": + version: 5.0.2 + resolution: "tstyche@npm:5.0.2" + peerDependencies: + typescript: ">=5.0" + peerDependenciesMeta: + typescript: + optional: true + bin: + tstyche: ./build/bin.js + checksum: 10/bbc11c2f7c5c1cbf4f848a9af1eef947cf9b2643491a59fb7dbaa903bb15bba620dad24904c3db6279f206a23599d97d47f9d9e00473c54f6bb9375a7dc26178 + languageName: node + linkType: hard + "tsx@npm:^4.20.5": version: 4.20.5 resolution: "tsx@npm:4.20.5"