From b57be0087e4420d5baca2c5ebb299fc75912a882 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 31 Jan 2024 13:54:02 +0000 Subject: [PATCH 1/3] feat: share deviceType with local peers fixes #451 and #452. Does not store deviceType in project deviceInfo records, which will be done in a follow up PR (see #455) which also requires changes to @mapeo/schema. `deviceType` is optional, and must be set in the constructor of MapeoManager. Ideally this should not be possible to change, but not sure how to enforce that. --- proto/rpc.proto | 6 + src/generated/rpc.d.ts | 18 +- src/generated/rpc.js | 592 ++++++++++++++++++++-------------------- src/generated/rpc.ts | 53 ++++ src/local-peers.js | 7 + src/mapeo-manager.js | 9 +- test-e2e/local-peers.js | 56 ++++ test-e2e/utils.js | 37 ++- tests/invite-api.js | 15 +- tests/local-peers.js | 21 +- 10 files changed, 499 insertions(+), 315 deletions(-) create mode 100644 test-e2e/local-peers.js diff --git a/proto/rpc.proto b/proto/rpc.proto index ef94dd94..a11ef90a 100644 --- a/proto/rpc.proto +++ b/proto/rpc.proto @@ -25,5 +25,11 @@ message InviteResponse { } message DeviceInfo { + enum DeviceType { + mobile = 0; + tablet = 1; + desktop = 2; + } string name = 1; + optional DeviceType deviceType = 2; } diff --git a/src/generated/rpc.d.ts b/src/generated/rpc.d.ts index d48647b7..7e10f27a 100644 --- a/src/generated/rpc.d.ts +++ b/src/generated/rpc.d.ts @@ -28,7 +28,17 @@ export declare function inviteResponse_DecisionFromJSON(object: any): InviteResp export declare function inviteResponse_DecisionToNumber(object: InviteResponse_Decision): number; export interface DeviceInfo { name: string; + deviceType?: DeviceInfo_DeviceType | undefined; } +export declare const DeviceInfo_DeviceType: { + readonly mobile: "mobile"; + readonly tablet: "tablet"; + readonly desktop: "desktop"; + readonly UNRECOGNIZED: "UNRECOGNIZED"; +}; +export type DeviceInfo_DeviceType = typeof DeviceInfo_DeviceType[keyof typeof DeviceInfo_DeviceType]; +export declare function deviceInfo_DeviceTypeFromJSON(object: any): DeviceInfo_DeviceType; +export declare function deviceInfo_DeviceTypeToNumber(object: DeviceInfo_DeviceType): number; export declare const Invite: { encode(message: Invite, writer?: _m0.Writer): _m0.Writer; decode(input: _m0.Reader | Uint8Array, length?: number): Invite; @@ -148,12 +158,16 @@ export declare const DeviceInfo: { decode(input: _m0.Reader | Uint8Array, length?: number): DeviceInfo; create]: never; }>(base?: I): DeviceInfo; + deviceType?: DeviceInfo_DeviceType | undefined; + } & { [K in Exclude]: never; }>(base?: I): DeviceInfo; fromPartial]: never; }>(object: I_1): DeviceInfo; + deviceType?: DeviceInfo_DeviceType | undefined; + } & { [K_1 in Exclude]: never; }>(object: I_1): DeviceInfo; }; diff --git a/src/generated/rpc.js b/src/generated/rpc.js index a8f2bc2b..0343700e 100644 --- a/src/generated/rpc.js +++ b/src/generated/rpc.js @@ -1,313 +1,317 @@ /* eslint-disable */ -import _m0 from 'protobufjs/minimal.js' -import { EncryptionKeys } from './keys.js' +import _m0 from "protobufjs/minimal.js"; +import { EncryptionKeys } from "./keys.js"; export var InviteResponse_Decision = { - REJECT: 'REJECT', - ACCEPT: 'ACCEPT', - ALREADY: 'ALREADY', - UNRECOGNIZED: 'UNRECOGNIZED', -} + REJECT: "REJECT", + ACCEPT: "ACCEPT", + ALREADY: "ALREADY", + UNRECOGNIZED: "UNRECOGNIZED", +}; export function inviteResponse_DecisionFromJSON(object) { - switch (object) { - case 0: - case 'REJECT': - return InviteResponse_Decision.REJECT - case 1: - case 'ACCEPT': - return InviteResponse_Decision.ACCEPT - case 2: - case 'ALREADY': - return InviteResponse_Decision.ALREADY - case -1: - case 'UNRECOGNIZED': - default: - return InviteResponse_Decision.UNRECOGNIZED - } + switch (object) { + case 0: + case "REJECT": + return InviteResponse_Decision.REJECT; + case 1: + case "ACCEPT": + return InviteResponse_Decision.ACCEPT; + case 2: + case "ALREADY": + return InviteResponse_Decision.ALREADY; + case -1: + case "UNRECOGNIZED": + default: + return InviteResponse_Decision.UNRECOGNIZED; + } } export function inviteResponse_DecisionToNumber(object) { - switch (object) { - case InviteResponse_Decision.REJECT: - return 0 - case InviteResponse_Decision.ACCEPT: - return 1 - case InviteResponse_Decision.ALREADY: - return 2 - case InviteResponse_Decision.UNRECOGNIZED: - default: - return -1 - } -} -function createBaseInvite() { - return { - projectKey: Buffer.alloc(0), - encryptionKeys: undefined, - roleName: '', - invitorName: '', - } -} -export var Invite = { - encode: function (message, writer) { - if (writer === void 0) { - writer = _m0.Writer.create() - } - if (message.projectKey.length !== 0) { - writer.uint32(10).bytes(message.projectKey) - } - if (message.encryptionKeys !== undefined) { - EncryptionKeys.encode( - message.encryptionKeys, - writer.uint32(18).fork() - ).ldelim() + switch (object) { + case InviteResponse_Decision.REJECT: + return 0; + case InviteResponse_Decision.ACCEPT: + return 1; + case InviteResponse_Decision.ALREADY: + return 2; + case InviteResponse_Decision.UNRECOGNIZED: + default: + return -1; } - if (message.projectInfo !== undefined) { - Invite_ProjectInfo.encode( - message.projectInfo, - writer.uint32(26).fork() - ).ldelim() - } - if (message.roleName !== '') { - writer.uint32(34).string(message.roleName) - } - if (message.roleDescription !== undefined) { - writer.uint32(42).string(message.roleDescription) - } - if (message.invitorName !== '') { - writer.uint32(50).string(message.invitorName) - } - return writer - }, - decode: function (input, length) { - var reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input) - var end = length === undefined ? reader.len : reader.pos + length - var message = createBaseInvite() - while (reader.pos < end) { - var tag = reader.uint32() - switch (tag >>> 3) { +} +export var DeviceInfo_DeviceType = { + mobile: "mobile", + tablet: "tablet", + desktop: "desktop", + UNRECOGNIZED: "UNRECOGNIZED", +}; +export function deviceInfo_DeviceTypeFromJSON(object) { + switch (object) { + case 0: + case "mobile": + return DeviceInfo_DeviceType.mobile; case 1: - if (tag !== 10) { - break - } - message.projectKey = reader.bytes() - continue + case "tablet": + return DeviceInfo_DeviceType.tablet; case 2: - if (tag !== 18) { - break - } - message.encryptionKeys = EncryptionKeys.decode( - reader, - reader.uint32() - ) - continue - case 3: - if (tag !== 26) { - break - } - message.projectInfo = Invite_ProjectInfo.decode( - reader, - reader.uint32() - ) - continue - case 4: - if (tag !== 34) { - break - } - message.roleName = reader.string() - continue - case 5: - if (tag !== 42) { - break - } - message.roleDescription = reader.string() - continue - case 6: - if (tag !== 50) { - break - } - message.invitorName = reader.string() - continue - } - if ((tag & 7) === 4 || tag === 0) { - break - } - reader.skipType(tag & 7) + case "desktop": + return DeviceInfo_DeviceType.desktop; + case -1: + case "UNRECOGNIZED": + default: + return DeviceInfo_DeviceType.UNRECOGNIZED; } - return message - }, - create: function (base) { - return Invite.fromPartial(base !== null && base !== void 0 ? base : {}) - }, - fromPartial: function (object) { - var _a, _b, _c, _d - var message = createBaseInvite() - message.projectKey = - (_a = object.projectKey) !== null && _a !== void 0 ? _a : Buffer.alloc(0) - message.encryptionKeys = - object.encryptionKeys !== undefined && object.encryptionKeys !== null - ? EncryptionKeys.fromPartial(object.encryptionKeys) - : undefined - message.projectInfo = - object.projectInfo !== undefined && object.projectInfo !== null - ? Invite_ProjectInfo.fromPartial(object.projectInfo) - : undefined - message.roleName = - (_b = object.roleName) !== null && _b !== void 0 ? _b : '' - message.roleDescription = - (_c = object.roleDescription) !== null && _c !== void 0 ? _c : undefined - message.invitorName = - (_d = object.invitorName) !== null && _d !== void 0 ? _d : '' - return message - }, } +export function deviceInfo_DeviceTypeToNumber(object) { + switch (object) { + case DeviceInfo_DeviceType.mobile: + return 0; + case DeviceInfo_DeviceType.tablet: + return 1; + case DeviceInfo_DeviceType.desktop: + return 2; + case DeviceInfo_DeviceType.UNRECOGNIZED: + default: + return -1; + } +} +function createBaseInvite() { + return { projectKey: Buffer.alloc(0), encryptionKeys: undefined, roleName: "", invitorName: "" }; +} +export var Invite = { + encode: function (message, writer) { + if (writer === void 0) { writer = _m0.Writer.create(); } + if (message.projectKey.length !== 0) { + writer.uint32(10).bytes(message.projectKey); + } + if (message.encryptionKeys !== undefined) { + EncryptionKeys.encode(message.encryptionKeys, writer.uint32(18).fork()).ldelim(); + } + if (message.projectInfo !== undefined) { + Invite_ProjectInfo.encode(message.projectInfo, writer.uint32(26).fork()).ldelim(); + } + if (message.roleName !== "") { + writer.uint32(34).string(message.roleName); + } + if (message.roleDescription !== undefined) { + writer.uint32(42).string(message.roleDescription); + } + if (message.invitorName !== "") { + writer.uint32(50).string(message.invitorName); + } + return writer; + }, + decode: function (input, length) { + var reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + var end = length === undefined ? reader.len : reader.pos + length; + var message = createBaseInvite(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + message.projectKey = reader.bytes(); + continue; + case 2: + if (tag !== 18) { + break; + } + message.encryptionKeys = EncryptionKeys.decode(reader, reader.uint32()); + continue; + case 3: + if (tag !== 26) { + break; + } + message.projectInfo = Invite_ProjectInfo.decode(reader, reader.uint32()); + continue; + case 4: + if (tag !== 34) { + break; + } + message.roleName = reader.string(); + continue; + case 5: + if (tag !== 42) { + break; + } + message.roleDescription = reader.string(); + continue; + case 6: + if (tag !== 50) { + break; + } + message.invitorName = reader.string(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + create: function (base) { + return Invite.fromPartial(base !== null && base !== void 0 ? base : {}); + }, + fromPartial: function (object) { + var _a, _b, _c, _d; + var message = createBaseInvite(); + message.projectKey = (_a = object.projectKey) !== null && _a !== void 0 ? _a : Buffer.alloc(0); + message.encryptionKeys = (object.encryptionKeys !== undefined && object.encryptionKeys !== null) + ? EncryptionKeys.fromPartial(object.encryptionKeys) + : undefined; + message.projectInfo = (object.projectInfo !== undefined && object.projectInfo !== null) + ? Invite_ProjectInfo.fromPartial(object.projectInfo) + : undefined; + message.roleName = (_b = object.roleName) !== null && _b !== void 0 ? _b : ""; + message.roleDescription = (_c = object.roleDescription) !== null && _c !== void 0 ? _c : undefined; + message.invitorName = (_d = object.invitorName) !== null && _d !== void 0 ? _d : ""; + return message; + }, +}; function createBaseInvite_ProjectInfo() { - return {} + return {}; } export var Invite_ProjectInfo = { - encode: function (message, writer) { - if (writer === void 0) { - writer = _m0.Writer.create() - } - if (message.name !== undefined) { - writer.uint32(10).string(message.name) - } - return writer - }, - decode: function (input, length) { - var reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input) - var end = length === undefined ? reader.len : reader.pos + length - var message = createBaseInvite_ProjectInfo() - while (reader.pos < end) { - var tag = reader.uint32() - switch (tag >>> 3) { - case 1: - if (tag !== 10) { - break - } - message.name = reader.string() - continue - } - if ((tag & 7) === 4 || tag === 0) { - break - } - reader.skipType(tag & 7) - } - return message - }, - create: function (base) { - return Invite_ProjectInfo.fromPartial( - base !== null && base !== void 0 ? base : {} - ) - }, - fromPartial: function (object) { - var _a - var message = createBaseInvite_ProjectInfo() - message.name = (_a = object.name) !== null && _a !== void 0 ? _a : undefined - return message - }, -} + encode: function (message, writer) { + if (writer === void 0) { writer = _m0.Writer.create(); } + if (message.name !== undefined) { + writer.uint32(10).string(message.name); + } + return writer; + }, + decode: function (input, length) { + var reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + var end = length === undefined ? reader.len : reader.pos + length; + var message = createBaseInvite_ProjectInfo(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + message.name = reader.string(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + create: function (base) { + return Invite_ProjectInfo.fromPartial(base !== null && base !== void 0 ? base : {}); + }, + fromPartial: function (object) { + var _a; + var message = createBaseInvite_ProjectInfo(); + message.name = (_a = object.name) !== null && _a !== void 0 ? _a : undefined; + return message; + }, +}; function createBaseInviteResponse() { - return { - projectKey: Buffer.alloc(0), - decision: InviteResponse_Decision.REJECT, - } + return { projectKey: Buffer.alloc(0), decision: InviteResponse_Decision.REJECT }; } export var InviteResponse = { - encode: function (message, writer) { - if (writer === void 0) { - writer = _m0.Writer.create() - } - if (message.projectKey.length !== 0) { - writer.uint32(10).bytes(message.projectKey) - } - if (message.decision !== InviteResponse_Decision.REJECT) { - writer.uint32(16).int32(inviteResponse_DecisionToNumber(message.decision)) - } - return writer - }, - decode: function (input, length) { - var reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input) - var end = length === undefined ? reader.len : reader.pos + length - var message = createBaseInviteResponse() - while (reader.pos < end) { - var tag = reader.uint32() - switch (tag >>> 3) { - case 1: - if (tag !== 10) { - break - } - message.projectKey = reader.bytes() - continue - case 2: - if (tag !== 16) { - break - } - message.decision = inviteResponse_DecisionFromJSON(reader.int32()) - continue - } - if ((tag & 7) === 4 || tag === 0) { - break - } - reader.skipType(tag & 7) - } - return message - }, - create: function (base) { - return InviteResponse.fromPartial( - base !== null && base !== void 0 ? base : {} - ) - }, - fromPartial: function (object) { - var _a, _b - var message = createBaseInviteResponse() - message.projectKey = - (_a = object.projectKey) !== null && _a !== void 0 ? _a : Buffer.alloc(0) - message.decision = - (_b = object.decision) !== null && _b !== void 0 - ? _b - : InviteResponse_Decision.REJECT - return message - }, -} + encode: function (message, writer) { + if (writer === void 0) { writer = _m0.Writer.create(); } + if (message.projectKey.length !== 0) { + writer.uint32(10).bytes(message.projectKey); + } + if (message.decision !== InviteResponse_Decision.REJECT) { + writer.uint32(16).int32(inviteResponse_DecisionToNumber(message.decision)); + } + return writer; + }, + decode: function (input, length) { + var reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + var end = length === undefined ? reader.len : reader.pos + length; + var message = createBaseInviteResponse(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + message.projectKey = reader.bytes(); + continue; + case 2: + if (tag !== 16) { + break; + } + message.decision = inviteResponse_DecisionFromJSON(reader.int32()); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + create: function (base) { + return InviteResponse.fromPartial(base !== null && base !== void 0 ? base : {}); + }, + fromPartial: function (object) { + var _a, _b; + var message = createBaseInviteResponse(); + message.projectKey = (_a = object.projectKey) !== null && _a !== void 0 ? _a : Buffer.alloc(0); + message.decision = (_b = object.decision) !== null && _b !== void 0 ? _b : InviteResponse_Decision.REJECT; + return message; + }, +}; function createBaseDeviceInfo() { - return { name: '' } + return { name: "" }; } export var DeviceInfo = { - encode: function (message, writer) { - if (writer === void 0) { - writer = _m0.Writer.create() - } - if (message.name !== '') { - writer.uint32(10).string(message.name) - } - return writer - }, - decode: function (input, length) { - var reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input) - var end = length === undefined ? reader.len : reader.pos + length - var message = createBaseDeviceInfo() - while (reader.pos < end) { - var tag = reader.uint32() - switch (tag >>> 3) { - case 1: - if (tag !== 10) { - break - } - message.name = reader.string() - continue - } - if ((tag & 7) === 4 || tag === 0) { - break - } - reader.skipType(tag & 7) - } - return message - }, - create: function (base) { - return DeviceInfo.fromPartial(base !== null && base !== void 0 ? base : {}) - }, - fromPartial: function (object) { - var _a - var message = createBaseDeviceInfo() - message.name = (_a = object.name) !== null && _a !== void 0 ? _a : '' - return message - }, -} + encode: function (message, writer) { + if (writer === void 0) { writer = _m0.Writer.create(); } + if (message.name !== "") { + writer.uint32(10).string(message.name); + } + if (message.deviceType !== undefined) { + writer.uint32(16).int32(deviceInfo_DeviceTypeToNumber(message.deviceType)); + } + return writer; + }, + decode: function (input, length) { + var reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + var end = length === undefined ? reader.len : reader.pos + length; + var message = createBaseDeviceInfo(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + message.name = reader.string(); + continue; + case 2: + if (tag !== 16) { + break; + } + message.deviceType = deviceInfo_DeviceTypeFromJSON(reader.int32()); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + create: function (base) { + return DeviceInfo.fromPartial(base !== null && base !== void 0 ? base : {}); + }, + fromPartial: function (object) { + var _a, _b; + var message = createBaseDeviceInfo(); + message.name = (_a = object.name) !== null && _a !== void 0 ? _a : ""; + message.deviceType = (_b = object.deviceType) !== null && _b !== void 0 ? _b : undefined; + return message; + }, +}; diff --git a/src/generated/rpc.ts b/src/generated/rpc.ts index 4db432b8..833c4749 100644 --- a/src/generated/rpc.ts +++ b/src/generated/rpc.ts @@ -64,6 +64,48 @@ export function inviteResponse_DecisionToNumber(object: InviteResponse_Decision) export interface DeviceInfo { name: string; + deviceType?: DeviceInfo_DeviceType | undefined; +} + +export const DeviceInfo_DeviceType = { + mobile: "mobile", + tablet: "tablet", + desktop: "desktop", + UNRECOGNIZED: "UNRECOGNIZED", +} as const; + +export type DeviceInfo_DeviceType = typeof DeviceInfo_DeviceType[keyof typeof DeviceInfo_DeviceType]; + +export function deviceInfo_DeviceTypeFromJSON(object: any): DeviceInfo_DeviceType { + switch (object) { + case 0: + case "mobile": + return DeviceInfo_DeviceType.mobile; + case 1: + case "tablet": + return DeviceInfo_DeviceType.tablet; + case 2: + case "desktop": + return DeviceInfo_DeviceType.desktop; + case -1: + case "UNRECOGNIZED": + default: + return DeviceInfo_DeviceType.UNRECOGNIZED; + } +} + +export function deviceInfo_DeviceTypeToNumber(object: DeviceInfo_DeviceType): number { + switch (object) { + case DeviceInfo_DeviceType.mobile: + return 0; + case DeviceInfo_DeviceType.tablet: + return 1; + case DeviceInfo_DeviceType.desktop: + return 2; + case DeviceInfo_DeviceType.UNRECOGNIZED: + default: + return -1; + } } function createBaseInvite(): Invite { @@ -280,6 +322,9 @@ export const DeviceInfo = { if (message.name !== "") { writer.uint32(10).string(message.name); } + if (message.deviceType !== undefined) { + writer.uint32(16).int32(deviceInfo_DeviceTypeToNumber(message.deviceType)); + } return writer; }, @@ -297,6 +342,13 @@ export const DeviceInfo = { message.name = reader.string(); continue; + case 2: + if (tag !== 16) { + break; + } + + message.deviceType = deviceInfo_DeviceTypeFromJSON(reader.int32()); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -312,6 +364,7 @@ export const DeviceInfo = { fromPartial, I>>(object: I): DeviceInfo { const message = createBaseDeviceInfo(); message.name = object.name ?? ""; + message.deviceType = object.deviceType ?? undefined; return message; }, }; diff --git a/src/local-peers.js b/src/local-peers.js index e3aaf658..7ec6b504 100644 --- a/src/local-peers.js +++ b/src/local-peers.js @@ -35,6 +35,7 @@ const MESSAGES_MAX_ID = Math.max.apply(null, [...Object.values(MESSAGE_TYPES)]) * @typedef {object} PeerInfoBase * @property {string} deviceId * @property {string | undefined} name + * @property {import('./generated/rpc.js').DeviceInfo['deviceType']} deviceType */ /** @typedef {PeerInfoBase & { status: 'connecting' }} PeerInfoConnecting */ /** @typedef {PeerInfoBase & { status: 'connected', connectedAt: number, protomux: Protomux }} PeerInfoConnected */ @@ -62,6 +63,8 @@ class Peer { pendingInvites = new Map() /** @type {string | undefined} */ #name + /** @type {DeviceInfo['deviceType']} */ + #deviceType #connectedAt = 0 #disconnectedAt = 0 #protomux @@ -95,12 +98,14 @@ class Peer { status: this.#state, deviceId: this.#deviceId, name: this.#name, + deviceType: this.#deviceType, } case 'connected': return { status: this.#state, deviceId: this.#deviceId, name: this.#name, + deviceType: this.#deviceType, connectedAt: this.#connectedAt, protomux: this.#protomux, } @@ -109,6 +114,7 @@ class Peer { status: this.#state, deviceId: this.#deviceId, name: this.#name, + deviceType: this.#deviceType, disconnectedAt: this.#disconnectedAt, } /* c8 ignore next 4 */ @@ -193,6 +199,7 @@ class Peer { /** @param {DeviceInfo} deviceInfo */ receiveDeviceInfo(deviceInfo) { this.#name = deviceInfo.name + this.#deviceType = deviceInfo.deviceType this.#log('received deviceInfo %o', deviceInfo) } #assertConnected() { diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index c15f5fc1..8727788f 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -91,6 +91,7 @@ export class MapeoManager extends TypedEmitter { #localDiscovery #loggerBase #l + #deviceType /** * @param {Object} opts @@ -100,6 +101,7 @@ export class MapeoManager extends TypedEmitter { * @param {string} opts.clientMigrationsFolder path for drizzle migrations folder for client database * @param {string | import('./types.js').CoreStorage} opts.coreStorage Folder for hypercore storage or a function that returns a RandomAccessStorage instance * @param {import('fastify').FastifyInstance} opts.fastify Fastify server instance + * @param {import('./generated/rpc.js').DeviceInfo['deviceType']} [opts.deviceType] Device type, shared with local peers and project members */ constructor({ rootKey, @@ -108,10 +110,12 @@ export class MapeoManager extends TypedEmitter { clientMigrationsFolder, coreStorage, fastify, + deviceType, }) { super() this.#keyManager = new KeyManager(rootKey) this.#deviceId = getDeviceId(this.#keyManager) + this.#deviceType = deviceType const logger = (this.#loggerBase = new Logger({ deviceId: this.#deviceId })) this.#l = Logger.create('manager', logger) this.#dbFolder = dbFolder @@ -252,7 +256,10 @@ export class MapeoManager extends TypedEmitter { .then(([{ name }, openedNoiseStream]) => { if (openedNoiseStream.destroyed || !name) return const peerId = keyToId(openedNoiseStream.remotePublicKey) - return this.#localPeers.sendDeviceInfo(peerId, { name }) + return this.#localPeers.sendDeviceInfo(peerId, { + name, + deviceType: this.#deviceType, + }) }) .catch((e) => { // Ignore error but log diff --git a/test-e2e/local-peers.js b/test-e2e/local-peers.js new file mode 100644 index 00000000..19256599 --- /dev/null +++ b/test-e2e/local-peers.js @@ -0,0 +1,56 @@ +import { test } from 'brittle' +import { + connectPeers, + createManagers, + disconnectPeers, + waitForPeers, +} from './utils.js' + +test('Local peers discovery each other and share device info', async (t) => { + const mobileManagers = await createManagers(5, t, 'mobile') + const desktopManagers = await createManagers(5, t, 'desktop') + const managers = [...mobileManagers, ...desktopManagers] + connectPeers(managers, { discovery: true }) + await waitForPeers(managers, { waitForDeviceInfo: true }) + const deviceInfos = [ + ...(await Promise.all(mobileManagers.map((m) => m.getDeviceInfo()))).map( + (deviceInfo) => ({ ...deviceInfo, deviceType: 'mobile' }) + ), + ...(await Promise.all(desktopManagers.map((m) => m.getDeviceInfo()))).map( + (deviceInfo) => ({ ...deviceInfo, deviceType: 'desktop' }) + ), + ] + const mPeers = await Promise.all(managers.map((m) => m.listLocalPeers())) + for (const [i, peers] of mPeers.entries()) { + const expectedDeviceInfos = removeElementAt(deviceInfos, i) + const actualDeviceInfos = peers.map((p) => ({ + name: p.name, + deviceId: p.deviceId, + deviceType: p.deviceType, + })) + t.alike( + actualDeviceInfos.sort(sortByDeviceId), + expectedDeviceInfos.sort(sortByDeviceId), + `manager ${i} has correct peers` + ) + } + await disconnectPeers(managers) +}) + +/** + * @param {any[]} array + * @param {number} i + */ +function removeElementAt(array, i) { + return array.slice(0, i).concat(array.slice(i + 1)) +} + +/** + * @param {any} a + * @param {any} b + */ +function sortByDeviceId(a, b) { + if (a.deviceId < b.deviceId) return -1 + if (a.deviceId > b.deviceId) return 1 + return 0 +} diff --git a/test-e2e/utils.js b/test-e2e/utils.js index 44453658..e5fd4031 100644 --- a/test-e2e/utils.js +++ b/test-e2e/utils.js @@ -117,15 +117,25 @@ export async function invite({ * Waits for all manager instances to be connected to each other * * @param {readonly MapeoManager[]} managers + * @param {{ waitForDeviceInfo?: boolean }} [opts] Optionally wait for device names to be set */ -export async function waitForPeers(managers) { +export async function waitForPeers( + managers, + { waitForDeviceInfo = false } = {} +) { const peerCounts = managers.map((manager) => { return manager[kRPC].peers.filter(({ status }) => status === 'connected') .length }) + const peersHaveNames = managers.map((manager) => { + return manager[kRPC].peers.every(({ name }) => !!name) + }) const expectedCount = managers.length - 1 return new Promise((res) => { - if (peerCounts.every((v) => v === expectedCount)) { + if ( + peerCounts.every((v) => v === expectedCount) && + (!waitForDeviceInfo || peersHaveNames.every((v) => v)) + ) { return res(null) } for (const [idx, manager] of managers.entries()) { @@ -133,11 +143,19 @@ export async function waitForPeers(managers) { const connectedPeerCount = peers.filter( ({ status }) => status === 'connected' ).length + const allHaveNames = peers.every(({ name }) => !!name) peerCounts[idx] = connectedPeerCount - if (connectedPeerCount === expectedCount) { + peersHaveNames[idx] = allHaveNames + if ( + connectedPeerCount === expectedCount && + (!waitForDeviceInfo || allHaveNames) + ) { manager.off('local-peers', onPeers) } - if (peerCounts.every((v) => v === expectedCount)) { + if ( + peerCounts.every((v) => v === expectedCount) && + (!waitForDeviceInfo || peersHaveNames.every((v) => v)) + ) { res(null) } }) @@ -152,16 +170,17 @@ export async function waitForPeers(managers) { * @template {number} T * @param {T} count * @param {import('brittle').TestInstance} t + * @param {import('../src/generated/rpc.js').DeviceInfo['deviceType']} [deviceType] * @returns {Promise>} */ -export async function createManagers(count, t) { +export async function createManagers(count, t, deviceType) { // @ts-ignore return Promise.all( Array(count) .fill(null) .map(async (_, i) => { - const name = 'device' + i - const manager = createManager(name, t) + const name = 'device' + i + (deviceType ? `-${deviceType}` : '') + const manager = createManager(name, t, deviceType) await manager.setDeviceInfo({ name }) return manager }) @@ -171,8 +190,9 @@ export async function createManagers(count, t) { /** * @param {string} seed * @param {import('brittle').TestInstance} t + * @param {import('../src/generated/rpc.js').DeviceInfo['deviceType']} [deviceType] */ -export function createManager(seed, t) { +export function createManager(seed, t, deviceType) { const dbFolder = FAST_TESTS ? ':memory:' : temporaryDirectory() const coreStorage = FAST_TESTS ? () => new RAM() : temporaryDirectory() @@ -195,6 +215,7 @@ export function createManager(seed, t) { dbFolder, coreStorage, fastify: Fastify(), + deviceType, }) } diff --git a/tests/invite-api.js b/tests/invite-api.js index 2b8d3371..7278b5c7 100644 --- a/tests/invite-api.js +++ b/tests/invite-api.js @@ -3,7 +3,7 @@ import { randomBytes } from 'crypto' import { KeyManager } from '@mapeo/crypto' import { LocalPeers } from '../src/local-peers.js' import { InviteApi } from '../src/invite-api.js' -import { projectKeyToPublicId } from '../src/utils.js' +import { keyToId, projectKeyToPublicId } from '../src/utils.js' import { replicate } from './helpers/local-peers.js' import NoiseSecretStream from '@hyperswarm/secret-stream' import pDefer from 'p-defer' @@ -30,6 +30,7 @@ test('invite-received event has expected payload', async (t) => { }, }) + /** @type {undefined | string} */ let expectedInvitorPeerId r2.on('peers', (peers) => { @@ -312,15 +313,15 @@ test('trying to accept or reject non-existent invite throws', async (t) => { const inviteApi = new InviteApi({ rpc, queries: { - isMember: async () => {}, + isMember: async () => true, addProject: async () => {}, }, }) await t.exception(() => { - return inviteApi.accept(randomBytes(32)) + return inviteApi.accept(keyToId(randomBytes(32))) }) await t.exception(() => { - return inviteApi.reject(randomBytes(32)) + return inviteApi.reject(keyToId(randomBytes(32))) }) }) @@ -376,7 +377,7 @@ test('invitor disconnecting results in invite reject response not throwing', asy const inviteApi = new InviteApi({ rpc: r2, queries: { - isMember: async () => {}, + isMember: async () => false, addProject: async () => {}, }, }) @@ -461,7 +462,7 @@ test('addProject throwing results in invite accept throwing', async (t) => { const inviteApi = new InviteApi({ rpc: r2, queries: { - isMember: async () => {}, + isMember: async () => false, addProject: async () => { throw new Error('Failed to add project') }, @@ -513,6 +514,7 @@ test('Invite from multiple peers', async (t) => { }, }) + /** @type {undefined | string} */ let first let connected = 0 const deferred = pDefer() @@ -578,6 +580,7 @@ test.skip('Invite from multiple peers, first disconnects before accepted, receiv }, }) + /** @type {string[]} */ let invitesReceived = [] let connected = 0 const disconnects = new Map() diff --git a/tests/local-peers.js b/tests/local-peers.js index 921d957d..c38d8886 100644 --- a/tests/local-peers.js +++ b/tests/local-peers.js @@ -217,6 +217,7 @@ test('Invite to unknown peer', async (t) => { roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, invitorName: 'device0', }), + // @ts-ignore UnknownPeerError ) await t.exception( @@ -225,6 +226,7 @@ test('Invite to unknown peer', async (t) => { projectKey, decision: LocalPeers.InviteResponse.ACCEPT, }), + // @ts-ignore UnknownPeerError ) }) @@ -376,6 +378,7 @@ test('Disconnect results in rejected invite', async (t) => { }) await t.exception( invite, + // @ts-ignore PeerDisconnectedError, 'invite rejected with PeerDisconnectedError' ) @@ -532,6 +535,7 @@ test('Invite timeout', async (t) => { roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, invitorName: 'device0', }), + // @ts-ignore TimeoutError ) clock.tickAsync(5005) @@ -554,6 +558,7 @@ test('Send invite to non-existent peer', async (t) => { roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, invitorName: 'device0', }), + // @ts-ignore UnknownPeerError ) }) @@ -595,7 +600,12 @@ test('Reconnect peer and send invite', async (t) => { test('invalid stream', (t) => { const r1 = new LocalPeers() const regularStream = new Duplex() - t.exception(() => r1.connect(regularStream), 'Invalid stream') + t.exception( + () => + // @ts-expect-error + r1.connect(regularStream), + 'Invalid stream' + ) }) test('Send device info', async (t) => { @@ -603,7 +613,7 @@ test('Send device info', async (t) => { const r2 = new LocalPeers() /** @type {import('../src/generated/rpc.js').DeviceInfo} */ - const expectedDeviceInfo = { name: 'mapeo' } + const expectedDeviceInfo = { name: 'mapeo', deviceType: 'mobile' } r1.on('peers', async (peers) => { t.is(peers.length, 1) @@ -616,6 +626,7 @@ test('Send device info', async (t) => { r2.on('peers', (peers) => { if (!(peers.length === 1 && peers[0].name)) return t.is(peers[0].name, expectedDeviceInfo.name) + t.is(peers[0].deviceType, expectedDeviceInfo.deviceType) res(true) }) }) @@ -626,7 +637,7 @@ test('Send device info immediately', async (t) => { const r2 = new LocalPeers() /** @type {import('../src/generated/rpc.js').DeviceInfo} */ - const expectedDeviceInfo = { name: 'mapeo' } + const expectedDeviceInfo = { name: 'mapeo', deviceType: 'mobile' } const kp1 = NoiseSecretStream.keyPair() const kp2 = NoiseSecretStream.keyPair() @@ -639,6 +650,7 @@ test('Send device info immediately', async (t) => { r2.on('peers', (peers) => { if (!(peers.length === 1 && peers[0].name)) return t.is(peers[0].name, expectedDeviceInfo.name) + t.is(peers[0].deviceType, expectedDeviceInfo.deviceType) res(true) }) }) @@ -649,7 +661,7 @@ test('Reconnect peer and send device info', async (t) => { const r2 = new LocalPeers() /** @type {import('../src/generated/rpc.js').DeviceInfo} */ - const expectedDeviceInfo = { name: 'mapeo' } + const expectedDeviceInfo = { name: 'mapeo', deviceType: 'mobile' } const destroy = replicate(r1, r2) await once(r1, 'peers') @@ -668,6 +680,7 @@ test('Reconnect peer and send device info', async (t) => { const [r2Peers] = await once(r2, 'peers') t.is(r2Peers[0].name, expectedDeviceInfo.name) + t.is(r2Peers[0].deviceType, expectedDeviceInfo.deviceType) }) test('connected peer has protomux instance', async (t) => { From 70dc2478a526de292338f61769da4cd48efcebbe Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Mon, 5 Feb 2024 13:21:21 +0000 Subject: [PATCH 2/3] Incorporate review comments --- src/mapeo-manager.js | 1 + test-e2e/local-peers.js | 18 +++++------------- tests/local-peers.js | 5 ----- types/brittle.d.ts | 30 ++++++++++++++++++++++-------- 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 8727788f..7e2036a6 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -91,6 +91,7 @@ export class MapeoManager extends TypedEmitter { #localDiscovery #loggerBase #l + /** @readonly */ #deviceType /** diff --git a/test-e2e/local-peers.js b/test-e2e/local-peers.js index 19256599..b981df3f 100644 --- a/test-e2e/local-peers.js +++ b/test-e2e/local-peers.js @@ -11,6 +11,9 @@ test('Local peers discovery each other and share device info', async (t) => { const desktopManagers = await createManagers(5, t, 'desktop') const managers = [...mobileManagers, ...desktopManagers] connectPeers(managers, { discovery: true }) + t.teardown(() => { + disconnectPeers(managers) + }) await waitForPeers(managers, { waitForDeviceInfo: true }) const deviceInfos = [ ...(await Promise.all(mobileManagers.map((m) => m.getDeviceInfo()))).map( @@ -29,12 +32,11 @@ test('Local peers discovery each other and share device info', async (t) => { deviceType: p.deviceType, })) t.alike( - actualDeviceInfos.sort(sortByDeviceId), - expectedDeviceInfos.sort(sortByDeviceId), + new Set(actualDeviceInfos), + new Set(expectedDeviceInfos), `manager ${i} has correct peers` ) } - await disconnectPeers(managers) }) /** @@ -44,13 +46,3 @@ test('Local peers discovery each other and share device info', async (t) => { function removeElementAt(array, i) { return array.slice(0, i).concat(array.slice(i + 1)) } - -/** - * @param {any} a - * @param {any} b - */ -function sortByDeviceId(a, b) { - if (a.deviceId < b.deviceId) return -1 - if (a.deviceId > b.deviceId) return 1 - return 0 -} diff --git a/tests/local-peers.js b/tests/local-peers.js index c38d8886..ab9c7640 100644 --- a/tests/local-peers.js +++ b/tests/local-peers.js @@ -217,7 +217,6 @@ test('Invite to unknown peer', async (t) => { roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, invitorName: 'device0', }), - // @ts-ignore UnknownPeerError ) await t.exception( @@ -226,7 +225,6 @@ test('Invite to unknown peer', async (t) => { projectKey, decision: LocalPeers.InviteResponse.ACCEPT, }), - // @ts-ignore UnknownPeerError ) }) @@ -378,7 +376,6 @@ test('Disconnect results in rejected invite', async (t) => { }) await t.exception( invite, - // @ts-ignore PeerDisconnectedError, 'invite rejected with PeerDisconnectedError' ) @@ -535,7 +532,6 @@ test('Invite timeout', async (t) => { roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, invitorName: 'device0', }), - // @ts-ignore TimeoutError ) clock.tickAsync(5005) @@ -558,7 +554,6 @@ test('Send invite to non-existent peer', async (t) => { roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, invitorName: 'device0', }), - // @ts-ignore UnknownPeerError ) }) diff --git a/types/brittle.d.ts b/types/brittle.d.ts index 5cf4c8ce..552f0586 100644 --- a/types/brittle.d.ts +++ b/types/brittle.d.ts @@ -6,17 +6,31 @@ declare module 'brittle' { coercively(actual: any, expected: any, message?: string): void } + type AnyErrorConstructor = new () => Error + interface ExceptionAssertion { - ( - fn: T | Promise, - error?: RegExp | Error, + (fn: () => unknown, message?: string): void + ( + fn: () => unknown, + error?: RegExp | AnyErrorConstructor, + message?: string + ): void + (fn: Promise, message?: string): Promise + ( + fn: Promise, + error?: RegExp | AnyErrorConstructor, message?: string ): Promise - (fn: T | Promise, message?: string): Promise - all(fn: T | Promise, message?: string): Promise - all( - fn: T | Promise, - error?: RegExp | Error, + all(fn: () => unknown, message?: string): void + all( + fn: () => unknown, + error?: RegExp | AnyErrorConstructor, + message?: string + ): void + all(fn: Promise, message?: string): Promise + all( + fn: Promise, + error?: RegExp | AnyErrorConstructor, message?: string ): Promise } From 80d1f2cc3f4d786059c6fc6791347499e9a6460d Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Mon, 5 Feb 2024 14:56:53 +0000 Subject: [PATCH 3/3] fix bug in teardown --- test-e2e/local-peers.js | 4 +--- test-e2e/utils.js | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/test-e2e/local-peers.js b/test-e2e/local-peers.js index b981df3f..f2cd7f19 100644 --- a/test-e2e/local-peers.js +++ b/test-e2e/local-peers.js @@ -11,9 +11,7 @@ test('Local peers discovery each other and share device info', async (t) => { const desktopManagers = await createManagers(5, t, 'desktop') const managers = [...mobileManagers, ...desktopManagers] connectPeers(managers, { discovery: true }) - t.teardown(() => { - disconnectPeers(managers) - }) + t.teardown(() => disconnectPeers(managers)) await waitForPeers(managers, { waitForDeviceInfo: true }) const deviceInfos = [ ...(await Promise.all(mobileManagers.map((m) => m.getDeviceInfo()))).map( diff --git a/test-e2e/utils.js b/test-e2e/utils.js index e5fd4031..7dd55757 100644 --- a/test-e2e/utils.js +++ b/test-e2e/utils.js @@ -24,7 +24,7 @@ const clientMigrationsFolder = new URL('../drizzle/client', import.meta.url) * @param {readonly MapeoManager[]} managers */ export async function disconnectPeers(managers) { - return Promise.all( + await Promise.all( managers.map(async (manager) => { return manager.stopLocalPeerDiscovery({ force: true }) })