mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 07:37:55 -05:00
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:
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
93
apps/web-react/e2e/leaderboards.spec.ts
Normal file
93
apps/web-react/e2e/leaderboards.spec.ts
Normal 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=");
|
||||
});
|
||||
});
|
||||
42
apps/web-react/e2e/pages/leaderboards/leaderboards-page.ts
Normal file
42
apps/web-react/e2e/pages/leaderboards/leaderboards-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user