@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,302 @@
1
+ // e2e: p2p blob transfer state machine - mocked transport scenarios.
2
+ //
3
+ // all tests use __mockBlobFetch + __evictBlob to simulate p2p behaviour
4
+ // deterministically in a single browser without a real peer connection.
5
+ //
6
+ // companion to audio-player.spec.ts which covers the basic happy path.
7
+ // this file focuses on: pending state, timeout, retry, prefetch triggering,
8
+ // and prefetch cancellation on playlist switch.
9
+
10
+ import { test, expect } from "@playwright/test";
11
+ import { readFileSync } from "node:fs";
12
+ import { join, dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import {
15
+ resetAppState,
16
+ createPlaylistViaUI,
17
+ addSongs,
18
+ seekTo,
19
+ mockBlobFetch,
20
+ clearMockBlobFetch,
21
+ evictBlob,
22
+ setBlobFetchTimeout,
23
+ fetchBlobBySha,
24
+ currentSong,
25
+ } from "./helpers.js";
26
+
27
+ const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), "fixtures");
28
+
29
+ test.beforeEach(async ({ page }) => {
30
+ await resetAppState(page);
31
+ });
32
+
33
+ // import a committed fixture via the __processFiles dev hook
34
+ async function importFixture(
35
+ page: Parameters<typeof evictBlob>[0],
36
+ filename: string,
37
+ mimeType = "audio/wav"
38
+ ): Promise<void> {
39
+ const bytes = readFileSync(join(FIXTURES_DIR, filename));
40
+ const result = await page.evaluate(
41
+ async ({ b64, name, mime }: { b64: string; name: string; mime: string }) => {
42
+ const bin = atob(b64);
43
+ const arr = new Uint8Array(bin.length);
44
+ for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
45
+ const file = new File([arr], name, { type: mime });
46
+ const hook = (
47
+ window as Window & { __processFiles?: (files: File[]) => Promise<void> }
48
+ ).__processFiles;
49
+ if (!hook) return "hook-missing";
50
+ await hook([file]);
51
+ return "ok";
52
+ },
53
+ { b64: Buffer.from(bytes).toString("base64"), name: filename, mime: mimeType }
54
+ );
55
+ if (result !== "ok") throw new Error(`importFixture failed: ${result}`);
56
+ }
57
+
58
+ // get the sha256 of the first song row's blob
59
+ async function firstSongSha(
60
+ page: Parameters<typeof evictBlob>[0]
61
+ ): Promise<string | null> {
62
+ const cell = page.getByTestId("song-duration").first();
63
+ await cell.waitFor({ timeout: 8000 });
64
+ return cell.getAttribute("data-sha256");
65
+ }
66
+
67
+ // --- pending state ---
68
+
69
+ test("prefetch marks songs pending before fetch starts @mock", async ({ page }) => {
70
+ test.setTimeout(30_000);
71
+ await createPlaylistViaUI(page);
72
+ // 3 songs: song-00 plays, song-01 and song-02 should enter pending state
73
+ await addSongs(page, 3, 2);
74
+
75
+ // stall fetches so we can observe pending state before resolve
76
+ await mockBlobFetch(page, { type: "stall" });
77
+
78
+ // evict all blobs so prefetch has something to fetch
79
+ const cells = page.getByTestId("song-duration");
80
+ const count = await cells.count();
81
+ for (let i = 0; i < count; i++) {
82
+ const sha = await cells.nth(i).getAttribute("data-sha256");
83
+ if (sha) await evictBlob(page, sha);
84
+ }
85
+
86
+ // start playback on song-00 - this triggers prefetchUpcoming for songs 1+2
87
+ await page.getByText("song-00").dblclick();
88
+ await expect
89
+ .poll(() => currentSong(page), { timeout: 10000 })
90
+ .toBe("song-00");
91
+
92
+ // at least one upcoming song should show pending or downloading state while fetches are stalled
93
+ await expect(
94
+ page.locator("[data-testid='song-duration'][data-download-state]").first()
95
+ ).toBeVisible({ timeout: 5000 });
96
+
97
+ // clean up
98
+ await clearMockBlobFetch(page);
99
+ });
100
+
101
+ // --- fetch timeout ---
102
+
103
+ test("blob fetch timeout: song shows error state after timeout @mock", async ({
104
+ page,
105
+ }) => {
106
+ test.setTimeout(20_000);
107
+ await createPlaylistViaUI(page);
108
+ await importFixture(page, "tone-440hz-2s.wav");
109
+ await expect(page.getByText("tone-440hz-2s")).toBeVisible({ timeout: 10000 });
110
+
111
+ const durationCell = page.getByTestId("song-duration").first();
112
+ const sha256 = await firstSongSha(page);
113
+
114
+ if (sha256) {
115
+ await evictBlob(page, sha256);
116
+ // set a very short timeout so the test doesn't wait 30s
117
+ await setBlobFetchTimeout(page, 500);
118
+ await mockBlobFetch(page, { type: "stall" });
119
+ }
120
+
121
+ await page.getByText("tone-440hz-2s").dblclick();
122
+
123
+ if (sha256) {
124
+ // after ~500ms timeout, state should flip to error
125
+ await expect(durationCell).toHaveAttribute("data-download-state", "error", {
126
+ timeout: 5000,
127
+ });
128
+ await clearMockBlobFetch(page);
129
+ // reset timeout to default
130
+ await setBlobFetchTimeout(page, 30_000);
131
+ }
132
+ });
133
+
134
+ // --- retry on error ---
135
+
136
+ test("error duration cell has retry affordance and retry clears error state @mock", async ({ page }) => {
137
+ test.setTimeout(20_000);
138
+ await createPlaylistViaUI(page);
139
+ await importFixture(page, "tone-440hz-2s.wav");
140
+ await expect(page.getByText("tone-440hz-2s")).toBeVisible({ timeout: 10000 });
141
+
142
+ const durationCell = page.getByTestId("song-duration").first();
143
+ const sha256 = await firstSongSha(page);
144
+
145
+ if (sha256) {
146
+ await evictBlob(page, sha256);
147
+ // make the fetch error immediately
148
+ await mockBlobFetch(page, { type: "error", code: "not_found" });
149
+ }
150
+
151
+ await page.getByText("tone-440hz-2s").dblclick();
152
+
153
+ if (sha256) {
154
+ await expect(durationCell).toHaveAttribute("data-download-state", "error", {
155
+ timeout: 8000,
156
+ });
157
+
158
+ // error state should expose a retry affordance (cursor-pointer class)
159
+ await expect(durationCell).toHaveClass(/cursor-pointer/);
160
+
161
+ // switch mock to instant and trigger retry programmatically.
162
+ // clicking the cell via Playwright is unreliable here because the
163
+ // song-row action-buttons overlay sits on top of the duration cell.
164
+ await mockBlobFetch(page, { type: "instant" });
165
+ await fetchBlobBySha(page, sha256);
166
+
167
+ // error state should clear after successful retry
168
+ await expect(durationCell).not.toHaveAttribute(
169
+ "data-download-state",
170
+ "error",
171
+ { timeout: 5000 }
172
+ );
173
+ await clearMockBlobFetch(page);
174
+ }
175
+ });
176
+
177
+ // --- prefetch triggered on play ---
178
+
179
+ test("prefetch activates for upcoming songs when playback starts @mock", async ({
180
+ page,
181
+ }) => {
182
+ test.setTimeout(30_000);
183
+ await createPlaylistViaUI(page);
184
+ await addSongs(page, 3, 2);
185
+
186
+ // evict all blobs so there's something to prefetch
187
+ const cells = page.getByTestId("song-duration");
188
+ const count = await cells.count();
189
+ for (let i = 0; i < count; i++) {
190
+ const sha = await cells.nth(i).getAttribute("data-sha256");
191
+ if (sha) await evictBlob(page, sha);
192
+ }
193
+
194
+ // instant mock - prefetch should complete quickly
195
+ await mockBlobFetch(page, { type: "instant" });
196
+
197
+ await page.getByText("song-00").dblclick();
198
+ await expect
199
+ .poll(() => currentSong(page), { timeout: 10000 })
200
+ .toBe("song-00");
201
+
202
+ // after a short wait, the upcoming songs' download states should clear
203
+ // (instant mock means they resolve immediately - no "downloading" lingers)
204
+ await page.waitForTimeout(1000);
205
+ const anyDownloading = await page.evaluate(() => {
206
+ const cells = document.querySelectorAll("[data-download-state]");
207
+ return Array.from(cells).some(
208
+ (el) => el.getAttribute("data-download-state") === "downloading"
209
+ );
210
+ });
211
+ expect(anyDownloading).toBe(false);
212
+
213
+ await clearMockBlobFetch(page);
214
+ });
215
+
216
+ // --- prefetch cancels on playlist switch ---
217
+
218
+ test("prefetch for old playlist cancels when switching to new playlist @mock", async ({
219
+ page,
220
+ }) => {
221
+ test.setTimeout(40_000);
222
+
223
+ // create playlist A with 3 songs
224
+ await createPlaylistViaUI(page);
225
+ await addSongs(page, 3, 2);
226
+
227
+ // evict all blobs from playlist A
228
+ const cells = page.getByTestId("song-duration");
229
+ const count = await cells.count();
230
+ for (let i = 0; i < count; i++) {
231
+ const sha = await cells.nth(i).getAttribute("data-sha256");
232
+ if (sha) await evictBlob(page, sha);
233
+ }
234
+
235
+ // stall fetches - playlist A prefetch will hang
236
+ await mockBlobFetch(page, { type: "stall" });
237
+
238
+ // start playback on playlist A song-00 (triggers prefetch for song-01, song-02)
239
+ await page.getByText("song-00").dblclick();
240
+ await expect
241
+ .poll(() => currentSong(page), { timeout: 10000 })
242
+ .toBe("song-00");
243
+
244
+ // create playlist B and switch to it - this should cancel playlist A's prefetch
245
+ await createPlaylistViaUI(page);
246
+ await addSongs(page, 1, 2);
247
+
248
+ // switch mock to instant so playlist B can prefetch cleanly
249
+ await mockBlobFetch(page, { type: "instant" });
250
+
251
+ // after switching, playlist A's stalled prefetches should be abandoned.
252
+ // the pending/downloading states on those shas should not appear on playlist B's songs.
253
+ await page.waitForTimeout(800);
254
+
255
+ // playlist B's song row should be visible (strict-safe: use testid, not song name text)
256
+ await expect(page.getByTestId("song-duration").first()).toBeVisible();
257
+ // pending states should be cleared after switching playlists.
258
+ // (in-flight "downloading" states may persist until the stall mock times out -
259
+ // that is expected. what matters is queued-but-not-started "pending" states are gone.)
260
+ const pendingCount = await page.evaluate(() =>
261
+ document.querySelectorAll("[data-download-state='pending']").length
262
+ );
263
+ expect(pendingCount).toBe(0);
264
+
265
+ await clearMockBlobFetch(page);
266
+ });
267
+
268
+ // --- seek recalculates prefetch window ---
269
+
270
+ test("seeking forward recalculates the prefetch window @mock", async ({ page }) => {
271
+ test.setTimeout(30_000);
272
+ await createPlaylistViaUI(page);
273
+ await addSongs(page, 4, 2); // 4x 2s songs
274
+
275
+ // evict songs 2-4 blobs so there's something to prefetch
276
+ const cells = page.getByTestId("song-duration");
277
+ const count = await cells.count();
278
+ for (let i = 1; i < count; i++) {
279
+ const sha = await cells.nth(i).getAttribute("data-sha256");
280
+ if (sha) await evictBlob(page, sha);
281
+ }
282
+
283
+ // use instant mock so prefetch resolves immediately
284
+ await mockBlobFetch(page, { type: "instant" });
285
+
286
+ await page.getByText("song-00").dblclick();
287
+ await expect
288
+ .poll(() => currentSong(page), { timeout: 10000 })
289
+ .toBe("song-00");
290
+
291
+ // seek near the end of song-00 - should re-trigger prefetchUpcoming
292
+ await seekTo(page, 1.8);
293
+
294
+ // brief wait then assert no downloading states are stuck
295
+ await page.waitForTimeout(800);
296
+ const downloading = await page.evaluate(() =>
297
+ document.querySelectorAll("[data-download-state='downloading']").length
298
+ );
299
+ expect(downloading).toBe(0);
300
+
301
+ await clearMockBlobFetch(page);
302
+ });
@@ -0,0 +1,56 @@
1
+ // e2e: audio playback with synthetic WAV tones.
2
+
3
+ import { test, expect } from "@playwright/test";
4
+ import { resetAppState, createPlaylistViaUI, addSongs } from "./helpers.js";
5
+
6
+ test.beforeEach(async ({ page }) => {
7
+ await resetAppState(page);
8
+ });
9
+
10
+ test("double-clicking a song row starts playback", async ({ page }) => {
11
+ await createPlaylistViaUI(page);
12
+ await addSongs(page, 2, 2);
13
+
14
+ // desktop rows play on double click
15
+ await page.getByText("song-00").dblclick();
16
+
17
+ // the playlist play button switches to its "playing" state (magenta bg + pause icon)
18
+ await expect(page.locator("button.bg-magenta-500").first()).toBeVisible({
19
+ timeout: 10000,
20
+ });
21
+ });
22
+
23
+ test("decoded duration shows in the row", async ({ page }) => {
24
+ await createPlaylistViaUI(page);
25
+ // 2-second tone -> row should show 0:02 once metadata is decoded
26
+ await addSongs(page, 1, 2);
27
+
28
+ await expect(page.getByText("0:02").first()).toBeVisible({ timeout: 15000 });
29
+ });
30
+
31
+ test("all-playlists thumbnail shows a play icon while the playlist plays", async ({
32
+ page,
33
+ }) => {
34
+ await createPlaylistViaUI(page);
35
+ await addSongs(page, 2, 2);
36
+
37
+ // start playback on the first playlist
38
+ await page.getByText("song-00").dblclick();
39
+ await expect(page.locator("button.bg-magenta-500").first()).toBeVisible({
40
+ timeout: 10000,
41
+ });
42
+
43
+ // create a second playlist so the first one (playing) appears as a row
44
+ // (the selected playlist is excluded from panel rows)
45
+ await page.getByTestId("btn-all-playlists").click();
46
+ await page.getByTestId("btn-new-playlist").click();
47
+ await page.getByTestId("btn-edit-playlist").waitFor();
48
+
49
+ // open the panel - the first playlist (still playing) is now a row
50
+ await page.getByTestId("btn-all-playlists").click();
51
+ await page.getByTestId("all-playlists-panel").waitFor();
52
+
53
+ await expect(page.getByTestId("row-playing-indicator")).toBeVisible({
54
+ timeout: 8000,
55
+ });
56
+ });
@@ -0,0 +1,126 @@
1
+ // e2e: playlist creation, song adding, and persistence across reloads.
2
+
3
+ import { test, expect } from "@playwright/test";
4
+ import {
5
+ resetAppState,
6
+ createPlaylistViaUI,
7
+ addSongs,
8
+ waitForApp,
9
+ makePng,
10
+ } from "./helpers.js";
11
+
12
+ test.beforeEach(async ({ page }) => {
13
+ await resetAppState(page);
14
+ });
15
+
16
+ test("create a playlist via the sidebar", async ({ page }) => {
17
+ await createPlaylistViaUI(page);
18
+ await expect(page.getByTestId("empty-songs")).toBeVisible();
19
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue(
20
+ "new playlist"
21
+ );
22
+ });
23
+
24
+ test("add songs via drag and drop", async ({ page }) => {
25
+ await createPlaylistViaUI(page);
26
+ await addSongs(page, 3);
27
+
28
+ await expect(page.getByText("song-00")).toBeVisible();
29
+ await expect(page.getByText("song-01")).toBeVisible();
30
+ await expect(page.getByText("song-02")).toBeVisible();
31
+ await expect(page.getByTestId("playlist-song-count").first()).toBeVisible();
32
+ });
33
+
34
+ test("songs survive a page reload", async ({ page }) => {
35
+ // regression: after reload the song registry was empty and every
36
+ // row rendered "song not found"
37
+ await createPlaylistViaUI(page);
38
+ await addSongs(page, 2);
39
+
40
+ await page.reload();
41
+ await waitForApp(page);
42
+
43
+ await expect(page.getByText("song-00")).toBeVisible({ timeout: 10000 });
44
+ await expect(page.getByText("song-01")).toBeVisible();
45
+ await expect(page.getByText("song not found")).toHaveCount(0);
46
+ });
47
+
48
+ test("playlist title edit persists across reload", async ({ page }) => {
49
+ await createPlaylistViaUI(page);
50
+
51
+ const title = page.getByTestId("input-playlist-title");
52
+ await title.fill("doom mix");
53
+ await title.blur();
54
+ await page.waitForTimeout(500);
55
+
56
+ await page.reload();
57
+ await waitForApp(page);
58
+
59
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue(
60
+ "doom mix",
61
+ { timeout: 10000 }
62
+ );
63
+ });
64
+
65
+ test("playlist cover image persists across reload", async ({ page }) => {
66
+ // regression: images appeared once then were "lost" after reload
67
+ await createPlaylistViaUI(page);
68
+ await addSongs(page, 1);
69
+
70
+ // open the edit panel and upload a cover
71
+ await page.getByTestId("btn-edit-playlist").click();
72
+ const fileInput = page.locator("input[type='file']").first();
73
+ await fileInput.waitFor({ state: "attached", timeout: 5000 });
74
+
75
+ const png = await makePng(page, { color: "#00ffcc", label: "cover" });
76
+ await fileInput.setInputFiles({
77
+ name: "cover.png",
78
+ mimeType: "image/png",
79
+ buffer: Buffer.from(png),
80
+ });
81
+
82
+ // cover preview appears in the edit panel
83
+ await expect(page.getByTestId("edit-panel").locator("img[alt='playlist cover']").first()).toBeVisible(
84
+ { timeout: 10000 }
85
+ );
86
+
87
+ await page.reload();
88
+ await waitForApp(page);
89
+
90
+ // re-open edit panel and confirm the cover is still there from blob store
91
+ await page.getByTestId("btn-edit-playlist").click();
92
+ await expect(page.getByTestId("edit-panel").locator("img[alt='playlist cover']").first()).toBeVisible({
93
+ timeout: 10000,
94
+ });
95
+ });
96
+
97
+ test("selected playlist is restored after a page reload", async ({ page }) => {
98
+ // create two playlists and select the second one, then reload.
99
+ // the app should re-select the second playlist rather than defaulting
100
+ // to the first one.
101
+ await createPlaylistViaUI(page);
102
+ await page.getByTestId("input-playlist-title").fill("first");
103
+ await page.getByTestId("input-playlist-title").blur();
104
+
105
+ // create a second playlist via the all-playlists panel
106
+ await page.getByTestId("btn-all-playlists").click();
107
+ await page.getByTestId("btn-new-playlist").click();
108
+ await page.getByTestId("btn-edit-playlist").waitFor({ timeout: 5000 });
109
+ await page.getByTestId("input-playlist-title").click({ clickCount: 3 });
110
+ await page.getByTestId("input-playlist-title").fill("second");
111
+ await page.getByTestId("input-playlist-title").blur();
112
+
113
+ // "second" is currently selected
114
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue("second");
115
+
116
+ // wait a moment for the saveSetting idb write to complete
117
+ await page.waitForTimeout(300);
118
+
119
+ await page.reload();
120
+ await waitForApp(page);
121
+
122
+ // should restore "second", not fall back to "first"
123
+ await expect(page.getByTestId("input-playlist-title")).toHaveValue("second", {
124
+ timeout: 10000,
125
+ });
126
+ });
@@ -0,0 +1,129 @@
1
+ // e2e: share link auto-play - navigate to /?#share/<token> with a local doc.
2
+ //
3
+ // the share link auto-play flow (§3a) works even without p2p for docs that
4
+ // are already in local idb - repo.find() resolves from local storage.
5
+ // we construct a synthetic #share/ token pointing at a real docId read
6
+ // from the idb docIndex after creating a playlist.
7
+
8
+ import { test, expect } from "@playwright/test";
9
+ import {
10
+ resetAppState,
11
+ createPlaylistViaUI,
12
+ addSongs,
13
+ waitForApp,
14
+ } from "./helpers.js";
15
+
16
+ // read the first docId from the app's musicPlaylistDB docIndex store.
17
+ // returns null if the store is empty.
18
+ async function readFirstDocId(page: ReturnType<typeof import("@playwright/test")["test"]["info"]> extends never ? never : import("@playwright/test").Page): Promise<string | null> {
19
+ return page.evaluate(() => {
20
+ return new Promise<string | null>((resolve) => {
21
+ const req = indexedDB.open("musicPlaylistDB");
22
+ req.onerror = () => resolve(null);
23
+ req.onsuccess = () => {
24
+ const db = req.result;
25
+ if (!db.objectStoreNames.contains("docIndex")) { resolve(null); return; }
26
+ const tx = db.transaction("docIndex", "readonly");
27
+ const store = tx.objectStore("docIndex");
28
+ const all = store.getAll();
29
+ all.onsuccess = () => {
30
+ const entries = all.result as Array<{ docId: string }>;
31
+ resolve(entries.length > 0 ? entries[0]!.docId : null);
32
+ };
33
+ all.onerror = () => resolve(null);
34
+ };
35
+ });
36
+ });
37
+ }
38
+
39
+ // base64url-encode a share token (matches encodeShareToken in freqhole-api-client).
40
+ // done in Node.js so we don't need a browser context.
41
+ function buildShareToken(docId: string, nodeId = "local", title = "test playlist"): string {
42
+ const payload = JSON.stringify({ v: 1, n: nodeId, d: docId, t: title });
43
+ const b64 = Buffer.from(payload).toString("base64");
44
+ // base64url: replace + with -, / with _, strip =
45
+ return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
46
+ }
47
+
48
+ test.beforeEach(async ({ page }) => {
49
+ await resetAppState(page);
50
+ });
51
+
52
+ test("navigating to a #share/ link selects the playlist", async ({ page }) => {
53
+ // set up: create a playlist with songs
54
+ await createPlaylistViaUI(page);
55
+ await addSongs(page, 2);
56
+ await page.getByTestId("input-playlist-title").fill("share target");
57
+ await page.getByTestId("input-playlist-title").blur();
58
+ await page.waitForTimeout(500);
59
+
60
+ // read the docId from idb
61
+ const docId = await readFirstDocId(page);
62
+ expect(docId).toBeTruthy();
63
+
64
+ // navigate to a share link pointing at that docId
65
+ const token = buildShareToken(docId!);
66
+ await page.goto(`/?#share/${token}`);
67
+ await waitForApp(page);
68
+
69
+ // the playlist with that title should be selected
70
+ // (either in the edit input or as the active playlist header)
71
+ await expect(
72
+ page.getByTestId("input-playlist-title").or(
73
+ page.getByText("share target").first()
74
+ )
75
+ ).toBeVisible({ timeout: 10000 });
76
+ });
77
+
78
+ test("#share/ link for a doc already in idb does not show an error", async ({ page }) => {
79
+ await createPlaylistViaUI(page);
80
+ await addSongs(page, 1);
81
+ await page.waitForTimeout(500);
82
+
83
+ const docId = await readFirstDocId(page);
84
+ expect(docId).toBeTruthy();
85
+
86
+ // capture console errors
87
+ const errors: string[] = [];
88
+ page.on("console", (msg) => {
89
+ if (msg.type() === "error") errors.push(msg.text());
90
+ });
91
+
92
+ const token = buildShareToken(docId!);
93
+ await page.goto(`/?#share/${token}`);
94
+ await waitForApp(page);
95
+ await page.waitForTimeout(1000);
96
+
97
+ // no app-level error banner
98
+ await expect(page.getByText(/failed to initialize/i)).not.toBeVisible();
99
+ // no js errors about the share link itself
100
+ const shareErrors = errors.filter((e) => e.includes("share") || e.includes("invalid share link"));
101
+ expect(shareErrors).toHaveLength(0);
102
+ });
103
+
104
+ test("#share/ fragment is cleared from the url after processing", async ({ page }) => {
105
+ await createPlaylistViaUI(page);
106
+ await page.waitForTimeout(500);
107
+
108
+ const docId = await readFirstDocId(page);
109
+ expect(docId).toBeTruthy();
110
+
111
+ const token = buildShareToken(docId!);
112
+ await page.goto(`/?#share/${token}`);
113
+ await waitForApp(page);
114
+ await page.waitForTimeout(1000);
115
+
116
+ // handleShareFragment calls history.replaceState to clear the fragment.
117
+ // this is async (depends on p2p initialization), so poll until done.
118
+ await page.waitForFunction(
119
+ () => !window.location.hash.startsWith("#share/"),
120
+ { timeout: 10000 }
121
+ );
122
+ });
123
+
124
+ test("invalid #share/ token shows no crash and loads app normally", async ({ page }) => {
125
+ await page.goto("/?#share/thisisnotavalidtoken");
126
+ await waitForApp(page);
127
+ // app should still load without a white screen
128
+ await expect(page.getByRole("heading", { name: "playlistz" })).toBeAttached();
129
+ });