@automerge/automerge-repo 2.0.0-alpha.13 → 2.0.0-alpha.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/AutomergeUrl.d.ts +19 -4
  2. package/dist/AutomergeUrl.d.ts.map +1 -1
  3. package/dist/AutomergeUrl.js +71 -24
  4. package/dist/DocHandle.d.ts +21 -17
  5. package/dist/DocHandle.d.ts.map +1 -1
  6. package/dist/DocHandle.js +83 -26
  7. package/dist/RemoteHeadsSubscriptions.d.ts +4 -4
  8. package/dist/RemoteHeadsSubscriptions.d.ts.map +1 -1
  9. package/dist/RemoteHeadsSubscriptions.js +4 -1
  10. package/dist/Repo.d.ts +11 -2
  11. package/dist/Repo.d.ts.map +1 -1
  12. package/dist/Repo.js +19 -14
  13. package/dist/helpers/bufferFromHex.d.ts +3 -0
  14. package/dist/helpers/bufferFromHex.d.ts.map +1 -0
  15. package/dist/helpers/bufferFromHex.js +13 -0
  16. package/dist/helpers/headsAreSame.d.ts +2 -2
  17. package/dist/helpers/headsAreSame.d.ts.map +1 -1
  18. package/dist/helpers/mergeArrays.d.ts +1 -1
  19. package/dist/helpers/mergeArrays.d.ts.map +1 -1
  20. package/dist/helpers/tests/storage-adapter-tests.d.ts.map +1 -1
  21. package/dist/helpers/tests/storage-adapter-tests.js +6 -9
  22. package/dist/storage/StorageSubsystem.d.ts.map +1 -1
  23. package/dist/storage/StorageSubsystem.js +2 -1
  24. package/dist/synchronizer/CollectionSynchronizer.d.ts +2 -2
  25. package/dist/synchronizer/CollectionSynchronizer.d.ts.map +1 -1
  26. package/dist/synchronizer/CollectionSynchronizer.js +16 -2
  27. package/dist/synchronizer/Synchronizer.d.ts +3 -0
  28. package/dist/synchronizer/Synchronizer.d.ts.map +1 -1
  29. package/package.json +2 -2
  30. package/src/AutomergeUrl.ts +103 -26
  31. package/src/DocHandle.ts +130 -37
  32. package/src/RemoteHeadsSubscriptions.ts +11 -8
  33. package/src/Repo.ts +41 -13
  34. package/src/helpers/bufferFromHex.ts +14 -0
  35. package/src/helpers/headsAreSame.ts +2 -2
  36. package/src/helpers/tests/storage-adapter-tests.ts +13 -24
  37. package/src/storage/StorageSubsystem.ts +3 -1
  38. package/src/synchronizer/CollectionSynchronizer.ts +19 -3
  39. package/src/synchronizer/Synchronizer.ts +12 -7
  40. package/test/AutomergeUrl.test.ts +130 -0
  41. package/test/DocHandle.test.ts +70 -4
  42. package/test/DocSynchronizer.test.ts +10 -3
  43. package/test/Repo.test.ts +155 -3
@@ -1,9 +1,9 @@
1
1
  import debug from "debug"
2
2
  import { DocHandle } from "../DocHandle.js"
3
- import { stringifyAutomergeUrl } from "../AutomergeUrl.js"
3
+ import { parseAutomergeUrl, stringifyAutomergeUrl } from "../AutomergeUrl.js"
4
4
  import { Repo } from "../Repo.js"
5
5
  import { DocMessage } from "../network/messages.js"
6
- import { DocumentId, PeerId } from "../types.js"
6
+ import { AutomergeUrl, DocumentId, PeerId } from "../types.js"
7
7
  import { DocSynchronizer } from "./DocSynchronizer.js"
8
8
  import { Synchronizer } from "./Synchronizer.js"
9
9
 
