@freqhole/playlistz 0.0.1

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 (180) hide show
  1. package/.changeset/config.json +11 -0
  2. package/.changeset/nice-wolves-thank.md +5 -0
  3. package/.freqhole-versions.json +4 -0
  4. package/.github/copilot-instructions.md +201 -0
  5. package/.github/workflows/changesets.yml +50 -0
  6. package/.github/workflows/npm-publish.yml +124 -0
  7. package/.github/workflows/pr-checks.yml +103 -0
  8. package/README.md +30 -0
  9. package/build-component.js +141 -0
  10. package/build-zip-bundle-lib.js +44 -0
  11. package/config/playwright.config.ts +47 -0
  12. package/config/vite.config.ts +44 -0
  13. package/config/vitest.config.ts +39 -0
  14. package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
  15. package/dist/assets/index-CbOXzGiA.js +216 -0
  16. package/dist/assets/index-CbOXzGiA.js.map +1 -0
  17. package/dist/assets/index-TvJ6RFpy.css +1 -0
  18. package/dist/assets/midden-DceCrT_L.js +2 -0
  19. package/dist/assets/midden-DceCrT_L.js.map +1 -0
  20. package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
  21. package/dist/index.html +55 -0
  22. package/dist/sw.js +134 -0
  23. package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
  24. package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
  25. package/docs/E2E_TESTID_PLAN.md +234 -0
  26. package/docs/IROH_P2P_PLAN.md +302 -0
  27. package/docs/ROADMAP.md +695 -0
  28. package/docs/TODO.md +167 -0
  29. package/docs/bundle-embedding-plan.md +134 -0
  30. package/docs/standalone-refactor.md +184 -0
  31. package/e2e/all-playlists.spec.ts +220 -0
  32. package/e2e/audio-player.spec.ts +226 -0
  33. package/e2e/collaborative-features.spec.ts +229 -0
  34. package/e2e/contexts.ts +238 -0
  35. package/e2e/edit-panel.spec.ts +87 -0
  36. package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
  37. package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
  38. package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
  39. package/e2e/fixtures/chord-stack-3s.wav +0 -0
  40. package/e2e/fixtures/cover-anim.gif +0 -0
  41. package/e2e/fixtures/cover-blue.png +0 -0
  42. package/e2e/fixtures/cover-checkers.png +0 -0
  43. package/e2e/fixtures/cover-gradient.jpg +0 -0
  44. package/e2e/fixtures/cover-mono.gif +0 -0
  45. package/e2e/fixtures/cover-noise.png +0 -0
  46. package/e2e/fixtures/cover-plasma.webp +0 -0
  47. package/e2e/fixtures/cover-portrait.jpg +0 -0
  48. package/e2e/fixtures/cover-red.png +0 -0
  49. package/e2e/fixtures/cover-thumb.jpg +0 -0
  50. package/e2e/fixtures/cover-wide.webp +0 -0
  51. package/e2e/fixtures/generate.mjs +257 -0
  52. package/e2e/fixtures/long-drone-90s.mp3 +0 -0
  53. package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
  54. package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
  55. package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
  56. package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
  57. package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
  58. package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
  59. package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
  60. package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
  61. package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
  62. package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
  63. package/e2e/fixtures/tone-220hz-10s.wav +0 -0
  64. package/e2e/fixtures/tone-440hz-2s.wav +0 -0
  65. package/e2e/fixtures/tone-880hz-5s.wav +0 -0
  66. package/e2e/fixtures/tone-stereo-3s.wav +0 -0
  67. package/e2e/fixtures/user-provided/README.md +1 -0
  68. package/e2e/helpers/app.ts +143 -0
  69. package/e2e/helpers/hooks.ts +133 -0
  70. package/e2e/helpers/index.ts +12 -0
  71. package/e2e/helpers/media.ts +125 -0
  72. package/e2e/helpers.ts +10 -0
  73. package/e2e/p2p-collaboration.spec.ts +356 -0
  74. package/e2e/p2p-multi-peer.spec.ts +723 -0
  75. package/e2e/p2p-states.spec.ts +302 -0
  76. package/e2e/playback.spec.ts +56 -0
  77. package/e2e/playlist-crud.spec.ts +126 -0
  78. package/e2e/share-link-autoplay.spec.ts +129 -0
  79. package/e2e/sharing-access.spec.ts +205 -0
  80. package/e2e/sharing.spec.ts +195 -0
  81. package/e2e/song-cache-state.spec.ts +202 -0
  82. package/e2e/zip-bundle.spec.ts +855 -0
  83. package/eslint.config.js +114 -0
  84. package/index.html +54 -0
  85. package/package.json +119 -0
  86. package/public/sw.js +134 -0
  87. package/scripts/use-local.mjs +37 -0
  88. package/scripts/use-published.mjs +37 -0
  89. package/src/App.tsx +9 -0
  90. package/src/cli/check.ts +164 -0
  91. package/src/cli/generate.ts +184 -0
  92. package/src/cli/http.ts +88 -0
  93. package/src/cli/index.ts +65 -0
  94. package/src/cli/init.ts +18 -0
  95. package/src/components/AllPlaylistsPanel.tsx +713 -0
  96. package/src/components/AudioPlayer.tsx +122 -0
  97. package/src/components/MarqueeText.tsx +101 -0
  98. package/src/components/PlaylistCoverModal.tsx +519 -0
  99. package/src/components/PlaylistEditPanel.tsx +803 -0
  100. package/src/components/PlaylistSharePanel.tsx +1020 -0
  101. package/src/components/ShareLinkKnockPanel.tsx +144 -0
  102. package/src/components/SharePanel.tsx +584 -0
  103. package/src/components/SongEditModal.tsx +453 -0
  104. package/src/components/SongEditPanel.tsx +578 -0
  105. package/src/components/SongRow.tsx +689 -0
  106. package/src/components/index.tsx +494 -0
  107. package/src/components/playlist/index.tsx +1203 -0
  108. package/src/context/PlaylistzContext.tsx +74 -0
  109. package/src/dev-hooks.ts +35 -0
  110. package/src/hooks/createDocIndexQuery.ts +53 -0
  111. package/src/hooks/createDocStore.test.ts +303 -0
  112. package/src/hooks/createDocStore.ts +90 -0
  113. package/src/hooks/useDragAndDrop.test.ts +474 -0
  114. package/src/hooks/useDragAndDrop.ts +400 -0
  115. package/src/hooks/useImageModal.test.ts +174 -0
  116. package/src/hooks/useImageModal.ts +201 -0
  117. package/src/hooks/usePlaylistManager.test.ts +453 -0
  118. package/src/hooks/usePlaylistManager.ts +685 -0
  119. package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
  120. package/src/hooks/usePlaylistsQuery.ts +44 -0
  121. package/src/hooks/useSongState.test.ts +236 -0
  122. package/src/hooks/useSongState.ts +114 -0
  123. package/src/hooks/useUIState.ts +71 -0
  124. package/src/index.tsx +18 -0
  125. package/src/services/audioService.dev.ts +22 -0
  126. package/src/services/audioService.test.ts +1226 -0
  127. package/src/services/audioService.ts +1395 -0
  128. package/src/services/automergeRepo.test.ts +269 -0
  129. package/src/services/automergeRepo.ts +226 -0
  130. package/src/services/blobTransferService.dev.ts +119 -0
  131. package/src/services/blobTransferService.test.ts +441 -0
  132. package/src/services/blobTransferService.ts +702 -0
  133. package/src/services/docIndexService.test.ts +179 -0
  134. package/src/services/docIndexService.ts +118 -0
  135. package/src/services/fileProcessingService.test.ts +554 -0
  136. package/src/services/fileProcessingService.ts +239 -0
  137. package/src/services/imageService.test.ts +701 -0
  138. package/src/services/imageService.ts +365 -0
  139. package/src/services/indexedDBService.integration.test.ts +104 -0
  140. package/src/services/indexedDBService.test.ts +202 -0
  141. package/src/services/indexedDBService.ts +436 -0
  142. package/src/services/offlineService.test.ts +661 -0
  143. package/src/services/offlineService.ts +382 -0
  144. package/src/services/p2pService.test.ts +305 -0
  145. package/src/services/p2pService.ts +344 -0
  146. package/src/services/playlistDocService.test.ts +448 -0
  147. package/src/services/playlistDocService.ts +707 -0
  148. package/src/services/playlistDownloadService.test.ts +674 -0
  149. package/src/services/playlistDownloadService.ts +389 -0
  150. package/src/services/sharingService.test.ts +812 -0
  151. package/src/services/sharingService.ts +1073 -0
  152. package/src/services/sharingState.ts +161 -0
  153. package/src/services/songReactivity.test.ts +620 -0
  154. package/src/services/songReactivity.ts +145 -0
  155. package/src/services/standaloneService.test.ts +1025 -0
  156. package/src/services/standaloneService.ts +588 -0
  157. package/src/services/streamingAudioService.test.ts +275 -0
  158. package/src/services/streamingAudioService.ts +166 -0
  159. package/src/styles.css +428 -0
  160. package/src/test-setup.ts +547 -0
  161. package/src/types/global.d.ts +40 -0
  162. package/src/types/playlist.ts +99 -0
  163. package/src/utils/hashUtils.ts +41 -0
  164. package/src/utils/log.ts +97 -0
  165. package/src/utils/m3u.test.ts +172 -0
  166. package/src/utils/m3u.ts +136 -0
  167. package/src/utils/mockData.ts +166 -0
  168. package/src/utils/standaloneTemplates.test.ts +175 -0
  169. package/src/utils/standaloneTemplates.ts +83 -0
  170. package/src/utils/swTemplate.ts +84 -0
  171. package/src/utils/timeUtils.ts +166 -0
  172. package/src/utils/typeGuards.ts +171 -0
  173. package/src/web-component.tsx +98 -0
  174. package/src/zip-bundle/index.ts +7 -0
  175. package/src/zip-bundle/m3u.ts +45 -0
  176. package/src/zip-bundle/types.ts +50 -0
  177. package/src/zip-bundle/utils.ts +33 -0
  178. package/src/zip-bundle/zipBuilder.ts +309 -0
  179. package/tailwind.config.js +55 -0
  180. package/tsconfig.json +43 -0
