@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,143 @@
|
|
|
1
|
+
// app interaction helpers: navigate, reset state, create playlists, add songs.
|
|
2
|
+
//
|
|
3
|
+
// these helpers drive the real app UI through Playwright. no mock transport
|
|
4
|
+
// hooks here - see hooks.ts for window.__* wrappers.
|
|
5
|
+
|
|
6
|
+
import { expect, type Page } from "@playwright/test";
|
|
7
|
+
import { makeWav, type FixtureFile } from "./media.js";
|
|
8
|
+
|
|
9
|
+
// log with a wall-clock timestamp so slow steps and stalls are visible
|
|
10
|
+
// in test output (e.g. "[12:34:56] peer a: p2p node online")
|
|
11
|
+
export function logTs(message: string): void {
|
|
12
|
+
const now = new Date().toTimeString().slice(0, 8);
|
|
13
|
+
console.log(`[${now}] ${message}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// generate a real PNG in the page via canvas (solid color + optional label text).
|
|
17
|
+
// returns the bytes so they can be dropped or set on file inputs.
|
|
18
|
+
export async function makePng(
|
|
19
|
+
page: Page,
|
|
20
|
+
opts: { width?: number; height?: number; color?: string; label?: string } = {}
|
|
21
|
+
): Promise<Uint8Array> {
|
|
22
|
+
const { width = 64, height = 64, color = "#ff00ff", label = "" } = opts;
|
|
23
|
+
const base64 = await page.evaluate(
|
|
24
|
+
async ({ width, height, color, label }) => {
|
|
25
|
+
const canvas = document.createElement("canvas");
|
|
26
|
+
canvas.width = width;
|
|
27
|
+
canvas.height = height;
|
|
28
|
+
const ctx = canvas.getContext("2d")!;
|
|
29
|
+
ctx.fillStyle = color;
|
|
30
|
+
ctx.fillRect(0, 0, width, height);
|
|
31
|
+
if (label) {
|
|
32
|
+
ctx.fillStyle = "#000";
|
|
33
|
+
ctx.font = "12px monospace";
|
|
34
|
+
ctx.fillText(label, 4, height / 2);
|
|
35
|
+
}
|
|
36
|
+
const blob: Blob = await new Promise((res) =>
|
|
37
|
+
canvas.toBlob((b) => res(b!), "image/png")
|
|
38
|
+
);
|
|
39
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
40
|
+
let bin = "";
|
|
41
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
42
|
+
return btoa(bin);
|
|
43
|
+
},
|
|
44
|
+
{ width, height, color, label }
|
|
45
|
+
);
|
|
46
|
+
return Uint8Array.from(Buffer.from(base64, "base64"));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// drop files onto the app (simulates drag and drop of audio files / zips).
|
|
50
|
+
export async function dropFiles(page: Page, files: FixtureFile[]): Promise<void> {
|
|
51
|
+
const payload = files.map((f) => ({
|
|
52
|
+
name: f.name,
|
|
53
|
+
mimeType: f.mimeType,
|
|
54
|
+
base64: Buffer.from(f.bytes).toString("base64"),
|
|
55
|
+
}));
|
|
56
|
+
await page.evaluate(async (payload) => {
|
|
57
|
+
const dt = new DataTransfer();
|
|
58
|
+
for (const f of payload) {
|
|
59
|
+
const bin = atob(f.base64);
|
|
60
|
+
const bytes = new Uint8Array(bin.length);
|
|
61
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
62
|
+
dt.items.add(new File([bytes], f.name, { type: f.mimeType }));
|
|
63
|
+
}
|
|
64
|
+
const target = document.querySelector('[data-testid="app-root"]') ?? document.body;
|
|
65
|
+
target.dispatchEvent(
|
|
66
|
+
new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: dt })
|
|
67
|
+
);
|
|
68
|
+
}, payload);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// wipe all app storage (indexeddb + localstorage) and reload for a clean slate.
|
|
72
|
+
export async function resetAppState(page: Page): Promise<void> {
|
|
73
|
+
await page.goto("/");
|
|
74
|
+
await page.evaluate(async () => {
|
|
75
|
+
localStorage.clear();
|
|
76
|
+
const dbs = await indexedDB.databases();
|
|
77
|
+
await Promise.all(
|
|
78
|
+
dbs.map(
|
|
79
|
+
(db) =>
|
|
80
|
+
new Promise<void>((res) => {
|
|
81
|
+
if (!db.name) return res();
|
|
82
|
+
const req = indexedDB.deleteDatabase(db.name);
|
|
83
|
+
req.onsuccess = req.onerror = req.onblocked = () => res();
|
|
84
|
+
})
|
|
85
|
+
)
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
await page.reload();
|
|
89
|
+
await waitForApp(page);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// wait for the app shell to finish booting
|
|
93
|
+
export async function waitForApp(page: Page): Promise<void> {
|
|
94
|
+
await page.getByTestId("app-ready").waitFor({ timeout: 10000 });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// create a playlist via the UI and wait for the playlist header to appear.
|
|
98
|
+
// on a fresh app (no playlists): clicks the "new playlist" button in the empty state.
|
|
99
|
+
// if a playlist is already selected: opens the all-playlists panel via the
|
|
100
|
+
// hamburger and clicks "new playlist" there.
|
|
101
|
+
export async function createPlaylistViaUI(page: Page): Promise<void> {
|
|
102
|
+
// try the always-visible empty-state button first, fall back to hamburger flow
|
|
103
|
+
const newBtn = page.getByTestId("btn-new-playlist");
|
|
104
|
+
const isVisible = await newBtn.isVisible().catch(() => false);
|
|
105
|
+
if (isVisible) {
|
|
106
|
+
await newBtn.click();
|
|
107
|
+
} else {
|
|
108
|
+
// open all-playlists panel via hamburger
|
|
109
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
110
|
+
await page.getByTestId("btn-new-playlist").click();
|
|
111
|
+
}
|
|
112
|
+
await page.getByTestId("btn-edit-playlist").waitFor({ timeout: 5000 });
|
|
113
|
+
// wait for the title input to reflect the new playlist's default title,
|
|
114
|
+
// confirming the reactive binding is live before callers try to fill it
|
|
115
|
+
await expect(page.getByTestId("input-playlist-title")).toHaveValue(
|
|
116
|
+
"new playlist"
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// add n synthetic songs to the selected playlist via drag and drop
|
|
121
|
+
export async function addSongs(page: Page, count: number, durationSec = 1): Promise<void> {
|
|
122
|
+
const files: FixtureFile[] = [];
|
|
123
|
+
for (let i = 0; i < count; i++) {
|
|
124
|
+
files.push({
|
|
125
|
+
name: `song-${String(i).padStart(2, "0")}.wav`,
|
|
126
|
+
mimeType: "audio/wav",
|
|
127
|
+
bytes: makeWav(durationSec, 220 + i * 110),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
await dropFiles(page, files);
|
|
131
|
+
// wait for the last row to show up
|
|
132
|
+
await page
|
|
133
|
+
.getByText(`song-${String(count - 1).padStart(2, "0")}`)
|
|
134
|
+
.waitFor({ timeout: 15000 });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// set a cover image on the playlist edit panel via the file input
|
|
138
|
+
export async function setPlaylistCover(page: Page, f: FixtureFile): Promise<void> {
|
|
139
|
+
// use accept="image/*" to distinguish from the + add-songs audio input
|
|
140
|
+
const input = page.locator("input[type='file'][accept='image/*']").first();
|
|
141
|
+
await input.waitFor({ state: "attached", timeout: 5000 });
|
|
142
|
+
await input.setInputFiles({ name: f.name, mimeType: f.mimeType, buffer: Buffer.from(f.bytes) });
|
|
143
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// dev hook wrappers for e2e tests.
|
|
2
|
+
//
|
|
3
|
+
// typed wrappers around window.__* hooks registered by src/dev-hooks.ts.
|
|
4
|
+
// these are only available in DEV builds (the vite dev server).
|
|
5
|
+
//
|
|
6
|
+
// convention: tests that use any mockBlobFetch / clearMockBlobFetch call
|
|
7
|
+
// should tag their test description with "@mock" so they can be run or
|
|
8
|
+
// excluded as a group:
|
|
9
|
+
//
|
|
10
|
+
// test("downloads blob from peer @mock", async ({ page }) => { ... })
|
|
11
|
+
//
|
|
12
|
+
// # run only transport-mocked tests:
|
|
13
|
+
// npm run test:e2e:mock
|
|
14
|
+
//
|
|
15
|
+
// # run everything except mocked transport tests:
|
|
16
|
+
// npm run test:e2e:real
|
|
17
|
+
//
|
|
18
|
+
// the time-acceleration hooks (seekTo, triggerTrackEnd, triggerAudioError)
|
|
19
|
+
// are NOT considered "mock" - they accelerate time on the real audio element
|
|
20
|
+
// without substituting any service boundary. use them freely in any test.
|
|
21
|
+
|
|
22
|
+
import type { Page } from "@playwright/test";
|
|
23
|
+
|
|
24
|
+
// mock blob behaviour modes - single source of truth is global.d.ts (Window interface).
|
|
25
|
+
// this re-derives the type so e2e tests don't need to import from src/.
|
|
26
|
+
export type MockBlobBehaviour = NonNullable<Window["__mockBlobFetch"]> extends (
|
|
27
|
+
b: infer B
|
|
28
|
+
) => void
|
|
29
|
+
? B
|
|
30
|
+
: never;
|
|
31
|
+
|
|
32
|
+
// --- time-acceleration hooks ---
|
|
33
|
+
// these drive the real audio element without substituting any service boundary.
|
|
34
|
+
|
|
35
|
+
// returns the title of the currently playing song, or null if nothing is playing.
|
|
36
|
+
// use this instead of looking for a DOM element to assert playback state.
|
|
37
|
+
export async function currentSong(page: Page): Promise<string | null> {
|
|
38
|
+
return page.evaluate(() => window.__currentSong?.() ?? null);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// seek the audio element to a specific time (seconds)
|
|
42
|
+
export async function seekTo(page: Page, seconds: number): Promise<void> {
|
|
43
|
+
await page.evaluate((t) => window.__seekTo?.(t), seconds);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// fire the "ended" event on the audio element (advance to next track)
|
|
47
|
+
export async function triggerTrackEnd(page: Page): Promise<void> {
|
|
48
|
+
await page.evaluate(() => window.__triggerTrackEnd?.());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// fire an audio error event (code defaults to MEDIA_ERR_SRC_NOT_SUPPORTED = 4)
|
|
52
|
+
export async function triggerAudioError(page: Page, code = 4): Promise<void> {
|
|
53
|
+
await page.evaluate((c) => window.__triggerAudioError?.(c), code);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- transport mock hooks ---
|
|
57
|
+
// tests that call these should be tagged @mock in their description.
|
|
58
|
+
|
|
59
|
+
// override p2p blob fetching with a deterministic mock behaviour.
|
|
60
|
+
// call clearMockBlobFetch in afterEach / at the end of each test.
|
|
61
|
+
export async function mockBlobFetch(
|
|
62
|
+
page: Page,
|
|
63
|
+
behaviour: MockBlobBehaviour
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
await page.evaluate(
|
|
66
|
+
(b) => window.__mockBlobFetch?.(b),
|
|
67
|
+
behaviour as Parameters<NonNullable<typeof window.__mockBlobFetch>>[0]
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// restore real p2p blob fetching (always call this after a transport mock test)
|
|
72
|
+
export async function clearMockBlobFetch(page: Page): Promise<void> {
|
|
73
|
+
await page.evaluate(() => window.__clearMockBlobFetch?.());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- blob store control ---
|
|
77
|
+
// these manipulate the local blob cache directly; not "mocking" per se,
|
|
78
|
+
// but often used alongside transport mocks to create a cache-miss scenario.
|
|
79
|
+
|
|
80
|
+
// remove a blob from local store (simulates a cache miss before pressing play)
|
|
81
|
+
export async function evictBlob(page: Page, sha256: string): Promise<void> {
|
|
82
|
+
await page.evaluate((sha) => window.__evictBlob?.(sha), sha256);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// set the blob fetch timeout in ms (default 30000). use a short value in
|
|
86
|
+
// tests to avoid waiting for the real 30s when testing timeout behaviour.
|
|
87
|
+
// reset to 30000 after the test.
|
|
88
|
+
export async function setBlobFetchTimeout(page: Page, ms: number): Promise<void> {
|
|
89
|
+
await page.evaluate((t) => window.__setBlobFetchTimeout?.(t), ms);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// programmatically trigger a blob fetch by sha256.
|
|
93
|
+
// useful when the retry click target is obstructed by an overlay element.
|
|
94
|
+
export async function fetchBlobBySha(page: Page, sha256: string): Promise<void> {
|
|
95
|
+
await page.evaluate((sha) => window.__fetchBlobBySha?.(sha), sha256);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- docIndex dev hooks (registered in src/dev-hooks.ts) ---
|
|
99
|
+
|
|
100
|
+
export interface DocIndexEntry {
|
|
101
|
+
docId: string;
|
|
102
|
+
title: string;
|
|
103
|
+
addedAt: number;
|
|
104
|
+
source: "local" | "shared" | "freqhole";
|
|
105
|
+
remoteNodeId?: string;
|
|
106
|
+
remoteName?: string;
|
|
107
|
+
isForked?: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// return all docIndex entries from the running app (via service layer, not raw idb)
|
|
111
|
+
export async function getDocIndexEntries(page: Page): Promise<DocIndexEntry[]> {
|
|
112
|
+
// the hook is registered after a dynamic import - wait for it to appear
|
|
113
|
+
await page.waitForFunction(() => typeof window.__getDocIndexEntries === "function", {
|
|
114
|
+
timeout: 5000,
|
|
115
|
+
});
|
|
116
|
+
return page.evaluate(() => window.__getDocIndexEntries!()) as Promise<DocIndexEntry[]>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// patch a docIndex entry in-place (merge patch), then wait for the app to
|
|
120
|
+
// re-sync its playlist list from the updated docIndex
|
|
121
|
+
export async function patchDocIndexEntry(
|
|
122
|
+
page: Page,
|
|
123
|
+
docId: string,
|
|
124
|
+
patch: Partial<DocIndexEntry>
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
await page.waitForFunction(() => typeof window.__patchDocIndexEntry === "function", {
|
|
127
|
+
timeout: 5000,
|
|
128
|
+
});
|
|
129
|
+
await page.evaluate(
|
|
130
|
+
({ docId, patch }) => window.__patchDocIndexEntry!(docId, patch),
|
|
131
|
+
{ docId, patch }
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// barrel re-export for e2e/helpers/.
|
|
2
|
+
//
|
|
3
|
+
// existing spec files import from "../helpers.js" - this keeps that working.
|
|
4
|
+
// new code can import directly from the sub-modules for clarity:
|
|
5
|
+
// import { makeWav } from "../helpers/media.js"
|
|
6
|
+
// import { resetAppState } from "../helpers/app.js"
|
|
7
|
+
// import { mockBlobFetch } from "../helpers/hooks.js"
|
|
8
|
+
|
|
9
|
+
export * from "./media.js";
|
|
10
|
+
export * from "./app.js";
|
|
11
|
+
export * from "./hooks.js";
|
|
12
|
+
export * from "./hooks.js";
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// synthetic media generation and fixture file loading.
|
|
2
|
+
//
|
|
3
|
+
// no Playwright Page imports here - everything is pure data or Node fs ops.
|
|
4
|
+
// used by both app helpers and spec files directly.
|
|
5
|
+
|
|
6
|
+
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../fixtures");
|
|
11
|
+
const USER_PROVIDED_DIR = join(FIXTURES_DIR, "user-provided");
|
|
12
|
+
|
|
13
|
+
// --- synthetic audio ---
|
|
14
|
+
|
|
15
|
+
// build a valid mono 16-bit PCM WAV file with a sine tone.
|
|
16
|
+
// durationSec controls both file size and the decoded duration shown in the UI.
|
|
17
|
+
export function makeWav(durationSec = 1, freqHz = 440): Uint8Array {
|
|
18
|
+
const sampleRate = 8000;
|
|
19
|
+
const numSamples = Math.floor(sampleRate * durationSec);
|
|
20
|
+
const dataSize = numSamples * 2;
|
|
21
|
+
const buf = new ArrayBuffer(44 + dataSize);
|
|
22
|
+
const view = new DataView(buf);
|
|
23
|
+
const writeStr = (offset: number, s: string) => {
|
|
24
|
+
for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i));
|
|
25
|
+
};
|
|
26
|
+
writeStr(0, "RIFF");
|
|
27
|
+
view.setUint32(4, 36 + dataSize, true);
|
|
28
|
+
writeStr(8, "WAVE");
|
|
29
|
+
writeStr(12, "fmt ");
|
|
30
|
+
view.setUint32(16, 16, true); // fmt chunk size
|
|
31
|
+
view.setUint16(20, 1, true); // PCM
|
|
32
|
+
view.setUint16(22, 1, true); // mono
|
|
33
|
+
view.setUint32(24, sampleRate, true);
|
|
34
|
+
view.setUint32(28, sampleRate * 2, true); // byte rate
|
|
35
|
+
view.setUint16(32, 2, true); // block align
|
|
36
|
+
view.setUint16(34, 16, true); // bits per sample
|
|
37
|
+
writeStr(36, "data");
|
|
38
|
+
view.setUint32(40, dataSize, true);
|
|
39
|
+
for (let i = 0; i < numSamples; i++) {
|
|
40
|
+
const sample = Math.sin((2 * Math.PI * freqHz * i) / sampleRate);
|
|
41
|
+
view.setInt16(44 + i * 2, Math.floor(sample * 0x4fff), true);
|
|
42
|
+
}
|
|
43
|
+
return new Uint8Array(buf);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- fixture file type ---
|
|
47
|
+
|
|
48
|
+
export interface FixtureFile {
|
|
49
|
+
name: string;
|
|
50
|
+
mimeType: string;
|
|
51
|
+
bytes: Uint8Array;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const AUDIO_EXT: Record<string, string> = {
|
|
55
|
+
".mp3": "audio/mpeg",
|
|
56
|
+
".wav": "audio/wav",
|
|
57
|
+
".flac": "audio/flac",
|
|
58
|
+
".ogg": "audio/ogg",
|
|
59
|
+
".m4a": "audio/mp4",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const IMAGE_EXT: Record<string, string> = {
|
|
63
|
+
".png": "image/png",
|
|
64
|
+
".jpg": "image/jpeg",
|
|
65
|
+
".jpeg": "image/jpeg",
|
|
66
|
+
".webp": "image/webp",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function loadAudioDir(dir: string): FixtureFile[] {
|
|
70
|
+
if (!existsSync(dir)) return [];
|
|
71
|
+
return readdirSync(dir)
|
|
72
|
+
.filter((f) => Object.keys(AUDIO_EXT).some((ext) => f.toLowerCase().endsWith(ext)))
|
|
73
|
+
.map((f) => {
|
|
74
|
+
const ext = Object.keys(AUDIO_EXT).find((e) => f.toLowerCase().endsWith(e))!;
|
|
75
|
+
return {
|
|
76
|
+
name: f,
|
|
77
|
+
mimeType: AUDIO_EXT[ext]!,
|
|
78
|
+
bytes: new Uint8Array(readFileSync(join(dir, f))),
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function loadImageDir(dir: string): FixtureFile[] {
|
|
84
|
+
if (!existsSync(dir)) return [];
|
|
85
|
+
return readdirSync(dir)
|
|
86
|
+
.filter((f) => Object.keys(IMAGE_EXT).some((ext) => f.toLowerCase().endsWith(ext)))
|
|
87
|
+
.map((f) => {
|
|
88
|
+
const ext = Object.keys(IMAGE_EXT).find((e) => f.toLowerCase().endsWith(e))!;
|
|
89
|
+
return {
|
|
90
|
+
name: f,
|
|
91
|
+
mimeType: IMAGE_EXT[ext]!,
|
|
92
|
+
bytes: new Uint8Array(readFileSync(join(dir, f))),
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// committed audio fixtures (WAV sine waves, generated by generate.mjs).
|
|
98
|
+
// always available; never returns [].
|
|
99
|
+
export function loadCommittedAudioFixtures(): FixtureFile[] {
|
|
100
|
+
return loadAudioDir(FIXTURES_DIR);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// committed image fixtures (PNG patterns, generated by generate.mjs).
|
|
104
|
+
// always available; never returns [].
|
|
105
|
+
export function loadCommittedImageFixtures(): FixtureFile[] {
|
|
106
|
+
return loadImageDir(FIXTURES_DIR);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// load all audio files from both committed fixtures and user-provided/.
|
|
110
|
+
// user-provided/ is gitignored so returns [] when not present.
|
|
111
|
+
export function loadRealAudioFixtures(): FixtureFile[] {
|
|
112
|
+
return [...loadAudioDir(FIXTURES_DIR), ...loadAudioDir(USER_PROVIDED_DIR)];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// load all image files from both committed fixtures and user-provided/.
|
|
116
|
+
export function loadRealImageFixtures(): FixtureFile[] {
|
|
117
|
+
return [...loadImageDir(FIXTURES_DIR), ...loadImageDir(USER_PROVIDED_DIR)];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// load a single committed fixture by filename
|
|
121
|
+
export function fixture(name: string): FixtureFile {
|
|
122
|
+
const ext = `.${name.split(".").pop()!.toLowerCase()}`;
|
|
123
|
+
const mimeType = AUDIO_EXT[ext] ?? IMAGE_EXT[ext] ?? "application/octet-stream";
|
|
124
|
+
return { name, mimeType, bytes: new Uint8Array(readFileSync(join(FIXTURES_DIR, name))) };
|
|
125
|
+
}
|
package/e2e/helpers.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// re-export shim - the helpers have been split into e2e/helpers/:
|
|
2
|
+
//
|
|
3
|
+
// helpers/media.ts - makeWav, fixture loading (no Page deps)
|
|
4
|
+
// helpers/app.ts - resetAppState, createPlaylistViaUI, addSongs, etc.
|
|
5
|
+
// helpers/hooks.ts - window.__* dev hook wrappers + MockBlobBehaviour type
|
|
6
|
+
//
|
|
7
|
+
// existing spec imports ("./helpers.js") continue to work unchanged.
|
|
8
|
+
// new spec files should import directly from the sub-modules for clarity.
|
|
9
|
+
|
|
10
|
+
export * from "./helpers/index.js";
|