@@ -21,8 +21,11 @@ export class CollectionSynchronizer extends Synchronizer {
21
21
  /** Used to determine if the document is know to the Collection and a synchronizer exists or is being set up */
22
22
  #docSetUp: Record<DocumentId, boolean> = {}
23
23
 
24
- constructor(private repo: Repo) {
24
+ #denylist: DocumentId[]
25
+
26
+ constructor(private repo: Repo, denylist: AutomergeUrl[] = []) {
25
27
  super()
28
+ this.#denylist = denylist.map(url => parseAutomergeUrl(url).documentId)
26
29
  }
27
30
 
28
31
  /** Returns a synchronizer for the given document, creating one if it doesn't already exist. */
@@ -91,6 +94,19 @@ export class CollectionSynchronizer extends Synchronizer {
91
94
  throw new Error("received a message with an invalid documentId")
92
95
  }
93
96
 
97
+ if (this.#denylist.includes(documentId)) {
98
+ this.emit("metrics", {
99
+ type: "doc-denied",
100
+ documentId,
101
+ })
102
+ this.emit("message", {
103
+ type: "doc-unavailable",
104
+ documentId,
105
+ targetId: message.senderId,
106
+ })
107
+ return
108
+ }
109
+
94
110
  this.#docSetUp[documentId] = true
95
111
 
96
112
  const docSynchronizer = this.#fetchDocSynchronizer(documentId)
@@ -25,10 +25,15 @@ export interface SyncStatePayload {
25
25
  syncState: SyncState
26
26
  }
27
27
 
28
- export type DocSyncMetrics = {
29
- type: "receive-sync-message"
30
- documentId: DocumentId
31
- durationMillis: number
32
- numOps: number
33
- numChanges: number
34
- }
28
+ export type DocSyncMetrics =
29
+ | {
30
+ type: "receive-sync-message"
31
+ documentId: DocumentId
32
+ durationMillis: number
33
+ numOps: number
34
+ numChanges: number
35
+ }
36
+ | {
37
+ type: "doc-denied"
38
+ documentId: DocumentId
39
+ }
@@ -3,9 +3,11 @@ import bs58check from "bs58check"
3
3
  import { describe, it } from "vitest"
4
4
  import {
5
5
  generateAutomergeUrl,
6
+ getHeadsFromUrl,
6
7
  isValidAutomergeUrl,
7
8
  parseAutomergeUrl,
8
9
  stringifyAutomergeUrl,
10
+ UrlHeads,
9
11
  } from "../src/AutomergeUrl.js"
10
12
  import type {
11
13
  AutomergeUrl,
@@ -102,3 +104,131 @@ describe("AutomergeUrl", () => {
102
104
  })
103
105
  })
104
106
  })
