Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<area>.tsx` imports its screens from `pages/<area>/` 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.

Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<area>/`, 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
Expand Down
88 changes: 88 additions & 0 deletions scripts/check-webapp-layout.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF
$(find webapp/src -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.css' \) | sort)
EOF

while IFS= read -r file; do
[ -n "$file" ] || continue
check_size "$file" "$TEST_LIMIT"
done <<EOF
$(find webapp/e2e -type f | sort)
EOF

if [ "$failed" -ne 0 ]; then
exit 1
fi
echo "Webapp layout: every file within its limit."
2 changes: 1 addition & 1 deletion webapp/biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"useIgnoreFile": false
},
"files": {
"includes": ["src/**", "*.ts", "*.json"]
"includes": ["src/**", "e2e/**", "*.ts", "*.json"]
},
"formatter": {
"enabled": true,
Expand Down
5 changes: 5 additions & 0 deletions webapp/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 4 additions & 6 deletions webapp/e2e/admin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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",
);
});

/**
Expand Down
Loading
Loading