@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,220 @@
1
+ // e2e: all-playlists panel - hamburger button, row interactions, keyboard nav.
2
+
3
+ import { test, expect } from "@playwright/test";
4
+ import {
5
+ resetAppState,
6
+ createPlaylistViaUI,
7
+ addSongs,
8
+ } from "./helpers.js";
9
+
10
+ test.beforeEach(async ({ page }) => {
11
+ await resetAppState(page);
12
+ });
13
+
14
+ // --- panel open / close ---
15
+
16
+ test("hamburger opens the all-playlists panel", async ({ page }) => {
17
+ await createPlaylistViaUI(page);
18
+ // rename so we can identify it
19
+ await page.getByTestId("input-playlist-title").fill("alpha");
20
+ await page.getByTestId("input-playlist-title").blur();
21
+
22
+ // create a second playlist so the panel has something to show
23
+ await page.getByTestId("btn-all-playlists").click();
24
+ await page.getByTestId("btn-new-playlist").click();
25
+ await page.getByTestId("btn-edit-playlist").waitFor({ timeout: 5000 });
26
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue("new playlist");
27
+ await page.getByTestId("input-playlist-title").fill("beta");
28
+ await page.getByTestId("input-playlist-title").blur();
29
+
30
+ // open panel again - should see the panel
31
+ await page.getByTestId("btn-all-playlists").click();
32
+ await expect(page.getByTestId("all-playlists-panel")).toBeVisible();
33
+ });
34
+
35
+ test("escape closes the all-playlists panel", async ({ page }) => {
36
+ await createPlaylistViaUI(page);
37
+ await page.getByTestId("btn-all-playlists").click();
38
+ await expect(page.getByTestId("all-playlists-panel")).toBeVisible();
39
+ await page.keyboard.press("Escape");
40
+ await expect(page.getByTestId("all-playlists-panel")).not.toBeVisible();
41
+ });
42
+
43
+ test("hamburger button closes the all-playlists panel when open", async ({ page }) => {
44
+ await createPlaylistViaUI(page);
45
+ await page.getByTestId("btn-all-playlists").click();
46
+ await expect(page.getByTestId("all-playlists-panel")).toBeVisible();
47
+ await page.getByTestId("btn-all-playlists").click();
48
+ await expect(page.getByTestId("all-playlists-panel")).not.toBeVisible();
49
+ });
50
+
51
+ // --- row contents ---
52
+
53
+ test("selected playlist is not shown in panel rows", async ({ page }) => {
54
+ await createPlaylistViaUI(page);
55
+ await page.getByTestId("input-playlist-title").fill("selected one");
56
+ await page.getByTestId("input-playlist-title").blur();
57
+ await page.waitForTimeout(300);
58
+
59
+ // create a second playlist so panel has rows to show
60
+ await page.getByTestId("btn-all-playlists").click();
61
+ await page.getByTestId("btn-new-playlist").click();
62
+ await page.getByTestId("btn-edit-playlist").waitFor();
63
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue("new playlist");
64
+ await page.getByTestId("input-playlist-title").fill("other one");
65
+ await page.getByTestId("input-playlist-title").blur();
66
+ await page.waitForTimeout(300);
67
+
68
+ // go back to selected one
69
+ await page.getByTestId("btn-all-playlists").click();
70
+ // find and click "selected one" row
71
+ await page.getByText("selected one").first().click();
72
+ await page.waitForTimeout(300);
73
+
74
+ // open panel - "selected one" should NOT appear in the row list
75
+ await page.getByTestId("btn-all-playlists").click();
76
+ // the mini header shows the current playlist title ("selected one")
77
+ // but it should NOT appear as a clickable row
78
+ const rows = page.locator("[title*='play selected one']");
79
+ await expect(rows).toHaveCount(0);
80
+ // "other one" should be present as a row
81
+ await expect(page.getByText("other one").first()).toBeVisible();
82
+ });
83
+
84
+ test("all other playlists are shown in panel rows", async ({ page }) => {
85
+ // create three playlists
86
+ await createPlaylistViaUI(page);
87
+ await page.getByTestId("input-playlist-title").fill("playlist a");
88
+ await page.getByTestId("input-playlist-title").blur();
89
+
90
+ for (const name of ["playlist b", "playlist c"]) {
91
+ await page.getByTestId("btn-all-playlists").click();
92
+ await page.getByTestId("btn-new-playlist").click();
93
+ await page.getByTestId("btn-edit-playlist").waitFor();
94
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue("new playlist");
95
+ await page.getByTestId("input-playlist-title").fill(name);
96
+ await page.getByTestId("input-playlist-title").blur();
97
+ // wait for the doc to reflect the new title before navigating away
98
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue(name);
99
+ }
100
+
101
+ // currently selected is "playlist c" - panel should show a and b
102
+ await page.getByTestId("btn-all-playlists").click();
103
+ await expect(page.getByText("playlist a")).toBeVisible();
104
+ await expect(page.getByText("playlist b")).toBeVisible();
105
+ });
106
+
107
+ // --- row navigation ---
108
+
109
+ test("clicking a row selects the playlist and closes the panel", async ({ page }) => {
110
+ await createPlaylistViaUI(page);
111
+ await page.getByTestId("input-playlist-title").fill("first");
112
+ await page.getByTestId("input-playlist-title").blur();
113
+ await page.waitForTimeout(300);
114
+
115
+ await page.getByTestId("btn-all-playlists").click();
116
+ await page.getByTestId("btn-new-playlist").click();
117
+ await page.getByTestId("btn-edit-playlist").waitFor();
118
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue("new playlist");
119
+ await page.getByTestId("input-playlist-title").fill("second");
120
+ await page.getByTestId("input-playlist-title").blur();
121
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue("second");
122
+
123
+ // open panel and click "first"
124
+ await page.getByTestId("btn-all-playlists").click();
125
+ await page.getByText("first").first().click();
126
+
127
+ // panel should close
128
+ await expect(page.getByTestId("all-playlists-panel")).not.toBeVisible();
129
+ // "first" should now be the selected playlist shown in the title area
130
+ await expect(page.getByTestId("input-playlist-title").or(page.getByText("first").first())).toBeVisible();
131
+ });
132
+
133
+ test("edit button in row opens edit panel for that playlist", async ({ page }) => {
134
+ await createPlaylistViaUI(page);
135
+ await page.getByTestId("input-playlist-title").fill("edit me");
136
+ await page.getByTestId("input-playlist-title").blur();
137
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue("edit me");
138
+
139
+ // create a second playlist as the "currently selected" one
140
+ await page.getByTestId("btn-all-playlists").click();
141
+ await page.getByTestId("btn-new-playlist").click();
142
+ await page.getByTestId("btn-edit-playlist").waitFor();
143
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue("new playlist");
144
+ await page.getByTestId("input-playlist-title").fill("currently selected");
145
+ await page.getByTestId("input-playlist-title").blur();
146
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue("currently selected");
147
+
148
+ // open panel, hover over "edit me" row and click its edit button
149
+ await page.getByTestId("btn-all-playlists").click();
150
+ // wait for panel rows to appear
151
+ const panel = page.getByTestId("all-playlists-panel");
152
+ await panel.waitFor({ timeout: 3000 });
153
+ const row = panel.getByText("edit me").first();
154
+ await row.hover();
155
+ await panel.getByTestId("btn-edit-playlist-row").click();
156
+
157
+ // panel closes, edit panel opens for "edit me"
158
+ await expect(page.getByTestId("all-playlists-panel")).not.toBeVisible();
159
+ // the edit input should show "edit me"
160
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue("edit me");
161
+ });
162
+
163
+ test("share button in row opens share panel for that playlist", async ({ page }) => {
164
+ await createPlaylistViaUI(page);
165
+ await page.getByTestId("input-playlist-title").fill("share me");
166
+ await page.getByTestId("input-playlist-title").blur();
167
+ await page.waitForTimeout(300);
168
+
169
+ await page.getByTestId("btn-all-playlists").click();
170
+ await page.getByTestId("btn-new-playlist").click();
171
+ await page.getByTestId("btn-edit-playlist").waitFor();
172
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue("new playlist");
173
+ await page.getByTestId("input-playlist-title").fill("currently selected");
174
+ await page.getByTestId("input-playlist-title").blur();
175
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue("currently selected");
176
+
177
+ await page.getByTestId("btn-all-playlists").click();
178
+ const panel = page.getByTestId("all-playlists-panel");
179
+ await panel.waitFor({ timeout: 3000 });
180
+ const row = panel.getByText("share me").first();
181
+ await row.hover();
182
+ await panel.getByTestId("btn-share-playlist-row").click();
183
+
184
+ // share panel should be open
185
+ await expect(page.getByTestId("share-panel")).toBeVisible();
186
+ });
187
+
188
+ // --- new playlist ---
189
+
190
+ test("new playlist row creates a playlist and closes the panel", async ({ page }) => {
191
+ await createPlaylistViaUI(page);
192
+
193
+ await page.getByTestId("btn-all-playlists").click();
194
+ await page.getByTestId("btn-new-playlist").click();
195
+
196
+ // panel should close and new playlist edit mode should be open
197
+ await expect(page.getByTestId("all-playlists-panel")).not.toBeVisible();
198
+ await expect(page.getByTestId("btn-edit-playlist")).toBeVisible();
199
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue("new playlist");
200
+ });
201
+
202
+ // --- song count / panel with songs ---
203
+
204
+ test("row shows song count for playlists with songs", async ({ page }) => {
205
+ await createPlaylistViaUI(page);
206
+ await addSongs(page, 3);
207
+ await page.getByTestId("input-playlist-title").fill("has songs");
208
+ await page.getByTestId("input-playlist-title").blur();
209
+ await page.waitForTimeout(300);
210
+
211
+ // create a second (selected) playlist
212
+ await page.getByTestId("btn-all-playlists").click();
213
+ await page.getByTestId("btn-new-playlist").click();
214
+ await page.getByTestId("btn-edit-playlist").waitFor();
215
+ await page.waitForTimeout(300);
216
+
217
+ // open panel - "has songs" row should show "3 songz"
218
+ await page.getByTestId("btn-all-playlists").click();
219
+ await expect(page.getByTestId("row-song-count").first()).toContainText("3");
220
+ });
@@ -0,0 +1,226 @@
1
+ // e2e: audio player behaviour - autoplay next, errors, blob fetch states.
2
+ //
3
+ // most tests use the dev hook helpers (seekTo, triggerTrackEnd, etc.) from
4
+ // e2e/helpers.ts to drive playback events without waiting for real audio.
5
+ //
6
+ // one "real playthrough" test lets a 2s fixture actually finish to prove
7
+ // autoplay-next works end-to-end.
8
+ //
9
+ // blob-fetch state tests use mockBlobFetch + evictBlob to simulate p2p
10
+ // transfers without a real peer connection.
11
+
12
+ import { test, expect } from "@playwright/test";
13
+ import { readFileSync } from "node:fs";
14
+ import { join, dirname } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import {
17
+ resetAppState,
18
+ createPlaylistViaUI,
19
+ addSongs,
20
+ seekTo,
21
+ triggerTrackEnd,
22
+ triggerAudioError,
23
+ mockBlobFetch,
24
+ clearMockBlobFetch,
25
+ evictBlob,
26
+ currentSong,
27
+ } from "./helpers.js";
28
+
29
+ const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), "fixtures");
30
+
31
+ test.beforeEach(async ({ page }) => {
32
+ await resetAppState(page);
33
+ });
34
+
35
+ // --- autoplay / queue advance ---
36
+
37
+ test("autoplay next: seekTo near end advances to next song", async ({
38
+ page,
39
+ }) => {
40
+ await createPlaylistViaUI(page);
41
+ await addSongs(page, 3, 2);
42
+
43
+ await page.getByText("song-00").dblclick();
44
+ await expect(page.getByTestId("btn-play-playlist")).toHaveAttribute(
45
+ "aria-pressed",
46
+ "true",
47
+ { timeout: 10000 }
48
+ );
49
+ await expect
50
+ .poll(() => currentSong(page), { timeout: 8000 })
51
+ .toBe("song-00");
52
+
53
+ await seekTo(page, 1.9); // 2s song, seek to 0.1s before end
54
+
55
+ await expect
56
+ .poll(() => currentSong(page), { timeout: 8000 })
57
+ .toBe("song-01");
58
+ });
59
+
60
+ test("autoplay next: triggerTrackEnd advances queue", async ({ page }) => {
61
+ await createPlaylistViaUI(page);
62
+ await addSongs(page, 3, 2);
63
+
64
+ await page.getByText("song-00").dblclick();
65
+ await expect
66
+ .poll(() => currentSong(page), { timeout: 10000 })
67
+ .toBe("song-00");
68
+
69
+ await triggerTrackEnd(page);
70
+
71
+ await expect
72
+ .poll(() => currentSong(page), { timeout: 8000 })
73
+ .toBe("song-01");
74
+ });
75
+
76
+ test("end of playlist: last song ends, player stops", async ({ page }) => {
77
+ await createPlaylistViaUI(page);
78
+ await addSongs(page, 2, 2);
79
+
80
+ await page.getByText("song-01").dblclick();
81
+ await expect(page.getByTestId("btn-play-playlist")).toHaveAttribute(
82
+ "aria-pressed",
83
+ "true",
84
+ { timeout: 10000 }
85
+ );
86
+
87
+ await triggerTrackEnd(page);
88
+
89
+ // playback stops - button aria-pressed returns to "false"
90
+ await expect(page.getByTestId("btn-play-playlist")).toHaveAttribute(
91
+ "aria-pressed",
92
+ "false",
93
+ { timeout: 8000 }
94
+ );
95
+ });
96
+
97
+ // --- audio player error states ---
98
+
99
+ test("real playthrough: 2s fixture autoadvances without hooks", async ({
100
+ page,
101
+ }) => {
102
+ test.slow();
103
+ await createPlaylistViaUI(page);
104
+ await addSongs(page, 2, 2);
105
+
106
+ await page.getByText("song-00").dblclick();
107
+ await expect
108
+ .poll(() => currentSong(page), { timeout: 10000 })
109
+ .toBe("song-00");
110
+
111
+ // let it play through naturally
112
+ await expect
113
+ .poll(() => currentSong(page), { timeout: 12000 })
114
+ .toBe("song-01");
115
+ });
116
+
117
+ // --- audio player error states ---
118
+
119
+ test("triggerAudioError: player recovers without crash", async ({ page }) => {
120
+ await createPlaylistViaUI(page);
121
+ await addSongs(page, 2, 2);
122
+
123
+ await page.getByText("song-00").dblclick();
124
+ await expect(page.getByTestId("btn-play-playlist")).toHaveAttribute(
125
+ "aria-pressed",
126
+ "true",
127
+ { timeout: 10000 }
128
+ );
129
+
130
+ await triggerAudioError(page, 4);
131
+
132
+ // app must stay responsive
133
+ await expect(page.getByTestId("btn-play-playlist")).toBeVisible({
134
+ timeout: 5000,
135
+ });
136
+ await expect(page.getByTestId("song-duration").first()).toBeVisible();
137
+ });
138
+
139
+ // --- blob fetch states (mocked p2p transport) ---
140
+
141
+ // import a committed fixture file via the __processFiles dev hook
142
+ async function importFixture(
143
+ page: Parameters<typeof evictBlob>[0],
144
+ filename: string
145
+ ): Promise<void> {
146
+ const bytes = readFileSync(join(FIXTURES_DIR, filename));
147
+ const result = await page.evaluate(
148
+ async ({ b64, name }: { b64: string; name: string }) => {
149
+ const bin = atob(b64);
150
+ const arr = new Uint8Array(bin.length);
151
+ for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
152
+ const file = new File([arr], name, { type: "audio/wav" });
153
+ const hook = (
154
+ window as Window & { __processFiles?: (files: File[]) => Promise<void> }
155
+ ).__processFiles;
156
+ if (!hook) return "hook-missing";
157
+ await hook([file]);
158
+ return "ok";
159
+ },
160
+ { b64: Buffer.from(bytes).toString("base64"), name: filename }
161
+ );
162
+ if (result !== "ok") throw new Error(`importFixture failed: ${result}`);
163
+ }
164
+
165
+ test("mockBlobFetch delayed: song row shows downloading state @mock", async ({
166
+ page,
167
+ }) => {
168
+ await createPlaylistViaUI(page);
169
+ await importFixture(page, "tone-440hz-2s.wav");
170
+ await expect(page.getByText("tone-440hz-2s")).toBeVisible({ timeout: 10000 });
171
+
172
+ const durationCell = page.getByTestId("song-duration").first();
173
+ await durationCell.waitFor({ timeout: 8000 });
174
+ const sha256 = await durationCell.getAttribute("data-sha256");
175
+
176
+ if (sha256) {
177
+ await evictBlob(page, sha256);
178
+ await mockBlobFetch(page, { type: "delayed", ms: 2000 });
179
+ }
180
+
181
+ await page.getByText("tone-440hz-2s").dblclick();
182
+
183
+ if (sha256) {
184
+ await expect(durationCell).toHaveAttribute(
185
+ "data-download-state",
186
+ "downloading",
187
+ { timeout: 5000 }
188
+ );
189
+ await expect(page.getByTestId("btn-play-playlist")).toHaveAttribute(
190
+ "aria-busy",
191
+ "true",
192
+ { timeout: 5000 }
193
+ );
194
+ // after 2s delay resolves, downloading state clears
195
+ await expect(durationCell).not.toHaveAttribute(
196
+ "data-download-state",
197
+ "downloading",
198
+ { timeout: 8000 }
199
+ );
200
+ await clearMockBlobFetch(page);
201
+ }
202
+ });
203
+
204
+ test("mockBlobFetch error: song row shows error state @mock", async ({ page }) => {
205
+ await createPlaylistViaUI(page);
206
+ await importFixture(page, "tone-440hz-2s.wav");
207
+ await expect(page.getByText("tone-440hz-2s")).toBeVisible({ timeout: 10000 });
208
+
209
+ const durationCell = page.getByTestId("song-duration").first();
210
+ await durationCell.waitFor();
211
+ const sha256 = await durationCell.getAttribute("data-sha256");
212
+
213
+ if (sha256) {
214
+ await evictBlob(page, sha256);
215
+ await mockBlobFetch(page, { type: "error", code: "not_found" });
216
+ }
217
+
218
+ await page.getByText("tone-440hz-2s").dblclick();
219
+
220
+ if (sha256) {
221
+ await expect(durationCell).toHaveAttribute("data-download-state", "error", {
222
+ timeout: 8000,
223
+ });
224
+ await clearMockBlobFetch(page);
225
+ }
226
+ });
@@ -0,0 +1,229 @@
1
+ // e2e: single-browser tests for the collaborative sharing ui.
2
+ //
3
+ // covers: subscribed (read-only) playlist mode, fork flow,
4
+ // edit/share buttons exiting all-playlists view, and remoteName
5
+ // display in playlist rows.
6
+ //
7
+ // all tests here use the __patchDocIndexEntry / __getDocIndexEntries dev hooks
8
+ // to inject remote-source state without needing a real p2p connection.
9
+
10
+ import { test, expect } from "@playwright/test";
11
+ import {
12
+ resetAppState,
13
+ createPlaylistViaUI,
14
+ addSongs,
15
+ getDocIndexEntries,
16
+ patchDocIndexEntry,
17
+ } from "./helpers.js";
18
+
19
+ test.beforeEach(async ({ page }) => {
20
+ await resetAppState(page);
21
+ });
22
+
23
+ // get the docId of the only/first playlist in the docIndex
24
+ async function firstDocId(page: import("@playwright/test").Page): Promise<string> {
25
+ const entries = await getDocIndexEntries(page);
26
+ const id = entries[0]?.docId;
27
+ if (!id) throw new Error("no docIndex entry found");
28
+ return id;
29
+ }
30
+
31
+ // patch remoteNodeId onto the current playlist and wait for the reactive
32
+ // docIndex effect to propagate (no reload needed)
33
+ async function makeSubscribed(
34
+ page: import("@playwright/test").Page,
35
+ docId: string,
36
+ opts: { remoteNodeId?: string; remoteName?: string } = {}
37
+ ): Promise<void> {
38
+ await patchDocIndexEntry(page, docId, {
39
+ remoteNodeId: opts.remoteNodeId ?? "a".repeat(64),
40
+ remoteName: opts.remoteName,
41
+ });
42
+ // give the createLiveQuery broadcast + reactive effect time to propagate
43
+ await page.waitForTimeout(500);
44
+ }
45
+
46
+ // --- subscribed (read-only) mode ---
47
+
48
+ test("subscribed playlist disables title input", async ({ page }) => {
49
+ await createPlaylistViaUI(page);
50
+ const docId = await firstDocId(page);
51
+ await makeSubscribed(page, docId);
52
+ await expect(page.getByTestId("input-playlist-title")).toBeDisabled({
53
+ timeout: 5000,
54
+ });
55
+ await expect(page.getByTestId("input-playlist-description")).toBeDisabled();
56
+ });
57
+
58
+ test("subscribed playlist hides remove-song button", async ({ page }) => {
59
+ await createPlaylistViaUI(page);
60
+ await addSongs(page, 1);
61
+
62
+ // the remove button is in the hover overlay - hover the row to reveal it
63
+ const songRow = page.getByTestId("song-row").first();
64
+ await songRow.waitFor({ timeout: 10000 });
65
+ await songRow.hover();
66
+ await expect(page.getByTestId("btn-remove-song").first()).toBeVisible({
67
+ timeout: 3000,
68
+ });
69
+
70
+ const docId = await firstDocId(page);
71
+ await makeSubscribed(page, docId);
72
+
73
+ await expect(page.getByTestId("btn-remove-song")).toHaveCount(0, {
74
+ timeout: 5000,
75
+ });
76
+ });
77
+
78
+ test("subscribed playlist shows fork and request-collaboration in edit panel", async ({
79
+ page,
80
+ }) => {
81
+ await createPlaylistViaUI(page);
82
+ const docId = await firstDocId(page);
83
+ await makeSubscribed(page, docId, { remoteName: "peer-dave" });
84
+
85
+ await page.getByTestId("btn-edit-playlist").click();
86
+ await page.getByTestId("edit-panel").waitFor({ timeout: 5000 });
87
+
88
+ await expect(page.getByTestId("btn-fork-playlist")).toBeVisible();
89
+ await expect(page.getByTestId("btn-request-collaboration")).toBeVisible();
90
+ // banner should mention the remote name
91
+ await expect(page.getByTestId("edit-panel")).toContainText("peer-dave");
92
+ });
93
+
94
+ // --- fork flow ---
95
+
96
+ test("fork creates a new local playlist and selects it", async ({ page }) => {
97
+ await createPlaylistViaUI(page);
98
+
99
+ const firstTitle = "original-playlist";
100
+ await page.getByTestId("input-playlist-title").click({ clickCount: 3 });
101
+ await page.getByTestId("input-playlist-title").fill(firstTitle);
102
+ await page.getByTestId("input-playlist-title").blur();
103
+ await page.waitForTimeout(300);
104
+
105
+ const docId = await firstDocId(page);
106
+ await makeSubscribed(page, docId);
107
+
108
+ // confirm subscribed - title should be disabled
109
+ await expect(page.getByTestId("input-playlist-title")).toBeDisabled({
110
+ timeout: 5000,
111
+ });
112
+
113
+ // open edit panel and fork
114
+ await page.getByTestId("btn-edit-playlist").click();
115
+ await page.getByTestId("edit-panel").waitFor({ timeout: 5000 });
116
+ await page.getByTestId("btn-fork-playlist").click();
117
+
118
+ // after fork: a new local playlist is selected.
119
+ // the subscribed banner (with fork button) should disappear.
120
+ await expect(page.getByTestId("btn-fork-playlist")).not.toBeVisible({
121
+ timeout: 8000,
122
+ });
123
+
124
+ // close the edit panel, then verify title is enabled on the forked playlist
125
+ await page.keyboard.press("Escape");
126
+ await expect(page.getByTestId("input-playlist-title")).toBeEnabled({
127
+ timeout: 5000,
128
+ });
129
+
130
+ // open all-playlists - the original (now subscribed/forked) should appear in the list
131
+ await page.getByTestId("btn-all-playlists").click();
132
+ await page.getByTestId("all-playlists-panel").waitFor({ timeout: 5000 });
133
+ await expect(
134
+ page.getByTestId("all-playlists-panel").getByText(firstTitle)
135
+ ).toBeVisible();
136
+ });
137
+
138
+ test("forked playlist is editable (title input enabled)", async ({ page }) => {
139
+ await createPlaylistViaUI(page);
140
+ const docId = await firstDocId(page);
141
+ await makeSubscribed(page, docId);
142
+
143
+ await expect(page.getByTestId("input-playlist-title")).toBeDisabled({
144
+ timeout: 5000,
145
+ });
146
+
147
+ // fork
148
+ await page.getByTestId("btn-edit-playlist").click();
149
+ await page.getByTestId("edit-panel").waitFor({ timeout: 5000 });
150
+ await page.getByTestId("btn-fork-playlist").click();
151
+ // fork banner disappears once the new local playlist is selected
152
+ await expect(page.getByTestId("btn-fork-playlist")).not.toBeVisible({
153
+ timeout: 8000,
154
+ });
155
+ await page.keyboard.press("Escape");
156
+
157
+ // fork is now selected - title should be editable
158
+ await expect(page.getByTestId("input-playlist-title")).toBeEnabled({
159
+ timeout: 5000,
160
+ });
161
+
162
+ // actually type in it to confirm
163
+ await page.getByTestId("input-playlist-title").click({ clickCount: 3 });
164
+ await page.getByTestId("input-playlist-title").fill("my-fork");
165
+ await page.getByTestId("input-playlist-title").blur();
166
+ await page.waitForTimeout(300);
167
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue("my-fork");
168
+ });
169
+
170
+ // --- edit/share buttons close all-playlists panel ---
171
+
172
+ test("clicking edit button while all-playlists is open closes it", async ({
173
+ page,
174
+ }) => {
175
+ await createPlaylistViaUI(page);
176
+
177
+ await page.getByTestId("btn-all-playlists").click();
178
+ await expect(page.getByTestId("all-playlists-panel")).toBeVisible();
179
+
180
+ await page.getByTestId("btn-edit-playlist").click();
181
+
182
+ await expect(page.getByTestId("all-playlists-panel")).not.toBeVisible();
183
+ await expect(page.getByTestId("edit-panel")).toBeVisible();
184
+ });
185
+
186
+ test("clicking share button while all-playlists is open closes it", async ({
187
+ page,
188
+ }) => {
189
+ await createPlaylistViaUI(page);
190
+
191
+ await page.getByTestId("btn-all-playlists").click();
192
+ await expect(page.getByTestId("all-playlists-panel")).toBeVisible();
193
+
194
+ await page.getByTestId("btn-share-playlist").click();
195
+
196
+ await expect(page.getByTestId("all-playlists-panel")).not.toBeVisible();
197
+ await expect(page.getByTestId("share-panel")).toBeVisible();
198
+ });
199
+
200
+ // --- remoteName display in playlist rows ---
201
+
202
+ test("playlist row shows remoteName when set", async ({ page }) => {
203
+ // create the subscribed playlist first
204
+ await createPlaylistViaUI(page);
205
+ const docId = await firstDocId(page);
206
+ await makeSubscribed(page, docId, { remoteName: "peer-frank" });
207
+
208
+ // create a second playlist so the first appears in the all-playlists panel
209
+ // (the selected playlist is shown in the header, not in the panel list)
210
+ await createPlaylistViaUI(page);
211
+
212
+ await page.getByTestId("btn-all-playlists").click();
213
+ await page.getByTestId("all-playlists-panel").waitFor({ timeout: 5000 });
214
+
215
+ await expect(page.getByTestId("all-playlists-panel")).toContainText(
216
+ "peer-frank"
217
+ );
218
+ });
219
+
220
+ // --- per-playlist sharing mode ---
221
+
222
+ test("mode buttons in share panel are visible", async ({ page }) => {
223
+ await createPlaylistViaUI(page);
224
+ await page.getByTestId("btn-share-playlist").click();
225
+ await page.getByTestId("share-panel").waitFor({ timeout: 5000 });
226
+
227
+ await expect(page.getByTestId("btn-mode-public")).toBeVisible();
228
+ await expect(page.getByTestId("btn-mode-knock")).toBeVisible();
229
+ });