OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / test / multiwriter-integration.test.js

241 lines · javascript ref:
/**
 * Integration tests: two hypernote instances link, share notebooks,
 * create notes with Yjs content, and sync via Autobase replication.
 */

import test from 'brittle'
import Corestore from 'corestore'
import os from 'bare-os'
import path from 'bare-path'
import { randomBytes } from 'hypercore-crypto'
import * as Y from 'yjs'
import { MultiwriterManager } from '../lib/multiwriter/manager.js'
import { SharedRegistry } from '../lib/multiwriter/shared-registry.js'
import { SharedNotebook } from '../lib/multiwriter/shared-notebook.js'

function createStore () {
  const tmp = path.join(os.tmpdir(), 'hypernote-int-' + randomBytes(16).toString('hex'))
  return new Corestore(tmp)
}

// Helper: poll until a condition is met or timeout
async function waitFor (fn, { interval = 500, maxAttempts = 20 } = {}) {
  for (let i = 0; i < maxAttempts; i++) {
    const result = await fn()
    if (result) return result
    await new Promise(r => setTimeout(r, interval))
  }
  return null
}

test('integration: two registries replicate users and notebooks', async (t) => {
  const store1 = createStore()
  const store2 = createStore()

  // Instance 1: create registry, add user and notebook
  const reg1 = new SharedRegistry(store1)
  await reg1.ready()

  await reg1.createUser({ userId: 'u1', name: 'Alice', role: 'admin' })
  await reg1.createNotebook({ slug: 'work', title: 'Work Notes', owner: 'u1' })

  // Instance 2: join with bootstrap key
  const reg2 = new SharedRegistry(store2, { bootstrapKey: reg1.bootstrapKey })
  await reg2.ready()

  // Replicate via Autobase streams
  const s1 = reg1.base.replicate(true)
  const s2 = reg2.base.replicate(false)
  s1.pipe(s2).pipe(s1)

  // Wait for user to sync
  const user = await waitFor(async () => {
    try {
      await reg2.update()
      return await reg2.getUser('u1')
    } catch { return null }
  })

  t.ok(user, 'user synced to instance 2')
  t.is(user.name, 'Alice')
  t.is(user.role, 'admin')

  // Wait for notebook to sync
  const nb = await waitFor(async () => {
    try {
      await reg2.update()
      return await reg2.getNotebookBySlug('work')
    } catch { return null }
  })

  t.ok(nb, 'notebook synced to instance 2')
  t.is(nb.title, 'Work Notes')
  t.is(nb.owner, 'u1')

  // Cleanup
  s1.destroy()
  s2.destroy()
  await reg1.close()
  await reg2.close()
  await store1.close()
  await store2.close()
})

test('integration: shared notebook syncs notes with Yjs content', async (t) => {
  const store1 = createStore()
  const store2 = createStore()

  // Instance 1: create notebook, add a note with Yjs content
  const nb1 = new SharedNotebook(store1, 'collab')
  await nb1.ready()

  const { noteId } = await nb1.createNote({ title: 'Meeting Notes', author: 'alice' })
  t.ok(noteId, 'noteId created')

  // Apply Yjs content
  const doc1 = new Y.Doc()
  doc1.getText('content').insert(0, 'Action items: deploy multi-writer')
  const update = Y.encodeStateAsUpdate(doc1)
  await nb1.applyYjsUpdate(noteId, update, 'alice')

  // Instance 2: join same notebook via bootstrap key
  const nb2 = new SharedNotebook(store2, 'collab', { bootstrapKey: nb1.bootstrapKey })
  await nb2.ready()

  // nb1 (creator) adds nb2 as writer first — only the creator can write initially
  await nb1.addWriter(nb2.localWriterKey)

  // Replicate via Autobase streams
  const s1 = nb1._base.replicate(true)
  const s2 = nb2._base.replicate(false)
  s1.pipe(s2).pipe(s1)

  // Wait for nb2 to learn it's been added as a writer
  await new Promise(r => setTimeout(r, 1000))
  await nb2.update()

  // Wait for notes to sync
  const notes = await waitFor(async () => {
    try {
      await nb2.update()
      const list = await nb2.listNotes()
      return list.length > 0 ? list : null
    } catch { return null }
  })

  t.ok(notes, 'notes synced to instance 2')
  t.is(notes.length, 1, 'exactly one note')
  t.is(notes[0].title, 'Meeting Notes')
  t.is(notes[0].author, 'alice')

  // Verify Yjs content reconstructed on instance 2
  const doc2 = await nb2.reconstructDoc(noteId)
  t.ok(doc2, 'doc reconstructed')
  t.is(doc2.getText('content').toString(), 'Action items: deploy multi-writer',
    'Yjs content matches')

  // Cleanup
  s1.destroy()
  s2.destroy()
  await nb1.close()
  await nb2.close()
  await store1.close()
  await store2.close()
})

