chore(tooling): add eslint + prettier, fix the test watch-mode default

Three gaps in the frontend tooling, all in the package.json script surface.

1. No JS/TS linter or formatter existed at all for 274 TS/Svelte files.

   Adds an ESLint flat config (typescript-eslint + eslint-plugin-svelte,
   Svelte 5 + TS strict) and prettier + prettier-plugin-svelte, plus the
   `lint`, `lint:fix`, `format`, `format:check` scripts.

   The tree is error-clean (`npx eslint .` exits 0). Getting there needed
   seven real one-line fixes (braced switch cases that leaked `const` across
   arms, a useless regex escape, two `let`s that never change, a thrown Error
   that dropped its `cause`, and two `// eslint-disable-next-line` comments
   documenting the Svelte 5 bare-read-for-dependency idiom). Everything else
   that fires is set to `warn` with the reason written next to it in
   eslint.config.js — notably ~94 dead bindings and `any` at the IPC
   boundary. Those are real findings to drive to zero, not noise to delete.

   `no-console` is OFF for now: a parallel change is moving all ~468 console
   calls onto a logger facade, and turning the rule on today would collide
   with it. eslint.config.js says so, and says to flip it to `error` once
   that lands.

   `prettier --write` is deliberately NOT run here — it would rewrite ~200
   files and swamp every other diff in flight. The gate is available; the
   sweep is a separate commit. Markdown and CI YAML are in .prettierignore
   because both are hand-laid-out (and docs/traceability.md is generated).

2. `bun run test` was bare `vitest`, i.e. watch mode — while CLAUDE.md's
   "Before Committing" list tells people to run it. It is now `vitest run`,
   with `test:watch` and `test:coverage` (also `--run`-ified) alongside.
   scripts/test-all.sh drops the now-redundant `--run`, and
   scripts/test-frontend.sh keeps `--watch`/`--ui`/`-w` working by routing
   them to a long-running vitest instead of the single-pass one.

3. The webdriverio e2e suite is deleted. It was last touched in January
   ("First working POC"), has never run since, and is not in CI — five
   devDependencies and two scripts of pure decoration. Removes e2e/,
   wdio.conf.ts, the two `test:e2e*` scripts, the @wdio/* + webdriverio
   devDeps, and the WebdriverIO block in .gitignore.

The package.json diff also carries `hooks:install` and `check:links`, wired
up by the following commits.
This commit is contained in:
2026-08-20 19:35:17 +02:00
parent 51d914777a
commit 95eb16d5ef
27 changed files with 485 additions and 1868 deletions
@@ -66,6 +66,9 @@
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
$effect(() => {
// Bare read: registers `bottomGap` as a dependency of this effect. Svelte 5
// idiom, not a stray expression.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
bottomGap;
measure();
});
@@ -49,6 +49,9 @@
// A new item in the same slot (scrolling a virtualised list, switching series)
// must drop the previous item's optimistic state or it shows the wrong tick.
$effect(() => {
// Bare read: registers `itemId` as a dependency of this effect. Svelte 5
// idiom, not a stray expression.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
itemId;
optimistic = null;
});
+3 -2
View File
@@ -260,7 +260,6 @@ export class Html5PlayerAdapter implements PlayerAdapter {
timeoutMs: number
): Promise<boolean> {
return new Promise<boolean>((resolve) => {
let timer: ReturnType<typeof setTimeout>;
const done = (fired: boolean) => {
el.removeEventListener(event, listener);
clearTimeout(timer);
@@ -268,7 +267,9 @@ export class Html5PlayerAdapter implements PlayerAdapter {
};
const listener = () => done(true);
el.addEventListener(event, listener);
timer = setTimeout(() => done(false), timeoutMs);
// `done` closes over `timer`, but can only run once the listener fires or
// the timeout elapses — both strictly after this assignment.
const timer: ReturnType<typeof setTimeout> = setTimeout(() => done(false), timeoutMs);
});
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ export async function getDeviceId(): Promise<string> {
return deviceId;
} catch (e) {
console.error("[deviceId] Failed to get device ID from backend:", e);
throw new Error("Failed to initialize device ID: " + String(e));
throw new Error("Failed to initialize device ID: " + String(e), { cause: e });
}
}
+4 -2
View File
@@ -192,7 +192,7 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
switch (state) {
case "playing":
case "paused":
case "loading":
case "loading": {
// When local playback starts, ensure mode is set to local
const mode = get(playbackMode);
if (mode.mode !== "local") {
@@ -233,9 +233,10 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
}
break;
}
case "idle":
case "stopped":
case "stopped": {
player.setIdle();
// When local playback stops, revert to idle mode
const currentMode = get(playbackMode);
@@ -245,6 +246,7 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
}
break;
}
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ export interface InvokeCall {
}
let invokeHistory: InvokeCall[] = [];
let invokeResponses: Map<string, any> = new Map();
const invokeResponses: Map<string, any> = new Map();
/**
* Mock invoke function that captures calls
+1 -1
View File
@@ -79,7 +79,7 @@ export function validateUrlPathSegment(segment: string): void {
}
// Reject path separators and null bytes
if (/[\/\\%]/.test(segment)) {
if (/[/\\%]/.test(segment)) {
throw new Error("Invalid path segment: contains invalid characters");
}
}