Phase 2 harness wiring: differ --right-app web, e2e target app, dev-controls, cookbook

- differ can serve apps/web on the right side (--right-app web); impersonation no longer follows app-specific redirects
- e2e: E2E_TARGET_APP=web spawns apps/web servers per worker against the shared dbs; leaderboards spec + page object added; expectRouterIdle exported
- apps/web dev-controls endpoints: /auth/impersonate(+stop), /theme (404-redirect GET parity), /refresh-caches, /end-season, /run-routine, /sidenav
- HydrationTestIndicator implements the hydrated/router-idle contract (counts in-flight remote fetches)
- codemods: route-map (routes.ts -> SvelteKit route scaffolds, manifest-aware, idempotent) and remote-scaffold (loader->query, _action branches->commands)
- MIGRATION.md cookbook seeded with the golden read-path pattern and slice learnings; manifest: leaderboards migrated
This commit is contained in:
Kalle
2026-08-16 11:59:13 +03:00
parent 2aaf937ff9
commit 35ffc136be
27 changed files with 1062 additions and 16 deletions

View File

@@ -10,6 +10,12 @@ import {
} from "./helpers/playwright";
const DEBUG = process.env.E2E_DEBUG === "true";
/** `E2E_TARGET_APP=web` serves `apps/web` (the SvelteKit app) instead of this
* React app, for running migrated specs against the other side of the
* migration (`svelte-big-bang.md`). Factories keep writing through this app's
* code either way — the database files are shared. */
const TARGET_APP = process.env.E2E_TARGET_APP === "web" ? "web" : "web-react";
const WEB_APP_DIR = new URL("../../web", import.meta.url).pathname;
const SERVER_PROCESSES: ChildProcess[] = [];
const MINIO_MARKER_FILE = ".e2e-minio-started";
const STORAGE_BUCKET = "sendou";
@@ -127,6 +133,21 @@ async function globalSetup(config: FullConfig) {
: "Built the application",
);
if (TARGET_APP === "web") {
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log("Building apps/web (E2E_TARGET_APP=web)...");
execSync("pnpm run build", {
stdio: "inherit",
cwd: WEB_APP_DIR,
env: {
...process.env,
NODE_ENV: "production",
VITE_E2E_TEST_RUN: "true",
VITE_SITE_DOMAIN: `http://localhost:${E2E_BASE_PORT}`,
},
});
}
// Prepare databases and start servers for each worker
const serverPromises: Promise<void>[] = [];
@@ -156,12 +177,19 @@ async function globalSetup(config: FullConfig) {
// above already migrated, making the script's `migrate up` step redundant
const serverProcess = spawn(
process.execPath,
["./node_modules/@react-router/serve/bin.cjs", "./build/server/index.js"],
TARGET_APP === "web"
? [`${WEB_APP_DIR}/build/index.js`]
: [
"./node_modules/@react-router/serve/bin.cjs",
"./build/server/index.js",
],
{
env: {
...process.env,
NODE_ENV: "production",
DB_PATH: dbPath,
// the db files live in this app's directory either way (the
// factories and migrations run through this app's code)
DB_PATH: TARGET_APP === "web" ? `${process.cwd()}/${dbPath}` : dbPath,
PORT: String(port),
DISCORD_CLIENT_ID: "123",
DISCORD_CLIENT_SECRET: "secret",

View File

@@ -315,7 +315,7 @@ export async function waitForPOSTResponse(page: Page, cb: () => Promise<void>) {
}
/** Waits until no navigation, revalidation or fetcher is in flight. */
async function expectRouterIdle(page: Page) {
export async function expectRouterIdle(page: Page) {
// A submit's redirect plus the target page's loaders can exceed the default
// expect timeout when the full suite is loading all workers.
try {

View File

@@ -0,0 +1,93 @@
import { expect, test } from "./helpers/playwright";
import { LeaderboardsPage } from "./pages/leaderboards/leaderboards-page";
const HIGH_POWER_NAME = "TopPlayer";
const LOW_POWER_NAME = "RunnerUp";
const RM_ONLY_NAME = "RainmakerMain";
test.describe("Leaderboards", () => {
test("shows the season player leaderboard ordered by SP", async ({
page,
factories,
}) => {
const better = await factories.UserFactory.create({
discordName: "BetterPlayer",
});
const worse = await factories.UserFactory.create({
discordName: "WorsePlayer",
});
await factories.SkillFactory.create(
{ userId: better.id, mu: 30 },
{ matchesCount: 10 },
);
await factories.SkillFactory.create(
{ userId: worse.id, mu: 20 },
{ matchesCount: 10 },
);
const leaderboards = new LeaderboardsPage(page);
await leaderboards.goto();
const betterRow = page.getByRole("link", { name: /BetterPlayer/ });
const worseRow = page.getByRole("link", { name: /WorsePlayer/ });
await expect(betterRow).toBeVisible();
await expect(worseRow).toBeVisible();
const betterBox = await betterRow.boundingBox();
const worseBox = await worseRow.boundingBox();
expect(betterBox!.y).toBeLessThan(worseBox!.y);
});
test("filters the X Battle leaderboard by mode", async ({
page,
factories,
}) => {
const zonesPlayer = await factories.UserFactory.create();
const rainmakerPlayer = await factories.UserFactory.create();
await factories.XRankPlacementFactory.create({
playerUserId: zonesPlayer.id,
name: HIGH_POWER_NAME,
mode: "SZ",
power: 3200,
weaponSplId: 40,
});
await factories.XRankPlacementFactory.create({
playerUserId: zonesPlayer.id,
name: LOW_POWER_NAME,
playerSplId: `runner-up-${zonesPlayer.id}`,
mode: "SZ",
power: 3000,
weaponSplId: 1000,
});
await factories.XRankPlacementFactory.create({
playerUserId: rainmakerPlayer.id,
name: RM_ONLY_NAME,
mode: "RM",
power: 2900,
weaponSplId: 2000,
});
const leaderboards = new LeaderboardsPage(page);
await leaderboards.goto("?type=XP-ALL");
await expect(page.getByText(HIGH_POWER_NAME)).toBeVisible();
await expect(page.getByText(RM_ONLY_NAME)).toBeVisible();
await leaderboards.selectModeChip("Rainmaker");
await expect(page.getByText(RM_ONLY_NAME)).toBeVisible();
await expect(page.getByText(HIGH_POWER_NAME)).not.toBeVisible();
expect(page.url()).toContain("type=XP-MODE-RM");
});
test("switching to the X Battle tab clears the season param", async ({
page,
}) => {
const leaderboards = new LeaderboardsPage(page);
await leaderboards.goto("?season=1");
await leaderboards.selectTab("xpTab");
expect(page.url()).toContain("type=XP-ALL");
expect(page.url()).not.toContain("season=");
});
});

View File

@@ -0,0 +1,42 @@
import type { Page } from "@playwright/test";
import { LEADERBOARDS_PAGE } from "~/utils/urls";
import { expectRouterIdle, navigate } from "../../helpers/playwright";
/** `/leaderboards` */
export class LeaderboardsPage {
private readonly page: Page;
readonly locators;
constructor(page: Page) {
this.page = page;
this.locators = {
playersTab: this.page.getByRole("tab", { name: "Players" }),
teamsTab: this.page.getByRole("tab", { name: "Teams" }),
xpTab: this.page.getByRole("tab", { name: "X Battle" }),
weaponSelect: this.page.getByTestId("weapon-select"),
rows: this.page.locator("main a[href]").filter({
has: this.page.locator("img"),
}),
};
}
async goto(search = "") {
await navigate({
page: this.page,
url: `${LEADERBOARDS_PAGE}${search}`,
});
}
async selectTab(tab: "playersTab" | "teamsTab" | "xpTab") {
await this.locators[tab].click();
await expectRouterIdle(this.page);
}
async selectModeChip(label: string) {
await this.page
.getByRole("radiogroup")
.getByText(label, { exact: true })
.click();
await expectRouterIdle(this.page);
}
}