107
+
108
+ describe("AutomergeUrl with heads", () => {
109
+ // Create some sample encoded heads for testing
110
+ const head1 = bs58check.encode(new Uint8Array([1, 2, 3, 4])) as string
111
+ const head2 = bs58check.encode(new Uint8Array([5, 6, 7, 8])) as string
112
+ const goodHeads = [head1, head2] as UrlHeads
113
+ const urlWithHeads = `${goodUrl}#${head1}|${head2}` as AutomergeUrl
114
+ const invalidHead = "not-base58-encoded"
115
+ const invalidHeads = [invalidHead] as UrlHeads
116
+
117
+ describe("stringifyAutomergeUrl", () => {
118
+ it("should stringify a url with heads", () => {
119
+ const url = stringifyAutomergeUrl({
120
+ documentId: goodDocumentId,
121
+ heads: goodHeads,
122
+ })
123
+ assert.strictEqual(url, urlWithHeads)
124
+ })
125
+
126
+ it("should throw if heads are not valid base58check", () => {
127
+ assert.throws(() =>
128
+ stringifyAutomergeUrl({
129
+ documentId: goodDocumentId,
130
+ heads: invalidHeads,
131
+ })
132
+ )
133
+ })
134
+ })
135
+
136
+ describe("parseAutomergeUrl", () => {
137
+ it("should parse a url with heads", () => {
138
+ const { documentId, heads } = parseAutomergeUrl(urlWithHeads)
139
+ assert.equal(documentId, goodDocumentId)
140
+ assert.deepEqual(heads, [head1, head2])
141
+ })
142
+
143
+ it("should parse a url without heads", () => {
144
+ const { documentId, heads } = parseAutomergeUrl(goodUrl)
145
+ assert.equal(documentId, goodDocumentId)
146
+ assert.equal(heads, undefined)
147
+ })
148
+
149
+ it("should throw on url with invalid heads encoding", () => {
150
+ const badUrl = `${goodUrl}#${invalidHead}` as AutomergeUrl
151
+ assert.throws(() => parseAutomergeUrl(badUrl))
152
+ })
153
+ })
154
+
155
+ describe("isValidAutomergeUrl", () => {
156
+ it("should return true for a valid url with heads", () => {
157
+ assert(isValidAutomergeUrl(urlWithHeads) === true)
158
+ })
159
+
160
+ it("should return false for a url with invalid heads", () => {
161
+ const badUrl = `${goodUrl}#${invalidHead}` as AutomergeUrl
162
+ assert(isValidAutomergeUrl(badUrl) === false)
163
+ })
164
+ })
165
+
166
+ describe("getHeadsFromUrl", () => {
167
+ it("should return heads from a valid url", () => {
168
+ const heads = getHeadsFromUrl(urlWithHeads)
169
+ assert.deepEqual(heads, [head1, head2])
170
+ })
171
+
172
+ it("should return undefined for url without heads", () => {
173
+ const heads = getHeadsFromUrl(goodUrl)
174
+ assert.equal(heads, undefined)
175
+ })
176
+ })
177
+ it("should handle a single head correctly", () => {
178
+ const urlWithOneHead = `${goodUrl}#${head1}` as AutomergeUrl
179
+ const { heads } = parseAutomergeUrl(urlWithOneHead)
180
+ assert.deepEqual(heads, [head1])
181
+ })
182
+
183
+ it("should round-trip urls with heads", () => {
184
+ const originalUrl = urlWithHeads
185
+ const parsed = parseAutomergeUrl(originalUrl)
186
+ const roundTripped = stringifyAutomergeUrl({
187
+ documentId: parsed.documentId,
188
+ heads: parsed.heads,
189
+ })
190
+ assert.equal(roundTripped, originalUrl)
191
+ })
192
+
193
+ describe("should reject malformed urls", () => {
194
+ it("should reject urls with trailing delimiter", () => {
195
+ assert(!isValidAutomergeUrl(`${goodUrl}#${head1}:` as AutomergeUrl))
196
+ })
197
+
198
+ it("should reject urls with empty head", () => {
199
+ assert(!isValidAutomergeUrl(`${goodUrl}#|${head1}` as AutomergeUrl))
200
+ })
201
+
202
+ it("should reject urls with multiple hash characters", () => {
203
+ assert(
204
+ !isValidAutomergeUrl(`${goodUrl}#${head1}#${head2}` as AutomergeUrl)
205
+ )
206
+ })
207
+ })
208
+ })
209
+
210
+ describe("empty heads section", () => {
211
+ it("should treat bare # as empty heads array", () => {
212
+ const urlWithEmptyHeads = `${goodUrl}#` as AutomergeUrl
213
+ const { heads } = parseAutomergeUrl(urlWithEmptyHeads)
214
+ assert.deepEqual(heads, [])
215
+ })
216
+
217
+ it("should round-trip empty heads array", () => {
218
+ const original = `${goodUrl}#` as AutomergeUrl
219
+ const parsed = parseAutomergeUrl(original)
220
+ const roundTripped = stringifyAutomergeUrl({
221
+ documentId: parsed.documentId,
222
+ heads: parsed.heads,
223
+ })
224
+ assert.equal(roundTripped, original)
225
+ })
226
+
227
+ it("should distinguish between no heads and empty heads", () => {
228
+ const noHeads = parseAutomergeUrl(goodUrl)
229
+ const emptyHeads = parseAutomergeUrl(`${goodUrl}#` as AutomergeUrl)
230
+
231
+ assert.equal(noHeads.heads, undefined)
232
+ assert.deepEqual(emptyHeads.heads, [])
233
+ })
234
+ })
@@ -2,7 +2,11 @@ import * as A from "@automerge/automerge/next"
2
2
  import assert from "assert"
