@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.
- package/.changeset/config.json +11 -0
- package/.changeset/nice-wolves-thank.md +5 -0
- package/.freqhole-versions.json +4 -0
- package/.github/copilot-instructions.md +201 -0
- package/.github/workflows/changesets.yml +50 -0
- package/.github/workflows/npm-publish.yml +124 -0
- package/.github/workflows/pr-checks.yml +103 -0
- package/README.md +30 -0
- package/build-component.js +141 -0
- package/build-zip-bundle-lib.js +44 -0
- package/config/playwright.config.ts +47 -0
- package/config/vite.config.ts +44 -0
- package/config/vitest.config.ts +39 -0
- package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
- package/dist/assets/index-CbOXzGiA.js +216 -0
- package/dist/assets/index-CbOXzGiA.js.map +1 -0
- package/dist/assets/index-TvJ6RFpy.css +1 -0
- package/dist/assets/midden-DceCrT_L.js +2 -0
- package/dist/assets/midden-DceCrT_L.js.map +1 -0
- package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
- package/dist/index.html +55 -0
- package/dist/sw.js +134 -0
- package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
- package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
- package/docs/E2E_TESTID_PLAN.md +234 -0
- package/docs/IROH_P2P_PLAN.md +302 -0
- package/docs/ROADMAP.md +695 -0
- package/docs/TODO.md +167 -0
- package/docs/bundle-embedding-plan.md +134 -0
- package/docs/standalone-refactor.md +184 -0
- package/e2e/all-playlists.spec.ts +220 -0
- package/e2e/audio-player.spec.ts +226 -0
- package/e2e/collaborative-features.spec.ts +229 -0
- package/e2e/contexts.ts +238 -0
- package/e2e/edit-panel.spec.ts +87 -0
- package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
- package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
- package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
- package/e2e/fixtures/chord-stack-3s.wav +0 -0
- package/e2e/fixtures/cover-anim.gif +0 -0
- package/e2e/fixtures/cover-blue.png +0 -0
- package/e2e/fixtures/cover-checkers.png +0 -0
- package/e2e/fixtures/cover-gradient.jpg +0 -0
- package/e2e/fixtures/cover-mono.gif +0 -0
- package/e2e/fixtures/cover-noise.png +0 -0
- package/e2e/fixtures/cover-plasma.webp +0 -0
- package/e2e/fixtures/cover-portrait.jpg +0 -0
- package/e2e/fixtures/cover-red.png +0 -0
- package/e2e/fixtures/cover-thumb.jpg +0 -0
- package/e2e/fixtures/cover-wide.webp +0 -0
- package/e2e/fixtures/generate.mjs +257 -0
- package/e2e/fixtures/long-drone-90s.mp3 +0 -0
- package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
- package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
- package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
- package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
- package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
- package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
- package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
- package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
- package/e2e/fixtures/tone-220hz-10s.wav +0 -0
- package/e2e/fixtures/tone-440hz-2s.wav +0 -0
- package/e2e/fixtures/tone-880hz-5s.wav +0 -0
- package/e2e/fixtures/tone-stereo-3s.wav +0 -0
- package/e2e/fixtures/user-provided/README.md +1 -0
- package/e2e/helpers/app.ts +143 -0
- package/e2e/helpers/hooks.ts +133 -0
- package/e2e/helpers/index.ts +12 -0
- package/e2e/helpers/media.ts +125 -0
- package/e2e/helpers.ts +10 -0
- package/e2e/p2p-collaboration.spec.ts +356 -0
- package/e2e/p2p-multi-peer.spec.ts +723 -0
- package/e2e/p2p-states.spec.ts +302 -0
- package/e2e/playback.spec.ts +56 -0
- package/e2e/playlist-crud.spec.ts +126 -0
- package/e2e/share-link-autoplay.spec.ts +129 -0
- package/e2e/sharing-access.spec.ts +205 -0
- package/e2e/sharing.spec.ts +195 -0
- package/e2e/song-cache-state.spec.ts +202 -0
- package/e2e/zip-bundle.spec.ts +855 -0
- package/eslint.config.js +114 -0
- package/index.html +54 -0
- package/package.json +119 -0
- package/public/sw.js +134 -0
- package/scripts/use-local.mjs +37 -0
- package/scripts/use-published.mjs +37 -0
- package/src/App.tsx +9 -0
- package/src/cli/check.ts +164 -0
- package/src/cli/generate.ts +184 -0
- package/src/cli/http.ts +88 -0
- package/src/cli/index.ts +65 -0
- package/src/cli/init.ts +18 -0
- package/src/components/AllPlaylistsPanel.tsx +713 -0
- package/src/components/AudioPlayer.tsx +122 -0
- package/src/components/MarqueeText.tsx +101 -0
- package/src/components/PlaylistCoverModal.tsx +519 -0
- package/src/components/PlaylistEditPanel.tsx +803 -0
- package/src/components/PlaylistSharePanel.tsx +1020 -0
- package/src/components/ShareLinkKnockPanel.tsx +144 -0
- package/src/components/SharePanel.tsx +584 -0
- package/src/components/SongEditModal.tsx +453 -0
- package/src/components/SongEditPanel.tsx +578 -0
- package/src/components/SongRow.tsx +689 -0
- package/src/components/index.tsx +494 -0
- package/src/components/playlist/index.tsx +1203 -0
- package/src/context/PlaylistzContext.tsx +74 -0
- package/src/dev-hooks.ts +35 -0
- package/src/hooks/createDocIndexQuery.ts +53 -0
- package/src/hooks/createDocStore.test.ts +303 -0
- package/src/hooks/createDocStore.ts +90 -0
- package/src/hooks/useDragAndDrop.test.ts +474 -0
- package/src/hooks/useDragAndDrop.ts +400 -0
- package/src/hooks/useImageModal.test.ts +174 -0
- package/src/hooks/useImageModal.ts +201 -0
- package/src/hooks/usePlaylistManager.test.ts +453 -0
- package/src/hooks/usePlaylistManager.ts +685 -0
- package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
- package/src/hooks/usePlaylistsQuery.ts +44 -0
- package/src/hooks/useSongState.test.ts +236 -0
- package/src/hooks/useSongState.ts +114 -0
- package/src/hooks/useUIState.ts +71 -0
- package/src/index.tsx +18 -0
- package/src/services/audioService.dev.ts +22 -0
- package/src/services/audioService.test.ts +1226 -0
- package/src/services/audioService.ts +1395 -0
- package/src/services/automergeRepo.test.ts +269 -0
- package/src/services/automergeRepo.ts +226 -0
- package/src/services/blobTransferService.dev.ts +119 -0
- package/src/services/blobTransferService.test.ts +441 -0
- package/src/services/blobTransferService.ts +702 -0
- package/src/services/docIndexService.test.ts +179 -0
- package/src/services/docIndexService.ts +118 -0
- package/src/services/fileProcessingService.test.ts +554 -0
- package/src/services/fileProcessingService.ts +239 -0
- package/src/services/imageService.test.ts +701 -0
- package/src/services/imageService.ts +365 -0
- package/src/services/indexedDBService.integration.test.ts +104 -0
- package/src/services/indexedDBService.test.ts +202 -0
- package/src/services/indexedDBService.ts +436 -0
- package/src/services/offlineService.test.ts +661 -0
- package/src/services/offlineService.ts +382 -0
- package/src/services/p2pService.test.ts +305 -0
- package/src/services/p2pService.ts +344 -0
- package/src/services/playlistDocService.test.ts +448 -0
- package/src/services/playlistDocService.ts +707 -0
- package/src/services/playlistDownloadService.test.ts +674 -0
- package/src/services/playlistDownloadService.ts +389 -0
- package/src/services/sharingService.test.ts +812 -0
- package/src/services/sharingService.ts +1073 -0
- package/src/services/sharingState.ts +161 -0
- package/src/services/songReactivity.test.ts +620 -0
- package/src/services/songReactivity.ts +145 -0
- package/src/services/standaloneService.test.ts +1025 -0
- package/src/services/standaloneService.ts +588 -0
- package/src/services/streamingAudioService.test.ts +275 -0
- package/src/services/streamingAudioService.ts +166 -0
- package/src/styles.css +428 -0
- package/src/test-setup.ts +547 -0
- package/src/types/global.d.ts +40 -0
- package/src/types/playlist.ts +99 -0
- package/src/utils/hashUtils.ts +41 -0
- package/src/utils/log.ts +97 -0
- package/src/utils/m3u.test.ts +172 -0
- package/src/utils/m3u.ts +136 -0
- package/src/utils/mockData.ts +166 -0
- package/src/utils/standaloneTemplates.test.ts +175 -0
- package/src/utils/standaloneTemplates.ts +83 -0
- package/src/utils/swTemplate.ts +84 -0
- package/src/utils/timeUtils.ts +166 -0
- package/src/utils/typeGuards.ts +171 -0
- package/src/web-component.tsx +98 -0
- package/src/zip-bundle/index.ts +7 -0
- package/src/zip-bundle/m3u.ts +45 -0
- package/src/zip-bundle/types.ts +50 -0
- package/src/zip-bundle/utils.ts +33 -0
- package/src/zip-bundle/zipBuilder.ts +309 -0
- package/tailwind.config.js +55 -0
- 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
|
+
});
|