@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,220 @@
|
|
|
1
|
+
// e2e: all-playlists panel - hamburger button, row interactions, keyboard nav.
|
|
2
|
+
|
|
3
|
+
import { test, expect } from "@playwright/test";
|
|
4
|
+
import {
|
|
5
|
+
resetAppState,
|
|
6
|
+
createPlaylistViaUI,
|
|
7
|
+
addSongs,
|
|
8
|
+
} from "./helpers.js";
|
|
9
|
+
|
|
10
|
+
test.beforeEach(async ({ page }) => {
|
|
11
|
+
await resetAppState(page);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// --- panel open / close ---
|
|
15
|
+
|
|
16
|
+
test("hamburger opens the all-playlists panel", async ({ page }) => {
|
|
17
|
+
await createPlaylistViaUI(page);
|
|
18
|
+
// rename so we can identify it
|
|
19
|
+
await page.getByTestId("input-playlist-title").fill("alpha");
|
|
20
|
+
await page.getByTestId("input-playlist-title").blur();
|
|
21
|
+
|
|
22
|
+
// create a second playlist so the panel has something to show
|
|
23
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
24
|
+
await page.getByTestId("btn-new-playlist").click();
|
|
25
|
+
await page.getByTestId("btn-edit-playlist").waitFor({ timeout: 5000 });
|
|
26
|
+
await expect(page.getByTestId("input-playlist-title")).toHaveValue("new playlist");
|
|
27
|
+
await page.getByTestId("input-playlist-title").fill("beta");
|
|
28
|
+
await page.getByTestId("input-playlist-title").blur();
|
|
29
|
+
|
|
30
|
+
// open panel again - should see the panel
|
|
31
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
32
|
+
await expect(page.getByTestId("all-playlists-panel")).toBeVisible();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("escape closes the all-playlists panel", async ({ page }) => {
|
|
36
|
+
await createPlaylistViaUI(page);
|
|
37
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
38
|
+
await expect(page.getByTestId("all-playlists-panel")).toBeVisible();
|
|
39
|
+
await page.keyboard.press("Escape");
|
|
40
|
+
await expect(page.getByTestId("all-playlists-panel")).not.toBeVisible();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("hamburger button closes the all-playlists panel when open", async ({ page }) => {
|
|
44
|
+
await createPlaylistViaUI(page);
|
|
45
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
46
|
+
await expect(page.getByTestId("all-playlists-panel")).toBeVisible();
|
|
47
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
48
|
+
await expect(page.getByTestId("all-playlists-panel")).not.toBeVisible();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// --- row contents ---
|
|
52
|
+
|
|
53
|
+
test("selected playlist is not shown in panel rows", async ({ page }) => {
|
|
54
|
+
await createPlaylistViaUI(page);
|
|
55
|
+
await page.getByTestId("input-playlist-title").fill("selected one");
|
|
56
|
+
await page.getByTestId("input-playlist-title").blur();
|
|
57
|
+
await page.waitForTimeout(300);
|
|
58
|
+
|
|
59
|
+
// create a second playlist so panel has rows to show
|
|
60
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
61
|
+
await page.getByTestId("btn-new-playlist").click();
|
|
62
|
+
await page.getByTestId("btn-edit-playlist").waitFor();
|
|
63
|
+
await expect(page.getByTestId("input-playlist-title")).toHaveValue("new playlist");
|
|
64
|
+
await page.getByTestId("input-playlist-title").fill("other one");
|
|
65
|
+
await page.getByTestId("input-playlist-title").blur();
|
|
66
|
+
await page.waitForTimeout(300);
|
|
67
|
+
|
|
68
|
+
// go back to selected one
|
|
69
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
70
|
+
// find and click "selected one" row
|
|
71
|
+
await page.getByText("selected one").first().click();
|
|
72
|
+
await page.waitForTimeout(300);
|
|
73
|
+
|
|
74
|
+
// open panel - "selected one" should NOT appear in the row list
|
|
75
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
76
|
+
// the mini header shows the current playlist title ("selected one")
|
|
77
|
+
// but it should NOT appear as a clickable row
|
|
78
|
+
const rows = page.locator("[title*='play selected one']");
|
|
79
|
+
await expect(rows).toHaveCount(0);
|
|
80
|
+
// "other one" should be present as a row
|
|
81
|
+
await expect(page.getByText("other one").first()).toBeVisible();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("all other playlists are shown in panel rows", async ({ page }) => {
|
|
85
|
+
// create three playlists
|
|
86
|
+
await createPlaylistViaUI(page);
|
|
87
|
+
await page.getByTestId("input-playlist-title").fill("playlist a");
|
|
88
|
+
await page.getByTestId("input-playlist-title").blur();
|
|
89
|
+
|
|
90
|
+
for (const name of ["playlist b", "playlist c"]) {
|
|
91
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
92
|
+
await page.getByTestId("btn-new-playlist").click();
|
|
93
|
+
await page.getByTestId("btn-edit-playlist").waitFor();
|
|
94
|
+
await expect(page.getByTestId("input-playlist-title")).toHaveValue("new playlist");
|
|
95
|
+
await page.getByTestId("input-playlist-title").fill(name);
|
|
96
|
+
await page.getByTestId("input-playlist-title").blur();
|
|
97
|
+
// wait for the doc to reflect the new title before navigating away
|
|
98
|
+
await expect(page.getByTestId("input-playlist-title")).toHaveValue(name);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// currently selected is "playlist c" - panel should show a and b
|
|
102
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
103
|
+
await expect(page.getByText("playlist a")).toBeVisible();
|
|
104
|
+
await expect(page.getByText("playlist b")).toBeVisible();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// --- row navigation ---
|
|
108
|
+
|
|
109
|
+
test("clicking a row selects the playlist and closes the panel", async ({ page }) => {
|
|
110
|
+
await createPlaylistViaUI(page);
|
|
111
|
+
await page.getByTestId("input-playlist-title").fill("first");
|
|
112
|
+
await page.getByTestId("input-playlist-title").blur();
|
|
113
|
+
await page.waitForTimeout(300);
|
|
114
|
+
|
|
115
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
116
|
+
await page.getByTestId("btn-new-playlist").click();
|
|
117
|
+
await page.getByTestId("btn-edit-playlist").waitFor();
|
|
118
|
+
await expect(page.getByTestId("input-playlist-title")).toHaveValue("new playlist");
|
|
119
|
+
await page.getByTestId("input-playlist-title").fill("second");
|
|
120
|
+
await page.getByTestId("input-playlist-title").blur();
|
|
121
|
+
await expect(page.getByTestId("input-playlist-title")).toHaveValue("second");
|
|
122
|
+
|
|
123
|
+
// open panel and click "first"
|
|
124
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
125
|
+
await page.getByText("first").first().click();
|
|
126
|
+
|
|
127
|
+
// panel should close
|
|
128
|
+
await expect(page.getByTestId("all-playlists-panel")).not.toBeVisible();
|
|
129
|
+
// "first" should now be the selected playlist shown in the title area
|
|
130
|
+
await expect(page.getByTestId("input-playlist-title").or(page.getByText("first").first())).toBeVisible();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("edit button in row opens edit panel for that playlist", async ({ page }) => {
|
|
134
|
+
await createPlaylistViaUI(page);
|
|
135
|
+
await page.getByTestId("input-playlist-title").fill("edit me");
|
|
136
|
+
await page.getByTestId("input-playlist-title").blur();
|
|
137
|
+
await expect(page.getByTestId("input-playlist-title")).toHaveValue("edit me");
|
|
138
|
+
|
|
139
|
+
// create a second playlist as the "currently selected" one
|
|
140
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
141
|
+
await page.getByTestId("btn-new-playlist").click();
|
|
142
|
+
await page.getByTestId("btn-edit-playlist").waitFor();
|
|
143
|
+
await expect(page.getByTestId("input-playlist-title")).toHaveValue("new playlist");
|
|
144
|
+
await page.getByTestId("input-playlist-title").fill("currently selected");
|
|
145
|
+
await page.getByTestId("input-playlist-title").blur();
|
|
146
|
+
await expect(page.getByTestId("input-playlist-title")).toHaveValue("currently selected");
|
|
147
|
+
|
|
148
|
+
// open panel, hover over "edit me" row and click its edit button
|
|
149
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
150
|
+
// wait for panel rows to appear
|
|
151
|
+
const panel = page.getByTestId("all-playlists-panel");
|
|
152
|
+
await panel.waitFor({ timeout: 3000 });
|
|
153
|
+
const row = panel.getByText("edit me").first();
|
|
154
|
+
await row.hover();
|
|
155
|
+
await panel.getByTestId("btn-edit-playlist-row").click();
|
|
156
|
+
|
|
157
|
+
// panel closes, edit panel opens for "edit me"
|
|
158
|
+
await expect(page.getByTestId("all-playlists-panel")).not.toBeVisible();
|
|
159
|
+
// the edit input should show "edit me"
|
|
160
|
+
await expect(page.getByTestId("input-playlist-title")).toHaveValue("edit me");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("share button in row opens share panel for that playlist", async ({ page }) => {
|
|
164
|
+
await createPlaylistViaUI(page);
|
|
165
|
+
await page.getByTestId("input-playlist-title").fill("share me");
|
|
166
|
+
await page.getByTestId("input-playlist-title").blur();
|
|
167
|
+
await page.waitForTimeout(300);
|
|
168
|
+
|
|
169
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
170
|
+
await page.getByTestId("btn-new-playlist").click();
|
|
171
|
+
await page.getByTestId("btn-edit-playlist").waitFor();
|
|
172
|
+
await expect(page.getByTestId("input-playlist-title")).toHaveValue("new playlist");
|
|
173
|
+
await page.getByTestId("input-playlist-title").fill("currently selected");
|
|
174
|
+
await page.getByTestId("input-playlist-title").blur();
|
|
175
|
+
await expect(page.getByTestId("input-playlist-title")).toHaveValue("currently selected");
|
|
176
|
+
|
|
177
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
178
|
+
const panel = page.getByTestId("all-playlists-panel");
|
|
179
|
+
await panel.waitFor({ timeout: 3000 });
|
|
180
|
+
const row = panel.getByText("share me").first();
|
|
181
|
+
await row.hover();
|
|
182
|
+
await panel.getByTestId("btn-share-playlist-row").click();
|
|
183
|
+
|
|
184
|
+
// share panel should be open
|
|
185
|
+
await expect(page.getByTestId("share-panel")).toBeVisible();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// --- new playlist ---
|
|
189
|
+
|
|
190
|
+
test("new playlist row creates a playlist and closes the panel", async ({ page }) => {
|
|
191
|
+
await createPlaylistViaUI(page);
|
|
192
|
+
|
|
193
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
194
|
+
await page.getByTestId("btn-new-playlist").click();
|
|
195
|
+
|
|
196
|
+
// panel should close and new playlist edit mode should be open
|
|
197
|
+
await expect(page.getByTestId("all-playlists-panel")).not.toBeVisible();
|
|
198
|
+
await expect(page.getByTestId("btn-edit-playlist")).toBeVisible();
|
|
199
|
+
await expect(page.getByTestId("input-playlist-title")).toHaveValue("new playlist");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// --- song count / panel with songs ---
|
|
203
|
+
|
|
204
|
+
test("row shows song count for playlists with songs", async ({ page }) => {
|
|
205
|
+
await createPlaylistViaUI(page);
|
|
206
|
+
await addSongs(page, 3);
|
|
207
|
+
await page.getByTestId("input-playlist-title").fill("has songs");
|
|
208
|
+
await page.getByTestId("input-playlist-title").blur();
|
|
209
|
+
await page.waitForTimeout(300);
|
|
210
|
+
|
|
211
|
+
// create a second (selected) playlist
|
|
212
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
213
|
+
await page.getByTestId("btn-new-playlist").click();
|
|
214
|
+
await page.getByTestId("btn-edit-playlist").waitFor();
|
|
215
|
+
await page.waitForTimeout(300);
|
|
216
|
+
|
|
217
|
+
// open panel - "has songs" row should show "3 songz"
|
|
218
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
219
|
+
await expect(page.getByTestId("row-song-count").first()).toContainText("3");
|
|
220
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// e2e: audio player behaviour - autoplay next, errors, blob fetch states.
|
|
2
|
+
//
|
|
3
|
+
// most tests use the dev hook helpers (seekTo, triggerTrackEnd, etc.) from
|
|
4
|
+
// e2e/helpers.ts to drive playback events without waiting for real audio.
|
|
5
|
+
//
|
|
6
|
+
// one "real playthrough" test lets a 2s fixture actually finish to prove
|
|
7
|
+
// autoplay-next works end-to-end.
|
|
8
|
+
//
|
|
9
|
+
// blob-fetch state tests use mockBlobFetch + evictBlob to simulate p2p
|
|
10
|
+
// transfers without a real peer connection.
|
|
11
|
+
|
|
12
|
+
import { test, expect } from "@playwright/test";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { join, dirname } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import {
|
|
17
|
+
resetAppState,
|
|
18
|
+
createPlaylistViaUI,
|
|
19
|
+
addSongs,
|
|
20
|
+
seekTo,
|
|
21
|
+
triggerTrackEnd,
|
|
22
|
+
triggerAudioError,
|
|
23
|
+
mockBlobFetch,
|
|
24
|
+
clearMockBlobFetch,
|
|
25
|
+
evictBlob,
|
|
26
|
+
currentSong,
|
|
27
|
+
} from "./helpers.js";
|
|
28
|
+
|
|
29
|
+
const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), "fixtures");
|
|
30
|
+
|
|
31
|
+
test.beforeEach(async ({ page }) => {
|
|
32
|
+
await resetAppState(page);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// --- autoplay / queue advance ---
|
|
36
|
+
|
|
37
|
+
test("autoplay next: seekTo near end advances to next song", async ({
|
|
38
|
+
page,
|
|
39
|
+
}) => {
|
|
40
|
+
await createPlaylistViaUI(page);
|
|
41
|
+
await addSongs(page, 3, 2);
|
|
42
|
+
|
|
43
|
+
await page.getByText("song-00").dblclick();
|
|
44
|
+
await expect(page.getByTestId("btn-play-playlist")).toHaveAttribute(
|
|
45
|
+
"aria-pressed",
|
|
46
|
+
"true",
|
|
47
|
+
{ timeout: 10000 }
|
|
48
|
+
);
|
|
49
|
+
await expect
|
|
50
|
+
.poll(() => currentSong(page), { timeout: 8000 })
|
|
51
|
+
.toBe("song-00");
|
|
52
|
+
|
|
53
|
+
await seekTo(page, 1.9); // 2s song, seek to 0.1s before end
|
|
54
|
+
|
|
55
|
+
await expect
|
|
56
|
+
.poll(() => currentSong(page), { timeout: 8000 })
|
|
57
|
+
.toBe("song-01");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("autoplay next: triggerTrackEnd advances queue", async ({ page }) => {
|
|
61
|
+
await createPlaylistViaUI(page);
|
|
62
|
+
await addSongs(page, 3, 2);
|
|
63
|
+
|
|
64
|
+
await page.getByText("song-00").dblclick();
|
|
65
|
+
await expect
|
|
66
|
+
.poll(() => currentSong(page), { timeout: 10000 })
|
|
67
|
+
.toBe("song-00");
|
|
68
|
+
|
|
69
|
+
await triggerTrackEnd(page);
|
|
70
|
+
|
|
71
|
+
await expect
|
|
72
|
+
.poll(() => currentSong(page), { timeout: 8000 })
|
|
73
|
+
.toBe("song-01");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("end of playlist: last song ends, player stops", async ({ page }) => {
|
|
77
|
+
await createPlaylistViaUI(page);
|
|
78
|
+
await addSongs(page, 2, 2);
|
|
79
|
+
|
|
80
|
+
await page.getByText("song-01").dblclick();
|
|
81
|
+
await expect(page.getByTestId("btn-play-playlist")).toHaveAttribute(
|
|
82
|
+
"aria-pressed",
|
|
83
|
+
"true",
|
|
84
|
+
{ timeout: 10000 }
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
await triggerTrackEnd(page);
|
|
88
|
+
|
|
89
|
+
// playback stops - button aria-pressed returns to "false"
|
|
90
|
+
await expect(page.getByTestId("btn-play-playlist")).toHaveAttribute(
|
|
91
|
+
"aria-pressed",
|
|
92
|
+
"false",
|
|
93
|
+
{ timeout: 8000 }
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// --- audio player error states ---
|
|
98
|
+
|
|
99
|
+
test("real playthrough: 2s fixture autoadvances without hooks", async ({
|
|
100
|
+
page,
|
|
101
|
+
}) => {
|
|
102
|
+
test.slow();
|
|
103
|
+
await createPlaylistViaUI(page);
|
|
104
|
+
await addSongs(page, 2, 2);
|
|
105
|
+
|
|
106
|
+
await page.getByText("song-00").dblclick();
|
|
107
|
+
await expect
|
|
108
|
+
.poll(() => currentSong(page), { timeout: 10000 })
|
|
109
|
+
.toBe("song-00");
|
|
110
|
+
|
|
111
|
+
// let it play through naturally
|
|
112
|
+
await expect
|
|
113
|
+
.poll(() => currentSong(page), { timeout: 12000 })
|
|
114
|
+
.toBe("song-01");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// --- audio player error states ---
|
|
118
|
+
|
|
119
|
+
test("triggerAudioError: player recovers without crash", async ({ page }) => {
|
|
120
|
+
await createPlaylistViaUI(page);
|
|
121
|
+
await addSongs(page, 2, 2);
|
|
122
|
+
|
|
123
|
+
await page.getByText("song-00").dblclick();
|
|
124
|
+
await expect(page.getByTestId("btn-play-playlist")).toHaveAttribute(
|
|
125
|
+
"aria-pressed",
|
|
126
|
+
"true",
|
|
127
|
+
{ timeout: 10000 }
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
await triggerAudioError(page, 4);
|
|
131
|
+
|
|
132
|
+
// app must stay responsive
|
|
133
|
+
await expect(page.getByTestId("btn-play-playlist")).toBeVisible({
|
|
134
|
+
timeout: 5000,
|
|
135
|
+
});
|
|
136
|
+
await expect(page.getByTestId("song-duration").first()).toBeVisible();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// --- blob fetch states (mocked p2p transport) ---
|
|
140
|
+
|
|
141
|
+
// import a committed fixture file via the __processFiles dev hook
|
|
142
|
+
async function importFixture(
|
|
143
|
+
page: Parameters<typeof evictBlob>[0],
|
|
144
|
+
filename: string
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
const bytes = readFileSync(join(FIXTURES_DIR, filename));
|
|
147
|
+
const result = await page.evaluate(
|
|
148
|
+
async ({ b64, name }: { b64: string; name: string }) => {
|
|
149
|
+
const bin = atob(b64);
|
|
150
|
+
const arr = new Uint8Array(bin.length);
|
|
151
|
+
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
|
152
|
+
const file = new File([arr], name, { type: "audio/wav" });
|
|
153
|
+
const hook = (
|
|
154
|
+
window as Window & { __processFiles?: (files: File[]) => Promise<void> }
|
|
155
|
+
).__processFiles;
|
|
156
|
+
if (!hook) return "hook-missing";
|
|
157
|
+
await hook([file]);
|
|
158
|
+
return "ok";
|
|
159
|
+
},
|
|
160
|
+
{ b64: Buffer.from(bytes).toString("base64"), name: filename }
|
|
161
|
+
);
|
|
162
|
+
if (result !== "ok") throw new Error(`importFixture failed: ${result}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
test("mockBlobFetch delayed: song row shows downloading state @mock", async ({
|
|
166
|
+
page,
|
|
167
|
+
}) => {
|
|
168
|
+
await createPlaylistViaUI(page);
|
|
169
|
+
await importFixture(page, "tone-440hz-2s.wav");
|
|
170
|
+
await expect(page.getByText("tone-440hz-2s")).toBeVisible({ timeout: 10000 });
|
|
171
|
+
|
|
172
|
+
const durationCell = page.getByTestId("song-duration").first();
|
|
173
|
+
await durationCell.waitFor({ timeout: 8000 });
|
|
174
|
+
const sha256 = await durationCell.getAttribute("data-sha256");
|
|
175
|
+
|
|
176
|
+
if (sha256) {
|
|
177
|
+
await evictBlob(page, sha256);
|
|
178
|
+
await mockBlobFetch(page, { type: "delayed", ms: 2000 });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
await page.getByText("tone-440hz-2s").dblclick();
|
|
182
|
+
|
|
183
|
+
if (sha256) {
|
|
184
|
+
await expect(durationCell).toHaveAttribute(
|
|
185
|
+
"data-download-state",
|
|
186
|
+
"downloading",
|
|
187
|
+
{ timeout: 5000 }
|
|
188
|
+
);
|
|
189
|
+
await expect(page.getByTestId("btn-play-playlist")).toHaveAttribute(
|
|
190
|
+
"aria-busy",
|
|
191
|
+
"true",
|
|
192
|
+
{ timeout: 5000 }
|
|
193
|
+
);
|
|
194
|
+
// after 2s delay resolves, downloading state clears
|
|
195
|
+
await expect(durationCell).not.toHaveAttribute(
|
|
196
|
+
"data-download-state",
|
|
197
|
+
"downloading",
|
|
198
|
+
{ timeout: 8000 }
|
|
199
|
+
);
|
|
200
|
+
await clearMockBlobFetch(page);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("mockBlobFetch error: song row shows error state @mock", async ({ page }) => {
|
|
205
|
+
await createPlaylistViaUI(page);
|
|
206
|
+
await importFixture(page, "tone-440hz-2s.wav");
|
|
207
|
+
await expect(page.getByText("tone-440hz-2s")).toBeVisible({ timeout: 10000 });
|
|
208
|
+
|
|
209
|
+
const durationCell = page.getByTestId("song-duration").first();
|
|
210
|
+
await durationCell.waitFor();
|
|
211
|
+
const sha256 = await durationCell.getAttribute("data-sha256");
|
|
212
|
+
|
|
213
|
+
if (sha256) {
|
|
214
|
+
await evictBlob(page, sha256);
|
|
215
|
+
await mockBlobFetch(page, { type: "error", code: "not_found" });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await page.getByText("tone-440hz-2s").dblclick();
|
|
219
|
+
|
|
220
|
+
if (sha256) {
|
|
221
|
+
await expect(durationCell).toHaveAttribute("data-download-state", "error", {
|
|
222
|
+
timeout: 8000,
|
|
223
|
+
});
|
|
224
|
+
await clearMockBlobFetch(page);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// e2e: single-browser tests for the collaborative sharing ui.
|
|
2
|
+
//
|
|
3
|
+
// covers: subscribed (read-only) playlist mode, fork flow,
|
|
4
|
+
// edit/share buttons exiting all-playlists view, and remoteName
|
|
5
|
+
// display in playlist rows.
|
|
6
|
+
//
|
|
7
|
+
// all tests here use the __patchDocIndexEntry / __getDocIndexEntries dev hooks
|
|
8
|
+
// to inject remote-source state without needing a real p2p connection.
|
|
9
|
+
|
|
10
|
+
import { test, expect } from "@playwright/test";
|
|
11
|
+
import {
|
|
12
|
+
resetAppState,
|
|
13
|
+
createPlaylistViaUI,
|
|
14
|
+
addSongs,
|
|
15
|
+
getDocIndexEntries,
|
|
16
|
+
patchDocIndexEntry,
|
|
17
|
+
} from "./helpers.js";
|
|
18
|
+
|
|
19
|
+
test.beforeEach(async ({ page }) => {
|
|
20
|
+
await resetAppState(page);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// get the docId of the only/first playlist in the docIndex
|
|
24
|
+
async function firstDocId(page: import("@playwright/test").Page): Promise<string> {
|
|
25
|
+
const entries = await getDocIndexEntries(page);
|
|
26
|
+
const id = entries[0]?.docId;
|
|
27
|
+
if (!id) throw new Error("no docIndex entry found");
|
|
28
|
+
return id;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// patch remoteNodeId onto the current playlist and wait for the reactive
|
|
32
|
+
// docIndex effect to propagate (no reload needed)
|
|
33
|
+
async function makeSubscribed(
|
|
34
|
+
page: import("@playwright/test").Page,
|
|
35
|
+
docId: string,
|
|
36
|
+
opts: { remoteNodeId?: string; remoteName?: string } = {}
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
await patchDocIndexEntry(page, docId, {
|
|
39
|
+
remoteNodeId: opts.remoteNodeId ?? "a".repeat(64),
|
|
40
|
+
remoteName: opts.remoteName,
|
|
41
|
+
});
|
|
42
|
+
// give the createLiveQuery broadcast + reactive effect time to propagate
|
|
43
|
+
await page.waitForTimeout(500);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- subscribed (read-only) mode ---
|
|
47
|
+
|
|
48
|
+
test("subscribed playlist disables title input", async ({ page }) => {
|
|
49
|
+
await createPlaylistViaUI(page);
|
|
50
|
+
const docId = await firstDocId(page);
|
|
51
|
+
await makeSubscribed(page, docId);
|
|
52
|
+
await expect(page.getByTestId("input-playlist-title")).toBeDisabled({
|
|
53
|
+
timeout: 5000,
|
|
54
|
+
});
|
|
55
|
+
await expect(page.getByTestId("input-playlist-description")).toBeDisabled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("subscribed playlist hides remove-song button", async ({ page }) => {
|
|
59
|
+
await createPlaylistViaUI(page);
|
|
60
|
+
await addSongs(page, 1);
|
|
61
|
+
|
|
62
|
+
// the remove button is in the hover overlay - hover the row to reveal it
|
|
63
|
+
const songRow = page.getByTestId("song-row").first();
|
|
64
|
+
await songRow.waitFor({ timeout: 10000 });
|
|
65
|
+
await songRow.hover();
|
|
66
|
+
await expect(page.getByTestId("btn-remove-song").first()).toBeVisible({
|
|
67
|
+
timeout: 3000,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const docId = await firstDocId(page);
|
|
71
|
+
await makeSubscribed(page, docId);
|
|
72
|
+
|
|
73
|
+
await expect(page.getByTestId("btn-remove-song")).toHaveCount(0, {
|
|
74
|
+
timeout: 5000,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("subscribed playlist shows fork and request-collaboration in edit panel", async ({
|
|
79
|
+
page,
|
|
80
|
+
}) => {
|
|
81
|
+
await createPlaylistViaUI(page);
|
|
82
|
+
const docId = await firstDocId(page);
|
|
83
|
+
await makeSubscribed(page, docId, { remoteName: "peer-dave" });
|
|
84
|
+
|
|
85
|
+
await page.getByTestId("btn-edit-playlist").click();
|
|
86
|
+
await page.getByTestId("edit-panel").waitFor({ timeout: 5000 });
|
|
87
|
+
|
|
88
|
+
await expect(page.getByTestId("btn-fork-playlist")).toBeVisible();
|
|
89
|
+
await expect(page.getByTestId("btn-request-collaboration")).toBeVisible();
|
|
90
|
+
// banner should mention the remote name
|
|
91
|
+
await expect(page.getByTestId("edit-panel")).toContainText("peer-dave");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// --- fork flow ---
|
|
95
|
+
|
|
96
|
+
test("fork creates a new local playlist and selects it", async ({ page }) => {
|
|
97
|
+
await createPlaylistViaUI(page);
|
|
98
|
+
|
|
99
|
+
const firstTitle = "original-playlist";
|
|
100
|
+
await page.getByTestId("input-playlist-title").click({ clickCount: 3 });
|
|
101
|
+
await page.getByTestId("input-playlist-title").fill(firstTitle);
|
|
102
|
+
await page.getByTestId("input-playlist-title").blur();
|
|
103
|
+
await page.waitForTimeout(300);
|
|
104
|
+
|
|
105
|
+
const docId = await firstDocId(page);
|
|
106
|
+
await makeSubscribed(page, docId);
|
|
107
|
+
|
|
108
|
+
// confirm subscribed - title should be disabled
|
|
109
|
+
await expect(page.getByTestId("input-playlist-title")).toBeDisabled({
|
|
110
|
+
timeout: 5000,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// open edit panel and fork
|
|
114
|
+
await page.getByTestId("btn-edit-playlist").click();
|
|
115
|
+
await page.getByTestId("edit-panel").waitFor({ timeout: 5000 });
|
|
116
|
+
await page.getByTestId("btn-fork-playlist").click();
|
|
117
|
+
|
|
118
|
+
// after fork: a new local playlist is selected.
|
|
119
|
+
// the subscribed banner (with fork button) should disappear.
|
|
120
|
+
await expect(page.getByTestId("btn-fork-playlist")).not.toBeVisible({
|
|
121
|
+
timeout: 8000,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// close the edit panel, then verify title is enabled on the forked playlist
|
|
125
|
+
await page.keyboard.press("Escape");
|
|
126
|
+
await expect(page.getByTestId("input-playlist-title")).toBeEnabled({
|
|
127
|
+
timeout: 5000,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// open all-playlists - the original (now subscribed/forked) should appear in the list
|
|
131
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
132
|
+
await page.getByTestId("all-playlists-panel").waitFor({ timeout: 5000 });
|
|
133
|
+
await expect(
|
|
134
|
+
page.getByTestId("all-playlists-panel").getByText(firstTitle)
|
|
135
|
+
).toBeVisible();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("forked playlist is editable (title input enabled)", async ({ page }) => {
|
|
139
|
+
await createPlaylistViaUI(page);
|
|
140
|
+
const docId = await firstDocId(page);
|
|
141
|
+
await makeSubscribed(page, docId);
|
|
142
|
+
|
|
143
|
+
await expect(page.getByTestId("input-playlist-title")).toBeDisabled({
|
|
144
|
+
timeout: 5000,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// fork
|
|
148
|
+
await page.getByTestId("btn-edit-playlist").click();
|
|
149
|
+
await page.getByTestId("edit-panel").waitFor({ timeout: 5000 });
|
|
150
|
+
await page.getByTestId("btn-fork-playlist").click();
|
|
151
|
+
// fork banner disappears once the new local playlist is selected
|
|
152
|
+
await expect(page.getByTestId("btn-fork-playlist")).not.toBeVisible({
|
|
153
|
+
timeout: 8000,
|
|
154
|
+
});
|
|
155
|
+
await page.keyboard.press("Escape");
|
|
156
|
+
|
|
157
|
+
// fork is now selected - title should be editable
|
|
158
|
+
await expect(page.getByTestId("input-playlist-title")).toBeEnabled({
|
|
159
|
+
timeout: 5000,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// actually type in it to confirm
|
|
163
|
+
await page.getByTestId("input-playlist-title").click({ clickCount: 3 });
|
|
164
|
+
await page.getByTestId("input-playlist-title").fill("my-fork");
|
|
165
|
+
await page.getByTestId("input-playlist-title").blur();
|
|
166
|
+
await page.waitForTimeout(300);
|
|
167
|
+
await expect(page.getByTestId("input-playlist-title")).toHaveValue("my-fork");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// --- edit/share buttons close all-playlists panel ---
|
|
171
|
+
|
|
172
|
+
test("clicking edit button while all-playlists is open closes it", async ({
|
|
173
|
+
page,
|
|
174
|
+
}) => {
|
|
175
|
+
await createPlaylistViaUI(page);
|
|
176
|
+
|
|
177
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
178
|
+
await expect(page.getByTestId("all-playlists-panel")).toBeVisible();
|
|
179
|
+
|
|
180
|
+
await page.getByTestId("btn-edit-playlist").click();
|
|
181
|
+
|
|
182
|
+
await expect(page.getByTestId("all-playlists-panel")).not.toBeVisible();
|
|
183
|
+
await expect(page.getByTestId("edit-panel")).toBeVisible();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("clicking share button while all-playlists is open closes it", async ({
|
|
187
|
+
page,
|
|
188
|
+
}) => {
|
|
189
|
+
await createPlaylistViaUI(page);
|
|
190
|
+
|
|
191
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
192
|
+
await expect(page.getByTestId("all-playlists-panel")).toBeVisible();
|
|
193
|
+
|
|
194
|
+
await page.getByTestId("btn-share-playlist").click();
|
|
195
|
+
|
|
196
|
+
await expect(page.getByTestId("all-playlists-panel")).not.toBeVisible();
|
|
197
|
+
await expect(page.getByTestId("share-panel")).toBeVisible();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// --- remoteName display in playlist rows ---
|
|
201
|
+
|
|
202
|
+
test("playlist row shows remoteName when set", async ({ page }) => {
|
|
203
|
+
// create the subscribed playlist first
|
|
204
|
+
await createPlaylistViaUI(page);
|
|
205
|
+
const docId = await firstDocId(page);
|
|
206
|
+
await makeSubscribed(page, docId, { remoteName: "peer-frank" });
|
|
207
|
+
|
|
208
|
+
// create a second playlist so the first appears in the all-playlists panel
|
|
209
|
+
// (the selected playlist is shown in the header, not in the panel list)
|
|
210
|
+
await createPlaylistViaUI(page);
|
|
211
|
+
|
|
212
|
+
await page.getByTestId("btn-all-playlists").click();
|
|
213
|
+
await page.getByTestId("all-playlists-panel").waitFor({ timeout: 5000 });
|
|
214
|
+
|
|
215
|
+
await expect(page.getByTestId("all-playlists-panel")).toContainText(
|
|
216
|
+
"peer-frank"
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// --- per-playlist sharing mode ---
|
|
221
|
+
|
|
222
|
+
test("mode buttons in share panel are visible", async ({ page }) => {
|
|
223
|
+
await createPlaylistViaUI(page);
|
|
224
|
+
await page.getByTestId("btn-share-playlist").click();
|
|
225
|
+
await page.getByTestId("share-panel").waitFor({ timeout: 5000 });
|
|
226
|
+
|
|
227
|
+
await expect(page.getByTestId("btn-mode-public")).toBeVisible();
|
|
228
|
+
await expect(page.getByTestId("btn-mode-knock")).toBeVisible();
|
|
229
|
+
});
|