@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,855 @@
|
|
|
1
|
+
// e2e: zip bundle download + standalone roundtrip.
|
|
2
|
+
//
|
|
3
|
+
// 1. create a playlist with songs in the main vite dev app
|
|
4
|
+
// 2. click the download zip button and intercept the download
|
|
5
|
+
// 3. unzip the bundle (in the test process via jszip)
|
|
6
|
+
// 4. extract to a temp dir and start a mini http server serving it
|
|
7
|
+
// 5. navigate playwright to the standalone app
|
|
8
|
+
// 6. assert songs are visible and interactive (reusing existing helper patterns)
|
|
9
|
+
// 7. also tests zip reimport: drop the zip back onto the main app and verify
|
|
10
|
+
// songs reappear without duplication
|
|
11
|
+
//
|
|
12
|
+
// the suite runs `npm run build:standalone` automatically if
|
|
13
|
+
// dist/freqhole-playlistz.js is missing. the vite dev server at port 5917
|
|
14
|
+
// serves dist/ so the download service can embed the bundle in the zip.
|
|
15
|
+
|
|
16
|
+
import { test, expect, type Page } from "@playwright/test";
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import * as http from "node:http";
|
|
20
|
+
import * as os from "node:os";
|
|
21
|
+
import { execSync } from "node:child_process";
|
|
22
|
+
import JSZip from "jszip";
|
|
23
|
+
import {
|
|
24
|
+
resetAppState,
|
|
25
|
+
createPlaylistViaUI,
|
|
26
|
+
addSongs,
|
|
27
|
+
makePng,
|
|
28
|
+
setPlaylistCover,
|
|
29
|
+
} from "./helpers.js";
|
|
30
|
+
|
|
31
|
+
// --- inline http server for serving extracted zip contents ---
|
|
32
|
+
|
|
33
|
+
function startStaticServer(dir: string, port: number): Promise<http.Server> {
|
|
34
|
+
const MIME: Record<string, string> = {
|
|
35
|
+
".html": "text/html",
|
|
36
|
+
".js": "application/javascript",
|
|
37
|
+
".css": "text/css",
|
|
38
|
+
".json": "application/json",
|
|
39
|
+
".mp3": "audio/mpeg",
|
|
40
|
+
".wav": "audio/wav",
|
|
41
|
+
".flac": "audio/flac",
|
|
42
|
+
".ogg": "audio/ogg",
|
|
43
|
+
".jpg": "image/jpeg",
|
|
44
|
+
".jpeg": "image/jpeg",
|
|
45
|
+
".png": "image/png",
|
|
46
|
+
".map": "application/json",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const server = http.createServer((req, res) => {
|
|
50
|
+
const rawPath = req.url?.split("?")[0] ?? "/";
|
|
51
|
+
const urlPath = decodeURIComponent(rawPath);
|
|
52
|
+
const filePath = path.join(dir, urlPath === "/" ? "index.html" : urlPath);
|
|
53
|
+
|
|
54
|
+
// prevent path traversal
|
|
55
|
+
const absDir = path.resolve(dir);
|
|
56
|
+
const absFile = path.resolve(filePath);
|
|
57
|
+
if (!absFile.startsWith(absDir + path.sep) && absFile !== absDir) {
|
|
58
|
+
res.writeHead(403);
|
|
59
|
+
res.end("forbidden");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!fs.existsSync(absFile) || fs.statSync(absFile).isDirectory()) {
|
|
64
|
+
res.writeHead(404);
|
|
65
|
+
res.end("not found");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ext = path.extname(absFile).toLowerCase();
|
|
70
|
+
const mime = MIME[ext] ?? "application/octet-stream";
|
|
71
|
+
const total = fs.statSync(absFile).size;
|
|
72
|
+
const range = req.headers["range"];
|
|
73
|
+
|
|
74
|
+
if (range) {
|
|
75
|
+
const [, rangeStr] = range.split("=");
|
|
76
|
+
const [s, e] = (rangeStr ?? "").split("-");
|
|
77
|
+
const start = parseInt(s ?? "0", 10);
|
|
78
|
+
const end = e ? parseInt(e, 10) : total - 1;
|
|
79
|
+
res.writeHead(206, {
|
|
80
|
+
"Content-Range": `bytes ${start}-${end}/${total}`,
|
|
81
|
+
"Accept-Ranges": "bytes",
|
|
82
|
+
"Content-Length": end - start + 1,
|
|
83
|
+
"Content-Type": mime,
|
|
84
|
+
});
|
|
85
|
+
fs.createReadStream(absFile, { start, end }).pipe(res);
|
|
86
|
+
} else {
|
|
87
|
+
res.writeHead(200, {
|
|
88
|
+
"Content-Length": total,
|
|
89
|
+
"Content-Type": mime,
|
|
90
|
+
"Accept-Ranges": "bytes",
|
|
91
|
+
});
|
|
92
|
+
fs.createReadStream(absFile).pipe(res);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
server.on("error", reject);
|
|
98
|
+
server.listen(port, () => resolve(server));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// extract a JSZip instance to a directory on disk.
|
|
103
|
+
// returns the path.
|
|
104
|
+
async function extractZip(
|
|
105
|
+
zipBuffer: Buffer,
|
|
106
|
+
outDir: string
|
|
107
|
+
): Promise<string> {
|
|
108
|
+
const zip = await JSZip.loadAsync(zipBuffer);
|
|
109
|
+
const writes: Promise<void>[] = [];
|
|
110
|
+
zip.forEach((relativePath, file) => {
|
|
111
|
+
if (file.dir) return;
|
|
112
|
+
const dest = path.join(outDir, relativePath);
|
|
113
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
114
|
+
writes.push(
|
|
115
|
+
file.async("nodebuffer").then((buf) => fs.writeFileSync(dest, buf))
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
await Promise.all(writes);
|
|
119
|
+
return outDir;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// find the first subdirectory inside outDir that contains index.html
|
|
123
|
+
function findRootDir(outDir: string): string {
|
|
124
|
+
// zip layout: {safe-playlist-name}/index.html ...
|
|
125
|
+
for (const entry of fs.readdirSync(outDir)) {
|
|
126
|
+
const sub = path.join(outDir, entry);
|
|
127
|
+
if (
|
|
128
|
+
fs.statSync(sub).isDirectory() &&
|
|
129
|
+
fs.existsSync(path.join(sub, "index.html"))
|
|
130
|
+
) {
|
|
131
|
+
return sub;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// fallback: index.html at outDir root
|
|
135
|
+
return outDir;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// dist/freqhole-playlistz.js path
|
|
139
|
+
const REPO_ROOT = path.join(
|
|
140
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
141
|
+
".."
|
|
142
|
+
);
|
|
143
|
+
const BUNDLE_PATH = path.join(REPO_ROOT, "dist", "freqhole-playlistz.js");
|
|
144
|
+
|
|
145
|
+
// build the standalone bundle if it does not already exist.
|
|
146
|
+
// called in test.beforeAll() so it runs once per suite, not per test.
|
|
147
|
+
function ensureBundleBuilt(): void {
|
|
148
|
+
if (fs.existsSync(BUNDLE_PATH)) return;
|
|
149
|
+
console.log("[zip-bundle] dist/freqhole-playlistz.js not found - running build:standalone...");
|
|
150
|
+
execSync("npm run build:standalone", { cwd: REPO_ROOT, stdio: "inherit" });
|
|
151
|
+
if (!fs.existsSync(BUNDLE_PATH)) {
|
|
152
|
+
throw new Error("build:standalone did not produce dist/freqhole-playlistz.js");
|
|
153
|
+
}
|
|
154
|
+
console.log("[zip-bundle] build:standalone done");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- standalone http server port (avoid collision with vite port 5917) ---
|
|
158
|
+
const STANDALONE_PORT = 5920;
|
|
159
|
+
|
|
160
|
+
// open the edit panel (if not already open) and click the download zip button.
|
|
161
|
+
// the download button lives in the edit panel, not the main header.
|
|
162
|
+
async function openEditAndClickDownload(page: Page): Promise<void> {
|
|
163
|
+
const panel = page.getByTestId("edit-panel");
|
|
164
|
+
const isOpen = await panel.isVisible().catch(() => false);
|
|
165
|
+
if (!isOpen) {
|
|
166
|
+
await page.getByTestId("btn-edit-playlist").click();
|
|
167
|
+
await panel.waitFor({ timeout: 5_000 });
|
|
168
|
+
}
|
|
169
|
+
// wait for the download button to be enabled (not disabled by isDownloading/isLoading)
|
|
170
|
+
const dlBtn = page.getByTestId("btn-download-zip");
|
|
171
|
+
await dlBtn.waitFor({ state: "visible", timeout: 3_000 });
|
|
172
|
+
await expect(dlBtn).not.toBeDisabled({ timeout: 3_000 });
|
|
173
|
+
await dlBtn.click();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
test.describe("zip bundle download + standalone roundtrip", () => {
|
|
177
|
+
test.beforeAll(() => ensureBundleBuilt());
|
|
178
|
+
|
|
179
|
+
test.beforeEach(async ({ page }) => {
|
|
180
|
+
await resetAppState(page);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("download button is visible and triggers a zip download", async ({
|
|
184
|
+
page,
|
|
185
|
+
}) => {
|
|
186
|
+
await createPlaylistViaUI(page);
|
|
187
|
+
await addSongs(page, 2);
|
|
188
|
+
|
|
189
|
+
const downloadPromise = page.waitForEvent("download");
|
|
190
|
+
await openEditAndClickDownload(page);
|
|
191
|
+
const download = await downloadPromise;
|
|
192
|
+
|
|
193
|
+
expect(download.suggestedFilename()).toMatch(/\.zip$/);
|
|
194
|
+
await download.cancel(); // just checking the trigger fires
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("downloaded zip contains index.html, playlistz.js, and audio files", async ({
|
|
198
|
+
page,
|
|
199
|
+
}) => {
|
|
200
|
+
await createPlaylistViaUI(page);
|
|
201
|
+
await addSongs(page, 2);
|
|
202
|
+
|
|
203
|
+
const titleInput = page.getByTestId("input-playlist-title");
|
|
204
|
+
await titleInput.fill("my test playlist");
|
|
205
|
+
await titleInput.blur();
|
|
206
|
+
await expect(titleInput).toHaveValue("my test playlist");
|
|
207
|
+
|
|
208
|
+
const downloadPromise = page.waitForEvent("download");
|
|
209
|
+
await openEditAndClickDownload(page);
|
|
210
|
+
const download = await downloadPromise;
|
|
211
|
+
|
|
212
|
+
const zipPath = await download.path();
|
|
213
|
+
expect(zipPath).toBeTruthy();
|
|
214
|
+
const zipBuf = fs.readFileSync(zipPath!);
|
|
215
|
+
const zip = await JSZip.loadAsync(zipBuf);
|
|
216
|
+
|
|
217
|
+
const names = Object.keys(zip.files);
|
|
218
|
+
|
|
219
|
+
// must have index.html and playlistz.js somewhere in the zip
|
|
220
|
+
expect(names.some((n) => n.endsWith("index.html"))).toBe(true);
|
|
221
|
+
expect(names.some((n) => n.endsWith("playlistz.js"))).toBe(true);
|
|
222
|
+
|
|
223
|
+
// must have at least 2 audio files
|
|
224
|
+
const audioFiles = names.filter((n) =>
|
|
225
|
+
/\.(wav|mp3|flac|ogg|aiff|m4a)$/i.test(n)
|
|
226
|
+
);
|
|
227
|
+
expect(audioFiles.length).toBeGreaterThanOrEqual(2);
|
|
228
|
+
|
|
229
|
+
// validate playlistz.js content
|
|
230
|
+
const playlistzJsFile = zip.file(/playlistz\.js$/)[0];
|
|
231
|
+
expect(playlistzJsFile).toBeTruthy();
|
|
232
|
+
const playlistzJs = await playlistzJsFile!.async("string");
|
|
233
|
+
|
|
234
|
+
// must set the data-playlistz attribute on the web component element
|
|
235
|
+
expect(playlistzJs).toContain("setAttribute('data-playlistz'");
|
|
236
|
+
|
|
237
|
+
// extract and parse the JSON payload from setAttribute('data-playlistz', <json>)
|
|
238
|
+
const attrMatch = playlistzJs.match(/setAttribute\('data-playlistz',\s*("(?:[^"\\]|\\.)*")\)/);
|
|
239
|
+
expect(attrMatch).toBeTruthy();
|
|
240
|
+
const innerJson = JSON.parse(attrMatch![1]!);
|
|
241
|
+
const playlistzData = JSON.parse(innerJson) as Array<{
|
|
242
|
+
playlist: { id: string; title: string };
|
|
243
|
+
songs: Array<{ title: string; duration: number; originalFilename: string; mimeType: string }>;
|
|
244
|
+
}>;
|
|
245
|
+
|
|
246
|
+
expect(playlistzData).toHaveLength(1);
|
|
247
|
+
const entry = playlistzData[0]!;
|
|
248
|
+
|
|
249
|
+
expect(entry.playlist.title).toBe("my test playlist");
|
|
250
|
+
expect(entry.songs).toHaveLength(2);
|
|
251
|
+
|
|
252
|
+
// each song must have a title, a positive duration, a filename, and a mime type
|
|
253
|
+
for (const song of entry.songs) {
|
|
254
|
+
expect(song.title).toBeTruthy();
|
|
255
|
+
expect(song.duration).toBeGreaterThan(0);
|
|
256
|
+
expect(song.originalFilename).toMatch(/\.wav$/i);
|
|
257
|
+
expect(song.mimeType).toBeTruthy();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("downloaded zip contains playlist cover and song cover images with valid extensions", async ({
|
|
262
|
+
page,
|
|
263
|
+
}) => {
|
|
264
|
+
await createPlaylistViaUI(page);
|
|
265
|
+
await addSongs(page, 1);
|
|
266
|
+
|
|
267
|
+
// add a playlist cover
|
|
268
|
+
await page.getByTestId("btn-edit-playlist").click();
|
|
269
|
+
const coverBytes = await makePng(page, { width: 32, height: 32, color: "#aa00ff" });
|
|
270
|
+
await setPlaylistCover(page, { name: "cover.png", mimeType: "image/png", bytes: coverBytes });
|
|
271
|
+
await page.waitForTimeout(600);
|
|
272
|
+
// close the playlist edit panel before interacting with song rows
|
|
273
|
+
await page.getByTestId("btn-edit-playlist").click();
|
|
274
|
+
await page.waitForTimeout(200);
|
|
275
|
+
|
|
276
|
+
// add a song cover via the song edit panel
|
|
277
|
+
const row = page.getByTestId("song-row").first();
|
|
278
|
+
await row.hover();
|
|
279
|
+
await page.getByTestId("btn-edit-song").first().click();
|
|
280
|
+
await page.getByTestId("song-edit-panel").waitFor();
|
|
281
|
+
|
|
282
|
+
const songCoverBytes = await makePng(page, { width: 32, height: 32, color: "#00aaff" });
|
|
283
|
+
const songCoverInput = page.locator("#song-image-upload-panel");
|
|
284
|
+
await songCoverInput.setInputFiles({ name: "song-cover.png", mimeType: "image/png", buffer: Buffer.from(songCoverBytes) });
|
|
285
|
+
await page.waitForTimeout(400);
|
|
286
|
+
|
|
287
|
+
// click save in the song edit panel
|
|
288
|
+
await page.locator('[data-testid="song-edit-panel"] button').filter({ hasText: "save" }).click();
|
|
289
|
+
await page.waitForTimeout(600);
|
|
290
|
+
|
|
291
|
+
// close the song edit panel
|
|
292
|
+
await page.locator('[data-testid="song-edit-panel"] button[title="close"]').click();
|
|
293
|
+
await page.waitForTimeout(300);
|
|
294
|
+
|
|
295
|
+
const downloadPromise = page.waitForEvent("download");
|
|
296
|
+
await openEditAndClickDownload(page);
|
|
297
|
+
const download = await downloadPromise;
|
|
298
|
+
const zipBuf = fs.readFileSync((await download.path())!);
|
|
299
|
+
const zip = await JSZip.loadAsync(zipBuf);
|
|
300
|
+
const names = Object.keys(zip.files);
|
|
301
|
+
|
|
302
|
+
// playlist cover must be a valid image file (not .bin)
|
|
303
|
+
const playlistCoverFiles = names.filter((n) => n.includes("playlist-cover"));
|
|
304
|
+
expect(playlistCoverFiles.length).toBeGreaterThanOrEqual(1);
|
|
305
|
+
expect(playlistCoverFiles.some((n) => /\.(png|jpg|jpeg|gif|webp)$/i.test(n))).toBe(true);
|
|
306
|
+
|
|
307
|
+
// song cover image must be a valid image file (not .bin)
|
|
308
|
+
const songImageFiles = names.filter((n) => n.includes("-cover.") && !n.includes("playlist-cover"));
|
|
309
|
+
expect(songImageFiles.length).toBeGreaterThanOrEqual(1);
|
|
310
|
+
expect(songImageFiles.some((n) => /\.(png|jpg|jpeg|gif|webp)$/i.test(n))).toBe(true);
|
|
311
|
+
|
|
312
|
+
// imageMimeType in playlistz.js must be a real MIME type, not "original"
|
|
313
|
+
const playlistzJsFile = zip.file(/playlistz\.js$/)[0]!;
|
|
314
|
+
const playlistzJs = await playlistzJsFile.async("string");
|
|
315
|
+
const attrMatch = playlistzJs.match(/setAttribute\('data-playlistz',\s*("(?:[^"\\]|\\.)*")\)/);
|
|
316
|
+
expect(attrMatch).toBeTruthy();
|
|
317
|
+
const innerJson = JSON.parse(attrMatch![1]!);
|
|
318
|
+
const playlistzData = JSON.parse(innerJson) as Array<{
|
|
319
|
+
playlist: { imageMimeType?: string };
|
|
320
|
+
songs: Array<{ imageMimeType?: string }>;
|
|
321
|
+
}>;
|
|
322
|
+
const entry = playlistzData[0]!;
|
|
323
|
+
expect(entry.playlist.imageMimeType).toMatch(/^image\//);
|
|
324
|
+
expect(entry.songs[0]?.imageMimeType).toMatch(/^image\//);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("zip reimport: drop zip back onto the app and songs reappear", async ({
|
|
328
|
+
page,
|
|
329
|
+
}) => {
|
|
330
|
+
await createPlaylistViaUI(page);
|
|
331
|
+
await addSongs(page, 3);
|
|
332
|
+
|
|
333
|
+
// download and capture the zip
|
|
334
|
+
const downloadPromise = page.waitForEvent("download");
|
|
335
|
+
await openEditAndClickDownload(page);
|
|
336
|
+
const download = await downloadPromise;
|
|
337
|
+
const zipPath = await download.path();
|
|
338
|
+
const zipBuf = fs.readFileSync(zipPath!);
|
|
339
|
+
|
|
340
|
+
// reset to a clean state
|
|
341
|
+
await resetAppState(page);
|
|
342
|
+
// wait for empty state - confirms app is interactive and __processFiles is live
|
|
343
|
+
await page.getByTestId("btn-new-playlist").waitFor();
|
|
344
|
+
|
|
345
|
+
// use window.__processFiles (dev hook) instead of DragEvent to avoid
|
|
346
|
+
// browser DataTransfer restrictions on synthesized events
|
|
347
|
+
const result = await page.evaluate(async (zipBase64: string) => {
|
|
348
|
+
const bin = atob(zipBase64);
|
|
349
|
+
const bytes = new Uint8Array(bin.length);
|
|
350
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
351
|
+
const file = new File([bytes], "playlist.zip", { type: "application/zip" });
|
|
352
|
+
const hook = (window as typeof window & { __processFiles?: (files: File[]) => Promise<void> }).__processFiles;
|
|
353
|
+
if (!hook) return "hook-missing";
|
|
354
|
+
try {
|
|
355
|
+
await hook([file]);
|
|
356
|
+
return "ok";
|
|
357
|
+
} catch (e) {
|
|
358
|
+
return String(e);
|
|
359
|
+
}
|
|
360
|
+
}, zipBuf.toString("base64"));
|
|
361
|
+
|
|
362
|
+
if (result !== "ok") throw new Error(`__processFiles failed: ${result}`);
|
|
363
|
+
|
|
364
|
+
// after reimport, songs should be visible
|
|
365
|
+
await expect(page.getByText("song-00")).toBeVisible({ timeout: 15000 });
|
|
366
|
+
await expect(page.getByText("song-01")).toBeVisible({ timeout: 15000 });
|
|
367
|
+
await expect(page.getByText("song-02")).toBeVisible({ timeout: 15000 });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("zip reimport dedup: re-importing the same zip does not add duplicate songs", async ({
|
|
371
|
+
page,
|
|
372
|
+
}) => {
|
|
373
|
+
test.setTimeout(60_000);
|
|
374
|
+
|
|
375
|
+
// build a playlist with 3 songs and download its zip
|
|
376
|
+
await createPlaylistViaUI(page);
|
|
377
|
+
await addSongs(page, 3);
|
|
378
|
+
|
|
379
|
+
const downloadPromise = page.waitForEvent("download");
|
|
380
|
+
await openEditAndClickDownload(page);
|
|
381
|
+
const download = await downloadPromise;
|
|
382
|
+
const zipPath = await download.path();
|
|
383
|
+
expect(zipPath).toBeTruthy();
|
|
384
|
+
const zipBuf = fs.readFileSync(zipPath!);
|
|
385
|
+
|
|
386
|
+
await resetAppState(page);
|
|
387
|
+
await page.getByTestId("btn-new-playlist").waitFor();
|
|
388
|
+
|
|
389
|
+
const importZip = async () =>
|
|
390
|
+
page.evaluate(async (b64: string) => {
|
|
391
|
+
const bin = atob(b64);
|
|
392
|
+
const bytes = new Uint8Array(bin.length);
|
|
393
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
394
|
+
const file = new File([bytes], "playlist.zip", { type: "application/zip" });
|
|
395
|
+
const hook = (window as typeof window & { __processFiles?: (files: File[]) => Promise<void> }).__processFiles;
|
|
396
|
+
if (!hook) return "hook-missing";
|
|
397
|
+
await hook([file]);
|
|
398
|
+
return "ok";
|
|
399
|
+
}, zipBuf.toString("base64"));
|
|
400
|
+
|
|
401
|
+
// first import
|
|
402
|
+
const r1 = await importZip();
|
|
403
|
+
if (r1 !== "ok") throw new Error(`first import failed: ${r1}`);
|
|
404
|
+
await expect(page.getByText("song-00")).toBeVisible({ timeout: 15000 });
|
|
405
|
+
|
|
406
|
+
const countBefore = await page.getByTestId("song-duration").count();
|
|
407
|
+
expect(countBefore).toBe(3);
|
|
408
|
+
|
|
409
|
+
// second import of the same zip - should dedup, not add duplicates
|
|
410
|
+
const r2 = await importZip();
|
|
411
|
+
if (r2 !== "ok") throw new Error(`second import failed: ${r2}`);
|
|
412
|
+
|
|
413
|
+
// wait briefly then assert count is unchanged
|
|
414
|
+
await page.waitForTimeout(500);
|
|
415
|
+
const countAfter = await page.getByTestId("song-duration").count();
|
|
416
|
+
expect(countAfter).toBe(countBefore);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("standalone mode: zip serves via http and shows songs", async ({
|
|
420
|
+
page,
|
|
421
|
+
}) => {
|
|
422
|
+
test.setTimeout(120_000); // build:standalone + extract + serve
|
|
423
|
+
await createPlaylistViaUI(page);
|
|
424
|
+
|
|
425
|
+
// give the playlist a recognisable title
|
|
426
|
+
const titleInput = page.getByTestId("input-playlist-title");
|
|
427
|
+
await titleInput.fill("standalone-test");
|
|
428
|
+
await titleInput.blur();
|
|
429
|
+
await page.waitForTimeout(300);
|
|
430
|
+
|
|
431
|
+
await addSongs(page, 2);
|
|
432
|
+
|
|
433
|
+
// download and capture the zip
|
|
434
|
+
const downloadPromise = page.waitForEvent("download", { timeout: 30000 });
|
|
435
|
+
await openEditAndClickDownload(page);
|
|
436
|
+
const download = await downloadPromise;
|
|
437
|
+
const zipPath = await download.path();
|
|
438
|
+
expect(zipPath, "zip download path should exist").toBeTruthy();
|
|
439
|
+
|
|
440
|
+
const zipBuf = fs.readFileSync(zipPath!);
|
|
441
|
+
|
|
442
|
+
// extract zip to a temp dir
|
|
443
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "playlistz-e2e-"));
|
|
444
|
+
await extractZip(zipBuf, tmpDir);
|
|
445
|
+
|
|
446
|
+
// find the subdirectory containing index.html
|
|
447
|
+
const serveDir = findRootDir(tmpDir);
|
|
448
|
+
expect(
|
|
449
|
+
fs.existsSync(path.join(serveDir, "index.html")),
|
|
450
|
+
"index.html should exist in extracted zip"
|
|
451
|
+
).toBe(true);
|
|
452
|
+
expect(
|
|
453
|
+
fs.existsSync(path.join(serveDir, "playlistz.js")),
|
|
454
|
+
"playlistz.js should exist in extracted zip"
|
|
455
|
+
).toBe(true);
|
|
456
|
+
|
|
457
|
+
// start a static server for the standalone app
|
|
458
|
+
const server = await startStaticServer(serveDir, STANDALONE_PORT);
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
// open a fresh browser context (no IndexedDB from the main app)
|
|
462
|
+
const ctx = await page.context().browser()!.newContext();
|
|
463
|
+
const standalonePage = await ctx.newPage();
|
|
464
|
+
|
|
465
|
+
await standalonePage.goto(`http://localhost:${STANDALONE_PORT}/`);
|
|
466
|
+
|
|
467
|
+
// standalone app uses <freqhole-playlistz> web component + STANDALONE_MODE
|
|
468
|
+
// wait for the app heading to appear
|
|
469
|
+
await standalonePage
|
|
470
|
+
.getByRole("heading", { name: "playlistz" })
|
|
471
|
+
.waitFor({ timeout: 15000 });
|
|
472
|
+
|
|
473
|
+
// songs should be visible (loaded from playlistz.js)
|
|
474
|
+
await expect(standalonePage.getByText("song-00")).toBeVisible({
|
|
475
|
+
timeout: 10000,
|
|
476
|
+
});
|
|
477
|
+
await expect(standalonePage.getByText("song-01")).toBeVisible();
|
|
478
|
+
|
|
479
|
+
// song count badge should show 2
|
|
480
|
+
await expect(standalonePage.getByText("2 songz").first()).toBeVisible();
|
|
481
|
+
|
|
482
|
+
// the title should match what we set
|
|
483
|
+
await expect(
|
|
484
|
+
standalonePage.getByTestId("input-playlist-title")
|
|
485
|
+
).toHaveValue("standalone-test");
|
|
486
|
+
|
|
487
|
+
await ctx.close();
|
|
488
|
+
} finally {
|
|
489
|
+
await new Promise<void>((res) => server.close(() => res()));
|
|
490
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("standalone mode: audio plays from the served zip", async ({ page }) => {
|
|
495
|
+
test.setTimeout(120_000);
|
|
496
|
+
await createPlaylistViaUI(page);
|
|
497
|
+
await addSongs(page, 1, 2); // 2-second song for faster playback check
|
|
498
|
+
|
|
499
|
+
const downloadPromise = page.waitForEvent("download", { timeout: 30000 });
|
|
500
|
+
await openEditAndClickDownload(page);
|
|
501
|
+
const download = await downloadPromise;
|
|
502
|
+
const zipBuf = fs.readFileSync((await download.path())!);
|
|
503
|
+
|
|
504
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "playlistz-e2e-audio-"));
|
|
505
|
+
await extractZip(zipBuf, tmpDir);
|
|
506
|
+
const serveDir = findRootDir(tmpDir);
|
|
507
|
+
|
|
508
|
+
const server = await startStaticServer(serveDir, STANDALONE_PORT + 1);
|
|
509
|
+
|
|
510
|
+
try {
|
|
511
|
+
const ctx = await page.context().browser()!.newContext();
|
|
512
|
+
const standalonePage = await ctx.newPage();
|
|
513
|
+
|
|
514
|
+
await standalonePage.goto(`http://localhost:${STANDALONE_PORT + 1}/`);
|
|
515
|
+
await standalonePage.getByRole("heading", { name: "playlistz" }).waitFor({ timeout: 15000 });
|
|
516
|
+
await standalonePage.getByText("song-00").waitFor({ timeout: 10000 });
|
|
517
|
+
|
|
518
|
+
// click the first song row to select + play
|
|
519
|
+
await standalonePage.getByText("song-00").first().click();
|
|
520
|
+
await standalonePage.waitForTimeout(1000);
|
|
521
|
+
|
|
522
|
+
// the audio player should show the song title
|
|
523
|
+
await expect(
|
|
524
|
+
standalonePage.getByText("song-00").first()
|
|
525
|
+
).toBeVisible();
|
|
526
|
+
|
|
527
|
+
await ctx.close();
|
|
528
|
+
} finally {
|
|
529
|
+
await new Promise<void>((res) => server.close(() => res()));
|
|
530
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test.describe("--http CLI server mode", () => {
|
|
536
|
+
test.beforeAll(() => ensureBundleBuilt());
|
|
537
|
+
|
|
538
|
+
test.beforeEach(async ({ page }) => {
|
|
539
|
+
await resetAppState(page);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("serves extracted zip via the cli http server logic", async ({
|
|
543
|
+
page,
|
|
544
|
+
}) => {
|
|
545
|
+
test.setTimeout(120_000);
|
|
546
|
+
await createPlaylistViaUI(page);
|
|
547
|
+
|
|
548
|
+
const titleInput = page.getByTestId("input-playlist-title");
|
|
549
|
+
await titleInput.fill("http-server-test");
|
|
550
|
+
await titleInput.blur();
|
|
551
|
+
await page.waitForTimeout(300);
|
|
552
|
+
|
|
553
|
+
await addSongs(page, 2);
|
|
554
|
+
|
|
555
|
+
const downloadPromise = page.waitForEvent("download", { timeout: 30000 });
|
|
556
|
+
await openEditAndClickDownload(page);
|
|
557
|
+
const download = await downloadPromise;
|
|
558
|
+
const zipBuf = fs.readFileSync((await download.path())!);
|
|
559
|
+
|
|
560
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "playlistz-e2e-http-"));
|
|
561
|
+
await extractZip(zipBuf, tmpDir);
|
|
562
|
+
const serveDir = findRootDir(tmpDir);
|
|
563
|
+
|
|
564
|
+
// start using the same logic as src/cli/http.ts (inline for test isolation)
|
|
565
|
+
const server = await startStaticServer(serveDir, STANDALONE_PORT + 2);
|
|
566
|
+
|
|
567
|
+
try {
|
|
568
|
+
const ctx = await page.context().browser()!.newContext();
|
|
569
|
+
const standalonePage = await ctx.newPage();
|
|
570
|
+
|
|
571
|
+
await standalonePage.goto(`http://localhost:${STANDALONE_PORT + 2}/`);
|
|
572
|
+
await standalonePage.getByRole("heading", { name: "playlistz" }).waitFor({ timeout: 15000 });
|
|
573
|
+
|
|
574
|
+
// songs loaded from the static playlistz.js
|
|
575
|
+
await expect(standalonePage.getByText("song-00")).toBeVisible({ timeout: 10000 });
|
|
576
|
+
await expect(standalonePage.getByText("song-01")).toBeVisible();
|
|
577
|
+
await expect(
|
|
578
|
+
standalonePage.getByTestId("input-playlist-title")
|
|
579
|
+
).toHaveValue("http-server-test");
|
|
580
|
+
|
|
581
|
+
// range requests work: click a song to trigger audio element range fetch
|
|
582
|
+
await standalonePage.getByText("song-00").first().click();
|
|
583
|
+
// give browser time to try a range request
|
|
584
|
+
await standalonePage.waitForTimeout(1000);
|
|
585
|
+
// no "not found" errors should appear
|
|
586
|
+
await expect(standalonePage.getByText("not found")).toHaveCount(0);
|
|
587
|
+
|
|
588
|
+
await ctx.close();
|
|
589
|
+
} finally {
|
|
590
|
+
await new Promise<void>((res) => server.close(() => res()));
|
|
591
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test.describe("zip bundle: file:// standalone mode", () => {
|
|
597
|
+
test.beforeAll(() => ensureBundleBuilt());
|
|
598
|
+
|
|
599
|
+
test.beforeEach(async ({ page }) => {
|
|
600
|
+
await resetAppState(page);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// helper: open the edit panel and click the download zip button.
|
|
604
|
+
// helper: download a zip from the current state and extract to a temp dir.
|
|
605
|
+
// returns { serveDir, tmpDir }.
|
|
606
|
+
async function downloadAndExtract(
|
|
607
|
+
page: Page,
|
|
608
|
+
): Promise<{ serveDir: string; tmpDir: string }> {
|
|
609
|
+
const downloadPromise = page.waitForEvent("download", { timeout: 30_000 });
|
|
610
|
+
await openEditAndClickDownload(page);
|
|
611
|
+
const download = await downloadPromise;
|
|
612
|
+
const zipBuf = fs.readFileSync((await download.path())!);
|
|
613
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "playlistz-e2e-file-"));
|
|
614
|
+
await extractZip(zipBuf, tmpDir);
|
|
615
|
+
return { serveDir: findRootDir(tmpDir), tmpDir };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
test("audio plays when index.html is opened via file://", async ({ page }) => {
|
|
619
|
+
test.setTimeout(90_000);
|
|
620
|
+
await createPlaylistViaUI(page);
|
|
621
|
+
await addSongs(page, 2, 2); // 2-second songs
|
|
622
|
+
|
|
623
|
+
const { serveDir, tmpDir } = await downloadAndExtract(page);
|
|
624
|
+
const ctx = await page.context().browser()!.newContext();
|
|
625
|
+
const standalonePage = await ctx.newPage();
|
|
626
|
+
|
|
627
|
+
try {
|
|
628
|
+
await standalonePage.goto(`file://${path.join(serveDir, "index.html")}`);
|
|
629
|
+
await standalonePage.getByTestId("app-ready").waitFor({ timeout: 20_000 });
|
|
630
|
+
await standalonePage.getByText("song-00").waitFor({ timeout: 10_000 });
|
|
631
|
+
await standalonePage.getByText("song-01").waitFor();
|
|
632
|
+
|
|
633
|
+
// double-click the first song row to play (desktop uses onDblClick)
|
|
634
|
+
await standalonePage.getByTestId("song-row").first().dblclick();
|
|
635
|
+
|
|
636
|
+
// verify no audio error shown
|
|
637
|
+
await expect(
|
|
638
|
+
standalonePage.getByText("no audio source available"),
|
|
639
|
+
).not.toBeVisible({ timeout: 500 });
|
|
640
|
+
|
|
641
|
+
// verify playback started: btn-play-playlist flips to aria-pressed="true"
|
|
642
|
+
// when isPlaying && currentPlaylist match
|
|
643
|
+
await expect(
|
|
644
|
+
standalonePage.getByTestId("btn-play-playlist"),
|
|
645
|
+
).toHaveAttribute("aria-pressed", "true", { timeout: 8_000 });
|
|
646
|
+
} finally {
|
|
647
|
+
await ctx.close();
|
|
648
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
test("global play button starts playback in standalone file:// mode", async ({ page }) => {
|
|
653
|
+
test.setTimeout(90_000);
|
|
654
|
+
await createPlaylistViaUI(page);
|
|
655
|
+
await addSongs(page, 2, 2);
|
|
656
|
+
|
|
657
|
+
const { serveDir, tmpDir } = await downloadAndExtract(page);
|
|
658
|
+
const ctx = await page.context().browser()!.newContext();
|
|
659
|
+
const standalonePage = await ctx.newPage();
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
await standalonePage.goto(`file://${path.join(serveDir, "index.html")}`);
|
|
663
|
+
await standalonePage.getByTestId("app-ready").waitFor({ timeout: 20_000 });
|
|
664
|
+
await standalonePage.getByText("song-00").waitFor({ timeout: 10_000 });
|
|
665
|
+
|
|
666
|
+
// click the global play button directly (not a song row double-click)
|
|
667
|
+
await standalonePage.getByTestId("btn-play-playlist").click();
|
|
668
|
+
|
|
669
|
+
await expect(
|
|
670
|
+
standalonePage.getByText("no audio source available"),
|
|
671
|
+
).not.toBeVisible({ timeout: 500 });
|
|
672
|
+
await expect(
|
|
673
|
+
standalonePage.getByTestId("btn-play-playlist"),
|
|
674
|
+
).toHaveAttribute("aria-pressed", "true", { timeout: 8_000 });
|
|
675
|
+
} finally {
|
|
676
|
+
await ctx.close();
|
|
677
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test("global play button works after page reload in standalone file:// mode", async ({ page }) => {
|
|
682
|
+
test.setTimeout(120_000);
|
|
683
|
+
await createPlaylistViaUI(page);
|
|
684
|
+
await addSongs(page, 2, 2);
|
|
685
|
+
|
|
686
|
+
const { serveDir, tmpDir } = await downloadAndExtract(page);
|
|
687
|
+
const ctx = await page.context().browser()!.newContext();
|
|
688
|
+
const standalonePage = await ctx.newPage();
|
|
689
|
+
|
|
690
|
+
try {
|
|
691
|
+
// first load - initialize the standalone playlist into IDB
|
|
692
|
+
await standalonePage.goto(`file://${path.join(serveDir, "index.html")}`);
|
|
693
|
+
await standalonePage.getByTestId("app-ready").waitFor({ timeout: 20_000 });
|
|
694
|
+
await standalonePage.getByText("song-00").waitFor({ timeout: 10_000 });
|
|
695
|
+
await standalonePage.waitForTimeout(1000);
|
|
696
|
+
|
|
697
|
+
// reload - the docIndex entry already exists; paths must still be restored
|
|
698
|
+
await standalonePage.reload();
|
|
699
|
+
await standalonePage.getByTestId("app-ready").waitFor({ timeout: 20_000 });
|
|
700
|
+
await standalonePage.getByText("song-00").waitFor({ timeout: 10_000 });
|
|
701
|
+
|
|
702
|
+
// global play button must work after reload (songs need standaloneFilePath)
|
|
703
|
+
await standalonePage.getByTestId("btn-play-playlist").click();
|
|
704
|
+
|
|
705
|
+
await expect(
|
|
706
|
+
standalonePage.getByText("no audio source available"),
|
|
707
|
+
).not.toBeVisible({ timeout: 500 });
|
|
708
|
+
await expect(
|
|
709
|
+
standalonePage.getByTestId("btn-play-playlist"),
|
|
710
|
+
).toHaveAttribute("aria-pressed", "true", { timeout: 8_000 });
|
|
711
|
+
} finally {
|
|
712
|
+
await ctx.close();
|
|
713
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test("cover image is displayed when index.html is opened via file://", async ({ page }) => {
|
|
718
|
+
test.setTimeout(90_000);
|
|
719
|
+
await createPlaylistViaUI(page);
|
|
720
|
+
await addSongs(page, 1);
|
|
721
|
+
|
|
722
|
+
// attach a playlist cover image via the edit panel
|
|
723
|
+
await page.getByTestId("btn-edit-playlist").click();
|
|
724
|
+
const coverBytes = await makePng(page, { width: 64, height: 64, color: "#ff00ff" });
|
|
725
|
+
await setPlaylistCover(page, { name: "cover.png", mimeType: "image/png", bytes: coverBytes });
|
|
726
|
+
await page.waitForTimeout(500);
|
|
727
|
+
// close edit panel by clicking the toggle button again
|
|
728
|
+
await page.getByTestId("btn-edit-playlist").click();
|
|
729
|
+
|
|
730
|
+
const { serveDir, tmpDir } = await downloadAndExtract(page);
|
|
731
|
+
const ctx = await page.context().browser()!.newContext();
|
|
732
|
+
const standalonePage = await ctx.newPage();
|
|
733
|
+
|
|
734
|
+
try {
|
|
735
|
+
await standalonePage.goto(`file://${path.join(serveDir, "index.html")}`);
|
|
736
|
+
await standalonePage.getByTestId("app-ready").waitFor({ timeout: 20_000 });
|
|
737
|
+
await standalonePage.getByText("song-00").waitFor({ timeout: 10_000 });
|
|
738
|
+
|
|
739
|
+
// at least one img element should have loaded a file:// src from data/
|
|
740
|
+
await standalonePage.waitForFunction(
|
|
741
|
+
() => {
|
|
742
|
+
const imgs = Array.from(document.querySelectorAll("img"));
|
|
743
|
+
return imgs.some(
|
|
744
|
+
(img) =>
|
|
745
|
+
img.naturalWidth > 0 &&
|
|
746
|
+
(img.src.startsWith("file://") || img.src.includes("data/")),
|
|
747
|
+
);
|
|
748
|
+
},
|
|
749
|
+
undefined,
|
|
750
|
+
{ timeout: 10_000 },
|
|
751
|
+
);
|
|
752
|
+
} finally {
|
|
753
|
+
await ctx.close();
|
|
754
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
test("song row thumbnail renders in standalone file:// mode", async ({ page }) => {
|
|
759
|
+
test.setTimeout(90_000);
|
|
760
|
+
await createPlaylistViaUI(page);
|
|
761
|
+
await addSongs(page, 1);
|
|
762
|
+
|
|
763
|
+
// add a per-song cover image
|
|
764
|
+
await page.getByTestId("song-row").first().hover();
|
|
765
|
+
await page.getByTestId("btn-edit-song").first().click();
|
|
766
|
+
await page.getByTestId("song-edit-panel").waitFor();
|
|
767
|
+
const songCoverBytes = await makePng(page, { width: 32, height: 32, color: "#00ccff" });
|
|
768
|
+
await page.locator("#song-image-upload-panel").setInputFiles({
|
|
769
|
+
name: "song-cover.png",
|
|
770
|
+
mimeType: "image/png",
|
|
771
|
+
buffer: Buffer.from(songCoverBytes),
|
|
772
|
+
});
|
|
773
|
+
await page.waitForTimeout(400);
|
|
774
|
+
await page.locator('[data-testid="song-edit-panel"] button').filter({ hasText: "save" }).click();
|
|
775
|
+
await page.waitForTimeout(600);
|
|
776
|
+
await page.locator('[data-testid="song-edit-panel"] button[title="close"]').click();
|
|
777
|
+
await page.waitForTimeout(300);
|
|
778
|
+
|
|
779
|
+
const { serveDir, tmpDir } = await downloadAndExtract(page);
|
|
780
|
+
const ctx = await page.context().browser()!.newContext();
|
|
781
|
+
const standalonePage = await ctx.newPage();
|
|
782
|
+
|
|
783
|
+
try {
|
|
784
|
+
await standalonePage.goto(`file://${path.join(serveDir, "index.html")}`);
|
|
785
|
+
await standalonePage.getByTestId("app-ready").waitFor({ timeout: 20_000 });
|
|
786
|
+
await standalonePage.getByText("song-00").waitFor({ timeout: 10_000 });
|
|
787
|
+
|
|
788
|
+
// the song row should render an img thumbnail (not gated behind missing imageType)
|
|
789
|
+
await standalonePage.waitForFunction(
|
|
790
|
+
() => {
|
|
791
|
+
const row = document.querySelector("[data-testid='song-row']");
|
|
792
|
+
if (!row) return false;
|
|
793
|
+
const img = row.querySelector("img");
|
|
794
|
+
return !!img && img.naturalWidth > 0;
|
|
795
|
+
},
|
|
796
|
+
undefined,
|
|
797
|
+
{ timeout: 10_000 },
|
|
798
|
+
);
|
|
799
|
+
} finally {
|
|
800
|
+
await ctx.close();
|
|
801
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test("background image updates when a song plays in standalone file:// mode", async ({ page }) => {
|
|
806
|
+
test.setTimeout(90_000);
|
|
807
|
+
await createPlaylistViaUI(page);
|
|
808
|
+
await addSongs(page, 1, 2);
|
|
809
|
+
|
|
810
|
+
// add a playlist cover so background can derive from it
|
|
811
|
+
await page.getByTestId("btn-edit-playlist").click();
|
|
812
|
+
const coverBytes = await makePng(page, { width: 64, height: 64, color: "#ff8800" });
|
|
813
|
+
await setPlaylistCover(page, { name: "cover.png", mimeType: "image/png", bytes: coverBytes });
|
|
814
|
+
await page.waitForTimeout(500);
|
|
815
|
+
await page.getByTestId("btn-edit-playlist").click();
|
|
816
|
+
|
|
817
|
+
const { serveDir, tmpDir } = await downloadAndExtract(page);
|
|
818
|
+
const ctx = await page.context().browser()!.newContext();
|
|
819
|
+
const standalonePage = await ctx.newPage();
|
|
820
|
+
|
|
821
|
+
try {
|
|
822
|
+
await standalonePage.goto(`file://${path.join(serveDir, "index.html")}`);
|
|
823
|
+
await standalonePage.getByTestId("app-ready").waitFor({ timeout: 20_000 });
|
|
824
|
+
await standalonePage.getByText("song-00").waitFor({ timeout: 10_000 });
|
|
825
|
+
|
|
826
|
+
// play a song to trigger background image
|
|
827
|
+
await standalonePage.getByTestId("song-row").first().dblclick();
|
|
828
|
+
await expect(standalonePage.getByTestId("btn-play-playlist")).toHaveAttribute(
|
|
829
|
+
"aria-pressed",
|
|
830
|
+
"true",
|
|
831
|
+
{ timeout: 8_000 },
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
// background image element or container should have a src pointing at data/
|
|
835
|
+
await standalonePage.waitForFunction(
|
|
836
|
+
() => {
|
|
837
|
+
// check for a background img element with a loaded image
|
|
838
|
+
const bgImgs = Array.from(document.querySelectorAll("img[data-testid='background-image'], .bg-image img, img.background"));
|
|
839
|
+
if (bgImgs.some((img) => (img as HTMLImageElement).naturalWidth > 0)) return true;
|
|
840
|
+
// fallback: check any element with background-image style pointing at data/
|
|
841
|
+
const allEls = Array.from(document.querySelectorAll("*"));
|
|
842
|
+
return allEls.some((el) => {
|
|
843
|
+
const style = window.getComputedStyle(el).backgroundImage;
|
|
844
|
+
return style && style !== "none" && (style.includes("data/") || style.includes("file://"));
|
|
845
|
+
});
|
|
846
|
+
},
|
|
847
|
+
undefined,
|
|
848
|
+
{ timeout: 10_000 },
|
|
849
|
+
);
|
|
850
|
+
} finally {
|
|
851
|
+
await ctx.close();
|
|
852
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
});
|