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