@@ -0,0 +1,812 @@
1
+ // tests for the p2p sharing service (phase 5).
2
+ //
3
+ // uses fake-indexeddb for the real docIndex/knocks/grants/settings stores,
4
+ // with mocked p2pService, automergeRepo, and blobTransferService. protocol
5
+ // streams are scripted in-memory BiStreamLike objects.
6
+
7
+ import { describe, it, expect, beforeEach, vi } from "vitest";
8
+ import "fake-indexeddb/auto";
9
+ import { IDBFactory } from "fake-indexeddb";
10
+ import {
11
+ PLAYLISTZ_ALPN,
12
+ encodeMessage,
13
+ decodeMessage,
14
+ decodeShareToken,
15
+ type Message,
16
+ type BiStreamLike,
17
+ } from "@freqhole/api-client/playlistz";
18
+
19
+ // --- mocks (hoisted before module imports) ---
20
+
21
+ const { docs, adapter, p2p, blobs } = vi.hoisted(() => {
22
+ // docId -> mutable doc object served by the mocked findPlaylistDoc
23
+ const docs = new Map<string, Record<string, unknown>>();
24
+ const adapter = {
25
+ addPeer: vi.fn(async (_nodeId: string) => {}),
26
+ isConnected: vi.fn(() => false),
27
+ registerAlpnHandler: vi.fn(),
28
+ };
29
+ const p2p = {
30
+ startP2P: vi.fn(async () => {}),
31
+ getIdentity: vi.fn(() => ({ node_id: "me-node" })),
32
+ getNode: vi.fn((): unknown => null),
33
+ isLeader: vi.fn(() => true),
34
+ onLeadershipChange: vi.fn((cb: (leader: boolean) => void) => {
35
+ cb(true);
36
+ return () => {};
37
+ }),
38
+ hasExistingIdentity: vi.fn(async () => false),
39
+ waitForNode: vi.fn(async (): Promise<unknown> => null),
40
+ };
41
+ const blobs = {
42
+ serveBlobRequest: vi.fn(async () => {}),
43
+ };
44
+ return { docs, adapter, p2p, blobs };
45
+ });
46
+
47
+ vi.mock("./automergeRepo.js", () => ({
48
+ getIrohAdapter: () => adapter,
49
+ authorizePeerForDoc: vi.fn(),
50
+ findPlaylistDoc: vi.fn(async (docId: string) => {
51
+ const doc = docs.get(docId);
52
+ if (!doc) throw new Error(`doc not found: ${docId}`);
53
+ return {
54
+ doc: () => doc,
55
+ change: (cb: (d: Record<string, unknown>) => void) => cb(doc),
56
+ };
57
+ }),
58
+ flushDoc: vi.fn(async () => {}),
59
+ }));
60
+
61
+ vi.mock("./p2pService.js", () => ({
62
+ startP2P: p2p.startP2P,
63
+ getIdentity: p2p.getIdentity,
64
+ getNode: p2p.getNode,
65
+ isLeader: p2p.isLeader,
66
+ onLeadershipChange: p2p.onLeadershipChange,
67
+ hasExistingIdentity: p2p.hasExistingIdentity,
68
+ waitForNode: p2p.waitForNode,
69
+ }));
70
+
71
+ vi.mock("./blobTransferService.js", () => ({
72
+ serveBlobRequest: blobs.serveBlobRequest,
73
+ }));
74
+
75
+ import {
76
+ getShareSettings,
77
+ saveShareSettings,
78
+ ensureSharingReady,
79
+ reconnectKnownPeers,
80
+ buildShareLink,
81
+ openShareLink,
82
+ handleShareFragment,
83
+ queryPeerPlaylists,
84
+ knockOnPeer,
85
+ knockForDocAccess,
86
+ acceptKnock,
87
+ denyKnock,
88
+ getInboundKnocks,
89
+ handlePlaylistzStream,
90
+ _resetSharingForTests,
91
+ } from "./sharingService.js";
92
+ import { resetDBCache } from "./indexedDBService.js";
93
+ import {
94
+ addDocIndexEntry,
95
+ getDocIndexEntry,
96
+ getAllKnocks,
97
+ getAccessGrant,
98
+ upsertAccessGrant,
99
+ } from "./docIndexService.js";
100
+ import { flushDoc } from "./automergeRepo.js";
101
+
102
+ // scripted bidirectional stream: replies are read in order, everything
103
+ // written by the code under test is collected in `sent`
104
+ class MockStream implements BiStreamLike {
105
+ sent: Message[] = [];
106
+ closed = false;
107
+ private incoming: Message[];
108
+
109
+ constructor(
110
+ private peer: string,
111
+ incoming: Message[] = []
112
+ ) {
113
+ this.incoming = [...incoming];
114
+ }
115
+
116
+ async write_message(data: Uint8Array): Promise<void> {
117
+ this.sent.push(decodeMessage(data));
118
+ }
119
+
120
+ async read_message(): Promise<Uint8Array | null> {
121
+ const msg = this.incoming.shift();
122
+ return msg === undefined ? null : encodeMessage(msg);
123
+ }
124
+
125
+ close(): void {
126
+ this.closed = true;
127
+ }
128
+
129
+ peer_node_id(): string {
130
+ return this.peer;
131
+ }
132
+
133
+ alpn(): string {
134
+ return PLAYLISTZ_ALPN;
135
+ }
136
+ }
137
+
138
+ function makeDoc(
139
+ docId: string,
140
+ overrides: Record<string, unknown> = {}
141
+ ): Record<string, unknown> {
142
+ const doc = {
143
+ title: "tunez",
144
+ songs: {},
145
+ peers: {},
146
+ ...overrides,
147
+ };
148
+ docs.set(docId, doc);
149
+ return doc;
150
+ }
151
+
152
+ const DOC_ID = "automerge:abc123";
153
+
154
+ describe("sharingService", () => {
155
+ beforeEach(() => {
156
+ globalThis.indexedDB = new IDBFactory();
157
+ resetDBCache();
158
+ _resetSharingForTests();
159
+ docs.clear();
160
+ vi.clearAllMocks();
161
+ p2p.getIdentity.mockReturnValue({ node_id: "me-node" });
162
+ p2p.getNode.mockReturnValue(null);
163
+ p2p.hasExistingIdentity.mockResolvedValue(false);
164
+ window.location.hash = "";
165
+ });
166
+
167
+ describe("share settings", () => {
168
+ it("defaults to knock mode with empty name", async () => {
169
+ expect(await getShareSettings()).toEqual({ name: "", mode: "knock" });
170
+ });
171
+
172
+ it("round-trips saved settings", async () => {
173
+ await saveShareSettings({ name: "edward", mode: "public" });
174
+ expect(await getShareSettings()).toEqual({
175
+ name: "edward",
176
+ mode: "public",
177
+ });
178
+ });
179
+ });
180
+
181
+ describe("ensureSharingReady", () => {
182
+ it("starts p2p and registers the protocol handler once", async () => {
183
+ await ensureSharingReady();
184
+ await ensureSharingReady();
185
+ expect(p2p.startP2P).toHaveBeenCalledTimes(2);
186
+ expect(adapter.registerAlpnHandler).toHaveBeenCalledTimes(1);
187
+ expect(adapter.registerAlpnHandler).toHaveBeenCalledWith(
188
+ PLAYLISTZ_ALPN,
189
+ expect.any(Function)
190
+ );
191
+ });
192
+ });
193
+
194
+ describe("reconnectKnownPeers", () => {
195
+ it("dials every peer recorded in indexed docs, excluding self", async () => {
196
+ makeDoc(DOC_ID, { peers: { "me-node": {}, "peer-a": {}, "peer-b": {} } });
197
+ await addDocIndexEntry({
198
+ docId: DOC_ID,
199
+ title: "tunez",
200
+ addedAt: 1,
201
+ source: "local",
202
+ });
203
+
204
+ await reconnectKnownPeers();
205
+
206
+ const dialed = adapter.addPeer.mock.calls.map((c) => c[0]);
207
+ expect(dialed.sort()).toEqual(["peer-a", "peer-b"]);
208
+ });
209
+ });
210
+
211
+ describe("share links", () => {
212
+ it("builds a decodable share link with our node id", async () => {
213
+ const { token, url, fragment } = await buildShareLink(DOC_ID, "tunez");
214
+ const decoded = decodeShareToken(token);
215
+ expect(decoded).toMatchObject({
216
+ v: 1,
217
+ n: "me-node",
218
+ d: DOC_ID,
219
+ t: "tunez",
220
+ });
221
+ expect(fragment.startsWith("#share/")).toBe(true);
222
+ expect(url.endsWith(fragment)).toBe(true);
223
+ });
224
+
225
+ it("throws without a node identity", async () => {
226
+ p2p.getIdentity.mockReturnValue(
227
+ null as unknown as { node_id: string }
228
+ );
229
+ await expect(buildShareLink(DOC_ID)).rejects.toThrow(/node id/);
230
+ });
231
+
232
+ it("opens a share link: dials peer, records self, indexes doc", async () => {
233
+ await saveShareSettings({ name: "", mode: "public" });
234
+ const doc = makeDoc(DOC_ID, { title: "their tunez" });
235
+ const { token } = await buildShareLink(DOC_ID);
236
+ // simulate receiving someone else's link
237
+ vi.clearAllMocks();
238
+ p2p.getIdentity.mockReturnValue({ node_id: "me-node" });
239
+
240
+ const result = await openShareLink(token);
241
+
242
+ expect(result).toEqual({ status: "synced", docId: DOC_ID });
243
+ expect(adapter.addPeer).toHaveBeenCalledWith("me-node");
244
+ expect(doc.peers).toHaveProperty("me-node");
245
+ expect(flushDoc).toHaveBeenCalledWith(DOC_ID);
246
+ const entry = await getDocIndexEntry(DOC_ID);
247
+ expect(entry?.source).toBe("shared");
248
+ expect(entry?.title).toBe("their tunez");
249
+ });
250
+
251
+ it("returns knock_required when link was created in knock mode", async () => {
252
+ await saveShareSettings({ name: "", mode: "knock" });
253
+ makeDoc(DOC_ID);
254
+ const { token } = await buildShareLink(DOC_ID, "private tunez");
255
+ // reset identity for recipient
256
+ vi.clearAllMocks();
257
+ p2p.getIdentity.mockReturnValue({ node_id: "me-node" });
258
+
259
+ const result = await openShareLink(token);
260
+
261
+ expect(result).toEqual({
262
+ status: "knock_required",
263
+ ownerNodeId: "me-node",
264
+ docId: DOC_ID,
265
+ title: "private tunez",
266
+ });
267
+ // doc should not have been synced
268
+ expect(adapter.addPeer).not.toHaveBeenCalled();
269
+ expect(await getDocIndexEntry(DOC_ID)).toBeUndefined();
270
+ });
271
+
272
+ it("skips knock gate when doc is already local", async () => {
273
+ await saveShareSettings({ name: "", mode: "knock" });
274
+ makeDoc(DOC_ID, { title: "mine" });
275
+ await addDocIndexEntry({ docId: DOC_ID, title: "mine", addedAt: 1, source: "local" });
276
+ const { token } = await buildShareLink(DOC_ID);
277
+
278
+ const result = await openShareLink(token);
279
+
280
+ expect(result).toEqual({ status: "synced", docId: DOC_ID });
281
+ });
282
+
283
+ it("does not duplicate an existing index entry", async () => {
284
+ makeDoc(DOC_ID);
285
+ await addDocIndexEntry({
286
+ docId: DOC_ID,
287
+ title: "already here",
288
+ addedAt: 42,
289
+ source: "local",
290
+ });
291
+ // public mode so no knock gate
292
+ await saveShareSettings({ name: "", mode: "public" });
293
+ const { token } = await buildShareLink(DOC_ID);
294
+
295
+ await openShareLink(token);
296
+
297
+ const entry = await getDocIndexEntry(DOC_ID);
298
+ expect(entry?.title).toBe("already here");
299
+ expect(entry?.source).toBe("local");
300
+ });
301
+
302
+ it("rejects garbage input", async () => {
303
+ await expect(openShareLink("not a link!!!")).rejects.toThrow(
304
+ /invalid share link/
305
+ );
306
+ });
307
+
308
+ it("handleShareFragment opens #share/ links and clears the hash", async () => {
309
+ await saveShareSettings({ name: "", mode: "public" });
310
+ makeDoc(DOC_ID);
311
+ const { fragment } = await buildShareLink(DOC_ID);
312
+ window.location.hash = fragment;
313
+ // the test setup mocks window.location as a plain object, so emulate
314
+ // the browser's replaceState -> location sync here
315
+ const replaceState = vi
316
+ .spyOn(history, "replaceState")
317
+ .mockImplementation(() => {
318
+ window.location.hash = "";
319
+ });
320
+
321
+ const result = await handleShareFragment();
322
+
323
+ expect(result).toEqual({ status: "synced", docId: DOC_ID });
324
+ expect(replaceState).toHaveBeenCalled();
325
+ expect(window.location.hash).toBe("");
326
+ expect(await getDocIndexEntry(DOC_ID)).toBeTruthy();
327
+ replaceState.mockRestore();
328
+ });
329
+
330
+ it("handleShareFragment returns knock_required and clears the hash for knock-mode links", async () => {
331
+ await saveShareSettings({ name: "", mode: "knock" });
332
+ makeDoc(DOC_ID);
333
+ const { fragment } = await buildShareLink(DOC_ID, "secret tunez");
334
+ window.location.hash = fragment;
335
+ const replaceState = vi
336
+ .spyOn(history, "replaceState")
337
+ .mockImplementation(() => { window.location.hash = ""; });
338
+
339
+ const result = await handleShareFragment();
340
+
341
+ expect(result?.status).toBe("knock_required");
342
+ expect(window.location.hash).toBe("");
343
+ expect(await getDocIndexEntry(DOC_ID)).toBeUndefined();
344
+ replaceState.mockRestore();
345
+ });
346
+
347
+ it("handleShareFragment is a no-op without a share fragment", async () => {
348
+ expect(await handleShareFragment()).toBeNull();
349
+ });
350
+ });
351
+
352
+ describe("protocol responder", () => {
353
+ it("answers hello with hello_ok and our settings", async () => {
354
+ await saveShareSettings({ name: "edward", mode: "public" });
355
+ const stream = new MockStream("peer-a", [
356
+ { v: 1, type: "hello", nodeId: "peer-a" },
357
+ ]);
358
+
359
+ await handlePlaylistzStream(stream);
360
+
361
+ expect(stream.sent).toEqual([
362
+ {
363
+ v: 1,
364
+ type: "hello_ok",
365
+ nodeId: "me-node",
366
+ name: "edward",
367
+ public: true,
368
+ },
369
+ ]);
370
+ expect(stream.closed).toBe(true);
371
+ });
372
+
373
+ it("requires a knock for list_playlists in knock mode", async () => {
374
+ const stream = new MockStream("peer-a", [
375
+ { v: 1, type: "list_playlists" },
376
+ ]);
377
+
378
+ await handlePlaylistzStream(stream);
379
+
380
+ expect(stream.sent[0]).toMatchObject({
381
+ type: "error",
382
+ code: "knock_required",
383
+ });
384
+ });
385
+
386
+ it("lists playlists in public mode", async () => {
387
+ await saveShareSettings({ name: "", mode: "public" });
388
+ makeDoc(DOC_ID, { title: "tunez", songs: { s1: {}, s2: {} } });
389
+ await addDocIndexEntry({
390
+ docId: DOC_ID,
391
+ title: "tunez",
392
+ addedAt: 1,
393
+ source: "local",
394
+ });
395
+ const stream = new MockStream("peer-a", [
396
+ { v: 1, type: "list_playlists" },
397
+ ]);
398
+
399
+ await handlePlaylistzStream(stream);
400
+
401
+ expect(stream.sent[0]).toEqual({
402
+ v: 1,
403
+ type: "playlists",
404
+ items: [{ docId: DOC_ID, title: "tunez", songCount: 2 }],
405
+ });
406
+ });
407
+
408
+ it("scopes the listing to a grant's docIds in knock mode", async () => {
409
+ makeDoc(DOC_ID, { title: "granted" });
410
+ makeDoc("automerge:other", { title: "private" });
411
+ await addDocIndexEntry({
412
+ docId: DOC_ID,
413
+ title: "granted",
414
+ addedAt: 1,
415
+ source: "local",
416
+ });
417
+ await addDocIndexEntry({
418
+ docId: "automerge:other",
419
+ title: "private",
420
+ addedAt: 2,
421
+ source: "local",
422
+ });
423
+ await upsertAccessGrant({
424
+ nodeId: "peer-a",
425
+ name: "",
426
+ grantedAt: 1,
427
+ docIds: [DOC_ID],
428
+ });
429
+ const stream = new MockStream("peer-a", [
430
+ { v: 1, type: "list_playlists" },
431
+ ]);
432
+
433
+ await handlePlaylistzStream(stream);
434
+
435
+ expect(stream.sent[0]).toMatchObject({
436
+ type: "playlists",
437
+ items: [{ docId: DOC_ID, title: "granted", songCount: 0 }],
438
+ });
439
+ });
440
+
441
+ it("records an inbound knock and replies pending", async () => {
442
+ const stream = new MockStream("peer-a", [
443
+ { v: 1, type: "knock", nodeId: "peer-a", name: "viz", message: "yo" },
444
+ ]);
445
+
446
+ await handlePlaylistzStream(stream);
447
+
448
+ expect(stream.sent[0]).toEqual({
449
+ v: 1,
450
+ type: "knock_status",
451
+ status: "pending",
452
+ });
453
+ const knocks = await getInboundKnocks();
454
+ expect(knocks).toHaveLength(1);
455
+ expect(knocks[0]).toMatchObject({
456
+ nodeId: "peer-a",
457
+ name: "viz",
458
+ message: "yo",
459
+ status: "pending",
460
+ knockType: "browse",
461
+ });
462
+ });
463
+
464
+ it("records an inbound doc_access knock with the requested docId", async () => {
465
+ const stream = new MockStream("peer-a", [
466
+ {
467
+ v: 1,
468
+ type: "knock",
469
+ nodeId: "peer-a",
470
+ name: "viz",
471
+ message: "let me in",
472
+ knockType: "doc_access",
473
+ docId: DOC_ID,
474
+ },
475
+ ]);
476
+
477
+ await handlePlaylistzStream(stream);
478
+
479
+ expect(stream.sent[0]).toMatchObject({ type: "knock_status", status: "pending" });
480
+ const knocks = await getInboundKnocks();
481
+ expect(knocks[0]).toMatchObject({
482
+ knockType: "doc_access",
483
+ requestedDocId: DOC_ID,
484
+ status: "pending",
485
+ });
486
+ });
487
+
488
+ it("queues doc_access knock as pending even when peer has a grant (no collaborative flag)", async () => {
489
+ makeDoc(DOC_ID);
490
+ await upsertAccessGrant({
491
+ nodeId: "peer-a",
492
+ name: "",
493
+ grantedAt: 1,
494
+ docIds: [DOC_ID],
495
+ });
496
+ const stream = new MockStream("peer-a", [
497
+ { v: 1, type: "knock", nodeId: "peer-a", knockType: "doc_access", docId: DOC_ID },
498
+ ]);
499
+
500
+ await handlePlaylistzStream(stream);
501
+
502
+ // without collaborative flag the owner must approve explicitly
503
+ expect(stream.sent[0]).toMatchObject({ type: "knock_status", status: "pending" });
504
+ const knocks = await getInboundKnocks();
505
+ expect(knocks[0]).toMatchObject({ knockType: "doc_access", requestedDocId: DOC_ID, status: "pending" });
506
+ });
507
+
508
+ it("auto-accepts doc_access knock when collaborative is true and peer has a grant", async () => {
509
+ makeDoc(DOC_ID, { collaborative: true });
510
+ await upsertAccessGrant({
511
+ nodeId: "peer-a",
512
+ name: "",
513
+ grantedAt: 1,
514
+ docIds: [DOC_ID],
515
+ });
516
+ const stream = new MockStream("peer-a", [
517
+ { v: 1, type: "knock", nodeId: "peer-a", knockType: "doc_access", docId: DOC_ID },
518
+ ]);
519
+
520
+ await handlePlaylistzStream(stream);
521
+
522
+ expect(stream.sent[0]).toEqual({
523
+ v: 1,
524
+ type: "knock_status",
525
+ status: "accepted",
526
+ grantedDocIds: [DOC_ID],
527
+ });
528
+ });
529
+
530
+ it("auto-accepts doc_access knock when collaborative is true and mode is public", async () => {
531
+ await saveShareSettings({ name: "", mode: "public" });
532
+ makeDoc(DOC_ID, { collaborative: true });
533
+ const stream = new MockStream("peer-a", [
534
+ { v: 1, type: "knock", nodeId: "peer-a", knockType: "doc_access", docId: DOC_ID },
535
+ ]);
536
+
537
+ await handlePlaylistzStream(stream);
538
+
539
+ expect(stream.sent[0]).toEqual({
540
+ v: 1,
541
+ type: "knock_status",
542
+ status: "accepted",
543
+ grantedDocIds: [DOC_ID],
544
+ });
545
+ });
546
+
547
+ it("browse and doc_access knocks from same node are tracked separately", async () => {
548
+ const browseStream = new MockStream("peer-a", [
549
+ { v: 1, type: "knock", nodeId: "peer-a" },
550
+ ]);
551
+ await handlePlaylistzStream(browseStream);
552
+
553
+ const docStream = new MockStream("peer-a", [
554
+ { v: 1, type: "knock", nodeId: "peer-a", knockType: "doc_access", docId: DOC_ID },
555
+ ]);
556
+ await handlePlaylistzStream(docStream);
557
+
558
+ const knocks = await getInboundKnocks();
559
+ expect(knocks).toHaveLength(2);
560
+ expect(knocks.find((k) => k.knockType === "browse")).toBeDefined();
561
+ expect(knocks.find((k) => k.knockType === "doc_access")).toBeDefined();
562
+ });
563
+
564
+ it("does not duplicate a repeated knock", async () => {
565
+ for (let i = 0; i < 2; i++) {
566
+ const stream = new MockStream("peer-a", [
567
+ { v: 1, type: "knock", nodeId: "peer-a" },
568
+ ]);
569
+ await handlePlaylistzStream(stream);
570
+ }
571
+ expect(await getInboundKnocks()).toHaveLength(1);
572
+ });
573
+
574
+ it("answers accepted with granted docIds when a grant exists", async () => {
575
+ await upsertAccessGrant({
576
+ nodeId: "peer-a",
577
+ name: "",
578
+ grantedAt: 1,
579
+ docIds: [DOC_ID],
580
+ });
581
+ const stream = new MockStream("peer-a", [
582
+ { v: 1, type: "knock", nodeId: "peer-a" },
583
+ ]);
584
+
585
+ await handlePlaylistzStream(stream);
586
+
587
+ expect(stream.sent[0]).toEqual({
588
+ v: 1,
589
+ type: "knock_status",
590
+ status: "accepted",
591
+ grantedDocIds: [DOC_ID],
592
+ });
593
+ });
594
+
595
+ it("answers denied after a knock was rejected", async () => {
596
+ const knockStream = new MockStream("peer-a", [
597
+ { v: 1, type: "knock", nodeId: "peer-a" },
598
+ ]);
599
+ await handlePlaylistzStream(knockStream);
600
+ const knock = (await getInboundKnocks())[0]!;
601
+ await denyKnock(knock.id);
602
+
603
+ const retry = new MockStream("peer-a", [
604
+ { v: 1, type: "knock", nodeId: "peer-a" },
605
+ ]);
606
+ await handlePlaylistzStream(retry);
607
+
608
+ expect(retry.sent[0]).toEqual({
609
+ v: 1,
610
+ type: "knock_status",
611
+ status: "denied",
612
+ });
613
+ });
614
+
615
+ it("dispatches blob_request to the blob transfer service", async () => {
616
+ await saveShareSettings({ name: "", mode: "public" });
617
+ const stream = new MockStream("peer-a", [
618
+ { v: 1, type: "blob_request", sha256: "deadbeef" },
619
+ ]);
620
+
621
+ await handlePlaylistzStream(stream);
622
+
623
+ expect(blobs.serveBlobRequest).toHaveBeenCalledWith(
624
+ stream,
625
+ "deadbeef"
626
+ );
627
+ });
628
+
629
+ it("rejects unexpected message types", async () => {
630
+ const stream = new MockStream("peer-a", [
631
+ { v: 1, type: "playlists", items: [] },
632
+ ]);
633
+
634
+ await handlePlaylistzStream(stream);
635
+
636
+ expect(stream.sent[0]).toMatchObject({
637
+ type: "error",
638
+ code: "unexpected_message",
639
+ });
640
+ });
641
+ });
642
+
643
+ describe("knock requester", () => {
644
+ function givePeerNode(stream: MockStream): void {
645
+ p2p.getNode.mockReturnValue({
646
+ open_bi: vi.fn(async () => stream),
647
+ });
648
+ }
649
+
650
+ it("queryPeerPlaylists returns the peer's listing", async () => {
651
+ const stream = new MockStream("peer-a", [
652
+ { v: 1, type: "hello_ok", nodeId: "peer-a", name: "viz", public: true },
653
+ {
654
+ v: 1,
655
+ type: "playlists",
656
+ items: [{ docId: DOC_ID, title: "tunez", songCount: 3 }],
657
+ },
658
+ ]);
659
+ givePeerNode(stream);
660
+
661
+ const listing = await queryPeerPlaylists("peer-a");
662
+
663
+ expect(listing).toEqual({
664
+ nodeId: "peer-a",
665
+ name: "viz",
666
+ public: true,
667
+ items: [{ docId: DOC_ID, title: "tunez", songCount: 3 }],
668
+ knockRequired: false,
669
+ });
670
+ expect(stream.closed).toBe(true);
671
+ });
672
+
673
+ it("queryPeerPlaylists flags knock_required", async () => {
674
+ const stream = new MockStream("peer-a", [
675
+ { v: 1, type: "hello_ok", nodeId: "peer-a", public: false },
676
+ { v: 1, type: "error", code: "knock_required", message: "knock" },
677
+ ]);
678
+ givePeerNode(stream);
679
+
680
+ const listing = await queryPeerPlaylists("peer-a");
681
+
682
+ expect(listing.knockRequired).toBe(true);
683
+ expect(listing.items).toEqual([]);
684
+ });
685
+
686
+ it("knockOnPeer records the outbound knock and opens granted docs", async () => {
687
+ makeDoc(DOC_ID, { title: "granted tunez" });
688
+ const stream = new MockStream("peer-a", [
689
+ {
690
+ v: 1,
691
+ type: "knock_status",
692
+ status: "accepted",
693
+ grantedDocIds: [DOC_ID],
694
+ },
695
+ ]);
696
+ givePeerNode(stream);
697
+
698
+ const result = await knockOnPeer("peer-a", "lemme in");
699
+
700
+ expect(result).toEqual({ status: "accepted", docIds: [DOC_ID] });
701
+ const knocks = await getAllKnocks();
702
+ expect(knocks.find((k) => k.id === "out:peer-a")).toMatchObject({
703
+ direction: "outbound",
704
+ status: "accepted",
705
+ message: "lemme in",
706
+ });
707
+ const entry = await getDocIndexEntry(DOC_ID);
708
+ expect(entry?.source).toBe("shared");
709
+ expect(docs.get(DOC_ID)?.peers).toHaveProperty("me-node");
710
+ });
711
+
712
+ it("knockOnPeer records a pending knock", async () => {
713
+ const stream = new MockStream("peer-a", [
714
+ { v: 1, type: "knock_status", status: "pending" },
715
+ ]);
716
+ givePeerNode(stream);
717
+
718
+ const result = await knockOnPeer("peer-a");
719
+
720
+ expect(result).toEqual({ status: "pending", docIds: [] });
721
+ const knocks = await getAllKnocks();
722
+ expect(knocks.find((k) => k.id === "out:peer-a")?.status).toBe(
723
+ "pending"
724
+ );
725
+ });
726
+
727
+ it("knockForDocAccess sends a doc_access knock and syncs on acceptance", async () => {
728
+ makeDoc(DOC_ID, { title: "locked tunez" });
729
+ const stream = new MockStream("peer-a", [
730
+ {
731
+ v: 1,
732
+ type: "knock_status",
733
+ status: "accepted",
734
+ grantedDocIds: [DOC_ID],
735
+ },
736
+ ]);
737
+ givePeerNode(stream);
738
+
739
+ const result = await knockForDocAccess("peer-a", DOC_ID, "please let me in");
740
+
741
+ expect(result.status).toBe("accepted");
742
+ const sentKnock = stream.sent[0];
743
+ expect(sentKnock).toMatchObject({
744
+ type: "knock",
745
+ knockType: "doc_access",
746
+ docId: DOC_ID,
747
+ message: "please let me in",
748
+ });
749
+ const outKnock = (await getAllKnocks()).find(
750
+ (k) => k.id === `out:peer-a:doc:${DOC_ID}`
751
+ );
752
+ expect(outKnock).toMatchObject({
753
+ direction: "outbound",
754
+ knockType: "doc_access",
755
+ requestedDocId: DOC_ID,
756
+ status: "accepted",
757
+ });
758
+ // doc should have been synced + indexed
759
+ const entry = await getDocIndexEntry(DOC_ID);
760
+ expect(entry?.source).toBe("shared");
761
+ });
762
+
763
+ it("knockForDocAccess returns pending when owner queues the request", async () => {
764
+ const stream = new MockStream("peer-a", [
765
+ { v: 1, type: "knock_status", status: "pending" },
766
+ ]);
767
+ givePeerNode(stream);
768
+
769
+ const result = await knockForDocAccess("peer-a", DOC_ID, "");
770
+
771
+ expect(result.status).toBe("pending");
772
+ expect(await getDocIndexEntry(DOC_ID)).toBeUndefined();
773
+ });
774
+ });
775
+
776
+ describe("knock inbox", () => {
777
+ async function recordInboundKnock(nodeId: string): Promise<string> {
778
+ const stream = new MockStream(nodeId, [
779
+ { v: 1, type: "knock", nodeId },
780
+ ]);
781
+ await handlePlaylistzStream(stream);
782
+ const knock = (await getInboundKnocks()).find(
783
+ (k) => k.nodeId === nodeId
784
+ )!;
785
+ return knock.id;
786
+ }
787
+
788
+ it("acceptKnock persists the grant and records the peer in docs", async () => {
789
+ const doc = makeDoc(DOC_ID);
790
+ const knockId = await recordInboundKnock("peer-a");
791
+
792
+ await acceptKnock(knockId, [DOC_ID]);
793
+
794
+ const grant = await getAccessGrant("peer-a");
795
+ expect(grant?.docIds).toEqual([DOC_ID]);
796
+ expect(doc.peers).toHaveProperty("peer-a");
797
+ expect(flushDoc).toHaveBeenCalledWith(DOC_ID);
798
+ expect(adapter.addPeer).toHaveBeenCalledWith("peer-a");
799
+ const knocks = await getInboundKnocks();
800
+ expect(knocks[0]?.status).toBe("accepted");
801
+ });
802
+
803
+ it("denyKnock marks the knock rejected without a grant", async () => {
804
+ const knockId = await recordInboundKnock("peer-a");
805
+
806
+ await denyKnock(knockId);
807
+
808
+ expect(await getAccessGrant("peer-a")).toBeUndefined();
809
+ expect((await getInboundKnocks())[0]?.status).toBe("rejected");
810
+ });
811
+ });
812
+ });