From 2b37ea5552181dbab8312632fe879d8c8dbc7ca5 Mon Sep 17 00:00:00 2001 From: Peter van Hardenberg Date: Thu, 2 Jan 2025 23:54:36 -0800 Subject: [PATCH] tests all passing --- packages/automerge-repo/src/AutomergeUrl.ts | 32 +++-- packages/automerge-repo/src/DocHandle.ts | 75 ++++++----- .../src/RemoteHeadsSubscriptions.ts | 11 +- packages/automerge-repo/src/Repo.ts | 12 +- .../src/helpers/bufferFromHex.ts | 14 ++ .../src/storage/StorageSubsystem.ts | 2 + .../automerge-repo/test/AutomergeUrl.test.ts | 9 +- .../automerge-repo/test/DocHandle.test.ts | 8 +- .../test/DocSynchronizer.test.ts | 13 +- packages/automerge-repo/test/Repo.test.ts | 120 +++++++++++++++++- 10 files changed, 237 insertions(+), 59 deletions(-) create mode 100644 packages/automerge-repo/src/helpers/bufferFromHex.ts diff --git a/packages/automerge-repo/src/AutomergeUrl.ts b/packages/automerge-repo/src/AutomergeUrl.ts index 44f5fa885..94e4fb574 100644 --- a/packages/automerge-repo/src/AutomergeUrl.ts +++ b/packages/automerge-repo/src/AutomergeUrl.ts @@ -5,19 +5,30 @@ import type { DocumentId, AnyDocumentId, } from "./types.js" -import type { Heads } from "@automerge/automerge/slim" + import * as Uuid from "uuid" import bs58check from "bs58check" +import { + uint8ArrayFromHexString, + uint8ArrayToHexString, +} from "./helpers/bufferFromHex.js" + +import type { Heads as AutomergeHeads } from "@automerge/automerge/slim" export const urlPrefix = "automerge:" +// We need to define our own version of heads because the AutomergeHeads type is not bs58check encoded +export type UrlHeads = string[] & { __automergeUrlHeadsBrand: unknown } + interface ParsedAutomergeUrl { /** unencoded DocumentId */ binaryDocumentId: BinaryDocumentId /** bs58 encoded DocumentId */ documentId: DocumentId /** Optional array of heads, if specified in URL */ - heads?: Heads + heads?: UrlHeads + /** Optional hex array of heads, in Automerge core format */ + hexHeads?: string[] // AKA: heads } /** Given an Automerge URL, returns the DocumentId in both base58check-encoded form and binary form */ @@ -34,16 +45,15 @@ export const parseAutomergeUrl = (url: AutomergeUrl): ParsedAutomergeUrl => { if (!binaryDocumentId) throw new Error("Invalid document URL: " + url) if (headsSection === undefined) return { binaryDocumentId, documentId } - const encodedHeads = headsSection === "" ? [] : headsSection.split("|") - const heads = encodedHeads.map(head => { + const heads = (headsSection === "" ? [] : headsSection.split("|")) as UrlHeads + const hexHeads = heads.map(head => { try { - bs58check.decode(head) - return head + return uint8ArrayToHexString(bs58check.decode(head)) } catch (e) { throw new Error(`Invalid head in URL: ${head}`) } }) - return { binaryDocumentId, documentId, heads } + return { binaryDocumentId, hexHeads, documentId, heads } } /** @@ -157,6 +167,12 @@ export const documentIdToBinary = (docId: DocumentId) => export const binaryToDocumentId = (docId: BinaryDocumentId) => bs58check.encode(docId) as DocumentId +export const encodeHeads = (heads: AutomergeHeads): UrlHeads => + heads.map(h => bs58check.encode(uint8ArrayFromHexString(h))) as UrlHeads + +export const decodeHeads = (heads: UrlHeads): AutomergeHeads => + heads.map(h => uint8ArrayToHexString(bs58check.decode(h))) as AutomergeHeads + export const parseLegacyUUID = (str: string) => { if (!Uuid.validate(str)) return undefined const documentId = Uuid.parse(str) as BinaryDocumentId @@ -201,5 +217,5 @@ export const interpretAsDocumentId = (id: AnyDocumentId) => { type UrlOptions = { documentId: DocumentId | BinaryDocumentId - heads?: Heads + heads?: UrlHeads } diff --git a/packages/automerge-repo/src/DocHandle.ts b/packages/automerge-repo/src/DocHandle.ts index 1380fc35e..b2a443e41 100644 --- a/packages/automerge-repo/src/DocHandle.ts +++ b/packages/automerge-repo/src/DocHandle.ts @@ -2,7 +2,12 @@ import * as A from "@automerge/automerge/slim/next" import debug from "debug" import { EventEmitter } from "eventemitter3" import { assertEvent, assign, createActor, setup, waitFor } from "xstate" -import { stringifyAutomergeUrl } from "./AutomergeUrl.js" +import { + decodeHeads, + encodeHeads, + stringifyAutomergeUrl, + UrlHeads, +} from "./AutomergeUrl.js" import { encode } from "./helpers/cbor.js" import { headsAreSame } from "./helpers/headsAreSame.js" import { withTimeout } from "./helpers/withTimeout.js" @@ -29,7 +34,7 @@ export class DocHandle extends EventEmitter> { #machine /** If set, this handle will only show the document at these heads */ - #fixedHeads?: A.Heads + #fixedHeads?: UrlHeads /** The last known state of our document. */ #prevDocState: T = A.init() @@ -39,7 +44,7 @@ export class DocHandle extends EventEmitter> { #timeoutDelay = 60_000 /** A dictionary mapping each peer to the last heads we know they have. */ - #remoteHeads: Record = {} + #remoteHeads: Record = {} /** @hidden */ constructor( @@ -289,7 +294,7 @@ export class DocHandle extends EventEmitter> { if (this.#fixedHeads) { const doc = this.#doc if (!doc || this.isUnavailable()) return undefined - return A.view(doc, this.#fixedHeads) + return A.view(doc, decodeHeads(this.#fixedHeads)) } // Return the document return !this.isUnavailable() ? this.#doc : undefined @@ -312,7 +317,7 @@ export class DocHandle extends EventEmitter> { if (!this.isReady()) return undefined if (this.#fixedHeads) { const doc = this.#doc - return doc ? A.view(doc, this.#fixedHeads) : undefined + return doc ? A.view(doc, decodeHeads(this.#fixedHeads)) : undefined } return this.#doc } @@ -322,12 +327,12 @@ export class DocHandle extends EventEmitter> { * This precisely defines the state of a document. * @returns the current document's heads, or undefined if the document is not ready */ - heads(): A.Heads | undefined { + heads(): UrlHeads | undefined { if (!this.isReady()) return undefined if (this.#fixedHeads) { return this.#fixedHeads } - return A.getHeads(this.#doc) + return encodeHeads(A.getHeads(this.#doc)) } begin() { @@ -344,15 +349,17 @@ export class DocHandle extends EventEmitter> { * history views would be quite large under concurrency (every thing in each branch against each other). * There might be a clever way to think about this, but we haven't found it yet, so for now at least * we present a single traversable view which excludes concurrency. - * @returns A.Heads[] - The individual heads for every change in the document. Each item is a tagged string[1]. + * @returns UrlHeads[] - The individual heads for every change in the document. Each item is a tagged string[1]. */ - history(): A.Heads[] | undefined { + history(): UrlHeads[] | undefined { if (!this.isReady()) { return undefined } // This just returns all the heads as individual strings. - return A.topoHistoryTraversal(this.#doc).map(h => [h]) as A.Heads[] + return A.topoHistoryTraversal(this.#doc).map(h => + encodeHeads([h]) + ) as UrlHeads[] } /** @@ -368,7 +375,7 @@ export class DocHandle extends EventEmitter> { * @argument heads - The heads to view the document at. See history(). * @returns DocHandle at the time of `heads` */ - view(heads: A.Heads): DocHandle { + view(heads: UrlHeads): DocHandle { if (!this.isReady()) { throw new Error( `DocHandle#${this.documentId} is not ready. Check \`handle.isReady()\` before calling view().` @@ -398,7 +405,7 @@ export class DocHandle extends EventEmitter> { * @throws Error if the documents don't share history or if either document is not ready * @returns Automerge patches that go from one document state to the other */ - diff(first: A.Heads | DocHandle, second?: A.Heads): A.Patch[] { + diff(first: UrlHeads | DocHandle, second?: UrlHeads): A.Patch[] { if (!this.isReady()) { throw new Error( `DocHandle#${this.documentId} is not ready. Check \`handle.isReady()\` before calling diff().` @@ -417,19 +424,19 @@ export class DocHandle extends EventEmitter> { if (!otherHeads) throw new Error("Other document's heads not available") // Create a temporary merged doc to verify shared history and compute diff - try { - const mergedDoc = A.merge(A.clone(doc), first.docSync()!) - // Use the merged doc to compute the diff - return A.diff(mergedDoc, this.heads()!, otherHeads) - } catch (e) { - throw new Error("Documents do not share history") - } + const mergedDoc = A.merge(A.clone(doc), first.docSync()!) + // Use the merged doc to compute the diff + return A.diff( + mergedDoc, + decodeHeads(this.heads()!), + decodeHeads(otherHeads) + ) } // Otherwise treat as heads - const from = second ? first : this.heads() || [] + const from = second ? first : ((this.heads() || []) as UrlHeads) const to = second ? second : first - return A.diff(doc, from, to) + return A.diff(doc, decodeHeads(from), decodeHeads(to)) } /** @@ -447,11 +454,15 @@ export class DocHandle extends EventEmitter> { if (!this.isReady()) { return undefined } + if (!change) { change = this.heads()![0] } // we return undefined instead of null by convention in this API - return A.inspectChange(this.#doc, change) || undefined + return ( + A.inspectChange(this.#doc, decodeHeads([change] as UrlHeads)[0]) || + undefined + ) } /** @@ -477,13 +488,13 @@ export class DocHandle extends EventEmitter> { * Called by the repo either when a doc handle changes or we receive new remote heads. * @hidden */ - setRemoteHeads(storageId: StorageId, heads: A.Heads) { + setRemoteHeads(storageId: StorageId, heads: UrlHeads) { this.#remoteHeads[storageId] = heads this.emit("remote-heads", { storageId, heads }) } /** Returns the heads of the storageId. */ - getRemoteHeads(storageId: StorageId): A.Heads | undefined { + getRemoteHeads(storageId: StorageId): UrlHeads | undefined { return this.#remoteHeads[storageId] } @@ -526,10 +537,10 @@ export class DocHandle extends EventEmitter> { * @returns A set of heads representing the concurrent change that was made. */ changeAt( - heads: A.Heads, + heads: UrlHeads, callback: A.ChangeFn, options: A.ChangeOptions = {} - ): A.Heads[] | undefined { + ): UrlHeads[] | undefined { if (!this.isReady()) { throw new Error( `DocHandle#${this.documentId} is not ready. Check \`handle.isReady()\` before accessing the document.` @@ -540,13 +551,15 @@ export class DocHandle extends EventEmitter> { `DocHandle#${this.documentId} is in view-only mode at specific heads. Use clone() to create a new document from this state.` ) } - let resultHeads: A.Heads | undefined = undefined + let resultHeads: UrlHeads | undefined = undefined this.#machine.send({ type: UPDATE, payload: { callback: doc => { - const result = A.changeAt(doc, heads, options, callback) - resultHeads = result.newHeads || undefined + const result = A.changeAt(doc, decodeHeads(heads), options, callback) + resultHeads = result.newHeads + ? encodeHeads(result.newHeads) + : undefined return result.newDoc }, }, @@ -652,7 +665,7 @@ export type DocHandleOptions = isNew?: false // An optional point in time to lock the document to. - heads?: A.Heads + heads?: UrlHeads /** The number of milliseconds before we mark this document as unavailable if we don't have it and nobody shares it with us. */ timeoutDelay?: number @@ -717,7 +730,7 @@ export interface DocHandleOutboundEphemeralMessagePayload { /** Emitted when we have new remote heads for this document */ export interface DocHandleRemoteHeadsPayload { storageId: StorageId - heads: A.Heads + heads: UrlHeads } // STATE MACHINE TYPES & CONSTANTS diff --git a/packages/automerge-repo/src/RemoteHeadsSubscriptions.ts b/packages/automerge-repo/src/RemoteHeadsSubscriptions.ts index 6862c7d5a..222bba65b 100644 --- a/packages/automerge-repo/src/RemoteHeadsSubscriptions.ts +++ b/packages/automerge-repo/src/RemoteHeadsSubscriptions.ts @@ -7,12 +7,13 @@ import { } from "./network/messages.js" import { StorageId } from "./index.js" import debug from "debug" +import { UrlHeads } from "./AutomergeUrl.js" // Notify a DocHandle that remote heads have changed export type RemoteHeadsSubscriptionEventPayload = { documentId: DocumentId storageId: StorageId - remoteHeads: A.Heads + remoteHeads: UrlHeads timestamp: number } @@ -21,7 +22,7 @@ export type NotifyRemoteHeadsPayload = { targetId: PeerId documentId: DocumentId storageId: StorageId - heads: A.Heads + heads: UrlHeads timestamp: number } @@ -216,7 +217,7 @@ export class RemoteHeadsSubscriptions extends EventEmitter { const heads = handle.getRemoteHeads(storageId) const haveHeadsChanged = message.syncState.theirHeads && - (!heads || !headsAreSame(heads, message.syncState.theirHeads)) + (!heads || + !headsAreSame(heads, encodeHeads(message.syncState.theirHeads))) if (haveHeadsChanged && message.syncState.theirHeads) { - handle.setRemoteHeads(storageId, message.syncState.theirHeads) + handle.setRemoteHeads( + storageId, + encodeHeads(message.syncState.theirHeads) + ) if (storageId && this.#remoteHeadsGossipingEnabled) { this.#remoteHeadsSubscriptions.handleImmediateRemoteHeadsChanged( message.documentId, storageId, - message.syncState.theirHeads + encodeHeads(message.syncState.theirHeads) ) } } diff --git a/packages/automerge-repo/src/helpers/bufferFromHex.ts b/packages/automerge-repo/src/helpers/bufferFromHex.ts new file mode 100644 index 000000000..293223ce0 --- /dev/null +++ b/packages/automerge-repo/src/helpers/bufferFromHex.ts @@ -0,0 +1,14 @@ +export const uint8ArrayFromHexString = (hexString: string): Uint8Array => { + if (hexString.length % 2 !== 0) { + throw new Error("Hex string must have an even length") + } + const bytes = new Uint8Array(hexString.length / 2) + for (let i = 0; i < hexString.length; i += 2) { + bytes[i >> 1] = parseInt(hexString.slice(i, i + 2), 16) + } + return bytes +} + +export const uint8ArrayToHexString = (data: Uint8Array): string => { + return Array.from(data, byte => byte.toString(16).padStart(2, "0")).join("") +} diff --git a/packages/automerge-repo/src/storage/StorageSubsystem.ts b/packages/automerge-repo/src/storage/StorageSubsystem.ts index a6b81638a..b46afcc30 100644 --- a/packages/automerge-repo/src/storage/StorageSubsystem.ts +++ b/packages/automerge-repo/src/storage/StorageSubsystem.ts @@ -9,6 +9,7 @@ import { keyHash, headsHash } from "./keyHash.js" import { chunkTypeFromKey } from "./chunkTypeFromKey.js" import * as Uuid from "uuid" import { EventEmitter } from "eventemitter3" +import { decodeHeads, UrlHeads } from "../AutomergeUrl.js" type StorageSubsystemEvents = { "document-loaded": (arg: { @@ -173,6 +174,7 @@ export class StorageSubsystem extends EventEmitter { } else { await this.#saveIncremental(documentId, doc) } + this.#storedHeads.set(documentId, A.getHeads(doc)) } diff --git a/packages/automerge-repo/test/AutomergeUrl.test.ts b/packages/automerge-repo/test/AutomergeUrl.test.ts index f7b0b64bb..b076e0cf3 100644 --- a/packages/automerge-repo/test/AutomergeUrl.test.ts +++ b/packages/automerge-repo/test/AutomergeUrl.test.ts @@ -7,6 +7,7 @@ import { isValidAutomergeUrl, parseAutomergeUrl, stringifyAutomergeUrl, + UrlHeads, } from "../src/AutomergeUrl.js" import type { AutomergeUrl, @@ -108,14 +109,16 @@ describe("AutomergeUrl with heads", () => { // Create some sample encoded heads for testing const head1 = bs58check.encode(new Uint8Array([1, 2, 3, 4])) as string const head2 = bs58check.encode(new Uint8Array([5, 6, 7, 8])) as string + const goodHeads = [head1, head2] as UrlHeads const urlWithHeads = `${goodUrl}#${head1}|${head2}` as AutomergeUrl - const invalidHead = "not-base58-encoded" as string + const invalidHead = "not-base58-encoded" + const invalidHeads = [invalidHead] as UrlHeads describe("stringifyAutomergeUrl", () => { it("should stringify a url with heads", () => { const url = stringifyAutomergeUrl({ documentId: goodDocumentId, - heads: [head1, head2], + heads: goodHeads, }) assert.strictEqual(url, urlWithHeads) }) @@ -124,7 +127,7 @@ describe("AutomergeUrl with heads", () => { assert.throws(() => stringifyAutomergeUrl({ documentId: goodDocumentId, - heads: [invalidHead], + heads: invalidHeads, }) ) }) diff --git a/packages/automerge-repo/test/DocHandle.test.ts b/packages/automerge-repo/test/DocHandle.test.ts index 86877d441..7c9d4860f 100644 --- a/packages/automerge-repo/test/DocHandle.test.ts +++ b/packages/automerge-repo/test/DocHandle.test.ts @@ -2,7 +2,11 @@ import * as A from "@automerge/automerge/next" import assert from "assert" import { decode } from "cbor-x" import { describe, it, vi } from "vitest" -import { generateAutomergeUrl, parseAutomergeUrl } from "../src/AutomergeUrl.js" +import { + encodeHeads, + generateAutomergeUrl, + parseAutomergeUrl, +} from "../src/AutomergeUrl.js" import { eventPromise } from "../src/helpers/eventPromise.js" import { pause } from "../src/helpers/pause.js" import { DocHandle, DocHandleChangePayload } from "../src/index.js" @@ -83,7 +87,7 @@ describe("DocHandle", () => { handle.change(d => (d.foo = "bar")) assert.equal(handle.isReady(), true) - const heads = A.getHeads(handle.docSync()) + const heads = encodeHeads(A.getHeads(handle.docSync())) assert.notDeepEqual(handle.heads(), []) assert.deepEqual(heads, handle.heads()) }) diff --git a/packages/automerge-repo/test/DocSynchronizer.test.ts b/packages/automerge-repo/test/DocSynchronizer.test.ts index 81f4ae2e4..037488015 100644 --- a/packages/automerge-repo/test/DocSynchronizer.test.ts +++ b/packages/automerge-repo/test/DocSynchronizer.test.ts @@ -1,7 +1,11 @@ import assert from "assert" import { describe, it } from "vitest" import { next as Automerge } from "@automerge/automerge" -import { generateAutomergeUrl, parseAutomergeUrl } from "../src/AutomergeUrl.js" +import { + encodeHeads, + generateAutomergeUrl, + parseAutomergeUrl, +} from "../src/AutomergeUrl.js" import { DocHandle } from "../src/DocHandle.js" import { eventPromise } from "../src/helpers/eventPromise.js" import { @@ -67,11 +71,14 @@ describe("DocSynchronizer", () => { assert.equal(message1.peerId, "alice") assert.equal(message1.documentId, handle.documentId) - assert.deepEqual(message1.syncState.lastSentHeads, []) + assert.deepStrictEqual(message1.syncState.lastSentHeads, []) assert.equal(message2.peerId, "alice") assert.equal(message2.documentId, handle.documentId) - assert.deepEqual(message2.syncState.lastSentHeads, handle.heads()) + assert.deepStrictEqual( + encodeHeads(message2.syncState.lastSentHeads), + handle.heads() + ) }) it("still syncs with a peer after it disconnects and reconnects", async () => { diff --git a/packages/automerge-repo/test/Repo.test.ts b/packages/automerge-repo/test/Repo.test.ts index 94aa3e6b2..aa8be2270 100644 --- a/packages/automerge-repo/test/Repo.test.ts +++ b/packages/automerge-repo/test/Repo.test.ts @@ -3,7 +3,13 @@ import { MessageChannelNetworkAdapter } from "../../automerge-repo-network-messa import assert from "assert" import * as Uuid from "uuid" import { describe, expect, it } from "vitest" -import { parseAutomergeUrl } from "../src/AutomergeUrl.js" +import { + encodeHeads, + getHeadsFromUrl, + isValidAutomergeUrl, + parseAutomergeUrl, + UrlHeads, +} from "../src/AutomergeUrl.js" import { generateAutomergeUrl, stringifyAutomergeUrl, @@ -1175,7 +1181,10 @@ describe("Repo", () => { bobHandle.documentId, await charlieRepo!.storageSubsystem.id() ) - assert.deepStrictEqual(storedSyncState.sharedHeads, bobHandle.heads()) + assert.deepStrictEqual( + encodeHeads(storedSyncState.sharedHeads), + bobHandle.heads() + ) teardown() }) @@ -1275,7 +1284,7 @@ describe("Repo", () => { const nextRemoteHeadsPromise = new Promise<{ storageId: StorageId - heads: A.Heads + heads: UrlHeads }>(resolve => { handle.on("remote-heads", ({ storageId, heads }) => { resolve({ storageId, heads }) @@ -1526,6 +1535,111 @@ describe("Repo", () => { }) }) +describe("Repo heads-in-URLs functionality", () => { + const setup = () => { + const repo = new Repo({}) + const handle = repo.create() + handle.change((doc: any) => (doc.title = "Hello World")) + return { repo, handle } + } + + it("finds a document view by URL with heads", async () => { + const { repo, handle } = setup() + const heads = handle.heads()! + const url = stringifyAutomergeUrl({ documentId: handle.documentId, heads }) + const view = repo.find(url) + expect(view.docSync()).toEqual({ title: "Hello World" }) + }) + + it("returns a view, not the actual handle, when finding by URL with heads", async () => { + const { repo, handle } = setup() + const heads = handle.heads()! + await handle.change((doc: any) => (doc.title = "Changed")) + const url = stringifyAutomergeUrl({ documentId: handle.documentId, heads }) + const view = repo.find(url) + expect(view.docSync()).toEqual({ title: "Hello World" }) + expect(handle.docSync()).toEqual({ title: "Changed" }) + }) + + it("changes to a document view do not affect the original", async () => { + const { repo, handle } = setup() + const heads = handle.heads()! + const url = stringifyAutomergeUrl({ documentId: handle.documentId, heads }) + const view = repo.find(url) + expect(() => + view.change((doc: any) => (doc.title = "Changed in View")) + ).toThrow() + expect(handle.docSync()).toEqual({ title: "Hello World" }) + }) + + it("document views are read-only", async () => { + const { repo, handle } = setup() + const heads = handle.heads()! + const url = stringifyAutomergeUrl({ documentId: handle.documentId, heads }) + const view = repo.find(url) + expect(() => view.change((doc: any) => (doc.title = "Changed"))).toThrow() + }) + + it("finds the latest document when given a URL without heads", async () => { + const { repo, handle } = setup() + await handle.change((doc: any) => (doc.title = "Changed")) + const found = repo.find(handle.url) + expect(found.docSync()).toEqual({ title: "Changed" }) + }) + + it("getHeadsFromUrl returns heads array if present or undefined", () => { + const { repo, handle } = setup() + const heads = handle.heads()! + const url = stringifyAutomergeUrl({ documentId: handle.documentId, heads }) + expect(getHeadsFromUrl(url)).toEqual(heads) + + const urlWithoutHeads = generateAutomergeUrl() + expect(getHeadsFromUrl(urlWithoutHeads)).toBeUndefined() + }) + + it("isValidAutomergeUrl returns true for valid URLs", () => { + const { repo, handle } = setup() + const url = generateAutomergeUrl() + expect(isValidAutomergeUrl(url)).toBe(true) + + const urlWithHeads = stringifyAutomergeUrl({ + documentId: handle.documentId, + heads: handle.heads()!, + }) + expect(isValidAutomergeUrl(urlWithHeads)).toBe(true) + }) + + it("isValidAutomergeUrl returns false for invalid URLs", () => { + const { repo, handle } = setup() + expect(isValidAutomergeUrl("not a url")).toBe(false) + expect(isValidAutomergeUrl("automerge:invalidid")).toBe(false) + expect(isValidAutomergeUrl("automerge:validid#invalidhead")).toBe(false) + }) + + it("parseAutomergeUrl extracts documentId and heads", () => { + const { repo, handle } = setup() + const url = stringifyAutomergeUrl({ + documentId: handle.documentId, + heads: handle.heads()!, + }) + const parsed = parseAutomergeUrl(url) + expect(parsed.documentId).toBe(handle.documentId) + expect(parsed.heads).toEqual(handle.heads()) + }) + + it("stringifyAutomergeUrl creates valid URL", () => { + const { repo, handle } = setup() + const url = stringifyAutomergeUrl({ + documentId: handle.documentId, + heads: handle.heads()!, + }) + expect(isValidAutomergeUrl(url)).toBe(true) + const parsed = parseAutomergeUrl(url) + expect(parsed.documentId).toBe(handle.documentId) + expect(parsed.heads).toEqual(handle.heads()) + }) +}) + const warn = console.warn const NO_OP = () => {}