mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 15:17:43 -05:00
Diff leaderboard
This commit is contained in:
@@ -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 `<img>` 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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -34,12 +34,14 @@ let {
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
|
||||
<div {title} class={containerClass} style={containerStyle} {onclick}>
|
||||
<!-- dimensions as strings: numbers go through the img.width/height DOM
|
||||
property setters on hydration, which truncate fractional values (27.76 -> 27) -->
|
||||
<img
|
||||
{alt}
|
||||
src="{path}.avif"
|
||||
class={className}
|
||||
width={size ?? width}
|
||||
height={size ?? height}
|
||||
width={(size ?? width)?.toString()}
|
||||
height={(size ?? height)?.toString()}
|
||||
{style}
|
||||
draggable="false"
|
||||
{loading}
|
||||
|
||||
@@ -84,6 +84,10 @@ const unseenFriendRequests = $derived(
|
||||
|
||||
const isFrontPage = $derived(page.url.pathname === "/");
|
||||
|
||||
// xxx: wire to the chat sidebar when the chat rebuild lands (svelte-big-bang
|
||||
// phase 3); until then the button renders for layout parity with the React app
|
||||
const noopUntilChatRebuild = () => {};
|
||||
|
||||
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}
|
||||
/>
|
||||
<div id="nprogress-anchor" aria-hidden="true"></div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`;
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
@@ -265,11 +266,12 @@ const labelId = $derived(label ? `${uid}-select-label` : undefined);
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={labelId && !ariaLabel ? `${valueId} ${labelId}` : undefined}
|
||||
data-required={isRequired || undefined}
|
||||
onclick={() => setOpen(!open)}
|
||||
onkeydown={onTriggerKeydown}
|
||||
>
|
||||
<span class="selectValue" data-placeholder={selectedText === undefined ? "true" : undefined}>
|
||||
<span id={valueId} class="selectValue" data-placeholder={selectedText === undefined ? "true" : undefined}>
|
||||
{#if currentKey !== null && valueContent}
|
||||
{@render valueContent(currentKey)}
|
||||
{:else if selectedText !== undefined}
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ The run writes `output/<timestamp>/`:
|
||||
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
|
||||
|
||||
64
tooling/differ/scripts/memwatch.sh
Executable file
64
tooling/differ/scripts/memwatch.sh
Executable file
@@ -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 <log-dir> <cap-mb-per-process> -- <command...>
|
||||
#
|
||||
# writes to <log-dir>:
|
||||
# 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"
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user