3
3
  import { decode } from "cbor-x"
4
4
  import { describe, it, vi } from "vitest"
5
- import { generateAutomergeUrl, parseAutomergeUrl } from "../src/AutomergeUrl.js"
5
+ import {
6
+ encodeHeads,
7
+ generateAutomergeUrl,
8
+ parseAutomergeUrl,
9
+ } from "../src/AutomergeUrl.js"
6
10
  import { eventPromise } from "../src/helpers/eventPromise.js"
7
11
  import { pause } from "../src/helpers/pause.js"
8
12
  import { DocHandle, DocHandleChangePayload } from "../src/index.js"
@@ -83,7 +87,7 @@ describe("DocHandle", () => {
83
87
  handle.change(d => (d.foo = "bar"))
84
88
  assert.equal(handle.isReady(), true)
85
89
 
86
- const heads = A.getHeads(handle.docSync())
90
+ const heads = encodeHeads(A.getHeads(handle.docSync()))
87
91
  assert.notDeepEqual(handle.heads(), [])
88
92
  assert.deepEqual(heads, handle.heads())
89
93
  })
@@ -113,8 +117,45 @@ describe("DocHandle", () => {
113
117
  assert.equal(handle.isReady(), true)
114
118
 
115
119
  const history = handle.history()
116
- const view = handle.view(history[1])
117
- assert.deepEqual(view, { foo: "one" })
120
+ const viewHandle = handle.view(history[1])
121
+ assert.deepEqual(await viewHandle.doc(), { foo: "one" })
122
+ })
123
+
124
+ it("should support fixed heads from construction", async () => {
125
+ const handle = setup()
126
+ handle.change(d => (d.foo = "zero"))
127
+ handle.change(d => (d.foo = "one"))
128
+
129
+ const history = handle.history()
130
+ const viewHandle = new DocHandle<TestDoc>(TEST_ID, { heads: history[0] })
131
+ viewHandle.update(() => A.clone(handle.docSync()!))
132
+ viewHandle.doneLoading()
133
+
134
+ assert.deepEqual(await viewHandle.doc(), { foo: "zero" })
135
+ })
136
+
137
+ it("should prevent changes on fixed-heads handles", async () => {
138
+ const handle = setup()
139
+ handle.change(d => (d.foo = "zero"))
140
+ const viewHandle = handle.view(handle.heads()!)
141
+
142
+ assert.throws(() => viewHandle.change(d => (d.foo = "one")))
143
+ assert.throws(() =>
144
+ viewHandle.changeAt(handle.heads()!, d => (d.foo = "one"))
145
+ )
146
+ assert.throws(() => viewHandle.merge(handle))
147
+ })
148
+
149
+ it("should return fixed heads from heads()", async () => {
150
+ const handle = setup()
151
+ handle.change(d => (d.foo = "zero"))
152
+ const originalHeads = handle.heads()!
153
+
154
+ handle.change(d => (d.foo = "one"))
155
+ const viewHandle = handle.view(originalHeads)
156
+
157
+ assert.deepEqual(viewHandle.heads(), originalHeads)
158
+ assert.notDeepEqual(viewHandle.heads(), handle.heads())
118
159
  })
119
160
 
