Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test: cross-version sync with @comapeo/core@2.0.1 #941

Merged
merged 5 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
Comment on lines -224 to -229
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer needed.


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/core@2.0.1 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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had to do a small amount of work to stop calling a private method.

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
Loading