@simplepg/repo
Version:
SimplePage repository
233 lines (187 loc) • 9.74 kB
JavaScript
import { jest } from '@jest/globals'
import 'fake-indexeddb/auto'
import { IDBFactory } from 'fake-indexeddb'
import { globSource } from '@helia/unixfs'
import all from 'it-all'
import { fileURLToPath } from 'url'
import { join } from 'path'
import { JSDOM } from 'jsdom'
import { createPublicClient, createWalletClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { MockStorage, TestEnvironmentMultiNode } from '@simplepg/test-utils'
import { Repo } from '../src/repo.js'
const dom = new JSDOM()
global.DOMParser = dom.window.DOMParser
const YEAR_SECONDS = 365 * 24 * 60 * 60
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000001'
const FIXTURES_DIR = fileURLToPath(new URL('./__fixtures__', import.meta.url))
const encode = (value) => new TextEncoder().encode(value)
const decode = (value) => new TextDecoder().decode(value)
const resetIDB = () => {
global.indexedDB = new IDBFactory()
}
const loadFixtures = async (kuboApi, fixtureFolder) => {
const fixturePath = join(FIXTURES_DIR, fixtureFolder)
const glob = globSource(fixturePath, '**/*')
const entries = await all(glob)
const result = await all(await kuboApi.addAll(entries, { wrapWithDirectory: true }))
return result[result.length - 1].cid.toV1()
}
const commitAndFinalize = async ({ repo, domain, walletClient, client }) => {
const result = await repo.stage(domain, false)
const hash = await walletClient.writeContract(result.prepTx)
const receipt = await client.waitForTransactionReceipt({ hash })
await repo.finalizeCommit(result.cid)
return { cid: result.cid, receipt }
}
jest.setTimeout(180000)
describe('Repo Multi-node Integration Tests', () => {
let testEnv
let addresses
let client
let walletClient
let templateCid
let baseSiteCid
const openRepos = []
const createRepo = async ({ domain, apiEndpoint }) => {
const storage = new MockStorage()
const repo = new Repo(domain, storage, apiEndpoint ? { apiEndpoint } : {})
await repo.init(client, {
chainId: parseInt(testEnv.evm.chainId),
universalResolver: addresses.universalResolver,
})
openRepos.push(repo)
return repo
}
const waitForAllNodes = async (blockNumber = null) => {
const target = blockNumber ?? Number(testEnv.evm.getBlockNumber())
await testEnv.waitUntilBlockIsIndexed(target, { timeoutMs: 90_000 })
}
const setupDomain = async (domain) => {
testEnv.evm.mintPage(domain, YEAR_SECONDS, ZERO_ADDRESS)
testEnv.evm.setResolver(addresses.universalResolver, domain, addresses.resolver1)
testEnv.evm.setContenthash(addresses.resolver1, domain, baseSiteCid.toString())
await waitForAllNodes()
await testEnv.waitUntilCidIsServedByAll(baseSiteCid, { timeoutMs: 90_000 })
}
beforeAll(async () => {
testEnv = new TestEnvironmentMultiNode()
await testEnv.start({ nodeCount: 2 })
addresses = testEnv.addresses
templateCid = await loadFixtures(testEnv.nodes[0].kuboApi, 'new.simplepage.eth')
baseSiteCid = await loadFixtures(testEnv.nodes[0].kuboApi, 'test.eth')
testEnv.evm.mintPage('new.simplepage.eth', YEAR_SECONDS, ZERO_ADDRESS)
testEnv.setTemplateDserviceEndpoints()
testEnv.evm.setContenthash(addresses.resolver1, 'new.simplepage.eth', templateCid.toString())
await waitForAllNodes()
await testEnv.waitUntilCidIsServedByAll(templateCid, { timeoutMs: 90_000 })
await testEnv.waitUntilCidIsServedByAll(baseSiteCid, { timeoutMs: 90_000 })
client = createPublicClient({
transport: http(testEnv.evm.url)
})
walletClient = createWalletClient({
transport: http(testEnv.evm.url),
account: privateKeyToAccount(testEnv.evm.secretKey)
})
})
afterEach(async () => {
while (openRepos.length > 0) {
const repo = openRepos.pop()
await repo.close()
}
resetIDB()
})
afterAll(async () => {
await testEnv.stop()
})
it('syncs committed pages between node endpoints', async () => {
const domain = 'sync-pages.eth'
await setupDomain(domain)
const repoA = await createRepo({ domain, apiEndpoint: testEnv.nodes[0].dserviceUrl })
await repoA.setPageEdit('/', '# Node A Home', '<h1>Node A Home</h1>')
await repoA.setPageEdit('/docs/', '# Distributed Docs', '<h1>Distributed Docs</h1>')
const { cid, receipt } = await commitAndFinalize({ repo: repoA, domain, walletClient, client })
await waitForAllNodes(Number(receipt.blockNumber))
await testEnv.nodes[1].waitUntilCidIsServed(cid, { timeoutMs: 90_000 })
const repoB = await createRepo({ domain, apiEndpoint: testEnv.nodes[1].dserviceUrl })
expect(await repoB.getMarkdown('/')).toBe('# Node A Home')
expect(await repoB.getMarkdown('/docs/')).toBe('# Distributed Docs')
const pages = await repoB.getAllPages()
expect(pages).toContain('/')
expect(pages).toContain('/docs/')
})
it('syncs files and settings with reverse updates across nodes', async () => {
const domain = 'sync-files.eth'
await setupDomain(domain)
const repoA = await createRepo({ domain, apiEndpoint: testEnv.nodes[0].dserviceUrl })
await repoA.files.add('/assets/welcome.txt', encode('hello-from-node-a'))
await repoA.files.add('/assets/remove-me.txt', encode('delete-this'))
await repoA.settings.write({ theme: 'light', source: 'node-a' })
await repoA.setPageEdit('/to-delete/', '# Delete me', '<h1>Delete me</h1>')
const firstCommit = await commitAndFinalize({ repo: repoA, domain, walletClient, client })
await waitForAllNodes(Number(firstCommit.receipt.blockNumber))
await testEnv.nodes[1].waitUntilCidIsServed(firstCommit.cid, { timeoutMs: 90_000 })
const repoB = await createRepo({ domain, apiEndpoint: testEnv.nodes[1].dserviceUrl })
expect(decode(await repoB.files.cat('/assets/welcome.txt'))).toBe('hello-from-node-a')
expect(await repoB.settings.readProperty('source')).toBe('node-a')
await repoB.files.add('/assets/welcome.txt', encode('updated-from-node-b'))
await repoB.files.rm('/assets/remove-me.txt')
await repoB.settings.writeProperty('source', 'node-b')
await repoB.deletePage('/to-delete/')
const secondCommit = await commitAndFinalize({ repo: repoB, domain, walletClient, client })
await waitForAllNodes(Number(secondCommit.receipt.blockNumber))
await testEnv.nodes[0].waitUntilCidIsServed(secondCommit.cid, { timeoutMs: 90_000 })
const repoAReadBack = await createRepo({ domain, apiEndpoint: testEnv.nodes[0].dserviceUrl })
expect(decode(await repoAReadBack.files.cat('/assets/welcome.txt'))).toBe('updated-from-node-b')
await expect(repoAReadBack.files.cat('/assets/remove-me.txt')).rejects.toThrow()
await expect(repoAReadBack.getMarkdown('/to-delete/')).rejects.toThrow()
expect(await repoAReadBack.settings.readProperty('source')).toBe('node-b')
})
it('uses new.simplepage.eth text record to discover dservice endpoints', async () => {
const domain = 'txt-record-sync.eth'
await setupDomain(domain)
const repoViaTxtRecord = await createRepo({ domain })
expect(repoViaTxtRecord.dservice.dserviceEndpoints).toHaveLength(2)
expect(repoViaTxtRecord.dservice.dserviceEndpoints).toEqual(
expect.arrayContaining(testEnv.dserviceUrls)
)
await repoViaTxtRecord.setPageEdit('/from-txt/', '# TXT Record Sync', '<h1>TXT Record Sync</h1>')
const { cid, receipt } = await commitAndFinalize({
repo: repoViaTxtRecord,
domain,
walletClient,
client,
})
await waitForAllNodes(Number(receipt.blockNumber))
await testEnv.waitUntilCidIsServedByAll(cid, { timeoutMs: 90_000 })
const repoFromA = await createRepo({ domain, apiEndpoint: testEnv.nodes[0].dserviceUrl })
const repoFromB = await createRepo({ domain, apiEndpoint: testEnv.nodes[1].dserviceUrl })
expect(await repoFromA.getMarkdown('/from-txt/')).toBe('# TXT Record Sync')
expect(await repoFromB.getMarkdown('/from-txt/')).toBe('# TXT Record Sync')
})
it('syncs full history to a node that joins after earlier publishes', async () => {
const domain = 'late-history.eth'
await setupDomain(domain)
const repoA = await createRepo({ domain, apiEndpoint: testEnv.nodes[0].dserviceUrl })
await repoA.setPageEdit('/chapter-one/', '# Chapter One', '<h1>Chapter One</h1>')
const first = await commitAndFinalize({ repo: repoA, domain, walletClient, client })
await waitForAllNodes(Number(first.receipt.blockNumber))
await repoA.setPageEdit('/chapter-two/', '# Chapter Two', '<h1>Chapter Two</h1>')
await repoA.setPageEdit('/', '# Updated Home', '<h1>Updated Home</h1>')
const second = await commitAndFinalize({ repo: repoA, domain, walletClient, client })
await waitForAllNodes(Number(second.receipt.blockNumber))
const lateNode = await testEnv.startNode()
await lateNode.waitUntilBlockIsIndexed(Number(second.receipt.blockNumber), { timeoutMs: 120_000 })
await lateNode.waitUntilCidIsServed(first.cid, { timeoutMs: 120_000 })
await lateNode.waitUntilCidIsServed(second.cid, { timeoutMs: 120_000 })
const lateRepo = await createRepo({ domain, apiEndpoint: lateNode.dserviceUrl })
const history = await lateRepo.history.get()
const historyCids = history.map(entry => entry.cid.toString())
expect(historyCids).toContain(first.cid.toString())
expect(historyCids).toContain(second.cid.toString())
expect(await lateRepo.getMarkdown('/chapter-one/')).toBe('# Chapter One')
expect(await lateRepo.getMarkdown('/chapter-two/')).toBe('# Chapter Two')
const oldCidResponse = await fetch(`${lateNode.dserviceUrl}/page?cid=${encodeURIComponent(first.cid.toString())}`)
expect(oldCidResponse.ok).toBe(true)
})
})