120
161
  it("should return diffs", async () => {
@@ -154,6 +195,31 @@ describe("DocHandle", () => {
154
195
  ])
155
196
  })
156
197
 
198
+ it("should support diffing against another handle", async () => {
199
+ const handle = setup()
200
+ handle.change(d => (d.foo = "zero"))
201
+ const viewHandle = handle.view(handle.heads()!)
202
+
203
+ handle.change(d => (d.foo = "one"))
204
+
205
+ const patches = viewHandle.diff(handle)
206
+ assert.deepEqual(patches, [
207
+ { action: "put", path: ["foo"], value: "" },
208
+ { action: "splice", path: ["foo", 0], value: "one" },
209
+ ])
210
+ })
211
+
212
+ // TODO: alexg -- should i remove this test? should this fail or no?
213
+ it.skip("should fail diffing against unrelated handles", async () => {
214
+ const handle1 = setup()
215
+ const handle2 = setup()
216
+
217
+ handle1.change(d => (d.foo = "zero"))
218
+ handle2.change(d => (d.foo = "one"))
219
+
220
+ assert.throws(() => handle1.diff(handle2))
221
+ })
222
+
157
223
  it("should allow direct access to decoded changes", async () => {
158
224
  const handle = setup()
159
225
  const time = Date.now()
@@ -1,7 +1,11 @@
1
1
  import assert from "assert"
2
2
  import { describe, it } from "vitest"
3
3
  import { next as Automerge } from "@automerge/automerge"
4
- import { generateAutomergeUrl, parseAutomergeUrl } from "../src/AutomergeUrl.js"
4
+ import {
5
+ encodeHeads,
6
+ generateAutomergeUrl,
7
+ parseAutomergeUrl,
8
+ } from "../src/AutomergeUrl.js"
5
9
  import { DocHandle } from "../src/DocHandle.js"
6
10
  import { eventPromise } from "../src/helpers/eventPromise.js"
7
11
  import {
@@ -67,11 +71,14 @@ describe("DocSynchronizer", () => {
67
71
 
68
72
  assert.equal(message1.peerId, "alice")
69
73
  assert.equal(message1.documentId, handle.documentId)
70
- assert.deepEqual(message1.syncState.lastSentHeads, [])
74
+ assert.deepStrictEqual(message1.syncState.lastSentHeads, [])
71
75
 
72
76
  assert.equal(message2.peerId, "alice")
73
77
  assert.equal(message2.documentId, handle.documentId)
74
- assert.deepEqual(message2.syncState.lastSentHeads, handle.heads())
78
+ assert.deepStrictEqual(
79
+ encodeHeads(message2.syncState.lastSentHeads),
80
+ handle.heads()
81
+ )
75
82
  })
76
83
 
77
84
  it("still syncs with a peer after it disconnects and reconnects", async () => {
package/test/Repo.test.ts CHANGED
@@ -3,7 +3,13 @@ import { MessageChannelNetworkAdapter } from "../../automerge-repo-network-messa
3
3
  import assert from "assert"
4
4
  import * as Uuid from "uuid"
5
5
  import { describe, expect, it } from "vitest"
6
- import { parseAutomergeUrl } from "../src/AutomergeUrl.js"
6
+ import {
7
+ encodeHeads,
8
+ getHeadsFromUrl,
9
+ isValidAutomergeUrl,
10
+ parseAutomergeUrl,
11
+ UrlHeads,
12
+ } from "../src/AutomergeUrl.js"
7
13
  import {
8
14
  generateAutomergeUrl,
9
15
  stringifyAutomergeUrl,
@@ -1175,7 +1181,10 @@ describe("Repo", () => {
1175
1181
  bobHandle.documentId,
1176
1182
  await charlieRepo!.storageSubsystem.id()
1177
1183
  )
1178
- assert.deepStrictEqual(storedSyncState.sharedHeads, bobHandle.heads())
1184
+ assert.deepStrictEqual(
1185
+ encodeHeads(storedSyncState.sharedHeads),
1186
+ bobHandle.heads()
1187
+ )
1179
1188
 
1180
1189
  teardown()
1181
1190
  })
@@ -1275,7 +1284,7 @@ describe("Repo", () => {
1275
1284
 
1276
1285
  const nextRemoteHeadsPromise = new Promise<{
1277
1286
  storageId: StorageId
1278
- heads: A.Heads
1287
+ heads: UrlHeads
1279
1288
  }>(resolve => {
1280
1289
  handle.on("remote-heads", ({ storageId, heads }) => {
1281
1290
  resolve({ storageId, heads })
@@ -1486,6 +1495,149 @@ describe("Repo", () => {
1486
1495
  teardown()
1487
1496
  })
1488
1497
  })
1498
+
1499
+ describe("the denylist", () => {
1500
+ it("should immediately return an unavailable message in response to a request for a denylisted document", async () => {
1501
+ const storage = new DummyStorageAdapter()
1502
+
1503
+ // first create the document in storage
1504
+ const dummyRepo = new Repo({ network: [], storage })
1505
+ const doc = dummyRepo.create({ foo: "bar" })
1506
+ await dummyRepo.flush()
1507
+
1508
+ // Check that the document actually is in storage
1509
+ let docId = doc.documentId
1510
+ assert(storage.keys().some((k: string) => k.includes(docId)))
1511
+
1512
+ const channel = new MessageChannel()
1513
+ const { port1: clientToServer, port2: serverToClient } = channel
1514
+ const server = new Repo({
1515
+ network: [new MessageChannelNetworkAdapter(serverToClient)],
1516
+ storage,
1517
+ denylist: [doc.url],
1518
+ })
1519
+ const client = new Repo({
1520
+ network: [new MessageChannelNetworkAdapter(clientToServer)],
1521
+ })
1522
+
1523
+ await Promise.all([
1524
+ eventPromise(server.networkSubsystem, "peer"),
1525
+ eventPromise(client.networkSubsystem, "peer"),
1526
+ ])
1527
+
1528
+ const clientDoc = client.find(doc.url)
1529
+ await pause(100)
1530
+ assert.strictEqual(clientDoc.docSync(), undefined)
1531
+
1532
+ const openDocs = Object.keys(server.metrics().documents).length
1533
+ assert.deepEqual(openDocs, 0)
1534
+ })
1535
+ })
1536
+ })
1537
+
1538
+ describe("Repo heads-in-URLs functionality", () => {
1539
+ const setup = () => {
1540
+ const repo = new Repo({})
1541
+ const handle = repo.create()
1542
+ handle.change((doc: any) => (doc.title = "Hello World"))
1543
+ return { repo, handle }
1544
+ }
1545
+
1546
+ it("finds a document view by URL with heads", async () => {
1547
+ const { repo, handle } = setup()
1548
+ const heads = handle.heads()!
1549
+ const url = stringifyAutomergeUrl({ documentId: handle.documentId, heads })
1550
+ const view = repo.find(url)
1551
+ expect(view.docSync()).toEqual({ title: "Hello World" })
1552
+ })
1553
+
1554
+ it("returns a view, not the actual handle, when finding by URL with heads", async () => {
1555
+ const { repo, handle } = setup()
1556
+ const heads = handle.heads()!
1557
+ await handle.change((doc: any) => (doc.title = "Changed"))
1558
+ const url = stringifyAutomergeUrl({ documentId: handle.documentId, heads })
1559
+ const view = repo.find(url)
1560
+ expect(view.docSync()).toEqual({ title: "Hello World" })
1561
+ expect(handle.docSync()).toEqual({ title: "Changed" })
1562
+ })
1563
+
1564
+ it("changes to a document view do not affect the original", async () => {
1565
+ const { repo, handle } = setup()
1566
+ const heads = handle.heads()!
1567
+ const url = stringifyAutomergeUrl({ documentId: handle.documentId, heads })
1568
+ const view = repo.find(url)
1569
+ expect(() =>
1570
+ view.change((doc: any) => (doc.title = "Changed in View"))
1571
+ ).toThrow()
1572
+ expect(handle.docSync()).toEqual({ title: "Hello World" })
1573
+ })
1574
+
1575
+ it("document views are read-only", async () => {
1576
+ const { repo, handle } = setup()
1577
+ const heads = handle.heads()!
1578
+ const url = stringifyAutomergeUrl({ documentId: handle.documentId, heads })
1579
+ const view = repo.find(url)
1580
+ expect(() => view.change((doc: any) => (doc.title = "Changed"))).toThrow()
1581
+ })
1582
+
1583
+ it("finds the latest document when given a URL without heads", async () => {
1584
+ const { repo, handle } = setup()
1585
+ await handle.change((doc: any) => (doc.title = "Changed"))
1586
+ const found = repo.find(handle.url)
1587
+ expect(found.docSync()).toEqual({ title: "Changed" })
1588
+ })
1589
+
1590
+ it("getHeadsFromUrl returns heads array if present or undefined", () => {
1591
+ const { repo, handle } = setup()
1592
+ const heads = handle.heads()!
1593
+ const url = stringifyAutomergeUrl({ documentId: handle.documentId, heads })
1594
+ expect(getHeadsFromUrl(url)).toEqual(heads)
1595
+
1596
+ const urlWithoutHeads = generateAutomergeUrl()
1597
+ expect(getHeadsFromUrl(urlWithoutHeads)).toBeUndefined()
1598
+ })
1599
+
1600
+ it("isValidAutomergeUrl returns true for valid URLs", () => {
1601
+ const { repo, handle } = setup()
1602
+ const url = generateAutomergeUrl()
1603
+ expect(isValidAutomergeUrl(url)).toBe(true)
1604
+
1605
+ const urlWithHeads = stringifyAutomergeUrl({
1606
+ documentId: handle.documentId,
1607
+ heads: handle.heads()!,
1608
+ })
1609
+ expect(isValidAutomergeUrl(urlWithHeads)).toBe(true)
1610
+ })
1611
+
1612
+ it("isValidAutomergeUrl returns false for invalid URLs", () => {
1613
+ const { repo, handle } = setup()
1614
+ expect(isValidAutomergeUrl("not a url")).toBe(false)
1615
+ expect(isValidAutomergeUrl("automerge:invalidid")).toBe(false)
1616
+ expect(isValidAutomergeUrl("automerge:validid#invalidhead")).toBe(false)
1617
+ })
1618
+
1619
+ it("parseAutomergeUrl extracts documentId and heads", () => {
1620
+ const { repo, handle } = setup()
1621
+ const url = stringifyAutomergeUrl({
1622
+ documentId: handle.documentId,
1623
+ heads: handle.heads()!,
1624
+ })
1625
+ const parsed = parseAutomergeUrl(url)
1626
+ expect(parsed.documentId).toBe(handle.documentId)
1627
+ expect(parsed.heads).toEqual(handle.heads())
1628
+ })
1629
+
1630
+ it("stringifyAutomergeUrl creates valid URL", () => {
1631
+ const { repo, handle } = setup()
1632
+ const url = stringifyAutomergeUrl({
1633
+ documentId: handle.documentId,
1634
+ heads: handle.heads()!,
1635
+ })
1636
+ expect(isValidAutomergeUrl(url)).toBe(true)
1637
+ const parsed = parseAutomergeUrl(url)
1638
+ expect(parsed.documentId).toBe(handle.documentId)
1639
+ expect(parsed.heads).toEqual(handle.heads())
1640
+ })
1489
1641
  })
1490
1642
 
1491
1643
  const warn = console.warn