@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,661 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+
3
+ // Mock navigator using vi.hoisted to ensure it runs before module imports
4
+ vi.hoisted(() => {
5
+ Object.defineProperty(global, "navigator", {
6
+ value: {
7
+ onLine: true,
8
+ serviceWorker: {
9
+ register: vi.fn().mockResolvedValue({}),
10
+ ready: Promise.resolve({
11
+ addEventListener: vi.fn(),
12
+ }),
13
+ addEventListener: vi.fn(),
14
+ controller: {
15
+ postMessage: vi.fn(),
16
+ },
17
+ },
18
+ storage: {
19
+ persist: vi.fn().mockResolvedValue(true),
20
+ estimate: vi.fn().mockResolvedValue({
21
+ usage: 100000000,
22
+ quota: 1000000000,
23
+ }),
24
+ },
25
+ },
26
+ writable: true,
27
+ });
28
+ });
29
+
30
+ import {
31
+ isOnline,
32
+ serviceWorkerReady,
33
+ persistentStorageGranted,
34
+ updatePWAManifest,
35
+ initializeOfflineSupport,
36
+ getStorageInfo,
37
+ isUrlCached,
38
+ cacheAudioFile,
39
+ } from "./offlineService.js";
40
+ import type { Playlist } from "../types/playlist.js";
41
+ import { mockManager } from "../test-setup.js";
42
+
43
+ Object.defineProperty(global, "URL", {
44
+ value: {
45
+ createObjectURL: vi.fn(() => `blob:mock-url-${Math.random()}`),
46
+ revokeObjectURL: vi.fn(),
47
+ },
48
+ writable: true,
49
+ });
50
+
51
+ // Test data
52
+ const createMockPlaylist = (overrides: Partial<Playlist> = {}): Playlist => ({
53
+ id: "test-playlist",
54
+ title: "Test Playlist",
55
+ description: "Test Description",
56
+ songIds: ["song-1", "song-2"],
57
+ createdAt: Date.now(),
58
+ updatedAt: Date.now(),
59
+ imageData: new ArrayBuffer(8),
60
+ imageType: "image/jpeg",
61
+ ...overrides,
62
+ });
63
+
64
+ describe("Offline Service Tests", () => {
65
+ beforeEach(() => {
66
+ mockManager.resetAllMocks();
67
+ mockManager.resetGlobalAPIs();
68
+
69
+ // Reset navigator mocks to default values
70
+ Object.defineProperty(global.navigator, "onLine", {
71
+ writable: true,
72
+ value: true,
73
+ });
74
+ global.navigator.serviceWorker.register = vi.fn().mockResolvedValue({});
75
+ Object.defineProperty(global.navigator.serviceWorker, "controller", {
76
+ writable: true,
77
+ value: {
78
+ postMessage: vi.fn(),
79
+ },
80
+ });
81
+ global.navigator.storage.persist = vi.fn().mockResolvedValue(true);
82
+ global.navigator.storage.estimate = vi.fn().mockResolvedValue({
83
+ usage: 100000000,
84
+ quota: 1000000000,
85
+ });
86
+
87
+ // Ensure document.querySelector returns proper mock elements
88
+ vi.mocked(document.querySelector).mockImplementation((selector) => {
89
+ // For specific test case that mocks existingLink
90
+ if (selector === 'link[rel="manifest"]') {
91
+ return null; // Let individual tests override this
92
+ }
93
+
94
+ if (selector.includes("meta") || selector.includes("link")) {
95
+ return {
96
+ setAttribute: vi.fn(),
97
+ remove: vi.fn(),
98
+ href: "",
99
+ content: "",
100
+ name: "",
101
+ getAttribute: vi.fn(),
102
+ rel: "",
103
+ sizes: "",
104
+ } as any;
105
+ }
106
+ return null;
107
+ });
108
+ });
109
+
110
+ afterEach(() => {
111
+ vi.restoreAllMocks();
112
+ });
113
+
114
+ describe("Online Status", () => {
115
+ it("should track online status", () => {
116
+ expect(isOnline()).toBe(true);
117
+
118
+ // Simulate going offline
119
+ Object.defineProperty(navigator, "onLine", { value: false });
120
+ const offlineEvent = new Event("offline");
121
+ window.dispatchEvent(offlineEvent);
122
+
123
+ // Note: The signal would update after event processing
124
+ expect(typeof isOnline()).toBe("boolean");
125
+ });
126
+
127
+ it("should track service worker ready status", () => {
128
+ expect(typeof serviceWorkerReady()).toBe("boolean");
129
+ });
130
+
131
+ it("should track persistent storage status", () => {
132
+ expect(typeof persistentStorageGranted()).toBe("boolean");
133
+ });
134
+ });
135
+
136
+ describe("PWA Manifest Generation", () => {
137
+ it("should update PWA manifest with playlist title", () => {
138
+ const playlistTitle = "My Awesome Playlist";
139
+
140
+ updatePWAManifest(playlistTitle);
141
+
142
+ expect(document.createElement).toHaveBeenCalledWith("link");
143
+ expect(document.head.appendChild).toHaveBeenCalled();
144
+ });
145
+
146
+ it("should update PWA manifest with playlist image", () => {
147
+ const playlistTitle = "My Playlist";
148
+ const playlist = createMockPlaylist({
149
+ imageType: "image/png",
150
+ });
151
+
152
+ updatePWAManifest(playlistTitle, playlist);
153
+
154
+ expect(document.createElement).toHaveBeenCalledWith("link");
155
+ expect(document.head.appendChild).toHaveBeenCalled();
156
+ });
157
+
158
+ it("should handle playlist without image", () => {
159
+ const playlistTitle = "My Playlist";
160
+ const playlist = createMockPlaylist({
161
+ imageData: undefined,
162
+ imageType: undefined,
163
+ });
164
+
165
+ updatePWAManifest(playlistTitle, playlist);
166
+
167
+ expect(document.createElement).toHaveBeenCalledWith("link");
168
+ });
169
+
170
+ it("should create apple touch icons", () => {
171
+ updatePWAManifest("Test Playlist");
172
+
173
+ // Should create multiple apple-touch-icon links
174
+ expect(document.createElement).toHaveBeenCalledWith("link");
175
+ // Expects 10 icon sizes + 1 default = 11 apple-touch-icon calls, plus other meta tags
176
+ expect(document.head.appendChild).toHaveBeenCalled();
177
+ });
178
+
179
+ it("should remove existing manifest before adding new one", () => {
180
+ const existingLink = {
181
+ remove: vi.fn(),
182
+ setAttribute: vi.fn(),
183
+ href: "",
184
+ rel: "manifest",
185
+ };
186
+ vi.mocked(document.querySelector).mockImplementation((selector) => {
187
+ if (selector === 'link[rel="manifest"]') {
188
+ return existingLink as any;
189
+ }
190
+ // Return proper meta elements for other queries
191
+ if (selector.includes("meta")) {
192
+ return {
193
+ setAttribute: vi.fn(),
194
+ content: "",
195
+ name: "",
196
+ } as any;
197
+ }
198
+ return null;
199
+ });
200
+
201
+ updatePWAManifest("New Playlist");
202
+
203
+ expect(existingLink.remove).toHaveBeenCalled();
204
+ });
205
+
206
+ it("should handle different image types", () => {
207
+ const cases = [
208
+ { imageType: "image/png", extension: ".png" },
209
+ { imageType: "image/webp", extension: ".webp" },
210
+ { imageType: "image/gif", extension: ".gif" },
211
+ { imageType: "image/jpeg", extension: ".jpg" },
212
+ ];
213
+
214
+ cases.forEach(({ imageType }) => {
215
+ const playlist = createMockPlaylist({ imageType });
216
+ updatePWAManifest("Test", playlist);
217
+ expect(document.createElement).toHaveBeenCalled();
218
+ });
219
+ });
220
+ });
221
+
222
+ describe("Storage Management", () => {
223
+ it("should get storage information", async () => {
224
+ const info = await getStorageInfo();
225
+
226
+ expect(info.quota).toBe(1000000000);
227
+ expect(info.usage).toBe(100000000);
228
+ expect(info.quotaFormatted).toBe("954 MB");
229
+ expect(info.usageFormatted).toBe("95 MB");
230
+ expect(info.usagePercent).toBe(10);
231
+ expect(info.persistent).toBe(true);
232
+ });
233
+
234
+ it("should handle storage API not available", async () => {
235
+ mockManager.mockAPIUnavailable.storage();
236
+
237
+ const info = await getStorageInfo();
238
+
239
+ expect(info.quota).toBeUndefined();
240
+ expect(info.usage).toBeUndefined();
241
+ expect(info.persistent).toBeUndefined();
242
+ });
243
+
244
+ it("should handle storage estimate not available", async () => {
245
+ const mockStorageWithoutEstimate = {
246
+ persist: vi.fn().mockResolvedValue(true),
247
+ persisted: vi.fn().mockResolvedValue(true),
248
+ };
249
+
250
+ Object.defineProperty(navigator, "storage", {
251
+ value: mockStorageWithoutEstimate,
252
+ });
253
+
254
+ const info = await getStorageInfo();
255
+
256
+ expect(info.persistent).toBe(true);
257
+ expect(info.quota).toBeUndefined();
258
+ });
259
+
260
+ it("should handle storage errors", async () => {
261
+ const { navigatorStorage } = mockManager.getMocks();
262
+ navigatorStorage.estimate.mockRejectedValue(new Error("Storage error"));
263
+
264
+ const info = await getStorageInfo();
265
+
266
+ expect(info.error).toBe("Storage error");
267
+ });
268
+
269
+ it("should calculate usage percentage correctly", async () => {
270
+ const { navigatorStorage } = mockManager.getMocks();
271
+ navigatorStorage.estimate.mockResolvedValue({
272
+ quota: 1000,
273
+ usage: 250,
274
+ });
275
+
276
+ const info = await getStorageInfo();
277
+
278
+ expect(info.usagePercent).toBe(25);
279
+ });
280
+ });
281
+
282
+ describe("Cache Management", () => {
283
+ it("should check if URL is cached", async () => {
284
+ const testUrl = "http://freqhole.net/audio.mp3";
285
+ const { cache, caches } = mockManager.getMocks();
286
+ cache.match.mockResolvedValue(new Response());
287
+
288
+ const isCached = await isUrlCached(testUrl);
289
+
290
+ expect(isCached).toBe(true);
291
+ expect(caches.open).toHaveBeenCalledWith("playlistz-cache-v1");
292
+ expect(cache.match).toHaveBeenCalledWith(testUrl);
293
+ });
294
+
295
+ it("should return false for non-cached URLs", async () => {
296
+ const testUrl = "http://freqhole.net/not-cached.mp3";
297
+ const { cache } = mockManager.getMocks();
298
+ cache.match.mockResolvedValue(undefined);
299
+
300
+ const isCached = await isUrlCached(testUrl);
301
+
302
+ expect(isCached).toBe(false);
303
+ });
304
+
305
+ it("should handle cache check errors", async () => {
306
+ const { caches } = mockManager.getMocks();
307
+ caches.open.mockRejectedValue(new Error("Cache error"));
308
+
309
+ const isCached = await isUrlCached("test-url");
310
+
311
+ expect(isCached).toBe(false);
312
+ });
313
+
314
+ it("should return false when caches API not available", async () => {
315
+ // Mock window.caches as undefined
316
+ const originalCaches = (global as any).window.caches;
317
+ delete (global as any).window.caches;
318
+
319
+ const isCached = await isUrlCached("test-url");
320
+
321
+ expect(isCached).toBe(false);
322
+
323
+ // Restore caches API
324
+ (global as any).window.caches = originalCaches;
325
+ });
326
+ });
327
+
328
+ describe("Audio File Caching", () => {
329
+ it("should cache audio file via service worker", async () => {
330
+ const testUrl = "blob:http://localhost/audio.mp3";
331
+ const testTitle = "Test Song";
332
+
333
+ const { serviceWorker } = mockManager.getMocks();
334
+ const mockController = {
335
+ postMessage: vi.fn(),
336
+ };
337
+ serviceWorker.controller = mockController as any;
338
+
339
+ await cacheAudioFile(testUrl, testTitle);
340
+
341
+ expect(mockController.postMessage).toHaveBeenCalledWith({
342
+ type: "CACHE_URL",
343
+ data: { url: testUrl },
344
+ });
345
+ });
346
+
347
+ it("should cache audio file directly when no service worker", async () => {
348
+ const testUrl = "http://freqhole.net/audio.mp3";
349
+ const testTitle = "Test Song";
350
+
351
+ const { serviceWorker } = mockManager.getMocks();
352
+ serviceWorker.controller = null;
353
+
354
+ await cacheAudioFile(testUrl, testTitle);
355
+
356
+ // Note: mockCache would need to be properly setup in beforeEach for this test
357
+ // expect(mockCache.match).toHaveBeenCalledWith(testUrl);
358
+ });
359
+
360
+ it("should handle file:// protocol gracefully", async () => {
361
+ Object.defineProperty(window.location, "protocol", { value: "file:" });
362
+
363
+ await cacheAudioFile("file://local/audio.mp3", "Local Song");
364
+
365
+ // Should return without error for file:// protocol
366
+ // Should return without error for file:// protocol
367
+ // Cache operations are skipped for file:// protocol
368
+ });
369
+
370
+ it("should throw error when cache API not supported", async () => {
371
+ // Mock window.caches as undefined
372
+ const originalCaches = (global as any).window.caches;
373
+ delete (global as any).window.caches;
374
+
375
+ await expect(cacheAudioFile("test-url", "Test Song")).rejects.toThrow(
376
+ "Cache API not supported"
377
+ );
378
+
379
+ // Restore caches API
380
+ (global as any).window.caches = originalCaches;
381
+ });
382
+
383
+ it("should handle cache add failures", async () => {
384
+ const { cache, caches, serviceWorker } = mockManager.getMocks();
385
+
386
+ // Force direct cache path by setting serviceWorker.controller to null
387
+ serviceWorker.controller = null;
388
+
389
+ // Mock window.location.protocol to not be "file:"
390
+ Object.defineProperty(window, "location", {
391
+ value: {
392
+ protocol: "http:",
393
+ href: "http://localhost:3000",
394
+ },
395
+ writable: true,
396
+ configurable: true,
397
+ });
398
+
399
+ // Use the existing caches mock from mockManager
400
+ caches.open.mockResolvedValue(cache);
401
+ cache.add.mockRejectedValue(new Error("Cache add failed"));
402
+
403
+ await expect(cacheAudioFile("test-url", "Test Song")).rejects.toThrow(
404
+ "Cache add failed"
405
+ );
406
+ });
407
+ });
408
+
409
+ describe("Service Worker Registration", () => {
410
+ it("should register service worker successfully", async () => {
411
+ const mockServiceWorkerRegistration = {
412
+ active: null,
413
+ installing: null,
414
+ waiting: null,
415
+ update: vi.fn().mockResolvedValue(undefined),
416
+ unregister: vi.fn().mockResolvedValue(true),
417
+ };
418
+
419
+ // Use the global navigator.serviceWorker mock directly
420
+ const mockRegister = vi
421
+ .fn()
422
+ .mockResolvedValue(mockServiceWorkerRegistration);
423
+ const mockAddEventListener = vi.fn();
424
+
425
+ // Mock the service worker with register function
426
+ vi.stubGlobal("navigator", {
427
+ ...global.navigator,
428
+ serviceWorker: {
429
+ register: mockRegister,
430
+ ready: Promise.resolve(mockServiceWorkerRegistration),
431
+ addEventListener: mockAddEventListener,
432
+ controller: null,
433
+ },
434
+ });
435
+
436
+ await initializeOfflineSupport("Test Playlist");
437
+
438
+ expect(mockRegister).toHaveBeenCalledWith("/sw.js");
439
+ });
440
+
441
+ it("should handle service worker registration failure", async () => {
442
+ const { serviceWorker } = mockManager.getMocks();
443
+ serviceWorker.register.mockRejectedValue(
444
+ new Error("SW registration failed")
445
+ );
446
+
447
+ // Should not throw error even if SW registration fails
448
+ await expect(
449
+ initializeOfflineSupport("Test Playlist")
450
+ ).resolves.toBeUndefined();
451
+ });
452
+
453
+ it("should handle browsers without service worker support", async () => {
454
+ mockManager.mockAPIUnavailable.serviceWorker();
455
+
456
+ await expect(
457
+ initializeOfflineSupport("Test Playlist")
458
+ ).resolves.toBeUndefined();
459
+ });
460
+
461
+ it("should set up service worker message listener", async () => {
462
+ const mockServiceWorkerRegistration = {
463
+ active: null,
464
+ installing: null,
465
+ waiting: null,
466
+ update: vi.fn().mockResolvedValue(undefined),
467
+ unregister: vi.fn().mockResolvedValue(true),
468
+ };
469
+
470
+ const mockAddEventListener = vi.fn();
471
+
472
+ // Mock the service worker with message listener support
473
+ const { serviceWorker } = mockManager.getMocks();
474
+ serviceWorker.register.mockResolvedValue(mockServiceWorkerRegistration);
475
+ serviceWorker.ready = Promise.resolve(mockServiceWorkerRegistration);
476
+ serviceWorker.addEventListener = mockAddEventListener;
477
+
478
+ await initializeOfflineSupport("Test Playlist");
479
+
480
+ // Check if addEventListener was called on navigator.serviceWorker
481
+ expect(mockAddEventListener).toHaveBeenCalledWith(
482
+ "message",
483
+ expect.any(Function)
484
+ );
485
+ });
486
+ });
487
+
488
+ describe("Persistent Storage", () => {
489
+ it("should request persistent storage successfully", async () => {
490
+ await initializeOfflineSupport("Test Playlist");
491
+
492
+ const { navigatorStorage } = mockManager.getMocks();
493
+ expect(navigatorStorage.persist).toHaveBeenCalled();
494
+ });
495
+
496
+ it("should handle persistent storage denial", async () => {
497
+ const { navigatorStorage } = mockManager.getMocks();
498
+ navigatorStorage.persist.mockResolvedValue(false);
499
+
500
+ await initializeOfflineSupport("Test Playlist");
501
+
502
+ expect(navigatorStorage.persist).toHaveBeenCalled();
503
+ });
504
+
505
+ it("should handle browsers without persistent storage support", async () => {
506
+ mockManager.mockAPIUnavailable.storage();
507
+
508
+ await expect(
509
+ initializeOfflineSupport("Test Playlist")
510
+ ).resolves.toBeUndefined();
511
+ });
512
+
513
+ it("should handle persistent storage errors", async () => {
514
+ const { navigatorStorage } = mockManager.getMocks();
515
+ navigatorStorage.persist.mockRejectedValue(new Error("Storage error"));
516
+
517
+ await expect(
518
+ initializeOfflineSupport("Test Playlist")
519
+ ).resolves.toBeUndefined();
520
+ });
521
+ });
522
+
523
+ describe("Event Listeners", () => {
524
+ it("should set up online/offline event listeners", async () => {
525
+ await initializeOfflineSupport("Test Playlist");
526
+
527
+ expect(window.addEventListener).toHaveBeenCalledWith(
528
+ "online",
529
+ expect.any(Function)
530
+ );
531
+ expect(window.addEventListener).toHaveBeenCalledWith(
532
+ "offline",
533
+ expect.any(Function)
534
+ );
535
+ });
536
+
537
+ it("should update online status on events", async () => {
538
+ await initializeOfflineSupport("Test Playlist");
539
+
540
+ // Get the event handler
541
+ const onlineHandler = vi
542
+ .mocked(window.addEventListener)
543
+ .mock.calls.find((call) => call[0] === "online")?.[1];
544
+
545
+ const offlineHandler = vi
546
+ .mocked(window.addEventListener)
547
+ .mock.calls.find((call) => call[0] === "offline")?.[1];
548
+
549
+ expect(onlineHandler).toBeDefined();
550
+ expect(offlineHandler).toBeDefined();
551
+
552
+ // These would update the signals when called
553
+ if (onlineHandler && typeof onlineHandler === "function") {
554
+ onlineHandler(new Event("online"));
555
+ }
556
+ if (offlineHandler && typeof offlineHandler === "function") {
557
+ offlineHandler(new Event("offline"));
558
+ }
559
+ });
560
+ });
561
+
562
+ describe("Initialization", () => {
563
+ it("should initialize with playlist title only", async () => {
564
+ await initializeOfflineSupport("Test Playlist");
565
+
566
+ // Wait for setTimeout to execute service worker registration
567
+ await new Promise((resolve) => setTimeout(resolve, 150));
568
+
569
+ const { navigatorStorage, serviceWorker } = mockManager.getMocks();
570
+ expect(document.createElement).toHaveBeenCalledWith("link");
571
+ expect(navigatorStorage.persist).toHaveBeenCalled();
572
+ expect(serviceWorker.register).toHaveBeenCalledWith("/sw.js");
573
+ });
574
+
575
+ it("should initialize with playlist title and data", async () => {
576
+ const playlist = createMockPlaylist();
577
+
578
+ await initializeOfflineSupport("Test Playlist", playlist);
579
+
580
+ expect(document.createElement).toHaveBeenCalled();
581
+ });
582
+
583
+ it("should handle initialization without arguments", async () => {
584
+ await initializeOfflineSupport();
585
+
586
+ expect(document.createElement).toHaveBeenCalled();
587
+ });
588
+ });
589
+
590
+ describe("Edge Cases", () => {
591
+ it("should handle very long playlist titles", () => {
592
+ const longTitle = "A".repeat(100);
593
+
594
+ updatePWAManifest(longTitle);
595
+
596
+ expect(document.createElement).toHaveBeenCalled();
597
+ });
598
+
599
+ it("should handle special characters in playlist titles", () => {
600
+ const specialTitle = "My Playlist! @#$%^&*()";
601
+
602
+ updatePWAManifest(specialTitle);
603
+
604
+ expect(document.createElement).toHaveBeenCalled();
605
+ });
606
+
607
+ it("should handle empty playlist title", () => {
608
+ updatePWAManifest("");
609
+
610
+ expect(document.createElement).toHaveBeenCalled();
611
+ });
612
+
613
+ it("should handle playlist with very large image data", () => {
614
+ const largeImageData = new ArrayBuffer(10 * 1024 * 1024); // 10MB
615
+ const playlist = createMockPlaylist({
616
+ imageData: largeImageData,
617
+ });
618
+
619
+ updatePWAManifest("Test", playlist);
620
+
621
+ expect(document.createElement).toHaveBeenCalled();
622
+ });
623
+ });
624
+
625
+ describe("Browser Compatibility", () => {
626
+ it("should work when storage estimate returns null values", async () => {
627
+ // Mock navigator.storage.estimate to return null values
628
+ Object.defineProperty(navigator, "storage", {
629
+ value: {
630
+ estimate: vi.fn().mockResolvedValue({
631
+ quota: null,
632
+ usage: null,
633
+ }),
634
+ },
635
+ configurable: true,
636
+ });
637
+
638
+ const info = await getStorageInfo();
639
+
640
+ expect(info.quota).toBeNull();
641
+ expect(info.usage).toBeNull();
642
+ expect(info.quotaFormatted).toBeUndefined();
643
+ expect(info.usageFormatted).toBeUndefined();
644
+ });
645
+
646
+ it("should handle missing document.head", () => {
647
+ Object.defineProperty(document, "head", { value: undefined });
648
+
649
+ // Should throw error when document.head is missing
650
+ expect(() => updatePWAManifest("Test")).toThrow();
651
+ });
652
+
653
+ it("should handle missing URL API", async () => {
654
+ Object.defineProperty(global, "URL", { value: undefined });
655
+
656
+ // Should throw error when URL API is missing
657
+ const playlist = createMockPlaylist();
658
+ expect(() => updatePWAManifest("Test", playlist)).toThrow();
659
+ });
660
+ });
661
+ });