@simplepg/repo
Version:
SimplePage repository
1,126 lines (886 loc) • 106 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 { createPublicClient, createWalletClient, http } from 'viem';
import { privateKeyToAccount } from 'viem/accounts'
import { join } from 'path';
import { CID } from 'multiformats/cid'
import { JSDOM } from 'jsdom'
import { resolveEnsDomain } from '@simplepg/common'
import { TestEnvironmentDservice } from '@simplepg/test-utils';
import { Repo } from '../src/repo.js';
import { CHANGE_TYPE } from '../src/constants.js';
// Mock DOMParser for Node.js environment
const dom = new JSDOM()
global.DOMParser = dom.window.DOMParser
const resetIDB = () => global.indexedDB = new IDBFactory()
const checkMeta = (doc, name, content) => {
const meta = doc.querySelector(`meta[name="${name}"]`)
expect(meta).toBeDefined()
expect(meta.content).toBe(content)
}
// Mock storage for testing
class MockStorage {
constructor() {
this.store = new Map();
}
getItem(key) {
return this.store.get(key) || null;
}
setItem(key, value) {
this.store.set(key, value);
}
removeItem(key) {
this.store.delete(key);
}
get length() {
return this.store.size;
}
key(index) {
return Array.from(this.store.keys())[index];
}
clear() {
this.store.clear();
}
}
const cat = async (kubo, path) => {
const content = await all(await kubo.cat(path))
return new TextDecoder().decode(content[0])
}
const ls = async (kubo, path) => {
const files = await all(await kubo.ls(path))
return files.map(file => file.name)
}
jest.setTimeout(10000);
describe('Repo Integration Tests', () => {
let testEnv;
let addresses;
let client;
let walletClient;
let storage;
let repo;
let templateCid;
let testDataCid;
let parser;
beforeAll(async () => {
testEnv = new TestEnvironmentDservice();
await testEnv.start();
addresses = testEnv.addresses;
parser = new DOMParser()
// Set up the resolver for new.simplepage.eth (template domain)
testEnv.evm.setResolver(addresses.universalResolver, 'new.simplepage.eth', addresses.resolver1);
testEnv.evm.setTextRecord(addresses.resolver1, 'new.simplepage.eth', 'dservice', testEnv.dserviceUrl);
// Set up a test domain
testEnv.evm.setResolver(addresses.universalResolver, 'test.eth', addresses.resolver1);
// Add some test content to IPFS for the template
async function loadFixtures(path) {
const glob = globSource(join(process.cwd(), path), '**/*')
const entries = await all(glob)
const result = await all(await testEnv.kubo.kuboApi.addAll(entries, { wrapWithDirectory: true }))
return result[result.length - 1].cid.toV1()
}
templateCid = await loadFixtures('./test/__fixtures__/new.simplepage.eth')
testDataCid = await loadFixtures('./test/__fixtures__/test.eth')
client = createPublicClient({
transport: http(testEnv.evm.url)
});
const account = privateKeyToAccount(testEnv.evm.secretKey)
walletClient = createWalletClient({
chain: testEnv.evm.chain,
transport: http(testEnv.evm.url),
account
});
});
afterAll(async () => {
await testEnv.stop();
});
beforeEach(async () => {
storage = new MockStorage();
repo = new Repo('test.eth', storage);
});
afterEach(async () => {
storage.clear();
await repo.close()
});
describe('Constructor and init', () => {
it('should construct Repo with domain and storage', () => {
expect(repo.domain).toBe('test.eth');
expect(repo.storage).toBe(storage);
expect(repo.dservice).toBeDefined();
});
it('should fail initialization if test.eth has no contenthash', async () => {
await expect(repo.init(client, {
chainId: parseInt(testEnv.evm.chainId),
universalResolver: addresses.universalResolver
})).rejects.toThrow('Repo root not found for test.eth');
});
it('should fail to initialize repo with viem client when test domain has no contenthash', async () => {
testEnv.evm.setContenthash(addresses.resolver1, 'new.simplepage.eth', templateCid.toString());
await expect(repo.init(client, {
chainId: parseInt(testEnv.evm.chainId),
universalResolver: addresses.universalResolver
})).rejects.toThrow('Repo root not found');
});
it('should initialize repo with viem client when test domain has contenthash', async () => {
testEnv.evm.setContenthash(addresses.resolver1, 'test.eth', testDataCid.toString());
await repo.init(client, {
chainId: parseInt(testEnv.evm.chainId),
universalResolver: addresses.universalResolver
});
expect(repo.viemClient).toBe(client);
expect(repo.chainId).toBe(parseInt(testEnv.evm.chainId));
expect(repo.universalResolver).toBe(addresses.universalResolver);
expect(repo.repoRoot).toBeDefined();
expect(repo.templateRoot).toBeDefined();
});
});
describe('Changes', () => {
beforeAll(async () => {
testEnv.evm.setContenthash(addresses.resolver1, 'new.simplepage.eth', templateCid.toString());
testEnv.evm.setContenthash(addresses.resolver1, 'test.eth', templateCid.toString());
});
beforeEach(async () => {
await repo.init(client, {
chainId: parseInt(testEnv.evm.chainId),
universalResolver: addresses.universalResolver
});
});
it('should get markdown without active edits', async () => {
const markdown = await repo.getMarkdown('/');
expect(markdown).toBeDefined();
expect(typeof markdown).toBe('string');
});
it('should get markdown with active page edits', async () => {
const testMarkdown = '# Test Page\n\nThis is a test.';
const testBody = '<h1>Test Page</h1><p>This is a test.</p>';
await repo.setPageEdit('/', testMarkdown, testBody);
const markdown = await repo.getMarkdown('/');
expect(markdown).toBe(testMarkdown);
});
it('should get HTML body without active edits', async () => {
const htmlBody = await repo.getHtmlBody('/');
expect(htmlBody).toBeDefined();
expect(typeof htmlBody).toBe('string');
});
it('should get HTML body with active edits', async () => {
const testMarkdown = '# Test Page\n\nThis is a test.';
const testBody = '<h1>Test Page</h1><p>This is a test.</p>';
await repo.setPageEdit('/', testMarkdown, testBody);
const htmlBody = await repo.getHtmlBody('/');
expect(htmlBody).toBe(testBody);
});
it('should get HTML body ignoring edits when requested', async () => {
const testMarkdown = '# Test Page\n\nThis is a test.';
const testBody = '<h1>Test Page</h1><p>This is a test.</p>';
await repo.setPageEdit('/', testMarkdown, testBody);
const htmlBody = await repo.getHtmlBody('/', true);
expect(htmlBody).not.toBe(testBody);
expect(htmlBody).toBeDefined();
});
it('should set page edits correctly', async () => {
const testMarkdown = '# Test Page\n\nThis is a test.';
const testBody = '<h1>Test Page</h1><p>This is a test.</p>';
await repo.setPageEdit('/', testMarkdown, testBody);
const storedData = storage.getItem('spg_edit_/');
expect(storedData).toBeDefined();
const parsedData = JSON.parse(storedData);
expect(parsedData.markdown).toBe(testMarkdown);
expect(parsedData.body).toBe(testBody);
expect(parsedData.root).toBe(repo.repoRoot.cid.toString());
});
it('should list staged edits', async () => {
const testMarkdown = '# Test Page\n\nThis is a test.';
const testBody = '<h1>Test Page</h1><p>This is a test.</p>';
await repo.setPageEdit('/', testMarkdown, testBody);
await repo.setPageEdit('/about/', '# About\n\nAbout page content.', '<h1>About</h1><p>About page content.</p>');
const changes = await repo.getChanges();
const paths = changes.map(change => change.path);
expect(paths).toContain('/');
expect(paths).toContain('/about/');
expect(changes.length).toBe(2);
expect(changes[0].type).toBe('edit');
expect(changes[1].type).toBe('new');
});
it('should validate path format in setPageEdit', async () => {
const testMarkdown = '# Test Page\n\nThis is a test.';
const testBody = '<h1>Test Page</h1><p>This is a test.</p>';
// Should throw for invalid paths
await expect(repo.setPageEdit('invalid', testMarkdown, testBody)).rejects.toThrow('Path must start with /');
await expect(repo.setPageEdit('/invalid', testMarkdown, testBody)).rejects.toThrow('Path must end with /');
// Should work for valid paths
await expect(repo.setPageEdit('/', testMarkdown, testBody)).resolves.not.toThrow();
await expect(repo.setPageEdit('/about/', testMarkdown, testBody)).resolves.not.toThrow();
});
});
describe('Version Management', () => {
beforeAll(async () => {
testEnv.evm.setContenthash(addresses.resolver1, 'new.simplepage.eth', templateCid.toString());
});
it('should check if new version is available', async () => {
testEnv.evm.setContenthash(addresses.resolver1, 'test.eth', templateCid.toString());
await repo.init(client, {
chainId: parseInt(testEnv.evm.chainId),
universalResolver: addresses.universalResolver
});
const versionInfo = await repo.isNewVersionAvailable();
expect(versionInfo).toHaveProperty('templateVersion');
expect(versionInfo).toHaveProperty('currentVersion');
expect(versionInfo).toHaveProperty('canUpdate');
expect(typeof versionInfo.templateVersion).toBe('string');
expect(typeof versionInfo.currentVersion).toBe('string');
expect(versionInfo.canUpdate).toBe(false);
});
it('should detect when template version is newer', async () => {
testEnv.evm.setContenthash(addresses.resolver1, 'test.eth', testDataCid.toString());
await repo.init(client, {
chainId: parseInt(testEnv.evm.chainId),
universalResolver: addresses.universalResolver
});
const versionInfo = await repo.isNewVersionAvailable();
expect(versionInfo.canUpdate).toBe(true);
});
it('should persist pages across version updates', async () => {
// Start with test.eth using testDataCid
testEnv.evm.setContenthash(addresses.resolver1, 'test.eth', testDataCid.toString());
await repo.init(client, {
chainId: parseInt(testEnv.evm.chainId),
universalResolver: addresses.universalResolver
});
// Add a few pages and commit without version update
const pages = {
about: {
path: '/about/',
markdown: '# About\n\nAbout page content.',
body: '<h1>About</h1><p>About page content.</p>'
},
blog: {
path: '/blog/',
markdown: '# Blog\n\nBlog index page.',
body: '<h1>Blog</h1><p>Blog index page.</p>'
},
contact: {
path: '/contact/',
markdown: '# Contact\n\nContact information.',
body: '<h1>Contact</h1><p>Contact information.</p>'
}
};
// Set page edits
for (const page of Object.values(pages)) {
await repo.setPageEdit(page.path, page.markdown, page.body);
}
// Stage and commit without version update
const firstResult = await repo.stage('test.eth', false);
expect(firstResult).toHaveProperty('cid');
expect(firstResult.cid instanceof CID).toBe(true);
// Verify all pages are staged correctly
for (const page of Object.values(pages)) {
const markdown = await cat(testEnv.kubo.kuboApi, `/ipfs/${firstResult.cid.toString()}${page.path}index.md`);
expect(markdown).toBe(page.markdown);
const html = await cat(testEnv.kubo.kuboApi, `/ipfs/${firstResult.cid.toString()}${page.path}index.html`);
expect(html).toContain(page.body);
}
// Commit the first version
await repo.finalizeCommit(firstResult.cid);
// Edit the root page and commit with version update
const updatedRootMarkdown = '# Updated Home Page\n\nThis is the updated home page content.';
const updatedRootBody = '<h1>Updated Home Page</h1><p>This is the updated home page content.</p>';
await repo.setPageEdit('/', updatedRootMarkdown, updatedRootBody);
const secondResult = await repo.stage('test.eth', true);
expect(secondResult).toHaveProperty('cid');
expect(secondResult.cid instanceof CID).toBe(true);
// Verify the root page is updated
const updatedRootMarkdownContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/index.md`);
expect(updatedRootMarkdownContent).toBe(updatedRootMarkdown);
const updatedRootHtmlContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/index.html`);
expect(updatedRootHtmlContent).toContain(updatedRootBody);
// Verify that pages added before are still there
for (const page of Object.values(pages)) {
const markdown = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}${page.path}index.md`);
expect(markdown).toBe(page.markdown);
const html = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}${page.path}index.html`);
expect(html).toContain(page.body);
}
// Verify version is updated in the template
const templateContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/_template.html`);
expect(templateContent).toBeDefined();
const templateDoc = parser.parseFromString(templateContent, 'text/html');
checkMeta(templateDoc, 'version', '0.5.0');
// Verify _assets and _js folders are updated
const assetsContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/_assets`);
expect(assetsContent).toBe('folder-updated');
const jsContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/_js`);
expect(jsContent).toBe('folder-updated');
// Commit the second version
const hash2 = await walletClient.writeContract(secondResult.prepTx);
expect(hash2).toBeDefined();
const transaction2 = await client.waitForTransactionReceipt({ hash: hash2 });
expect(transaction2.status).toBe('success');
// Verify the final state through ENS resolution
const { cid: finalRoot } = await resolveEnsDomain(client, 'test.eth', addresses.universalResolver);
expect(finalRoot.toString()).toBe(secondResult.cid.toString());
});
});
describe('Staging and Finalization', () => {
beforeAll(async () => {
testEnv.evm.setContenthash(addresses.resolver1, 'new.simplepage.eth', templateCid.toString());
});
beforeEach(async () => {
testEnv.evm.setContenthash(addresses.resolver1, 'test.eth', testDataCid.toString());
await repo.init(client, {
chainId: parseInt(testEnv.evm.chainId),
universalResolver: addresses.universalResolver
});
});
it('should stage and commit changes without template update', async () => {
const testMarkdown = '# Test Page\n\nThis is a test.';
const testBody = '<h1>Test Page</h1><p>This is a test.</p>';
await repo.setPageEdit('/', testMarkdown, testBody);
const result = await repo.stage('test.eth', false);
expect(result).toHaveProperty('cid');
expect(result).toHaveProperty('prepTx');
expect(result.cid instanceof CID).toBe(true);
expect(result.prepTx).toHaveProperty('address');
expect(result.prepTx).toHaveProperty('functionName');
expect(result.prepTx.functionName).toBe('setContenthash');
// verify the markdown content
const markdown = await cat(testEnv.kubo.kuboApi, '/ipfs/' + result.cid.toString() + '/index.md')
expect(markdown).toBe(testMarkdown)
// verify the html content
const html = await cat(testEnv.kubo.kuboApi, '/ipfs/' + result.cid.toString() + '/index.html')
// verify the testBody is inside the root div of the html
expect(html).toContain(testBody)
expect(html).toMatch(/^<!DOCTYPE html>?/)
expect(html).toContain('<title>test.eth</title>')
// verify meta tags: version, ens-domain, description
const doc = parser.parseFromString(html, 'text/html')
checkMeta(doc, 'version', '0.4.0')
checkMeta(doc, 'ens-domain', 'test.eth')
checkMeta(doc, 'description', 'A SimplePage by test.eth')
// verify favicon path
const favicon = doc.querySelector('link[rel="icon"]')
expect(favicon).toBeDefined()
expect(favicon.href).toBe('/_assets/images/favicon.ico')
// verify the repo root is updated
// uses viemClient to submit the prepTx
const hash = await walletClient.writeContract(result.prepTx)
expect(hash).toBeDefined()
const transaction = await client.waitForTransactionReceipt({ hash })
expect(transaction).toBeDefined()
expect(transaction.status).toBe('success')
const { cid: updatedRoot } = await resolveEnsDomain(client, 'test.eth', addresses.universalResolver)
// verify the repo root is updated
expect(updatedRoot.toString()).toBe(result.cid.toString())
// verify the _assets folders are not updated
const assetsContent = await cat(testEnv.kubo.kuboApi, '/ipfs/' + result.cid.toString() + '/_assets')
expect(assetsContent).toBe('folder-not-updated')
const jsContent = await cat(testEnv.kubo.kuboApi, '/ipfs/' + result.cid.toString() + '/_js')
expect(jsContent).toBe('folder-not-updated')
const templateContent = await cat(testEnv.kubo.kuboApi, '/ipfs/' + result.cid.toString() + '/_template.html')
expect(templateContent).toBeDefined()
const templateDoc = parser.parseFromString(templateContent, 'text/html')
checkMeta(templateDoc, 'version', '0.4.0')
});
it('should stage and commit changes with template update', async () => {
const testMarkdown = '# Test Page\n\nThis is a test.';
const testBody = '<h1>Test Page</h1><p>This is a test.</p>';
await repo.setPageEdit('/', testMarkdown, testBody);
const result = await repo.stage('test.eth', true);
expect(result).toHaveProperty('cid');
expect(result).toHaveProperty('prepTx');
expect(result.cid instanceof CID).toBe(true);
expect(result.prepTx).toHaveProperty('address');
expect(result.prepTx).toHaveProperty('functionName');
expect(result.prepTx.functionName).toBe('setContenthash');
// verify the markdown content
const markdown = await cat(testEnv.kubo.kuboApi, '/ipfs/' + result.cid.toString() + '/index.md')
expect(markdown).toBe(testMarkdown)
// verify the html content
const html = await cat(testEnv.kubo.kuboApi, '/ipfs/' + result.cid.toString() + '/index.html')
// verify the testBody is inside the root div of the html
expect(html).toContain(testBody)
expect(html).toContain('<title>test.eth</title>')
// verify meta tags: version, ens-domain, description
const doc = parser.parseFromString(html, 'text/html')
checkMeta(doc, 'version', '0.5.0')
// verify the _assets folders are updated
const assetsContent = await cat(testEnv.kubo.kuboApi, '/ipfs/' + result.cid.toString() + '/_assets')
expect(assetsContent).toBe('folder-updated')
const jsContent = await cat(testEnv.kubo.kuboApi, '/ipfs/' + result.cid.toString() + '/_js')
expect(jsContent).toBe('folder-updated')
const templateContent = await cat(testEnv.kubo.kuboApi, '/ipfs/' + result.cid.toString() + '/_template.html')
expect(templateContent).toBeDefined()
const templateDoc = parser.parseFromString(templateContent, 'text/html')
checkMeta(templateDoc, 'version', '0.5.0')
// verify the repo root is updated
// uses viemClient to submit the prepTx
const hash = await walletClient.writeContract(result.prepTx)
expect(hash).toBeDefined()
const transaction = await client.waitForTransactionReceipt({ hash })
expect(transaction).toBeDefined()
expect(transaction.status).toBe('success')
const { cid: updatedRoot } = await resolveEnsDomain(client, 'test.eth', addresses.universalResolver)
// verify the repo root is updated
expect(updatedRoot.toString()).toBe(result.cid.toString())
});
it('should properly populate template with title and description in markdown preamble', async () => {
const testMarkdown = `---
title: My Custom Title
description: This is a custom description for the page
---
# Test Page
This is a test page with custom title and description.`;
const testBody = '<h1>Test Page</h1><p>This is a test page with custom title and description.</p>';
await repo.setPageEdit('/', testMarkdown, testBody);
const result = await repo.stage('test.eth', false);
expect(result).toHaveProperty('cid');
expect(result).toHaveProperty('prepTx');
expect(result.cid instanceof CID).toBe(true);
// verify the markdown content is preserved
const markdown = await cat(testEnv.kubo.kuboApi, '/ipfs/' + result.cid.toString() + '/index.md')
expect(markdown).toBe(testMarkdown)
// verify the html content
const html = await cat(testEnv.kubo.kuboApi, '/ipfs/' + result.cid.toString() + '/index.html')
// verify the testBody is inside the root div of the html
expect(html).toContain(testBody)
// verify meta tags are populated with custom title and description
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
// Check that title is set to custom title from frontmatter
const titleElement = doc.querySelector('title')
expect(titleElement).toBeDefined()
expect(titleElement.textContent).toBe('My Custom Title')
checkMeta(doc, 'version', '0.4.0')
checkMeta(doc, 'ens-domain', 'test.eth')
checkMeta(doc, 'description', 'This is a custom description for the page')
// Check Open Graph and Twitter meta tags
const ogTitle = doc.querySelector('meta[property="og:title"]')
expect(ogTitle).toBeDefined()
expect(ogTitle.content).toBe('My Custom Title')
const ogSiteName = doc.querySelector('meta[property="og:site_name"]')
expect(ogSiteName).toBeDefined()
expect(ogSiteName.content).toBe('test.eth')
const ogDescription = doc.querySelector('meta[property="og:description"]')
expect(ogDescription).toBeDefined()
expect(ogDescription.content).toBe('This is a custom description for the page')
const twitterTitle = doc.querySelector('meta[name="twitter:title"]')
expect(twitterTitle).toBeDefined()
expect(twitterTitle.content).toBe('My Custom Title')
const twitterDescription = doc.querySelector('meta[name="twitter:description"]')
expect(twitterDescription).toBeDefined()
expect(twitterDescription.content).toBe('This is a custom description for the page')
// verify favicon path
const favicon = doc.querySelector('link[rel="icon"]')
expect(favicon).toBeDefined()
expect(favicon.href).toBe('/_assets/images/favicon.ico')
// verify the repo root is updated
const hash = await walletClient.writeContract(result.prepTx)
expect(hash).toBeDefined()
const transaction = await client.waitForTransactionReceipt({ hash })
expect(transaction).toBeDefined()
expect(transaction.status).toBe('success')
const { cid: updatedRoot } = await resolveEnsDomain(client, 'test.eth', addresses.universalResolver)
expect(updatedRoot.toString()).toBe(result.cid.toString())
});
it('should stage and commit changes (multiple edits)', async () => {
const pages = {
root: {
path: '/',
markdown: '# Test Page\n\nThis is a test.',
body: '<h1>Test Page</h1><p>This is a test.</p>'
},
about: {
path: '/about/',
markdown: '# About\n\nAbout page content.',
body: '<h1>About</h1><p>About page content.</p>'
},
blog: {
path: '/blog/',
markdown: '# Blog\n\nBlog index page.',
body: '<h1>Blog</h1><p>Blog index page.</p>'
},
blogOne: {
path: '/blog/one/',
markdown: '# Blog Post One\n\nFirst blog post.',
body: '<h1>Blog Post One</h1><p>First blog post.</p>'
},
blogTwo: {
path: '/blog/two/',
markdown: '# Blog Post Two\n\nSecond blog post.',
body: '<h1>Blog Post Two</h1><p>Second blog post.</p>'
}
};
// Set page edits
for (const page of Object.values(pages)) {
await repo.setPageEdit(page.path, page.markdown, page.body);
}
// Verify edits exist
let changes = await repo.getChanges();
const paths = changes.map(change => change.path);
expect(changes.length).toBe(5);
expect(paths).toContain('/');
expect(paths).toContain('/about/');
expect(paths).toContain('/blog/');
expect(paths).toContain('/blog/one/');
expect(paths).toContain('/blog/two/');
// Stage and finalize
const result = await repo.stage('test.eth', false);
expect(result).toHaveProperty('cid');
expect(result).toHaveProperty('prepTx');
expect(result.cid instanceof CID).toBe(true);
// Verify all pages are staged correctly
for (const page of Object.values(pages)) {
const markdown = await cat(testEnv.kubo.kuboApi, `/ipfs/${result.cid.toString()}${page.path}index.md`);
expect(markdown).toBe(page.markdown);
const html = await cat(testEnv.kubo.kuboApi, `/ipfs/${result.cid.toString()}${page.path}index.html`);
expect(html).toContain(page.body);
const doc = parser.parseFromString(html, 'text/html');
const favicon = doc.querySelector('link[rel="icon"]');
expect(favicon).toBeDefined();
expect(favicon.href).toBe('/_assets/images/favicon.ico');
}
});
it('should use _prev directory as the root for the previous version', async () => {
// First update: stage changes without template update
const firstMarkdown = '# First Version\n\nThis is the first version.';
const firstBody = '<h1>First Version</h1><p>This is the first version.</p>';
await repo.setPageEdit('/', firstMarkdown, firstBody);
const firstResult = await repo.stage('test.eth', false);
expect(firstResult).toHaveProperty('cid');
expect(firstResult.cid instanceof CID).toBe(true);
await repo.finalizeCommit(firstResult.cid);
// Verify first version content
const firstMarkdownContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${firstResult.cid.toString()}/index.md`);
expect(firstMarkdownContent).toBe(firstMarkdown);
const firstHtmlContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${firstResult.cid.toString()}/index.html`);
expect(firstHtmlContent).toContain(firstBody);
const firstTemplateMdContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${firstResult.cid.toString()}/_template.html`);
// verify the template (testDataCid) content is in the _prev/0 directory
const actualTemplateMdContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${testDataCid.toString()}/_template.html`);
const prevTemplateMdContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${firstResult.cid.toString()}/_prev/0/_template.html`);
expect(actualTemplateMdContent).toBe(prevTemplateMdContent);
expect(actualTemplateMdContent).toBe(firstTemplateMdContent);
const actualIndexMdContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${testDataCid.toString()}/index.md`);
const prevIndexMdContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${firstResult.cid.toString()}/_prev/0/index.md`);
expect(actualIndexMdContent).toBe(prevIndexMdContent);
const actualIndexHtmlContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${testDataCid.toString()}/index.html`);
const prevIndexHtmlContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${firstResult.cid.toString()}/_prev/0/index.html`);
expect(actualIndexHtmlContent).toBe(prevIndexHtmlContent);
// Second update: stage changes with template update
const secondMarkdown = '# Second Version\n\nThis is the second version with template update.';
const secondBody = '<h1>Second Version</h1><p>This is the second version with template update.</p>';
await repo.setPageEdit('/', secondMarkdown, secondBody);
const secondResult = await repo.stage('test.eth', true);
expect(secondResult).toHaveProperty('cid');
expect(secondResult.cid instanceof CID).toBe(true);
await repo.finalizeCommit(secondResult.cid);
// Verify second version content
const secondMarkdownContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/index.md`);
expect(secondMarkdownContent).toBe(secondMarkdown);
const secondHtmlContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/index.html`);
expect(secondHtmlContent).toContain(secondBody);
const secondHtmlDoc = parser.parseFromString(secondHtmlContent, 'text/html');
checkMeta(secondHtmlDoc, 'version', '0.5.0');
// verify the template (testDataCid) content is in the _prev/0/_prev/0 directory
const prevPrevTemplateMdContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/_prev/0/_prev/0/_template.html`);
expect(actualTemplateMdContent).toBe(prevPrevTemplateMdContent);
const prevPrevIndexMdContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/_prev/0/_prev/0/index.md`);
expect(actualIndexMdContent).toBe(prevPrevIndexMdContent);
const prevPrevIndexHtmlContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/_prev/0/_prev/0/index.html`);
expect(actualIndexHtmlContent).toBe(prevPrevIndexHtmlContent);
const prevPrevHtmlDoc = parser.parseFromString(prevPrevIndexHtmlContent, 'text/html');
checkMeta(prevPrevHtmlDoc, 'version', '0.4.0');
// verify the previous version of the app is in the _prev/0 directory
const prevSecondResultTemplateMdContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/_prev/0/_template.html`);
expect(prevSecondResultTemplateMdContent).toBe(firstTemplateMdContent);
const prevSecondResultMarkdownContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/_prev/0/index.md`);
expect(prevSecondResultMarkdownContent).toBe(firstMarkdownContent);
expect(prevSecondResultMarkdownContent).toBe(firstMarkdown);
const prevSecondResultHtmlContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/_prev/0/index.html`);
expect(prevSecondResultHtmlContent).toBe(firstHtmlContent);
expect(prevSecondResultHtmlContent).toContain(firstBody);
const prevSecondResultHtmlDoc = parser.parseFromString(prevSecondResultHtmlContent, 'text/html');
checkMeta(prevSecondResultHtmlDoc, 'version', '0.4.0');
// Verify _assets are updated in second version
const secondAssetsContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/_assets`);
expect(secondAssetsContent).toBe('folder-updated');
const prev0AssetsContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/_prev/0/_assets`);
expect(prev0AssetsContent).toBe('folder-not-updated');
const prevPrevPrevAssetsContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/_prev/0/_prev/0/_assets`);
expect(prevPrevPrevAssetsContent).toBe('folder-not-updated');
// Verify _js are updated in second version
const secondJsContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/_js`);
expect(secondJsContent).toBe('folder-updated');
const prev0JsContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/_prev/0/_js`);
expect(prev0JsContent).toBe('folder-not-updated');
const prevPrevPrevJsContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/_prev/0/_prev/0/_js`);
expect(prevPrevPrevJsContent).toBe('folder-not-updated');
})
it('should handle page deletion correctly', async () => {
// Test 1: deleting page at '/' shouldn't work
await expect(repo.deletePage('/')).rejects.toThrow('Cannot delete root page');
// Test 2: delete a page that only exists as an edit
const testMarkdown = '# Test Page\n\nThis is a test.';
const testBody = '<h1>Test Page</h1><p>This is a test.</p>';
await repo.setPageEdit('/about/', testMarkdown, testBody);
await repo.setPageEdit('/blog/', '# Blog\n\nBlog content.', '<h1>Blog</h1><p>Blog content.</p>');
await repo.setPageEdit('/contact/', '# Contact\n\nContact info.', '<h1>Contact</h1><p>Contact info.</p>');
// Verify edits exist
let changes = await repo.getChanges();
expect(changes.length).toBe(3);
expect(changes.map(c => c.path)).toContain('/about/');
expect(changes.map(c => c.path)).toContain('/blog/');
expect(changes.map(c => c.path)).toContain('/contact/');
// Delete a page that only exists as an edit
await repo.deletePage('/contact/');
// Verify the edit was removed
changes = await repo.getChanges();
expect(changes.length).toBe(2);
expect(changes.map(c => c.path)).toContain('/about/');
expect(changes.map(c => c.path)).toContain('/blog/');
expect(changes.map(c => c.path)).not.toContain('/contact/');
// Stage and commit the new pages
const firstResult = await repo.stage('test.eth', false);
expect(firstResult).toHaveProperty('cid');
expect(firstResult.cid instanceof CID).toBe(true);
// Verify the pages are staged correctly
const aboutMarkdown = await cat(testEnv.kubo.kuboApi, `/ipfs/${firstResult.cid.toString()}/about/index.md`);
expect(aboutMarkdown).toBe(testMarkdown);
const blogMarkdown = await cat(testEnv.kubo.kuboApi, `/ipfs/${firstResult.cid.toString()}/blog/index.md`);
expect(blogMarkdown).toBe('# Blog\n\nBlog content.');
// Verify contact page doesn't exist
await expect(cat(testEnv.kubo.kuboApi, `/ipfs/${firstResult.cid.toString()}/contact/index.md`)).rejects.toThrow();
// Commit the first version
const hash1 = await walletClient.writeContract(firstResult.prepTx);
expect(hash1).toBeDefined();
const transaction1 = await client.waitForTransactionReceipt({ hash: hash1 });
expect(transaction1.status).toBe('success');
await repo.finalizeCommit(firstResult.cid);
// Delete one of the newly committed pages
await repo.deletePage('/about/');
// Verify the deletion is staged
changes = await repo.getChanges();
expect(changes.length).toBe(1);
expect(changes[0].type).toBe('delete');
expect(changes[0].path).toBe('/about/');
// Stage and commit the deletion
const secondResult = await repo.stage('test.eth', false);
expect(secondResult).toHaveProperty('cid');
expect(secondResult.cid instanceof CID).toBe(true);
// Verify the about page is deleted
await expect(cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/about/index.md`)).rejects.toThrow();
// Verify the blog page still exists
const blogMarkdownAfterDelete = await cat(testEnv.kubo.kuboApi, `/ipfs/${secondResult.cid.toString()}/blog/index.md`);
expect(blogMarkdownAfterDelete).toBe('# Blog\n\nBlog content.');
// Commit the second version
const hash2 = await walletClient.writeContract(secondResult.prepTx);
expect(hash2).toBeDefined();
const transaction2 = await client.waitForTransactionReceipt({ hash: hash2 });
expect(transaction2.status).toBe('success');
// Verify the final state through ENS resolution
const { cid: finalRoot } = await resolveEnsDomain(client, 'test.eth', addresses.universalResolver);
expect(finalRoot.toString()).toBe(secondResult.cid.toString());
});
it('should handle restorePage functionality', async () => {
// Create initial pages
await repo.setPageEdit('/about/', '# About\n\nAbout content.', '<h1>About</h1><p>About content.</p>');
await repo.setPageEdit('/blog/', '# Blog\n\nBlog content.', '<h1>Blog</h1><p>Blog content.</p>');
// Stage and commit initial pages
const firstResult = await repo.stage('test.eth', false);
const hash1 = await walletClient.writeContract(firstResult.prepTx);
await client.waitForTransactionReceipt({ hash: hash1 });
await repo.finalizeCommit(firstResult.cid);
// Delete a page
await repo.deletePage('/about/');
// Verify deletion is staged
let changes = await repo.getChanges();
expect(changes.length).toBe(1);
expect(changes[0].type).toBe('delete');
expect(changes[0].path).toBe('/about/');
// Restore the deleted page
await repo.restorePage('/about/');
// Verify the deletion is removed from changes
changes = await repo.getChanges();
expect(changes.length).toBe(0);
});
it('should handle deletion with multiple directory depths', async () => {
// Create a nested structure
await repo.setPageEdit('/docs/', '# Documentation\n\nMain docs.', '<h1>Documentation</h1><p>Main docs.</p>');
await repo.setPageEdit('/docs/guides/', '# Guides\n\nUser guides.', '<h1>Guides</h1><p>User guides.</p>');
await repo.setPageEdit('/docs/guides/getting-started/', '# Getting Started\n\nGetting started guide.', '<h1>Getting Started</h1><p>Getting started guide.</p>');
await repo.setPageEdit('/docs/guides/advanced/', '# Advanced\n\nAdvanced guide.', '<h1>Advanced</h1><p>Advanced guide.</p>');
await repo.setPageEdit('/docs-v2/api/', '# API Docs\n\nAPI documentation.', '<h1>API Docs</h1><p>API documentation.</p>');
// Stage and commit the initial structure
const initialResult = await repo.stage('test.eth', false);
expect(initialResult).toHaveProperty('cid');
expect(initialResult.cid instanceof CID).toBe(true);
// Verify all pages are staged correctly
const docsMarkdown = await cat(testEnv.kubo.kuboApi, `/ipfs/${initialResult.cid.toString()}/docs/index.md`);
expect(docsMarkdown).toBe('# Documentation\n\nMain docs.');
const guidesMarkdown = await cat(testEnv.kubo.kuboApi, `/ipfs/${initialResult.cid.toString()}/docs/guides/index.md`);
expect(guidesMarkdown).toBe('# Guides\n\nUser guides.');
const gettingStartedMarkdown = await cat(testEnv.kubo.kuboApi, `/ipfs/${initialResult.cid.toString()}/docs/guides/getting-started/index.md`);
expect(gettingStartedMarkdown).toBe('# Getting Started\n\nGetting started guide.');
const advancedMarkdown = await cat(testEnv.kubo.kuboApi, `/ipfs/${initialResult.cid.toString()}/docs/guides/advanced/index.md`);
expect(advancedMarkdown).toBe('# Advanced\n\nAdvanced guide.');
const apiMarkdown = await cat(testEnv.kubo.kuboApi, `/ipfs/${initialResult.cid.toString()}/docs-v2/api/index.md`);
expect(apiMarkdown).toBe('# API Docs\n\nAPI documentation.');
// Commit the initial version
const hash1 = await walletClient.writeContract(initialResult.prepTx);
expect(hash1).toBeDefined();
const transaction1 = await client.waitForTransactionReceipt({ hash: hash1 });
expect(transaction1.status).toBe('success');
await repo.finalizeCommit(initialResult.cid);
// Test 1: Delete /docs-v2/api/ - should remove api page and empty parent directory
await repo.deletePage('/docs-v2/api/');
let changes = await repo.getChanges();
expect(changes.length).toBe(1);
expect(changes[0].type).toBe('delete');
expect(changes[0].path).toBe('/docs-v2/api/');
const deleteApiResult = await repo.stage('test.eth', false);
expect(deleteApiResult).toHaveProperty('cid');
expect(deleteApiResult.cid instanceof CID).toBe(true);
// Verify api page is deleted
await expect(cat(testEnv.kubo.kuboApi, `/ipfs/${deleteApiResult.cid.toString()}/docs-v2/api/index.md`)).rejects.toThrow();
// Verify docs-v2 directory is also removed (empty parent directory)
expect(await ls(testEnv.kubo.kuboApi, `/ipfs/${deleteApiResult.cid.toString()}/`)).toBeDefined();
// await expect(ls(testEnv.kubo.kuboApi, `/ipfs/${deleteApiResult.cid.toString()}/docs-v2/api/not-exists`)).rejects.toThrow();
await expect(ls(testEnv.kubo.kuboApi, `/ipfs/${deleteApiResult.cid.toString()}/docs-v2/api`)).rejects.toThrow();
await expect(ls(testEnv.kubo.kuboApi, `/ipfs/${deleteApiResult.cid.toString()}/docs-v2`)).rejects.toThrow();
// Verify other pages still exist
const docsMarkdownAfterApiDelete = await cat(testEnv.kubo.kuboApi, `/ipfs/${deleteApiResult.cid.toString()}/docs/index.md`);
expect(docsMarkdownAfterApiDelete).toBe('# Documentation\n\nMain docs.');
const guidesMarkdownAfterApiDelete = await cat(testEnv.kubo.kuboApi, `/ipfs/${deleteApiResult.cid.toString()}/docs/guides/index.md`);
expect(guidesMarkdownAfterApiDelete).toBe('# Guides\n\nUser guides.');
// // Commit the api deletion
await repo.finalizeCommit(deleteApiResult.cid);
let pages = await repo.getAllPages();
expect(pages.length).toBe(5);
expect(pages).toContain('/');
expect(pages).toContain('/docs/');
expect(pages).toContain('/docs/guides/');
expect(pages).toContain('/docs/guides/getting-started/');
expect(pages).toContain('/docs/guides/advanced/');
expect(pages).not.toContain('/docs-v2/api/');
// Test 2: Delete /docs/guides/ - should remove guides content but keep children
await repo.deletePage('/docs/guides/');
changes = await repo.getChanges();
expect(changes.length).toBe(1);
expect(changes[0].type).toBe('delete');
expect(changes[0].path).toBe('/docs/guides/');
const deleteGuidesResult = await repo.stage('test.eth', false);
expect(deleteGuidesResult).toHaveProperty('cid');
expect(deleteGuidesResult.cid instanceof CID).toBe(true);
// Verify guides index page is deleted
await expect(cat(testEnv.kubo.kuboApi, `/ipfs/${deleteGuidesResult.cid.toString()}/docs/guides/index.md`)).rejects.toThrow();
await expect(cat(testEnv.kubo.kuboApi, `/ipfs/${deleteGuidesResult.cid.toString()}/docs/guides/index.html`)).rejects.toThrow();
// Verify children pages still exist
const gettingStartedMarkdownAfterGuidesDelete = await cat(testEnv.kubo.kuboApi, `/ipfs/${deleteGuidesResult.cid.toString()}/docs/guides/getting-started/index.md`);
expect(gettingStartedMarkdownAfterGuidesDelete).toBe('# Getting Started\n\nGetting started guide.');
const advancedMarkdownAfterGuidesDelete = await cat(testEnv.kubo.kuboApi, `/ipfs/${deleteGuidesResult.cid.toString()}/docs/guides/advanced/index.md`);
expect(advancedMarkdownAfterGuidesDelete).toBe('# Advanced\n\nAdvanced guide.');
// Verify docs page still exists
const docsMarkdownAfterGuidesDelete = await cat(testEnv.kubo.kuboApi, `/ipfs/${deleteGuidesResult.cid.toString()}/docs/index.md`);
expect(docsMarkdownAfterGuidesDelete).toBe('# Documentation\n\nMain docs.');
// Commit the guides deletion
await repo.finalizeCommit(deleteGuidesResult.cid);
pages = await repo.getAllPages();
expect(pages.length).toBe(4);
expect(pages).toContain('/');
expect(pages).toContain('/docs/');
expect(pages).toContain('/docs/guides/getting-started/');
expect(pages).toContain('/docs/guides/advanced/');
// Test 3: Delete /docs/guides/advanced/ - should remove only the specified page
await repo.deletePage('/docs/guides/advanced/');
changes = await repo.getChanges();
expect(changes.length).toBe(1);
expect(changes[0].type).toBe('delete');
expect(changes[0].path).toBe('/docs/guides/advanced/');
const deleteAdvancedResult = await repo.stage('test.eth', false);
expect(deleteAdvancedResult).toHaveProperty('cid');
expect(deleteAdvancedResult.cid instanceof CID).toBe(true);
// Verify advanced page is deleted
await expect(cat(testEnv.kubo.kuboApi, `/ipfs/${deleteAdvancedResult.cid.toString()}/docs/guides/advanced/index.md`)).rejects.toThrow();
// Verify sibling page still exists
const gettingStartedMarkdownAfterAdvancedDelete = await cat(testEnv.kubo.kuboApi, `/ipfs/${deleteAdvancedResult.cid.toString()}/docs/guides/getting-started/index.md`);
expect(gettingStartedMarkdownAfterAdvancedDelete).toBe('# Getting Started\n\nGetting started guide.');
// Verify parent pages still exist
const docsMarkdownAfterAdvancedDelete = await cat(testEnv.kubo.kuboApi, `/ipfs/${deleteAdvancedResult.cid.toString()}/docs/index.md`);
expect(docsMarkdownAfterAdvancedDelete).toBe('# Documentation\n\nMain docs.');
// Commit the final version
await repo.finalizeCommit(deleteAdvancedResult.cid);
pages = await repo.getAllPages();
expect(pages.length).toBe(3);
expect(pages).toContain('/');
expect(pages).toContain('/docs/');
expect(pages).toContain('/docs/guides/getting-started/');
expect(pages).not.toContain('/docs/guides/advanced/');
});
it('should handle staging with no edits', async () => {
await expect(repo.stage('test.eth', false)).rejects.toThrow('No edits to stage');
});
it('should stage with template update when there are no file or page edits', async () => {
// Stage with template update should work
const result = await repo.stage('test.eth', true);
expect(result).toHaveProperty('cid');
expect(result).toHaveProperty('prepTx');
expect(result.cid instanceof CID).toBe(true);
// Verify template is updated to version 0.5.0
const templateContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${result.cid.toString()}/_template.html`);
expect(templateContent).toBeDefined();
const templateDoc = parser.parseFromString(templateContent, 'text/html');
checkMeta(templateDoc, 'version', '0.5.0');
// Verify _assets and _js folders are updated
const assetsContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${result.cid.toString()}/_assets`);
expect(assetsContent).toBe('folder-updated');
const jsContent = await cat(testEnv.kubo.kuboApi, `/ipfs/${result.cid.toString()}/_js`);
expect(jsContent).toBe('folder-updated');
});
it('should include manifest.json and _redirects files during staging', async () => {
const testMarkdown = `---
title: My Custom Title
description: This is a custom description for the page
---
# Test Page
This is a test.`;
const testBody = '<h1>Test Page</h1><p>This is a test.</p>';
await repo.setPageEdit('/', testMarkdown, testBody);
await repo.setPageEdit('/about/', testMarkdown, testBody);
const result = await repo.stage('test.eth', false);
expect(result).toHaveProperty('cid');
expect(result.cid instanceof CID).toBe(true);
// verify the manifest.json file is included
const manifest = await cat(testEnv.kubo.kuboApi, '/ipfs/' + result.cid.toString() + '/manifest.json');
expect(manifest).toBeDefined();
// parse and verify manifest content
const manifestData = JSON.parse(manifest);
expect(manifestData.name).toBe('My Custom Title');
expect(manifestData.short_name).toBe('test.eth');
expect(manifestData.description).toBe('This is a custom description for the page');
expect(manifestData.icons).toBeDefined();
expect(manifestData.icons.length).toBe(1);
expect(manifestData.icons[0].src).toBe('/_assets/images/logo.svg');
expect(manifestData.icons[0].sizes).toBe('192x192');
expect(manifestData.icons[0].type).toBe('image/svg+xml');
expect(manifestData.dapp_repository).toBe('https://github.com/stigmergic-org/simplepage');
expect(manifestData.dapp_contracts).toEqual([]);
// verify the _redirects file is included
const redirects = await cat(testEnv.kubo.kuboApi, '/ipfs/' + result.cid.toString() + '/_redirects');
expect(redirects).toBeDefined();
expect(redirects.trim()).toContain('/* / 200');
expect(redirects.trim()).toContain('/about/* /about/ 200');
});
it('should persist committed pages and allow loading from a new Repo instance', async () => {
// Add two pages
const page1 = {
path: '/foo/',
markdown: '# Foo\n\nFoo page content.',
body: '<h1>Foo</h1><p>Foo page content.</p>'
};
const page2 = {
path: '/bar/',
markdown: '# Bar\n\nBar page content.',
body: '<h1>Bar</h1><p>Bar page content.</p>'
};
// Add multi-depth: