@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,382 @@
1
+ import { createSignal } from "solid-js";
2
+ import type { Playlist } from "../types/playlist.js";
3
+
4
+ // Offline state signals
5
+ const [isOnline, setIsOnline] = createSignal(navigator.onLine);
6
+ const [serviceWorkerReady, setServiceWorkerReady] = createSignal(false);
7
+ const [persistentStorageGranted, setPersistentStorageGranted] =
8
+ createSignal(false);
9
+
10
+ // Export signals for components to use
11
+ export { isOnline, serviceWorkerReady, persistentStorageGranted };
12
+
13
+ const CACHE_NAME = "playlistz-cache-v1";
14
+
15
+ /**
16
+ * Request persistent storage
17
+ */
18
+ async function requestPersistentStorage(): Promise<boolean> {
19
+ try {
20
+ if ("storage" in navigator && "persist" in navigator.storage) {
21
+ const granted = await navigator.storage.persist();
22
+
23
+ if (granted) {
24
+ setPersistentStorageGranted(true);
25
+ } else {
26
+ setPersistentStorageGranted(false);
27
+ }
28
+
29
+ return granted;
30
+ } else {
31
+ return false;
32
+ }
33
+ } catch (error) {
34
+ console.error("Error requesting persistent storage:", error);
35
+ return false;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Generate and register PWA manifest - simplified approach
41
+ */
42
+ function generatePWAManifest(
43
+ playlistTitle?: string,
44
+ playlistImagePath?: string
45
+ ): void {
46
+ const appName = playlistTitle || "playlistz";
47
+
48
+ // use playlist cover image if available, otherwise fallback to svg
49
+ let iconSrc;
50
+ let iconType;
51
+ if (playlistImagePath) {
52
+ iconSrc = playlistImagePath;
53
+ // determine type from file extension
54
+ if (playlistImagePath.endsWith(".png")) {
55
+ iconType = "image/png";
56
+ } else if (playlistImagePath.endsWith(".webp")) {
57
+ iconType = "image/webp";
58
+ } else {
59
+ iconType = "image/jpeg"; // default to jpeg
60
+ }
61
+ } else {
62
+ iconSrc = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192"><rect width="192" height="192" fill="#000000"/><text x="96" y="125" text-anchor="middle" font-size="100" font-family="Arial,sans-serif" font-weight="bold" fill="magenta">z</text></svg>')}`;
63
+ iconType = "image/svg+xml";
64
+ }
65
+
66
+ // Create a super simple manifest object
67
+ const manifest = {
68
+ name: appName,
69
+ short_name: appName.length > 12 ? appName.substring(0, 12) : appName,
70
+ description: "Offline music playlist manager",
71
+ start_url: "./",
72
+ display: "standalone",
73
+ background_color: "#000000",
74
+ theme_color: "#000000",
75
+ icons: [
76
+ {
77
+ src: iconSrc,
78
+ sizes: "192x192",
79
+ type: iconType,
80
+ },
81
+ ],
82
+ };
83
+
84
+ // Clear any existing manifest and apple-touch-icons for iOS refresh
85
+ const existingLink = document.querySelector('link[rel="manifest"]');
86
+ if (existingLink) {
87
+ existingLink.remove();
88
+ }
89
+
90
+ // Force iOS to refresh by removing all apple-touch-icons
91
+ const existingAppleIcons = document.querySelectorAll(
92
+ 'link[rel="apple-touch-icon"]'
93
+ );
94
+ existingAppleIcons.forEach((icon) => icon.remove());
95
+
96
+ // Create new manifest with cache busting
97
+ const manifestJSON = JSON.stringify(manifest);
98
+ const manifestBlob = new Blob([manifestJSON], {
99
+ type: "application/manifest+json",
100
+ });
101
+ const manifestURL = URL.createObjectURL(manifestBlob);
102
+
103
+ const link = document.createElement("link");
104
+ link.rel = "manifest";
105
+ // note: blob urls must not carry query strings - browsers treat the whole
106
+ // string as the lookup key and fail with ERR_FILE_NOT_FOUND
107
+ link.href = manifestURL;
108
+ document.head.appendChild(link);
109
+
110
+ // Add iOS meta tags
111
+ const metaTags = [
112
+ { name: "apple-mobile-web-app-capable", content: "yes" },
113
+ {
114
+ name: "apple-mobile-web-app-status-bar-style",
115
+ content: "black-translucent",
116
+ },
117
+ { name: "apple-mobile-web-app-title", content: appName },
118
+ { name: "theme-color", content: "#000000" },
119
+ ];
120
+
121
+ metaTags.forEach(({ name, content }) => {
122
+ let meta = document.querySelector(`meta[name="${name}"]`);
123
+ if (!meta) {
124
+ meta = document.createElement("meta");
125
+ meta.setAttribute("name", name);
126
+ document.head.appendChild(meta);
127
+ }
128
+ meta.setAttribute("content", content);
129
+ });
130
+
131
+ // Add fresh apple-touch-icons for iOS (multiple sizes with cache busting)
132
+ const iconSizes = [
133
+ "57x57",
134
+ "60x60",
135
+ "72x72",
136
+ "76x76",
137
+ "114x114",
138
+ "120x120",
139
+ "144x144",
140
+ "152x152",
141
+ "180x180",
142
+ ];
143
+ // use playlist image for apple touch icons if available
144
+ const appleIconSrc =
145
+ playlistImagePath ||
146
+ `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180" viewBox="0 0 180 180"><rect width="180" height="180" fill="#000000"/><text x="90" y="117" text-anchor="middle" font-size="94" font-family="Arial,sans-serif" font-weight="bold" fill="magenta">z</text></svg>')}`;
147
+
148
+ iconSizes.forEach((size) => {
149
+ const iconLink = document.createElement("link");
150
+ iconLink.rel = "apple-touch-icon";
151
+ iconLink.sizes = size;
152
+ iconLink.href = `${appleIconSrc}#${Date.now()}`;
153
+ document.head.appendChild(iconLink);
154
+ });
155
+
156
+ // Default apple-touch-icon
157
+ const defaultIcon = document.createElement("link");
158
+ defaultIcon.rel = "apple-touch-icon";
159
+ defaultIcon.href = `${appleIconSrc}#${Date.now()}`;
160
+ document.head.appendChild(defaultIcon);
161
+ }
162
+
163
+ /**
164
+ * helper function to generate playlist image path for pwa manifest
165
+ */
166
+ function getPlaylistImagePath(playlist: Playlist): string | undefined {
167
+ if (!playlist.imageData || !playlist.imageType) {
168
+ return undefined;
169
+ }
170
+
171
+ // determine file extension from mime type
172
+ let extension = ".jpg"; // default
173
+ if (playlist.imageType === "image/png") {
174
+ extension = ".png";
175
+ } else if (playlist.imageType === "image/webp") {
176
+ extension = ".webp";
177
+ } else if (playlist.imageType === "image/gif") {
178
+ extension = ".gif";
179
+ }
180
+
181
+ return `/data/playlist-cover${extension}`;
182
+ }
183
+
184
+ /**
185
+ * Update PWA manifest with new playlist title
186
+ */
187
+ export function updatePWAManifest(
188
+ playlistTitle: string,
189
+ playlist?: Playlist
190
+ ): void {
191
+ console.log("[trace] updatePWAManifest", playlistTitle);
192
+ const imagePath = playlist ? getPlaylistImagePath(playlist) : undefined;
193
+ generatePWAManifest(playlistTitle, imagePath);
194
+ }
195
+
196
+ /**
197
+ * Register service worker
198
+ */
199
+ async function registerServiceWorker(): Promise<boolean> {
200
+ try {
201
+ if (!("serviceWorker" in navigator)) {
202
+ return false;
203
+ }
204
+
205
+ const swPath = "/sw.js";
206
+ const registration = await navigator.serviceWorker.register(swPath);
207
+ await navigator.serviceWorker.ready;
208
+
209
+ setServiceWorkerReady(true);
210
+
211
+ // Listen for service worker messages
212
+ navigator.serviceWorker.addEventListener("message", (event) => {
213
+ const { type } = event.data;
214
+ if (type === "SW_READY") {
215
+ cacheCurrentPage();
216
+ }
217
+ });
218
+
219
+ // Check if SW is already controlling and cache page if so
220
+ if (navigator.serviceWorker.controller) {
221
+ cacheCurrentPage();
222
+ } else {
223
+ const newWorker =
224
+ registration.active || registration.installing || registration.waiting;
225
+ if (newWorker) {
226
+ newWorker.postMessage({ type: "CLAIM_CLIENTS" });
227
+ }
228
+ }
229
+
230
+ return true;
231
+ } catch (error) {
232
+ console.warn("service worker registration failed:", error);
233
+ return false;
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Cache the current page for offline access
239
+ */
240
+ async function cacheCurrentPage(): Promise<void> {
241
+ try {
242
+ const cache = await caches.open(CACHE_NAME);
243
+ const currentUrl = window.location.href;
244
+
245
+ const cached = await cache.match(currentUrl);
246
+ if (!cached) {
247
+ await cache.add(currentUrl);
248
+ }
249
+ } catch (error) {
250
+ console.warn("⚠️ Failed to auto-cache page:", error);
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Cache an audio file for offline access
256
+ */
257
+ export async function cacheAudioFile(
258
+ url: string,
259
+ title: string
260
+ ): Promise<void> {
261
+ try {
262
+ if (!("caches" in window)) {
263
+ throw new Error("Cache API not supported");
264
+ }
265
+
266
+ if (window.location.protocol === "file:") {
267
+ return;
268
+ }
269
+
270
+ if (navigator.serviceWorker && navigator.serviceWorker.controller) {
271
+ navigator.serviceWorker.controller.postMessage({
272
+ type: "CACHE_URL",
273
+ data: { url },
274
+ });
275
+ return;
276
+ }
277
+
278
+ const cache = await caches.open(CACHE_NAME);
279
+ await cache.add(url);
280
+ } catch (error) {
281
+ console.error(`Failed to cache audio file ${title}:`, error);
282
+ throw error;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Initialize offline support
288
+ */
289
+ export async function initializeOfflineSupport(
290
+ playlistTitle?: string,
291
+ playlist?: Playlist
292
+ ): Promise<void> {
293
+ const updateOnlineStatus = () => {
294
+ setIsOnline(navigator.onLine);
295
+ };
296
+
297
+ window.addEventListener("online", updateOnlineStatus);
298
+ window.addEventListener("offline", updateOnlineStatus);
299
+
300
+ const imagePath = playlist ? getPlaylistImagePath(playlist) : undefined;
301
+ generatePWAManifest(playlistTitle, imagePath);
302
+ await requestPersistentStorage();
303
+ await registerServiceWorker();
304
+ }
305
+
306
+ /**
307
+ * Get storage usage information
308
+ */
309
+ export async function getStorageInfo(): Promise<{
310
+ quota?: number;
311
+ usage?: number;
312
+ usagePercentage?: number;
313
+ persistent?: boolean;
314
+ error?: string;
315
+ quotaFormatted?: string;
316
+ usageFormatted?: string;
317
+ usagePercent?: number;
318
+ }> {
319
+ try {
320
+ const info: {
321
+ quota?: number;
322
+ usage?: number;
323
+ usagePercentage?: number;
324
+ persistent?: boolean;
325
+ error?: string;
326
+ quotaFormatted?: string;
327
+ usageFormatted?: string;
328
+ usagePercent?: number;
329
+ } = {};
330
+
331
+ if ("storage" in navigator && navigator.storage) {
332
+ if ("estimate" in navigator.storage) {
333
+ const estimate = await navigator.storage.estimate();
334
+ info.quota = estimate.quota;
335
+ info.usage = estimate.usage;
336
+
337
+ if (estimate.quota) {
338
+ info.quotaFormatted =
339
+ Math.round(estimate.quota / 1024 / 1024) + " MB";
340
+ }
341
+
342
+ if (estimate.usage) {
343
+ info.usageFormatted =
344
+ Math.round(estimate.usage / 1024 / 1024) + " MB";
345
+ }
346
+
347
+ if (estimate.quota && estimate.usage) {
348
+ info.usagePercent = Math.round(
349
+ (estimate.usage / estimate.quota) * 100
350
+ );
351
+ }
352
+ }
353
+
354
+ if ("persisted" in navigator.storage) {
355
+ info.persistent = await navigator.storage.persisted();
356
+ }
357
+ }
358
+
359
+ return info;
360
+ } catch (error) {
361
+ console.error("Error getting storage info:", error);
362
+ return { error: error instanceof Error ? error.message : String(error) };
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Check if a URL is cached
368
+ */
369
+ export async function isUrlCached(url: string): Promise<boolean> {
370
+ try {
371
+ if (!("caches" in window)) {
372
+ return false;
373
+ }
374
+
375
+ const cache = await caches.open(CACHE_NAME);
376
+ const response = await cache.match(url);
377
+ return !!response;
378
+ } catch (error) {
379
+ console.error("Error checking cache:", error);
380
+ return false;
381
+ }
382
+ }
@@ -0,0 +1,305 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import type { P2PIdentity } from "@freqhole/api-client/storage";
3
+
4
+ // --- mocks (hoisted before module imports) ---
5
+
6
+ const {
7
+ mockResolveIdentity,
8
+ mockPersistIdentity,
9
+ mockAcquireLeadership,
10
+ mockCreateWithAlpns,
11
+ } = vi.hoisted(() => ({
12
+ mockResolveIdentity: vi.fn(),
13
+ mockPersistIdentity: vi.fn(),
14
+ mockAcquireLeadership: vi.fn(),
15
+ mockCreateWithAlpns: vi.fn(),
16
+ }));
17
+
18
+ vi.mock("@freqhole/api-client/storage", () => ({
19
+ resolveIdentity: mockResolveIdentity,
20
+ persistIdentity: mockPersistIdentity,
21
+ acquireNodeLeadership: mockAcquireLeadership,
22
+ }));
23
+
24
+ vi.mock("@freqhole/midden", () => ({
25
+ MiddenNode: {
26
+ create_with_alpns: mockCreateWithAlpns,
27
+ },
28
+ }));
29
+
30
+ // automerge types are type-only imports in the service; no runtime mock needed.
31
+ // playlistz constants are plain strings - no mock needed either.
32
+
33
+ import {
34
+ startP2P,
35
+ stopP2P,
36
+ getNode,
37
+ getIdentity,
38
+ isLeader,
39
+ onLeadershipChange,
40
+ onIdentityChange,
41
+ getAdapterOptions,
42
+ _resetForTests,
43
+ } from "./p2pService.js";
44
+
45
+ // --- test helpers ---
46
+
47
+ const fakeIdentity = (overrides: Partial<P2PIdentity> = {}): P2PIdentity => ({
48
+ id: "p2p_identity",
49
+ secret_key: new Uint8Array(32).fill(7),
50
+ node_id: "fake-node-id",
51
+ created_at: 1000,
52
+ ...overrides,
53
+ });
54
+
55
+ const fakeMockNode = {
56
+ node_id: vi.fn().mockReturnValue("real-node-id"),
57
+ open_bi: vi.fn(),
58
+ accept: vi.fn().mockResolvedValue(null),
59
+ };
60
+
61
+ /** simulate the leadership callback being called (i.e. this tab won the lock). */
62
+ async function triggerLeader(): Promise<void> {
63
+ const call = mockAcquireLeadership.mock.calls[0];
64
+ if (!call) throw new Error("acquireNodeLeadership was not called");
65
+ await call[0].onAcquired();
66
+ }
67
+
68
+ // --- setup / teardown ---
69
+
70
+ beforeEach(() => {
71
+ _resetForTests();
72
+ vi.clearAllMocks();
73
+
74
+ // default: acquireLeadership never calls onAcquired (another tab holds the lock)
75
+ mockAcquireLeadership.mockReturnValue(() => {});
76
+
77
+ // default: persistIdentity succeeds silently
78
+ mockPersistIdentity.mockResolvedValue(undefined);
79
+ });
80
+
81
+ afterEach(() => {
82
+ stopP2P();
83
+ });
84
+
85
+ // --- identity fallback chain ---
86
+
87
+ describe("identity fallback chain", () => {
88
+ it("generates a new identity when none exists anywhere", async () => {
89
+ mockResolveIdentity.mockResolvedValue(null);
90
+
91
+ await startP2P();
92
+
93
+ const identity = getIdentity();
94
+ expect(identity).not.toBeNull();
95
+ expect(identity!.id).toBe("p2p_identity");
96
+ expect(identity!.secret_key).toBeInstanceOf(Uint8Array);
97
+ expect(identity!.secret_key.length).toBe(32);
98
+ // node_id is empty until midden boots
99
+ expect(identity!.node_id).toBe("");
100
+ expect(identity!.created_at).toBeGreaterThan(0);
101
+ // new identity should be persisted
102
+ expect(mockPersistIdentity).toHaveBeenCalledOnce();
103
+ });
104
+
105
+ it("uses existing identity from resolveIdentity when one exists", async () => {
106
+ const existing = fakeIdentity({ node_id: "stored-node-id" });
107
+ mockResolveIdentity.mockResolvedValue(existing);
108
+
109
+ await startP2P();
110
+
111
+ const identity = getIdentity();
112
+ expect(identity).toEqual(existing);
113
+ // should not generate or persist a new identity
114
+ expect(mockPersistIdentity).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it("notifies identity listeners after resolution", async () => {
118
+ const existing = fakeIdentity();
119
+ mockResolveIdentity.mockResolvedValue(existing);
120
+
121
+ const listener = vi.fn();
122
+ onIdentityChange(listener);
123
+
124
+ await startP2P();
125
+
126
+ expect(listener).toHaveBeenCalledWith(existing);
127
+ });
128
+
129
+ it("is idempotent: calling startP2P twice has no effect the second time", async () => {
130
+ mockResolveIdentity.mockResolvedValue(fakeIdentity());
131
+
132
+ await startP2P();
133
+ await startP2P();
134
+
135
+ // resolveIdentity called exactly once
136
+ expect(mockResolveIdentity).toHaveBeenCalledOnce();
137
+ });
138
+
139
+ it("handles identity resolution failure gracefully", async () => {
140
+ mockResolveIdentity.mockRejectedValue(new Error("idb exploded"));
141
+
142
+ // should not throw
143
+ await expect(startP2P()).resolves.toBeUndefined();
144
+ expect(getIdentity()).toBeNull();
145
+ });
146
+ });
147
+
148
+ // --- leadership gating ---
149
+
150
+ describe("leadership gating", () => {
151
+ it("does not boot midden when this tab is not the leader", async () => {
152
+ mockResolveIdentity.mockResolvedValue(fakeIdentity());
153
+ // acquireLeadership never calls onAcquired (someone else holds the lock)
154
+ mockAcquireLeadership.mockReturnValue(() => {});
155
+
156
+ await startP2P();
157
+
158
+ expect(getNode()).toBeNull();
159
+ expect(isLeader()).toBe(false);
160
+ });
161
+
162
+ it("boots midden and exposes the node when this tab wins the lock", async () => {
163
+ mockResolveIdentity.mockResolvedValue(fakeIdentity({ node_id: "" }));
164
+ mockCreateWithAlpns.mockResolvedValue(fakeMockNode);
165
+
166
+ await startP2P();
167
+ await triggerLeader();
168
+
169
+ expect(isLeader()).toBe(true);
170
+ expect(getNode()).toBe(fakeMockNode);
171
+ });
172
+
173
+ it("updates node_id in the stored identity after midden boots", async () => {
174
+ mockResolveIdentity.mockResolvedValue(fakeIdentity({ node_id: "old-id" }));
175
+ mockCreateWithAlpns.mockResolvedValue(fakeMockNode);
176
+
177
+ await startP2P();
178
+ await triggerLeader();
179
+
180
+ // real node_id from midden should be persisted
181
+ const updatedIdentity = getIdentity();
182
+ expect(updatedIdentity!.node_id).toBe("real-node-id");
183
+ expect(mockPersistIdentity).toHaveBeenCalledWith(
184
+ expect.objectContaining({ node_id: "real-node-id" }),
185
+ expect.anything()
186
+ );
187
+ });
188
+
189
+ it("notifies leadership listeners when acquiring leadership", async () => {
190
+ mockResolveIdentity.mockResolvedValue(fakeIdentity());
191
+ mockCreateWithAlpns.mockResolvedValue(fakeMockNode);
192
+
193
+ const listener = vi.fn();
194
+ onLeadershipChange(listener);
195
+ // called immediately with current state (false)
196
+ expect(listener).toHaveBeenCalledWith(false);
197
+ listener.mockClear();
198
+
199
+ await startP2P();
200
+ await triggerLeader();
201
+
202
+ expect(listener).toHaveBeenCalledWith(true);
203
+ });
204
+
205
+ it("releases leadership and clears node on stopP2P", async () => {
206
+ mockResolveIdentity.mockResolvedValue(fakeIdentity());
207
+ mockCreateWithAlpns.mockResolvedValue(fakeMockNode);
208
+
209
+ await startP2P();
210
+ await triggerLeader();
211
+ expect(isLeader()).toBe(true);
212
+
213
+ stopP2P();
214
+
215
+ expect(isLeader()).toBe(false);
216
+ expect(getNode()).toBeNull();
217
+ });
218
+ });
219
+
220
+ // --- graceful degradation when wasm is unavailable ---
221
+
222
+ describe("graceful degradation when wasm import fails", () => {
223
+ it("does not throw when midden import fails", async () => {
224
+ mockResolveIdentity.mockResolvedValue(fakeIdentity());
225
+ mockCreateWithAlpns.mockRejectedValue(new Error("wasm not available"));
226
+
227
+ await startP2P();
228
+ await triggerLeader();
229
+
230
+ // identity is still set; node is null (midden boot failed)
231
+ expect(getIdentity()).not.toBeNull();
232
+ expect(getNode()).toBeNull();
233
+ // still became leader (lock acquired) even though midden failed
234
+ expect(isLeader()).toBe(true);
235
+ });
236
+
237
+ it("does not throw when midden module is missing entirely", async () => {
238
+ mockResolveIdentity.mockResolvedValue(fakeIdentity());
239
+ // simulate dynamic import("@freqhole/midden") rejecting with module-not-found
240
+ mockCreateWithAlpns.mockImplementation(() => {
241
+ throw new TypeError("cannot find module 'midden'");
242
+ });
243
+
244
+ await startP2P();
245
+ await triggerLeader();
246
+
247
+ expect(getNode()).toBeNull();
248
+ expect(getIdentity()).not.toBeNull();
249
+ });
250
+ });
251
+
252
+ // --- adapter options ---
253
+
254
+ describe("getAdapterOptions", () => {
255
+ it("getNode rejects when node is not available", async () => {
256
+ mockResolveIdentity.mockResolvedValue(fakeIdentity());
257
+
258
+ await startP2P();
259
+ const opts = getAdapterOptions();
260
+
261
+ await expect(opts.getNode()).rejects.toThrow(
262
+ "p2p: midden node is not available"
263
+ );
264
+ });
265
+
266
+ it("getIdentity returns the current identity", async () => {
267
+ const id = fakeIdentity();
268
+ mockResolveIdentity.mockResolvedValue(id);
269
+
270
+ await startP2P();
271
+ const opts = getAdapterOptions();
272
+
273
+ await expect(opts.getIdentity()).resolves.toEqual(id);
274
+ });
275
+
276
+ it("getNode resolves the midden node after boot", async () => {
277
+ mockResolveIdentity.mockResolvedValue(fakeIdentity());
278
+ mockCreateWithAlpns.mockResolvedValue(fakeMockNode);
279
+
280
+ await startP2P();
281
+ await triggerLeader();
282
+
283
+ const opts = getAdapterOptions();
284
+ await expect(opts.getNode()).resolves.toBe(fakeMockNode);
285
+ });
286
+
287
+ it("onIdentityChange subscribes to identity updates", async () => {
288
+ const id = fakeIdentity();
289
+ mockResolveIdentity.mockResolvedValue(id);
290
+
291
+ const opts = getAdapterOptions();
292
+ const listener = vi.fn();
293
+ const unsub = opts.onIdentityChange!(listener);
294
+
295
+ await startP2P();
296
+
297
+ expect(listener).toHaveBeenCalledWith(id);
298
+ unsub();
299
+ });
300
+
301
+ it("syncAlpn is the automerge ALPN", () => {
302
+ const opts = getAdapterOptions();
303
+ expect(opts.syncAlpn).toBe("iroh/automerge-repo/1");
304
+ });
305
+ });