test('integration: bidirectional note creation syncs both ways', async (t) => {
  const store1 = createStore()
  const store2 = createStore()

  // Instance 1 creates the notebook
  const nb1 = new SharedNotebook(store1, 'bidi')
  await nb1.ready()

  // Instance 2 joins
  const nb2 = new SharedNotebook(store2, 'bidi', { bootstrapKey: nb1.bootstrapKey })
  await nb2.ready()

  // nb1 (creator) adds nb2 as writer — only creator can write initially
  await nb1.addWriter(nb2.localWriterKey)

  // Replicate
  const s1 = nb1._base.replicate(true)
  const s2 = nb2._base.replicate(false)
  s1.pipe(s2).pipe(s1)

  // Let replication establish and nb2 learn it's a writer
  await new Promise(r => setTimeout(r, 1500))
  await nb2.update()
  await nb1.update()

  // Instance 1 creates a note
  const { noteId: id1 } = await nb1.createNote({ title: 'From Alice', author: 'alice' })

  // Wait for it to appear on instance 2
  const aliceNote = await waitFor(async () => {
    try {
      await nb2.update()
      return await nb2.getNote(id1)
    } catch { return null }
  })
  t.ok(aliceNote, 'Alice note synced to instance 2')
  t.is(aliceNote.title, 'From Alice')

  // Instance 2 creates a note
  const { noteId: id2 } = await nb2.createNote({ title: 'From Bob', author: 'bob' })

  // Wait for it to appear on instance 1
  const bobNote = await waitFor(async () => {
    try {
      await nb1.update()
      return await nb1.getNote(id2)
    } catch { return null }
  })
  t.ok(bobNote, 'Bob note synced to instance 1')
  t.is(bobNote.title, 'From Bob')

  // Both instances should see both notes
  await nb1.update()
  await nb2.update()
  const list1 = await nb1.listNotes()
  const list2 = await nb2.listNotes()
  t.is(list1.length, 2, 'instance 1 has 2 notes')
  t.is(list2.length, 2, 'instance 2 has 2 notes')

  // Cleanup
  s1.destroy()
  s2.destroy()
  await nb1.close()
  await nb2.close()
  await store1.close()
  await store2.close()
})

test('integration: MultiwriterManager shareNotebook creates autobase entry in registry', async (t) => {
  const store = createStore()
  const mgr = new MultiwriterManager(store)
  await mgr.ready()

  // Create notebook entry in registry
  await mgr.registry.createNotebook({ slug: 'project', title: 'Project', owner: 'u1' })

  // Share it — creates a SharedNotebook and updates registry
  const snb = await mgr.shareNotebook('project')
  t.ok(snb, 'SharedNotebook returned')
  t.ok(snb.bootstrapKey, 'has bootstrap key')

  // Verify registry was updated
  const nb = await mgr.registry.getNotebookBySlug('project')
  t.ok(nb, 'notebook in registry')
  t.is(nb.shared, true, 'marked shared')
  t.is(nb.autobaseKey, snb.bootstrapKey.toString('hex'), 'autobase key stored')

  // Verify status
  const status = mgr.getStatus()
  t.is(status.sharedNotebooks, 1)
  t.ok(status.sharedSlugs.includes('project'))

  await mgr.close()
  await store.close()
})