Skip to content

Commit

Permalink
test: cross-version sync with @comapeo/[email protected] (#941)
Browse files Browse the repository at this point in the history
This tests that we can connect and sync between the current version and
`@comapeo/[email protected]`.

Notably, it required several changes to our test utilities to support
the current `MapeoManager` and older ones. (One of these changes allowed
us to remove a test-only method from `MapeoManager`.)
  • Loading branch information
EvanHahn authored Oct 29, 2024
1 parent 0bd8a7a commit 2db6be2
Show file tree
Hide file tree
Showing 4 changed files with 200 additions and 60 deletions.
9 changes: 0 additions & 9 deletions src/mapeo-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,6 @@ export const DEFAULT_FALLBACK_MAP_FILE_PATH = require.resolve(
export const DEFAULT_ONLINE_STYLE_URL =
'https://demotiles.maplibre.org/style.json'

export const kRPC = Symbol('rpc')

/**
* @typedef {Omit<import('./local-peers.js').PeerInfo, 'protomux'>} PublicPeerInfo
*/
Expand Down Expand Up @@ -221,13 +219,6 @@ export class MapeoManager extends TypedEmitter {
this.#localDiscovery.on('connection', this.#replicate.bind(this))
}

/**
* MapeoRPC instance, used for tests
*/
get [kRPC]() {
return this.#localPeers
}

get deviceId() {
return this.#deviceId
}
Expand Down
79 changes: 79 additions & 0 deletions test-e2e/cross-version-sync.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { valueOf } from '@comapeo/schema'
import { generate } from '@mapeo/mock-data'
import assert from 'node:assert/strict'
import test from 'node:test'
import {
connectPeers,
createManager,
createOldManagerOnVersion2_0_1,
invite,
waitForPeers,
} from './utils.js'

test('syncing @comapeo/[email protected] with the current version', async (t) => {
const oldManager = await createOldManagerOnVersion2_0_1('old')
await oldManager.setDeviceInfo({ name: 'old', deviceType: 'mobile' })

const newManager = createManager('new', t)
await newManager.setDeviceInfo({ name: 'new', deviceType: 'desktop' })

const managers = [oldManager, newManager]

const disconnect = connectPeers(managers)
t.after(disconnect)
await waitForPeers(managers)

const [oldManagerPeers, newManagerPeers] = await Promise.all(
managers.map((manager) => manager.listLocalPeers())
)
assert.equal(oldManagerPeers.length, 1, 'old manager sees 1 peer')
assert.equal(newManagerPeers.length, 1, 'new manager sees 1 peer')
assert(
oldManagerPeers.some((p) => p.deviceId === newManager.deviceId),
'old manager sees new manager'
)
assert(
newManagerPeers.some((p) => p.deviceId === oldManager.deviceId),
'new manager sees old manager'
)

const projectId = await oldManager.createProject({ name: 'foo bar' })

await invite({
projectId,
invitor: oldManager,
invitees: [newManager],
})

const projects = await Promise.all(
managers.map((manager) => manager.getProject(projectId))
)
const [oldProject, newProject] = projects
assert.equal(
(await newProject.$getProjectSettings()).name,
'foo bar',
'new manager sees the project'
)

oldProject.$sync.start()
newProject.$sync.start()

const [oldObservation, newObservation] = await Promise.all(
projects.map((project) =>
project.observation.create(valueOf(generate('observation')[0]))
)
)

await Promise.all(
projects.map((project) => project.$sync.waitForSync('full'))
)

assert(
await oldProject.observation.getByDocId(newObservation.docId),
'old project gets observation from new project'
)
assert(
await newProject.observation.getByDocId(oldObservation.docId),
'new project gets observation from old project'
)
})
18 changes: 2 additions & 16 deletions test-e2e/migration.js
Original file line number Diff line number Diff line change
@@ -1,39 +1,25 @@
import test from 'node:test'
import { KeyManager } from '@mapeo/crypto'
import { MapeoManager as MapeoManagerPreMigration } from '@comapeo/core2.0.1'
import RAM from 'random-access-memory'
import { MapeoManager } from '../src/mapeo-manager.js'
import Fastify from 'fastify'
import assert from 'node:assert/strict'
import { fileURLToPath } from 'node:url'
import fsPromises from 'node:fs/promises'
import { temporaryDirectory } from 'tempy'
import { createOldManagerOnVersion2_0_1 } from './utils.js'

const projectMigrationsFolder = new URL('../drizzle/project', import.meta.url)
.pathname
const clientMigrationsFolder = new URL('../drizzle/client', import.meta.url)
.pathname

test('migration of localDeviceInfo table', async (t) => {
const comapeoCorePreMigrationUrl = await import.meta.resolve?.(
'@comapeo/core2.0.1'
)
assert(comapeoCorePreMigrationUrl, 'Could not resolve @comapeo/core2.0.1')
const clientMigrationsFolderPreMigration = fileURLToPath(
new URL('../drizzle/client', comapeoCorePreMigrationUrl)
)
const projectMigrationsFolderPreMigration = fileURLToPath(
new URL('../drizzle/project', comapeoCorePreMigrationUrl)
)

const dbFolder = temporaryDirectory()
const rootKey = KeyManager.generateRootKey()
t.after(() => fsPromises.rm(dbFolder, { recursive: true }))

const managerPreMigration = new MapeoManagerPreMigration({
const managerPreMigration = await createOldManagerOnVersion2_0_1('seed', {
rootKey,
projectMigrationsFolder: projectMigrationsFolderPreMigration,
clientMigrationsFolder: clientMigrationsFolderPreMigration,
dbFolder,
coreStorage: () => new RAM(),
fastify: Fastify(),
Expand Down
154 changes: 119 additions & 35 deletions test-e2e/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ import sodium from 'sodium-universal'
import RAM from 'random-access-memory'
import Fastify from 'fastify'
import { arrayFrom } from 'iterpal'
import assert from 'node:assert/strict'
import * as path from 'node:path'
import { fork } from 'node:child_process'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import * as v8 from 'node:v8'
import { pEvent } from 'p-event'
import { MapeoManager as MapeoManager_2_0_1 } from '@comapeo/core2.0.1'

import { MapeoManager, roles } from '../src/index.js'
import { kRPC } from '../src/mapeo-manager.js'
import { generate } from '@mapeo/mock-data'
import { valueOf } from '../src/utils.js'
import { randomBytes, randomInt } from 'node:crypto'
Expand All @@ -19,14 +20,26 @@ import fsPromises from 'node:fs/promises'
import { kSyncState } from '../src/sync/sync-api.js'
import { readConfig } from '../src/config-import.js'

/** @import { MemberApi } from '../src/member-api.js' */

const FAST_TESTS = !!process.env.FAST_TESTS
const projectMigrationsFolder = new URL('../drizzle/project', import.meta.url)
.pathname
const clientMigrationsFolder = new URL('../drizzle/client', import.meta.url)
.pathname

/**
* @param {readonly MapeoManager[]} managers
* @internal
* @typedef {Pick<
* MapeoManager,
* 'startLocalPeerDiscoveryServer' |
* 'stopLocalPeerDiscoveryServer' |
* 'connectLocalPeer'
* >} ConnectableManager
*/

/**
* @param {ReadonlyArray<ConnectableManager>} managers
* @returns {() => Promise<void>}
*/
export function connectPeers(managers) {
Expand All @@ -52,17 +65,40 @@ export function connectPeers(managers) {
}
}

/**
* @internal
* @typedef {WaitForPeersManager & {
* getProject(projectId: string): PromiseLike<{
* $member: Pick<MemberApi, 'invite'>
* }>
* }} InvitorManager
*/

/**
* @internal
* @typedef {WaitForPeersManager & {
* deviceId: string
* invite: {
* on(
* event: 'invite-received',
* listener: (invite: { inviteId: string }
* ) => unknown): void
* accept(invite: unknown): PromiseLike<string>
* reject(invite: unknown): unknown
* }
* }} InviteeManager
*/

/**
* Invite mapeo clients to a project
*
* @param {{
* invitor: MapeoManager,
* projectId: string,
* invitees: MapeoManager[],
* roleId?: import('../src/roles.js').RoleIdAssignableToOthers,
* roleName?: string
* reject?: boolean
* }} opts
* @param {object} options
* @param {string} options.projectId
* @param {InvitorManager} options.invitor
* @param {ReadonlyArray<InviteeManager>} options.invitees
* @param {import('../src/roles.js').RoleIdAssignableToOthers} [options.roleId]
* @param {string} [options.roleName]
* @param {boolean} [options.reject]
*/
export async function invite({
invitor,
Expand Down Expand Up @@ -101,46 +137,68 @@ export async function invite({
)
}

/**
* A simple Promise-aware version of `Array.prototype.every`.
*
* Similar to the [p-every package](https://www.npmjs.com/package/p-every),
* which I couldn't figure out how to import without type errors.
*
* @template T
* @param {Iterable<T>} iterable
* @param {(value: T) => boolean | PromiseLike<boolean>} predicate
* @returns {Promise<boolean>}
*/
async function pEvery(iterable, predicate) {
const results = await Promise.all([...iterable].map(predicate))
return results.every(Boolean)
}

/**
* @internal
* @typedef {Pick<MapeoManager, 'deviceId' | 'listLocalPeers'> & {
* on(event: 'local-peers', listener: () => unknown): void;
* off(event: 'local-peers', listener: () => unknown): void;
* }} WaitForPeersManager
*/

/**
* Waits for all manager instances to be connected to each other
*
* @param {readonly MapeoManager[]} managers
* @param {ReadonlyArray<WaitForPeersManager>} managers
* @param {{ waitForDeviceInfo?: boolean }} [opts] Optionally wait for device names to be set
* @returns {Promise<void>}
*/
export const waitForPeers = (managers, { waitForDeviceInfo = false } = {}) =>
new Promise((res) => {
const deviceIds = new Set(managers.map((m) => m.deviceId))

const isDone = () =>
managers.every((manager) => {
const unconnectedDeviceIds = new Set(deviceIds)
unconnectedDeviceIds.delete(manager.deviceId)
for (const peer of manager[kRPC].peers) {
if (
peer.status === 'connected' &&
(!waitForDeviceInfo || peer.name)
) {
unconnectedDeviceIds.delete(peer.deviceId)
}
export async function waitForPeers(
managers,
{ waitForDeviceInfo = false } = {}
) {
const deviceIds = new Set(managers.map((m) => m.deviceId))

/** @returns {Promise<boolean>} */
const isDone = async () =>
pEvery(managers, async (manager) => {
const unconnectedDeviceIds = new Set(deviceIds)
unconnectedDeviceIds.delete(manager.deviceId)
for (const peer of await manager.listLocalPeers()) {
if (peer.status === 'connected' && (!waitForDeviceInfo || peer.name)) {
unconnectedDeviceIds.delete(peer.deviceId)
}
return unconnectedDeviceIds.size === 0
})
}
return unconnectedDeviceIds.size === 0
})

if (isDone()) {
res()
return
}
if (await isDone()) return

const onLocalPeers = () => {
if (isDone()) {
return new Promise((res) => {
const onLocalPeers = async () => {
if (await isDone()) {
for (const manager of managers) manager.off('local-peers', onLocalPeers)
res()
}
}

for (const manager of managers) manager.on('local-peers', onLocalPeers)
})
}

/**
* Create `count` manager instances. Each instance has a deterministic identity
Expand Down Expand Up @@ -174,6 +232,7 @@ export async function createManagers(
* @param {string} seed
* @param {import('node:test').TestContext} t
* @param {Partial<ConstructorParameters<typeof MapeoManager>[0]>} [overrides]
* @returns {MapeoManager}
*/
export function createManager(seed, t, overrides = {}) {
/** @type {string} */ let dbFolder
Expand Down Expand Up @@ -208,6 +267,31 @@ export function createManager(seed, t, overrides = {}) {
...overrides,
})
}
/**
* @param {string} seed
* @param {Partial<ConstructorParameters<typeof MapeoManager_2_0_1>[0]>} [overrides]
* @returns {Promise<MapeoManager_2_0_1>}
*/
export async function createOldManagerOnVersion2_0_1(seed, overrides = {}) {
const comapeoCorePreMigrationUrl = await import.meta.resolve?.(
'@comapeo/core2.0.1'
)
assert(comapeoCorePreMigrationUrl, 'Could not resolve @comapeo/core2.0.1')

return new MapeoManager_2_0_1({
rootKey: getRootKey(seed),
clientMigrationsFolder: fileURLToPath(
new URL('../drizzle/client', comapeoCorePreMigrationUrl)
),
projectMigrationsFolder: fileURLToPath(
new URL('../drizzle/project', comapeoCorePreMigrationUrl)
),
dbFolder: ':memory:',
coreStorage: () => new RAM(),
fastify: Fastify(),
...overrides,
})
}

/**
* `ManagerCustodian` helps you test the creation of multiple managers accessing
Expand Down

0 comments on commit 2db6be2

Please sign in to comment.