diff --git a/.github/workflows/ci-web.yml b/.github/workflows/ci-web.yml
index c6c9f6bb..c59cd8c5 100644
--- a/.github/workflows/ci-web.yml
+++ b/.github/workflows/ci-web.yml
@@ -16,11 +16,13 @@ on:
branches: [main]
paths:
- "webapp/**"
+ - "scripts/check-webapp-layout.sh"
- ".github/workflows/ci-web.yml"
pull_request:
branches: [main]
paths:
- "webapp/**"
+ - "scripts/check-webapp-layout.sh"
- ".github/workflows/ci-web.yml"
permissions:
@@ -54,6 +56,15 @@ jobs:
# codeql).
persist-credentials: false
+ # The shape the restructuring gave the client: no code file past 600
+ # lines, no stylesheet past 800, no test, spec or fixture past 1,000.
+ # The limits and any allowed exception live in the script, with why.
+ # It reads the tree from the repository root, so it steps out of the
+ # job's default `webapp/`.
+ - name: Webapp layout
+ working-directory: .
+ run: sh scripts/check-webapp-layout.sh
+
- name: Install Bun
# Pinned to the v2.2.0 commit SHA. `pull_request` runs with a
# read-only token but CodeQL's actions/unpinned-tag rule
@@ -77,6 +88,12 @@ jobs:
- name: Build
run: bun run build
+ # The build's check reads `tsconfig.json`, the browser code alone, with
+ # no Node types. The tool configs (`tsconfig.node.json`) and the
+ # browser tests (`e2e/tsconfig.json`) run under Node and have their own.
+ - name: Typecheck tool configs and end-to-end tests
+ run: bun run typecheck:node && bun run typecheck:e2e
+
# Covers the redirect guards that keep the consent screen and the
# post-login hop from navigating to attacker-supplied targets.
- name: Test
diff --git a/CLAUDE.md b/CLAUDE.md
index 5f28955b..a5bfa7bf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -31,7 +31,15 @@ Layout:
- `remote/` — everything that talks to another host: the outbound client (`outbound`), `enrichment/`, `portrait`, `lrclib`, `podcast` (the feed fetcher) and `scrobblers/`.
- `http/` — HTTP outside the two API surfaces: `security_headers`, `public_share`, `reference` (the `/reference` page) and `webui`.
- `media/` — streaming, transcoding, artwork, canvases and `replay_gain`; `quality/` — the audio analysis.
-- `webapp/` — the embedded React client, built to `webapp/dist` and compiled in by `rust_embed`. Under `webapp/src/`, only `main.tsx` (the bootstrap: the stylesheets in cascade order, the providers, `createRouter` and the render) sits at the root; everything else is filed by what it is: `app/` (the shell, the rail and its More sheet, and the routes — one file per area under `app/routes/`, assembled in matching order by `app/route-tree.ts`), `api/` (the HTTP client, one file per resource over the session and transport in `client.ts`, re-exported by `index.ts` so the rest of the app imports `../api`; then the query keys and the query client), `pages/` (one screen per file), `components/` (pieces shared between screens), `player/`, `state/` (contexts: i18n, preferences, library scope), `lib/` (logic with no React in it) and `styles/` (the sheet and the design tokens). A test sits beside the file it tests.
+- `webapp/` — the embedded React client, built to `webapp/dist` and compiled in by `rust_embed`. Under `webapp/src/`, only `main.tsx` (the bootstrap: the stylesheets in cascade order, the providers, `createRouter` and the render) sits at the root; everything else is filed by what it is:
+ - `app/` — the shell, the rail and its More sheet, and the routes: one file per area under `app/routes/` (`root`, `library`, `listen`, `settings`, `maintenance`), assembled in matching order by `app/route-tree.ts`.
+ - `pages/` — one screen per file (a large one is a directory with an `index.tsx`), filed under the area of the route file that mounts it: `pages/root/` (sign-in, `/authorize`, a share link, offline, not found), `pages/library/`, `pages/listen/`, `pages/settings/` (administration included, since it is a section of Settings) and `pages/maintenance/`. `app/routes/.tsx` imports its screens from `pages//` and nowhere else.
+ - `components/` — pieces shared between screens, filed by what they serve: `layout/` (the page frame, its header controls, the icons, the theme picker), `catalog/` (the album grid, the song table, the mix shelves, the folder picker, the play-a-set actions), `menus/` (the popup menu and the action and track menus built on it), `track/` (Properties and its sections), `artist/` (the portrait controls and the credit split), `artwork/` (covers, canvases and the cover's light) and `playlists/`.
+ - `api/` — the HTTP client, one file per resource over the session and transport in `client.ts`, re-exported by `index.ts` so the rest of the app imports the directory; then the query keys (`api/queries/`, one file per domain) and the query client.
+ - `player/`, `lib/` (logic with no React in it) and `state/` (contexts: preferences, library scope, and i18n, whose strings are one file per domain and locale under `state/i18n/en/` and `state/i18n/fr/`, merged by each locale's `index.ts`).
+ - `styles/` — the design tokens (`design-tokens/`) and three stylesheets, `styles.css`, `fluid.css` and `refresh.css`, each an ordered list of `@import`s of its parts in the directory of the same name. The order of the imports is the cascade, so it is kept; a new rule goes into the part it belongs to.
+
+ A unit test sits beside the file it tests, as `*.test.ts` or `*.test.tsx`. The Playwright suite under `webapp/e2e/` is one spec per subject over a shared mock server in `e2e/fixtures/` (its state, its catalogue and its `/api/v2`), linted by biome like `src/`. Three tsconfigs split the type-check by where the code runs: `tsconfig.json` is the browser code under `src/`, with the DOM and `vite/client` types and no Node ones, so `Buffer` or `process` there is an error; `tsconfig.node.json` is `vite.config.ts` and `vitest.config.ts`; `e2e/tsconfig.json` is the Playwright suite and its config, with Node types. `bun run typecheck` runs all three. CI holds every file to a size (`scripts/check-webapp-layout.sh`): 600 lines for a `.ts`/`.tsx` file under `src/`, 800 for a stylesheet, 1,000 for a unit test and for anything under `e2e/`. Past that, split along the file's seams; an exception is listed in the script with its reason.
The desktop app at [`InstaZDLL/WaveFlow`](https://github.com/InstaZDLL/WaveFlow) is a separate repository. It consumes this API but is not part of this codebase.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 87cea9ee..f34c3134 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -47,6 +47,8 @@ Tests follow two rules, and CI checks both with `scripts/check-rust-layout.sh`:
- **Unit tests sit beside their module, never inline.** A module with tests is a directory: `foo/mod.rs` declares `#[cfg(test)] mod tests;` and the tests live in `foo/tests.rs`. Integration tests live under `tests/`, one `[[test]]` target per subject in `Cargo.toml`; a large target is a directory whose `main.rs` declares one file per subject.
- **Files stay small.** A source file under `src/` stays within 800 lines; a `tests.rs`, or any file under `tests/`, within 1,000. Past that, split the file along its seams into a directory.
+The web client under `webapp/src/` is filed the same way, by domain: `app/` holds the shell and the routes, one file per area under `app/routes/`; a screen goes under `pages//`, beside the others its route file mounts; a piece shared between screens goes under `components/`, in the folder of what it serves (`layout/`, `catalog/`, `menus/`, `track/`, `artist/`, `artwork/`, `playlists/`); then `api/`, `player/`, `state/`, `lib/` and `styles/`. A unit test sits beside the file it tests, as `foo.test.ts` or `foo.test.tsx`. The Playwright suite under `webapp/e2e/` is one spec per subject, over the shared mock server in `e2e/fixtures/`. In `webapp/`, `bun run lint`, `bun run typecheck` (the client, the tool configs and the end-to-end tests, each under its own tsconfig), `bun run test` and `bun run build` cover what CI checks before the browser; `bun run test:e2e` runs the Playwright suite, which CI runs on every pull request too. CI also checks the client's shape with `scripts/check-webapp-layout.sh`: a `.ts`/`.tsx` file under `src/` stays within 600 lines, a stylesheet within 800, a unit test or any file under `e2e/` within 1,000.
+
CI also builds the docs with `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features`, so an intra-doc link that resolves nowhere fails the build. Write a link to an item in another module as a full path, ``[`Item`](crate::module::Item)``, rather than importing the item for the link's sake.
## Commit conventions
diff --git a/scripts/check-webapp-layout.sh b/scripts/check-webapp-layout.sh
new file mode 100755
index 00000000..1d0b8c48
--- /dev/null
+++ b/scripts/check-webapp-layout.sh
@@ -0,0 +1,88 @@
+#!/bin/sh
+# Holds the web client to the shape the restructuring gave it.
+#
+# - A code file under `webapp/src/` (`.ts` or `.tsx`, not a test) stays under
+# 600 lines. Past that it holds more than one responsibility; split it along
+# its seams, a module directory with an `index` if others import it.
+# - A stylesheet under `webapp/src/` stays under 800 lines. The entry sheets
+# are ordered lists of `@import`; a part that grows past the limit becomes
+# two parts, in cascade order.
+# - A unit test under `webapp/src/` (`*.test.ts` or `*.test.tsx`, beside the
+# file it tests) stays under 1,000 lines, as does any file under
+# `webapp/e2e/`, specs and fixtures alike.
+#
+# Run from the repository root: `sh scripts/check-webapp-layout.sh`. POSIX sh
+# and coreutils only, so it runs the same on a runner and on a laptop.
+
+set -eu
+
+CODE_LIMIT=600
+STYLE_LIMIT=800
+TEST_LIMIT=1000
+
+# Files allowed past their limit, one path per line, each with its reason.
+#
+# webapp/e2e/smart-playlists-and-podcasts.spec.ts: 965 lines until biome first
+# formatted `e2e/`, 1,102 after, by layout alone. Its two subjects
+# share one mock whose state is module-level `let`s the tests reassign, and a
+# binding imported from another module cannot be reassigned; splitting it
+# means moving that state onto an object, as `e2e/fixtures/state.ts` does for
+# the rest, and rewriting every read and write of it. That is a change to the
+# tests, owed its own pull request, not a formatting pass; remove this entry
+# with it.
+ALLOWLIST="
+webapp/e2e/smart-playlists-and-podcasts.spec.ts
+"
+
+failed=0
+
+allowed() {
+ case "$ALLOWLIST" in
+ *"
+$1
+"*) return 0 ;;
+ esac
+ return 1
+}
+
+report() {
+ # `::error` is a GitHub annotation; elsewhere it reads as a plain line.
+ echo "::error file=$1::$2"
+ failed=1
+}
+
+check_size() {
+ file=$1
+ limit=$2
+ lines=$(wc -l < "$file" | tr -d ' ')
+ if [ "$lines" -gt "$limit" ] && ! allowed "$file"; then
+ report "$file" "$file has $lines lines; the limit is $limit."
+ fi
+}
+
+# One path per line, read whole: a `for` over `$(find)` would split a name
+# with a space in it into pieces that name no file, and the file itself would
+# go unmeasured. Fed by a here-document rather than a pipe, so the loop runs
+# in this shell and what it sets in `failed` survives it.
+while IFS= read -r file; do
+ [ -n "$file" ] || continue
+ case "$file" in
+ *.test.ts | *.test.tsx) check_size "$file" "$TEST_LIMIT" ;;
+ *.css) check_size "$file" "$STYLE_LIMIT" ;;
+ *) check_size "$file" "$CODE_LIMIT" ;;
+ esac
+done <=22.12.0", "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ=="],
diff --git a/webapp/e2e/admin.spec.ts b/webapp/e2e/admin.spec.ts
index f919475d..63d7892e 100644
--- a/webapp/e2e/admin.spec.ts
+++ b/webapp/e2e/admin.spec.ts
@@ -77,9 +77,7 @@ test("says a token list failed instead of calling it empty", async ({
}) => {
mock.tokensFail = true;
await page.goto("/admin");
- await page
- .getByRole("button", { name: "API tokens for listener" })
- .click();
+ await page.getByRole("button", { name: "API tokens for listener" }).click();
// Scoped to the panel: the player bar raises an alert of its own here, its
// stream being mocked away.
@@ -179,9 +177,9 @@ test("lists who may see a library, once its panel is opened", async ({
await expect(
page.getByRole("combobox", { name: "Role: listener" }),
).toHaveCount(0);
- await expect(
- page.getByRole("combobox", { name: "Role: guest" }),
- ).toHaveValue("listener");
+ await expect(page.getByRole("combobox", { name: "Role: guest" })).toHaveValue(
+ "listener",
+ );
});
/**
diff --git a/webapp/e2e/artist.spec.ts b/webapp/e2e/artist.spec.ts
index 11a4cf0f..8449b76e 100644
--- a/webapp/e2e/artist.spec.ts
+++ b/webapp/e2e/artist.spec.ts
@@ -43,7 +43,9 @@ test("an artist page shows this account's most played, and no heading without a
// A screenful, not the discography.
expect(mock.topSongAsks).toEqual(["10"]);
// And the albums are still what the page is.
- await expect(page.getByRole("link", { name: "Open the album: Vespertine" })).toBeVisible();
+ await expect(
+ page.getByRole("link", { name: "Open the album: Vespertine" }),
+ ).toBeVisible();
await expectNoWcagViolations(page);
@@ -53,7 +55,9 @@ test("an artist page shows this account's most played, and no heading without a
await expect(
page.getByRole("heading", { name: "Björk", level: 2 }),
).toBeVisible();
- await expect(page.getByRole("link", { name: "Open the album: Vespertine" })).toBeVisible();
+ await expect(
+ page.getByRole("link", { name: "Open the album: Vespertine" }),
+ ).toBeVisible();
await expect(
page.getByRole("heading", { name: "Most played here" }),
).toHaveCount(0);
@@ -112,7 +116,9 @@ test("an artist page shows what the server learned, and where each part came fro
await expect(page.getByRole("heading", { name: "About" })).toBeVisible({
timeout: 10_000,
});
- await expect(page.getByText("An Icelandic singer and songwriter.").first()).toBeVisible();
+ await expect(
+ page.getByText("An Icelandic singer and songwriter.").first(),
+ ).toBeVisible();
await expect(page.getByText(/^Biography from Last\.fm, /)).toBeVisible();
await expect(page.getByText(/^Portrait from Deezer, /)).toBeVisible();
await expect(page.locator(".artist-hero img.artist-portrait")).toBeVisible();
@@ -131,9 +137,16 @@ test("an artist page shows what the server learned, and where each part came fro
test("an artist nobody knows shows no enrichment and is not asked again", async ({
page,
}) => {
- mock.enrichmentOf = () => ({ state: "none", bio: null, image: null, similar: null });
+ mock.enrichmentOf = () => ({
+ state: "none",
+ bio: null,
+ image: null,
+ similar: null,
+ });
await page.goto("/artists/artist-1");
- await expect(page.getByRole("link", { name: "Open the album: Vespertine" })).toBeVisible();
+ await expect(
+ page.getByRole("link", { name: "Open the album: Vespertine" }),
+ ).toBeVisible();
await page.waitForTimeout(4_000);
await expect(page.getByRole("heading", { name: "About" })).toHaveCount(0);
await expect(page.getByText("Looking this artist up")).toHaveCount(0);
@@ -150,9 +163,16 @@ test("a pending artist whose server keeps failing is asked a bounded number of t
}) => {
test.setTimeout(45_000);
mock.artistFailsAfterFirst = true;
- mock.enrichmentOf = () => ({ state: "pending", bio: null, image: null, similar: null });
+ mock.enrichmentOf = () => ({
+ state: "pending",
+ bio: null,
+ image: null,
+ similar: null,
+ });
await page.goto("/artists/artist-1");
- await expect(page.getByRole("heading", { name: "Björk", level: 2 })).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Björk", level: 2 }),
+ ).toBeVisible();
await expect.poll(() => mock.artistAsks, { timeout: 30_000 }).toBe(6);
await page.waitForTimeout(5_000);
expect(mock.artistAsks).toBe(6);
@@ -172,9 +192,15 @@ test("changes an artist's banner and portrait from the artist page", async ({
const hero = page.locator(".artist-hero");
// The actions sit behind one button in the header's corner.
let menu = await openImageMenu(page);
- await expect(menu.getByRole("menuitem", { name: "Change portrait" })).toBeVisible();
- await expect(menu.getByRole("menuitem", { name: "Change banner" })).toBeVisible();
- await expect(menu.getByRole("menuitem", { name: "No banner" })).toHaveCount(0);
+ await expect(
+ menu.getByRole("menuitem", { name: "Change portrait" }),
+ ).toBeVisible();
+ await expect(
+ menu.getByRole("menuitem", { name: "Change banner" }),
+ ).toBeVisible();
+ await expect(menu.getByRole("menuitem", { name: "No banner" })).toHaveCount(
+ 0,
+ );
await page.keyboard.press("Escape");
await expect(menu).toHaveCount(0);
// With no banner, the portrait is the backdrop, blurred.
@@ -188,7 +214,9 @@ test("changes an artist's banner and portrait from the artist page", async ({
await expect(hero.getByRole("status")).toHaveText("Banner changed.");
await expect(hero.locator("img.artist-banner")).toBeVisible();
await expect(hero.locator(".artist-hero-ambient")).toHaveCount(0);
- expect(state.writes).toEqual([{ method: "PUT", kind: "banner", bytes: png.length }]);
+ expect(state.writes).toEqual([
+ { method: "PUT", kind: "banner", bytes: png.length },
+ ]);
await expectNoWcagViolations(page);
await hero.getByLabel("Change portrait").setInputFiles({
@@ -206,7 +234,9 @@ test("changes an artist's banner and portrait from the artist page", async ({
mimeType: "image/png",
buffer: Buffer.from("not an image"),
});
- await expect(hero.getByRole("alert")).toHaveText("Choose a JPEG, PNG or WebP image.");
+ await expect(hero.getByRole("alert")).toHaveText(
+ "Choose a JPEG, PNG or WebP image.",
+ );
// Back to automatic: no banner, and the fetched portrait again.
menu = await openImageMenu(page);
@@ -215,7 +245,9 @@ test("changes an artist's banner and portrait from the artist page", async ({
await expect(hero.locator("img.artist-banner")).toHaveCount(0);
menu = await openImageMenu(page);
await menu.getByRole("menuitem", { name: "Automatic portrait" }).click();
- await expect(hero.getByRole("status")).toHaveText("Back to the automatic portrait.");
+ await expect(hero.getByRole("status")).toHaveText(
+ "Back to the automatic portrait.",
+ );
await expect(hero.getByText("Portrait chosen by hand")).toHaveCount(0);
expect(state.writes.slice(-2)).toEqual([
{ method: "DELETE", kind: "banner", bytes: 0 },
@@ -227,16 +259,22 @@ test("changes an artist's banner and portrait from the artist page", async ({
test("shows a listener the artist's banner with nothing to change", async ({
page,
}) => {
- mock.libraries = [{ ...library("library-1", "Ma musique"), role: "listener" }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), role: "listener" },
+ ];
const { state } = await serveArtistImages(page);
state.banner = "c".repeat(64);
await page.goto("/artists/artist-1");
const hero = page.locator(".artist-hero");
await expect(hero.locator("img.artist-banner")).toBeVisible();
// Not even the button the actions sit behind.
- await expect(hero.getByRole("button", { name: "Edit images" })).toHaveCount(0);
+ await expect(hero.getByRole("button", { name: "Edit images" })).toHaveCount(
+ 0,
+ );
await expect(hero.getByRole("button", { name: /Change/ })).toHaveCount(0);
- await expect(hero.getByRole("button", { name: "Search Deezer" })).toHaveCount(0);
+ await expect(hero.getByRole("button", { name: "Search Deezer" })).toHaveCount(
+ 0,
+ );
});
/**
@@ -312,45 +350,67 @@ test("chooses an artist's portrait from a Deezer search", async ({ page }) => {
const thirdParty: string[] = [];
let refuseNext = false;
page.on("request", (request) => {
- if (/deezer\.com|dzcdn\.net/.test(request.url())) thirdParty.push(request.url());
- });
- await page.route("**/api/v2/artists/artist-1/image/candidates?*", async (route) => {
- const query = new URL(route.request().url()).searchParams.get("q") ?? "";
- searches.push(query);
- if (refuseNext) {
- refuseNext = false;
- await route.fulfill({ status: 429, json: { code: "too_many_requests" } });
- return;
- }
- await route.fulfill({
- json:
- query === "Nobody"
- ? []
- : [
- { deezer_id: 76, name: "Björk", fans: 1500000, artwork_hash: "d".repeat(64) },
- { deezer_id: 77, name: "Björk Tribute", fans: 1, artwork_hash: "e".repeat(64) },
- ],
- });
- });
- await page.route("**/api/v2/artists/artist-1/image/portrait/deezer", async (route) => {
- picks.push(route.request().postDataJSON());
- state.portrait = "d".repeat(64);
- await route.fulfill({
- json: {
- ...artistDetail,
- library_id: "library-1",
- artwork_hash: state.portrait,
- image_choice: { portrait: { source: "deezer", set_at: 1 } },
- },
- });
+ if (/deezer\.com|dzcdn\.net/.test(request.url()))
+ thirdParty.push(request.url());
});
+ await page.route(
+ "**/api/v2/artists/artist-1/image/candidates?*",
+ async (route) => {
+ const query = new URL(route.request().url()).searchParams.get("q") ?? "";
+ searches.push(query);
+ if (refuseNext) {
+ refuseNext = false;
+ await route.fulfill({
+ status: 429,
+ json: { code: "too_many_requests" },
+ });
+ return;
+ }
+ await route.fulfill({
+ json:
+ query === "Nobody"
+ ? []
+ : [
+ {
+ deezer_id: 76,
+ name: "Björk",
+ fans: 1500000,
+ artwork_hash: "d".repeat(64),
+ },
+ {
+ deezer_id: 77,
+ name: "Björk Tribute",
+ fans: 1,
+ artwork_hash: "e".repeat(64),
+ },
+ ],
+ });
+ },
+ );
+ await page.route(
+ "**/api/v2/artists/artist-1/image/portrait/deezer",
+ async (route) => {
+ picks.push(route.request().postDataJSON());
+ state.portrait = "d".repeat(64);
+ await route.fulfill({
+ json: {
+ ...artistDetail,
+ library_id: "library-1",
+ artwork_hash: state.portrait,
+ image_choice: { portrait: { source: "deezer", set_at: 1 } },
+ },
+ });
+ },
+ );
await page.goto("/artists/artist-1");
const hero = page.locator(".artist-hero");
await (await openImageMenu(page))
.getByRole("menuitem", { name: "Search Deezer" })
.click();
- const picker = page.getByRole("dialog", { name: "Choose a portrait from Deezer" });
+ const picker = page.getByRole("dialog", {
+ name: "Choose a portrait from Deezer",
+ });
await expect(picker).toBeVisible();
const results = picker.getByRole("list", { name: "Deezer artists" });
await expect(results.getByRole("button")).toHaveCount(2);
@@ -389,7 +449,12 @@ test("keeps the artist hero and the Deezer picker readable in the light", async
await page.route("**/api/v2/artists/artist-1/image/candidates?*", (route) =>
route.fulfill({
json: [
- { deezer_id: 76, name: "Björk", fans: 1500000, artwork_hash: "d".repeat(64) },
+ {
+ deezer_id: 76,
+ name: "Björk",
+ fans: 1500000,
+ artwork_hash: "d".repeat(64),
+ },
],
}),
);
@@ -404,7 +469,9 @@ test("keeps the artist hero and the Deezer picker readable in the light", async
await (await openImageMenu(page))
.getByRole("menuitem", { name: "Search Deezer" })
.click();
- const picker = page.getByRole("dialog", { name: "Choose a portrait from Deezer" });
+ const picker = page.getByRole("dialog", {
+ name: "Choose a portrait from Deezer",
+ });
await expect(picker.getByText("1500000 fans")).toBeVisible();
await expectNoWcagViolations(page);
await picker.getByRole("button", { name: "Close" }).click();
@@ -454,7 +521,9 @@ for (const { theme, banner, portrait } of [
).toBeVisible();
await page.evaluate(() =>
Promise.all(
- document.getAnimations().map((animation) => animation.finished.catch(() => undefined)),
+ document
+ .getAnimations()
+ .map((animation) => animation.finished.catch(() => undefined)),
),
);
@@ -470,18 +539,25 @@ for (const { theme, banner, portrait } of [
}
// And no other source line beside it, left unmeasured.
await expect(hero.locator(".artist-source")).toHaveCount(1);
- expect(await lowestContrast(page, name), "the name").toBeGreaterThanOrEqual(3);
+ expect(await lowestContrast(page, name), "the name").toBeGreaterThanOrEqual(
+ 3,
+ );
for (const [label, text] of [
["the kicker", kicker],
["the count", count],
["the source", source],
] as const) {
- expect(await lowestContrast(page, text), label).toBeGreaterThanOrEqual(4.5);
+ expect(await lowestContrast(page, text), label).toBeGreaterThanOrEqual(
+ 4.5,
+ );
}
// Every button over the picture is measured. The image actions sit
// behind one menu button now, which is required, so the loop below
// cannot pass over an empty list.
- const trigger = hero.getByRole("button", { name: "Edit images", exact: true });
+ const trigger = hero.getByRole("button", {
+ name: "Edit images",
+ exact: true,
+ });
await expect(trigger).toHaveCount(1);
await expect(trigger).toBeVisible();
const overPicture = await hero.locator(".artist-image-button").all();
@@ -490,6 +566,7 @@ for (const { theme, banner, portrait } of [
if (!(await button.isVisible())) continue;
expect(
await lowestContrast(page, button, true),
+ // biome-ignore lint/performance/useDomNodeTextContent: a Playwright locator, not a DOM node.
`the button “${await button.innerText()}”`,
).toBeGreaterThanOrEqual(4.5);
}
@@ -507,6 +584,7 @@ for (const { theme, banner, portrait } of [
for (const entry of await menu.getByRole("menuitem").all()) {
expect(
await lowestContrast(page, entry.locator("span")),
+ // biome-ignore lint/performance/useDomNodeTextContent: a Playwright locator, not a DOM node.
`the menu entry “${await entry.innerText()}”`,
).toBeGreaterThanOrEqual(4.5);
}
@@ -523,7 +601,12 @@ test("an artist page files its records by type, and lists what it appears on", a
}) => {
mock.artistExtra = {
albums: [
- { ...albums[1], id: "album-3", title: "Vespertine Live", release_group: "live" },
+ {
+ ...albums[1],
+ id: "album-3",
+ title: "Vespertine Live",
+ release_group: "live",
+ },
{ ...albums[0], release_group: "album" },
{ ...albums[1], release_group: "album" },
],
@@ -548,7 +631,9 @@ test("an artist page files its records by type, and lists what it appears on", a
"Appears on",
]);
const appearances = page.getByRole("region", { name: "Appears on" });
- await expect(appearances.getByText("Guest Spot", { exact: true })).toBeVisible();
+ await expect(
+ appearances.getByText("Guest Spot", { exact: true }),
+ ).toBeVisible();
await expect(appearances.getByText("Someone Else")).toBeVisible();
// Filed under its own section, and nowhere else.
await expect(
@@ -570,6 +655,8 @@ test("an artist page from a server without release groups keeps one section", as
await page.goto("/artists/artist-1");
await expect(page.locator(".artist-discography h3")).toHaveText(["Albums"]);
await expect(
- page.getByRole("region", { name: "Albums" }).getByText("Vespertine", { exact: true }),
+ page
+ .getByRole("region", { name: "Albums" })
+ .getByText("Vespertine", { exact: true }),
).toBeVisible();
});
diff --git a/webapp/e2e/attention.spec.ts b/webapp/e2e/attention.spec.ts
index 4581473c..66d03289 100644
--- a/webapp/e2e/attention.spec.ts
+++ b/webapp/e2e/attention.spec.ts
@@ -128,7 +128,9 @@ test("reloads the credits when a decision is refused", async ({ page }) => {
test("asks nothing of the server for a listener's library", async ({
page,
}) => {
- mock.libraries = [{ ...library("library-1", "Ma musique"), role: "listener" }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), role: "listener" },
+ ];
const state = await serveAttention(page);
await page.goto("/attention");
await expect(
@@ -177,7 +179,9 @@ test("splits a joined artist from its page, after a confirming click", async ({
]);
// A listener is offered nothing.
- mock.libraries = [{ ...library("library-1", "Ma musique"), role: "listener" }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), role: "listener" },
+ ];
await page.goto("/artists/artist-joined");
await expect(
page.getByRole("heading", { name: "Ashreveal, Ashish Swargiary" }),
@@ -191,7 +195,9 @@ test("keeps Needs attention readable in the light", async ({ page }) => {
await serveAttention(page);
await chooseLightTheme(page);
await page.goto("/attention");
- await expect(page.getByText("Ashreveal, Ashish Swargiary").first()).toBeVisible();
+ await expect(
+ page.getByText("Ashreveal, Ashish Swargiary").first(),
+ ).toBeVisible();
await expectNoWcagViolations(page);
});
@@ -215,7 +221,9 @@ for (const theme of [
}, theme);
await page.goto("/attention");
await expect(page.locator("html")).toHaveAttribute("data-theme", theme);
- await expect(page.getByText("Ashreveal, Ashish Swargiary").first()).toBeVisible();
+ await expect(
+ page.getByText("Ashreveal, Ashish Swargiary").first(),
+ ).toBeVisible();
await expectNoWcagViolations(page);
});
}
diff --git a/webapp/e2e/canvas.spec.ts b/webapp/e2e/canvas.spec.ts
index 346caccd..16d5f75b 100644
--- a/webapp/e2e/canvas.spec.ts
+++ b/webapp/e2e/canvas.spec.ts
@@ -15,7 +15,9 @@ test.beforeEach(resetAndMockApi);
* and taken away.
*/
test("places, replaces and removes a track's canvas", async ({ page }) => {
- mock.libraries = [{ ...library("library-1", "Ma musique"), accepts_canvas: true }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), accepts_canvas: true },
+ ];
await page.goto("/tracks/song-9/edit");
await expect(page.getByText("This track has no canvas.")).toBeVisible();
@@ -107,9 +109,9 @@ test("asks once whether a track carries a canvas, not once per visit", async ({
await page.goto("/playing");
await expect(page.locator(".canvas-stage .cover")).toBeVisible();
- await expect.poll(() => tickets).toEqual([
- "/api/v2/tracks/song-1/canvas-ticket",
- ]);
+ await expect
+ .poll(() => tickets)
+ .toEqual(["/api/v2/tracks/song-1/canvas-ticket"]);
for (const _visit of [1, 2]) {
await page.getByRole("link", { name: "Albums" }).click();
diff --git a/webapp/e2e/editorial-pages.spec.ts b/webapp/e2e/editorial-pages.spec.ts
index 33e5932c..d1d6fc49 100644
--- a/webapp/e2e/editorial-pages.spec.ts
+++ b/webapp/e2e/editorial-pages.spec.ts
@@ -177,7 +177,8 @@ async function mockApi(page: Page) {
return answer({ enabled: false, can_manage: false, channels: [] });
}
if (path === "/api/v2/albums") return answer(albums);
- if (path === "/api/v2/albums/album-2") return answer({ ...albums[1], songs });
+ if (path === "/api/v2/albums/album-2")
+ return answer({ ...albums[1], songs });
if (path === "/api/v2/mixes") return answer(mixes);
if (path === "/api/v2/artists/artist-1/top-songs") return answer([]);
if (path === "/api/v2/artists/artist-1") {
@@ -246,7 +247,12 @@ async function boxes(page: Page, first: string, second: string) {
[a, b].map((selector) => {
const box = document.querySelector(selector)?.getBoundingClientRect();
return box
- ? { left: box.left, right: box.right, top: box.top, bottom: box.bottom }
+ ? {
+ left: box.left,
+ right: box.right,
+ top: box.top,
+ bottom: box.bottom,
+ }
: null;
}),
[first, second] as const,
@@ -346,11 +352,15 @@ test("follows the sung line and seeks from the lyrics column", async ({
* nothing to read — rather than holding half the screen open for words that
* are not there.
*/
-test("keeps one centred column for a track without lyrics", async ({ page }) => {
+test("keeps one centred column for a track without lyrics", async ({
+ page,
+}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/playing");
const playing = page.locator(".playing");
- await expect(page.getByText("No lyrics travel with this file.")).toBeVisible();
+ await expect(
+ page.getByText("No lyrics travel with this file."),
+ ).toBeVisible();
await expect(playing).toHaveClass(/playing-solo/);
await expect(page.locator(".playing-lyrics")).toHaveCount(0);
@@ -430,7 +440,9 @@ test("names no album section when there is no shelf above it", async ({
await page.goto("/");
await expect(page.getByRole("heading", { name: "Albums" })).toBeVisible();
await expect(page.locator(".grid li")).toHaveCount(2);
- await expect(page.getByRole("heading", { name: "In your library" })).toHaveCount(0);
+ await expect(
+ page.getByRole("heading", { name: "In your library" }),
+ ).toHaveCount(0);
});
/**
@@ -444,7 +456,10 @@ test("offers an editor the artist's image actions behind one menu", async ({
await page.goto("/artists/artist-1");
const hero = page.locator(".artist-hero");
await expect(hero.locator("img.artist-banner")).toBeVisible();
- const trigger = hero.getByRole("button", { name: "Edit images", exact: true });
+ const trigger = hero.getByRole("button", {
+ name: "Edit images",
+ exact: true,
+ });
await expect(trigger).toBeVisible();
await expect(trigger).toHaveAttribute("aria-haspopup", "menu");
await expect(trigger).toHaveAttribute("aria-expanded", "false");
@@ -455,7 +470,9 @@ test("offers an editor the artist's image actions behind one menu", async ({
"Automatic portrait",
"Change banner",
]) {
- await expect(hero.getByRole("button", { name, exact: true })).toHaveCount(0);
+ await expect(hero.getByRole("button", { name, exact: true })).toHaveCount(
+ 0,
+ );
}
await expectNoWcagViolations(page);
@@ -497,7 +514,9 @@ test("shows a reader the artist's header with no edit menu", async ({
const hero = page.locator(".artist-hero");
await expect(hero.locator("img.artist-banner")).toBeVisible();
await expect(hero.getByRole("heading", { name: "Björk" })).toBeVisible();
- await expect(hero.getByRole("button", { name: "Edit images" })).toHaveCount(0);
+ await expect(hero.getByRole("button", { name: "Edit images" })).toHaveCount(
+ 0,
+ );
await expect(page.getByRole("menu")).toHaveCount(0);
await expectNoWcagViolations(page);
});
diff --git a/webapp/e2e/fixtures/accessibility.ts b/webapp/e2e/fixtures/accessibility.ts
index 81c11cdd..db3b8f61 100644
--- a/webapp/e2e/fixtures/accessibility.ts
+++ b/webapp/e2e/fixtures/accessibility.ts
@@ -54,10 +54,18 @@ export function chooseLightTheme(page: Page) {
* rounded ends and its ring, which are not behind the text: the corners of
* its box show the picture it sits on.
*/
-export async function lowestContrast(page: Page, target: Locator, pill = false) {
+export async function lowestContrast(
+ page: Page,
+ target: Locator,
+ pill = false,
+) {
const colour = await target.evaluate((element) => {
const { color } = getComputedStyle(element);
- (element as HTMLElement).style.setProperty("color", "transparent", "important");
+ (element as HTMLElement).style.setProperty(
+ "color",
+ "transparent",
+ "important",
+ );
return color;
});
const box = await target.boundingBox();
@@ -93,7 +101,9 @@ export async function lowestContrast(page: Page, target: Locator, pill = false)
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
const luminance = (red: number, green: number, blue: number) =>
- 0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue);
+ 0.2126 * channel(red) +
+ 0.7152 * channel(green) +
+ 0.0722 * channel(blue);
let lowest = Number.POSITIVE_INFINITY;
for (let index = 0; index < data.length; index += 4) {
const [br, bg, bb] = [data[index], data[index + 1], data[index + 2]];
diff --git a/webapp/e2e/fixtures/api.ts b/webapp/e2e/fixtures/api.ts
index d1bdc90f..0acd8e97 100644
--- a/webapp/e2e/fixtures/api.ts
+++ b/webapp/e2e/fixtures/api.ts
@@ -13,16 +13,16 @@ import {
freshDestinations,
freshUncertain,
freshUploadSession,
- genres,
genreSongs,
+ genres,
history,
imageHash,
importReport,
library,
linkState,
type Offer,
- playlists,
PURGE_GRACE_MS,
+ playlists,
type Recipient,
session,
song,
@@ -112,7 +112,10 @@ async function mockAuthenticatedApi(page: Page) {
}
// RFC-021: a library as an administrator manages it, and its missing
// tracks with their purge.
- if (url.pathname.endsWith("/settings") && url.pathname.includes("/libraries/")) {
+ if (
+ url.pathname.endsWith("/settings") &&
+ url.pathname.includes("/libraries/")
+ ) {
const id = url.pathname.split("/")[4];
const found = mock.libraries.find((entry) => entry.id === id);
await route.fulfill({
@@ -137,7 +140,9 @@ async function mockAuthenticatedApi(page: Page) {
const taken = mock.missingTracks.filter(
(entry) => "all" in asked || asked.track_ids.includes(entry.id),
);
- mock.missingTracks = mock.missingTracks.filter((entry) => !taken.includes(entry));
+ mock.missingTracks = mock.missingTracks.filter(
+ (entry) => !taken.includes(entry),
+ );
mock.purgedTracks = [
...mock.purgedTracks,
...taken.map((entry) => ({
@@ -159,7 +164,9 @@ async function mockAuthenticatedApi(page: Page) {
const back = mock.purgedTracks.filter(
(entry) => "all" in asked || asked.track_ids.includes(entry.id),
);
- mock.purgedTracks = mock.purgedTracks.filter((entry) => !back.includes(entry));
+ mock.purgedTracks = mock.purgedTracks.filter(
+ (entry) => !back.includes(entry),
+ );
mock.missingTracks = [
...mock.missingTracks,
...back.map((entry) => ({
@@ -336,7 +343,9 @@ async function mockAuthenticatedApi(page: Page) {
if (url.pathname.startsWith("/api/v2/api-tokens/")) {
const id = url.pathname.split("/").pop();
mock.ownClients = mock.ownClients.map((client) =>
- client.id === id ? { ...client, revoked_at: 1_700_950_000_000 } : client,
+ client.id === id
+ ? { ...client, revoked_at: 1_700_950_000_000 }
+ : client,
);
await route.fulfill({ status: 204, body: "" });
return;
@@ -488,7 +497,10 @@ async function mockAuthenticatedApi(page: Page) {
mock.uploadCommits.push("upload-1");
await route.fulfill({
status: 201,
- json: { track_id: "song-uploaded", full_hash: mock.uploadOffers[0]?.full_hash },
+ json: {
+ track_id: "song-uploaded",
+ full_hash: mock.uploadOffers[0]?.full_hash,
+ },
});
return;
}
@@ -587,7 +599,8 @@ async function mockAuthenticatedApi(page: Page) {
// What the server does with it: a key present replaces its correction,
// null removes it, and a list that is removed goes back to the file.
Object.assign(mock.correctable.tracked.overrides, body);
- if (typeof body.title === "string") mock.correctable.song.title = body.title;
+ if (typeof body.title === "string")
+ mock.correctable.song.title = body.title;
if (body.artists === null) mock.correctable.song.artists = fileArtists;
}
await route.fulfill({ json: mock.correctable.song });
@@ -595,7 +608,9 @@ async function mockAuthenticatedApi(page: Page) {
}
if (url.pathname.startsWith("/api/v2/tracks/")) {
const id = url.pathname.split("/")[4];
- await route.fulfill({ json: { ...song(1, `Track ${id}`, 0, false), id } });
+ await route.fulfill({
+ json: { ...song(1, `Track ${id}`, 0, false), id },
+ });
return;
}
// RFC-024: the statistics and the mixes, as the server shapes them.
@@ -626,7 +641,10 @@ async function mockAuthenticatedApi(page: Page) {
await route.fulfill({
json: {
...found,
- songs: [song(1, "Hidden Place", 0, false), song(2, "Cocoon", 0, false)],
+ songs: [
+ song(1, "Hidden Place", 0, false),
+ song(2, "Cocoon", 0, false),
+ ],
},
});
return;
@@ -742,7 +760,10 @@ async function mockAuthenticatedApi(page: Page) {
await route.fulfill({ json: sorted });
return;
}
- if (url.pathname === "/api/v2/shares" && route.request().method() === "GET") {
+ if (
+ url.pathname === "/api/v2/shares" &&
+ route.request().method() === "GET"
+ ) {
// None yet. Answered so that the Shares section of Settings draws as a
// page rather than as its failure when the hub's tests walk through it.
await route.fulfill({ json: [] });
@@ -772,7 +793,10 @@ export const resetAndMockApi = async ({ page }: { page: Page }) => {
mock.radioWrites = [];
mock.stationImages = [];
mock.radioGate = Promise.resolve();
- mock.artistPlayed = [song(2, "Cocoon", 0, false), song(1, "Hidden Place", 5, true)];
+ mock.artistPlayed = [
+ song(2, "Cocoon", 0, false),
+ song(1, "Hidden Place", 5, true),
+ ];
mock.topSongAsks = [];
mock.artistAsks = 0;
mock.artistExtra = {};
@@ -792,7 +816,10 @@ export const resetAndMockApi = async ({ page }: { page: Page }) => {
mock.clientGate = Promise.resolve();
// An account the operator has granted one, because that is the state the
// panel is about: it rotates and switches off, and never creates.
- mock.ownSubsonicCredential = { created_at: 1_700_000_000_000, updated_at: 1_700_000_000_000 };
+ mock.ownSubsonicCredential = {
+ created_at: 1_700_000_000_000,
+ updated_at: 1_700_000_000_000,
+ };
mock.scanFails = false;
mock.scanDrops = false;
mock.corrections = [];
diff --git a/webapp/e2e/fixtures/artist-images.ts b/webapp/e2e/fixtures/artist-images.ts
index e29a7650..77ce709c 100644
--- a/webapp/e2e/fixtures/artist-images.ts
+++ b/webapp/e2e/fixtures/artist-images.ts
@@ -37,8 +37,12 @@ export async function serveArtistImages(page: Page) {
...(state.portrait || state.banner
? {
image_choice: {
- ...(state.portrait ? { portrait: { source: "upload", set_at: 1 } } : {}),
- ...(state.banner ? { banner: { source: "upload", set_at: 1 } } : {}),
+ ...(state.portrait
+ ? { portrait: { source: "upload", set_at: 1 } }
+ : {}),
+ ...(state.banner
+ ? { banner: { source: "upload", set_at: 1 } }
+ : {}),
},
}
: {}),
diff --git a/webapp/e2e/fixtures/catalogue.ts b/webapp/e2e/fixtures/catalogue.ts
index 1203ed72..b7d03c05 100644
--- a/webapp/e2e/fixtures/catalogue.ts
+++ b/webapp/e2e/fixtures/catalogue.ts
@@ -1,7 +1,5 @@
// The fixed catalogue the mock answers from, and its answer factories.
-
-
export const session = {
access_token: "e2e-access-token",
user: { id: "user-1", username: "listener", role: "admin" },
@@ -320,7 +318,11 @@ export function correctableTrack() {
/** What the file credits: where a removed artists correction goes back to. */
export const fileArtists = [{ id: "artist-1", name: "Björk" }];
-export type Offer = { full_hash: string; size_bytes: number; extension: string };
+export type Offer = {
+ full_hash: string;
+ size_bytes: number;
+ extension: string;
+};
export const freshUploadSession = () => ({
session_id: "upload-1",
@@ -350,8 +352,24 @@ export type Recipient = "listenbrainz" | "maloja" | "lastfm";
*/
const NO_PUBLIC_URL = "browser_journey_needs_https";
-export const freshDestinations = () => [
- { provider: "listenbrainz" as const, destination: "default", available: true },
+/**
+ * A row of the listing. `unavailable` is optional, as on the wire: inferred
+ * from the rows below, it was required on Last.fm's, and a test declaring
+ * Last.fm usable could not leave it out.
+ */
+type Destination = {
+ provider: Recipient;
+ destination: string;
+ available: boolean;
+ unavailable?: string;
+};
+
+export const freshDestinations = (): Destination[] => [
+ {
+ provider: "listenbrainz" as const,
+ destination: "default",
+ available: true,
+ },
{ provider: "maloja" as const, destination: "alice", available: true },
{ provider: "maloja" as const, destination: "bob", available: true },
{
@@ -430,12 +448,22 @@ export function statsFor(period: string) {
],
top_albums: [{ album: albums[1], plays: 4 }],
top_artists: [
- { artist: { id: "artist-1", name: "Björk" }, artwork_hash: null, plays: 4 },
+ {
+ artist: { id: "artist-1", name: "Björk" },
+ artwork_hash: null,
+ plays: 4,
+ },
],
top_genres: [{ name: "Art Pop", plays: 4 }],
timeline,
- first_play: { song: song(2, "Cocoon", 0, false), played_at: 1_790_000_000_000 },
- last_play: { song: song(1, "Hidden Place", 0, false), played_at: 1_790_100_000_000 },
+ first_play: {
+ song: song(2, "Cocoon", 0, false),
+ played_at: 1_790_000_000_000,
+ },
+ last_play: {
+ song: song(1, "Hidden Place", 0, false),
+ played_at: 1_790_100_000_000,
+ },
};
}
diff --git a/webapp/e2e/fixtures/folders.ts b/webapp/e2e/fixtures/folders.ts
index e6883783..31be918d 100644
--- a/webapp/e2e/fixtures/folders.ts
+++ b/webapp/e2e/fixtures/folders.ts
@@ -2,16 +2,23 @@
import type { Page } from "@playwright/test";
-import { song } from "./catalogue";
+import type { song } from "./catalogue";
/**
* A stand-in for the filed-folder routes (RFC-012 decision 2), with state: the
* screen re-reads after every write, and the point is that it shows what the
* write did. Names collide case-insensitively, as on the server.
*/
-export async function serveFiledFolders(page: Page, songs: ReturnType[]) {
+export async function serveFiledFolders(
+ page: Page,
+ songs: ReturnType[],
+) {
const state = {
- folders: [] as Array<{ id: string; parent_id: string | null; name: string }>,
+ folders: [] as Array<{
+ id: string;
+ parent_id: string | null;
+ name: string;
+ }>,
placed: new Map(),
writes: [] as Array<{ method: string; path: string; body: unknown }>,
};
@@ -27,7 +34,10 @@ export async function serveFiledFolders(page: Page, songs: ReturnType {
const request = route.request();
if (request.method() === "POST") {
- const body = request.postDataJSON() as { name: string; parent_id: string | null };
+ const body = request.postDataJSON() as {
+ name: string;
+ parent_id: string | null;
+ };
state.writes.push({ method: "POST", path: "folders", body });
const name = body.name.trim();
if (
@@ -40,35 +50,45 @@ export async function serveFiledFolders(page: Page, songs: ReturnType {
- const folder = new URL(route.request().url()).pathname.split("/").at(-2);
- return route.fulfill({
- json: songs.filter((entry) =>
- folder === "unfiled"
- ? !state.placed.has(entry.id)
- : state.placed.get(entry.id) === folder,
- ),
- });
- });
- await page.route("**/api/v2/libraries/library-1/placements", async (route) => {
- const body = route.request().postDataJSON() as {
- track_ids: string[];
- folder_id: string | null;
- };
- state.writes.push({ method: "PUT", path: "placements", body });
- for (const id of body.track_ids) {
- if (body.folder_id) state.placed.set(id, body.folder_id);
- else state.placed.delete(id);
- }
- await route.fulfill({ json: { placed: body.track_ids.length } });
- });
+ await page.route(
+ "**/api/v2/libraries/library-1/folders/*/tracks?*",
+ (route) => {
+ const folder = new URL(route.request().url()).pathname.split("/").at(-2);
+ return route.fulfill({
+ json: songs.filter((entry) =>
+ folder === "unfiled"
+ ? !state.placed.has(entry.id)
+ : state.placed.get(entry.id) === folder,
+ ),
+ });
+ },
+ );
+ await page.route(
+ "**/api/v2/libraries/library-1/placements",
+ async (route) => {
+ const body = route.request().postDataJSON() as {
+ track_ids: string[];
+ folder_id: string | null;
+ };
+ state.writes.push({ method: "PUT", path: "placements", body });
+ for (const id of body.track_ids) {
+ if (body.folder_id) state.placed.set(id, body.folder_id);
+ else state.placed.delete(id);
+ }
+ await route.fulfill({ json: { placed: body.track_ids.length } });
+ },
+ );
await page.route("**/api/v2/folders/*", async (route) => {
const request = route.request();
const id = new URL(request.url()).pathname.split("/").at(-1) ?? "";
@@ -89,14 +109,20 @@ export async function serveFiledFolders(page: Page, songs: ReturnType
folder.id === id
? {
...folder,
name: patch.name ?? folder.name,
- parent_id: patch.parent_id === undefined ? folder.parent_id : patch.parent_id,
+ parent_id:
+ patch.parent_id === undefined
+ ? folder.parent_id
+ : patch.parent_id,
}
: folder,
);
diff --git a/webapp/e2e/fixtures/media.ts b/webapp/e2e/fixtures/media.ts
index d71fe80f..be04f5b6 100644
--- a/webapp/e2e/fixtures/media.ts
+++ b/webapp/e2e/fixtures/media.ts
@@ -1,7 +1,5 @@
// The bytes the mocks serve: a silent WAV and a one-pixel image.
-
-
/** A one-pixel PNG, for every cover the artwork route is asked for. */
export const PIXEL = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
diff --git a/webapp/e2e/fixtures/radio.ts b/webapp/e2e/fixtures/radio.ts
index e73ebebe..e445f115 100644
--- a/webapp/e2e/fixtures/radio.ts
+++ b/webapp/e2e/fixtures/radio.ts
@@ -24,7 +24,10 @@ export async function navigateWithin(page: Page, name: string) {
return;
}
await bar.getByRole("button", { name: "More" }).click();
- await page.getByRole("dialog").getByRole("link", { name, exact: true }).click();
+ await page
+ .getByRole("dialog")
+ .getByRole("link", { name, exact: true })
+ .click();
}
export const NOVA = "https://nova.example.org/live";
diff --git a/webapp/e2e/fixtures/state.ts b/webapp/e2e/fixtures/state.ts
index dd38e572..dacb41a5 100644
--- a/webapp/e2e/fixtures/state.ts
+++ b/webapp/e2e/fixtures/state.ts
@@ -7,10 +7,10 @@ import {
freshUncertain,
freshUploadSession,
library,
- linkState,
- missingTrack,
+ type linkState,
+ type missingTrack,
type Offer,
- song,
+ type song,
} from "./catalogue";
/**
@@ -31,7 +31,9 @@ class MockState {
restoreAsks: unknown[] = [];
/** Replaced by the scoping test; one library elsewhere, so no picker appears. */
- libraries: Array> = [library("library-1", "Ma musique")];
+ libraries: Array> = [
+ library("library-1", "Ma musique"),
+ ];
/** Flipped by the two tests that check a failure is shown as one. */
membersFail = false;
@@ -204,8 +206,7 @@ class MockState {
/** The loop the mock holds for each track, as bytes. Absent is none. */
canvases = new Map();
/** Every placement and removal the mock received, in order. */
- canvasWrites: Array<{ method: string; track: string; bytes?: number[] }> =
- [];
+ canvasWrites: Array<{ method: string; track: string; bytes?: number[] }> = [];
/** Set by a test to refuse the next placement with this status. */
refuseCanvasWith: number | null = null;
/**
diff --git a/webapp/e2e/folders.spec.ts b/webapp/e2e/folders.spec.ts
index 5c1c4fe8..827017dd 100644
--- a/webapp/e2e/folders.spec.ts
+++ b/webapp/e2e/folders.spec.ts
@@ -35,8 +35,12 @@ test("files tracks into folders from the menu and by dragging", async ({
// WaveFlow's list, not a folder of the owner's: kept apart from them, and
// it says so.
const kept = page.getByRole("list", { name: "Kept by WaveFlow" });
- await expect(kept.getByRole("link", { name: /To sort/ })).toContainText("3 tracks");
- await expect(kept.getByRole("link", { name: /To sort/ })).toContainText("Automatic");
+ await expect(kept.getByRole("link", { name: /To sort/ })).toContainText(
+ "3 tracks",
+ );
+ await expect(kept.getByRole("link", { name: /To sort/ })).toContainText(
+ "Automatic",
+ );
// A folder, and a second one refused for a name already taken.
await page.getByRole("button", { name: "New folder" }).click();
@@ -47,7 +51,9 @@ test("files tracks into folders from the menu and by dragging", async ({
await page.getByLabel("Folder name").fill("ROCK");
await page.getByRole("button", { name: "Create" }).click();
await expect(
- page.getByRole("alert").filter({ hasText: "A folder of that name is already here." }),
+ page
+ .getByRole("alert")
+ .filter({ hasText: "A folder of that name is already here." }),
).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
@@ -59,7 +65,9 @@ test("files tracks into folders from the menu and by dragging", async ({
await rows.first().locator("button.song-title").focus();
await page.keyboard.press("Shift+F10");
await page.getByRole("menuitem", { name: "Move to folder…" }).click();
- const picker = page.getByRole("dialog", { name: "Move “Hidden Place” to a folder" });
+ const picker = page.getByRole("dialog", {
+ name: "Move “Hidden Place” to a folder",
+ });
await expect(picker).toBeVisible();
await expectNoWcagViolations(page);
await picker.getByRole("button", { name: "Rock" }).click();
@@ -84,7 +92,9 @@ test("files tracks into folders from the menu and by dragging", async ({
await page.goto("/folders?folder=folder-1");
await expect(page.getByRole("heading", { name: "Rock" })).toBeVisible();
await expect(rows).toHaveCount(2);
- await expect(page.getByText("Folders that exist only in WaveFlow", { exact: false })).toHaveCount(0);
+ await expect(
+ page.getByText("Folders that exist only in WaveFlow", { exact: false }),
+ ).toHaveCount(0);
await page.getByRole("button", { name: "New folder" }).click();
await page.getByLabel("Folder name").fill("Indie");
await page.getByRole("button", { name: "Create" }).click();
@@ -92,8 +102,14 @@ test("files tracks into folders from the menu and by dragging", async ({
if (testInfo.project.name === "desktop") {
await rows
.filter({ hasText: "Cocoon" })
- .dragTo(page.getByRole("list", { name: "Folders" }).getByRole("link", { name: /Indie/ }));
- await expect(page.getByRole("status")).toHaveText("1 track filed in Indie.");
+ .dragTo(
+ page
+ .getByRole("list", { name: "Folders" })
+ .getByRole("link", { name: /Indie/ }),
+ );
+ await expect(page.getByRole("status")).toHaveText(
+ "1 track filed in Indie.",
+ );
await expect(rows).toHaveCount(1);
} else {
state.placed.set("song-2", "folder-2");
@@ -103,20 +119,28 @@ test("files tracks into folders from the menu and by dragging", async ({
await page.getByRole("button", { name: "Rename" }).click();
await page.getByLabel("New name").fill("Rock & Roll");
await page.getByRole("button", { name: "Save" }).click();
- await expect(page.getByRole("heading", { name: "Rock & Roll" })).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Rock & Roll" }),
+ ).toBeVisible();
page.once("dialog", (dialog) => void dialog.accept());
await page.getByRole("button", { name: "Delete" }).click();
await expect(page.getByRole("heading", { name: "Folders" })).toBeVisible();
await expect(page.getByRole("status")).toHaveText(
"Folder deleted: 2 tracks are back in To sort.",
);
- await expect(kept.getByRole("link", { name: /To sort/ })).toContainText("3 tracks");
+ await expect(kept.getByRole("link", { name: /To sort/ })).toContainText(
+ "3 tracks",
+ );
await expectNoWcagViolations(page);
});
/** A listener walks the filed tree and is offered nothing that writes. */
-test("shows a listener the filed tree with nothing to change", async ({ page }) => {
- mock.libraries = [{ ...library("library-1", "Ma musique"), role: "listener" }];
+test("shows a listener the filed tree with nothing to change", async ({
+ page,
+}) => {
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), role: "listener" },
+ ];
const songs = [song(1, "Hidden Place", 0, false)];
const state = await serveFiledFolders(page, songs);
state.folders.push({ id: "folder-1", parent_id: null, name: "Rock" });
@@ -127,12 +151,21 @@ test("shows a listener the filed tree with nothing to change", async ({ page })
await expect(page.getByRole("button", { name })).toHaveCount(0);
}
// A reader is told what Unfiled is, not how to file, and rows do not drag.
- await expect(page.getByText("WaveFlow keeps this list itself", { exact: false })).toBeVisible();
- await expect(page.locator(".songs tbody tr").first()).not.toHaveAttribute("draggable", "true");
+ await expect(
+ page.getByText("WaveFlow keeps this list itself", { exact: false }),
+ ).toBeVisible();
+ await expect(page.locator(".songs tbody tr").first()).not.toHaveAttribute(
+ "draggable",
+ "true",
+ );
await page.locator(".songs tbody tr button.song-title").first().focus();
await page.keyboard.press("Shift+F10");
- await expect(page.getByRole("menuitem", { name: "Properties" })).toBeVisible();
- await expect(page.getByRole("menuitem", { name: "Move to folder…" })).toHaveCount(0);
+ await expect(
+ page.getByRole("menuitem", { name: "Properties" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("menuitem", { name: "Move to folder…" }),
+ ).toHaveCount(0);
});
/**
@@ -142,12 +175,16 @@ test("shows a listener the filed tree with nothing to change", async ({ page })
test("files a received track into the folder the upload started from", async ({
page,
}) => {
- mock.libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), accepts_uploads: true },
+ ];
const state = await serveFiledFolders(page, []);
state.folders.push({ id: "folder-1", parent_id: null, name: "Rock" });
await page.goto("/upload?folder=folder-1");
- await expect(page.getByText("Received files go into the folder Rock.")).toBeVisible();
+ await expect(
+ page.getByText("Received files go into the folder Rock."),
+ ).toBeVisible();
await page.getByLabel("Choose audio files").setInputFiles({
name: "Army of Me.flac",
mimeType: "audio/flac",
@@ -171,7 +208,9 @@ test("files a received track into the folder the upload started from", async ({
test("keeps a received track when filing it fails, and says so", async ({
page,
}) => {
- mock.libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), accepts_uploads: true },
+ ];
const state = await serveFiledFolders(page, []);
state.folders.push({ id: "folder-1", parent_id: null, name: "Rock" });
await page.route("**/api/v2/libraries/library-1/placements", (route) =>
@@ -179,13 +218,17 @@ test("keeps a received track when filing it fails, and says so", async ({
);
await page.goto("/upload?folder=folder-1");
- await expect(page.getByText("Received files go into the folder Rock.")).toBeVisible();
+ await expect(
+ page.getByText("Received files go into the folder Rock."),
+ ).toBeVisible();
await page.getByLabel("Choose audio files").setInputFiles({
name: "Army of Me.flac",
mimeType: "audio/flac",
buffer: Buffer.from(Array.from({ length: 20 }, (_, i) => i)),
});
- await expect(page.getByText("Added to the library, in To sort")).toBeVisible();
+ await expect(
+ page.getByText("Added to the library, in To sort"),
+ ).toBeVisible();
expect(mock.uploadCommits).toEqual(["upload-1"]);
});
@@ -197,7 +240,9 @@ test("keeps a received track when filing it fails, and says so", async ({
test("waits for the folder before taking files, and says when it is gone", async ({
page,
}) => {
- mock.libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), accepts_uploads: true },
+ ];
let release = () => {};
const held = new Promise((resolve) => {
release = resolve;
@@ -244,7 +289,9 @@ test("offers no tracks at the root after visiting the unfiled ones", async ({
* "missing", and no file is taken until a reading works.
*/
test("takes no file while the folder cannot be checked", async ({ page }) => {
- mock.libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), accepts_uploads: true },
+ ];
let answers = 0;
await page.route("**/api/v2/libraries/library-1/folders", (route) => {
answers += 1;
@@ -252,7 +299,9 @@ test("takes no file while the folder cannot be checked", async ({ page }) => {
? route.fulfill({ status: 500, json: { error: "boom" } })
: route.fulfill({
json: {
- folders: [{ id: "folder-1", parent_id: null, name: "Rock", track_count: 0 }],
+ folders: [
+ { id: "folder-1", parent_id: null, name: "Rock", track_count: 0 },
+ ],
unfiled_track_count: 0,
},
});
@@ -264,10 +313,14 @@ test("takes no file while the folder cannot be checked", async ({ page }) => {
});
await expect(unchecked).toBeVisible();
await expect(page.getByLabel("Choose audio files")).toBeDisabled();
- await expect(page.getByText("cannot be found", { exact: false })).toHaveCount(0);
+ await expect(page.getByText("cannot be found", { exact: false })).toHaveCount(
+ 0,
+ );
await unchecked.getByRole("button", { name: "Check again" }).click();
- await expect(page.getByText("Received files go into the folder Rock.")).toBeVisible();
+ await expect(
+ page.getByText("Received files go into the folder Rock."),
+ ).toBeVisible();
await expect(page.getByLabel("Choose audio files")).toBeEnabled();
});
@@ -279,14 +332,18 @@ test("takes no file while the folder cannot be checked", async ({ page }) => {
test("goes back from the upload screen to the folder it was opened for", async ({
page,
}) => {
- mock.libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), accepts_uploads: true },
+ ];
const state = await serveFiledFolders(page, []);
state.folders.push({ id: "folder-1", parent_id: null, name: "Rock" });
const back = page.getByRole("button", { name: "Back", exact: true });
await page.goto("/folders?folder=folder-1");
await page.getByRole("link", { name: "Upload here" }).click();
- await expect(page.getByText("Received files go into the folder Rock.")).toBeVisible();
+ await expect(
+ page.getByText("Received files go into the folder Rock."),
+ ).toBeVisible();
await back.click();
await expect(page.getByRole("heading", { name: "Rock" })).toBeVisible();
@@ -305,7 +362,9 @@ test("goes back from the upload screen to the folder it was opened for", async (
* the folder navigated to with the old one's name, and a "new folder" opened
* at the root would create one from Unfiled.
*/
-test("closes a folder's form when another folder is opened", async ({ page }) => {
+test("closes a folder's form when another folder is opened", async ({
+ page,
+}) => {
const state = await serveFiledFolders(page, []);
state.folders.push(
{ id: "folder-1", parent_id: null, name: "Rock" },
@@ -315,7 +374,10 @@ test("closes a folder's form when another folder is opened", async ({ page }) =>
await page.goto("/folders?folder=folder-1");
await page.getByRole("button", { name: "Rename" }).click();
await expect(page.getByLabel("New name")).toHaveValue("Rock");
- await page.getByRole("list", { name: "Folders" }).getByRole("link", { name: /Indie/ }).click();
+ await page
+ .getByRole("list", { name: "Folders" })
+ .getByRole("link", { name: /Indie/ })
+ .click();
await expect(page.getByRole("heading", { name: "Indie" })).toBeVisible();
await expect(page.getByLabel("New name")).toHaveCount(0);
await page.getByRole("button", { name: "Rename" }).click();
@@ -344,7 +406,11 @@ test("keeps the folders and the folder picker readable in the light", async ({
await expectNoWcagViolations(page);
await cards.getByRole("link", { name: /To sort/ }).click();
- await page.locator(".songs tbody tr").first().locator("button.song-title").focus();
+ await page
+ .locator(".songs tbody tr")
+ .first()
+ .locator("button.song-title")
+ .focus();
await page.keyboard.press("Shift+F10");
await page.getByRole("menuitem", { name: "Move to folder…" }).click();
await expect(
diff --git a/webapp/e2e/library-cache.spec.ts b/webapp/e2e/library-cache.spec.ts
index 0cda5411..1179d6f7 100644
--- a/webapp/e2e/library-cache.spec.ts
+++ b/webapp/e2e/library-cache.spec.ts
@@ -38,17 +38,17 @@ test("keeps the current tab visible while the next tab loads", async ({
: page.locator(".primary-navigation").getByRole("link", { name: "Genres" });
async function visitGenres() {
if (mobile) {
- await page.locator(".mobile-navigation").getByRole("button", { name: "More" }).click();
+ await page
+ .locator(".mobile-navigation")
+ .getByRole("button", { name: "More" })
+ .click();
}
await genresLink.click();
}
await visitGenres();
await expect(page.getByRole("heading", { name: "Albums" })).toBeVisible();
await expect(page.locator(".skeleton-grid")).toHaveCount(0);
- await expect(genresLink).toHaveAttribute(
- "aria-busy",
- "true",
- );
+ await expect(genresLink).toHaveAttribute("aria-busy", "true");
answerGenres?.();
await expect(page.getByRole("heading", { name: "Genres" })).toBeVisible();
await page.getByRole("link", { name: "Albums" }).click();
@@ -130,10 +130,16 @@ test("draws the random tab once when switching to it", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Albums" })).toBeVisible();
if (await onPhoneNavigation(page)) {
- await page.locator(".mobile-navigation").getByRole("button", { name: "More" }).click();
+ await page
+ .locator(".mobile-navigation")
+ .getByRole("button", { name: "More" })
+ .click();
await page.locator('.more-sheet a[href="/random"]').click();
} else {
- await page.locator(".primary-navigation").getByRole("link", { name: "Random" }).click();
+ await page
+ .locator(".primary-navigation")
+ .getByRole("link", { name: "Random" })
+ .click();
}
await expect(page.getByRole("heading", { name: "Random" })).toBeVisible();
await expect(page.locator(".songs")).toContainText("Army of Me");
@@ -173,7 +179,10 @@ test("leaves a tab switch behind when the visitor moves on while it loads", asyn
await expect(page.getByRole("heading", { name: "Albums" })).toBeVisible();
const mobile = await onPhoneNavigation(page);
if (mobile) {
- await page.locator(".mobile-navigation").getByRole("button", { name: "More" }).click();
+ await page
+ .locator(".mobile-navigation")
+ .getByRole("button", { name: "More" })
+ .click();
}
const genresLink = mobile
? page.locator('.more-sheet a[href="/genres"]')
@@ -239,9 +248,7 @@ test("forgets the catalogue when the session changes", async ({
// player bar at this viewport, so a real click waits on hit-testing forever.
// What this test is about is what the sign-out does to the cache, and the
// gesture itself is covered by the accessibility pass.
- await page
- .getByRole("button", { name: "Sign out" })
- .dispatchEvent("click");
+ await page.getByRole("button", { name: "Sign out" }).dispatchEvent("click");
await expect(page).toHaveURL(/\/login$/);
// Signed in again through the form, not by reloading the page. A reload
@@ -353,12 +360,8 @@ test("asks the catalogue again after a track is favourited", async ({
await expect(page.getByRole("heading", { name: "Post" })).toBeVisible();
expect(asked).toBe(1);
- await page
- .getByRole("button", { name: "Add favourite: Army of Me" })
- .click();
- await expect
- .poll(() => asked, { message: "the album was re-asked" })
- .toBe(2);
+ await page.getByRole("button", { name: "Add favourite: Army of Me" }).click();
+ await expect.poll(() => asked, { message: "the album was re-asked" }).toBe(2);
});
/**
@@ -461,9 +464,7 @@ test("re-asks a held listing after a correction is saved", async ({
const sidebar = page.getByRole("navigation");
await page.getByRole("link", { name: /Post/ }).first().click();
- await page
- .getByRole("link", { name: "Correct tags: Army Of Me" })
- .click();
+ await page.getByRole("link", { name: "Correct tags: Army Of Me" }).click();
const title = page.getByLabel("Title", { exact: true });
await expect(title).toHaveValue("Army Of Me");
diff --git a/webapp/e2e/library.spec.ts b/webapp/e2e/library.spec.ts
index 538389d2..358c2bea 100644
--- a/webapp/e2e/library.spec.ts
+++ b/webapp/e2e/library.spec.ts
@@ -102,10 +102,12 @@ test("links each credited artist in a song list to its page", async ({
await expect(
rows.nth(1).locator(".song-artists").getByRole("link", { name: "Björk" }),
).toHaveAttribute("href", "/artists/artist-1");
- await expect(rows.nth(2).locator(".song-artists").getByRole("link")).toHaveCount(
- 0,
+ await expect(
+ rows.nth(2).locator(".song-artists").getByRole("link"),
+ ).toHaveCount(0);
+ await expect(rows.nth(2).locator(".song-artists")).toHaveText(
+ "Nobody We Know",
);
- await expect(rows.nth(2).locator(".song-artists")).toHaveText("Nobody We Know");
await expectNoWcagViolations(page);
@@ -152,9 +154,9 @@ test("keeps a long track menu on screen and lets it scroll", async ({
const box = await menu.boundingBox();
expect(box).not.toBeNull();
expect((box?.y ?? 0) + (box?.height ?? 0)).toBeLessThanOrEqual(420);
- expect(await menu.evaluate((node) => node.scrollHeight > node.clientHeight)).toBe(
- true,
- );
+ expect(
+ await menu.evaluate((node) => node.scrollHeight > node.clientHeight),
+ ).toBe(true);
// The wheel over the menu scrolls it and leaves it open.
await menu.hover();
@@ -185,8 +187,12 @@ test("gives random playback one clear primary action", async ({ page }) => {
await expect(play).toBeVisible();
await expect(queue).toBeVisible();
await expect(redraw).toBeVisible();
- const playFill = await play.evaluate((node) => getComputedStyle(node).backgroundColor);
- const queueFill = await queue.evaluate((node) => getComputedStyle(node).backgroundColor);
+ const playFill = await play.evaluate(
+ (node) => getComputedStyle(node).backgroundColor,
+ );
+ const queueFill = await queue.evaluate(
+ (node) => getComputedStyle(node).backgroundColor,
+ );
expect(playFill).not.toBe(queueFill);
});
@@ -354,18 +360,22 @@ test("album view plays its tracks and keeps its compact list readable", async ({
await expect(page.locator(".album-meta")).toContainText("15:00");
await expect(page.locator(".album-songs tbody tr")).toHaveCount(3);
await expect(page.locator(".album-songs .song-cover")).toHaveCount(0);
- await expect(page.getByRole("heading", { name: "More by Björk" })).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "More by Björk" }),
+ ).toBeVisible();
await expect(page.locator(".album-related-grid a")).toHaveCount(1);
expect(listings).toEqual([]);
- const actions = await page.locator(".album-actions button").evaluateAll((buttons) =>
- buttons.map((button) => {
- const style = getComputedStyle(button);
- return {
- left: Number.parseFloat(style.paddingLeft),
- right: Number.parseFloat(style.paddingRight),
- };
- }),
- );
+ const actions = await page
+ .locator(".album-actions button")
+ .evaluateAll((buttons) =>
+ buttons.map((button) => {
+ const style = getComputedStyle(button);
+ return {
+ left: Number.parseFloat(style.paddingLeft),
+ right: Number.parseFloat(style.paddingRight),
+ };
+ }),
+ );
expect(actions).toHaveLength(2);
for (const action of actions) {
expect(action.left).toBeGreaterThanOrEqual(20);
@@ -377,8 +387,8 @@ test("album view plays its tracks and keeps its compact list readable", async ({
await page.locator(".album-songs .song-title").nth(1).click();
await expect(page.locator(".player-track strong")).toHaveText("Cocoon");
expect(
- await page.evaluate(() =>
- document.documentElement.scrollWidth <= window.innerWidth,
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= window.innerWidth,
),
).toBe(true);
});
diff --git a/webapp/e2e/lyrics.spec.ts b/webapp/e2e/lyrics.spec.ts
index 8e491ffc..84102dd4 100644
--- a/webapp/e2e/lyrics.spec.ts
+++ b/webapp/e2e/lyrics.spec.ts
@@ -187,7 +187,9 @@ test("centres a sheet that lands after the head has moved", async ({
page.getByRole("listitem").filter({ hasText: "late line 20" }),
).toHaveAttribute("aria-current", "true");
// The line the head is on is in view, which is the whole claim.
- await expect.poll(() => column.evaluate((list) => list.scrollTop)).toBeGreaterThan(0);
+ await expect
+ .poll(() => column.evaluate((list) => list.scrollTop))
+ .toBeGreaterThan(0);
});
/**
@@ -363,9 +365,7 @@ test("opens the immersive view, and Back leaves the view and not the screen", as
// The close button takes the same route out.
await page.getByRole("link", { name: "Immersive" }).click();
- await page
- .getByRole("button", { name: "Leave the immersive view" })
- .click();
+ await page.getByRole("button", { name: "Leave the immersive view" }).click();
await expect(page.locator("dialog.immersive")).toHaveCount(0);
await expect(page).toHaveURL(/\/playing$/);
});
diff --git a/webapp/e2e/maintenance.spec.ts b/webapp/e2e/maintenance.spec.ts
index 30d25186..dafe4d4c 100644
--- a/webapp/e2e/maintenance.spec.ts
+++ b/webapp/e2e/maintenance.spec.ts
@@ -18,8 +18,16 @@ test.beforeEach(resetAndMockApi);
* answer, so these tests need none of the lists served.
*/
const MAINTENANCE_SECTIONS = [
- { link: "Needs attention", path: "/maintenance/attention", heading: "Needs attention" },
- { link: "Missing files", path: "/maintenance/missing", heading: "Missing files" },
+ {
+ link: "Needs attention",
+ path: "/maintenance/attention",
+ heading: "Needs attention",
+ },
+ {
+ link: "Missing files",
+ path: "/maintenance/missing",
+ heading: "Missing files",
+ },
{ link: "Quality", path: "/maintenance/quality", heading: "Quality" },
];
@@ -66,12 +74,15 @@ test("opens every section of Maintenance from its menu", async ({
);
if (testInfo.project.name === "desktop") {
// The rail's entry stays current in every section.
- await expect(
- page.locator(".sidebar a[data-status='active']"),
- ).toHaveText(["Maintenance"]);
+ await expect(page.locator(".sidebar a[data-status='active']")).toHaveText([
+ "Maintenance",
+ ]);
}
- for (const section of [...MAINTENANCE_SECTIONS.slice(1), MAINTENANCE_SECTIONS[0]]) {
+ for (const section of [
+ ...MAINTENANCE_SECTIONS.slice(1),
+ MAINTENANCE_SECTIONS[0],
+ ]) {
await menu.getByRole("link", { name: section.link, exact: true }).click();
await expect(page).toHaveURL((url) => url.pathname === section.path);
await expect(
@@ -85,7 +96,9 @@ test("opens every section of Maintenance from its menu", async ({
// Back to the section opened before the last.
await page.goBack();
- await expect(page).toHaveURL((url) => url.pathname === "/maintenance/quality");
+ await expect(page).toHaveURL(
+ (url) => url.pathname === "/maintenance/quality",
+ );
await expect(
page.getByRole("heading", { name: "Quality", exact: true }),
).toBeVisible();
@@ -100,13 +113,24 @@ test("lands every old address on its section of Maintenance", async ({
page,
}) => {
for (const [address, path, search, heading] of [
- ["/attention?category=missing_year", "/maintenance/attention", "category=missing_year", "Needs attention"],
+ [
+ "/attention?category=missing_year",
+ "/maintenance/attention",
+ "category=missing_year",
+ "Needs attention",
+ ],
["/missing", "/maintenance/missing", "", "Missing files"],
- ["/quality?verdict=upsampled", "/maintenance/quality", "verdict=upsampled", "Quality"],
+ [
+ "/quality?verdict=upsampled",
+ "/maintenance/quality",
+ "verdict=upsampled",
+ "Quality",
+ ],
]) {
await page.goto(address);
await expect(page).toHaveURL(
- (url) => url.pathname === path && url.search === (search ? `?${search}` : ""),
+ (url) =>
+ url.pathname === path && url.search === (search ? `?${search}` : ""),
);
await expect(
page.getByRole("heading", { name: heading, exact: true }),
@@ -116,7 +140,9 @@ test("lands every old address on its section of Maintenance", async ({
await page.goto("/");
await expect(page.getByRole("heading", { name: "Albums" })).toBeVisible();
await page.goto("/quality");
- await expect(page).toHaveURL((url) => url.pathname === "/maintenance/quality");
+ await expect(page).toHaveURL(
+ (url) => url.pathname === "/maintenance/quality",
+ );
await page.goBack();
await expect(page).toHaveURL((url) => url.pathname === "/");
});
@@ -134,9 +160,13 @@ test("shows a listener only the Maintenance sections theirs to use", async ({
json: { ...session, user: { ...session.user, role: "user" } },
}),
);
- mock.libraries = [{ ...library("library-1", "Ma musique"), role: "listener" }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), role: "listener" },
+ ];
await page.goto("/maintenance");
- await expect(page).toHaveURL((url) => url.pathname === "/maintenance/quality");
+ await expect(page).toHaveURL(
+ (url) => url.pathname === "/maintenance/quality",
+ );
await expect(
page.getByRole("heading", { name: "Quality", exact: true }),
).toBeVisible();
@@ -154,9 +184,13 @@ test("shows a listener only the Maintenance sections theirs to use", async ({
test("shows an administrator listening in a library the missing files", async ({
page,
}) => {
- mock.libraries = [{ ...library("library-1", "Ma musique"), role: "listener" }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), role: "listener" },
+ ];
await page.goto("/maintenance");
- await expect(page).toHaveURL((url) => url.pathname === "/maintenance/missing");
+ await expect(page).toHaveURL(
+ (url) => url.pathname === "/maintenance/missing",
+ );
await expect(
page
.getByRole("navigation", { name: "Maintenance sections" })
@@ -186,7 +220,10 @@ for (const section of MAINTENANCE_SECTIONS) {
test("lists the missing files and purges only what was chosen", async ({
page,
}) => {
- mock.missingTracks = [missingTrack(1, "First light"), missingTrack(2, "Second wind")];
+ mock.missingTracks = [
+ missingTrack(1, "First light"),
+ missingTrack(2, "Second wind"),
+ ];
await page.goto("/missing");
await expect(
@@ -238,7 +275,9 @@ test("keeps the missing files from a listener", async ({ page }) => {
json: { ...session, user: { ...session.user, role: "user" } },
}),
);
- mock.libraries = [{ ...library("library-1", "Ma musique"), role: "listener" }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), role: "listener" },
+ ];
mock.missingTracks = [missingTrack(1, "First light")];
await page.goto("/missing");
await expect(
@@ -258,7 +297,9 @@ test("keeps the missing files from a listener", async ({ page }) => {
test("shows the missing files to an administrator who only listens", async ({
page,
}) => {
- mock.libraries = [{ ...library("library-1", "Ma musique"), role: "listener" }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), role: "listener" },
+ ];
mock.missingTracks = [missingTrack(1, "First light")];
await page.goto("/missing");
await expect(page.getByText("Gone/1 First light.flac")).toBeVisible();
@@ -273,7 +314,10 @@ test("shows the missing files to an administrator who only listens", async ({
* says until when it can come back, and restoring it puts it back.
*/
test("takes a purge back while the server still keeps it", async ({ page }) => {
- mock.missingTracks = [missingTrack(1, "First light"), missingTrack(2, "Second wind")];
+ mock.missingTracks = [
+ missingTrack(1, "First light"),
+ missingTrack(2, "Second wind"),
+ ];
await page.goto("/missing");
await page.getByRole("checkbox", { name: "Select First light" }).check();
await page.getByRole("button", { name: "Purge the selected (1)" }).click();
diff --git a/webapp/e2e/mixes-and-statistics.spec.ts b/webapp/e2e/mixes-and-statistics.spec.ts
index bfab646b..ef437b9b 100644
--- a/webapp/e2e/mixes-and-statistics.spec.ts
+++ b/webapp/e2e/mixes-and-statistics.spec.ts
@@ -44,7 +44,9 @@ test("offers the day's mixes above the albums, and plays one", async ({
// A genre's name travels in the address whole, space and all.
await shelf.getByRole("link", { name: /Art Pop mix/ }).click();
- await expect(page.getByRole("heading", { name: "Art Pop mix" })).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Art Pop mix" }),
+ ).toBeVisible();
await expect(page.getByText("2 tracks, drawn for today")).toBeVisible();
expect(mock.mixAsks).toEqual(["most-played", "genre:art pop"]);
await expectNoWcagViolations(page);
@@ -68,7 +70,9 @@ test("says so when a mix cannot be opened, and lets the press be tried again", a
"“Most played” could not be opened. Try again.",
);
// Both actions say it, and neither stays disabled after the failure.
- await shelf.getByRole("button", { name: "Add to queue: Art Pop mix" }).click();
+ await shelf
+ .getByRole("button", { name: "Add to queue: Art Pop mix" })
+ .click();
await expect(shelf.getByRole("alert")).toHaveText(
"“Art Pop mix” could not be opened. Try again.",
);
diff --git a/webapp/e2e/offline.spec.ts b/webapp/e2e/offline.spec.ts
index 1cb86b43..c71f7cbf 100644
--- a/webapp/e2e/offline.spec.ts
+++ b/webapp/e2e/offline.spec.ts
@@ -77,7 +77,9 @@ test("keeps a track for offline, by its id, and plays it from the offline page",
await page
.getByRole("button", { name: "Remove the offline copy of Army Of Me" })
.click();
- await expect(page.getByText("Nothing is kept yet.", { exact: false })).toBeVisible();
+ await expect(
+ page.getByText("Nothing is kept yet.", { exact: false }),
+ ).toBeVisible();
await expect.poll(stored).toEqual([]);
});
@@ -102,7 +104,9 @@ test("a sign-out deletes the tracks kept for offline", async ({
);
await page.goto("/offline");
await expect(page.getByText(/Nothing is kept yet/)).toBeVisible();
- await expect(page.getByRole("button", { name: /^Army Of Me/ })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: /^Army Of Me/ })).toHaveCount(
+ 0,
+ );
await expect(page.getByLabel("Offline player")).toHaveCount(0);
});
@@ -126,7 +130,9 @@ test("another account signing in sees none of the tracks kept offline", async ({
await expect.poll(() => keptPaths(page)).toEqual([]);
await page.goto("/offline");
await expect(page.getByText(/Nothing is kept yet/)).toBeVisible();
- await expect(page.getByRole("button", { name: /^Army Of Me/ })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: /^Army Of Me/ })).toHaveCount(
+ 0,
+ );
});
/**
@@ -178,7 +184,9 @@ test("keeps playing the same kept track when another is forgotten", async ({
await page
.getByRole("button", { name: "Remove the offline copy of First kept" })
.click();
- await expect(page.getByRole("button", { name: /^First kept/ })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: /^First kept/ })).toHaveCount(
+ 0,
+ );
await expect(
page.getByRole("listitem").filter({ hasText: "Second kept" }),
).toHaveAttribute("aria-current", "true");
diff --git a/webapp/e2e/player.spec.ts b/webapp/e2e/player.spec.ts
index 9ceaab68..e7c72ba8 100644
--- a/webapp/e2e/player.spec.ts
+++ b/webapp/e2e/player.spec.ts
@@ -22,7 +22,10 @@ test.beforeEach(resetAndMockApi);
test("ends the played wave under the progress thumb", async ({
page,
}, testInfo) => {
- test.skip(testInfo.project.name !== "desktop", "the wave is drawn beside the rail");
+ test.skip(
+ testInfo.project.name !== "desktop",
+ "the wave is drawn beside the rail",
+ );
await page.goto("/");
const progress = page.locator(".player-progress");
await expect(progress).toBeVisible();
@@ -63,7 +66,9 @@ test("ends the played wave under the progress thumb", async ({
});
});
for (const { played, waveEnd, thumbCentre, trackEnd } of measured) {
- expect(Math.abs(waveEnd - thumbCentre), `at ${played}`).toBeLessThanOrEqual(1);
+ expect(Math.abs(waveEnd - thumbCentre), `at ${played}`).toBeLessThanOrEqual(
+ 1,
+ );
expect(waveEnd, `at ${played}`).toBeLessThanOrEqual(trackEnd);
}
});
@@ -88,7 +93,9 @@ test("carries shuffle and the three repeat states in the player bar", async ({
// layout that came before dropped previous and next instead and kept only
// play, which is the worse trade on a phone.
await expect(shuffle).toBeHidden();
- await expect(page.getByRole("button", { name: "Repeat: off" })).toBeHidden();
+ await expect(
+ page.getByRole("button", { name: "Repeat: off" }),
+ ).toBeHidden();
await expect(page.getByRole("button", { name: "Next" })).toBeVisible();
return;
}
@@ -171,9 +178,11 @@ test("shows queue artwork beside tracks without crowding times or actions", asyn
),
)
.toBe(true);
- const timeEdges = await page.locator(".queue-duration").evaluateAll((times) =>
- times.map((time) => Math.round(time.getBoundingClientRect().right)),
- );
+ const timeEdges = await page
+ .locator(".queue-duration")
+ .evaluateAll((times) =>
+ times.map((time) => Math.round(time.getBoundingClientRect().right)),
+ );
expect(new Set(timeEdges).size).toBe(1);
await page.setViewportSize({ width: 320, height: 700 });
@@ -233,9 +242,11 @@ test("shows saved tracks with artwork and one aligned time column", async ({
),
)
.toBe(true);
- const timeEdges = await page.locator(".bookmark-position").evaluateAll(
- (times) => times.map((time) => Math.round(time.getBoundingClientRect().right)),
- );
+ const timeEdges = await page
+ .locator(".bookmark-position")
+ .evaluateAll((times) =>
+ times.map((time) => Math.round(time.getBoundingClientRect().right)),
+ );
expect(new Set(timeEdges).size).toBe(1);
await expect(page.locator(".playing-bookmarks")).toHaveCSS(
"background-color",
@@ -285,9 +296,7 @@ test("keeps the shuffle still when a track in it is favourited", async ({
await expect(page.getByText("Army of Me")).toBeVisible();
expect(drawn).toBe(1);
- await page
- .getByRole("button", { name: "Add favourite: Army of Me" })
- .click();
+ await page.getByRole("button", { name: "Add favourite: Army of Me" }).click();
// The button renames itself once the star is on, so the state is read from
// the label rather than from a locator that no longer matches.
await expect(
@@ -369,7 +378,9 @@ test("levels a track by its ReplayGain only once asked to, and remembers how", a
};
});
const contexts = () =>
- page.evaluate(() => (window as unknown as { __contexts: number }).__contexts);
+ page.evaluate(
+ () => (window as unknown as { __contexts: number }).__contexts,
+ );
const gains = () =>
page.evaluate(() => (window as unknown as { __gains: number[] }).__gains);
@@ -391,7 +402,9 @@ test("levels a track by its ReplayGain only once asked to, and remembers how", a
await preamp.fill("3");
await expect(playback.locator("output")).toHaveText("+3 dB");
await expect
- .poll(() => page.evaluate(() => localStorage.getItem("waveflow.replayGain")))
+ .poll(() =>
+ page.evaluate(() => localStorage.getItem("waveflow.replayGain")),
+ )
.toBe(JSON.stringify({ mode: "album", preampDb: 3 }));
await expectNoWcagViolations(page);
// Choosing it is not playing: still no graph.
@@ -441,10 +454,27 @@ test("keeps the playback settings and the discography readable in light", async
mock.artistExtra = {
albums: [
{ ...albums[0], release_group: "album" },
- { ...albums[1], id: "album-4", title: "Single Cut", release_group: "single" },
- { ...albums[1], id: "album-5", title: "Best Of", release_group: "compilation" },
+ {
+ ...albums[1],
+ id: "album-4",
+ title: "Single Cut",
+ release_group: "single",
+ },
+ {
+ ...albums[1],
+ id: "album-5",
+ title: "Best Of",
+ release_group: "compilation",
+ },
+ ],
+ appears_on: [
+ {
+ ...albums[0],
+ id: "album-9",
+ title: "Guest Spot",
+ artist: "Someone Else",
+ },
],
- appears_on: [{ ...albums[0], id: "album-9", title: "Guest Spot", artist: "Someone Else" }],
};
await page.goto("/artists/artist-1");
await expect(page.locator(".artist-discography h3")).toHaveText([
@@ -474,7 +504,10 @@ test("leaves the element alone when the audio context will not run", async ({
"waveflow.replayGain",
JSON.stringify({ mode: "album", preampDb: 0 }),
);
- const record = window as unknown as { __contexts: number; __claims: number };
+ const record = window as unknown as {
+ __contexts: number;
+ __claims: number;
+ };
record.__contexts = 0;
record.__claims = 0;
const Original = window.AudioContext;
@@ -500,14 +533,19 @@ test("leaves the element alone when the audio context will not run", async ({
await expect(page.locator(".player-track strong")).toHaveText("Hidden Place");
await expect
.poll(() =>
- page.evaluate(() => (window as unknown as { __contexts: number }).__contexts),
+ page.evaluate(
+ () => (window as unknown as { __contexts: number }).__contexts,
+ ),
)
.toBe(1);
// A second gesture asks the same candidate again rather than making another.
await page.locator(".album-songs .song-title").nth(1).click();
await expect(page.locator(".player-track strong")).toHaveText("Cocoon");
const seen = await page.evaluate(() => {
- const record = window as unknown as { __contexts: number; __claims: number };
+ const record = window as unknown as {
+ __contexts: number;
+ __claims: number;
+ };
return { contexts: record.__contexts, claims: record.__claims };
});
expect(seen).toEqual({ contexts: 1, claims: 0 });
@@ -526,7 +564,10 @@ for (const kind of ["running", "pending"] as const) {
test(`closes the ${kind} audio context when the listener signs out`, async ({
page,
}, testInfo) => {
- test.skip(testInfo.project.name !== "desktop", "signs out from the sidebar");
+ test.skip(
+ testInfo.project.name !== "desktop",
+ "signs out from the sidebar",
+ );
await page.addInitScript((pending) => {
localStorage.setItem(
"waveflow.replayGain",
@@ -616,17 +657,22 @@ test("stops the music when the listener signs out with ReplayGain off", async ({
const audio = silentWav(60);
await page.route("**/api/v2/tracks/*/stream-ticket", (route) =>
route.fulfill({
- json: { url: "/api/v2/stream/ticket-e2e", expires_at: Date.now() + 60_000 },
+ json: {
+ url: "/api/v2/stream/ticket-e2e",
+ expires_at: Date.now() + 60_000,
+ },
}),
);
await page.route("**/api/v2/stream/*", (route) =>
route.fulfill({ body: audio, headers: { "content-type": "audio/wav" } }),
);
const playing = () =>
- page.evaluate(() =>
- [
- ...(window as unknown as { __played: Set }).__played,
- ].filter((element) => !element.paused).length,
+ page.evaluate(
+ () =>
+ [
+ ...(window as unknown as { __played: Set })
+ .__played,
+ ].filter((element) => !element.paused).length,
);
await page.goto("/albums/album-2");
@@ -637,7 +683,8 @@ test("stops the music when the listener signs out with ReplayGain off", async ({
.poll(() =>
page.evaluate(() =>
[
- ...(window as unknown as { __played: Set }).__played,
+ ...(window as unknown as { __played: Set })
+ .__played,
].some((element) => !element.paused && element.currentTime > 0),
),
)
diff --git a/webapp/e2e/playlists.spec.ts b/webapp/e2e/playlists.spec.ts
index aeebf892..d171cd74 100644
--- a/webapp/e2e/playlists.spec.ts
+++ b/webapp/e2e/playlists.spec.ts
@@ -37,7 +37,9 @@ test("previews an .m3u import, shows what it cannot place, then imports", async
).toBeVisible();
expect(mock.importAsks).toEqual([{ dryRun: true, name: "Soirée" }]);
// The name offered is the file's own, without its extension, and editable.
- await expect(page.getByLabel("Playlist name", { exact: true })).toHaveValue("Soirée");
+ await expect(page.getByLabel("Playlist name", { exact: true })).toHaveValue(
+ "Soirée",
+ );
// Both losses are named, with their line and their reason.
await expect(page.getByText("2 entries cannot be placed:")).toBeVisible();
@@ -58,7 +60,9 @@ test("previews an .m3u import, shows what it cannot place, then imports", async
// Cancelling writes nothing, and asks nothing more.
await page.getByRole("button", { name: "Cancel" }).click();
- await expect(page.getByLabel("Playlist name", { exact: true })).toHaveCount(0);
+ await expect(page.getByLabel("Playlist name", { exact: true })).toHaveCount(
+ 0,
+ );
expect(mock.importAsks).toHaveLength(1);
// And the panel opens again after a cancel, on a fresh choice.
@@ -77,9 +81,13 @@ test("previews an .m3u import, shows what it cannot place, then imports", async
expect(mock.importAsks).toHaveLength(2);
// A name of the reader's choosing, then the commit.
- await page.getByLabel("Playlist name", { exact: true }).fill("Evening from elsewhere");
+ await page
+ .getByLabel("Playlist name", { exact: true })
+ .fill("Evening from elsewhere");
await page.getByRole("button", { name: "Import", exact: true }).click();
- await expect(page.getByLabel("Playlist name", { exact: true })).toHaveCount(0);
+ await expect(page.getByLabel("Playlist name", { exact: true })).toHaveCount(
+ 0,
+ );
expect(mock.importAsks[2]).toEqual({
dryRun: false,
name: "Evening from elsewhere",
@@ -166,7 +174,9 @@ test("lists another account's public playlist apart, and read-only", async ({
);
await page.goto("/playlists");
const others = page.getByRole("region", { name: "From other accounts" });
- await expect(others.getByRole("heading", { name: "Guest picks" })).toBeVisible();
+ await expect(
+ others.getByRole("heading", { name: "Guest picks" }),
+ ).toBeVisible();
await expect(others.getByText("By guest")).toBeVisible();
await expect(others.getByRole("button", { name: "Delete" })).toHaveCount(0);
await expect(others.getByRole("checkbox")).toHaveCount(0);
@@ -244,13 +254,18 @@ test("offers no move or removal on another account's playlist", async ({
owner_username: "guest",
name: "Guest picks",
public: true,
- songs: [song(1, "Hidden Place", 5, true), song(2, "Cocoon", 0, false)],
+ songs: [
+ song(1, "Hidden Place", 5, true),
+ song(2, "Cocoon", 0, false),
+ ],
},
],
}),
);
await page.goto("/playlists");
- await expect(page.getByRole("heading", { name: "Guest picks" })).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Guest picks" }),
+ ).toBeVisible();
await expect(page.getByRole("button", { name: /^Move / })).toHaveCount(0);
await expect(
page.getByRole("button", { name: /from this playlist$/ }),
diff --git a/webapp/e2e/quality.spec.ts b/webapp/e2e/quality.spec.ts
index 0e59c5bf..c5415d24 100644
--- a/webapp/e2e/quality.spec.ts
+++ b/webapp/e2e/quality.spec.ts
@@ -48,9 +48,7 @@ test("lists a library's files by what their audio says, worst first", async ({
// A file opens on its real quality: the verdict, the numbers behind it, the
// spectrum it was judged on, and no spectrogram until somebody asks.
- await page
- .getByRole("button", { name: /^Made From An MP3/ })
- .click();
+ await page.getByRole("button", { name: /^Made From An MP3/ }).click();
const dialog = page.getByRole("dialog");
await expect(
dialog.getByRole("heading", { name: "Real quality" }),
@@ -88,13 +86,15 @@ test("asks for the whole library to be analysed again on the second click", asyn
test("shows a listener the quality, and nothing to ask for", async ({
page,
}) => {
- mock.libraries = [{ ...library("library-1", "Ma musique"), role: "listener" }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), role: "listener" },
+ ];
await serveQuality(page);
await page.goto("/quality");
await expect(page.locator(".quality-table tbody tr")).toHaveCount(2);
- await expect(
- page.getByRole("button", { name: "Analyse again" }),
- ).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Analyse again" })).toHaveCount(
+ 0,
+ );
});
test("keeps the quality page readable in the light", async ({ page }) => {
@@ -133,9 +133,7 @@ test("shows the analysis moving, then at rest, and stops asking", async ({
];
await page.goto("/quality");
const status = page.getByRole("status").filter({ hasText: /Analysing|Idle/ });
- await expect(status).toHaveText(
- "Analysing now: 1 of 3 done, 1 in progress.",
- );
+ await expect(status).toHaveText("Analysing now: 1 of 3 done, 1 in progress.");
// The count moves under the reader while the pass runs.
await expect(status).toHaveText(
"Analysing now: 2 of 3 done, 1 in progress.",
@@ -177,9 +175,9 @@ test("offers no new analysis while one runs", async ({ page }) => {
await expect(page.getByText(/^Analysing now:/)).toBeVisible();
// The server says a pass runs, so there is nothing to start: the offer
// comes from its answer, not from what this page remembers doing.
- await expect(
- page.getByRole("button", { name: "Analyse again" }),
- ).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Analyse again" })).toHaveCount(
+ 0,
+ );
});
test("asks for the header again when the list is filtered", async ({
@@ -197,9 +195,7 @@ test("asks for the header again when the list is filtered", async ({
await expect(page.getByText("2 of 3 lossless files analysed")).toBeVisible();
});
-test("asks for the header again on every visit", async ({
- page,
-}, testInfo) => {
+test("asks for the header again on every visit", async ({ page }, testInfo) => {
test.skip(testInfo.project.name === "mobile", "walks the desktop sidebar");
const state = await serveQuality(page);
state.analyses = [analysisAt(0, "idle"), analysisAt(2, "idle")];
@@ -212,7 +208,10 @@ test("asks for the header again on every visit", async ({
await expect(page.getByRole("heading", { name: "Queue" })).toBeVisible();
// The rail's entry opens the first section this owner may use, and the
// hub's menu the one this test is about.
- await page.locator(".sidebar").getByRole("link", { name: "Maintenance" }).click();
+ await page
+ .locator(".sidebar")
+ .getByRole("link", { name: "Maintenance" })
+ .click();
await page
.getByRole("navigation", { name: "Maintenance sections" })
.getByRole("link", { name: "Quality" })
diff --git a/webapp/e2e/radio.spec.ts b/webapp/e2e/radio.spec.ts
index e4dcd0c8..7488ae5a 100644
--- a/webapp/e2e/radio.spec.ts
+++ b/webapp/e2e/radio.spec.ts
@@ -10,8 +10,8 @@ import { resetAndMockApi } from "./fixtures/api";
import { PIXEL, silentWav } from "./fixtures/media";
import {
elements,
- navigateWithin,
NOVA,
+ navigateWithin,
serveStationAndQueue,
} from "./fixtures/radio";
import { mock } from "./fixtures/state";
@@ -42,7 +42,9 @@ test("keeps a list of radio stations, replacing rather than patching", async ({
const form = page.getByRole("dialog", { name: "Add a station" });
await form.getByLabel("Station name").fill("Radio Nova");
await form.getByLabel("Stream address").fill("https://nova.example.org/live");
- await form.getByLabel("Station page (optional)").fill("https://nova.example.org/");
+ await form
+ .getByLabel("Station page (optional)")
+ .fill("https://nova.example.org/");
await form.getByRole("button", { name: "Save station" }).click();
await expect(form).toHaveCount(0);
@@ -73,7 +75,10 @@ test("keeps a list of radio stations, replacing rather than patching", async ({
// Editing, from the card's menu, loads the station into the same form, and
// says what saving means: the page address is cleared because the form
// sends every field.
- await listed.first().getByRole("button", { name: "Actions for Radio Nova" }).click();
+ await listed
+ .first()
+ .getByRole("button", { name: "Actions for Radio Nova" })
+ .click();
await page.getByRole("menuitem", { name: "Edit" }).click();
const editor = page.getByRole("dialog", { name: "Edit “Radio Nova”" });
await expect(editor.getByLabel("Station name")).toHaveValue("Radio Nova");
@@ -153,7 +158,9 @@ test("closes the form while a station is saving, and leaves listening alone", as
// the fields and disabled the button for the other reason. Held open, the
// save cannot land, and removing the guard fails on this very line —
// measured both ways.
- await expect(form.getByRole("button", { name: "Save station" })).toBeDisabled();
+ await expect(
+ form.getByRole("button", { name: "Save station" }),
+ ).toBeDisabled();
await expect(form.getByLabel("Station name")).toBeDisabled();
await expect(form.getByLabel("Stream address")).toBeDisabled();
await expect(form.getByRole("button", { name: "Cancel" })).toBeDisabled();
@@ -212,10 +219,14 @@ test("carries a live station in the bar across pages, off the ReplayGain graph",
const bar = page.locator(".player");
await expect(bar.locator(".player-track strong")).toHaveText("Hidden Place");
// The graph is built, and holds the queue's element.
- await expect.poll(async () => (await elements(page)).queue?.claimed).toBe(true);
+ await expect
+ .poll(async () => (await elements(page)).queue?.claimed)
+ .toBe(true);
await navigateWithin(page, "Radio");
- const station = page.locator(".station-grid li").filter({ hasText: "Radio Nova" });
+ const station = page
+ .locator(".station-grid li")
+ .filter({ hasText: "Radio Nova" });
await station.getByRole("button", { name: "Listen" }).click();
await expect(bar.locator(".player-track strong")).toHaveText("Radio Nova");
@@ -225,7 +236,11 @@ test("carries a live station in the bar across pages, off the ReplayGain graph",
.poll(async () => (await elements(page)).station?.time ?? 0)
.toBeGreaterThan(0.2);
const playing = await elements(page);
- expect(playing.station).toMatchObject({ paused: false, source: NOVA, claimed: false });
+ expect(playing.station).toMatchObject({
+ paused: false,
+ source: NOVA,
+ claimed: false,
+ });
// Its own element: the queue's is paused under it, still the graph's, and
// still holding its track.
expect(playing.queue).toMatchObject({ paused: true, claimed: true });
@@ -233,7 +248,9 @@ test("carries a live station in the bar across pages, off the ReplayGain graph",
// A station has nothing to seek in and nothing to go to next.
await expect(bar.getByRole("slider", { name: "Seek" })).toHaveCount(0);
await expect(bar.getByRole("button", { name: "Next track" })).toHaveCount(0);
- await expect(bar.getByRole("button", { name: "Previous track" })).toHaveCount(0);
+ await expect(bar.getByRole("button", { name: "Previous track" })).toHaveCount(
+ 0,
+ );
await expect(bar.getByRole("button", { name: "Shuffle" })).toHaveCount(0);
await expectNoWcagViolations(page);
@@ -241,7 +258,8 @@ test("carries a live station in the bar across pages, off the ReplayGain graph",
await navigateWithin(page, "Albums");
await expect(page.locator(".grid li").first()).toBeVisible();
await expect(bar.locator(".player-track strong")).toHaveText("Radio Nova");
- await expect(bar.locator(".live-badge")).toHaveText("Live"); const before = (await elements(page)).station?.time ?? 0;
+ await expect(bar.locator(".live-badge")).toHaveText("Live");
+ const before = (await elements(page)).station?.time ?? 0;
await expect
.poll(async () => (await elements(page)).station?.time ?? 0)
.toBeGreaterThan(before);
@@ -249,7 +267,9 @@ test("carries a live station in the bar across pages, off the ReplayGain graph",
// The bar is the stop button now.
await bar.getByRole("button", { name: "Stop the station" }).click();
- await expect.poll(async () => (await elements(page)).station?.paused).toBe(true);
+ await expect
+ .poll(async () => (await elements(page)).station?.paused)
+ .toBe(true);
await expect(bar.getByRole("button", { name: "Play" })).toBeVisible();
await expect(bar.locator(".player-track strong")).toHaveText("Radio Nova");
});
@@ -270,7 +290,9 @@ test("pauses the queue under a station, keeps it, and gives it back to a track",
await navigateWithin(page, "Radio");
await page.getByRole("button", { name: "Listen" }).click();
await expect(bar.locator(".live-badge")).toHaveText("Live");
- await expect.poll(async () => (await elements(page)).station?.paused).toBe(false);
+ await expect
+ .poll(async () => (await elements(page)).station?.paused)
+ .toBe(false);
expect((await elements(page)).queue?.paused).toBe(true);
// Paused, not cleared: the queue is all there.
@@ -284,10 +306,15 @@ test("pauses the queue under a station, keeps it, and gives it back to a track",
await rows.filter({ hasText: "Cocoon" }).locator(".queue-track").click();
await expect(bar.locator(".player-track strong")).toHaveText("Cocoon");
await expect(bar.locator(".live-badge")).toHaveCount(0);
- await expect.poll(async () => (await elements(page)).station?.paused).toBe(true);
+ await expect
+ .poll(async () => (await elements(page)).station?.paused)
+ .toBe(true);
await expect
.poll(async () => (await elements(page)).queue)
- .toMatchObject({ paused: false, source: expect.stringContaining("ticket-song-2") });
+ .toMatchObject({
+ paused: false,
+ source: expect.stringContaining("ticket-song-2"),
+ });
// And the radio page no longer calls the station playing.
await navigateWithin(page, "Radio");
await expect(page.getByRole("button", { name: "Listen" })).toBeVisible();
@@ -318,20 +345,28 @@ test("takes an image address for a station, and shows the image the server kept"
await form.getByLabel("Station name").fill("Radio Nova");
await form.getByLabel("Stream address").fill(NOVA);
// Refused here first, as the server would, and the button stays shut.
- await form.getByLabel("Station image (optional)").fill("ftp://logos.example.org/n.png");
+ await form
+ .getByLabel("Station image (optional)")
+ .fill("ftp://logos.example.org/n.png");
await expect(
form.getByText("An address must start with http:// or https://."),
).toBeVisible();
- await expect(form.getByRole("button", { name: "Save station" })).toBeDisabled();
+ await expect(
+ form.getByRole("button", { name: "Save station" }),
+ ).toBeDisabled();
await form
.getByLabel("Station image (optional)")
.fill("https://logos.example.org/nova.png");
await expect(
- form.getByText("The server downloads it once, when you save", { exact: false }),
+ form.getByText("The server downloads it once, when you save", {
+ exact: false,
+ }),
).toBeVisible();
await form.getByRole("button", { name: "Save station" }).click();
- const listed = page.locator(".station-grid li").filter({ hasText: "Radio Nova" });
+ const listed = page
+ .locator(".station-grid li")
+ .filter({ hasText: "Radio Nova" });
await expect(listed.locator("img.station-cover")).toBeVisible();
expect(mock.stationImages).toEqual(["https://logos.example.org/nova.png"]);
@@ -345,8 +380,13 @@ test("takes an image address for a station, and shows the image the server kept"
);
await editor.getByLabel("Station image (optional)").fill("");
await editor.getByRole("button", { name: "Save station" }).click();
- await expect(listed.locator(".station-cover.cover-fallback svg")).toBeVisible();
- expect(mock.stationImages).toEqual(["https://logos.example.org/nova.png", null]);
+ await expect(
+ listed.locator(".station-cover.cover-fallback svg"),
+ ).toBeVisible();
+ expect(mock.stationImages).toEqual([
+ "https://logos.example.org/nova.png",
+ null,
+ ]);
await expectNoWcagViolations(page);
expect(asked, "the browser never asks the image's own host").toEqual([]);
});
@@ -358,7 +398,11 @@ for (const theme of ["dark", "light"] as const) {
if (theme === "light") await chooseLightTheme(page);
await serveStationAndQueue(page);
mock.stations = [
- { ...mock.stations[0], image_url: "https://logos.example.org/nova.png", artwork_hash: "station-art-nova" },
+ {
+ ...mock.stations[0],
+ image_url: "https://logos.example.org/nova.png",
+ artwork_hash: "station-art-nova",
+ },
{
...mock.stations[0],
id: "station-2",
@@ -371,7 +415,9 @@ for (const theme of ["dark", "light"] as const) {
return route.fulfill({ contentType: "image/png", body: PIXEL });
});
await page.goto("/radio");
- const nova = page.locator(".station-grid li").filter({ hasText: "Radio Nova" });
+ const nova = page
+ .locator(".station-grid li")
+ .filter({ hasText: "Radio Nova" });
await expect(nova.locator("img.station-cover")).toBeVisible();
await nova.getByRole("button", { name: "Listen" }).click();
@@ -390,14 +436,18 @@ for (const theme of ["dark", "light"] as const) {
await expectNoWcagViolations(page);
// A station without an image keeps the radio symbol.
- const plain = page.locator(".station-grid li").filter({ hasText: "Radio Plain" });
+ const plain = page
+ .locator(".station-grid li")
+ .filter({ hasText: "Radio Plain" });
await plain.getByRole("button", { name: "Listen" }).click();
await expect(bar.locator(".player-track strong")).toHaveText("Radio Plain");
await expect(bar.locator(".player-cover.cover-fallback svg")).toBeVisible();
await expect(bar.locator("img.player-cover")).toHaveCount(0);
await expect
.poll(() =>
- page.evaluate(() => navigator.mediaSession?.metadata?.artwork?.length ?? 0),
+ page.evaluate(
+ () => navigator.mediaSession?.metadata?.artwork?.length ?? 0,
+ ),
)
.toBe(0);
});
@@ -417,10 +467,14 @@ test("keeps a live station readable in the light, and closes back to the queue",
await page.getByRole("button", { name: "Listen" }).click();
const bar = page.locator(".player");
await expect(bar.locator(".live-badge")).toHaveText("Live");
- await expect.poll(async () => (await elements(page)).station?.paused).toBe(false);
+ await expect
+ .poll(async () => (await elements(page)).station?.paused)
+ .toBe(false);
await expectNoWcagViolations(page);
await bar.getByRole("button", { name: "Stop and close" }).click();
- await expect.poll(async () => (await elements(page)).station?.paused).toBe(true);
+ await expect
+ .poll(async () => (await elements(page)).station?.paused)
+ .toBe(true);
// The saved queue's track, still where the listener left it.
await expect(bar.locator(".player-track strong")).toHaveText("Hidden Place");
await expect(bar.getByRole("button", { name: "Play" })).toBeVisible();
@@ -444,7 +498,10 @@ for (const from of ["page", "bar"] as const) {
asked += 1;
return asked === 1
? route.fulfill({ status: 404, body: "" })
- : route.fulfill({ body: audio, headers: { "content-type": "audio/wav" } });
+ : route.fulfill({
+ body: audio,
+ headers: { "content-type": "audio/wav" },
+ });
});
await page.goto("/radio");
await page.getByRole("button", { name: "Listen" }).click();
@@ -452,7 +509,9 @@ for (const from of ["page", "bar"] as const) {
page.getByText("That station could not be played.", { exact: false }),
).toBeVisible();
const bar = page.locator(".player");
- await expect(bar.getByRole("alert")).toHaveText("This station could not be played.");
+ await expect(bar.getByRole("alert")).toHaveText(
+ "This station could not be played.",
+ );
expect(asked).toBe(1);
if (from === "page") {
@@ -466,7 +525,9 @@ for (const from of ["page", "bar"] as const) {
.toBeGreaterThan(0.2);
expect((await elements(page)).station?.paused).toBe(false);
await expect(bar.getByRole("alert")).toHaveCount(0);
- await expect(bar.getByRole("button", { name: "Stop the station" })).toBeVisible();
+ await expect(
+ bar.getByRole("button", { name: "Stop the station" }),
+ ).toBeVisible();
});
}
@@ -481,10 +542,14 @@ test("stops a live station when the listener signs out", async ({
await serveStationAndQueue(page);
await page.goto("/radio");
await page.getByRole("button", { name: "Listen" }).click();
- await expect.poll(async () => (await elements(page)).station?.paused).toBe(false);
+ await expect
+ .poll(async () => (await elements(page)).station?.paused)
+ .toBe(false);
// Dispatched rather than clicked, as in the cache test: the sidebar's
// footer sits under the fixed player bar at this viewport.
await page.getByRole("button", { name: "Sign out" }).dispatchEvent("click");
await expect(page).toHaveURL(/\/login$/);
- await expect.poll(async () => (await elements(page)).station?.paused).toBe(true);
+ await expect
+ .poll(async () => (await elements(page)).station?.paused)
+ .toBe(true);
});
diff --git a/webapp/e2e/scrobbling.spec.ts b/webapp/e2e/scrobbling.spec.ts
index 086f1783..a0f64ee5 100644
--- a/webapp/e2e/scrobbling.spec.ts
+++ b/webapp/e2e/scrobbling.spec.ts
@@ -26,10 +26,10 @@ test("offers each instance by name, and says why one cannot be linked", async ({
).toBeVisible();
// Two instances of one recipient, each named. A screen keyed on the
// recipient would show "Maloja" once and hide whichever it linked second.
- await expect(page.getByText('Maloja — instance “alice”')).toBeVisible();
- await expect(page.getByText('Maloja — instance “bob”')).toBeVisible();
+ await expect(page.getByText("Maloja — instance “alice”")).toBeVisible();
+ await expect(page.getByText("Maloja — instance “bob”")).toBeVisible();
await expect(
- page.getByText('ListenBrainz — instance “default”'),
+ page.getByText("ListenBrainz — instance “default”"),
).toBeVisible();
// Declared and unusable is a real shape, and the reason is published so the
@@ -100,10 +100,10 @@ test("links one instance by its key, and leaves the other alone", async ({
page,
}) => {
await page.goto("/settings/scrobbling");
- await expect(page.getByText('Maloja — instance “alice”')).toBeVisible();
+ await expect(page.getByText("Maloja — instance “alice”")).toBeVisible();
await page
- .getByLabel('API key: Maloja — instance “alice”')
+ .getByLabel("API key: Maloja — instance “alice”")
.fill("alice-api-key");
await page
.locator(".scrobble-row", { hasText: "alice" })
@@ -111,7 +111,7 @@ test("links one instance by its key, and leaves the other alone", async ({
.click();
await expect(
- page.getByRole("button", { name: 'Unlink: Maloja — instance “alice”' }),
+ page.getByRole("button", { name: "Unlink: Maloja — instance “alice”" }),
).toBeVisible();
expect(mock.scrobbleWrites).toEqual([
{
@@ -122,7 +122,7 @@ test("links one instance by its key, and leaves the other alone", async ({
]);
// The pair names the resource: bob is untouched and still offers its field.
await expect(
- page.getByLabel('API key: Maloja — instance “bob”'),
+ page.getByLabel("API key: Maloja — instance “bob”"),
).toBeVisible();
// The queue behind the link is a state and four counters, never an echo of
@@ -228,7 +228,11 @@ test("unlinks one instance and leaves a namesake at another recipient", async ({
// instance name alone withdraws both, which is the fault the unique index
// on `(user_id, provider, destination)` was rebuilt to make impossible.
mock.destinations = [
- { provider: "listenbrainz" as const, destination: "default", available: true },
+ {
+ provider: "listenbrainz" as const,
+ destination: "default",
+ available: true,
+ },
{ provider: "maloja" as const, destination: "default", available: true },
];
mock.scrobbleLinks = [
@@ -239,15 +243,15 @@ test("unlinks one instance and leaves a namesake at another recipient", async ({
await page.goto("/settings/scrobbling");
await page
.getByRole("button", {
- name: 'Unlink: ListenBrainz — instance “default”',
+ name: "Unlink: ListenBrainz — instance “default”",
})
.click();
await expect(
- page.getByRole("button", { name: 'Unlink: Maloja — instance “default”' }),
+ page.getByRole("button", { name: "Unlink: Maloja — instance “default”" }),
).toBeVisible();
await expect(
- page.getByLabel('API key: ListenBrainz — instance “default”'),
+ page.getByLabel("API key: ListenBrainz — instance “default”"),
).toBeVisible();
expect(mock.scrobbleWrites).toEqual([
{ method: "DELETE", path: "/api/v2/scrobble-links/listenbrainz/default" },
diff --git a/webapp/e2e/settings.spec.ts b/webapp/e2e/settings.spec.ts
index ce70bd43..63d1e669 100644
--- a/webapp/e2e/settings.spec.ts
+++ b/webapp/e2e/settings.spec.ts
@@ -42,7 +42,9 @@ test("gathers the account's places behind one Settings entry", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "desktop", "the rail is desktop-only");
- mock.libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), accepts_uploads: true },
+ ];
await page.goto("/");
await expect(page.getByRole("heading", { name: "Albums" })).toBeVisible();
const sidebar = page.locator(".sidebar");
@@ -51,8 +53,13 @@ test("gathers the account's places behind one Settings entry", async ({
// arrives proves nothing.
await expect(sidebar.locator('a[href="/maintenance"]')).toHaveCount(1);
- await expect(sidebar.locator('.nav-group[data-group="account"]')).toHaveCount(0);
- await expect(sidebar.locator(".nav-label")).toHaveText(["Library", "Listening"]);
+ await expect(sidebar.locator('.nav-group[data-group="account"]')).toHaveCount(
+ 0,
+ );
+ await expect(sidebar.locator(".nav-label")).toHaveText([
+ "Library",
+ "Listening",
+ ]);
const settings = sidebar.getByRole("link", { name: "Settings" });
await expect(settings).toHaveCount(1);
await expect(page.locator(".sidebar-footer").getByRole("link")).toHaveText([
@@ -89,7 +96,9 @@ test("gathers the account's places behind one Settings entry", async ({
test("opens every section of Settings from its menu", async ({
page,
}, testInfo) => {
- mock.libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), accepts_uploads: true },
+ ];
await page.goto("/");
await expect(page.getByRole("heading", { name: "Albums" })).toBeVisible();
if (testInfo.project.name === "mobile") {
@@ -97,9 +106,15 @@ test("opens every section of Settings from its menu", async ({
.locator(".mobile-navigation")
.getByRole("button", { name: "More" })
.click();
- await page.getByRole("dialog").getByRole("link", { name: "Settings" }).click();
+ await page
+ .getByRole("dialog")
+ .getByRole("link", { name: "Settings" })
+ .click();
} else {
- await page.locator(".sidebar").getByRole("link", { name: "Settings" }).click();
+ await page
+ .locator(".sidebar")
+ .getByRole("link", { name: "Settings" })
+ .click();
}
const menu = page.getByRole("navigation", { name: "Settings sections" });
@@ -127,7 +142,9 @@ test("opens every section of Settings from its menu", async ({
if (testInfo.project.name === "mobile") {
// A list two to a row, not a strip running off the side of the phone.
const overflow = await page.evaluate(
- () => document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ () =>
+ document.documentElement.scrollWidth -
+ document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(1);
const columns = await menu.evaluate(
@@ -153,7 +170,9 @@ test("opens every section of Settings from its menu", async ({
* landing on an address that would only send them forward again.
*/
test("lands every old address on its section of Settings", async ({ page }) => {
- mock.libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), accepts_uploads: true },
+ ];
for (const [address, path, heading] of [
["/settings", "/settings", "Account"],
["/settings/scrobbling", "/settings/scrobbling", "External scrobbling"],
@@ -199,7 +218,11 @@ test("hides Admin and Upload from the Settings menu of an account without them",
}),
);
mock.libraries = [
- { ...library("library-1", "Ma musique"), accepts_uploads: true, role: "listener" },
+ {
+ ...library("library-1", "Ma musique"),
+ accepts_uploads: true,
+ role: "listener",
+ },
];
await page.goto("/settings");
await expect(
@@ -226,7 +249,9 @@ for (const section of SETTINGS_SECTIONS) {
test(`keeps the Settings menu readable in the light on ${section.link}`, async ({
page,
}) => {
- mock.libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), accepts_uploads: true },
+ ];
await chooseLightTheme(page);
await page.goto(section.path);
await expect(page.locator("html")).toHaveAttribute("data-theme", "default");
@@ -270,9 +295,7 @@ test("shows the clients that can read the library, and revokes one", async ({
await expect(clients.getByRole("listitem")).toHaveCount(1);
await expect(clients.getByText("the kitchen speaker")).toBeVisible();
await expect(clients.getByText("a laptop that is gone")).toHaveCount(0);
- await expect(
- clients.getByText("a tablet whose year ran out"),
- ).toHaveCount(0);
+ await expect(clients.getByText("a tablet whose year ran out")).toHaveCount(0);
// Registering one shows the secret, once, and the list follows.
await clients.getByLabel("What it is for").fill("my phone");
@@ -370,9 +393,9 @@ test("changes the Subsonic password, then switches it off", async ({
),
).toBeVisible();
await expect(subsonic.getByLabel("New password")).toHaveCount(0);
- await expect(
- subsonic.getByRole("button", { name: "Change it" }),
- ).toHaveCount(0);
+ await expect(subsonic.getByRole("button", { name: "Change it" })).toHaveCount(
+ 0,
+ );
});
/**
diff --git a/webapp/e2e/shares.spec.ts b/webapp/e2e/shares.spec.ts
index e54e1e62..8952bbae 100644
--- a/webapp/e2e/shares.spec.ts
+++ b/webapp/e2e/shares.spec.ts
@@ -155,7 +155,8 @@ test("does not send a queue longer than a share holds", async ({ page }) => {
);
const created: string[] = [];
await page.route("**/api/v2/shares", async (route) => {
- if (route.request().method() === "POST") created.push(route.request().url());
+ if (route.request().method() === "POST")
+ created.push(route.request().url());
await route.fulfill({ json: [] });
});
await page.goto("/shares");
@@ -199,7 +200,9 @@ test("a share page falls back to a plain square when a cover will not load", asy
route.fulfill({ status: 404, body: "" }),
);
await page.goto("/share/wfs_broken");
- await expect(page.getByRole("heading", { name: "Broken cover" })).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Broken cover" }),
+ ).toBeVisible();
await expect(page.locator("img.share-track-cover")).toHaveCount(0);
await expect(page.locator("span.share-track-cover")).toHaveCount(1);
});
diff --git a/webapp/e2e/shell.spec.ts b/webapp/e2e/shell.spec.ts
index 1aa5e719..322f2b42 100644
--- a/webapp/e2e/shell.spec.ts
+++ b/webapp/e2e/shell.spec.ts
@@ -36,7 +36,9 @@ test("renders the studio shell and persists appearance preferences", async ({
const language = preferences.getByLabel("Language");
await language.selectOption("fr");
- await expect(page.getByRole("link", { name: "Recherche" }).first()).toBeVisible();
+ await expect(
+ page.getByRole("link", { name: "Recherche" }).first(),
+ ).toBeVisible();
await expect(page.locator("html")).toHaveAttribute("lang", "fr");
// And nowhere else: a picker still reachable from the corridor would mean
// the move had added a place rather than chosen one.
@@ -48,7 +50,9 @@ test("renders the studio shell and persists appearance preferences", async ({
if (testInfo.project.name === "mobile") {
await expect(page.locator(".mobile-navigation")).toBeVisible();
const overflow = await page.evaluate(
- () => document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ () =>
+ document.documentElement.scrollWidth -
+ document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(1);
} else {
@@ -66,10 +70,16 @@ test("keeps navigation and player controls within their own space", async ({
if (testInfo.project.name === "mobile") {
await expect(page.locator(".sidebar")).toBeHidden();
const bounds = await page.evaluate(() => {
- const track = document.querySelector(".player-track")?.getBoundingClientRect();
- const progress = document.querySelector(".player-progress")?.getBoundingClientRect();
+ const track = document
+ .querySelector(".player-track")
+ ?.getBoundingClientRect();
+ const progress = document
+ .querySelector(".player-progress")
+ ?.getBoundingClientRect();
const player = document.querySelector(".player")?.getBoundingClientRect();
- const navigation = document.querySelector(".mobile-navigation")?.getBoundingClientRect();
+ const navigation = document
+ .querySelector(".mobile-navigation")
+ ?.getBoundingClientRect();
return {
trackBottom: track?.bottom ?? 0,
progressTop: progress?.top ?? 0,
@@ -83,10 +93,17 @@ test("keeps navigation and player controls within their own space", async ({
expect(bounds.overflow).toBeLessThanOrEqual(1);
await page.setViewportSize({ width: 320, height: 700 });
const narrow = await page.evaluate(() => ({
- trackBottom: document.querySelector(".player-track")?.getBoundingClientRect().bottom ?? 0,
- progressTop: document.querySelector(".player-progress")?.getBoundingClientRect().top ?? 0,
- playerBottom: document.querySelector(".player")?.getBoundingClientRect().bottom ?? 0,
- navigationTop: document.querySelector(".mobile-navigation")?.getBoundingClientRect().top ?? 0,
+ trackBottom:
+ document.querySelector(".player-track")?.getBoundingClientRect()
+ .bottom ?? 0,
+ progressTop:
+ document.querySelector(".player-progress")?.getBoundingClientRect()
+ .top ?? 0,
+ playerBottom:
+ document.querySelector(".player")?.getBoundingClientRect().bottom ?? 0,
+ navigationTop:
+ document.querySelector(".mobile-navigation")?.getBoundingClientRect()
+ .top ?? 0,
overflow: document.documentElement.scrollWidth - window.innerWidth,
}));
expect(narrow.progressTop).toBeGreaterThanOrEqual(narrow.trackBottom);
@@ -94,9 +111,15 @@ test("keeps navigation and player controls within their own space", async ({
expect(narrow.overflow).toBeLessThanOrEqual(1);
} else {
const bounds = await page.evaluate(() => {
- const sidebar = document.querySelector(".sidebar")?.getBoundingClientRect();
- const footer = document.querySelector(".sidebar-footer")?.getBoundingClientRect();
- const navigation = document.querySelector(".primary-navigation")?.getBoundingClientRect();
+ const sidebar = document
+ .querySelector(".sidebar")
+ ?.getBoundingClientRect();
+ const footer = document
+ .querySelector(".sidebar-footer")
+ ?.getBoundingClientRect();
+ const navigation = document
+ .querySelector(".primary-navigation")
+ ?.getBoundingClientRect();
return {
sidebarBottom: sidebar?.bottom ?? 0,
footerBottom: footer?.bottom ?? 0,
@@ -105,11 +128,16 @@ test("keeps navigation and player controls within their own space", async ({
});
expect(bounds.footerBottom).toBeLessThanOrEqual(bounds.sidebarBottom);
expect(bounds.navigationBottom).toBeLessThanOrEqual(bounds.footerBottom);
- await expect(page.locator(".sidebar-footer").getByRole("button", { name: "Sign out" })).toBeVisible();
+ await expect(
+ page.locator(".sidebar-footer").getByRole("button", { name: "Sign out" }),
+ ).toBeVisible();
await page.setViewportSize({ width: 821, height: 650 });
const compact = await page.evaluate(() => ({
- sidebarBottom: document.querySelector(".sidebar")?.getBoundingClientRect().bottom ?? 0,
- footerBottom: document.querySelector(".sidebar-footer")?.getBoundingClientRect().bottom ?? 0,
+ sidebarBottom:
+ document.querySelector(".sidebar")?.getBoundingClientRect().bottom ?? 0,
+ footerBottom:
+ document.querySelector(".sidebar-footer")?.getBoundingClientRect()
+ .bottom ?? 0,
overflow: document.documentElement.scrollWidth - window.innerWidth,
}));
expect(compact.footerBottom).toBeLessThanOrEqual(compact.sidebarBottom);
@@ -120,7 +148,10 @@ test("keeps navigation and player controls within their own space", async ({
test("shows one current navigation item and separates administration panels", async ({
page,
}, testInfo) => {
- test.skip(testInfo.project.name !== "desktop", "sidebar layout is desktop-only");
+ test.skip(
+ testInfo.project.name !== "desktop",
+ "sidebar layout is desktop-only",
+ );
// A section of Settings: the rail has no entry of its own for it any more,
// and the one entry that leads there is the current one — in the rail's
// foot, so the whole sidebar is counted and not only the rail.
@@ -133,11 +164,11 @@ test("shows one current navigation item and separates administration panels", as
name: "Genres",
});
await genres.hover();
- const activeBackground = await current.evaluate((node) =>
- getComputedStyle(node).backgroundColor,
+ const activeBackground = await current.evaluate(
+ (node) => getComputedStyle(node).backgroundColor,
);
- const hoverBackground = await genres.evaluate((node) =>
- getComputedStyle(node).backgroundColor,
+ const hoverBackground = await genres.evaluate(
+ (node) => getComputedStyle(node).backgroundColor,
);
expect(hoverBackground).not.toBe(activeBackground);
@@ -172,7 +203,9 @@ test("fits the rail without scrolling at common desktop heights", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "desktop", "the rail is desktop-only");
- mock.libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), accepts_uploads: true },
+ ];
for (const [width, height] of [
[1366, 768],
[1440, 900],
@@ -206,9 +239,15 @@ test("fits the rail without scrolling at common desktop heights", async ({
),
),
);
- expect(smallest, `targets at ${width}x${height}`).toBeGreaterThanOrEqual(24);
- const settings = page.locator(".sidebar").getByRole("link", { name: "Settings" });
- await expect(settings, `at ${width}x${height}`).toBeInViewport({ ratio: 1 });
+ expect(smallest, `targets at ${width}x${height}`).toBeGreaterThanOrEqual(
+ 24,
+ );
+ const settings = page
+ .locator(".sidebar")
+ .getByRole("link", { name: "Settings" });
+ await expect(settings, `at ${width}x${height}`).toBeInViewport({
+ ratio: 1,
+ });
// And not under the player bar, which runs across the rail's foot.
const overlap = await settings.evaluate((link) => {
const player = document.querySelector(".player")?.getBoundingClientRect();
@@ -233,7 +272,9 @@ test("offers a keyboard skip link and a localized not-found route", async ({
await expect(
page.getByRole("heading", { name: "This room is silent" }),
).toBeVisible();
- await expect(page.getByRole("link", { name: "Back to the library" })).toBeVisible();
+ await expect(
+ page.getByRole("link", { name: "Back to the library" }),
+ ).toBeVisible();
});
test("has no automated WCAG A or AA violations", async ({ page }) => {
@@ -273,7 +314,9 @@ test("opens the rest of the client from the mobile bar", async ({
// the test beside this one is over-determined: that account is a listener
// *and* its library is closed, so it would pass against a sheet that had
// dropped the entry entirely.
- mock.libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), accepts_uploads: true },
+ ];
await page.goto("/");
const bar = page.locator(".mobile-navigation");
@@ -356,10 +399,15 @@ test("offers nothing in the sheet that the account may not reach", async ({
json: { ...session, user: { ...session.user, role: "user" } },
}),
);
- mock.libraries = [{ ...library("library-1", "Ma musique"), role: "listener" }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), role: "listener" },
+ ];
await page.goto("/");
- await page.locator(".mobile-navigation").getByRole("button", { name: "More" }).click();
+ await page
+ .locator(".mobile-navigation")
+ .getByRole("button", { name: "More" })
+ .click();
const sheet = page.getByRole("dialog");
// Settings is everyone's. What sits behind it is gated in its own menu, and
@@ -373,9 +421,9 @@ test("offers nothing in the sheet that the account may not reach", async ({
await expect(
sheet.getByRole("link", { name: "Needs attention" }),
).toHaveCount(0);
- await expect(
- sheet.getByRole("link", { name: "Missing files" }),
- ).toHaveCount(0);
+ await expect(sheet.getByRole("link", { name: "Missing files" })).toHaveCount(
+ 0,
+ );
});
/**
@@ -440,7 +488,8 @@ test("declares the logo kit's icons, and serves the ones it declares", async ({
test("draws the brand from the logo kit, in the version for the theme", async ({
page,
}, testInfo) => {
- const where = testInfo.project.name === "mobile" ? ".mobile-header" : ".sidebar";
+ const where =
+ testInfo.project.name === "mobile" ? ".mobile-header" : ".sidebar";
const brand = page.locator(`${where} .brand`);
const expectLogo = async (file: string, hidden: string) => {
@@ -449,7 +498,9 @@ test("draws the brand from the logo kit, in the version for the theme", async ({
await expect(brand.locator(`img[src="/brand/${hidden}"]`)).toBeHidden();
// Really drawn, not a broken image the size of its attributes.
await expect
- .poll(() => shown.evaluate((image: HTMLImageElement) => image.naturalWidth))
+ .poll(() =>
+ shown.evaluate((image: HTMLImageElement) => image.naturalWidth),
+ )
.toBeGreaterThan(0);
const drawn = await shown.evaluate((image) => {
const style = getComputedStyle(image);
@@ -645,9 +696,8 @@ test("declares an install manifest whose icons are all served", async ({
expect(icon.headers()["content-type"], src).toContain("image/png");
// The size it claims is the size it is: a PNG's IHDR carries both.
const bytes = await icon.body();
- expect(
- `${bytes.readUInt32BE(16)}x${bytes.readUInt32BE(20)}`,
- src,
- ).toBe(sizes);
+ expect(`${bytes.readUInt32BE(16)}x${bytes.readUInt32BE(20)}`, src).toBe(
+ sizes,
+ );
}
});
diff --git a/webapp/e2e/smart-playlists-and-podcasts.spec.ts b/webapp/e2e/smart-playlists-and-podcasts.spec.ts
index b0c3d716..312e367e 100644
--- a/webapp/e2e/smart-playlists-and-podcasts.spec.ts
+++ b/webapp/e2e/smart-playlists-and-podcasts.spec.ts
@@ -61,10 +61,13 @@ type Rules = {
/** What the mock answers for rules: the one condition it understands. */
function evaluate(rules: Rules) {
const contains = rules.conditions.find(
- (condition) => condition.field === "title" && condition.operator === "contains",
+ (condition) =>
+ condition.field === "title" && condition.operator === "contains",
);
const needle = String(contains?.value ?? "").toLowerCase();
- return catalogue.filter((track) => track.title.toLowerCase().includes(needle));
+ return catalogue.filter((track) =>
+ track.title.toLowerCase().includes(needle),
+ );
}
let smart: Array & { id: string; rules: Rules }> = [];
@@ -89,14 +92,20 @@ type Episode = {
};
let canManage = true;
-let channels: Array & { id: string; episodes: Episode[] }> = [];
+let channels: Array<
+ Record & { id: string; episodes: Episode[] }
+> = [];
let podcastWrites: string[] = [];
let bookmarkWrites: Array<{ id: string; body: unknown }> = [];
/** Held open by the tests that need a request in flight; instant otherwise. */
let bookmarkGate: Promise = Promise.resolve();
let ticketGate: Promise = Promise.resolve();
-const episode = (id: string, title: string, extra: Partial = {}): Episode => ({
+const episode = (
+ id: string,
+ title: string,
+ extra: Partial = {},
+): Episode => ({
id,
channel_id: "channel-1",
channel_title: "Night Shift",
@@ -160,7 +169,11 @@ function listed(playlist: (typeof smart)[number]) {
async function mockApi(page: Page) {
await page.context().addCookies([
- { name: "waveflow-csrf", value: "e2e-csrf", url: "http://127.0.0.1:4173" },
+ {
+ name: "waveflow-csrf",
+ value: "e2e-csrf",
+ url: "http://127.0.0.1:4173",
+ },
]);
await page.route("**/api/v2/**", async (route) => {
const request = route.request();
@@ -197,7 +210,11 @@ async function mockApi(page: Page) {
if (path === "/api/v2/smart-playlists/preview") {
const songs = evaluate(request.postDataJSON().rules);
await route.fulfill({
- json: { song_count: songs.length, duration_ms: songs.length * 200_000, songs },
+ json: {
+ song_count: songs.length,
+ duration_ms: songs.length * 200_000,
+ songs,
+ },
});
return;
}
@@ -294,7 +311,9 @@ async function mockApi(page: Page) {
json: {
enabled: true,
can_manage: canManage,
- channels: channels.map(({ episodes: _episodes, ...channel }) => channel),
+ channels: channels.map(
+ ({ episodes: _episodes, ...channel }) => channel,
+ ),
},
});
return;
@@ -314,17 +333,25 @@ async function mockApi(page: Page) {
}
const channel = channels.find((candidate) => candidate.id === id);
await route.fulfill(
- channel ? { json: channel } : { status: 404, json: { code: "not_found" } },
+ channel
+ ? { json: channel }
+ : { status: 404, json: { code: "not_found" } },
);
return;
}
- const download = path.match(/^\/api\/v2\/podcast-episodes\/([^/]+)\/download$/);
+ const download = path.match(
+ /^\/api\/v2\/podcast-episodes\/([^/]+)\/download$/,
+ );
if (download) {
podcastWrites.push(`download ${download[1]}`);
for (const channel of channels) {
for (const item of channel.episodes) {
if (item.id === download[1]) {
- Object.assign(item, { status: "completed", suffix: "wav", size: 8044 });
+ Object.assign(item, {
+ status: "completed",
+ suffix: "wav",
+ size: 8044,
+ });
}
}
}
@@ -335,7 +362,10 @@ async function mockApi(page: Page) {
if (ticket) {
await ticketGate;
await route.fulfill({
- json: { url: `/api/v2/stream/ticket-${ticket[1]}`, expires_at: Date.now() + 3_600_000 },
+ json: {
+ url: `/api/v2/stream/ticket-${ticket[1]}`,
+ expires_at: Date.now() + 3_600_000,
+ },
});
return;
}
@@ -387,7 +417,9 @@ test("writes a smart playlist's rules, previews them and keeps them", async ({
}) => {
await page.goto("/playlists");
const section = page.locator(".smart-playlists");
- await expect(section.getByRole("heading", { name: "Smart playlists" })).toBeVisible();
+ await expect(
+ section.getByRole("heading", { name: "Smart playlists" }),
+ ).toBeVisible();
await expect(section.getByText("No smart playlists yet.")).toBeVisible();
await section.getByRole("button", { name: "New smart playlist" }).click();
@@ -431,7 +463,9 @@ test("writes a smart playlist's rules, previews them and keeps them", async ({
limit: 50,
},
});
- const card = section.locator(".smart-playlist").filter({ hasText: "With a y" });
+ const card = section
+ .locator(".smart-playlist")
+ .filter({ hasText: "With a y" });
await expect(card).toContainText("2 tracks");
// Edited: the stored rules come back into the form, and a save replaces
@@ -439,13 +473,19 @@ test("writes a smart playlist's rules, previews them and keeps them", async ({
await card.getByRole("button", { name: "Edit rules" }).click();
const again = page.getByRole("form", { name: "Edit rules" });
await expect(again.getByLabel("Value of condition 1")).toHaveValue("y");
- await expect(again.getByLabel("Upper bound of condition 2")).toHaveValue("1999");
+ await expect(again.getByLabel("Upper bound of condition 2")).toHaveValue(
+ "1999",
+ );
await again.getByRole("button", { name: "Remove condition 2" }).click();
await again.getByRole("button", { name: "Save smart playlist" }).click();
await expect(again).toHaveCount(0);
expect(smartWrites[1]).toMatchObject({
method: "PATCH",
- body: { rules: { conditions: [{ field: "title", operator: "contains", value: "y" }] } },
+ body: {
+ rules: {
+ conditions: [{ field: "title", operator: "contains", value: "y" }],
+ },
+ },
});
await card.getByRole("button", { name: "Delete" }).click();
@@ -480,7 +520,9 @@ test("lists another account's smart playlist uncounted until it is opened", asyn
},
];
await page.goto("/playlists");
- const card = page.locator(".smart-playlist").filter({ hasText: "Their y songs" });
+ const card = page
+ .locator(".smart-playlist")
+ .filter({ hasText: "Their y songs" });
await expect(card).toContainText("Counted when opened");
await expect(card).toContainText("By guest");
await expect(card.getByRole("button", { name: "Edit rules" })).toHaveCount(0);
@@ -491,7 +533,12 @@ test("lists another account's smart playlist uncounted until it is opened", asyn
});
/** A smart playlist of this account's, as the listing answers it. */
-const ownSmart = (id: string, name: string, needle: string, counted: number | null) => ({
+const ownSmart = (
+ id: string,
+ name: string,
+ needle: string,
+ counted: number | null,
+) => ({
id,
owner_id: "user-1",
owner_username: "listener",
@@ -512,7 +559,9 @@ const ownSmart = (id: string, name: string, needle: string, counted: number | nu
duration_ms: counted === null ? null : counted * 200_000,
});
-test("plays a smart playlist whose remembered count is zero", async ({ page }) => {
+test("plays a smart playlist whose remembered count is zero", async ({
+ page,
+}) => {
// Counted at zero when last opened; matching tracks arrived since. A count
// is only what was true then, so it must not keep the playlist from playing.
smart = [ownSmart("smart-stale", "Was empty", "y", 0)];
@@ -532,16 +581,22 @@ test("starts a fresh editor for each playlist, and drops a preview that no longe
ownSmart("smart-b", "Playlist B", "Iso", 1),
];
await page.goto("/playlists");
- const first = page.locator(".smart-playlist").filter({ hasText: "Playlist A" });
+ const first = page
+ .locator(".smart-playlist")
+ .filter({ hasText: "Playlist A" });
await first.getByRole("button", { name: "Edit rules" }).click();
const editor = page.getByRole("form", { name: "Edit rules" });
await editor.getByRole("button", { name: "Preview" }).click();
await expect(editor.getByRole("status")).toContainText("2 tracks match");
// Another playlist: its own rules, and no preview of the first's.
- const second = page.locator(".smart-playlist").filter({ hasText: "Playlist B" });
+ const second = page
+ .locator(".smart-playlist")
+ .filter({ hasText: "Playlist B" });
await second.getByRole("button", { name: "Edit rules" }).click();
- await expect(editor.getByLabel("Name", { exact: true })).toHaveValue("Playlist B");
+ await expect(editor.getByLabel("Name", { exact: true })).toHaveValue(
+ "Playlist B",
+ );
await expect(editor.getByLabel("Value of condition 1")).toHaveValue("Iso");
await expect(editor.getByRole("status")).toHaveCount(0);
@@ -552,7 +607,9 @@ test("starts a fresh editor for each playlist, and drops a preview that no longe
await expect(editor.getByRole("status")).toHaveCount(0);
});
-test("chooses a library for a condition, by name, and sends its id", async ({ page }) => {
+test("chooses a library for a condition, by name, and sends its id", async ({
+ page,
+}) => {
await page.goto("/playlists");
const section = page.locator(".smart-playlists");
await section.getByRole("button", { name: "New smart playlist" }).click();
@@ -562,8 +619,12 @@ test("chooses a library for a condition, by name, and sends its id", async ({ pa
await expect(
editor.getByLabel("Comparison of condition 1").locator("option"),
).toHaveText(["is", "is not"]);
- await expect(editor.getByRole("button", { name: "Save smart playlist" })).toBeDisabled();
- await editor.getByLabel("Value of condition 1").selectOption({ label: "Ma musique" });
+ await expect(
+ editor.getByRole("button", { name: "Save smart playlist" }),
+ ).toBeDisabled();
+ await editor
+ .getByLabel("Value of condition 1")
+ .selectOption({ label: "Ma musique" });
await editor.getByRole("button", { name: "Save smart playlist" }).click();
await expect(editor).toHaveCount(0);
expect(smartWrites[0].body).toMatchObject({
@@ -583,7 +644,9 @@ test("subscribes to a podcast, downloads, plays and keeps the place", async ({
await expect(page.getByLabel("Feed address")).toHaveAccessibleDescription(
/^Visible to every account/,
);
- await page.getByLabel("Feed address").fill("https://feeds.example.org/night.xml");
+ await page
+ .getByLabel("Feed address")
+ .fill("https://feeds.example.org/night.xml");
await page.getByRole("button", { name: "Subscribe" }).click();
// The route sees the write after the click returns, not before it: polled.
await expect
@@ -591,7 +654,9 @@ test("subscribes to a podcast, downloads, plays and keeps the place", async ({
.toEqual(["subscribe https://feeds.example.org/night.xml"]);
const episodes = page.locator(".podcast-episodes");
- await expect(episodes.getByRole("heading", { name: "Night Shift" })).toBeVisible();
+ await expect(
+ episodes.getByRole("heading", { name: "Night Shift" }),
+ ).toBeVisible();
const first = episodes.locator("li").filter({ hasText: "First night" });
await expect(first).toContainText("not downloaded");
await first.getByRole("button", { name: "Download" }).click();
@@ -603,10 +668,14 @@ test("subscribes to a podcast, downloads, plays and keeps the place", async ({
await expect(second).toContainText("resumes at 0:30");
await expectNoWcagViolations(page);
await first.getByRole("button", { name: "Play First night" }).click();
- await expect(first.getByRole("button", { name: "Pause First night" })).toBeVisible();
+ await expect(
+ first.getByRole("button", { name: "Pause First night" }),
+ ).toBeVisible();
await first.getByRole("button", { name: "Pause First night" }).click();
// Pausing writes the place through the bookmark route, under the episode's id.
- await expect.poll(() => bookmarkWrites.map((write) => write.id)).toContain("episode-1");
+ await expect
+ .poll(() => bookmarkWrites.map((write) => write.id))
+ .toContain("episode-1");
await page.getByRole("button", { name: "Refresh every feed" }).click();
await expect.poll(() => podcastWrites).toContain("refresh");
@@ -659,7 +728,10 @@ async function navigateWithin(page: Page, name: string) {
return;
}
await bar.getByRole("button", { name: "More" }).click();
- await page.getByRole("dialog").getByRole("link", { name, exact: true }).click();
+ await page
+ .getByRole("dialog")
+ .getByRole("link", { name, exact: true })
+ .click();
}
const ready = { status: "completed" as const, suffix: "wav", size: 8044 };
@@ -699,28 +771,38 @@ test("plays an episode in the bar, across pages, and resumes it from its bookmar
];
await page.goto("/podcasts");
await page.getByRole("button", { name: "Episodes" }).click();
- const first = page.locator(".podcast-episodes li").filter({ hasText: "First night" });
+ const first = page
+ .locator(".podcast-episodes li")
+ .filter({ hasText: "First night" });
await first.getByRole("button", { name: "Play First night" }).click();
const bar = page.locator(".player");
await expect(bar.locator(".player-track strong")).toHaveText("First night");
await expect(bar.locator(".player-track")).toContainText("Night Shift");
await expect(bar.getByRole("button", { name: "Pause" })).toBeVisible();
- await expect.poll(async () => (await episodeElement(page, "ticket-episode-1"))?.paused).toBe(false);
+ await expect
+ .poll(async () => (await episodeElement(page, "ticket-episode-1"))?.paused)
+ .toBe(false);
// Away from the page that started it: still playing, still in the bar.
await navigateWithin(page, "Playlists");
await expect(page.locator(".smart-playlists")).toBeVisible();
await expect(bar.locator(".player-track strong")).toHaveText("First night");
await expect
- .poll(async () => (await episodeElement(page, "ticket-episode-1"))?.currentTime ?? 0)
+ .poll(
+ async () =>
+ (await episodeElement(page, "ticket-episode-1"))?.currentTime ?? 0,
+ )
.toBeGreaterThan(0.2);
expect((await episodeElement(page, "ticket-episode-1"))?.paused).toBe(false);
// An episode has a line and a scrubber, and nothing the queue has: no next,
// no previous, no shuffle, no repeat.
const seek = bar.getByRole("slider", { name: "Seek" });
- await expect(seek).toBeVisible(); await expect(bar.getByRole("button", { name: "Next track" })).toHaveCount(0);
- await expect(bar.getByRole("button", { name: "Previous track" })).toHaveCount(0);
+ await expect(seek).toBeVisible();
+ await expect(bar.getByRole("button", { name: "Next track" })).toHaveCount(0);
+ await expect(bar.getByRole("button", { name: "Previous track" })).toHaveCount(
+ 0,
+ );
await expect(bar.getByRole("button", { name: "Shuffle" })).toHaveCount(0);
await expectNoWcagViolations(page);
@@ -728,7 +810,10 @@ test("plays an episode in the bar, across pages, and resumes it from its bookmar
await seek.fill("12");
await seek.dispatchEvent("mouseup");
await expect
- .poll(async () => (await episodeElement(page, "ticket-episode-1"))?.currentTime ?? 0)
+ .poll(
+ async () =>
+ (await episodeElement(page, "ticket-episode-1"))?.currentTime ?? 0,
+ )
.toBeGreaterThanOrEqual(12);
// Pausing from the bar writes the place, under the episode's id. A seek
@@ -746,7 +831,9 @@ test("plays an episode in the bar, across pages, and resumes it from its bookmar
await expect.poll(() => placed(0)).toBe(true);
const beforePause = bookmarkWrites.length;
await bar.getByRole("button", { name: "Pause" }).click();
- await expect.poll(async () => (await episodeElement(page, "ticket-episode-1"))?.paused).toBe(true);
+ await expect
+ .poll(async () => (await episodeElement(page, "ticket-episode-1"))?.paused)
+ .toBe(true);
await expect.poll(() => placed(beforePause)).toBe(true);
// Taken out of the bar, then started again from its page: it resumes where
@@ -757,8 +844,12 @@ test("plays an episode in the bar, across pages, and resumes it from its bookmar
await page.getByRole("button", { name: "Episodes" }).click();
await expect(first).toContainText("resumes at 0:1");
await first.getByRole("button", { name: "Play First night" }).click();
- await expect.poll(async () => (await episodeElement(page, "ticket-episode-1"))?.paused).toBe(false);
- expect((await episodeElement(page, "ticket-episode-1"))?.currentTime).toBeGreaterThanOrEqual(12);
+ await expect
+ .poll(async () => (await episodeElement(page, "ticket-episode-1"))?.paused)
+ .toBe(false);
+ expect(
+ (await episodeElement(page, "ticket-episode-1"))?.currentTime,
+ ).toBeGreaterThanOrEqual(12);
});
/** A one-pixel PNG, for the covers the artwork route is asked for. */
@@ -793,14 +884,22 @@ test("shows a channel's cover in its list, above its episodes and in the bar", a
channelOf("channel-2", "Day Shift", []),
];
await page.goto("/podcasts");
- const night = page.locator(".podcast-channels li").filter({ hasText: "Night Shift" });
- const day = page.locator(".podcast-channels li").filter({ hasText: "Day Shift" });
+ const night = page
+ .locator(".podcast-channels li")
+ .filter({ hasText: "Night Shift" });
+ const day = page
+ .locator(".podcast-channels li")
+ .filter({ hasText: "Day Shift" });
await expect(night.locator("img.resource-cover")).toBeVisible();
await expect(day.locator(".resource-cover.cover-fallback svg")).toBeVisible();
await night.getByRole("button", { name: "Episodes" }).click();
- await expect(page.locator(".podcast-episodes img.podcast-cover")).toBeVisible();
- const first = page.locator(".podcast-episodes li").filter({ hasText: "First night" });
+ await expect(
+ page.locator(".podcast-episodes img.podcast-cover"),
+ ).toBeVisible();
+ const first = page
+ .locator(".podcast-episodes li")
+ .filter({ hasText: "First night" });
await first.getByRole("button", { name: "Play First night" }).click();
const bar = page.locator(".player");
@@ -808,7 +907,9 @@ test("shows a channel's cover in its list, above its episodes and in the bar", a
await expect(bar.locator("img.player-cover")).toBeVisible();
await expect
.poll(() =>
- page.evaluate(() => navigator.mediaSession?.metadata?.artwork?.[0]?.src ?? ""),
+ page.evaluate(
+ () => navigator.mediaSession?.metadata?.artwork?.[0]?.src ?? "",
+ ),
)
.toMatch(/^blob:/);
// One request for the hash, however many places show it.
@@ -821,25 +922,39 @@ test("keeps an episode playing when another channel is opened", async ({
}) => {
await recordPlayedElements(page);
channels = [
- channelOf("channel-1", "Night Shift", [episode("episode-1", "First night", ready)]),
+ channelOf("channel-1", "Night Shift", [
+ episode("episode-1", "First night", ready),
+ ]),
channelOf("channel-2", "Day Shift", [
{ ...episode("episode-9", "First day", ready), channel_id: "channel-2" },
]),
];
await page.goto("/podcasts");
- const night = page.locator(".podcast-channels li").filter({ hasText: "Night Shift" });
+ const night = page
+ .locator(".podcast-channels li")
+ .filter({ hasText: "Night Shift" });
await night.getByRole("button", { name: "Episodes" }).click();
- const first = page.locator(".podcast-episodes li").filter({ hasText: "First night" });
+ const first = page
+ .locator(".podcast-episodes li")
+ .filter({ hasText: "First night" });
await first.getByRole("button", { name: "Play First night" }).click();
- await expect(first.getByRole("button", { name: "Pause First night" })).toBeVisible();
+ await expect(
+ first.getByRole("button", { name: "Pause First night" }),
+ ).toBeVisible();
// Another channel's list is only a list: the episode is the player's, and
// the bar goes on showing it.
- const day = page.locator(".podcast-channels li").filter({ hasText: "Day Shift" });
+ const day = page
+ .locator(".podcast-channels li")
+ .filter({ hasText: "Day Shift" });
await day.getByRole("button", { name: "Episodes" }).click();
await expect(page.getByRole("heading", { name: "Day Shift" })).toBeVisible();
- await expect(page.locator(".player .player-track strong")).toHaveText("First night");
- await expect.poll(async () => (await episodeElement(page, "ticket-episode-1"))?.paused).toBe(false);
+ await expect(page.locator(".player .player-track strong")).toHaveText(
+ "First night",
+ );
+ await expect
+ .poll(async () => (await episodeElement(page, "ticket-episode-1"))?.paused)
+ .toBe(false);
});
/**
@@ -863,7 +978,9 @@ test("forgets an episode heard to the end even when a write before it was slow",
await page.goto("/podcasts");
await page.getByRole("button", { name: "Episodes" }).click();
await page.getByRole("button", { name: "Play First night" }).click();
- await expect.poll(async () => (await episodeElement(page, "ticket-episode-1"))?.paused).toBe(false);
+ await expect
+ .poll(async () => (await episodeElement(page, "ticket-episode-1"))?.paused)
+ .toBe(false);
let release = () => {};
bookmarkGate = new Promise((resolve) => {
@@ -875,13 +992,21 @@ test("forgets an episode heard to the end even when a write before it was slow",
await seek.dispatchEvent("mouseup");
await expect.poll(() => bookmarkWrites.length).toBe(1);
// The episode ends while that write is still out.
- await expect.poll(async () => (await episodeElement(page, "ticket-episode-1"))?.paused).toBe(true);
- await expect(page.locator(".player").getByRole("button", { name: "Play" })).toBeVisible();
+ await expect
+ .poll(async () => (await episodeElement(page, "ticket-episode-1"))?.paused)
+ .toBe(true);
+ await expect(
+ page.locator(".player").getByRole("button", { name: "Play" }),
+ ).toBeVisible();
release();
// Both written, the forgetting last: what the server keeps is no place.
await expect
- .poll(() => bookmarkWrites.map((write) => (typeof write.body === "string" ? write.body : "PUT")))
+ .poll(() =>
+ bookmarkWrites.map((write) =>
+ typeof write.body === "string" ? write.body : "PUT",
+ ),
+ )
.toEqual(["PUT", "DELETE"]);
await expect
.poll(() => channels[0].episodes[0].position_ms, { timeout: 3_000 })
@@ -902,7 +1027,9 @@ test("plays no episode whose ticket lands after the listener signed out", async
test.skip(testInfo.project.name !== "desktop", "signs out from the sidebar");
await recordPlayedElements(page);
channels = [
- channelOf("channel-1", "Night Shift", [episode("episode-1", "First night", ready)]),
+ channelOf("channel-1", "Night Shift", [
+ episode("episode-1", "First night", ready),
+ ]),
];
let release = () => {};
ticketGate = new Promise((resolve) => {
@@ -910,8 +1037,10 @@ test("plays no episode whose ticket lands after the listener signed out", async
});
await page.goto("/podcasts");
await page.getByRole("button", { name: "Episodes" }).click();
- const asked = page.waitForRequest((request) =>
- request.url().includes("/podcast-episodes/") || request.url().includes("/stream-ticket"),
+ const asked = page.waitForRequest(
+ (request) =>
+ request.url().includes("/podcast-episodes/") ||
+ request.url().includes("/stream-ticket"),
);
await page.getByRole("button", { name: "Play First night" }).click();
await asked;
@@ -933,7 +1062,9 @@ test("plays no episode whose ticket lands after the listener signed out", async
expect(heard === null || heard.paused).toBe(true);
});
-test("offers a listener no podcast control it may not use", async ({ page }) => {
+test("offers a listener no podcast control it may not use", async ({
+ page,
+}) => {
canManage = false;
channels = [
{
@@ -956,10 +1087,16 @@ test("offers a listener no podcast control it may not use", async ({ page }) =>
await page.goto("/podcasts");
await expect(page.getByText("last fetch failed: http_500")).toBeVisible();
await expect(page.getByLabel("Feed address")).toHaveCount(0);
- await expect(page.getByRole("button", { name: "Refresh every feed" })).toHaveCount(0);
- await expect(page.getByRole("button", { name: "Unsubscribe" })).toHaveCount(0);
+ await expect(
+ page.getByRole("button", { name: "Refresh every feed" }),
+ ).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Unsubscribe" })).toHaveCount(
+ 0,
+ );
await page.getByRole("button", { name: "Episodes" }).click();
- const first = page.locator(".podcast-episodes li").filter({ hasText: "First night" });
+ const first = page
+ .locator(".podcast-episodes li")
+ .filter({ hasText: "First night" });
await expect(first).toContainText("not downloaded");
await expect(first.getByRole("button", { name: "Download" })).toHaveCount(0);
});
diff --git a/webapp/e2e/themes.spec.ts b/webapp/e2e/themes.spec.ts
index 0cbb6fd0..ab708bea 100644
--- a/webapp/e2e/themes.spec.ts
+++ b/webapp/e2e/themes.spec.ts
@@ -56,7 +56,12 @@ const albums = [
},
];
-const song = (index: number, title: string, rating: number, starred: boolean) => ({
+const song = (
+ index: number,
+ title: string,
+ rating: number,
+ starred: boolean,
+) => ({
id: `song-${index}`,
library_id: "library-1",
album_id: "album-2",
@@ -210,7 +215,10 @@ test("draws a card per preset under a Light and a Dark heading", async ({
// A first visit keeps the default, and its card is the checked one, with
// the accent's edge and the check that says so.
- await expect(page.locator("html")).toHaveAttribute("data-theme", "default-dark");
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-theme",
+ "default-dark",
+ );
const current = themes.getByRole("radio", { checked: true });
await expect(current).toHaveCount(1);
await expect(current).toHaveAccessibleName("Emerald dark");
@@ -271,7 +279,10 @@ test("walks the cards with the arrow keys, selecting as it goes", async ({
await page.keyboard.press("ArrowLeft");
const oceanLight = themes.getByRole("radio", { name: "Ocean light" });
await expect(oceanLight).toBeChecked();
- await expect(page.locator("html")).toHaveAttribute("data-theme", "ocean-light");
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-theme",
+ "ocean-light",
+ );
// And the tab order still holds one stop: out and back lands on it.
await page.keyboard.press("Shift+Tab");
@@ -311,7 +322,9 @@ test("follows the device between a preset and its pair, live", async ({
// Picking a card picks its family: Sunset light, at night, is Sunset.
await themes.getByRole("radio", { name: "Sunset light" }).click();
await expect(html).toHaveAttribute("data-theme", "sunset");
- await expect(themes.getByRole("radio", { name: "Sunset", exact: true })).toBeChecked();
+ await expect(
+ themes.getByRole("radio", { name: "Sunset", exact: true }),
+ ).toBeChecked();
// Kept per browser, beside the theme, and read back on the next visit.
await expect
@@ -328,7 +341,9 @@ test("follows the device between a preset and its pair, live", async ({
await themes.getByRole("radio", { name: "OLED black" }).click();
await expect(html).toHaveAttribute("data-theme", "default");
await expect(
- page.getByText("OLED black has no light version, so Emerald light stands in"),
+ page.getByText(
+ "OLED black has no light version, so Emerald light stands in",
+ ),
).toBeVisible();
await page.emulateMedia({ colorScheme: "dark" });
await expect(html).toHaveAttribute("data-theme", "oled");
@@ -399,7 +414,10 @@ test("paints the page in each dark preset's own surfaces", async ({
// And the card that chose it shows what the page now paints.
const card = page.locator(`.theme-card[data-theme-id="${id}"]`);
expect(
- await backgroundOf(page, `.theme-card[data-theme-id="${id}"] .theme-card-preview`),
+ await backgroundOf(
+ page,
+ `.theme-card[data-theme-id="${id}"] .theme-card-preview`,
+ ),
`${id}: the card's ground`,
).toBe(body);
expect(
@@ -419,13 +437,17 @@ test("reflows the cards at any width without scrolling sideways", async ({
await page.goto("/settings");
await expect(picker(page)).toBeVisible();
const overflow = await page.evaluate(
- () => document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ () =>
+ document.documentElement.scrollWidth -
+ document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
// Every card inside the viewport's width, and at least two abreast.
- const boxes = await page.locator(".theme-card").evaluateAll((cards) =>
- cards.map((card) => card.getBoundingClientRect().toJSON()),
- );
+ const boxes = await page
+ .locator(".theme-card")
+ .evaluateAll((cards) =>
+ cards.map((card) => card.getBoundingClientRect().toJSON()),
+ );
const width = await page.evaluate(() => document.documentElement.clientWidth);
for (const box of boxes) expect(box.right).toBeLessThanOrEqual(width);
const firstRow = boxes.filter((box) => box.top === boxes[0]?.top);
@@ -440,7 +462,9 @@ test("drops the cards' motion when the device asks for less", async ({
const card = page.locator(".theme-card").first();
await expect(card).toBeVisible();
const durations = await card.evaluate((element) =>
- getComputedStyle(element).transitionDuration.split(",").map((value) => value.trim()),
+ getComputedStyle(element)
+ .transitionDuration.split(",")
+ .map((value) => value.trim()),
);
expect(durations.every((value) => value === "0s")).toBe(true);
});
@@ -474,7 +498,10 @@ for (const preset of THEME_PRESETS) {
}, preset.id);
for (const screen of SCREENS) {
await page.goto(screen.path);
- await expect(page.locator("html")).toHaveAttribute("data-theme", preset.id);
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-theme",
+ preset.id,
+ );
await screen.ready(page);
await expect(page.locator(".player")).toBeVisible();
await settle(page);
@@ -483,7 +510,10 @@ for (const preset of THEME_PRESETS) {
.analyze();
expect(
results.violations.flatMap((violation) =>
- violation.nodes.map((node) => `${screen.path} ${node.target.join(" ")}: ${node.failureSummary}`),
+ violation.nodes.map(
+ (node) =>
+ `${screen.path} ${node.target.join(" ")}: ${node.failureSummary}`,
+ ),
),
).toEqual([]);
}
diff --git a/webapp/e2e/track.spec.ts b/webapp/e2e/track.spec.ts
index c69301ce..74ac480f 100644
--- a/webapp/e2e/track.spec.ts
+++ b/webapp/e2e/track.spec.ts
@@ -77,7 +77,9 @@ test("opens a track's properties from its context menu", async ({ page }) => {
for (let press = 0; press < 6; press += 1) {
await page.keyboard.press("ArrowDown");
}
- await expect(menu.getByRole("menuitem", { name: "Properties" })).toBeFocused();
+ await expect(
+ menu.getByRole("menuitem", { name: "Properties" }),
+ ).toBeFocused();
await page.keyboard.press("Enter");
const panel = page.getByRole("dialog", { name: "Army Of Me" });
@@ -296,8 +298,7 @@ test("downloads a track from its context menu", async ({ page }) => {
await expect
.poll(() =>
page.evaluate(
- () =>
- (window as unknown as { __saved: unknown[] }).__saved,
+ () => (window as unknown as { __saved: unknown[] }).__saved,
),
)
.toEqual([{ href: "/api/v2/stream/ticket-9?download=1", download: "" }]);
@@ -392,7 +393,9 @@ test("offers a tag correction only to an owner or a manager", async ({
await expect(page.getByRole("heading", { name: "Vespertine" })).toBeVisible();
await expect(links).toHaveCount(3);
- mock.libraries = [{ ...library("library-1", "Ma musique"), role: "listener" }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), role: "listener" },
+ ];
await page.reload();
await expect(page.getByRole("heading", { name: "Vespertine" })).toBeVisible();
// The row's own marks cell, where the link would be. Once its rating is in
@@ -446,13 +449,22 @@ test("keeps an album, the player bar and a track's properties readable in the li
);
await page.route("**/api/v2/tracks/song-9", (route) =>
route.fulfill({
- json: { ...mock.correctable.song, relative_path: "Björk/Post/09 Army Of Me.flac" },
+ json: {
+ ...mock.correctable.song,
+ relative_path: "Björk/Post/09 Army Of Me.flac",
+ },
}),
);
await page.goto("/songs");
- await page.locator(".songs tbody tr").first().locator("button.song-title").focus();
+ await page
+ .locator(".songs tbody tr")
+ .first()
+ .locator("button.song-title")
+ .focus();
await page.keyboard.press("Shift+F10");
- await expect(page.getByRole("menu", { name: "Actions for Army Of Me" })).toBeVisible();
+ await expect(
+ page.getByRole("menu", { name: "Actions for Army Of Me" }),
+ ).toBeVisible();
await expectNoWcagViolations(page);
await page.getByRole("menuitem", { name: "Properties" }).click();
await expect(page.getByRole("dialog", { name: "Army Of Me" })).toBeVisible();
diff --git a/webapp/e2e/tsconfig.json b/webapp/e2e/tsconfig.json
new file mode 100644
index 00000000..5d6d3721
--- /dev/null
+++ b/webapp/e2e/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "types": ["node"]
+ },
+ "include": ["**/*.ts", "../playwright.config.ts"]
+}
diff --git a/webapp/e2e/uploads.spec.ts b/webapp/e2e/uploads.spec.ts
index 4f3af8b8..d068a93a 100644
--- a/webapp/e2e/uploads.spec.ts
+++ b/webapp/e2e/uploads.spec.ts
@@ -23,7 +23,9 @@ test.beforeEach(resetAndMockApi);
test("uploads a file in fragments, resuming from the server's account", async ({
page,
}) => {
- mock.libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), accepts_uploads: true },
+ ];
mock.loseAcknowledgementOf = 1;
const bytes = Buffer.from(Array.from({ length: 20 }, (_, i) => i));
@@ -43,12 +45,16 @@ test("uploads a file in fragments, resuming from the server's account", async ({
expect(mock.uploadOffers).toEqual([
{ full_hash: bytesToHex(blake3(bytes)), size_bytes: 20, extension: "flac" },
]);
- expect(mock.uploadChunks.map(({ index, bytes: sent }) => [index, sent.length])).toEqual([
+ expect(
+ mock.uploadChunks.map(({ index, bytes: sent }) => [index, sent.length]),
+ ).toEqual([
[0, 8],
[1, 8],
[2, 4],
]);
- expect(mock.uploadChunks.flatMap(({ bytes: sent }) => sent)).toEqual([...bytes]);
+ expect(mock.uploadChunks.flatMap(({ bytes: sent }) => sent)).toEqual([
+ ...bytes,
+ ]);
expect(mock.uploadCommits).toEqual(["upload-1"]);
await expectNoWcagViolations(page);
@@ -61,7 +67,9 @@ test("uploads a file in fragments, resuming from the server's account", async ({
test("refuses a file the library cannot index, before hashing it", async ({
page,
}) => {
- mock.libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), accepts_uploads: true },
+ ];
await page.goto("/upload");
await page.getByLabel("Choose audio files").setInputFiles({
name: "notes.txt",
@@ -83,14 +91,24 @@ test("refuses a file the library cannot index, before hashing it", async ({
test("fails a file whose fingerprinting worker cannot load, and moves on", async ({
page,
}) => {
- mock.libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), accepts_uploads: true },
+ ];
await page.route("**/assets/hash-worker-*.js", (route) =>
route.fulfill({ status: 404, body: "" }),
);
await page.goto("/upload");
await page.getByLabel("Choose audio files").setInputFiles([
- { name: "first.flac", mimeType: "audio/flac", buffer: Buffer.from([1, 2, 3]) },
- { name: "second.flac", mimeType: "audio/flac", buffer: Buffer.from([4, 5, 6]) },
+ {
+ name: "first.flac",
+ mimeType: "audio/flac",
+ buffer: Buffer.from([1, 2, 3]),
+ },
+ {
+ name: "second.flac",
+ mimeType: "audio/flac",
+ buffer: Buffer.from([4, 5, 6]),
+ },
]);
await expect(
page.getByText("Failed: this browser could not fingerprint the file"),
@@ -118,7 +136,9 @@ test("offers the upload only where the library takes files and the role may add
await expect(account).toBeVisible();
await expect(link).toHaveCount(0);
- mock.libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }];
+ mock.libraries = [
+ { ...library("library-1", "Ma musique"), accepts_uploads: true },
+ ];
await page.reload();
await expect(account).toBeVisible();
await expect(link).toHaveCount(1);
diff --git a/webapp/e2e/workspace-pages.spec.ts b/webapp/e2e/workspace-pages.spec.ts
index edced121..b31e4231 100644
--- a/webapp/e2e/workspace-pages.spec.ts
+++ b/webapp/e2e/workspace-pages.spec.ts
@@ -130,7 +130,11 @@ function stationFrom(id: string, draft: Record): Station {
async function mockApi(page: Page) {
await page.context().addCookies([
- { name: "waveflow-csrf", value: "e2e-csrf", url: "http://127.0.0.1:4173" },
+ {
+ name: "waveflow-csrf",
+ value: "e2e-csrf",
+ url: "http://127.0.0.1:4173",
+ },
]);
await page.route("**/api/v2/**", async (route) => {
const request = route.request();
@@ -198,7 +202,9 @@ async function mockApi(page: Page) {
json: {
enabled: true,
can_manage: canManage,
- channels: channels.map(({ episodes: _episodes, ...listed }) => listed),
+ channels: channels.map(
+ ({ episodes: _episodes, ...listed }) => listed,
+ ),
},
});
return;
@@ -258,8 +264,12 @@ test("adds a station in a dialog, and edits and deletes it from its card's menu"
page,
}) => {
await page.goto("/radio");
- await expect(page.getByRole("heading", { name: "Radio", exact: true })).toBeVisible();
- await expect(page.getByText("No stations yet. Add one to keep it here.")).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Radio", exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByText("No stations yet. Add one to keep it here."),
+ ).toBeVisible();
const opener = page.getByRole("button", { name: "Add station" });
await opener.click();
@@ -271,9 +281,15 @@ test("adds a station in a dialog, and edits and deletes it from its card's menu"
await expect(
dialog.getByText("An address must start with http:// or https://."),
).toBeVisible();
- await expect(dialog.getByRole("button", { name: "Save station" })).toBeDisabled();
- await dialog.getByLabel("Stream address").fill("https://nova.example.org/live");
- await dialog.getByLabel("Station page (optional)").fill("https://nova.example.org/");
+ await expect(
+ dialog.getByRole("button", { name: "Save station" }),
+ ).toBeDisabled();
+ await dialog
+ .getByLabel("Stream address")
+ .fill("https://nova.example.org/live");
+ await dialog
+ .getByLabel("Station page (optional)")
+ .fill("https://nova.example.org/");
await expect(dialog.getByLabel("Station image (optional)")).toBeVisible();
await expectNoWcagViolations(page);
await dialog.getByRole("button", { name: "Save station" }).click();
@@ -285,11 +301,12 @@ test("adds a station in a dialog, and edits and deletes it from its card's menu"
const grid = page.getByRole("list", { name: "Stations" });
const card = grid.getByRole("listitem").filter({ hasText: "Radio Nova" });
await expect(card).toHaveCount(1);
- await expect(card.getByRole("button", { name: "Listen to Radio Nova" })).toBeVisible();
- await expect(card.getByRole("link", { name: "Station page" })).toHaveAttribute(
- "href",
- "https://nova.example.org/",
- );
+ await expect(
+ card.getByRole("button", { name: "Listen to Radio Nova" }),
+ ).toBeVisible();
+ await expect(
+ card.getByRole("link", { name: "Station page" }),
+ ).toHaveAttribute("href", "https://nova.example.org/");
// No delete button sits on the card any more: it is in the menu.
await expect(card.getByRole("button", { name: "Delete" })).toHaveCount(0);
await expectNoWcagViolations(page);
@@ -323,21 +340,29 @@ test("adds a station in a dialog, and edits and deletes it from its card's menu"
expect(radioWrites).toEqual(["POST", "PUT station-1"]);
// A delete is asked first; declined, nothing is sent.
- const renamed = grid.getByRole("listitem").filter({ hasText: "Radio Nova Classics" });
+ const renamed = grid
+ .getByRole("listitem")
+ .filter({ hasText: "Radio Nova Classics" });
page.once("dialog", (question) => {
expect(question.message()).toContain("Radio Nova Classics");
void question.dismiss();
});
- await renamed.getByRole("button", { name: "Actions for Radio Nova Classics" }).click();
+ await renamed
+ .getByRole("button", { name: "Actions for Radio Nova Classics" })
+ .click();
await page.getByRole("menuitem", { name: "Delete" }).click();
await expect(grid.getByRole("listitem")).toHaveCount(1);
expect(radioWrites).toEqual(["POST", "PUT station-1"]);
// Confirmed, it goes.
page.once("dialog", (question) => void question.accept());
- await renamed.getByRole("button", { name: "Actions for Radio Nova Classics" }).click();
+ await renamed
+ .getByRole("button", { name: "Actions for Radio Nova Classics" })
+ .click();
await page.getByRole("menuitem", { name: "Delete" }).click();
- await expect(page.getByText("No stations yet. Add one to keep it here.")).toBeVisible();
+ await expect(
+ page.getByText("No stations yet. Add one to keep it here."),
+ ).toBeVisible();
expect(radioWrites).toEqual(["POST", "PUT station-1", "DELETE station-1"]);
});
@@ -345,7 +370,9 @@ test("adds a station in a dialog, and edits and deletes it from its card's menu"
* The dialog cannot be put away while it saves: Escape would otherwise close
* the form under a request that is about to report into it.
*/
-test("holds the station dialog open while its save is in flight", async ({ page }) => {
+test("holds the station dialog open while its save is in flight", async ({
+ page,
+}) => {
let release = () => {};
radioGate = new Promise((resolve) => {
release = resolve;
@@ -354,7 +381,9 @@ test("holds the station dialog open while its save is in flight", async ({ page
await page.getByRole("button", { name: "Add station" }).click();
const dialog = page.getByRole("dialog", { name: "Add a station" });
await dialog.getByLabel("Station name").fill("FIP");
- await dialog.getByLabel("Stream address").fill("https://fip.example.org/live");
+ await dialog
+ .getByLabel("Stream address")
+ .fill("https://fip.example.org/live");
await dialog.getByRole("button", { name: "Save station" }).click();
await expect.poll(() => radioWrites).toEqual(["POST"]);
@@ -395,7 +424,9 @@ test("explains an empty podcasts page to a reader, with nothing to press", async
canManage = false;
await page.goto("/podcasts");
const empty = page.locator(".empty-state");
- await expect(empty).toContainText("every account on this server can listen to them");
+ await expect(empty).toContainText(
+ "every account on this server can listen to them",
+ );
await expect(empty.getByRole("button")).toHaveCount(0);
await expect(page.getByLabel("Feed address")).toHaveCount(0);
await expectNoWcagViolations(page);
@@ -406,10 +437,14 @@ test("explains an empty podcasts page to a reader, with nothing to press", async
* only the folders that were made: so it is drawn apart from them, under a
* heading of its own that says what it is.
*/
-test("draws To sort apart from the folders the header counts", async ({ page }) => {
+test("draws To sort apart from the folders the header counts", async ({
+ page,
+}) => {
folders = [{ id: "folder-1", parent_id: null, name: "Jazz", track_count: 4 }];
await page.goto("/folders");
- await expect(page.getByText("1 folder created in this library")).toBeVisible();
+ await expect(
+ page.getByText("1 folder created in this library"),
+ ).toBeVisible();
const made = page.getByRole("list", { name: "Folders" });
await expect(made.getByRole("link")).toHaveCount(1);
await expect(made.getByRole("link", { name: /Jazz/ })).toBeVisible();
@@ -419,9 +454,11 @@ test("draws To sort apart from the folders the header counts", async ({ page })
await expect(kept).toContainText(
"it is not counted among the folders above, and it cannot be renamed, moved or deleted",
);
- const tray = kept.getByRole("list", { name: "Kept by WaveFlow" }).getByRole("link", {
- name: /To sort/,
- });
+ const tray = kept
+ .getByRole("list", { name: "Kept by WaveFlow" })
+ .getByRole("link", {
+ name: /To sort/,
+ });
await expect(tray).toContainText("12 tracks");
await expect(tray).toContainText("Automatic");
await expectNoWcagViolations(page);
@@ -430,24 +467,37 @@ test("draws To sort apart from the folders the header counts", async ({ page })
await expect(page.getByRole("heading", { name: "To sort" })).toBeVisible();
});
-test("says no folder was made yet, and still offers To sort", async ({ page }) => {
+test("says no folder was made yet, and still offers To sort", async ({
+ page,
+}) => {
await page.goto("/folders");
- await expect(page.getByText("0 folders created in this library")).toBeVisible();
+ await expect(
+ page.getByText("0 folders created in this library"),
+ ).toBeVisible();
await expect(
page.getByText("No folder has been created in this library yet."),
).toBeVisible();
await expect(page.getByRole("list", { name: "Folders" })).toHaveCount(0);
await expect(
- page.getByRole("list", { name: "Kept by WaveFlow" }).getByRole("link", { name: /To sort/ }),
+ page
+ .getByRole("list", { name: "Kept by WaveFlow" })
+ .getByRole("link", { name: /To sort/ }),
).toBeVisible();
});
-const WORKSPACE_PAGES = ["/radio", "/podcasts", "/folders", "/maintenance/quality"];
+const WORKSPACE_PAGES = [
+ "/radio",
+ "/podcasts",
+ "/folders",
+ "/maintenance/quality",
+];
async function mainWidth(page: Page, path: string) {
await page.goto(path);
await expect(page.locator("main h2").first()).toBeVisible();
- return page.locator("main").evaluate((main) => main.getBoundingClientRect().width);
+ return page
+ .locator("main")
+ .evaluate((main) => main.getBoundingClientRect().width);
}
/**
@@ -459,7 +509,12 @@ test("gives the workspace pages more of a wide window, and not the editorial one
}, testInfo) => {
test.skip(testInfo.project.name !== "desktop", "a wide window");
await page.setViewportSize({ width: 2560, height: 1440 });
- stations = [stationFrom("station-1", { name: "Radio Nova", stream_url: "https://nova.example.org/live" })];
+ stations = [
+ stationFrom("station-1", {
+ name: "Radio Nova",
+ stream_url: "https://nova.example.org/live",
+ }),
+ ];
const editorial = await mainWidth(page, "/");
// 105rem, the contained column.
expect(editorial).toBe(1680);
@@ -482,17 +537,23 @@ test("keeps the workspace pages inside a phone and a short window", async ({
stations = [
stationFrom("station-1", {
name: "A station whose name is far too long to fit on one card",
- stream_url: "https://a-very-long-host-name.example.org/a/very/long/stream/path.mp3",
+ stream_url:
+ "https://a-very-long-host-name.example.org/a/very/long/stream/path.mp3",
homepage_url: "https://example.org/",
}),
- stationFrom("station-2", { name: "FIP", stream_url: "https://fip.example.org/live" }),
+ stationFrom("station-2", {
+ name: "FIP",
+ stream_url: "https://fip.example.org/live",
+ }),
];
folders = [{ id: "folder-1", parent_id: null, name: "Jazz", track_count: 4 }];
for (const path of WORKSPACE_PAGES) {
await page.goto(path);
await expect(page.locator("main h2").first()).toBeVisible();
const overflow = await page.evaluate(
- () => document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ () =>
+ document.documentElement.scrollWidth -
+ document.documentElement.clientWidth,
);
expect(overflow, path).toBe(0);
if (testInfo.project.name === "mobile") {
@@ -503,7 +564,9 @@ test("keeps the workspace pages inside a phone and a short window", async ({
}
}
await page.goto("/radio");
- await expect(page.getByRole("list", { name: "Stations" }).getByRole("listitem")).toHaveCount(2);
+ await expect(
+ page.getByRole("list", { name: "Stations" }).getByRole("listitem"),
+ ).toHaveCount(2);
await expectNoWcagViolations(page);
});
@@ -550,11 +613,18 @@ for (const [width, height] of [
twoShows();
await page.goto("/podcasts");
const panel = page.locator("#podcast-panel");
- await expect(panel.getByRole("heading", { name: "Night Shift" })).toBeVisible();
+ await expect(
+ panel.getByRole("heading", { name: "Night Shift" }),
+ ).toBeVisible();
await expect(panel).toContainText("Second night");
- expect(await besideTheList(page)).toMatchObject({ right: true, overlap: true });
+ expect(await besideTheList(page)).toMatchObject({
+ right: true,
+ overlap: true,
+ });
const overflow = await page.evaluate(
- () => document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ () =>
+ document.documentElement.scrollWidth -
+ document.documentElement.clientWidth,
);
expect(overflow).toBe(0);
@@ -565,22 +635,31 @@ for (const [width, height] of [
.getByRole("button", { name: "Episodes" });
await day.click();
await expect(page).toHaveURL(/[?&]channel=channel-2/);
- await expect(panel.getByRole("heading", { name: "Day Shift" })).toBeVisible();
+ await expect(
+ panel.getByRole("heading", { name: "Day Shift" }),
+ ).toBeVisible();
await expect(panel).toContainText("First day");
await expect(panel).not.toContainText("First night");
await expect(day).toBeFocused();
await expect(day).toHaveAttribute("aria-pressed", "true");
- await expect(page.getByRole("status").filter({ hasText: "Showing" })).toHaveText(
- "Showing the episodes of Day Shift.",
- );
- expect(await besideTheList(page)).toMatchObject({ right: true, overlap: true });
+ await expect(
+ page.getByRole("status").filter({ hasText: "Showing" }),
+ ).toHaveText("Showing the episodes of Day Shift.");
+ expect(await besideTheList(page)).toMatchObject({
+ right: true,
+ overlap: true,
+ });
await expectNoWcagViolations(page);
// Back returns to the first show; a reload keeps the one named.
await page.goBack();
- await expect(panel.getByRole("heading", { name: "Night Shift" })).toBeVisible();
+ await expect(
+ panel.getByRole("heading", { name: "Night Shift" }),
+ ).toBeVisible();
await page.goto("/podcasts?channel=channel-2");
- await expect(panel.getByRole("heading", { name: "Day Shift" })).toBeVisible();
+ await expect(
+ panel.getByRole("heading", { name: "Day Shift" }),
+ ).toBeVisible();
});
}
@@ -592,7 +671,9 @@ test("opens the first show when the one in the address is gone", async ({
twoShows();
await page.goto("/podcasts?channel=channel-gone");
await expect(
- page.locator("#podcast-panel").getByRole("heading", { name: "Night Shift" }),
+ page
+ .locator("#podcast-panel")
+ .getByRole("heading", { name: "Night Shift" }),
).toBeVisible();
});
@@ -634,7 +715,9 @@ test("scrolls a long feed inside its panel, leaving the shows in reach", async (
});
/** On a phone the episodes stay under the list, opened and closed on request. */
-test("stacks the episodes under the shows on a phone", async ({ page }, testInfo) => {
+test("stacks the episodes under the shows on a phone", async ({
+ page,
+}, testInfo) => {
test.skip(testInfo.project.name !== "mobile", "a phone");
twoShows();
await page.goto("/podcasts");
@@ -660,8 +743,14 @@ test("leaves a card's menu by Tab from its button, not from the page's end", asy
page,
}) => {
stations = [
- stationFrom("station-1", { name: "Radio Nova", stream_url: "https://nova.example.org/live" }),
- stationFrom("station-2", { name: "FIP", stream_url: "https://fip.example.org/live" }),
+ stationFrom("station-1", {
+ name: "Radio Nova",
+ stream_url: "https://nova.example.org/live",
+ }),
+ stationFrom("station-2", {
+ name: "FIP",
+ stream_url: "https://fip.example.org/live",
+ }),
];
await page.goto("/radio");
const more = page.getByRole("button", { name: "Actions for Radio Nova" });
@@ -672,12 +761,16 @@ test("leaves a card's menu by Tab from its button, not from the page's end", asy
await expect(menu.getByRole("menuitem", { name: "Edit" })).toBeFocused();
await page.keyboard.press("Tab");
await expect(menu).toHaveCount(0);
- await expect(page.getByRole("button", { name: "Listen to FIP" })).toBeFocused();
+ await expect(
+ page.getByRole("button", { name: "Listen to FIP" }),
+ ).toBeFocused();
await more.focus();
await page.keyboard.press("Enter");
await expect(menu.getByRole("menuitem", { name: "Edit" })).toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect(menu).toHaveCount(0);
- await expect(page.getByRole("button", { name: "Listen to Radio Nova" })).toBeFocused();
+ await expect(
+ page.getByRole("button", { name: "Listen to Radio Nova" }),
+ ).toBeFocused();
});
diff --git a/webapp/package.json b/webapp/package.json
index 7ba9ced5..bae8708b 100644
--- a/webapp/package.json
+++ b/webapp/package.json
@@ -8,7 +8,9 @@
"dev": "vite dev --port 3000",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
- "typecheck": "tsc --noEmit",
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p e2e",
+ "typecheck:node": "tsc --noEmit -p tsconfig.node.json",
+ "typecheck:e2e": "tsc --noEmit -p e2e",
"test": "vitest run",
"test:e2e": "playwright test",
"lint": "biome check .",
@@ -27,6 +29,7 @@
"@axe-core/playwright": "^4.14.0",
"@biomejs/biome": "^2.6.2",
"@playwright/test": "^1.64.1",
+ "@types/node": "^26.6.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
diff --git a/webapp/src/app/more-sheet.tsx b/webapp/src/app/more-sheet.tsx
index bc4c28f9..bdb755aa 100644
--- a/webapp/src/app/more-sheet.tsx
+++ b/webapp/src/app/more-sheet.tsx
@@ -2,7 +2,7 @@
import { Link } from "@tanstack/react-router";
import type { MouseEvent, RefObject } from "react";
-import { Icon } from "../components/icons";
+import { Icon } from "../components/layout/icons";
import { useI18n } from "../state/i18n";
import type { NavigationTarget, navigation } from "./navigation-entries";
diff --git a/webapp/src/app/navigation-entries.ts b/webapp/src/app/navigation-entries.ts
index 99e4cf38..c689472d 100644
--- a/webapp/src/app/navigation-entries.ts
+++ b/webapp/src/app/navigation-entries.ts
@@ -1,5 +1,5 @@
// The rail's entries: their groups, labels, icons and addresses.
-import type { IconName } from "../components/icons";
+import type { IconName } from "../components/layout/icons";
import type { TranslationKey } from "../state/i18n";
/**
diff --git a/webapp/src/app/navigation.tsx b/webapp/src/app/navigation.tsx
index 880b2ef4..766d1747 100644
--- a/webapp/src/app/navigation.tsx
+++ b/webapp/src/app/navigation.tsx
@@ -18,9 +18,9 @@ import {
songsQuery,
statsQuery,
} from "../api/queries";
-import { Icon } from "../components/icons";
+import { Icon } from "../components/layout/icons";
import { localDayKey } from "../lib/local-day";
-import { maintenanceSections } from "../pages/maintenance-hub";
+import { maintenanceSections } from "../pages/maintenance/maintenance-hub";
import { useI18n } from "../state/i18n";
import { useLibraryScope } from "../state/library-scope";
import { MoreSheet } from "./more-sheet";
diff --git a/webapp/src/app/routes/library.tsx b/webapp/src/app/routes/library.tsx
index 204de26a..36e98432 100644
--- a/webapp/src/app/routes/library.tsx
+++ b/webapp/src/app/routes/library.tsx
@@ -1,21 +1,20 @@
// The catalogue's routes, the account's lists and listening, the editor.
import { createRoute } from "@tanstack/react-router";
-
-import { TrackEditorPage } from "../../components/track-editor";
-import { AlbumPage } from "../../pages/album-page";
-import { AlbumsPage } from "../../pages/albums-page";
-import { ArtistPage } from "../../pages/artist-page";
-import { ArtistsPage } from "../../pages/artists-page";
-import { FavoritesPage } from "../../pages/favorites-page";
-import { FoldersPage } from "../../pages/folders-page";
-import { GenrePage } from "../../pages/genre-page";
-import { GenresPage } from "../../pages/genres-page";
-import { HistoryPage } from "../../pages/history-page";
-import { MixPage } from "../../pages/mix-page";
-import { PlaylistsPage } from "../../pages/playlists-page";
-import { SearchPage } from "../../pages/search-page";
-import { SongsPage } from "../../pages/songs-page";
-import { StatsPage } from "../../pages/stats-page";
+import { AlbumPage } from "../../pages/library/album-page";
+import { AlbumsPage } from "../../pages/library/albums-page";
+import { ArtistPage } from "../../pages/library/artist-page";
+import { ArtistsPage } from "../../pages/library/artists-page";
+import { FavoritesPage } from "../../pages/library/favorites-page";
+import { FoldersPage } from "../../pages/library/folders-page";
+import { GenrePage } from "../../pages/library/genre-page";
+import { GenresPage } from "../../pages/library/genres-page";
+import { HistoryPage } from "../../pages/library/history-page";
+import { MixPage } from "../../pages/library/mix-page";
+import { PlaylistsPage } from "../../pages/library/playlists-page";
+import { SearchPage } from "../../pages/library/search-page";
+import { SongsPage } from "../../pages/library/songs-page";
+import { StatsPage } from "../../pages/library/stats-page";
+import { TrackEditorPage } from "../../pages/library/track-editor";
import { authedRoute } from "./root";
export const albumsRoute = createRoute({
diff --git a/webapp/src/app/routes/listen.tsx b/webapp/src/app/routes/listen.tsx
index fb35cd20..c07066bb 100644
--- a/webapp/src/app/routes/listen.tsx
+++ b/webapp/src/app/routes/listen.tsx
@@ -1,11 +1,11 @@
// The listening routes: now playing, the queue, random, radio, podcasts.
import { createRoute } from "@tanstack/react-router";
-import { PlayingPage } from "../../pages/playing-page";
-import { PodcastsPage } from "../../pages/podcasts-page";
-import { QueuePage } from "../../pages/queue-page";
-import { RadioPage } from "../../pages/radio-page";
-import { RandomPage } from "../../pages/random-page";
+import { PlayingPage } from "../../pages/listen/playing-page";
+import { PodcastsPage } from "../../pages/listen/podcasts-page";
+import { QueuePage } from "../../pages/listen/queue-page";
+import { RadioPage } from "../../pages/listen/radio-page";
+import { RandomPage } from "../../pages/listen/random-page";
import { authedRoute } from "./root";
export const randomRoute = createRoute({
diff --git a/webapp/src/app/routes/maintenance.tsx b/webapp/src/app/routes/maintenance.tsx
index ded0a61c..1d752e4b 100644
--- a/webapp/src/app/routes/maintenance.tsx
+++ b/webapp/src/app/routes/maintenance.tsx
@@ -1,10 +1,19 @@
// Maintenance and its sections, and the addresses they had before it.
import { createRoute, redirect } from "@tanstack/react-router";
-import { AttentionPage, isAttentionCategory } from "../../pages/attention-page";
-import { MaintenanceHub, MaintenanceIndex } from "../../pages/maintenance-hub";
-import { MissingPage } from "../../pages/missing-page";
-import { isQualityVerdict, QualityPage } from "../../pages/quality-page";
+import {
+ AttentionPage,
+ isAttentionCategory,
+} from "../../pages/maintenance/attention-page";
+import {
+ MaintenanceHub,
+ MaintenanceIndex,
+} from "../../pages/maintenance/maintenance-hub";
+import { MissingPage } from "../../pages/maintenance/missing-page";
+import {
+ isQualityVerdict,
+ QualityPage,
+} from "../../pages/maintenance/quality-page";
import { authedRoute } from "./root";
/**
diff --git a/webapp/src/app/routes/root.tsx b/webapp/src/app/routes/root.tsx
index 77b43837..53c1c838 100644
--- a/webapp/src/app/routes/root.tsx
+++ b/webapp/src/app/routes/root.tsx
@@ -2,11 +2,11 @@
import { createRootRoute, createRoute, redirect } from "@tanstack/react-router";
import { ensureSession } from "../../api";
-import { AuthorizePage } from "../../pages/authorize-page";
-import { LoginPage } from "../../pages/login-page";
-import { NotFoundPage } from "../../pages/not-found-page";
-import { OfflinePage } from "../../pages/offline-page";
-import { SharePage } from "../../pages/share-page";
+import { AuthorizePage } from "../../pages/root/authorize-page";
+import { LoginPage } from "../../pages/root/login-page";
+import { NotFoundPage } from "../../pages/root/not-found-page";
+import { OfflinePage } from "../../pages/root/offline-page";
+import { SharePage } from "../../pages/root/share-page";
import { RootFrame, Shell } from "../shell";
export const rootRoute = createRootRoute({
diff --git a/webapp/src/app/routes/settings.tsx b/webapp/src/app/routes/settings.tsx
index a23f2cc4..8327a540 100644
--- a/webapp/src/app/routes/settings.tsx
+++ b/webapp/src/app/routes/settings.tsx
@@ -2,12 +2,15 @@
import { createRoute, redirect } from "@tanstack/react-router";
import { currentUser } from "../../api";
-import { AdminPage } from "../../pages/admin-page";
-import { ScrobblingPage } from "../../pages/scrobbling-page";
-import { SettingsHub } from "../../pages/settings-hub";
-import { PlaybackSettingsPage, SettingsPage } from "../../pages/settings-page";
-import { SharesPage } from "../../pages/shares-page";
-import { UploadPage } from "../../pages/upload-page";
+import { AdminPage } from "../../pages/settings/admin-page";
+import { ScrobblingPage } from "../../pages/settings/scrobbling-page";
+import { SettingsHub } from "../../pages/settings/settings-hub";
+import {
+ PlaybackSettingsPage,
+ SettingsPage,
+} from "../../pages/settings/settings-page";
+import { SharesPage } from "../../pages/settings/shares-page";
+import { UploadPage } from "../../pages/settings/upload-page";
import { authedRoute } from "./root";
/**
diff --git a/webapp/src/app/shell.tsx b/webapp/src/app/shell.tsx
index ce8ec6a2..07d1ad6b 100644
--- a/webapp/src/app/shell.tsx
+++ b/webapp/src/app/shell.tsx
@@ -2,8 +2,8 @@
import { Outlet, useMatches } from "@tanstack/react-router";
import { useEffect } from "react";
-import { CoverLight } from "../components/cover-light";
-import { Waiting } from "../components/page-shell";
+import { CoverLight } from "../components/artwork/cover-light";
+import { Waiting } from "../components/layout/page-shell";
import { dismissSplash } from "../lib/splash";
import { PlayerBar, PlayerProvider } from "../player/player";
import { useI18n } from "../state/i18n";
diff --git a/webapp/src/components/artist-image-controls.tsx b/webapp/src/components/artist/artist-image-controls.tsx
similarity index 96%
rename from webapp/src/components/artist-image-controls.tsx
rename to webapp/src/components/artist/artist-image-controls.tsx
index f1c9a154..58a18d3d 100644
--- a/webapp/src/components/artist-image-controls.tsx
+++ b/webapp/src/components/artist/artist-image-controls.tsx
@@ -8,12 +8,12 @@ import {
type ArtistImageKind,
removeArtistImage,
setArtistImage,
-} from "../api";
-import { artistQuery } from "../api/queries";
-import { type TranslationKey, useI18n } from "../state/i18n";
+} from "../../api";
+import { artistQuery } from "../../api/queries";
+import { type TranslationKey, useI18n } from "../../state/i18n";
+import { Icon } from "../layout/icons";
+import { PopupMenu, type PopupMenuItem } from "../menus/popup-menu";
import { DeezerPortraitPicker } from "./deezer-portrait-picker";
-import { Icon } from "./icons";
-import { PopupMenu, type PopupMenuItem } from "./popup-menu";
/** What the server takes: recognised from the bytes, these three only. */
const ACCEPT = "image/jpeg,image/png,image/webp";
diff --git a/webapp/src/components/artist-split.tsx b/webapp/src/components/artist/artist-split.tsx
similarity index 93%
rename from webapp/src/components/artist-split.tsx
rename to webapp/src/components/artist/artist-split.tsx
index 48787c8d..3475c202 100644
--- a/webapp/src/components/artist-split.tsx
+++ b/webapp/src/components/artist/artist-split.tsx
@@ -2,9 +2,9 @@ import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
-import { type ArtistDetail, decideCredit } from "../api";
-import { creditFragments } from "../lib/credit-fragments";
-import { useI18n } from "../state/i18n";
+import { type ArtistDetail, decideCredit } from "../../api";
+import { creditFragments } from "../../lib/credit-fragments";
+import { useI18n } from "../../state/i18n";
/** How long the offer waits for its confirming click, as on the desktop. */
const CONFIRM_MS = 4000;
diff --git a/webapp/src/components/deezer-portrait-picker.tsx b/webapp/src/components/artist/deezer-portrait-picker.tsx
similarity index 97%
rename from webapp/src/components/deezer-portrait-picker.tsx
rename to webapp/src/components/artist/deezer-portrait-picker.tsx
index 1a790761..1c5d23e9 100644
--- a/webapp/src/components/deezer-portrait-picker.tsx
+++ b/webapp/src/components/artist/deezer-portrait-picker.tsx
@@ -6,10 +6,10 @@ import {
type ArtistImageCandidate,
getArtistImageCandidates,
pickDeezerPortrait,
-} from "../api";
-import { type TranslationKey, useI18n } from "../state/i18n";
-import { Artwork } from "./artwork";
-import { Icon } from "./icons";
+} from "../../api";
+import { type TranslationKey, useI18n } from "../../state/i18n";
+import { Artwork } from "../artwork/artwork";
+import { Icon } from "../layout/icons";
/**
* Choose an artist's portrait from Deezer (RFC-013 decision 4): the desktop's
diff --git a/webapp/src/components/artwork.tsx b/webapp/src/components/artwork/artwork.tsx
similarity index 98%
rename from webapp/src/components/artwork.tsx
rename to webapp/src/components/artwork/artwork.tsx
index 4dd3ba52..e9beac2f 100644
--- a/webapp/src/components/artwork.tsx
+++ b/webapp/src/components/artwork/artwork.tsx
@@ -1,6 +1,6 @@
import { type ReactNode, useCallback, useEffect, useState } from "react";
-import { artworkUrl, cachedArtworkUrl } from "../api";
+import { artworkUrl, cachedArtworkUrl } from "../../api";
type ArtworkProps = {
artworkId: string | null;
diff --git a/webapp/src/components/canvas-panel.tsx b/webapp/src/components/artwork/canvas-panel.tsx
similarity index 95%
rename from webapp/src/components/canvas-panel.tsx
rename to webapp/src/components/artwork/canvas-panel.tsx
index 97f04fc5..136360c3 100644
--- a/webapp/src/components/canvas-panel.tsx
+++ b/webapp/src/components/artwork/canvas-panel.tsx
@@ -1,12 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { type ChangeEvent, useState } from "react";
-import { ApiError, placeCanvas, removeCanvas, type Song } from "../api";
-import { canvasQuery } from "../api/queries";
-import { type TranslationKey, useI18n } from "../state/i18n";
-import { mayPlaceCanvas, useLibraryScope } from "../state/library-scope";
+import { ApiError, placeCanvas, removeCanvas, type Song } from "../../api";
+import { canvasQuery } from "../../api/queries";
+import { type TranslationKey, useI18n } from "../../state/i18n";
+import { mayPlaceCanvas, useLibraryScope } from "../../state/library-scope";
+import { useReload } from "../layout/page-shell";
import { canvasRefusal, usePrefersReducedMotion } from "./canvas";
-import { useReload } from "./page-shell";
/**
* What the file picker offers. The server reads the bytes and trusts neither
diff --git a/webapp/src/components/canvas.test.ts b/webapp/src/components/artwork/canvas.test.ts
similarity index 100%
rename from webapp/src/components/canvas.test.ts
rename to webapp/src/components/artwork/canvas.test.ts
diff --git a/webapp/src/components/canvas.tsx b/webapp/src/components/artwork/canvas.tsx
similarity index 96%
rename from webapp/src/components/canvas.tsx
rename to webapp/src/components/artwork/canvas.tsx
index edeb5694..9cec5290 100644
--- a/webapp/src/components/canvas.tsx
+++ b/webapp/src/components/artwork/canvas.tsx
@@ -1,8 +1,8 @@
import { type ReactNode, useEffect, useState } from "react";
-import { canvasUrl } from "../api";
-import { onMediaChange } from "../lib/media-query";
-import type { TranslationKey } from "../state/i18n";
+import { canvasUrl } from "../../api";
+import { onMediaChange } from "../../lib/media-query";
+import type { TranslationKey } from "../../state/i18n";
/**
* A refused placement or removal, in words. The statuses are the ones the
diff --git a/webapp/src/components/cover-light.tsx b/webapp/src/components/artwork/cover-light.tsx
similarity index 90%
rename from webapp/src/components/cover-light.tsx
rename to webapp/src/components/artwork/cover-light.tsx
index 2ff08f50..683320f8 100644
--- a/webapp/src/components/cover-light.tsx
+++ b/webapp/src/components/artwork/cover-light.tsx
@@ -1,8 +1,8 @@
import { useEffect } from "react";
-import { artworkUrl } from "../api";
-import { glowOf, sampleCover } from "../lib/cover-light";
-import { usePlayer } from "../player/player";
+import { artworkUrl } from "../../api";
+import { glowOf, sampleCover } from "../../lib/cover-light";
+import { usePlayer } from "../../player/player";
/**
* Lights the page with the playing record's cover.
diff --git a/webapp/src/components/album-grid.tsx b/webapp/src/components/catalog/album-grid.tsx
similarity index 91%
rename from webapp/src/components/album-grid.tsx
rename to webapp/src/components/catalog/album-grid.tsx
index 1ac906f4..11143c91 100644
--- a/webapp/src/components/album-grid.tsx
+++ b/webapp/src/components/catalog/album-grid.tsx
@@ -1,11 +1,11 @@
import { Link } from "@tanstack/react-router";
import { useState } from "react";
-import { type Album, getAlbum, type Song } from "../api";
-import { usePlayer } from "../player/player";
-import { useI18n } from "../state/i18n";
-import { Artwork } from "./artwork";
-import { Icon } from "./icons";
+import { type Album, getAlbum, type Song } from "../../api";
+import { usePlayer } from "../../player/player";
+import { useI18n } from "../../state/i18n";
+import { Artwork } from "../artwork/artwork";
+import { Icon } from "../layout/icons";
export function AlbumGrid({ albums }: { albums: Album[] }) {
const player = usePlayer();
diff --git a/webapp/src/components/folder-picker.tsx b/webapp/src/components/catalog/folder-picker.tsx
similarity index 95%
rename from webapp/src/components/folder-picker.tsx
rename to webapp/src/components/catalog/folder-picker.tsx
index e8a757c5..e7746ce7 100644
--- a/webapp/src/components/folder-picker.tsx
+++ b/webapp/src/components/catalog/folder-picker.tsx
@@ -1,10 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
-import { folderOutline, isWithin } from "../api";
-import { filedTreeQuery } from "../api/queries";
-import { useI18n } from "../state/i18n";
-import { Icon } from "./icons";
+import { folderOutline, isWithin } from "../../api";
+import { filedTreeQuery } from "../../api/queries";
+import { useI18n } from "../../state/i18n";
+import { Icon } from "../layout/icons";
/**
* Choose a filed folder (RFC-012 decision 2): the keyboard's way to file a
diff --git a/webapp/src/components/mix-shelves.tsx b/webapp/src/components/catalog/mix-shelves.tsx
similarity index 91%
rename from webapp/src/components/mix-shelves.tsx
rename to webapp/src/components/catalog/mix-shelves.tsx
index 07ef3173..d485ab3c 100644
--- a/webapp/src/components/mix-shelves.tsx
+++ b/webapp/src/components/catalog/mix-shelves.tsx
@@ -2,15 +2,15 @@ import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useState } from "react";
-import { getMix, type MixSummary, type Song } from "../api";
-import { mixesQuery } from "../api/queries";
-import { mixTitle } from "../lib/mixes";
-import { usePlayer } from "../player/player";
-import { useI18n } from "../state/i18n";
-import { useScopeId } from "../state/library-scope";
-import { useLocalDay } from "../state/local-day";
-import { Artwork } from "./artwork";
-import { Icon } from "./icons";
+import { getMix, type MixSummary, type Song } from "../../api";
+import { mixesQuery } from "../../api/queries";
+import { mixTitle } from "../../lib/mixes";
+import { usePlayer } from "../../player/player";
+import { useI18n } from "../../state/i18n";
+import { useScopeId } from "../../state/library-scope";
+import { useLocalDay } from "../../state/local-day";
+import { Artwork } from "../artwork/artwork";
+import { Icon } from "../layout/icons";
/**
* A mix's cover: four of its sleeves in a square where it has four, the one
diff --git a/webapp/src/components/play-set-actions.tsx b/webapp/src/components/catalog/play-set-actions.tsx
similarity index 81%
rename from webapp/src/components/play-set-actions.tsx
rename to webapp/src/components/catalog/play-set-actions.tsx
index edfbc478..211f5b08 100644
--- a/webapp/src/components/play-set-actions.tsx
+++ b/webapp/src/components/catalog/play-set-actions.tsx
@@ -1,7 +1,7 @@
-import type { Song } from "../api";
-import { usePlayer } from "../player/player";
-import { useI18n } from "../state/i18n";
-import { Icon } from "./icons";
+import type { Song } from "../../api";
+import { usePlayer } from "../../player/player";
+import { useI18n } from "../../state/i18n";
+import { Icon } from "../layout/icons";
/** Plays a set of songs, or adds them to the queue, from a page header. */
export function PlaySetActions({ songs }: { songs: Song[] }) {
diff --git a/webapp/src/components/song-table.tsx b/webapp/src/components/catalog/song-table.tsx
similarity index 96%
rename from webapp/src/components/song-table.tsx
rename to webapp/src/components/catalog/song-table.tsx
index d89ac745..9046af2f 100644
--- a/webapp/src/components/song-table.tsx
+++ b/webapp/src/components/catalog/song-table.tsx
@@ -2,14 +2,14 @@ import { useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { type ReactNode, useState } from "react";
-import { formatDuration, type Song, setFavorite, setRating } from "../api";
-import { creditedArtists } from "../lib/artist-credits";
-import { usePlayer } from "../player/player";
-import { useI18n } from "../state/i18n";
-import { mayCorrectTracks, useLibraryScope } from "../state/library-scope";
-import { Artwork } from "./artwork";
-import { Icon } from "./icons";
-import { useTrackMenu } from "./track-menu";
+import { formatDuration, type Song, setFavorite, setRating } from "../../api";
+import { creditedArtists } from "../../lib/artist-credits";
+import { usePlayer } from "../../player/player";
+import { useI18n } from "../../state/i18n";
+import { mayCorrectTracks, useLibraryScope } from "../../state/library-scope";
+import { Artwork } from "../artwork/artwork";
+import { Icon } from "../layout/icons";
+import { useTrackMenu } from "../menus/track-menu";
const RATING_STARS = [1, 2, 3, 4, 5];
diff --git a/webapp/src/components/filter-field.tsx b/webapp/src/components/layout/filter-field.tsx
similarity index 93%
rename from webapp/src/components/filter-field.tsx
rename to webapp/src/components/layout/filter-field.tsx
index f2d350f6..dbc1e22c 100644
--- a/webapp/src/components/filter-field.tsx
+++ b/webapp/src/components/layout/filter-field.tsx
@@ -1,4 +1,4 @@
-import { useI18n } from "../state/i18n";
+import { useI18n } from "../../state/i18n";
/**
* Text filter shared by the browse pages. It narrows what is already loaded
diff --git a/webapp/src/components/icons.tsx b/webapp/src/components/layout/icons.tsx
similarity index 100%
rename from webapp/src/components/icons.tsx
rename to webapp/src/components/layout/icons.tsx
diff --git a/webapp/src/components/page-back.tsx b/webapp/src/components/layout/page-back.tsx
similarity index 96%
rename from webapp/src/components/page-back.tsx
rename to webapp/src/components/layout/page-back.tsx
index a12ac4d6..01145aa5 100644
--- a/webapp/src/components/page-back.tsx
+++ b/webapp/src/components/layout/page-back.tsx
@@ -1,6 +1,6 @@
import { useNavigate, useRouter } from "@tanstack/react-router";
-import { useI18n } from "../state/i18n";
+import { useI18n } from "../../state/i18n";
import { Icon } from "./icons";
type Navigate = ReturnType;
diff --git a/webapp/src/components/page-shell.tsx b/webapp/src/components/layout/page-shell.tsx
similarity index 99%
rename from webapp/src/components/page-shell.tsx
rename to webapp/src/components/layout/page-shell.tsx
index ed210474..d96a15c5 100644
--- a/webapp/src/components/page-shell.tsx
+++ b/webapp/src/components/layout/page-shell.tsx
@@ -21,7 +21,7 @@
import { useQueryClient } from "@tanstack/react-query";
import type { ReactNode } from "react";
-import { useI18n } from "../state/i18n";
+import { useI18n } from "../../state/i18n";
import { Icon, type IconName } from "./icons";
/** Eight placeholder cards, keyed by name because their order is their only identity. */
diff --git a/webapp/src/components/theme-picker.tsx b/webapp/src/components/layout/theme-picker.tsx
similarity index 98%
rename from webapp/src/components/theme-picker.tsx
rename to webapp/src/components/layout/theme-picker.tsx
index 8d1fce95..1da52df0 100644
--- a/webapp/src/components/theme-picker.tsx
+++ b/webapp/src/components/layout/theme-picker.tsx
@@ -1,13 +1,13 @@
import { type CSSProperties, useId } from "react";
-import { type TranslationKey, useI18n } from "../state/i18n";
-import { useThemePreference } from "../state/preferences";
+import { type TranslationKey, useI18n } from "../../state/i18n";
+import { useThemePreference } from "../../state/preferences";
import {
findTheme,
THEME_PRESETS,
type ThemeMode,
type ThemePreset,
themeSurfaces,
-} from "../styles/design-tokens";
+} from "../../styles/design-tokens";
const MODES: ReadonlyArray<{ mode: ThemeMode; label: TranslationKey }> = [
{ mode: "light", label: "preferences.themeLight" },
diff --git a/webapp/src/components/action-menu.tsx b/webapp/src/components/menus/action-menu.tsx
similarity index 98%
rename from webapp/src/components/action-menu.tsx
rename to webapp/src/components/menus/action-menu.tsx
index 40922c57..2d7178ba 100644
--- a/webapp/src/components/action-menu.tsx
+++ b/webapp/src/components/menus/action-menu.tsx
@@ -1,7 +1,7 @@
import { useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
-import { Icon } from "./icons";
+import { Icon } from "../layout/icons";
import { PopupMenu, type PopupMenuItem } from "./popup-menu";
export type ActionMenuItem = Pick<
diff --git a/webapp/src/components/popup-menu.tsx b/webapp/src/components/menus/popup-menu.tsx
similarity index 98%
rename from webapp/src/components/popup-menu.tsx
rename to webapp/src/components/menus/popup-menu.tsx
index a6e35a46..f320767d 100644
--- a/webapp/src/components/popup-menu.tsx
+++ b/webapp/src/components/menus/popup-menu.tsx
@@ -7,8 +7,8 @@ import {
useState,
} from "react";
-import { placeMenu } from "../lib/menu-place";
-import { Icon, type IconName } from "./icons";
+import { placeMenu } from "../../lib/menu-place";
+import { Icon, type IconName } from "../layout/icons";
/** One entry of a menu. */
export type PopupMenuItem = {
diff --git a/webapp/src/components/track-menu.tsx b/webapp/src/components/menus/track-menu.tsx
similarity index 95%
rename from webapp/src/components/track-menu.tsx
rename to webapp/src/components/menus/track-menu.tsx
index d4ec0e8b..0d4bf672 100644
--- a/webapp/src/components/track-menu.tsx
+++ b/webapp/src/components/menus/track-menu.tsx
@@ -10,22 +10,22 @@ import {
} from "react";
import { createPortal } from "react-dom";
-import { placeTracks, type Song, streamUrl } from "../api";
-import { creditedArtists } from "../lib/artist-credits";
-import { downloadUrl, saveFrom } from "../lib/download";
+import { placeTracks, type Song, streamUrl } from "../../api";
+import { creditedArtists } from "../../lib/artist-credits";
+import { downloadUrl, saveFrom } from "../../lib/download";
import {
forgetOffline,
isKeptOffline,
keepOffline,
offlineSupported,
-} from "../lib/offline";
-import { usePlayer } from "../player/player";
-import { useI18n } from "../state/i18n";
-import { mayCorrectTracks, useLibraryScope } from "../state/library-scope";
-import { FolderPicker } from "./folder-picker";
-import { Icon } from "./icons";
+} from "../../lib/offline";
+import { usePlayer } from "../../player/player";
+import { useI18n } from "../../state/i18n";
+import { mayCorrectTracks, useLibraryScope } from "../../state/library-scope";
+import { FolderPicker } from "../catalog/folder-picker";
+import { Icon } from "../layout/icons";
+import { TrackProperties } from "../track/track-properties";
import { PopupMenu, type PopupMenuItem } from "./popup-menu";
-import { TrackProperties } from "./track-properties";
/** Where a menu was asked for, and for which row. */
type Opened = {
diff --git a/webapp/src/components/playlist-import.tsx b/webapp/src/components/playlists/playlist-import.tsx
similarity index 98%
rename from webapp/src/components/playlist-import.tsx
rename to webapp/src/components/playlists/playlist-import.tsx
index 9113457c..325b4e5c 100644
--- a/webapp/src/components/playlist-import.tsx
+++ b/webapp/src/components/playlists/playlist-import.tsx
@@ -5,9 +5,9 @@ import {
importPlaylist,
type PlaylistImport,
type UnresolvedEntry,
-} from "../api";
-import { useI18n } from "../state/i18n";
-import { useLibraryScope } from "../state/library-scope";
+} from "../../api";
+import { useI18n } from "../../state/i18n";
+import { useLibraryScope } from "../../state/library-scope";
/**
* Arriving with an existing collection's playlists, and leaving with them.
diff --git a/webapp/src/components/smart-playlists.tsx b/webapp/src/components/playlists/smart-playlists.tsx
similarity index 98%
rename from webapp/src/components/smart-playlists.tsx
rename to webapp/src/components/playlists/smart-playlists.tsx
index 0e3245a9..06c82b12 100644
--- a/webapp/src/components/smart-playlists.tsx
+++ b/webapp/src/components/playlists/smart-playlists.tsx
@@ -25,8 +25,8 @@ import {
type SmartPreview,
type SmartSort,
updateSmartPlaylist,
-} from "../api";
-import { smartPlaylistsQuery } from "../api/queries";
+} from "../../api";
+import { smartPlaylistsQuery } from "../../api/queries";
import {
blankCondition,
blankRules,
@@ -40,11 +40,11 @@ import {
SMART_FIELDS,
SMART_SORTS,
withField,
-} from "../lib/smart-rules";
-import { usePlayer } from "../player/player";
-import { type TranslationKey, useI18n } from "../state/i18n";
-import { useLibraryScope } from "../state/library-scope";
-import { useReload } from "./page-shell";
+} from "../../lib/smart-rules";
+import { usePlayer } from "../../player/player";
+import { type TranslationKey, useI18n } from "../../state/i18n";
+import { useLibraryScope } from "../../state/library-scope";
+import { useReload } from "../layout/page-shell";
type Editing = {
/** `null` while creating. */
diff --git a/webapp/src/components/file-quality.test.ts b/webapp/src/components/track/file-quality.test.ts
similarity index 99%
rename from webapp/src/components/file-quality.test.ts
rename to webapp/src/components/track/file-quality.test.ts
index 11ee0451..43f28516 100644
--- a/webapp/src/components/file-quality.test.ts
+++ b/webapp/src/components/track/file-quality.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import type { Song } from "../api";
+import type { Song } from "../../api";
import {
containerName,
fileSize,
diff --git a/webapp/src/components/file-quality.tsx b/webapp/src/components/track/file-quality.tsx
similarity index 98%
rename from webapp/src/components/file-quality.tsx
rename to webapp/src/components/track/file-quality.tsx
index 798355f4..7edf6a26 100644
--- a/webapp/src/components/file-quality.tsx
+++ b/webapp/src/components/track/file-quality.tsx
@@ -1,5 +1,5 @@
-import type { Song } from "../api";
-import { type Locale, type TranslationKey, useI18n } from "../state/i18n";
+import type { Song } from "../../api";
+import { type Locale, type TranslationKey, useI18n } from "../../state/i18n";
/**
* What a file is, and a meter that says it at a glance.
diff --git a/webapp/src/components/lyrics-section.tsx b/webapp/src/components/track/lyrics-section.tsx
similarity index 95%
rename from webapp/src/components/lyrics-section.tsx
rename to webapp/src/components/track/lyrics-section.tsx
index 6034717f..fd449ce2 100644
--- a/webapp/src/components/lyrics-section.tsx
+++ b/webapp/src/components/track/lyrics-section.tsx
@@ -10,12 +10,12 @@ import {
type LyricsSource,
type Song,
uploadLyrics,
-} from "../api";
-import { lyricsQuery } from "../api/queries";
-import { fileNameFrom, lyricsFormatOf, saveBlob } from "../lib/download";
-import { type TranslationKey, useI18n } from "../state/i18n";
-import { mayCorrectTracks, useLibraryScope } from "../state/library-scope";
-import { Icon } from "./icons";
+} from "../../api";
+import { lyricsQuery } from "../../api/queries";
+import { fileNameFrom, lyricsFormatOf, saveBlob } from "../../lib/download";
+import { type TranslationKey, useI18n } from "../../state/i18n";
+import { mayCorrectTracks, useLibraryScope } from "../../state/library-scope";
+import { Icon } from "../layout/icons";
const SOURCE_LABELS: Record = {
upload_ttml: "lyrics.source.uploadTtml",
diff --git a/webapp/src/components/quality-section.tsx b/webapp/src/components/track/quality-section.tsx
similarity index 98%
rename from webapp/src/components/quality-section.tsx
rename to webapp/src/components/track/quality-section.tsx
index aff5678c..391a8165 100644
--- a/webapp/src/components/quality-section.tsx
+++ b/webapp/src/components/track/quality-section.tsx
@@ -6,16 +6,16 @@ import {
type QualityVerdict,
type Song,
spectrogramUrl,
-} from "../api";
-import { trackQualityQuery } from "../api/queries";
+} from "../../api";
+import { trackQualityQuery } from "../../api/queries";
import {
frequencyX,
highsStop,
spectrumGridlines,
spectrumPath,
verdictTone,
-} from "../lib/quality";
-import { type TranslationKey, useI18n } from "../state/i18n";
+} from "../../lib/quality";
+import { type TranslationKey, useI18n } from "../../state/i18n";
/** The chart's box, in SVG units. */
const WIDTH = 480;
diff --git a/webapp/src/components/track-properties.tsx b/webapp/src/components/track/track-properties.tsx
similarity index 97%
rename from webapp/src/components/track-properties.tsx
rename to webapp/src/components/track/track-properties.tsx
index e1bb4f1f..59fd6244 100644
--- a/webapp/src/components/track-properties.tsx
+++ b/webapp/src/components/track/track-properties.tsx
@@ -1,11 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { type ReactNode, useEffect, useId, useRef } from "react";
-import { formatDuration, type Song } from "../api";
-import { lyricsQuery, trackPropertiesQuery } from "../api/queries";
-import { useI18n } from "../state/i18n";
-import { Artwork } from "./artwork";
-import { Icon } from "./icons";
+import { formatDuration, type Song } from "../../api";
+import { lyricsQuery, trackPropertiesQuery } from "../../api/queries";
+import { useI18n } from "../../state/i18n";
+import { Artwork } from "../artwork/artwork";
+import { Icon } from "../layout/icons";
import { LyricsSection } from "./lyrics-section";
import { QualitySection } from "./quality-section";
diff --git a/webapp/src/pages/album-page.tsx b/webapp/src/pages/library/album-page.tsx
similarity index 90%
rename from webapp/src/pages/album-page.tsx
rename to webapp/src/pages/library/album-page.tsx
index c13a5526..37f4d255 100644
--- a/webapp/src/pages/album-page.tsx
+++ b/webapp/src/pages/library/album-page.tsx
@@ -1,13 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
-import { formatDuration } from "../api";
-import { albumQuery, artistAlbumsQuery } from "../api/queries";
-import { Artwork } from "../components/artwork";
-import { Icon } from "../components/icons";
-import { Loading } from "../components/page-shell";
-import { SongTable } from "../components/song-table";
-import { usePlayer } from "../player/player";
-import { useI18n } from "../state/i18n";
+import { formatDuration } from "../../api";
+import { albumQuery, artistAlbumsQuery } from "../../api/queries";
+import { Artwork } from "../../components/artwork/artwork";
+import { SongTable } from "../../components/catalog/song-table";
+import { Icon } from "../../components/layout/icons";
+import { Loading } from "../../components/layout/page-shell";
+import { usePlayer } from "../../player/player";
+import { useI18n } from "../../state/i18n";
export function AlbumPage({ albumId }: { albumId: string }) {
const { t } = useI18n();
diff --git a/webapp/src/pages/albums-page.tsx b/webapp/src/pages/library/albums-page.tsx
similarity index 86%
rename from webapp/src/pages/albums-page.tsx
rename to webapp/src/pages/library/albums-page.tsx
index 0e6d7ba3..147ddd2b 100644
--- a/webapp/src/pages/albums-page.tsx
+++ b/webapp/src/pages/library/albums-page.tsx
@@ -1,16 +1,20 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
-import type { AlbumSort } from "../api";
-import { albumsQuery, mixesQuery } from "../api/queries";
-import { AlbumGrid } from "../components/album-grid";
-import { FilterField } from "../components/filter-field";
-import { MixShelves } from "../components/mix-shelves";
-import { EmptyState, Loading, PageHeader } from "../components/page-shell";
-import { matches, normalizeFilter } from "../lib/filter";
-import { type TranslationKey, useI18n } from "../state/i18n";
-import { useScopeId } from "../state/library-scope";
-import { useLocalDay } from "../state/local-day";
+import type { AlbumSort } from "../../api";
+import { albumsQuery, mixesQuery } from "../../api/queries";
+import { AlbumGrid } from "../../components/catalog/album-grid";
+import { MixShelves } from "../../components/catalog/mix-shelves";
+import { FilterField } from "../../components/layout/filter-field";
+import {
+ EmptyState,
+ Loading,
+ PageHeader,
+} from "../../components/layout/page-shell";
+import { matches, normalizeFilter } from "../../lib/filter";
+import { type TranslationKey, useI18n } from "../../state/i18n";
+import { useScopeId } from "../../state/library-scope";
+import { useLocalDay } from "../../state/local-day";
/**
* The orders offered on the albums page, in menu order. Four of them narrow the
diff --git a/webapp/src/pages/artist-page.tsx b/webapp/src/pages/library/artist-page.tsx
similarity index 92%
rename from webapp/src/pages/artist-page.tsx
rename to webapp/src/pages/library/artist-page.tsx
index 9cdd7049..cc1145ff 100644
--- a/webapp/src/pages/artist-page.tsx
+++ b/webapp/src/pages/library/artist-page.tsx
@@ -1,25 +1,25 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
-import type { Album, ArtistEnrichment } from "../api";
+import type { Album, ArtistEnrichment } from "../../api";
import {
artistQuery,
artistTopSongsQuery,
enrichmentRetryDelay,
-} from "../api/queries";
-import { AlbumGrid } from "../components/album-grid";
-import { ArtistImageControls } from "../components/artist-image-controls";
-import { ArtistSplit } from "../components/artist-split";
-import { Artwork } from "../components/artwork";
-import { Loading } from "../components/page-shell";
-import { SongTable } from "../components/song-table";
-import { isSplitCandidate } from "../lib/credit-fragments";
+} from "../../api/queries";
+import { ArtistImageControls } from "../../components/artist/artist-image-controls";
+import { ArtistSplit } from "../../components/artist/artist-split";
+import { Artwork } from "../../components/artwork/artwork";
+import { AlbumGrid } from "../../components/catalog/album-grid";
+import { SongTable } from "../../components/catalog/song-table";
+import { Loading } from "../../components/layout/page-shell";
+import { isSplitCandidate } from "../../lib/credit-fragments";
import {
type DiscographySection,
discographySections,
-} from "../lib/discography";
-import { type TranslationKey, useI18n } from "../state/i18n";
-import { mayCorrectTracks, useLibraryScope } from "../state/library-scope";
+} from "../../lib/discography";
+import { type TranslationKey, useI18n } from "../../state/i18n";
+import { mayCorrectTracks, useLibraryScope } from "../../state/library-scope";
/** A provider as a reader knows it, rather than as the setting spells it. */
const PROVIDER_NAMES: Record = {
diff --git a/webapp/src/pages/artists-page.tsx b/webapp/src/pages/library/artists-page.tsx
similarity index 82%
rename from webapp/src/pages/artists-page.tsx
rename to webapp/src/pages/library/artists-page.tsx
index 54aa9f66..fd1c8986 100644
--- a/webapp/src/pages/artists-page.tsx
+++ b/webapp/src/pages/library/artists-page.tsx
@@ -2,13 +2,17 @@ import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useMemo, useState } from "react";
-import { artistsQuery } from "../api/queries";
-import { Artwork } from "../components/artwork";
-import { FilterField } from "../components/filter-field";
-import { EmptyState, Loading, PageHeader } from "../components/page-shell";
-import { matches, normalizeFilter } from "../lib/filter";
-import { useI18n } from "../state/i18n";
-import { useScopeId } from "../state/library-scope";
+import { artistsQuery } from "../../api/queries";
+import { Artwork } from "../../components/artwork/artwork";
+import { FilterField } from "../../components/layout/filter-field";
+import {
+ EmptyState,
+ Loading,
+ PageHeader,
+} from "../../components/layout/page-shell";
+import { matches, normalizeFilter } from "../../lib/filter";
+import { useI18n } from "../../state/i18n";
+import { useScopeId } from "../../state/library-scope";
export function ArtistsPage() {
const { t } = useI18n();
diff --git a/webapp/src/pages/favorites-page.tsx b/webapp/src/pages/library/favorites-page.tsx
similarity index 69%
rename from webapp/src/pages/favorites-page.tsx
rename to webapp/src/pages/library/favorites-page.tsx
index 9a823430..7aebb933 100644
--- a/webapp/src/pages/favorites-page.tsx
+++ b/webapp/src/pages/library/favorites-page.tsx
@@ -1,8 +1,12 @@
import { useQuery } from "@tanstack/react-query";
-import { favoriteTracksQuery } from "../api/queries";
-import { EmptyState, Loading, PageHeader } from "../components/page-shell";
-import { SongTable } from "../components/song-table";
-import { useI18n } from "../state/i18n";
+import { favoriteTracksQuery } from "../../api/queries";
+import { SongTable } from "../../components/catalog/song-table";
+import {
+ EmptyState,
+ Loading,
+ PageHeader,
+} from "../../components/layout/page-shell";
+import { useI18n } from "../../state/i18n";
export function FavoritesPage() {
const { t } = useI18n();
diff --git a/webapp/src/pages/folders-page.tsx b/webapp/src/pages/library/folders-page.tsx
similarity index 97%
rename from webapp/src/pages/folders-page.tsx
rename to webapp/src/pages/library/folders-page.tsx
index d0d55880..f9c54dfd 100644
--- a/webapp/src/pages/folders-page.tsx
+++ b/webapp/src/pages/library/folders-page.tsx
@@ -16,19 +16,22 @@ import {
placeTracks,
UNFILED,
updateFolder,
-} from "../api";
-import { filedTreeQuery, folderTracksQuery } from "../api/queries";
-import { FolderPicker } from "../components/folder-picker";
-import { Icon } from "../components/icons";
-import { Loading, PageHeader } from "../components/page-shell";
-import { SongTable, TRACK_DRAG_TYPE } from "../components/song-table";
-import { usePlayer } from "../player/player";
-import { useI18n } from "../state/i18n";
+} from "../../api";
+import { filedTreeQuery, folderTracksQuery } from "../../api/queries";
+import { FolderPicker } from "../../components/catalog/folder-picker";
+import {
+ SongTable,
+ TRACK_DRAG_TYPE,
+} from "../../components/catalog/song-table";
+import { Icon } from "../../components/layout/icons";
+import { Loading, PageHeader } from "../../components/layout/page-shell";
+import { usePlayer } from "../../player/player";
+import { useI18n } from "../../state/i18n";
import {
mayCorrectTracks,
mayUploadTo,
useLibraryScope,
-} from "../state/library-scope";
+} from "../../state/library-scope";
/** What a dragged folder card carries: its id. */
const FOLDER_DRAG_TYPE = "application/x-waveflow-folder";
diff --git a/webapp/src/pages/genre-page.tsx b/webapp/src/pages/library/genre-page.tsx
similarity index 81%
rename from webapp/src/pages/genre-page.tsx
rename to webapp/src/pages/library/genre-page.tsx
index d31066f8..17c8d54a 100644
--- a/webapp/src/pages/genre-page.tsx
+++ b/webapp/src/pages/library/genre-page.tsx
@@ -1,13 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
-import { listRandomSongs } from "../api";
-import { genreSongsQuery } from "../api/queries";
-import { EmptyState, Loading, PageHeader } from "../components/page-shell";
-import { SongTable } from "../components/song-table";
-import { usePlayer } from "../player/player";
-import { useI18n } from "../state/i18n";
-import { useScopeId } from "../state/library-scope";
+import { listRandomSongs } from "../../api";
+import { genreSongsQuery } from "../../api/queries";
+import { SongTable } from "../../components/catalog/song-table";
+import {
+ EmptyState,
+ Loading,
+ PageHeader,
+} from "../../components/layout/page-shell";
+import { usePlayer } from "../../player/player";
+import { useI18n } from "../../state/i18n";
+import { useScopeId } from "../../state/library-scope";
export function GenrePage({ genre }: { genre: string }) {
const player = usePlayer();
diff --git a/webapp/src/pages/genres-page.tsx b/webapp/src/pages/library/genres-page.tsx
similarity index 82%
rename from webapp/src/pages/genres-page.tsx
rename to webapp/src/pages/library/genres-page.tsx
index aadf3ac6..dbfe17fe 100644
--- a/webapp/src/pages/genres-page.tsx
+++ b/webapp/src/pages/library/genres-page.tsx
@@ -2,12 +2,16 @@ import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useMemo, useState } from "react";
-import { genresQuery } from "../api/queries";
-import { FilterField } from "../components/filter-field";
-import { EmptyState, Loading, PageHeader } from "../components/page-shell";
-import { matches, normalizeFilter } from "../lib/filter";
-import { useI18n } from "../state/i18n";
-import { useScopeId } from "../state/library-scope";
+import { genresQuery } from "../../api/queries";
+import { FilterField } from "../../components/layout/filter-field";
+import {
+ EmptyState,
+ Loading,
+ PageHeader,
+} from "../../components/layout/page-shell";
+import { matches, normalizeFilter } from "../../lib/filter";
+import { useI18n } from "../../state/i18n";
+import { useScopeId } from "../../state/library-scope";
export function GenresPage() {
const { t } = useI18n();
diff --git a/webapp/src/pages/history-page.tsx b/webapp/src/pages/library/history-page.tsx
similarity index 77%
rename from webapp/src/pages/history-page.tsx
rename to webapp/src/pages/library/history-page.tsx
index 3118465b..aa00489f 100644
--- a/webapp/src/pages/history-page.tsx
+++ b/webapp/src/pages/library/history-page.tsx
@@ -1,9 +1,13 @@
import { useQuery } from "@tanstack/react-query";
-import { historyTracksQuery } from "../api/queries";
-import { EmptyState, Loading, PageHeader } from "../components/page-shell";
-import { PlaySetActions } from "../components/play-set-actions";
-import { SongTable } from "../components/song-table";
-import { useI18n } from "../state/i18n";
+import { historyTracksQuery } from "../../api/queries";
+import { PlaySetActions } from "../../components/catalog/play-set-actions";
+import { SongTable } from "../../components/catalog/song-table";
+import {
+ EmptyState,
+ Loading,
+ PageHeader,
+} from "../../components/layout/page-shell";
+import { useI18n } from "../../state/i18n";
/**
* How many distinct tracks the recently-played screen resolves.
diff --git a/webapp/src/pages/mix-page.tsx b/webapp/src/pages/library/mix-page.tsx
similarity index 62%
rename from webapp/src/pages/mix-page.tsx
rename to webapp/src/pages/library/mix-page.tsx
index 5975d58b..58104557 100644
--- a/webapp/src/pages/mix-page.tsx
+++ b/webapp/src/pages/library/mix-page.tsx
@@ -1,13 +1,13 @@
import { useQuery } from "@tanstack/react-query";
-import { mixQuery } from "../api/queries";
-import { Loading, PageHeader } from "../components/page-shell";
-import { PlaySetActions } from "../components/play-set-actions";
-import { SongTable } from "../components/song-table";
-import { mixTitle } from "../lib/mixes";
-import { useI18n } from "../state/i18n";
-import { useScopeId } from "../state/library-scope";
-import { useLocalDay } from "../state/local-day";
+import { mixQuery } from "../../api/queries";
+import { PlaySetActions } from "../../components/catalog/play-set-actions";
+import { SongTable } from "../../components/catalog/song-table";
+import { Loading, PageHeader } from "../../components/layout/page-shell";
+import { mixTitle } from "../../lib/mixes";
+import { useI18n } from "../../state/i18n";
+import { useScopeId } from "../../state/library-scope";
+import { useLocalDay } from "../../state/local-day";
/**
* One mix and its tracks (RFC-024 decision 7). The server draws it once a
diff --git a/webapp/src/pages/playlists-page.tsx b/webapp/src/pages/library/playlists-page.tsx
similarity index 95%
rename from webapp/src/pages/playlists-page.tsx
rename to webapp/src/pages/library/playlists-page.tsx
index b6e9e57e..97ea7fc5 100644
--- a/webapp/src/pages/playlists-page.tsx
+++ b/webapp/src/pages/library/playlists-page.tsx
@@ -11,23 +11,23 @@ import {
removePlaylistEntry,
type Song,
setPlaylistPublic,
-} from "../api";
-import { playlistsQuery } from "../api/queries";
-import { Icon } from "../components/icons";
+} from "../../api";
+import { playlistsQuery } from "../../api/queries";
+import { SongTable } from "../../components/catalog/song-table";
+import { Icon } from "../../components/layout/icons";
import {
EmptyState,
Loading,
PageHeader,
useReload,
-} from "../components/page-shell";
+} from "../../components/layout/page-shell";
import {
downloadPlaylist,
PlaylistImportPanel,
-} from "../components/playlist-import";
-import { SmartPlaylistsSection } from "../components/smart-playlists";
-import { SongTable } from "../components/song-table";
-import { usePlayer } from "../player/player";
-import { useI18n } from "../state/i18n";
+} from "../../components/playlists/playlist-import";
+import { SmartPlaylistsSection } from "../../components/playlists/smart-playlists";
+import { usePlayer } from "../../player/player";
+import { useI18n } from "../../state/i18n";
export function PlaylistsPage() {
const player = usePlayer();
diff --git a/webapp/src/pages/search-page.tsx b/webapp/src/pages/library/search-page.tsx
similarity index 91%
rename from webapp/src/pages/search-page.tsx
rename to webapp/src/pages/library/search-page.tsx
index d00793a6..d73639df 100644
--- a/webapp/src/pages/search-page.tsx
+++ b/webapp/src/pages/library/search-page.tsx
@@ -2,12 +2,12 @@ import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
-import { searchQuery } from "../api/queries";
-import { AlbumGrid } from "../components/album-grid";
-import { Loading, PageHeader } from "../components/page-shell";
-import { SongTable } from "../components/song-table";
-import { useI18n } from "../state/i18n";
-import { useLibraryScope } from "../state/library-scope";
+import { searchQuery } from "../../api/queries";
+import { AlbumGrid } from "../../components/catalog/album-grid";
+import { SongTable } from "../../components/catalog/song-table";
+import { Loading, PageHeader } from "../../components/layout/page-shell";
+import { useI18n } from "../../state/i18n";
+import { useLibraryScope } from "../../state/library-scope";
/**
* How long the typing has to settle before the search is asked.
diff --git a/webapp/src/pages/songs-page.tsx b/webapp/src/pages/library/songs-page.tsx
similarity index 91%
rename from webapp/src/pages/songs-page.tsx
rename to webapp/src/pages/library/songs-page.tsx
index 1d5f04b5..f3b899d4 100644
--- a/webapp/src/pages/songs-page.tsx
+++ b/webapp/src/pages/library/songs-page.tsx
@@ -1,14 +1,14 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
-import type { SongSort } from "../api";
-import { songsQuery } from "../api/queries";
-import { Icon } from "../components/icons";
-import { Loading, PageHeader } from "../components/page-shell";
-import { SongTable } from "../components/song-table";
-import { usePlayer } from "../player/player";
-import { type TranslationKey, useI18n } from "../state/i18n";
-import { useScopeId } from "../state/library-scope";
+import type { SongSort } from "../../api";
+import { songsQuery } from "../../api/queries";
+import { SongTable } from "../../components/catalog/song-table";
+import { Icon } from "../../components/layout/icons";
+import { Loading, PageHeader } from "../../components/layout/page-shell";
+import { usePlayer } from "../../player/player";
+import { type TranslationKey, useI18n } from "../../state/i18n";
+import { useScopeId } from "../../state/library-scope";
/**
* The orderings this menu offers, in the album menu's order plus one.
diff --git a/webapp/src/pages/stats-page.tsx b/webapp/src/pages/library/stats-page.tsx
similarity index 95%
rename from webapp/src/pages/stats-page.tsx
rename to webapp/src/pages/library/stats-page.tsx
index d08e6560..beba9b8a 100644
--- a/webapp/src/pages/stats-page.tsx
+++ b/webapp/src/pages/library/stats-page.tsx
@@ -2,14 +2,18 @@ import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { type CSSProperties, useState } from "react";
-import type { StatsPeriod } from "../api";
-import { statsQuery } from "../api/queries";
-import { Artwork } from "../components/artwork";
-import { EmptyState, Loading, PageHeader } from "../components/page-shell";
-import { barShares, listeningTime } from "../lib/mixes";
-import { usePlayer } from "../player/player";
-import { type TranslationKey, useI18n } from "../state/i18n";
-import { useLocalDay } from "../state/local-day";
+import type { StatsPeriod } from "../../api";
+import { statsQuery } from "../../api/queries";
+import { Artwork } from "../../components/artwork/artwork";
+import {
+ EmptyState,
+ Loading,
+ PageHeader,
+} from "../../components/layout/page-shell";
+import { barShares, listeningTime } from "../../lib/mixes";
+import { usePlayer } from "../../player/player";
+import { type TranslationKey, useI18n } from "../../state/i18n";
+import { useLocalDay } from "../../state/local-day";
const PERIODS: Array<{ value: StatsPeriod; labelKey: TranslationKey }> = [
{ value: "7d", labelKey: "stats.period7d" },
diff --git a/webapp/src/components/track-editor.tsx b/webapp/src/pages/library/track-editor.tsx
similarity index 96%
rename from webapp/src/components/track-editor.tsx
rename to webapp/src/pages/library/track-editor.tsx
index b00dd55c..c469bae4 100644
--- a/webapp/src/components/track-editor.tsx
+++ b/webapp/src/pages/library/track-editor.tsx
@@ -7,8 +7,11 @@ import {
correctTrack,
type SongCredits,
type TrackOverrides,
-} from "../api";
-import { trackEditQuery } from "../api/queries";
+} from "../../api";
+import { trackEditQuery } from "../../api/queries";
+import { CanvasPanel } from "../../components/artwork/canvas-panel";
+import { PageBack } from "../../components/layout/page-back";
+import { Loading, PageHeader } from "../../components/layout/page-shell";
import {
buildCorrectionPatch,
type CorrectionDraft,
@@ -21,11 +24,8 @@ import {
type ListField,
SCALAR_FIELDS,
type ScalarField,
-} from "../lib/track-corrections";
-import { type TranslationKey, useI18n } from "../state/i18n";
-import { CanvasPanel } from "./canvas-panel";
-import { PageBack } from "./page-back";
-import { Loading, PageHeader } from "./page-shell";
+} from "../../lib/track-corrections";
+import { type TranslationKey, useI18n } from "../../state/i18n";
const LABELS: Record = {
title: "correction.fieldTitle",
diff --git a/webapp/src/pages/playing-page/detail-view.tsx b/webapp/src/pages/listen/playing-page/detail-view.tsx
similarity index 91%
rename from webapp/src/pages/playing-page/detail-view.tsx
rename to webapp/src/pages/listen/playing-page/detail-view.tsx
index dbf85ec7..c715c295 100644
--- a/webapp/src/pages/playing-page/detail-view.tsx
+++ b/webapp/src/pages/listen/playing-page/detail-view.tsx
@@ -2,11 +2,11 @@
import { useState } from "react";
-import type { Song } from "../../api";
-import { Artwork } from "../../components/artwork";
-import { CanvasStage } from "../../components/canvas";
-import { FileQuality } from "../../components/file-quality";
-import { useI18n } from "../../state/i18n";
+import type { Song } from "../../../api";
+import { Artwork } from "../../../components/artwork/artwork";
+import { CanvasStage } from "../../../components/artwork/canvas";
+import { FileQuality } from "../../../components/track/file-quality";
+import { useI18n } from "../../../state/i18n";
import { type LyricSheet, LyricsStrip, useLyricSheet } from "./lyrics";
/**
diff --git a/webapp/src/pages/playing-page/immersive.tsx b/webapp/src/pages/listen/playing-page/immersive.tsx
similarity index 93%
rename from webapp/src/pages/playing-page/immersive.tsx
rename to webapp/src/pages/listen/playing-page/immersive.tsx
index 72497dff..edb32ca0 100644
--- a/webapp/src/pages/playing-page/immersive.tsx
+++ b/webapp/src/pages/listen/playing-page/immersive.tsx
@@ -3,12 +3,12 @@
import { useNavigate } from "@tanstack/react-router";
import { useLayoutEffect, useRef } from "react";
-import { formatDuration, type Song } from "../../api";
-import { Artwork } from "../../components/artwork";
-import { CanvasStage } from "../../components/canvas";
-import { Icon } from "../../components/icons";
-import { usePlayer, usePlayerProgress } from "../../player/player";
-import { useI18n } from "../../state/i18n";
+import { formatDuration, type Song } from "../../../api";
+import { Artwork } from "../../../components/artwork/artwork";
+import { CanvasStage } from "../../../components/artwork/canvas";
+import { Icon } from "../../../components/layout/icons";
+import { usePlayer, usePlayerProgress } from "../../../player/player";
+import { useI18n } from "../../../state/i18n";
/**
* The artwork, and the canvas where there is one, over everything else.
diff --git a/webapp/src/pages/playing-page/index.tsx b/webapp/src/pages/listen/playing-page/index.tsx
similarity index 95%
rename from webapp/src/pages/playing-page/index.tsx
rename to webapp/src/pages/listen/playing-page/index.tsx
index 68bbad02..e2b56be5 100644
--- a/webapp/src/pages/playing-page/index.tsx
+++ b/webapp/src/pages/listen/playing-page/index.tsx
@@ -9,12 +9,16 @@ import {
formatDuration,
type Song,
setBookmark,
-} from "../../api";
-import { bookmarksQuery } from "../../api/queries";
-import { Artwork } from "../../components/artwork";
-import { EmptyState, PageHeader, useReload } from "../../components/page-shell";
-import { usePlayer, usePlayerProgress } from "../../player/player";
-import { type TranslationKey, useI18n } from "../../state/i18n";
+} from "../../../api";
+import { bookmarksQuery } from "../../../api/queries";
+import { Artwork } from "../../../components/artwork/artwork";
+import {
+ EmptyState,
+ PageHeader,
+ useReload,
+} from "../../../components/layout/page-shell";
+import { usePlayer, usePlayerProgress } from "../../../player/player";
+import { type TranslationKey, useI18n } from "../../../state/i18n";
import { DetailView } from "./detail-view";
import { ImmersiveView } from "./immersive";
import { LyricsView } from "./lyrics";
diff --git a/webapp/src/pages/lyrics.test.ts b/webapp/src/pages/listen/playing-page/lyrics.test.ts
similarity index 97%
rename from webapp/src/pages/lyrics.test.ts
rename to webapp/src/pages/listen/playing-page/lyrics.test.ts
index 0277d106..e1fbca6e 100644
--- a/webapp/src/pages/lyrics.test.ts
+++ b/webapp/src/pages/listen/playing-page/lyrics.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { currentLyricLine } from "./playing-page";
+import { currentLyricLine } from "./lyrics";
/**
* The highlight in the now-playing view. Off-by-one here shows a line before
diff --git a/webapp/src/pages/playing-page/lyrics.tsx b/webapp/src/pages/listen/playing-page/lyrics.tsx
similarity index 97%
rename from webapp/src/pages/playing-page/lyrics.tsx
rename to webapp/src/pages/listen/playing-page/lyrics.tsx
index 6a1b0a8e..7408ee52 100644
--- a/webapp/src/pages/playing-page/lyrics.tsx
+++ b/webapp/src/pages/listen/playing-page/lyrics.tsx
@@ -8,12 +8,12 @@ import {
type LyricsLine,
type Song,
type StructuredLyrics,
-} from "../../api";
-import { lyricsQuery } from "../../api/queries";
-import { usePrefersReducedMotion } from "../../components/canvas";
-import { EmptyState } from "../../components/page-shell";
-import { usePlayer, usePlayerProgress } from "../../player/player";
-import { useI18n } from "../../state/i18n";
+} from "../../../api";
+import { lyricsQuery } from "../../../api/queries";
+import { usePrefersReducedMotion } from "../../../components/artwork/canvas";
+import { EmptyState } from "../../../components/layout/page-shell";
+import { usePlayer, usePlayerProgress } from "../../../player/player";
+import { useI18n } from "../../../state/i18n";
/**
* The line a synced lyric sheet is on at `seconds`. Lines carry their own start
diff --git a/webapp/src/pages/playing-view.test.ts b/webapp/src/pages/listen/playing-page/playing-view.test.ts
similarity index 98%
rename from webapp/src/pages/playing-view.test.ts
rename to webapp/src/pages/listen/playing-page/playing-view.test.ts
index 02d793a3..2cdc8788 100644
--- a/webapp/src/pages/playing-view.test.ts
+++ b/webapp/src/pages/listen/playing-page/playing-view.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { detailSplit, lyricSheetState, playingView } from "./playing-page";
+import { detailSplit, lyricSheetState, playingView } from "./index";
/**
* Which view a location asks for.
diff --git a/webapp/src/pages/podcasts-page.tsx b/webapp/src/pages/listen/podcasts-page.tsx
similarity index 96%
rename from webapp/src/pages/podcasts-page.tsx
rename to webapp/src/pages/listen/podcasts-page.tsx
index 7b85198f..df54cd4d 100644
--- a/webapp/src/pages/podcasts-page.tsx
+++ b/webapp/src/pages/listen/podcasts-page.tsx
@@ -12,20 +12,20 @@ import {
refreshPodcasts,
subscribePodcast,
unsubscribePodcast,
-} from "../api";
-import { podcastChannelQuery, podcastsQuery } from "../api/queries";
-import { Artwork } from "../components/artwork";
-import { Icon } from "../components/icons";
+} from "../../api";
+import { podcastChannelQuery, podcastsQuery } from "../../api/queries";
+import { Artwork } from "../../components/artwork/artwork";
+import { Icon } from "../../components/layout/icons";
import {
EmptyState,
Loading,
PageHeader,
useReload,
-} from "../components/page-shell";
-import { resumeSeconds } from "../lib/aside";
-import { onMediaChange } from "../lib/media-query";
-import { usePlayer } from "../player/player";
-import { type TranslationKey, useI18n } from "../state/i18n";
+} from "../../components/layout/page-shell";
+import { resumeSeconds } from "../../lib/aside";
+import { onMediaChange } from "../../lib/media-query";
+import { usePlayer } from "../../player/player";
+import { type TranslationKey, useI18n } from "../../state/i18n";
const statusKey = (status: PodcastEpisode["status"]) =>
`podcasts.status.${status}` as TranslationKey;
diff --git a/webapp/src/pages/queue-page.tsx b/webapp/src/pages/listen/queue-page.tsx
similarity index 90%
rename from webapp/src/pages/queue-page.tsx
rename to webapp/src/pages/listen/queue-page.tsx
index 37b656b5..fb4a5b8e 100644
--- a/webapp/src/pages/queue-page.tsx
+++ b/webapp/src/pages/listen/queue-page.tsx
@@ -1,10 +1,10 @@
import { useMemo } from "react";
-import { formatDuration } from "../api";
-import { Artwork } from "../components/artwork";
-import { EmptyState, PageHeader } from "../components/page-shell";
-import { usePlayer } from "../player/player";
-import { useI18n } from "../state/i18n";
+import { formatDuration } from "../../api";
+import { Artwork } from "../../components/artwork/artwork";
+import { EmptyState, PageHeader } from "../../components/layout/page-shell";
+import { usePlayer } from "../../player/player";
+import { useI18n } from "../../state/i18n";
export function QueuePage() {
const player = usePlayer();
diff --git a/webapp/src/pages/radio-page.tsx b/webapp/src/pages/listen/radio-page.tsx
similarity index 97%
rename from webapp/src/pages/radio-page.tsx
rename to webapp/src/pages/listen/radio-page.tsx
index 4ef373c6..e36c729c 100644
--- a/webapp/src/pages/radio-page.tsx
+++ b/webapp/src/pages/listen/radio-page.tsx
@@ -14,19 +14,19 @@ import {
deleteRadioStation,
type RadioStation,
replaceRadioStation,
-} from "../api";
-import { radioStationsQuery } from "../api/queries";
-import { ActionMenu } from "../components/action-menu";
-import { Artwork } from "../components/artwork";
-import { Icon } from "../components/icons";
+} from "../../api";
+import { radioStationsQuery } from "../../api/queries";
+import { Artwork } from "../../components/artwork/artwork";
+import { Icon } from "../../components/layout/icons";
import {
EmptyState,
Loading,
PageHeader,
useReload,
-} from "../components/page-shell";
-import { usePlayer } from "../player/player";
-import { useI18n } from "../state/i18n";
+} from "../../components/layout/page-shell";
+import { ActionMenu } from "../../components/menus/action-menu";
+import { usePlayer } from "../../player/player";
+import { useI18n } from "../../state/i18n";
/**
* The same rule the server applies, applied early enough to say so.
diff --git a/webapp/src/pages/radio.test.ts b/webapp/src/pages/listen/radio.test.ts
similarity index 100%
rename from webapp/src/pages/radio.test.ts
rename to webapp/src/pages/listen/radio.test.ts
diff --git a/webapp/src/pages/random-page.tsx b/webapp/src/pages/listen/random-page.tsx
similarity index 73%
rename from webapp/src/pages/random-page.tsx
rename to webapp/src/pages/listen/random-page.tsx
index c2ad2ab0..9739fe88 100644
--- a/webapp/src/pages/random-page.tsx
+++ b/webapp/src/pages/listen/random-page.tsx
@@ -1,11 +1,15 @@
import { useQuery } from "@tanstack/react-query";
-import { randomSongsQuery } from "../api/queries";
-import { Icon } from "../components/icons";
-import { EmptyState, Loading, PageHeader } from "../components/page-shell";
-import { PlaySetActions } from "../components/play-set-actions";
-import { SongTable } from "../components/song-table";
-import { useI18n } from "../state/i18n";
-import { useScopeId } from "../state/library-scope";
+import { randomSongsQuery } from "../../api/queries";
+import { PlaySetActions } from "../../components/catalog/play-set-actions";
+import { SongTable } from "../../components/catalog/song-table";
+import { Icon } from "../../components/layout/icons";
+import {
+ EmptyState,
+ Loading,
+ PageHeader,
+} from "../../components/layout/page-shell";
+import { useI18n } from "../../state/i18n";
+import { useScopeId } from "../../state/library-scope";
export function RandomPage() {
const { t } = useI18n();
diff --git a/webapp/src/pages/attention-page.tsx b/webapp/src/pages/maintenance/attention-page.tsx
similarity index 97%
rename from webapp/src/pages/attention-page.tsx
rename to webapp/src/pages/maintenance/attention-page.tsx
index 543a2180..ec6c27bf 100644
--- a/webapp/src/pages/attention-page.tsx
+++ b/webapp/src/pages/maintenance/attention-page.tsx
@@ -15,17 +15,17 @@ import {
decideCredit,
removeCreditDecision,
type TrackCategory,
-} from "../api";
+} from "../../api";
import {
attentionSummaryQuery,
attentionTracksQuery,
creditCandidatesQuery,
creditDecisionsQuery,
-} from "../api/queries";
-import { Loading, PageHeader } from "../components/page-shell";
-import { SongTable } from "../components/song-table";
-import { type TranslationKey, useI18n } from "../state/i18n";
-import { mayCorrectTracks, useLibraryScope } from "../state/library-scope";
+} from "../../api/queries";
+import { SongTable } from "../../components/catalog/song-table";
+import { Loading, PageHeader } from "../../components/layout/page-shell";
+import { type TranslationKey, useI18n } from "../../state/i18n";
+import { mayCorrectTracks, useLibraryScope } from "../../state/library-scope";
/** The page's lists, in the desktop's order, with what each is called. */
const CATEGORIES: Array<{ key: AttentionCategory; label: TranslationKey }> = [
diff --git a/webapp/src/pages/maintenance-hub.tsx b/webapp/src/pages/maintenance/maintenance-hub.tsx
similarity index 93%
rename from webapp/src/pages/maintenance-hub.tsx
rename to webapp/src/pages/maintenance/maintenance-hub.tsx
index c7466347..2c108058 100644
--- a/webapp/src/pages/maintenance-hub.tsx
+++ b/webapp/src/pages/maintenance/maintenance-hub.tsx
@@ -1,13 +1,13 @@
import { Link, Navigate, Outlet } from "@tanstack/react-router";
-import { currentUser, type Library } from "../api";
-import { PageHeader } from "../components/page-shell";
-import { type TranslationKey, useI18n } from "../state/i18n";
+import { currentUser, type Library } from "../../api";
+import { PageHeader } from "../../components/layout/page-shell";
+import { type TranslationKey, useI18n } from "../../state/i18n";
import {
mayCorrectTracks,
mayManageMissing,
useLibraryScope,
-} from "../state/library-scope";
+} from "../../state/library-scope";
/**
* Maintenance: the pages that put the library right rather than play it.
diff --git a/webapp/src/pages/missing-page.tsx b/webapp/src/pages/maintenance/missing-page.tsx
similarity index 97%
rename from webapp/src/pages/missing-page.tsx
rename to webapp/src/pages/maintenance/missing-page.tsx
index d262f342..f40c19bc 100644
--- a/webapp/src/pages/missing-page.tsx
+++ b/webapp/src/pages/maintenance/missing-page.tsx
@@ -6,11 +6,11 @@ import {
currentUser,
purgeUnavailableTracks,
restorePurgedTracks,
-} from "../api";
-import { purgedTracksQuery, unavailableTracksQuery } from "../api/queries";
-import { Loading, PageHeader } from "../components/page-shell";
-import { useI18n } from "../state/i18n";
-import { mayManageMissing, useLibraryScope } from "../state/library-scope";
+} from "../../api";
+import { purgedTracksQuery, unavailableTracksQuery } from "../../api/queries";
+import { Loading, PageHeader } from "../../components/layout/page-shell";
+import { useI18n } from "../../state/i18n";
+import { mayManageMissing, useLibraryScope } from "../../state/library-scope";
/** How long a purge waits for its second click, as Split does. */
const CONFIRM_MS = 4000;
diff --git a/webapp/src/pages/quality-page.tsx b/webapp/src/pages/maintenance/quality-page.tsx
similarity index 95%
rename from webapp/src/pages/quality-page.tsx
rename to webapp/src/pages/maintenance/quality-page.tsx
index c458143c..682753d0 100644
--- a/webapp/src/pages/quality-page.tsx
+++ b/webapp/src/pages/maintenance/quality-page.tsx
@@ -12,16 +12,16 @@ import {
type QualityTrack,
type QualityVerdict,
requestQualityAnalysis,
-} from "../api";
-import { qualitySummaryQuery, qualityTracksQuery } from "../api/queries";
-import { Artwork } from "../components/artwork";
-import { containerName, kilohertz } from "../components/file-quality";
-import { Loading, PageHeader } from "../components/page-shell";
-import { VERDICT_LABEL } from "../components/quality-section";
-import { TrackProperties } from "../components/track-properties";
-import { analysisIsWorking, highsStop, verdictTone } from "../lib/quality";
-import { useI18n } from "../state/i18n";
-import { mayCorrectTracks, useLibraryScope } from "../state/library-scope";
+} from "../../api";
+import { qualitySummaryQuery, qualityTracksQuery } from "../../api/queries";
+import { Artwork } from "../../components/artwork/artwork";
+import { Loading, PageHeader } from "../../components/layout/page-shell";
+import { containerName, kilohertz } from "../../components/track/file-quality";
+import { VERDICT_LABEL } from "../../components/track/quality-section";
+import { TrackProperties } from "../../components/track/track-properties";
+import { analysisIsWorking, highsStop, verdictTone } from "../../lib/quality";
+import { useI18n } from "../../state/i18n";
+import { mayCorrectTracks, useLibraryScope } from "../../state/library-scope";
/** How long "Analyse again" waits for the second click, as Split does. */
const CONFIRM_MS = 4000;
diff --git a/webapp/src/pages/authorize-page.tsx b/webapp/src/pages/root/authorize-page.tsx
similarity index 96%
rename from webapp/src/pages/authorize-page.tsx
rename to webapp/src/pages/root/authorize-page.tsx
index f3d66ef7..18d47122 100644
--- a/webapp/src/pages/authorize-page.tsx
+++ b/webapp/src/pages/root/authorize-page.tsx
@@ -1,7 +1,7 @@
import { useState } from "react";
-import { authorize, isAllowedRedirect } from "../api";
-import { useI18n } from "../state/i18n";
+import { authorize, isAllowedRedirect } from "../../api";
+import { useI18n } from "../../state/i18n";
/**
* Consent screen for the native Authorization Code + PKCE flow.
diff --git a/webapp/src/pages/login-page.tsx b/webapp/src/pages/root/login-page.tsx
similarity index 97%
rename from webapp/src/pages/login-page.tsx
rename to webapp/src/pages/root/login-page.tsx
index 31216712..d7f8984f 100644
--- a/webapp/src/pages/login-page.tsx
+++ b/webapp/src/pages/root/login-page.tsx
@@ -8,9 +8,9 @@ import {
SETUP_TOKEN_REJECTED,
safeInternalPath,
setupRequired,
-} from "../api";
-import { keptSongs, offlineSupported } from "../lib/offline";
-import { useI18n } from "../state/i18n";
+} from "../../api";
+import { keptSongs, offlineSupported } from "../../lib/offline";
+import { useI18n } from "../../state/i18n";
/**
* A way to what this browser kept for offline listening (RFC-022), shown only
diff --git a/webapp/src/pages/not-found-page.tsx b/webapp/src/pages/root/not-found-page.tsx
similarity index 92%
rename from webapp/src/pages/not-found-page.tsx
rename to webapp/src/pages/root/not-found-page.tsx
index 46e4aeeb..22371e8b 100644
--- a/webapp/src/pages/not-found-page.tsx
+++ b/webapp/src/pages/root/not-found-page.tsx
@@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router";
-import { useI18n } from "../state/i18n";
+import { useI18n } from "../../state/i18n";
export function NotFoundPage() {
const { t } = useI18n();
diff --git a/webapp/src/pages/offline-page.tsx b/webapp/src/pages/root/offline-page.tsx
similarity index 96%
rename from webapp/src/pages/offline-page.tsx
rename to webapp/src/pages/root/offline-page.tsx
index 03efa3fd..ec5c9c5c 100644
--- a/webapp/src/pages/offline-page.tsx
+++ b/webapp/src/pages/root/offline-page.tsx
@@ -1,8 +1,8 @@
import { Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
-import { formatDuration } from "../api";
-import { Waiting } from "../components/page-shell";
+import { formatDuration } from "../../api";
+import { Waiting } from "../../components/layout/page-shell";
import {
forgetOffline,
type KeptSong,
@@ -10,8 +10,8 @@ import {
offlineSource,
offlineSupported,
onOfflineChange,
-} from "../lib/offline";
-import { useI18n } from "../state/i18n";
+} from "../../lib/offline";
+import { useI18n } from "../../state/i18n";
/**
* The tracks kept for offline listening (RFC-022), and a player for them.
diff --git a/webapp/src/pages/share-page.tsx b/webapp/src/pages/root/share-page.tsx
similarity index 95%
rename from webapp/src/pages/share-page.tsx
rename to webapp/src/pages/root/share-page.tsx
index 1f9fed3d..9231fd8c 100644
--- a/webapp/src/pages/share-page.tsx
+++ b/webapp/src/pages/root/share-page.tsx
@@ -1,10 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
-import { formatDuration, openPublicShare, type PublicShareTrack } from "../api";
-import { Loading, Waiting } from "../components/page-shell";
-import { type CanPlayType, shareSource } from "../lib/share-playback";
-import { useI18n } from "../state/i18n";
+import {
+ formatDuration,
+ openPublicShare,
+ type PublicShareTrack,
+} from "../../api";
+import { Loading, Waiting } from "../../components/layout/page-shell";
+import { type CanPlayType, shareSource } from "../../lib/share-playback";
+import { useI18n } from "../../state/i18n";
/**
* What a share link opens in a browser (RFC-020 decision 3).
diff --git a/webapp/src/pages/admin-page/activity.tsx b/webapp/src/pages/settings/admin-page/activity.tsx
similarity index 96%
rename from webapp/src/pages/admin-page/activity.tsx
rename to webapp/src/pages/settings/admin-page/activity.tsx
index b08c647b..9644b747 100644
--- a/webapp/src/pages/admin-page/activity.tsx
+++ b/webapp/src/pages/settings/admin-page/activity.tsx
@@ -3,9 +3,9 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
-import { type ScanJob, watchScan } from "../../api";
-import { nowPlayingQuery } from "../../api/queries";
-import { type TranslationKey, useI18n } from "../../state/i18n";
+import { type ScanJob, watchScan } from "../../../api";
+import { nowPlayingQuery } from "../../../api/queries";
+import { type TranslationKey, useI18n } from "../../../state/i18n";
/**
* The statuses `scan_job.status` holds. Looked up rather than cast: an unknown
diff --git a/webapp/src/pages/admin-page/credentials.tsx b/webapp/src/pages/settings/admin-page/credentials.tsx
similarity index 96%
rename from webapp/src/pages/admin-page/credentials.tsx
rename to webapp/src/pages/settings/admin-page/credentials.tsx
index c83034a6..98893d47 100644
--- a/webapp/src/pages/admin-page/credentials.tsx
+++ b/webapp/src/pages/settings/admin-page/credentials.tsx
@@ -8,10 +8,10 @@ import {
revokeApiToken,
setSubsonicCredential,
type User,
-} from "../../api";
-import { apiTokensQuery } from "../../api/queries";
-import { useReload } from "../../components/page-shell";
-import { useI18n } from "../../state/i18n";
+} from "../../../api";
+import { apiTokensQuery } from "../../../api/queries";
+import { useReload } from "../../../components/layout/page-shell";
+import { useI18n } from "../../../state/i18n";
export function CredentialForm({ user }: { user: User }) {
const { t } = useI18n();
diff --git a/webapp/src/pages/admin-page/index.tsx b/webapp/src/pages/settings/admin-page/index.tsx
similarity index 97%
rename from webapp/src/pages/admin-page/index.tsx
rename to webapp/src/pages/settings/admin-page/index.tsx
index 5c6c3bed..8ad2db84 100644
--- a/webapp/src/pages/admin-page/index.tsx
+++ b/webapp/src/pages/settings/admin-page/index.tsx
@@ -10,10 +10,14 @@ import {
setUserDisabled,
startScan,
type User,
-} from "../../api";
-import { administrationQuery } from "../../api/queries";
-import { Loading, PageHeader, useReload } from "../../components/page-shell";
-import { useI18n } from "../../state/i18n";
+} from "../../../api";
+import { administrationQuery } from "../../../api/queries";
+import {
+ Loading,
+ PageHeader,
+ useReload,
+} from "../../../components/layout/page-shell";
+import { useI18n } from "../../../state/i18n";
import { NowPlayingPanel, ScanProgress } from "./activity";
import { ApiTokensPanel, CredentialForm } from "./credentials";
import { LibraryMembersPanel, LibrarySettingsPanel } from "./libraries";
diff --git a/webapp/src/pages/admin-page/libraries.tsx b/webapp/src/pages/settings/admin-page/libraries.tsx
similarity index 98%
rename from webapp/src/pages/admin-page/libraries.tsx
rename to webapp/src/pages/settings/admin-page/libraries.tsx
index ea7f52c9..1ae44fab 100644
--- a/webapp/src/pages/admin-page/libraries.tsx
+++ b/webapp/src/pages/settings/admin-page/libraries.tsx
@@ -11,14 +11,14 @@ import {
setLibraryMember,
type User,
updateLibrary,
-} from "../../api";
+} from "../../../api";
import {
administrationQuery,
libraryMembersQuery,
librarySettingsQuery,
-} from "../../api/queries";
-import { useReload, Waiting } from "../../components/page-shell";
-import { type TranslationKey, useI18n } from "../../state/i18n";
+} from "../../../api/queries";
+import { useReload, Waiting } from "../../../components/layout/page-shell";
+import { type TranslationKey, useI18n } from "../../../state/i18n";
/**
* Who may see one library, and in what standing.
diff --git a/webapp/src/pages/scrobbling-page.tsx b/webapp/src/pages/settings/scrobbling-page.tsx
similarity index 97%
rename from webapp/src/pages/scrobbling-page.tsx
rename to webapp/src/pages/settings/scrobbling-page.tsx
index a72046f7..f87434bc 100644
--- a/webapp/src/pages/scrobbling-page.tsx
+++ b/webapp/src/pages/settings/scrobbling-page.tsx
@@ -12,11 +12,11 @@ import {
type ScrobbleUnavailable,
type UncertainScrobble,
unlinkScrobble,
-} from "../api";
-import { scrobbleRowsQuery, uncertainScrobblesQuery } from "../api/queries";
-import { Loading, PageHeader } from "../components/page-shell";
-import { returnedFrom, type ScrobbleRow } from "../lib/scrobbling";
-import { type Locale, type TranslationKey, useI18n } from "../state/i18n";
+} from "../../api";
+import { scrobbleRowsQuery, uncertainScrobblesQuery } from "../../api/queries";
+import { Loading, PageHeader } from "../../components/layout/page-shell";
+import { returnedFrom, type ScrobbleRow } from "../../lib/scrobbling";
+import { type Locale, type TranslationKey, useI18n } from "../../state/i18n";
/**
* The four gestures RFC-010 leaves to a client.
diff --git a/webapp/src/pages/settings-hub.tsx b/webapp/src/pages/settings/settings-hub.tsx
similarity index 94%
rename from webapp/src/pages/settings-hub.tsx
rename to webapp/src/pages/settings/settings-hub.tsx
index 5e43096b..4cc1429b 100644
--- a/webapp/src/pages/settings-hub.tsx
+++ b/webapp/src/pages/settings/settings-hub.tsx
@@ -1,8 +1,8 @@
import { Link, Outlet } from "@tanstack/react-router";
-import { currentUser } from "../api";
-import { type TranslationKey, useI18n } from "../state/i18n";
-import { mayUploadTo, useLibraryScope } from "../state/library-scope";
+import { currentUser } from "../../api";
+import { type TranslationKey, useI18n } from "../../state/i18n";
+import { mayUploadTo, useLibraryScope } from "../../state/library-scope";
/**
* Settings, as one place with sections.
diff --git a/webapp/src/pages/settings-page.tsx b/webapp/src/pages/settings/settings-page.tsx
similarity index 97%
rename from webapp/src/pages/settings-page.tsx
rename to webapp/src/pages/settings/settings-page.tsx
index d6e953a4..4a77b9e0 100644
--- a/webapp/src/pages/settings-page.tsx
+++ b/webapp/src/pages/settings/settings-page.tsx
@@ -6,12 +6,19 @@ import {
revokeOwnApiToken,
revokeOwnSubsonicCredential,
rotateOwnSubsonicCredential,
-} from "../api";
-import { ownApiTokensQuery, ownSubsonicCredentialQuery } from "../api/queries";
-import { Loading, PageHeader, useReload } from "../components/page-shell";
-import { ThemePicker } from "../components/theme-picker";
-import { LanguagePicker, type Locale, useI18n } from "../state/i18n";
-import { ReplayGainPicker } from "../state/preferences";
+} from "../../api";
+import {
+ ownApiTokensQuery,
+ ownSubsonicCredentialQuery,
+} from "../../api/queries";
+import {
+ Loading,
+ PageHeader,
+ useReload,
+} from "../../components/layout/page-shell";
+import { ThemePicker } from "../../components/layout/theme-picker";
+import { LanguagePicker, type Locale, useI18n } from "../../state/i18n";
+import { ReplayGainPicker } from "../../state/preferences";
/**
* The Account section of Settings, the clients allowed to read the library,
diff --git a/webapp/src/pages/shares-page.tsx b/webapp/src/pages/settings/shares-page.tsx
similarity index 93%
rename from webapp/src/pages/shares-page.tsx
rename to webapp/src/pages/settings/shares-page.tsx
index 4cf7b88a..821e308b 100644
--- a/webapp/src/pages/shares-page.tsx
+++ b/webapp/src/pages/settings/shares-page.tsx
@@ -1,17 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { type FormEvent, useState } from "react";
-import { createShare, deleteShare, type Share } from "../api";
-import { sharesQuery } from "../api/queries";
-import { MAX_SHARE_TRACKS } from "../api/shares";
+import { createShare, deleteShare, type Share } from "../../api";
+import { sharesQuery } from "../../api/queries";
+import { MAX_SHARE_TRACKS } from "../../api/shares";
import {
EmptyState,
Loading,
PageHeader,
useReload,
-} from "../components/page-shell";
-import { usePlayer } from "../player/player";
-import { useI18n } from "../state/i18n";
+} from "../../components/layout/page-shell";
+import { usePlayer } from "../../player/player";
+import { useI18n } from "../../state/i18n";
export function SharesPage() {
const player = usePlayer();
diff --git a/webapp/src/pages/upload-page.tsx b/webapp/src/pages/settings/upload-page.tsx
similarity index 96%
rename from webapp/src/pages/upload-page.tsx
rename to webapp/src/pages/settings/upload-page.tsx
index 2aab7589..8e570989 100644
--- a/webapp/src/pages/upload-page.tsx
+++ b/webapp/src/pages/settings/upload-page.tsx
@@ -10,19 +10,19 @@ import {
putUploadChunk,
type UploadDecision,
type UploadSessionState,
-} from "../api";
-import { filedTreeQuery } from "../api/queries";
-import { PageBack } from "../components/page-back";
-import { PageHeader } from "../components/page-shell";
-import type { HashRequest, HashResponse } from "../lib/hash-worker";
+} from "../../api";
+import { filedTreeQuery } from "../../api/queries";
+import { PageBack } from "../../components/layout/page-back";
+import { PageHeader } from "../../components/layout/page-shell";
+import type { HashRequest, HashResponse } from "../../lib/hash-worker";
import {
extensionOf,
isUploadable,
nextRange,
UPLOAD_EXTENSIONS,
-} from "../lib/uploads";
-import { type TranslationKey, useI18n } from "../state/i18n";
-import { mayUploadTo, useLibraryScope } from "../state/library-scope";
+} from "../../lib/uploads";
+import { type TranslationKey, useI18n } from "../../state/i18n";
+import { mayUploadTo, useLibraryScope } from "../../state/library-scope";
type Status =
| "waiting"
@@ -112,7 +112,7 @@ function hashFile(
file: File,
onProgress: (read: number) => void,
): Promise {
- worker ??= new Worker(new URL("../lib/hash-worker.ts", import.meta.url), {
+ worker ??= new Worker(new URL("../../lib/hash-worker.ts", import.meta.url), {
type: "module",
});
const hasher = worker;
diff --git a/webapp/src/player/bar.tsx b/webapp/src/player/bar.tsx
index 84fb2842..2e94c8ac 100644
--- a/webapp/src/player/bar.tsx
+++ b/webapp/src/player/bar.tsx
@@ -3,8 +3,8 @@ import { Link } from "@tanstack/react-router";
import { type CSSProperties, useState } from "react";
import { formatDuration } from "../api";
-import { Artwork } from "../components/artwork";
-import { Icon } from "../components/icons";
+import { Artwork } from "../components/artwork/artwork";
+import { Icon } from "../components/layout/icons";
import { type Aside, asideArtwork, asideTransport } from "../lib/aside";
import { useI18n } from "../state/i18n";
import { useAsideProgress, usePlayer, usePlayerProgress } from "./context";
diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json
index 432f4dbd..d02865aa 100644
--- a/webapp/tsconfig.json
+++ b/webapp/tsconfig.json
@@ -12,7 +12,8 @@
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true,
- "isolatedModules": true
+ "isolatedModules": true,
+ "types": ["vite/client"]
},
- "include": ["src", "vite.config.ts"]
+ "include": ["src"]
}
diff --git a/webapp/tsconfig.node.json b/webapp/tsconfig.node.json
new file mode 100644
index 00000000..33b7e595
--- /dev/null
+++ b/webapp/tsconfig.node.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "verbatimModuleSyntax": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "types": ["node"]
+ },
+ "include": ["vite.config.ts", "vitest.config.ts"]
+}
diff --git a/webapp/vitest.config.ts b/webapp/vitest.config.ts
index ba8d6e2a..2f372526 100644
--- a/webapp/vitest.config.ts
+++ b/webapp/vitest.config.ts
@@ -7,7 +7,9 @@ export default defineConfig({
test: {
environment: "jsdom",
passWithNoTests: false,
- include: ["src/**/*.test.ts"],
+ // `.tsx` as well: a component test written with JSX would otherwise be
+ // left out without a word, and the suite would pass over it.
+ include: ["src/**/*.test.{ts,tsx}"],
// Vitest hands a CSS import back as an empty string by default, which is
// sound for a component test and wrong for a test *about* the stylesheet:
// the scale check read "" and passed over nothing. The sheet needs no