@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,356 @@
1
+ // e2e: two-peer collaboration access request and collaborative-mode tests.
2
+ //
3
+ // tests here require real iroh relay connections and are tagged @p2p.
4
+ // run with: npm run test:e2e:p2p
5
+ //
6
+ // scenarios covered:
7
+ // - peer B requests edit access → knock appears in peer A's inbox → A accepts → B sees granted
8
+ // - peer A enables collaborative mode → peer B's request is auto-accepted
9
+ // - peer A denies a collaboration request → peer B sees denied status
10
+
11
+ import { test, expect } from "@playwright/test";
12
+ import {
13
+ resetAppState,
14
+ createPlaylistViaUI,
15
+ logTs,
16
+ } from "./helpers.js";
17
+
18
+ // helper: enable p2p for a page, return the share link
19
+ async function enableP2PAndGetShareLink(
20
+ page: import("@playwright/test").Page,
21
+ tag: string
22
+ ): Promise<string> {
23
+ await page.getByTestId("btn-share-playlist").click();
24
+ logTs(`[e2e] ${tag}: enabling p2p...`);
25
+ await page.getByTestId("btn-enable-sharing").click();
26
+ await expect(page.getByTestId("sharing-status")).toBeVisible({
27
+ timeout: 180_000,
28
+ });
29
+ logTs(`[e2e] ${tag}: p2p node online`);
30
+ const link = await page.locator("input[readonly]").first().inputValue();
31
+ expect(link).toContain("#share/");
32
+ return link;
33
+ }
34
+
35
+ // helper: peer B subscribes to A's share link (public mode - direct sync)
36
+ async function subscribeToPublicPlaylist(
37
+ page: import("@playwright/test").Page,
38
+ shareUrl: string,
39
+ tag: string,
40
+ expectedTitle: string
41
+ ): Promise<void> {
42
+ // close share panel if open
43
+ if (
44
+ await page
45
+ .getByTestId("share-panel")
46
+ .isVisible({ timeout: 500 })
47
+ .catch(() => false)
48
+ ) {
49
+ await page.getByTestId("btn-share-playlist").click();
50
+ }
51
+ await page.getByTestId("btn-all-playlists").click();
52
+ await page.getByTestId("all-playlists-panel").waitFor({ timeout: 5000 });
53
+ await page.getByTestId("input-search-playlists").fill(shareUrl);
54
+ logTs(`[e2e] ${tag}: opening share link...`);
55
+ await expect(page.getByTestId("all-playlists-panel")).not.toBeVisible({
56
+ timeout: 120_000,
57
+ });
58
+ logTs(`[e2e] ${tag}: subscribed`);
59
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue(
60
+ expectedTitle,
61
+ { timeout: 30_000 }
62
+ );
63
+ }
64
+
65
+ // -----------------------------------------------------------------------
66
+ // collaboration request - explicit accept
67
+ // -----------------------------------------------------------------------
68
+
69
+ test(
70
+ "peer B requests collab access, A accepts, B sees access granted @p2p",
71
+ async ({ browser }) => {
72
+ test.setTimeout(600_000);
73
+
74
+ const ctxA = await browser.newContext();
75
+ const ctxB = await browser.newContext();
76
+ const pageA = await ctxA.newPage();
77
+ const pageB = await ctxB.newPage();
78
+
79
+ const fwd =
80
+ (tag: string) => (msg: import("@playwright/test").ConsoleMessage) => {
81
+ logTs(`[${tag}] ${msg.text()}`);
82
+ };
83
+ pageA.on("console", fwd("peerA"));
84
+ pageB.on("console", fwd("peerB"));
85
+
86
+ try {
87
+ // --- peer A: create playlist in public mode ---
88
+ await resetAppState(pageA);
89
+ await createPlaylistViaUI(pageA);
90
+ await pageA
91
+ .getByTestId("input-playlist-title")
92
+ .fill("collab-test-accept");
93
+ await pageA.getByTestId("input-playlist-title").blur();
94
+ await pageA.waitForTimeout(300);
95
+
96
+ const shareUrl = await enableP2PAndGetShareLink(pageA, "peerA");
97
+
98
+ // set mode to public so peer B can subscribe without knocking
99
+ await pageA.getByTestId("btn-mode-public").click();
100
+ await expect(pageA.getByTestId("btn-mode-public")).toHaveAttribute(
101
+ "aria-pressed",
102
+ "true"
103
+ );
104
+ logTs("[e2e] peerA: mode set to public");
105
+
106
+ // close share panel
107
+ await pageA.getByTestId("btn-share-playlist").click();
108
+
109
+ // --- peer B: boot p2p and subscribe to A's playlist ---
110
+ await resetAppState(pageB);
111
+ await createPlaylistViaUI(pageB);
112
+ await enableP2PAndGetShareLink(pageB, "peerB");
113
+ // close B's own share panel before subscribing
114
+ await pageB.getByTestId("btn-share-playlist").click();
115
+
116
+ await subscribeToPublicPlaylist(
117
+ pageB,
118
+ shareUrl,
119
+ "peerB",
120
+ "collab-test-accept"
121
+ );
122
+
123
+ // B should see the subscribed banner (read only)
124
+ await expect(pageB.getByTestId("subscribed-banner")).toBeVisible();
125
+ logTs("[e2e] peerB: subscribed, read-only banner visible");
126
+
127
+ // --- peer B: request collaboration access ---
128
+ await pageB.getByTestId("btn-share-playlist").click();
129
+ await pageB.getByTestId("share-panel").waitFor({ timeout: 5000 });
130
+ await expect(
131
+ pageB.getByTestId("btn-request-collab-access")
132
+ ).toBeVisible();
133
+
134
+ await pageB
135
+ .getByTestId("input-collab-request-message")
136
+ .fill("hey, can i edit this?");
137
+ await pageB.getByTestId("btn-request-collab-access").click();
138
+ logTs("[e2e] peerB: sent collaboration request");
139
+
140
+ // B should see pending status
141
+ await expect(pageB.getByTestId("collab-request-status")).toContainText(
142
+ "waiting for owner approval",
143
+ { timeout: 30_000 }
144
+ );
145
+ logTs("[e2e] peerB: status shows waiting for approval");
146
+
147
+ // --- peer A: check knock inbox ---
148
+ await pageA.getByTestId("btn-share-playlist").click();
149
+ await pageA.getByTestId("share-panel").waitFor({ timeout: 5000 });
150
+
151
+ // wait for the knock inbox to appear on A's panel
152
+ await expect(pageA.getByTestId("knock-inbox")).toBeVisible({
153
+ timeout: 60_000,
154
+ });
155
+ logTs("[e2e] peerA: knock inbox has pending request");
156
+
157
+ // accept the knock (accept all docs)
158
+ const acceptBtn = pageA.getByRole("button", { name: /accept/ }).first();
159
+ await acceptBtn.click();
160
+ logTs("[e2e] peerA: accepted collaboration request");
161
+
162
+ // inbox should clear
163
+ await expect(pageA.getByTestId("knock-inbox")).not.toBeVisible({
164
+ timeout: 15_000,
165
+ });
166
+
167
+ // --- peer B: check if accepted via "check if accepted" button ---
168
+ // the status may already show granted if B received knock_notify
169
+ // otherwise click the check button
170
+ const statusEl = pageB.getByTestId("collab-request-status");
171
+ const alreadyGranted = await statusEl
172
+ .textContent({ timeout: 1000 })
173
+ .then((t) => t?.includes("access granted"))
174
+ .catch(() => false);
175
+
176
+ if (!alreadyGranted) {
177
+ // the "check if accepted" button appears in the outbound knocks section
178
+ const checkBtn = pageB.getByRole("button", { name: "check if accepted" });
179
+ if (await checkBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
180
+ await checkBtn.click();
181
+ logTs("[e2e] peerB: clicked check if accepted");
182
+ }
183
+ await expect(statusEl).toContainText("access granted", {
184
+ timeout: 30_000,
185
+ });
186
+ }
187
+ logTs("[e2e] peerB: access granted confirmed");
188
+ } finally {
189
+ await Promise.allSettled([ctxA.close(), ctxB.close()]);
190
+ }
191
+ }
192
+ );
193
+
194
+ // -----------------------------------------------------------------------
195
+ // collaboration request - denied
196
+ // -----------------------------------------------------------------------
197
+
198
+ test(
199
+ "peer A denies collaboration request, peer B sees denied @p2p",
200
+ async ({ browser }) => {
201
+ test.setTimeout(600_000);
202
+
203
+ const ctxA = await browser.newContext();
204
+ const ctxB = await browser.newContext();
205
+ const pageA = await ctxA.newPage();
206
+ const pageB = await ctxB.newPage();
207
+
208
+ const fwd =
209
+ (tag: string) => (msg: import("@playwright/test").ConsoleMessage) => {
210
+ logTs(`[${tag}] ${msg.text()}`);
211
+ };
212
+ pageA.on("console", fwd("peerA"));
213
+ pageB.on("console", fwd("peerB"));
214
+
215
+ try {
216
+ await resetAppState(pageA);
217
+ await createPlaylistViaUI(pageA);
218
+ await pageA.getByTestId("input-playlist-title").fill("collab-test-deny");
219
+ await pageA.getByTestId("input-playlist-title").blur();
220
+ await pageA.waitForTimeout(300);
221
+
222
+ const shareUrl = await enableP2PAndGetShareLink(pageA, "peerA");
223
+ await pageA.getByTestId("btn-mode-public").click();
224
+ await pageA.getByTestId("btn-share-playlist").click();
225
+
226
+ await resetAppState(pageB);
227
+ await createPlaylistViaUI(pageB);
228
+ await enableP2PAndGetShareLink(pageB, "peerB");
229
+ await pageB.getByTestId("btn-share-playlist").click();
230
+
231
+ await subscribeToPublicPlaylist(
232
+ pageB,
233
+ shareUrl,
234
+ "peerB",
235
+ "collab-test-deny"
236
+ );
237
+
238
+ // B requests access
239
+ await pageB.getByTestId("btn-share-playlist").click();
240
+ await pageB.getByTestId("share-panel").waitFor({ timeout: 5000 });
241
+ await pageB.getByTestId("btn-request-collab-access").click();
242
+ logTs("[e2e] peerB: sent collaboration request");
243
+
244
+ await expect(pageB.getByTestId("collab-request-status")).toContainText(
245
+ "waiting for owner approval",
246
+ { timeout: 30_000 }
247
+ );
248
+
249
+ // A denies
250
+ await pageA.getByTestId("btn-share-playlist").click();
251
+ await pageA.getByTestId("share-panel").waitFor({ timeout: 5000 });
252
+ await expect(pageA.getByTestId("knock-inbox")).toBeVisible({
253
+ timeout: 60_000,
254
+ });
255
+
256
+ const denyBtn = pageA.getByRole("button", { name: "deny" }).first();
257
+ await denyBtn.click();
258
+ logTs("[e2e] peerA: denied collaboration request");
259
+
260
+ // B retries to get denied status
261
+ const checkBtn = pageB.getByRole("button", { name: "check if accepted" });
262
+ if (await checkBtn.isVisible({ timeout: 10_000 }).catch(() => false)) {
263
+ await checkBtn.click();
264
+ await expect(pageB.getByTestId("collab-request-status")).toContainText(
265
+ "access denied",
266
+ { timeout: 30_000 }
267
+ );
268
+ logTs("[e2e] peerB: denied status confirmed");
269
+ }
270
+ } finally {
271
+ await Promise.allSettled([ctxA.close(), ctxB.close()]);
272
+ }
273
+ }
274
+ );
275
+
276
+ // -----------------------------------------------------------------------
277
+ // collaborative mode - auto-accept
278
+ // -----------------------------------------------------------------------
279
+
280
+ test(
281
+ "collaborative mode auto-accepts edit request from subscriber @p2p",
282
+ async ({ browser }) => {
283
+ test.setTimeout(600_000);
284
+
285
+ const ctxA = await browser.newContext();
286
+ const ctxB = await browser.newContext();
287
+ const pageA = await ctxA.newPage();
288
+ const pageB = await ctxB.newPage();
289
+
290
+ const fwd =
291
+ (tag: string) => (msg: import("@playwright/test").ConsoleMessage) => {
292
+ logTs(`[${tag}] ${msg.text()}`);
293
+ };
294
+ pageA.on("console", fwd("peerA"));
295
+ pageB.on("console", fwd("peerB"));
296
+
297
+ try {
298
+ await resetAppState(pageA);
299
+ await createPlaylistViaUI(pageA);
300
+ await pageA
301
+ .getByTestId("input-playlist-title")
302
+ .fill("collab-test-auto");
303
+ await pageA.getByTestId("input-playlist-title").blur();
304
+ await pageA.waitForTimeout(300);
305
+
306
+ const shareUrl = await enableP2PAndGetShareLink(pageA, "peerA");
307
+
308
+ // set mode to public + enable collaborative editing
309
+ await pageA.getByTestId("btn-mode-public").click();
310
+ await pageA.getByTestId("btn-toggle-collaborative").click();
311
+ await expect(
312
+ pageA.getByTestId("btn-toggle-collaborative")
313
+ ).toHaveAttribute("aria-pressed", "true");
314
+ logTs("[e2e] peerA: mode=public, collaborative=on");
315
+
316
+ await pageA.getByTestId("btn-share-playlist").click();
317
+
318
+ // peer B subscribes
319
+ await resetAppState(pageB);
320
+ await createPlaylistViaUI(pageB);
321
+ await enableP2PAndGetShareLink(pageB, "peerB");
322
+ await pageB.getByTestId("btn-share-playlist").click();
323
+
324
+ await subscribeToPublicPlaylist(
325
+ pageB,
326
+ shareUrl,
327
+ "peerB",
328
+ "collab-test-auto"
329
+ );
330
+
331
+ // B requests collaboration access
332
+ await pageB.getByTestId("btn-share-playlist").click();
333
+ await pageB.getByTestId("share-panel").waitFor({ timeout: 5000 });
334
+ await pageB.getByTestId("btn-request-collab-access").click();
335
+ logTs("[e2e] peerB: sent collaboration request (should auto-accept)");
336
+
337
+ // should get immediate "access granted" since collaborative is on
338
+ await expect(pageB.getByTestId("collab-request-status")).toContainText(
339
+ "access granted",
340
+ { timeout: 60_000 }
341
+ );
342
+ logTs("[e2e] peerB: access granted automatically");
343
+
344
+ // no knock should appear in A's inbox (auto-accepted server-side)
345
+ // wait a moment then confirm knock inbox is absent
346
+ await pageA.getByTestId("btn-share-playlist").click();
347
+ await pageA.getByTestId("share-panel").waitFor({ timeout: 5000 });
348
+ await expect(pageA.getByTestId("knock-inbox")).not.toBeVisible({
349
+ timeout: 5000,
350
+ });
351
+ logTs("[e2e] peerA: knock inbox empty (auto-accepted, no pending knocks)");
352
+ } finally {
353
+ await Promise.allSettled([ctxA.close(), ctxB.close()]);
354
+ }
355
+ }
356
+ );