@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,723 @@
|
|
|
1
|
+
// e2e: multi-peer p2p sync tests.
|
|
2
|
+
//
|
|
3
|
+
// tests here require real iroh relay connections and are tagged @p2p.
|
|
4
|
+
// run with: npm run test:e2e:p2p
|
|
5
|
+
//
|
|
6
|
+
// - 3-browser triangular sync: verifies automerge changes propagate to a peer
|
|
7
|
+
// that was not the direct source (A→B and A→C, then B sees C's additions)
|
|
8
|
+
// - cli zip peer: downloads a zip from peer A, serves it via the
|
|
9
|
+
// freqhole-playlistz-cli.mjs --http subprocess, confirms the cli-served app
|
|
10
|
+
// joins the relay and receives automerge updates
|
|
11
|
+
|
|
12
|
+
import { test, expect } from "@playwright/test";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as child_process from "node:child_process";
|
|
17
|
+
import JSZip from "jszip";
|
|
18
|
+
import {
|
|
19
|
+
resetAppState,
|
|
20
|
+
createPlaylistViaUI,
|
|
21
|
+
addSongs,
|
|
22
|
+
makePng,
|
|
23
|
+
logTs,
|
|
24
|
+
getDocIndexEntries,
|
|
25
|
+
patchDocIndexEntry,
|
|
26
|
+
} from "./helpers.js";
|
|
27
|
+
|
|
28
|
+
const REPO_ROOT = path.join(path.dirname(new URL(import.meta.url).pathname), "..");
|
|
29
|
+
const CLI_PATH = path.join(REPO_ROOT, "dist", "freqhole-playlistz-cli.mjs");
|
|
30
|
+
|
|
31
|
+
// extract a JSZip instance to a temp directory, return the serve root
|
|
32
|
+
async function extractZipToTmp(zipBuf: Buffer, prefix: string): Promise<string> {
|
|
33
|
+
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
34
|
+
const zip = await JSZip.loadAsync(zipBuf);
|
|
35
|
+
const writes: Promise<void>[] = [];
|
|
36
|
+
zip.forEach((rel, file) => {
|
|
37
|
+
if (file.dir) return;
|
|
38
|
+
const dest = path.join(outDir, rel);
|
|
39
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
40
|
+
writes.push(file.async("nodebuffer").then((buf) => fs.writeFileSync(dest, buf)));
|
|
41
|
+
});
|
|
42
|
+
await Promise.all(writes);
|
|
43
|
+
// find the subdirectory that contains index.html
|
|
44
|
+
for (const entry of fs.readdirSync(outDir)) {
|
|
45
|
+
const sub = path.join(outDir, entry);
|
|
46
|
+
if (fs.statSync(sub).isDirectory() && fs.existsSync(path.join(sub, "index.html"))) {
|
|
47
|
+
return sub;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return outDir;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ensure the standalone bundle exists (required for cli zip peer test)
|
|
54
|
+
function ensureBundleBuilt(): void {
|
|
55
|
+
if (fs.existsSync(CLI_PATH)) return;
|
|
56
|
+
console.log("[p2p-multi] cli bundle missing - running build:standalone...");
|
|
57
|
+
child_process.execSync("npm run build:standalone", { cwd: REPO_ROOT, stdio: "inherit" });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// start the cli http subprocess. returns { url, proc, port, cleanup }.
|
|
61
|
+
// the cli prints "http://localhost:PORT" to stdout once the server is ready.
|
|
62
|
+
async function startCliServer(
|
|
63
|
+
serveDir: string,
|
|
64
|
+
port: number
|
|
65
|
+
): Promise<{ url: string; cleanup: () => void }> {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const env = { ...process.env, PORT: String(port) };
|
|
68
|
+
const proc = child_process.spawn(
|
|
69
|
+
process.execPath, // node
|
|
70
|
+
[CLI_PATH, "--http", serveDir],
|
|
71
|
+
{ env, stdio: ["ignore", "pipe", "pipe"] }
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
let ready = false;
|
|
75
|
+
const timeout = setTimeout(() => {
|
|
76
|
+
if (!ready) {
|
|
77
|
+
proc.kill();
|
|
78
|
+
reject(new Error(`cli server did not start on port ${port} within 10s`));
|
|
79
|
+
}
|
|
80
|
+
}, 10_000);
|
|
81
|
+
|
|
82
|
+
proc.stdout?.on("data", (chunk: Buffer) => {
|
|
83
|
+
const text = chunk.toString();
|
|
84
|
+
logTs(`[cli-server] ${text.trim()}`);
|
|
85
|
+
if (!ready && text.includes(`http://localhost:${port}`)) {
|
|
86
|
+
ready = true;
|
|
87
|
+
clearTimeout(timeout);
|
|
88
|
+
resolve({
|
|
89
|
+
url: `http://localhost:${port}`,
|
|
90
|
+
cleanup: () => {
|
|
91
|
+
try { proc.kill(); } catch { /* ignore */ }
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
proc.stderr?.on("data", (chunk: Buffer) => {
|
|
98
|
+
logTs(`[cli-server stderr] ${chunk.toString().trim()}`);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
proc.on("error", (err) => {
|
|
102
|
+
clearTimeout(timeout);
|
|
103
|
+
reject(err);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
proc.on("exit", (code) => {
|
|
107
|
+
if (!ready) {
|
|
108
|
+
clearTimeout(timeout);
|
|
109
|
+
reject(new Error(`cli server exited with code ${code} before becoming ready`));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// port range: avoid collisions with vite (5917) and zip-bundle tests (5920-5922)
|
|
116
|
+
const P2P_MULTI_PORT_BASE = 5930;
|
|
117
|
+
|
|
118
|
+
// -----------------------------------------------------------------------
|
|
119
|
+
// 3-browser triangular sync
|
|
120
|
+
// -----------------------------------------------------------------------
|
|
121
|
+
// topology:
|
|
122
|
+
// peer A creates "triangular-doom", adds song, shares with B and C
|
|
123
|
+
// peer B opens A's share link, syncs
|
|
124
|
+
// peer C opens A's share link, syncs
|
|
125
|
+
// then: peer B renames the playlist -> peer C should see the rename
|
|
126
|
+
//
|
|
127
|
+
// this verifies that the automerge relay properly triangulates changes
|
|
128
|
+
// (C does not need a direct stream from B to receive B's edits)
|
|
129
|
+
|
|
130
|
+
test("three peers triangulate automerge changes @p2p", async ({ browser }) => {
|
|
131
|
+
test.setTimeout(600_000); // 10 min - 3 nodes bootstrapping takes ~3 min combined
|
|
132
|
+
|
|
133
|
+
const ctxA = await browser.newContext();
|
|
134
|
+
const ctxB = await browser.newContext();
|
|
135
|
+
const ctxC = await browser.newContext();
|
|
136
|
+
const pageA = await ctxA.newPage();
|
|
137
|
+
const pageB = await ctxB.newPage();
|
|
138
|
+
const pageC = await ctxC.newPage();
|
|
139
|
+
|
|
140
|
+
const fwd = (tag: string) => (msg: import("@playwright/test").ConsoleMessage) => {
|
|
141
|
+
logTs(`[${tag}] ${msg.text()}`);
|
|
142
|
+
};
|
|
143
|
+
pageA.on("console", fwd("peerA"));
|
|
144
|
+
pageB.on("console", fwd("peerB"));
|
|
145
|
+
pageC.on("console", fwd("peerC"));
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
// --- boot all three peers in parallel ---
|
|
149
|
+
const bootPeer = async (
|
|
150
|
+
page: import("@playwright/test").Page,
|
|
151
|
+
tag: string
|
|
152
|
+
) => {
|
|
153
|
+
await resetAppState(page);
|
|
154
|
+
await createPlaylistViaUI(page);
|
|
155
|
+
await page.getByTestId("btn-share-playlist").click();
|
|
156
|
+
logTs(`[e2e] ${tag}: enabling p2p...`);
|
|
157
|
+
await page.getByTestId("btn-enable-sharing").click();
|
|
158
|
+
await expect(page.getByTestId("sharing-status")).toBeVisible({
|
|
159
|
+
timeout: 180_000,
|
|
160
|
+
});
|
|
161
|
+
logTs(`[e2e] ${tag}: p2p node online`);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
await Promise.all([
|
|
165
|
+
bootPeer(pageA, "peerA"),
|
|
166
|
+
bootPeer(pageB, "peerB"),
|
|
167
|
+
bootPeer(pageC, "peerC"),
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
// --- peer A: name the playlist and build a share link ---
|
|
171
|
+
await pageA.getByTestId("input-playlist-title").fill("triangular-doom");
|
|
172
|
+
await pageA.getByTestId("input-playlist-title").blur();
|
|
173
|
+
await pageA.waitForTimeout(500);
|
|
174
|
+
|
|
175
|
+
const shareUrl = await pageA.locator("input[readonly]").first().inputValue();
|
|
176
|
+
expect(shareUrl).toContain("#share/");
|
|
177
|
+
logTs(`[e2e] peerA: share url: ${shareUrl.slice(0, 60)}...`);
|
|
178
|
+
|
|
179
|
+
// --- close share panel on A so it is out of the way ---
|
|
180
|
+
await pageA.getByTestId("btn-share-playlist").click();
|
|
181
|
+
|
|
182
|
+
// --- peers B and C open the share link via the all-playlists search bar ---
|
|
183
|
+
const openShareOnPeer = async (
|
|
184
|
+
page: import("@playwright/test").Page,
|
|
185
|
+
tag: string
|
|
186
|
+
) => {
|
|
187
|
+
// close the share panel first (it opened during boot)
|
|
188
|
+
if (await page.getByTestId("share-panel").isVisible({ timeout: 500 }).catch(() => false)) {
|
|
189
|
+
await page.getByTestId("btn-share-playlist").click();
|
|
190
|
+
}
|
|
191
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
192
|
+
await page.getByTestId("all-playlists-panel").waitFor({ timeout: 5000 });
|
|
193
|
+
await page.getByTestId("input-search-playlists").fill(shareUrl);
|
|
194
|
+
logTs(`[e2e] ${tag}: opening share link...`);
|
|
195
|
+
await expect(page.getByTestId("all-playlists-panel")).not.toBeVisible({
|
|
196
|
+
timeout: 120_000,
|
|
197
|
+
});
|
|
198
|
+
logTs(`[e2e] ${tag}: share link opened`);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
await Promise.all([
|
|
202
|
+
openShareOnPeer(pageB, "peerB"),
|
|
203
|
+
openShareOnPeer(pageC, "peerC"),
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
// confirm B and C both received the initial title from A
|
|
207
|
+
await expect(pageB.getByTestId("input-playlist-title")).toHaveValue(
|
|
208
|
+
"triangular-doom",
|
|
209
|
+
{ timeout: 30_000 }
|
|
210
|
+
);
|
|
211
|
+
await expect(pageC.getByTestId("input-playlist-title")).toHaveValue(
|
|
212
|
+
"triangular-doom",
|
|
213
|
+
{ timeout: 30_000 }
|
|
214
|
+
);
|
|
215
|
+
logTs("[e2e] B and C confirmed initial sync from A");
|
|
216
|
+
|
|
217
|
+
// --- peer B renames the playlist ---
|
|
218
|
+
// click three times to select all, then type to replace
|
|
219
|
+
await pageB.getByTestId("input-playlist-title").click({ clickCount: 3 });
|
|
220
|
+
await pageB.getByTestId("input-playlist-title").fill("doom-renamed-by-b");
|
|
221
|
+
await pageB.getByTestId("input-playlist-title").blur();
|
|
222
|
+
await pageB.waitForTimeout(500);
|
|
223
|
+
logTs("[e2e] peerB: renamed playlist to doom-renamed-by-b");
|
|
224
|
+
|
|
225
|
+
// --- peer C should see B's rename (triangulated via relay, not direct B→C stream) ---
|
|
226
|
+
await expect(pageC.getByTestId("input-playlist-title")).toHaveValue(
|
|
227
|
+
"doom-renamed-by-b",
|
|
228
|
+
{ timeout: 60_000 }
|
|
229
|
+
);
|
|
230
|
+
logTs("[e2e] peerC: confirmed rename from B propagated");
|
|
231
|
+
|
|
232
|
+
// --- peer A should also see B's rename ---
|
|
233
|
+
await expect(pageA.getByTestId("input-playlist-title")).toHaveValue(
|
|
234
|
+
"doom-renamed-by-b",
|
|
235
|
+
{ timeout: 30_000 }
|
|
236
|
+
);
|
|
237
|
+
logTs("[e2e] peerA: confirmed rename from B propagated");
|
|
238
|
+
} finally {
|
|
239
|
+
await Promise.allSettled([ctxA.close(), ctxB.close(), ctxC.close()]);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// -----------------------------------------------------------------------
|
|
244
|
+
// cli zip peer
|
|
245
|
+
// -----------------------------------------------------------------------
|
|
246
|
+
// peer A creates a playlist with songs, shares it, downloads a zip.
|
|
247
|
+
// the zip is extracted and served by the freqhole-playlistz-cli.mjs --http
|
|
248
|
+
// subprocess (port P2P_MULTI_PORT_BASE). a third browser context opens the
|
|
249
|
+
// cli-served app, enables p2p, opens A's share link, and verifies sync.
|
|
250
|
+
// then A renames the playlist and the cli-served peer sees the change.
|
|
251
|
+
|
|
252
|
+
test("cli-served zip app joins relay and syncs with peers @p2p", async ({
|
|
253
|
+
browser,
|
|
254
|
+
}) => {
|
|
255
|
+
test.setTimeout(600_000);
|
|
256
|
+
ensureBundleBuilt();
|
|
257
|
+
|
|
258
|
+
const ctxA = await browser.newContext();
|
|
259
|
+
const ctxB = await browser.newContext();
|
|
260
|
+
const pageA = await ctxA.newPage();
|
|
261
|
+
const pageB = await ctxB.newPage();
|
|
262
|
+
|
|
263
|
+
const fwd = (tag: string) => (msg: import("@playwright/test").ConsoleMessage) => {
|
|
264
|
+
logTs(`[${tag}] ${msg.text()}`);
|
|
265
|
+
};
|
|
266
|
+
pageA.on("console", fwd("peerA"));
|
|
267
|
+
pageB.on("console", fwd("cliPeer"));
|
|
268
|
+
|
|
269
|
+
let tmpDir: string | null = null;
|
|
270
|
+
let cliCleanup: (() => void) | null = null;
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
// --- peer A: create playlist, enable p2p, add a song ---
|
|
274
|
+
await resetAppState(pageA);
|
|
275
|
+
await createPlaylistViaUI(pageA);
|
|
276
|
+
const title = pageA.getByTestId("input-playlist-title");
|
|
277
|
+
await title.fill("cli-peer-test");
|
|
278
|
+
await title.blur();
|
|
279
|
+
await pageA.waitForTimeout(300);
|
|
280
|
+
|
|
281
|
+
await addSongs(pageA, 2);
|
|
282
|
+
|
|
283
|
+
logTs("[e2e] peerA: enabling p2p...");
|
|
284
|
+
await pageA.getByTestId("btn-share-playlist").click();
|
|
285
|
+
await pageA.getByTestId("btn-enable-sharing").click();
|
|
286
|
+
const copyBtn = pageA.getByTestId("btn-copy-share-link");
|
|
287
|
+
await expect(copyBtn).toBeEnabled({ timeout: 180_000 });
|
|
288
|
+
logTs("[e2e] peerA: p2p node online");
|
|
289
|
+
|
|
290
|
+
const shareUrl = await pageA.locator("input[readonly]").first().inputValue();
|
|
291
|
+
expect(shareUrl).toContain("#share/");
|
|
292
|
+
logTs(`[e2e] peerA: share url: ${shareUrl.slice(0, 60)}...`);
|
|
293
|
+
|
|
294
|
+
// close the share panel so the download button is accessible
|
|
295
|
+
await pageA.getByTestId("btn-share-playlist").click();
|
|
296
|
+
|
|
297
|
+
// --- peer A: download the zip ---
|
|
298
|
+
const downloadPromise = pageA.waitForEvent("download", { timeout: 30_000 });
|
|
299
|
+
await pageA.getByTestId("btn-download-zip").click();
|
|
300
|
+
const download = await downloadPromise;
|
|
301
|
+
const zipBuf = fs.readFileSync((await download.path())!);
|
|
302
|
+
logTs("[e2e] peerA: zip downloaded");
|
|
303
|
+
|
|
304
|
+
// --- extract zip and start cli http server ---
|
|
305
|
+
tmpDir = await extractZipToTmp(zipBuf, "playlistz-e2e-clipeer-");
|
|
306
|
+
logTs(`[e2e] zip extracted to: ${tmpDir}`);
|
|
307
|
+
|
|
308
|
+
const cli = await startCliServer(tmpDir, P2P_MULTI_PORT_BASE);
|
|
309
|
+
cliCleanup = cli.cleanup;
|
|
310
|
+
logTs(`[e2e] cli server started at ${cli.url}`);
|
|
311
|
+
|
|
312
|
+
// --- cli peer: navigate to the cli-served app ---
|
|
313
|
+
await pageB.goto(cli.url);
|
|
314
|
+
// the standalone app uses the <freqhole-playlistz> web component; wait for
|
|
315
|
+
// it to finish booting (same heading sentinel as zip-bundle tests)
|
|
316
|
+
await pageB.getByRole("heading", { name: "playlistz" }).waitFor({
|
|
317
|
+
timeout: 15_000,
|
|
318
|
+
});
|
|
319
|
+
logTs("[e2e] cli peer: app loaded");
|
|
320
|
+
|
|
321
|
+
// the zip contains A's songs from the exported playlist data
|
|
322
|
+
await expect(pageB.getByText("song-00")).toBeVisible({ timeout: 10_000 });
|
|
323
|
+
|
|
324
|
+
// --- cli peer: enable p2p and open A's share link ---
|
|
325
|
+
// the standalone web component renders the share button in its own header
|
|
326
|
+
await pageB.getByTestId("btn-share-playlist").click();
|
|
327
|
+
logTs("[e2e] cli peer: enabling p2p...");
|
|
328
|
+
await pageB.getByTestId("btn-enable-sharing").click();
|
|
329
|
+
await expect(pageB.getByTestId("sharing-status")).toBeVisible({
|
|
330
|
+
timeout: 180_000,
|
|
331
|
+
});
|
|
332
|
+
logTs("[e2e] cli peer: p2p node online");
|
|
333
|
+
|
|
334
|
+
// open A's share link via the search bar
|
|
335
|
+
if (await pageB.getByTestId("share-panel").isVisible({ timeout: 500 }).catch(() => false)) {
|
|
336
|
+
await pageB.getByTestId("btn-share-playlist").click();
|
|
337
|
+
}
|
|
338
|
+
await pageB.getByTestId("btn-all-playlists").click();
|
|
339
|
+
await pageB.getByTestId("all-playlists-panel").waitFor({ timeout: 5000 });
|
|
340
|
+
await pageB.getByTestId("input-search-playlists").fill(shareUrl);
|
|
341
|
+
logTs("[e2e] cli peer: opening share link...");
|
|
342
|
+
await expect(pageB.getByTestId("all-playlists-panel")).not.toBeVisible({
|
|
343
|
+
timeout: 120_000,
|
|
344
|
+
});
|
|
345
|
+
logTs("[e2e] cli peer: share link opened");
|
|
346
|
+
|
|
347
|
+
// confirm initial sync
|
|
348
|
+
await expect(pageB.getByTestId("input-playlist-title")).toHaveValue(
|
|
349
|
+
"cli-peer-test",
|
|
350
|
+
{ timeout: 30_000 }
|
|
351
|
+
);
|
|
352
|
+
logTs("[e2e] cli peer: confirmed initial sync from peerA");
|
|
353
|
+
|
|
354
|
+
// --- peer A renames the playlist - cli peer should receive the update ---
|
|
355
|
+
await pageA.getByTestId("input-playlist-title").click({ clickCount: 3 });
|
|
356
|
+
await pageA.getByTestId("input-playlist-title").fill("cli-peer-updated");
|
|
357
|
+
await pageA.getByTestId("input-playlist-title").blur();
|
|
358
|
+
await pageA.waitForTimeout(500);
|
|
359
|
+
logTs("[e2e] peerA: renamed playlist to cli-peer-updated");
|
|
360
|
+
|
|
361
|
+
await expect(pageB.getByTestId("input-playlist-title")).toHaveValue(
|
|
362
|
+
"cli-peer-updated",
|
|
363
|
+
{ timeout: 60_000 }
|
|
364
|
+
);
|
|
365
|
+
logTs("[e2e] cli peer: confirmed rename from peerA propagated");
|
|
366
|
+
} finally {
|
|
367
|
+
cliCleanup?.();
|
|
368
|
+
await Promise.allSettled([ctxA.close(), ctxB.close()]);
|
|
369
|
+
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// -----------------------------------------------------------------------
|
|
374
|
+
// collaborative editing sync
|
|
375
|
+
// -----------------------------------------------------------------------
|
|
376
|
+
// topology:
|
|
377
|
+
// peer A creates a playlist with 3 songs, sets mode to "public", shares
|
|
378
|
+
// peer B opens A's share link, then gets promoted to editor (docIndex patch)
|
|
379
|
+
// peer C opens A's share link (stays subscribed / read-only)
|
|
380
|
+
//
|
|
381
|
+
// collaboration round 1 - peer B edits:
|
|
382
|
+
// B changes the description
|
|
383
|
+
// B removes song-00
|
|
384
|
+
// B adds a new song
|
|
385
|
+
// A and C see the updated description and updated song count (3 total)
|
|
386
|
+
//
|
|
387
|
+
// collaboration round 2 - peer A adds songs:
|
|
388
|
+
// A adds 2 more songs (5 total)
|
|
389
|
+
// B and C see 5 songs
|
|
390
|
+
//
|
|
391
|
+
// this verifies the full bidirectional automerge sync loop and that all
|
|
392
|
+
// document mutations (description, song add, song remove) propagate to every peer.
|
|
393
|
+
|
|
394
|
+
test(
|
|
395
|
+
"collaborative playlist editing syncs across three peers @p2p",
|
|
396
|
+
async ({ browser }) => {
|
|
397
|
+
test.setTimeout(600_000);
|
|
398
|
+
|
|
399
|
+
const ctxA = await browser.newContext();
|
|
400
|
+
const ctxB = await browser.newContext();
|
|
401
|
+
const ctxC = await browser.newContext();
|
|
402
|
+
const pageA = await ctxA.newPage();
|
|
403
|
+
const pageB = await ctxB.newPage();
|
|
404
|
+
const pageC = await ctxC.newPage();
|
|
405
|
+
|
|
406
|
+
const fwd = (tag: string) => (msg: import("@playwright/test").ConsoleMessage) =>
|
|
407
|
+
logTs(`[${tag}] ${msg.text()}`);
|
|
408
|
+
pageA.on("console", fwd("peerA"));
|
|
409
|
+
pageB.on("console", fwd("peerB"));
|
|
410
|
+
pageC.on("console", fwd("peerC"));
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
// --- peer A: create playlist with 3 songs and enable p2p ---
|
|
414
|
+
await resetAppState(pageA);
|
|
415
|
+
await createPlaylistViaUI(pageA);
|
|
416
|
+
await pageA.getByTestId("input-playlist-title").fill("collab-test-playlist");
|
|
417
|
+
await pageA.getByTestId("input-playlist-title").blur();
|
|
418
|
+
await pageA.waitForTimeout(300);
|
|
419
|
+
|
|
420
|
+
// add 3 songs so we can remove one and still have a healthy count to check
|
|
421
|
+
await addSongs(pageA, 3);
|
|
422
|
+
logTs("[e2e] peerA: 3 songs added");
|
|
423
|
+
|
|
424
|
+
// open share panel, set mode to "public" so B's edits are accepted, enable p2p
|
|
425
|
+
await pageA.getByTestId("btn-share-playlist").click();
|
|
426
|
+
await pageA.getByTestId("btn-mode-public").click();
|
|
427
|
+
await expect(pageA.getByTestId("btn-mode-public")).toHaveAttribute(
|
|
428
|
+
"aria-pressed",
|
|
429
|
+
"true",
|
|
430
|
+
{ timeout: 5000 }
|
|
431
|
+
);
|
|
432
|
+
logTs("[e2e] peerA: mode set to public");
|
|
433
|
+
await pageA.getByTestId("btn-enable-sharing").click();
|
|
434
|
+
const copyBtn = pageA.getByTestId("btn-copy-share-link");
|
|
435
|
+
await expect(copyBtn).toBeEnabled({ timeout: 180_000 });
|
|
436
|
+
logTs("[e2e] peerA: p2p node online");
|
|
437
|
+
|
|
438
|
+
const shareUrl = await pageA.locator("input[readonly]").first().inputValue();
|
|
439
|
+
expect(shareUrl).toContain("#share/");
|
|
440
|
+
logTs(`[e2e] peerA: share url: ${shareUrl.slice(0, 60)}...`);
|
|
441
|
+
|
|
442
|
+
// close share panel
|
|
443
|
+
await pageA.getByTestId("btn-share-playlist").click();
|
|
444
|
+
|
|
445
|
+
// --- pre-boot B and C p2p nodes in parallel (each takes ~1-2 min) ---
|
|
446
|
+
const bootPeer = async (
|
|
447
|
+
page: import("@playwright/test").Page,
|
|
448
|
+
tag: string
|
|
449
|
+
) => {
|
|
450
|
+
await resetAppState(page);
|
|
451
|
+
await createPlaylistViaUI(page);
|
|
452
|
+
await page.getByTestId("btn-share-playlist").click();
|
|
453
|
+
logTs(`[e2e] ${tag}: enabling p2p...`);
|
|
454
|
+
await page.getByTestId("btn-enable-sharing").click();
|
|
455
|
+
await expect(page.getByTestId("sharing-status")).toBeVisible({
|
|
456
|
+
timeout: 180_000,
|
|
457
|
+
});
|
|
458
|
+
logTs(`[e2e] ${tag}: p2p node online`);
|
|
459
|
+
// close share panel
|
|
460
|
+
await page.getByTestId("btn-share-playlist").click();
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
await Promise.all([bootPeer(pageB, "peerB"), bootPeer(pageC, "peerC")]);
|
|
464
|
+
|
|
465
|
+
// --- both B and C open A's share link via the all-playlists search bar ---
|
|
466
|
+
const openShareLink = async (
|
|
467
|
+
page: import("@playwright/test").Page,
|
|
468
|
+
tag: string
|
|
469
|
+
) => {
|
|
470
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
471
|
+
await page.getByTestId("all-playlists-panel").waitFor({ timeout: 5000 });
|
|
472
|
+
await page.getByTestId("input-search-playlists").fill(shareUrl);
|
|
473
|
+
logTs(`[e2e] ${tag}: opening share link...`);
|
|
474
|
+
await expect(page.getByTestId("all-playlists-panel")).not.toBeVisible({
|
|
475
|
+
timeout: 120_000,
|
|
476
|
+
});
|
|
477
|
+
logTs(`[e2e] ${tag}: share link opened`);
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
await Promise.all([
|
|
481
|
+
openShareLink(pageB, "peerB"),
|
|
482
|
+
openShareLink(pageC, "peerC"),
|
|
483
|
+
]);
|
|
484
|
+
|
|
485
|
+
// confirm B and C received A's initial content
|
|
486
|
+
await expect(pageB.getByTestId("input-playlist-title")).toHaveValue(
|
|
487
|
+
"collab-test-playlist",
|
|
488
|
+
{ timeout: 30_000 }
|
|
489
|
+
);
|
|
490
|
+
await expect(pageC.getByTestId("input-playlist-title")).toHaveValue(
|
|
491
|
+
"collab-test-playlist",
|
|
492
|
+
{ timeout: 30_000 }
|
|
493
|
+
);
|
|
494
|
+
// 3 songs visible on both
|
|
495
|
+
await expect(pageB.getByTestId("song-row")).toHaveCount(3, { timeout: 30_000 });
|
|
496
|
+
await expect(pageC.getByTestId("song-row")).toHaveCount(3, { timeout: 30_000 });
|
|
497
|
+
logTs("[e2e] B and C confirmed initial sync: 3 songs");
|
|
498
|
+
|
|
499
|
+
// ---------------------------------------------------------------
|
|
500
|
+
// round 1: peer B makes edits
|
|
501
|
+
// promote B to editor by clearing remoteNodeId so the UI unlocks
|
|
502
|
+
// ---------------------------------------------------------------
|
|
503
|
+
const bEntries = await getDocIndexEntries(pageB);
|
|
504
|
+
const sharedEntry = bEntries.find((e) => e.source === "shared");
|
|
505
|
+
if (!sharedEntry) throw new Error("peerB: no shared docIndex entry found");
|
|
506
|
+
await patchDocIndexEntry(pageB, sharedEntry.docId, { remoteNodeId: null as unknown as undefined });
|
|
507
|
+
await pageB.waitForTimeout(500); // let reactive effects settle
|
|
508
|
+
logTs("[e2e] peerB: promoted to editor (remoteNodeId cleared)");
|
|
509
|
+
|
|
510
|
+
// B changes the description
|
|
511
|
+
const descInput = pageB.getByTestId("input-playlist-description");
|
|
512
|
+
await expect(descInput).toBeEnabled({ timeout: 5000 });
|
|
513
|
+
await descInput.fill("collab-edited-by-b");
|
|
514
|
+
await descInput.blur();
|
|
515
|
+
await pageB.waitForTimeout(300);
|
|
516
|
+
logTs("[e2e] peerB: description updated");
|
|
517
|
+
|
|
518
|
+
// B removes song-00 (hover the first row, click remove)
|
|
519
|
+
await pageB.getByTestId("song-row").first().hover();
|
|
520
|
+
await pageB.getByTestId("btn-remove-song").first().click();
|
|
521
|
+
logTs("[e2e] peerB: song-00 removed (2 songs remain)");
|
|
522
|
+
|
|
523
|
+
// B adds a new song (file drop, same helper as addSongs)
|
|
524
|
+
await addSongs(pageB, 1, 1);
|
|
525
|
+
logTs("[e2e] peerB: new song added (3 songs total on B)");
|
|
526
|
+
|
|
527
|
+
// --- A and C should converge to B's state ---
|
|
528
|
+
// description change visible on A
|
|
529
|
+
await expect(pageA.getByTestId("input-playlist-description")).toHaveValue(
|
|
530
|
+
"collab-edited-by-b",
|
|
531
|
+
{ timeout: 60_000 }
|
|
532
|
+
);
|
|
533
|
+
logTs("[e2e] peerA: confirmed description update from B");
|
|
534
|
+
|
|
535
|
+
// description change visible on C (disabled but still has the value)
|
|
536
|
+
await expect(pageC.getByTestId("input-playlist-description")).toHaveValue(
|
|
537
|
+
"collab-edited-by-b",
|
|
538
|
+
{ timeout: 60_000 }
|
|
539
|
+
);
|
|
540
|
+
logTs("[e2e] peerC: confirmed description update from B");
|
|
541
|
+
|
|
542
|
+
// song count: A started with 3, B removed 1 and added 1 → still 3
|
|
543
|
+
await expect(pageA.getByTestId("song-row")).toHaveCount(3, { timeout: 60_000 });
|
|
544
|
+
await expect(pageC.getByTestId("song-row")).toHaveCount(3, { timeout: 60_000 });
|
|
545
|
+
logTs("[e2e] A and C confirmed song count = 3 after B's edits");
|
|
546
|
+
|
|
547
|
+
// ---------------------------------------------------------------
|
|
548
|
+
// round 2: peer A adds 2 more songs → all peers see 5
|
|
549
|
+
// ---------------------------------------------------------------
|
|
550
|
+
await addSongs(pageA, 2, 1);
|
|
551
|
+
logTs("[e2e] peerA: 2 more songs added (5 total)");
|
|
552
|
+
|
|
553
|
+
await expect(pageA.getByTestId("song-row")).toHaveCount(5, { timeout: 30_000 });
|
|
554
|
+
|
|
555
|
+
await expect(pageB.getByTestId("song-row")).toHaveCount(5, { timeout: 60_000 });
|
|
556
|
+
logTs("[e2e] peerB: confirmed 5 songs from peerA");
|
|
557
|
+
|
|
558
|
+
await expect(pageC.getByTestId("song-row")).toHaveCount(5, { timeout: 60_000 });
|
|
559
|
+
logTs("[e2e] peerC: confirmed 5 songs from peerA");
|
|
560
|
+
|
|
561
|
+
// A's two new songs will be in "pending" blob state on B and C since the
|
|
562
|
+
// audio data travels via p2p blob transfer, not the doc itself.
|
|
563
|
+
// verify the song rows are present (entries synced) even if blobs aren't cached yet.
|
|
564
|
+
// songs with a non-empty data-download-state have arrived in the doc and are queued for fetch.
|
|
565
|
+
const bNewSongCount = await pageB.evaluate(() =>
|
|
566
|
+
document.querySelectorAll('[data-testid="song-duration"][data-download-state]').length
|
|
567
|
+
);
|
|
568
|
+
expect(bNewSongCount).toBe(2);
|
|
569
|
+
logTs("[e2e] peerB: A's 2 new songs have download state (blob transfer queued)");
|
|
570
|
+
} finally {
|
|
571
|
+
await Promise.allSettled([ctxA.close(), ctxB.close(), ctxC.close()]);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
// -----------------------------------------------------------------------
|
|
577
|
+
// blob transfer: audio + image
|
|
578
|
+
// -----------------------------------------------------------------------
|
|
579
|
+
// peer A creates a playlist with songs (each has an embedded image), shares it.
|
|
580
|
+
// peer B opens the share link, syncs the doc, then uses "save offline" to pull
|
|
581
|
+
// all blobs via the freqhole-playlistz/1 blob_request / blob_ready exchange.
|
|
582
|
+
// verifies that:
|
|
583
|
+
// - song audio blobs arrive (song rows exit pending/downloading state)
|
|
584
|
+
// - image blobs arrive (cover image becomes visible on B)
|
|
585
|
+
// - all transfers complete without errors (no "error" download state on any row)
|
|
586
|
+
// - playing a song on B works after blobs are local (no second-attempt needed)
|
|
587
|
+
|
|
588
|
+
test("peer B fetches audio and image blobs from peer A @p2p", async ({ browser }) => {
|
|
589
|
+
test.setTimeout(600_000);
|
|
590
|
+
|
|
591
|
+
const ctxA = await browser.newContext();
|
|
592
|
+
const ctxB = await browser.newContext();
|
|
593
|
+
const pageA = await ctxA.newPage();
|
|
594
|
+
const pageB = await ctxB.newPage();
|
|
595
|
+
|
|
596
|
+
const fwd = (tag: string) => (msg: import("@playwright/test").ConsoleMessage) => {
|
|
597
|
+
logTs(`[${tag}] ${msg.text()}`);
|
|
598
|
+
};
|
|
599
|
+
pageA.on("console", fwd("peerA"));
|
|
600
|
+
pageB.on("console", fwd("peerB"));
|
|
601
|
+
pageA.on("pageerror", (e) => logTs(`[peerA error] ${e}`));
|
|
602
|
+
pageB.on("pageerror", (e) => logTs(`[peerB error] ${e}`));
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
// --- peer A: create playlist with songs + cover images ---
|
|
606
|
+
await resetAppState(pageA);
|
|
607
|
+
await createPlaylistViaUI(pageA);
|
|
608
|
+
await pageA.getByTestId("input-playlist-title").fill("blob-transfer-test");
|
|
609
|
+
await pageA.getByTestId("input-playlist-title").blur();
|
|
610
|
+
await pageA.waitForTimeout(300);
|
|
611
|
+
|
|
612
|
+
// 3 songs so we exercise parallel prefetch
|
|
613
|
+
await addSongs(pageA, 3);
|
|
614
|
+
await expect(pageA.getByTestId("song-row")).toHaveCount(3, { timeout: 10_000 });
|
|
615
|
+
|
|
616
|
+
// add a cover image to the first song via the song edit panel
|
|
617
|
+
const pngBytes = await makePng(pageA, { color: "#ff00ff", label: "cover" });
|
|
618
|
+
const firstRow = pageA.getByTestId("song-row").first();
|
|
619
|
+
await firstRow.hover();
|
|
620
|
+
await pageA.getByTestId("btn-edit-song").first().click();
|
|
621
|
+
await pageA.getByTestId("song-edit-panel").waitFor({ timeout: 5000 });
|
|
622
|
+
// drop the image onto the song edit panel drop zone
|
|
623
|
+
await pageA.evaluate(
|
|
624
|
+
async ({ b64 }: { b64: string }) => {
|
|
625
|
+
const bin = atob(b64);
|
|
626
|
+
const arr = new Uint8Array(bin.length);
|
|
627
|
+
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
|
628
|
+
const file = new File([arr], "cover.png", { type: "image/png" });
|
|
629
|
+
const dt = new DataTransfer();
|
|
630
|
+
dt.items.add(file);
|
|
631
|
+
const panel = document.querySelector('[data-testid="song-edit-panel"]') ?? document.body;
|
|
632
|
+
panel.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: dt }));
|
|
633
|
+
},
|
|
634
|
+
{ b64: Buffer.from(pngBytes).toString("base64") }
|
|
635
|
+
);
|
|
636
|
+
await pageA.waitForTimeout(500);
|
|
637
|
+
await pageA.keyboard.press("Escape");
|
|
638
|
+
|
|
639
|
+
// --- peer A: enable p2p and set public mode ---
|
|
640
|
+
logTs("[e2e] peerA: enabling p2p...");
|
|
641
|
+
await pageA.getByTestId("btn-share-playlist").click();
|
|
642
|
+
await pageA.getByTestId("btn-enable-sharing").click();
|
|
643
|
+
await expect(pageA.getByTestId("btn-copy-share-link")).toBeEnabled({ timeout: 180_000 });
|
|
644
|
+
|
|
645
|
+
await pageA.getByText("anyone (public)").click();
|
|
646
|
+
await pageA.waitForTimeout(300);
|
|
647
|
+
|
|
648
|
+
const shareUrl = await pageA.locator("input[readonly]").first().inputValue();
|
|
649
|
+
expect(shareUrl).toContain("#share/");
|
|
650
|
+
logTs(`[e2e] peerA: share url built: ${shareUrl.slice(0, 60)}...`);
|
|
651
|
+
await pageA.getByTestId("btn-share-playlist").click();
|
|
652
|
+
|
|
653
|
+
// --- peer B: boot p2p and open share link ---
|
|
654
|
+
await resetAppState(pageB);
|
|
655
|
+
await createPlaylistViaUI(pageB);
|
|
656
|
+
await pageB.getByTestId("btn-share-playlist").click();
|
|
657
|
+
logTs("[e2e] peerB: enabling p2p...");
|
|
658
|
+
await pageB.getByTestId("btn-enable-sharing").click();
|
|
659
|
+
await expect(pageB.getByTestId("sharing-status")).toBeVisible({ timeout: 180_000 });
|
|
660
|
+
await pageB.getByTestId("btn-share-playlist").click();
|
|
661
|
+
logTs("[e2e] peerB: p2p online");
|
|
662
|
+
|
|
663
|
+
await pageB.getByTestId("btn-all-playlists").click();
|
|
664
|
+
await pageB.getByTestId("input-search-playlists").fill(shareUrl);
|
|
665
|
+
logTs("[e2e] peerB: opening share link...");
|
|
666
|
+
await expect(pageB.getByTestId("all-playlists-panel")).not.toBeVisible({ timeout: 120_000 });
|
|
667
|
+
|
|
668
|
+
// confirm doc metadata arrived
|
|
669
|
+
await expect(pageB.getByTestId("input-playlist-title")).toHaveValue(
|
|
670
|
+
"blob-transfer-test",
|
|
671
|
+
{ timeout: 30_000 }
|
|
672
|
+
);
|
|
673
|
+
await expect(pageB.getByTestId("song-row")).toHaveCount(3, { timeout: 30_000 });
|
|
674
|
+
logTs("[e2e] peerB: doc synced - 3 song rows visible");
|
|
675
|
+
|
|
676
|
+
// --- peer B: trigger "save offline" to pull all blobs ---
|
|
677
|
+
// open share panel to reach the p2p save button
|
|
678
|
+
await pageB.getByTestId("btn-share-playlist").click();
|
|
679
|
+
await pageB.getByTestId("share-panel").waitFor({ timeout: 5000 });
|
|
680
|
+
await pageB.getByTestId("btn-share-playlist").click();
|
|
681
|
+
|
|
682
|
+
// the p2p save button is visible only if blobs are missing
|
|
683
|
+
const saveBtn = pageB.getByTestId("btn-p2p-save-offline");
|
|
684
|
+
if (await saveBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
685
|
+
logTs("[e2e] peerB: triggering save offline...");
|
|
686
|
+
await saveBtn.click();
|
|
687
|
+
// wait for the button to disappear (all blobs cached = button hidden)
|
|
688
|
+
await expect(saveBtn).not.toBeVisible({ timeout: 180_000 });
|
|
689
|
+
logTs("[e2e] peerB: save offline complete");
|
|
690
|
+
} else {
|
|
691
|
+
logTs("[e2e] peerB: blobs already local (skip save offline)");
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// no song rows should be in error state
|
|
695
|
+
const errorRows = pageB.locator("[data-testid='song-duration'][data-download-state='error']");
|
|
696
|
+
await expect(errorRows).toHaveCount(0, { timeout: 5000 });
|
|
697
|
+
logTs("[e2e] peerB: no song rows in error state");
|
|
698
|
+
|
|
699
|
+
// all 3 songs should have no pending/downloading state (blobs cached)
|
|
700
|
+
await expect(
|
|
701
|
+
pageB.locator("[data-testid='song-duration'][data-download-state]")
|
|
702
|
+
).toHaveCount(0, { timeout: 10_000 });
|
|
703
|
+
logTs("[e2e] peerB: all song blobs cached locally");
|
|
704
|
+
|
|
705
|
+
// --- play first song on B - should work on first attempt ---
|
|
706
|
+
await pageB.getByText("song-00").dblclick();
|
|
707
|
+
// audio player should show the song title within a few seconds
|
|
708
|
+
// (if the blob fetch needed a second attempt the title flickers, which
|
|
709
|
+
// is what we're guarding against)
|
|
710
|
+
await expect(pageB.getByTestId("audio-player")).toBeVisible({ timeout: 5000 });
|
|
711
|
+
logTs("[e2e] peerB: first song playing");
|
|
712
|
+
|
|
713
|
+
// --- cover image on B ---
|
|
714
|
+
// the first song row should have an image thumbnail visible
|
|
715
|
+
const firstSongRow = pageB.getByTestId("song-row").first();
|
|
716
|
+
const thumb = firstSongRow.locator("img");
|
|
717
|
+
await expect(thumb).toBeVisible({ timeout: 10_000 });
|
|
718
|
+
logTs("[e2e] peerB: song cover image visible");
|
|
719
|
+
} finally {
|
|
720
|
+
await Promise.allSettled([ctxA.close(), ctxB.close()]);
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
|