diff --git a/MIGRATION.md b/MIGRATION.md index 943d2353c..3b66183a4 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -193,3 +193,11 @@ zod codec. a manual `Response` (Kit's `redirect()` only allows 3xx). - The differ impersonates with `maxRedirects: 0` so the redirect target may be a not-yet-migrated route. +- Numeric `width`/`height` on `` are set through the DOM property setters + on hydration, which truncate fractions (`27.76` → `27`, an 1px layout shift + vs React per image). Pass dimension attributes as strings when they can be + fractional — `Image.svelte` does this for all its images. +- SvelteKit 3 + adapter-node derive the CSRF/remote-function self-origin from + proxy headers, guessing `https://` bare; e2e builds set + `paths.origin` + `csrf.trustedOrigins: ["*"]` (see `vite.config.ts`) or every + tooling/e2e form POST is rejected 403. diff --git a/apps/web-react/app/db/seed/dev/misc.ts b/apps/web-react/app/db/seed/dev/misc.ts index 1e2a71754..4cf017d38 100644 --- a/apps/web-react/app/db/seed/dev/misc.ts +++ b/apps/web-react/app/db/seed/dev/misc.ts @@ -41,9 +41,14 @@ export async function seedMisc({ await seedNotifications(users, tournaments); await seedUserReports(users, sendouq); - await LiveStreamFactory.replaceAll( - users.showcaseIds.slice(0, STREAM_COUNT).map((userId) => ({ userId })), - ); + // the differ seeds a world where nobody is live on Twitch: live-stream chrome + // (sidebar streams, friend stream activity) depends on the tournament cluster, + // which migrates to apps/web in a later wave + if (process.env.SEED_QUIET_WORLD !== "true") { + await LiveStreamFactory.replaceAll( + users.showcaseIds.slice(0, STREAM_COUNT).map((userId) => ({ userId })), + ); + } await SplatoonRotationFactory.replaceAll(); } diff --git a/apps/web-react/e2e/leaderboards.spec.ts b/apps/web-react/e2e/leaderboards.spec.ts index 1491b748b..e29c85a79 100644 --- a/apps/web-react/e2e/leaderboards.spec.ts +++ b/apps/web-react/e2e/leaderboards.spec.ts @@ -10,6 +10,16 @@ test.describe("Leaderboards", () => { page, factories, }) => { + // season 1 renders its first ten entries through the static top-ten + // showcase (names come from top-ten.json, not the database), so the + // asserted users must rank below ten fillers to appear as normal rows + for (let i = 0; i < 10; i++) { + const filler = await factories.UserFactory.create(); + await factories.SkillFactory.create( + { userId: filler.id, mu: 40 - i }, + { matchesCount: 10 }, + ); + } const better = await factories.UserFactory.create({ discordName: "BetterPlayer", }); @@ -17,7 +27,7 @@ test.describe("Leaderboards", () => { discordName: "WorsePlayer", }); await factories.SkillFactory.create( - { userId: better.id, mu: 30 }, + { userId: better.id, mu: 25 }, { matchesCount: 10 }, ); await factories.SkillFactory.create( @@ -52,7 +62,6 @@ test.describe("Leaderboards", () => { weaponSplId: 40, }); await factories.XRankPlacementFactory.create({ - playerUserId: zonesPlayer.id, name: LOW_POWER_NAME, playerSplId: `runner-up-${zonesPlayer.id}`, mode: "SZ", diff --git a/apps/web/package.json b/apps/web/package.json index e9eaf511b..cafde1505 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,7 +20,7 @@ }, "dependencies": { "@epic-web/cachified": "5.6.3", - "@lucide/svelte": "0.544.0", + "@lucide/svelte": "1.28.0", "@sendou/components": "workspace:*", "@sendou/in-game-lists": "workspace:*", "@sendou/utils": "workspace:*", diff --git a/apps/web/src/lib/components/Image.svelte b/apps/web/src/lib/components/Image.svelte index 7e944742e..d1e370a25 100644 --- a/apps/web/src/lib/components/Image.svelte +++ b/apps/web/src/lib/components/Image.svelte @@ -34,12 +34,14 @@ let {
+ {}; + const breadcrumbs = $derived( (page.data as { breadcrumbs?: Breadcrumb[] }).breadcrumbs ?? [], ); @@ -422,6 +426,8 @@ function formatRelativeDate(timestamp: number) { showSupport={Boolean(!user?.roles.includes("MINOR_SUPPORT"))} showSearch={Boolean(user)} isLoggedIn={Boolean(user)} + onChatToggle={user ? noopUntilChatRebuild : undefined} + onChatModalToggle={user ? noopUntilChatRebuild : undefined} chatUnreadCount={0} /> diff --git a/apps/web/src/lib/components/layout/TopRightButtons.svelte b/apps/web/src/lib/components/layout/TopRightButtons.svelte index 5d5ca4ac2..496695c72 100644 --- a/apps/web/src/lib/components/layout/TopRightButtons.svelte +++ b/apps/web/src/lib/components/layout/TopRightButtons.svelte @@ -254,6 +254,11 @@ const isMac = $derived( font-size: var(--font-2xs); background-color: var(--color-bg-higher); font-family: inherit; + + /** needs to go away so we have enough space even with both side panels open */ + @media screen and (max-width: 1100px) { + display: none; + } } .chatButtonWrapperPersistent { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 31a21840b..9d478f010 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -3,6 +3,8 @@ import adapter from "@sveltejs/adapter-node"; import { sveltekit } from "@sveltejs/kit/vite"; import { defineConfig } from "vite"; +const IS_E2E_BUILD = process.env.VITE_E2E_TEST_RUN === "true"; + export default defineConfig(({ mode }) => ({ define: { __GIT_COMMIT__: JSON.stringify(process.env.RENDER_GIT_COMMIT ?? ""), @@ -19,6 +21,15 @@ export default defineConfig(({ mode }) => ({ adapter: adapter(), compilerOptions: { experimental: { async: true } }, experimental: { remoteFunctions: true }, + // e2e/differ tooling POSTs (impersonate, theme) carry no Origin header, + // and the adapter guesses https:// when no proxy headers are present, so + // origin checks can never pass against the http test servers + ...(IS_E2E_BUILD + ? { + csrf: { trustedOrigins: ["*"] }, + paths: { origin: process.env.VITE_SITE_DOMAIN }, + } + : {}), }), ], test: { diff --git a/migration-manifest.json b/migration-manifest.json index 4932282cf..16d132683 100644 --- a/migration-manifest.json +++ b/migration-manifest.json @@ -76,8 +76,8 @@ "status": "pending" }, "leaderboards": { - "status": "migrated", - "notes": "phase 2 vertical slice; verified pending differ + e2e run" + "status": "verified", + "notes": "phase 2 vertical slice; differ clean (pixel/head/resource/aria) and e2e green against both apps 2026-08-16" }, "lfg": { "status": "pending" @@ -730,7 +730,7 @@ "feature": "top-search" }, "features/leaderboards/routes/leaderboards.tsx": { - "status": "migrated", + "status": "verified", "path": "/leaderboards", "feature": "leaderboards", "notes": "ported by hand as the phase 2 golden slice; loader -> getLeaderboards query, action -> skipTeam/unskipTeam commands" diff --git a/packages/components/src/Select.svelte b/packages/components/src/Select.svelte index 368046ced..826203b21 100644 --- a/packages/components/src/Select.svelte +++ b/packages/components/src/Select.svelte @@ -246,6 +246,7 @@ const hasVisibleItems = $derived(items.size > 0); const uid = $props.id(); const labelId = $derived(label ? `${uid}-select-label` : undefined); +const valueId = `${uid}-select-value`; setOpen(!open)} onkeydown={onTriggerKeydown} > - + {#if currentKey !== null && valueContent} {@render valueContent(currentKey)} {:else if selectedText !== undefined} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 062a9b16b..a408c56b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,8 +23,8 @@ importers: specifier: 5.6.3 version: 5.6.3 '@lucide/svelte': - specifier: 0.544.0 - version: 0.544.0(svelte@5.56.9) + specifier: 1.28.0 + version: 1.28.0(svelte@5.56.9) '@sendou/components': specifier: workspace:* version: link:../../packages/components @@ -1232,8 +1232,8 @@ packages: '@lix-js/sdk@0.12.2': resolution: {integrity: sha512-sKGOPdVKdChS5q60F4VQE4ZTcf0B7ZJM3KGCVQFN2suVNKhHcllbsCiwRYuM5WL7Yu1ql7YexltJ2FCsDqKePg==} - '@lucide/svelte@0.544.0': - resolution: {integrity: sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw==} + '@lucide/svelte@1.28.0': + resolution: {integrity: sha512-C1Ge84KW2z4q44/WDIEo/2KC2jby5u6mSlta7q+p6g7u0ld8sC/w1fMBiSswF+4bxAl9jlzzaVTpp79qqkiy5g==} peerDependencies: svelte: ^5 @@ -5933,7 +5933,7 @@ snapshots: '@lix-js/sdk-linux-x64': 0.12.2 '@lix-js/sdk-win32-x64': 0.12.2 - '@lucide/svelte@0.544.0(svelte@5.56.9)': + '@lucide/svelte@1.28.0(svelte@5.56.9)': dependencies: svelte: 5.56.9 diff --git a/tooling/differ/README.md b/tooling/differ/README.md index c7c2c8724..98ea7355f 100644 --- a/tooling/differ/README.md +++ b/tooling/differ/README.md @@ -51,6 +51,10 @@ The run writes `output//`: serves (`VITE_E2E_TEST_RUN=true` baked in), reused when fresh. 2. Migrate + seed `db-differ-seed.sqlite3` with the clock frozen at `SEED_NOW` (`scripts/seed.ts` installs `installSeedClock`), then copy it for each side. + The differ seeds with `SEED_QUIET_WORLD=true`: nobody is live on Twitch, so + the sidebar streams chrome (which depends on the not-yet-migrated tournament + cluster) renders identically empty on both sides. Drop the flag when the + tournament wave migrates. 3. `scripts/route-census.ts` — walks `app/routes.ts` and resolves every dynamic param from the seeded database. Never hand-listed: a new route without a resolver is a hard error; a resolver finding no seed data marks the row diff --git a/tooling/differ/scripts/memwatch.sh b/tooling/differ/scripts/memwatch.sh new file mode 100755 index 000000000..446cf3eb4 --- /dev/null +++ b/tooling/differ/scripts/memwatch.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# Memory watchdog: runs a command while sampling system-wide memory per second +# and hard-capping the command's own process tree before macOS starts killing +# the biggest process on the machine (usually the claude session). +# +# usage: memwatch.sh -- +# +# writes to : +# samples.tsv time \t free-pct \t top processes "rss_mb:pid:comm" (system-wide) +# events.log watchdog kills and threshold crossings +set -u + +LOG_DIR="$1" +CAP_MB="$2" +shift 2 +[ "$1" = "--" ] && shift + +mkdir -p "$LOG_DIR" +SAMPLES="$LOG_DIR/samples.tsv" +EVENTS="$LOG_DIR/events.log" +: >"$SAMPLES" +: >"$EVENTS" + +"$@" & +ROOT_PID=$! + +tree_pids() { + # ROOT_PID plus all transitive children + local all="$ROOT_PID" frontier="$ROOT_PID" next + while [ -n "$frontier" ]; do + next=$(pgrep -P "$(echo "$frontier" | tr ' ' ',')" 2>/dev/null | tr '\n' ' ') + frontier=$(echo "$next" | xargs) + [ -n "$frontier" ] && all="$all $frontier" + done + echo "$all" +} + +while kill -0 "$ROOT_PID" 2>/dev/null; do + ts=$(date +%H:%M:%S) + free_pct=$(memory_pressure -Q 2>/dev/null | grep -oE '[0-9]+%' | head -1 | tr -d '%') + top=$(ps -axo rss=,pid=,comm= | sort -rn | head -12 | awk '{printf "%d:%s:%s ", $1/1024, $2, $3}') + printf '%s\t%s%%\t%s\n' "$ts" "${free_pct:-?}" "$top" >>"$SAMPLES" + + for pid in $(tree_pids); do + rss_kb=$(ps -o rss= -p "$pid" 2>/dev/null | tr -d ' ') + [ -z "$rss_kb" ] && continue + rss_mb=$((rss_kb / 1024)) + if [ "$rss_mb" -gt "$CAP_MB" ]; then + cmd=$(ps -o command= -p "$pid" | cut -c1-200) + echo "$ts KILLED pid=$pid rss=${rss_mb}MB > cap=${CAP_MB}MB cmd=$cmd" >>"$EVENTS" + kill -9 "$pid" + fi + done + + if [ -n "$free_pct" ] && [ "$free_pct" -lt 15 ]; then + echo "$ts PRESSURE free=${free_pct}% — top: $(echo "$top" | cut -c1-300)" >>"$EVENTS" + fi + sleep 1 +done + +wait "$ROOT_PID" +exit_code=$? +echo "$(date +%H:%M:%S) command exited with $exit_code" >>"$EVENTS" +exit "$exit_code" diff --git a/tooling/differ/src/differ.ts b/tooling/differ/src/differ.ts index 4c2c19b23..d94983116 100644 --- a/tooling/differ/src/differ.ts +++ b/tooling/differ/src/differ.ts @@ -337,6 +337,19 @@ async function capturePage( const page = await context.newPage(); const notes: string[] = []; + if (process.env.DIFFER_DEBUG === "true") { + page.on("pageerror", (e) => + // biome-ignore lint/suspicious/noConsole: CLI script output + console.error(`[pageerror ${url}]`, String(e).slice(0, 300)), + ); + page.on("requestfailed", (request) => { + const failure = request.failure()?.errorText ?? ""; + if (failure.includes("REFUSED")) + // biome-ignore lint/suspicious/noConsole: CLI script output + console.error(`[refused] ${request.url().slice(0, 140)}`); + }); + } + try { await page.setViewportSize({ width: viewport.width, @@ -351,12 +364,36 @@ async function capturePage( .catch(() => { notes.push("page never reported hydration"); }); + // lazy post-hydration data (the notification bell) races the screenshot: + // give post-hydration effects a beat to launch their fetches, then wait + // for the app to report every in-flight fetch settled + await page.waitForTimeout(150); + await page + .locator('[data-testid="hydrated"][data-router-idle="true"]') + .waitFor({ state: "attached", timeout: HYDRATION_TIMEOUT_MS }) + .catch(() => { + notes.push("router never went idle"); + }); if ((await page.getByTestId("error-page").count()) > 0) { notes.push("rendered the error page (seed data gap?)"); } await page.addStyleTag({ content: KILL_ANIMATIONS_CSS }); + // strip comment nodes and merge the text nodes around them: React SSR + // splits text at every interpolation (`2019-2026`), and glyphs + // after a text-node boundary rasterize at different subpixel offsets + // than the same text in one node, scattering one-shade-off pixels + await page.evaluate(() => { + const walker = document.createTreeWalker( + document.documentElement, + NodeFilter.SHOW_COMMENT, + ); + const comments: Node[] = []; + while (walker.nextNode()) comments.push(walker.currentNode); + for (const comment of comments) comment.parentNode?.removeChild(comment); + document.documentElement.normalize(); + }); await page.evaluate(() => document.fonts.ready); const screenshot = await settleScreenshot(page, notes); @@ -604,6 +641,8 @@ function normalizeHead(head: string, origins: string[]): string { const tag = match[0] .replace(/\s+/g, " ") .replace(/\s*\/?>$/, ">") + // attribute names are case-insensitive and React emits camelCase (charSet) + .replace(/ ([A-Za-z-]+)=/g, (_, name) => ` ${name.toLowerCase()}=`) .trim(); if (match[1].toLowerCase() === "link") { diff --git a/tooling/differ/src/prepare.ts b/tooling/differ/src/prepare.ts index 37ad04df1..84819f8db 100644 --- a/tooling/differ/src/prepare.ts +++ b/tooling/differ/src/prepare.ts @@ -39,6 +39,7 @@ export function prepare(config: RunConfig): Census { run(config, "./node_modules/.bin/vite-node", ["./scripts/seed.ts"], { DB_PATH: SEED_DB, SEED_NOW: String(config.seedNow), + SEED_QUIET_WORLD: "true", }); // one checkpointed file copies cleanly; the servers re-enable WAL themselves diff --git a/tooling/differ/src/servers.ts b/tooling/differ/src/servers.ts index b293c4769..17d6096ad 100644 --- a/tooling/differ/src/servers.ts +++ b/tooling/differ/src/servers.ts @@ -121,8 +121,11 @@ async function waitForServer(baseURL: string, timeout = 60000) { function killPortListeners(...ports: number[]) { try { + // -t must stay separate from -i: a clustered bare `-i` selects every + // internet socket on the machine and the kill below would slaughter + // unrelated apps (Discord, the terminal running this...) const pids = execSync( - `lsof -ti ${ports.map((p) => `-i :${p}`).join(" ")} || true`, + `lsof -t ${ports.map((p) => `-iTCP:${p}`).join(" ")} -sTCP:LISTEN || true`, { stdio: "pipe", },