This commit is contained in:
Kalle
2026-08-16 11:05:12 +03:00
parent c0fdb922fd
commit e3e071b6a5
30 changed files with 3023 additions and 136 deletions

View File

@@ -309,12 +309,13 @@ function MobilePanel({
<Modal
className={clsx(styles.panel, skipAnimation && styles.noAnimation)}
>
<Dialog className={styles.panelDialog}>
<Dialog data-testid="mobile-nav-panel" className={styles.panelDialog}>
<header className={styles.panelHeader}>
<div className={styles.panelIconContainer}>{icon}</div>
<h2 className={styles.panelTitle}>{title}</h2>
<button
type="button"
data-testid="panel-close-button"
className={styles.panelCloseButton}
onClick={onClose}
>
@@ -363,7 +364,7 @@ function MenuOverlay({
skipAnimation && styles.noAnimation,
)}
>
<Dialog className={styles.panelDialog}>
<Dialog data-testid="mobile-nav-panel" className={styles.panelDialog}>
<header className={styles.menuHeader}>
<div className={styles.panelIconContainer}>
<Menu size={18} />
@@ -387,6 +388,7 @@ function MenuOverlay({
/>
<button
type="button"
data-testid="panel-close-button"
className={styles.panelCloseButton}
onClick={onClose}
>
@@ -433,7 +435,7 @@ function MenuOverlay({
</h3>
</header>
{streams.length === 0 ? (
<div className={styles.sideNavEmpty}>
<div data-testid="side-nav-empty" className={styles.sideNavEmpty}>
{t("front:sideNav.noStreams")}
</div>
) : null}
@@ -483,7 +485,7 @@ function FriendsPanel({
<FriendMenu key={friend.id} {...friend} onNavigate={onClose} />
))
) : (
<div className={styles.sideNavEmpty}>
<div data-testid="side-nav-empty" className={styles.sideNavEmpty}>
{user
? t("front:sideNav.friends.noFriends")
: t("front:sideNav.friends.notLoggedIn")}
@@ -573,7 +575,12 @@ function YouPanel({
onClick={onClose}
>
<Avatar user={user} size="sm" />
<span className={styles.youPanelUsername}>{user.username}</span>
<span
data-testid="you-panel-username"
className={styles.youPanelUsername}
>
{user.username}
</span>
</Link>
<Link
to={SETTINGS_PAGE}
@@ -619,7 +626,7 @@ function ChatPanel({
skipAnimation && styles.noAnimation,
)}
>
<Dialog className={styles.panelDialog}>
<Dialog data-testid="mobile-nav-panel" className={styles.panelDialog}>
<ChatSidebar onClose={onClose} />
<GhostTabBar onTabPress={onTabPress} isLoggedIn={isLoggedIn} />
</Dialog>
@@ -652,6 +659,7 @@ function GhostTabBar({
<button
key={tab}
type="button"
data-testid="ghost-tab"
className={styles.ghostTab}
tabIndex={-1}
onClick={() => onTabPress(tab)}

View File

@@ -25,6 +25,7 @@ export function SideNav({
}) {
return (
<nav
data-testid="side-nav"
className={clsx(styles.sideNav, className, {
[styles.sideNavCollapsed]: collapsed,
})}
@@ -54,7 +55,7 @@ export function SideNavHeader({
action?: React.ReactNode;
}) {
return (
<header className={styles.sideNavHeader}>
<header data-testid="side-nav-header" className={styles.sideNavHeader}>
{icon ? <div className={styles.iconContainer}>{icon}</div> : null}
<h2>{children}</h2>
{action ? (
@@ -115,6 +116,7 @@ function ListItemContent({
<div className={styles.listLinkSubtitleRow}>
{subtitle ? (
<span
data-testid="list-item-subtitle"
className={styles.listLinkSubtitle}
suppressHydrationWarning={suppressSubtitleHydrationWarning}
>
@@ -123,6 +125,7 @@ function ListItemContent({
) : null}
{typeof badge === "string" ? (
<span
data-testid="list-item-badge"
className={clsx(styles.listLinkBadge, {
[styles.listLinkBadgeWarning]: badgeVariant === "warning",
})}
@@ -167,6 +170,7 @@ export function ListLink({
return (
<Link
to={to}
data-testid="list-link"
className={styles.listLink}
onClick={onClick}
aria-current={isActive ? "page" : undefined}
@@ -202,7 +206,7 @@ export function ListButton({
badgeVariant?: "default" | "warning";
}) {
return (
<Button className={styles.listButton}>
<Button data-testid="list-button" className={styles.listButton}>
<ListItemContent
user={user}
subtitle={subtitle}

View File

@@ -78,7 +78,10 @@ export function StreamListItems({
return (
<React.Fragment key={stream.id}>
{showUpcomingDivider ? (
<div className={styles.upcomingDivider}>
<div
data-testid="upcoming-divider"
className={styles.upcomingDivider}
>
{t("front:sideNav.streams.upcoming")}
</div>
) : null}
@@ -169,7 +172,12 @@ function SaveTournamentStreamButton({
/>
<input type="hidden" name="revalidateRoot" value="true" />
<button type="submit" className={styles.saveIconButton} title="Save">
<Icon size={14} />
<Icon
size={14}
data-testid={
optimisticSaved ? "stream-saved-icon" : "stream-save-icon"
}
/>
</button>
</fetcher.Form>
);

View File

@@ -106,7 +106,7 @@ function DevMenu() {
isOpen={isOpen}
onOpenChange={setIsOpen}
>
<div className={styles.menuContent}>
<div data-testid="menu-content" className={styles.menuContent}>
{DEV_IMPERSONATE_ITEMS.map((item) => (
<Form
key={item.name}
@@ -226,7 +226,7 @@ function CategoryMenu({
isOpen={isOpen}
onOpenChange={setIsOpen}
>
<div className={styles.menuContent}>
<div data-testid="menu-content" className={styles.menuContent}>
{visibleItems.map((item) => (
<Link
key={item.url}

View File

@@ -337,7 +337,9 @@ export function Layout({
</ListLink>
))
) : (
<div className={styles.sideNavEmpty}>{t("front:sideNav.noEvents")}</div>
<div data-testid="side-nav-empty" className={styles.sideNavEmpty}>
{t("front:sideNav.noEvents")}
</div>
)}
<SideNavHeader
@@ -371,7 +373,7 @@ export function Layout({
.slice(0, MAX_DESKTOP_FRIENDS)
.map((friend) => <FriendMenu key={friend.id} {...friend} />)
) : (
<div className={styles.sideNavEmpty}>
<div data-testid="side-nav-empty" className={styles.sideNavEmpty}>
{user
? t("front:sideNav.friends.noFriends")
: t("front:sideNav.friends.notLoggedIn")}
@@ -380,7 +382,7 @@ export function Layout({
<SideNavHeader icon={<Tv />}>{t("front:sideNav.streams")}</SideNavHeader>
{streams.length === 0 ? (
<div className={styles.sideNavEmpty}>
<div data-testid="side-nav-empty" className={styles.sideNavEmpty}>
{t("front:sideNav.noStreams")}
</div>
) : null}

View File

@@ -0,0 +1,66 @@
const RealDate = Date;
/**
* Parses the `SEED_NOW` env var (epoch milliseconds or an ISO 8601 date) into
* epoch milliseconds, or returns null when it is not set.
*/
function parseSeedNow(): number | null {
const raw = process.env.SEED_NOW;
if (!raw) return null;
const parsed = /^\d+$/.test(raw) ? Number(raw) : RealDate.parse(raw);
if (Number.isNaN(parsed)) {
throw new Error(
`SEED_NOW must be epoch milliseconds or an ISO 8601 date, got "${raw}"`,
);
}
return parsed;
}
/**
* Freezes the process-global clock at `SEED_NOW` so that everything the seed
* touches — factories, `databaseTimestampNow`, date-fns arithmetic — observes
* the same instant, making seeded timestamps a pure function of `SEED_NOW`.
* No-op when `SEED_NOW` is not set. Timers are left real; only `Date`'s idea
* of "now" is pinned.
*
* Meant for dedicated seed processes (`scripts/seed.ts`, the differ's seeding
* step), never for a serving app process.
*/
export function installSeedClock() {
const seedNow = parseSeedNow();
if (seedNow === null) return;
const fixedNow: number = seedNow;
type DateArgs =
| []
| [value: number | string | Date]
| [
year: number,
monthIndex: number,
day?: number,
hours?: number,
minutes?: number,
seconds?: number,
ms?: number,
];
class FrozenDate extends RealDate {
constructor(...args: DateArgs) {
if (args.length === 0) {
super(fixedNow);
} else if (args.length === 1) {
super(args[0]);
} else {
super(...(args as [number, number]));
}
}
static now() {
return fixedNow;
}
}
globalThis.Date = FrozenDate as DateConstructor;
}

View File

@@ -139,7 +139,7 @@ function SegmentBar({
subWeaponId={params.subWeaponId}
specialWeaponId={params.specialWeaponId}
/>
<span className={styles.damageValue}>
<span data-testid="damage-value" className={styles.damageValue}>
{segment.damageValue}
{segment.count > 1 ? (
<span className={styles.hitMultiplier}>×{segment.count}</span>
@@ -148,6 +148,7 @@ function SegmentBar({
</div>
<button
type="button"
data-testid="damage-type-label"
className={styles.damageTypeLabel}
onClick={handleFilterClick}
>
@@ -179,7 +180,9 @@ function InkTimeSegment({
<Image path={abilityImageUrl("RES")} alt="" size={18} />
<span className={styles.inkTimeDamage}>{inkDamage.toFixed(1)}</span>
</div>
<div className={styles.inkTimeLabel}>{inkTimeFrames}f</div>
<div data-testid="ink-time-label" className={styles.inkTimeLabel}>
{inkTimeFrames}f
</div>
</div>
);
}
@@ -243,6 +246,7 @@ function FilteredItem({ filterKey, onRestore }: FilteredItemProps) {
return (
<button
type="button"
data-testid="filtered-item"
className={styles.filteredItem}
onClick={() => onRestore(filterKey)}
>
@@ -331,7 +335,7 @@ export function DamageComboList({ weaponIds }: DamageComboListProps) {
<span className={styles.collapseIcon}>{isCollapsed ? "+" : "-"}</span>
</button>
{isCollapsed ? null : (
<div className={styles.content}>
<div data-testid="damage-combo-content" className={styles.content}>
<div className={styles.slidersContainer}>
<Image path={abilityImageUrl("SRU")} alt="" size={24} />
<label className={styles.resSliderLabel}>

View File

@@ -40,7 +40,11 @@ export function ParamComparisonDialog({
>
<div className={styles.bars}>
{sortedEntries.map((entry) => (
<div key={entry.weaponId} className={styles.row}>
<div
key={entry.weaponId}
data-testid="comparison-bar-row"
className={styles.row}
>
<div className={styles.weapon}>
<WeaponParamImage kind={kind} id={entry.weaponId} size={28} />
<span className={styles.name}>{entry.name}</span>

View File

@@ -186,7 +186,11 @@ export function WeaponParamsTable({
const slug = naming.slug(weaponId);
return (
<th key={weaponId} className={clsx(styles.weaponHeader, {})}>
<th
key={weaponId}
data-testid="weapon-header"
className={clsx(styles.weaponHeader, {})}
>
<Link
to={weaponParamsPage(slug)}
className={styles.weaponHeaderContent}
@@ -247,11 +251,13 @@ export function WeaponParamsTable({
return (
<tr
key={fullKey}
data-testid={hasHistory ? "expandable-row" : undefined}
className={clsx({
[styles.expandableRow]: hasHistory,
})}
>
<td
data-testid="param-name"
className={styles.paramName}
onClick={
hasHistory ? () => toggleRow(fullKey) : undefined
@@ -434,9 +440,11 @@ function DamageRateInfoSection({
return (
<tr
key={fullKey}
data-testid={hasHistory ? "expandable-row" : undefined}
className={clsx({ [styles.expandableRow]: hasHistory })}
>
<td
data-testid="param-name"
className={styles.paramName}
onClick={hasHistory ? () => onToggle(fullKey) : undefined}
>
@@ -541,8 +549,12 @@ function SpecialPointsRow({
);
return (
<tr className={clsx({ [styles.expandableRow]: hasHistory })}>
<tr
data-testid={hasHistory ? "expandable-row" : undefined}
className={clsx({ [styles.expandableRow]: hasHistory })}
>
<td
data-testid="param-name"
className={styles.paramName}
onClick={hasHistory ? onToggle : undefined}
>
@@ -605,7 +617,9 @@ function SpecialPointCell({
))}
</div>
{kitsWithHistory.length > 0 && !isExpanded ? (
<span className={styles.historyBadge}>{kitsWithHistory.length}</span>
<span data-testid="history-badge" className={styles.historyBadge}>
{kitsWithHistory.length}
</span>
) : null}
</div>
{showHistory ? (
@@ -659,7 +673,9 @@ function ParamCell({
{WeaponParams.formatValue(param.current)}
</span>
{param.history.length > 0 && !isExpanded ? (
<span className={styles.historyBadge}>{param.history.length}</span>
<span data-testid="history-badge" className={styles.historyBadge}>
{param.history.length}
</span>
) : null}
</div>
{showHistory ? (

View File

@@ -54,7 +54,11 @@ export function WeaponPatchHistory({ patches }: { patches: WeaponPatch[] }) {
return (
<div className={styles.container}>
{patches.map((patch) => (
<div key={patch.version} className={styles.column}>
<div
key={patch.version}
data-testid="patch-column"
className={styles.column}
>
<PatchColumnHeader version={patch.version} date={patch.date} />
<div className={styles.changes}>
{patch.changes.map((change, i) => (
@@ -185,7 +189,7 @@ function KitPatchColumn({
);
return (
<div className={styles.column}>
<div data-testid="patch-column" className={styles.column}>
<PatchColumnHeader version={patch.version} date={patch.date} />
<div className={styles.changes}>
{mainChanges.map((change, i) => (

View File

@@ -1,6 +1,7 @@
import { type ChildProcess, execSync, spawn } from "node:child_process";
import fs from "node:fs";
import type { FullConfig } from "@playwright/test";
import { ensureE2eBuild } from "../scripts/ensure-e2e-build";
import { ensureMigratedDb } from "../scripts/ensure-test-db";
import {
E2E_BASE_PORT,
@@ -14,15 +15,6 @@ const MINIO_MARKER_FILE = ".e2e-minio-started";
const STORAGE_BUCKET = "sendou";
/** Anonymously listable only once the bucket exists and its public policy is set. */
const MINIO_BUCKET_URL = `http://127.0.0.1:9000/${STORAGE_BUCKET}/`;
const BUILD_MARKER_FILE = ".e2e-build-marker";
const BUILD_INPUTS = [
"app",
"public",
"package.json",
"../../pnpm-lock.yaml",
"vite.config.ts",
"react-router.config.ts",
];
declare global {
var __E2E_SERVERS__: ChildProcess[];
@@ -116,40 +108,6 @@ async function waitForServer(port: number, timeout = 120000): Promise<void> {
throw new Error(`Server on port ${port} did not start within ${timeout}ms`);
}
/**
* The last e2e build can be reused when no build input changed since its marker
* was written. Directory mtimes catch deletes and renames; a build made outside
* the e2e flow (no marker, or output newer than the marker) forces a rebuild, as
* does a marker from a different port setup. E2E_FORCE_BUILD=true overrides.
*/
function isBuildFresh(): boolean {
if (process.env.E2E_FORCE_BUILD === "true") return false;
if (!fs.existsSync(BUILD_MARKER_FILE)) return false;
if (!fs.existsSync("build/server/index.js")) return false;
try {
const marker = JSON.parse(fs.readFileSync(BUILD_MARKER_FILE, "utf8"));
if (marker.siteDomain !== `http://localhost:${E2E_BASE_PORT}`) return false;
if (marker.skalopWsUrl !== "") return false;
} catch {
return false;
}
const markerMtime = fs.statSync(BUILD_MARKER_FILE).mtimeMs;
if (fs.statSync("build/server/index.js").mtimeMs > markerMtime) return false;
try {
const changedInput = execSync(
`find ${BUILD_INPUTS.join(" ")} -newer ${BUILD_MARKER_FILE} -print -quit`,
)
.toString()
.trim();
return changedInput === "";
} catch {
return false;
}
}
async function globalSetup(config: FullConfig) {
const workerCount = config.workers;
@@ -161,37 +119,13 @@ async function globalSetup(config: FullConfig) {
// Build the app once with E2E test flag so VITE_E2E_TEST_RUN is embedded
// Use port 6173 as the base - tests will rewrite URLs as needed
if (isBuildFresh()) {
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log(
"Reusing existing build (no source changes since last e2e build)",
);
} else {
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log("Building the application...");
fs.rmSync(BUILD_MARKER_FILE, { force: true });
execSync("pnpm run build", {
stdio: "inherit",
env: {
...process.env,
VITE_E2E_TEST_RUN: "true",
VITE_SITE_DOMAIN: `http://localhost:${E2E_BASE_PORT}`,
// Skalop is disconnected in e2e: all workers sharing one instance
// cross-talk (identical seeded row ids -> colliding room names ->
// spurious revalidations). When e2e tests for chat etc. are added
// this needs an actual solution: one skalop (or stub) per worker
// with a runtime-derived ws URL, since this build is shared.
VITE_SKALOP_WS_URL: "",
},
});
fs.writeFileSync(
BUILD_MARKER_FILE,
JSON.stringify({
siteDomain: `http://localhost:${E2E_BASE_PORT}`,
skalopWsUrl: "",
}),
);
}
const { reused } = ensureE2eBuild(`http://localhost:${E2E_BASE_PORT}`);
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log(
reused
? "Reusing existing build (no source changes since last e2e build)"
: "Built the application",
);
// Prepare databases and start servers for each worker
const serverPromises: Promise<void>[] = [];

View File

@@ -9,17 +9,17 @@ export class WeaponParamsPage {
constructor(page: Page) {
this.page = page;
this.locators = {
weaponHeaders: page.locator("th[class*='weaponHeader']"),
weaponHeaders: page.getByTestId("weapon-header"),
hideWeaponButtons: page.locator("[data-testid^='hide-weapon-']"),
showAllWeaponsButton: page.getByTestId("show-all-weapons"),
compareParamButtons: page.getByTestId("compare-param"),
comparisonDialog: page.getByRole("dialog"),
comparisonBars: page
.getByRole("dialog")
.locator("[class*='bars'] [class*='row']"),
expandableRows: page.locator("[class*='expandableRow']"),
.getByTestId("comparison-bar-row"),
expandableRows: page.getByTestId("expandable-row"),
patchHistoryTab: page.getByRole("tab", { name: /Patch history/ }),
patchColumns: page.locator("[class*='column']"),
patchColumns: page.getByTestId("patch-column"),
subAndSpecialChangesSwitch: page.getByRole("switch", {
name: /Show sub & special changes/,
}),
@@ -68,10 +68,10 @@ class ParamHistoryRow {
/** Shown while the row is collapsed, hidden once expanded */
get historyBadge() {
return this.root.locator("[class*='historyBadge']").first();
return this.root.getByTestId("history-badge").first();
}
async toggle() {
await this.root.locator("td[class*='paramName']").click();
await this.root.getByTestId("param-name").click();
}
}

View File

@@ -61,23 +61,23 @@ class DamageComboList {
}
get content() {
return this.root.locator(".content, [class*='content']");
return this.root.getByTestId("damage-combo-content");
}
get damageValues() {
return this.root.locator("[class*='damageValue']");
return this.root.getByTestId("damage-value");
}
get inkTimes() {
return this.root.locator("[class*='inkTime']");
return this.root.getByTestId("ink-time-label");
}
get damageTypeLabels() {
return this.root.locator("[class*='damageTypeLabel']");
return this.root.getByTestId("damage-type-label");
}
get filteredItems() {
return this.root.locator("button[class*='filteredItem']");
return this.root.getByTestId("filtered-item");
}
get subDefenseSlider() {

View File

@@ -26,13 +26,15 @@ export class MobileNav {
constructor(page: Page) {
this.page = page;
this.openPanelDialog = page.locator("[class*='panelDialog']:visible");
this.openPanelDialog = page.locator(
'[data-testid="mobile-nav-panel"]:visible',
);
this.locators = {
menuPanel: page.getByLabel("Menu", { exact: true }),
streamsHeading: page.locator("h3").filter({ hasText: "Streams" }),
viewAllLink: page.getByRole("link", { name: "View all", exact: true }),
youPanelUsername: page.locator("[class*='youPanelUsername']"),
friendItems: this.openPanelDialog.locator("button[class*='listButton']"),
youPanelUsername: page.getByTestId("you-panel-username"),
friendItems: this.openPanelDialog.getByTestId("list-button"),
};
}
@@ -50,13 +52,13 @@ export class MobileNav {
*/
async switchPanel(panel: Panel) {
await this.page
.locator("[class*='ghostTab']:not([class*='ghostTabBar'])")
.getByTestId("ghost-tab")
.nth(PANELS.indexOf(panel))
.dispatchEvent("click");
}
async closePanel() {
await this.page.locator("button:has(svg.lucide-x)").first().click();
await this.page.getByTestId("panel-close-button").first().click();
}
menuLink(name: string) {

View File

@@ -17,17 +17,17 @@ export class SideNav {
constructor(page: Page) {
this.page = page;
this.root = page.locator("nav[class*='sideNav']:visible");
this.root = page.locator('[data-testid="side-nav"]:visible');
this.locators = {
collapseButton: page.getByTestId("sidenav-collapse-button"),
modalTrigger: page.getByTestId("sidenav-modal-trigger"),
unseenRequestsBadge: page.getByRole("status", {
name: /unseen friend request/,
}),
upcomingDivider: this.root.locator("[class*='upcomingDivider']"),
upcomingDivider: this.root.getByTestId("upcoming-divider"),
/** Every row of the sidebar's link lists, in render order: events, then streams. */
listItems: this.root.locator("a[class*='listLink']"),
friendItems: this.root.locator("button[class*='listButton']"),
listItems: this.root.getByTestId("list-link"),
friendItems: this.root.getByTestId("list-button"),
};
}
@@ -41,14 +41,15 @@ export class SideNav {
}
viewAllLink(section: "Events" | "Friends") {
return this.sectionHeading(section)
.locator("xpath=..")
return this.root
.getByTestId("side-nav-header")
.filter({ has: this.page.getByRole("heading", { name: section }) })
.getByRole("link", { name: /View all/ });
}
/** Text shown in place of a section's rows when it has none. */
emptyText(text: string) {
return this.page.locator("[class*='sideNavEmpty']:visible", {
return this.page.locator('[data-testid="side-nav-empty"]:visible', {
hasText: text,
});
}
@@ -62,11 +63,11 @@ export class SideNav {
}
itemSubtitle(name: string) {
return this.eventItem(name).locator("span[class*='listLinkSubtitle']");
return this.eventItem(name).getByTestId("list-item-subtitle");
}
itemBadge(name: string) {
return this.eventItem(name).locator("span[class*='listLinkBadge']");
return this.eventItem(name).getByTestId("list-item-badge");
}
/** The tier an upcoming tournament's stream row is pilled with. */
@@ -79,7 +80,7 @@ export class SideNav {
}
savedStreamIcon(streamName: string) {
return this.streamItem(streamName).locator("svg.lucide-bookmark-check");
return this.streamItem(streamName).getByTestId("stream-saved-icon");
}
async saveStream(streamName: string) {
@@ -117,8 +118,8 @@ export class FriendRow {
this.page = page;
this.trigger = root.getByRole("button", { name });
this.locators = {
subtitle: this.trigger.locator("span[class*='listLinkSubtitle']"),
badge: this.trigger.locator("span[class*='listLinkBadge']"),
subtitle: this.trigger.getByTestId("list-item-subtitle"),
badge: this.trigger.getByTestId("list-item-badge"),
};
}

View File

@@ -12,7 +12,7 @@ export class TopNavMenus {
this.locators = {
// a closed menu renders the same links as an icon-only preview, so
// anything asserted about an open menu has to be scoped to it
openMenu: page.locator("[class*='menuContent']"),
openMenu: page.getByTestId("menu-content"),
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const BUILD_MARKER_FILE = ".e2e-build-marker";
const BUILD_INPUTS = [
"app",
"public",
"package.json",
"../../pnpm-lock.yaml",
"vite.config.ts",
"react-router.config.ts",
];
/**
* Ensures `build/` holds a production build made with the e2e flag baked in
* (`VITE_E2E_TEST_RUN=true`), reusing the previous build when no build input
* changed since its marker was written. Shared by the e2e global setup and the
* differ, which both serve this build. `siteDomain` is baked into the client
* bundle; runtime servers on other ports override it via their environment.
*
* The last e2e build can be reused when no build input changed since its
* marker was written. Directory mtimes catch deletes and renames; a build made
* outside the e2e flow (no marker, or output newer than the marker) forces a
* rebuild, as does a marker from a different port setup. E2E_FORCE_BUILD=true
* overrides.
*/
export function ensureE2eBuild(siteDomain: string): { reused: boolean } {
if (isBuildFresh(siteDomain)) {
return { reused: true };
}
fs.rmSync(path.join(ROOT_DIR, BUILD_MARKER_FILE), { force: true });
execSync("pnpm run build", {
stdio: "inherit",
cwd: ROOT_DIR,
env: {
...process.env,
VITE_E2E_TEST_RUN: "true",
VITE_SITE_DOMAIN: siteDomain,
// Skalop is disconnected in e2e: all workers sharing one instance
// cross-talk (identical seeded row ids -> colliding room names ->
// spurious revalidations). When e2e tests for chat etc. are added
// this needs an actual solution: one skalop (or stub) per worker
// with a runtime-derived ws URL, since this build is shared.
VITE_SKALOP_WS_URL: "",
},
});
fs.writeFileSync(
path.join(ROOT_DIR, BUILD_MARKER_FILE),
JSON.stringify({ siteDomain, skalopWsUrl: "" }),
);
return { reused: false };
}
function isBuildFresh(siteDomain: string): boolean {
if (process.env.E2E_FORCE_BUILD === "true") return false;
const markerPath = path.join(ROOT_DIR, BUILD_MARKER_FILE);
const serverEntryPath = path.join(ROOT_DIR, "build/server/index.js");
if (!fs.existsSync(markerPath)) return false;
if (!fs.existsSync(serverEntryPath)) return false;
try {
const marker = JSON.parse(fs.readFileSync(markerPath, "utf8"));
if (marker.siteDomain !== siteDomain) return false;
if (marker.skalopWsUrl !== "") return false;
} catch {
return false;
}
const markerMtime = fs.statSync(markerPath).mtimeMs;
if (fs.statSync(serverEntryPath).mtimeMs > markerMtime) return false;
try {
const changedInput = execSync(
`find ${BUILD_INPUTS.join(" ")} -newer ${BUILD_MARKER_FILE} -print -quit`,
{ cwd: ROOT_DIR },
)
.toString()
.trim();
return changedInput === "";
} catch {
return false;
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const siteDomain = process.env.E2E_BUILD_SITE_DOMAIN;
if (!siteDomain) {
throw new Error("E2E_BUILD_SITE_DOMAIN must be set");
}
const { reused } = ensureE2eBuild(siteDomain);
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log(reused ? "Reusing existing e2e build" : "Built the application");
}

View File

@@ -0,0 +1,503 @@
/**
* Generates the differ's route census: every URL pattern the app serves,
* expanded with representative params drawn from the seeded database at
* `DB_PATH`. Never hand-listed — the pattern list comes from walking
* `app/routes.ts`, and a dynamic segment without a resolver below is a hard
* error so new routes cannot silently drop out of differential coverage.
*
* Usage: DB_PATH=<seeded db> vite-node scripts/route-census.ts [--out census.json]
*
* A resolver that finds no seed data marks its rows `skipped` (with the reason
* in the census) instead of failing: that is a seed-coverage gap to close, not
* a census bug, and the differ report keeps it visible.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
// routes.ts branches on NODE_ENV at module scope; the census must describe the
// production build the differ serves, never the dev-only routes
process.env.NODE_ENV = "production";
// the production config demands real credentials at import time, but this
// script only ever reads the database; the e2e flag makes config fall back to
// its development defaults, matching the build the differ serves
process.env.VITE_E2E_TEST_RUN = "true";
if (!process.env.DB_PATH) {
throw new Error("DB_PATH must point to a seeded database");
}
const APP_DIR = fileURLToPath(new URL("..", import.meta.url));
type RouteEntry = {
file: string;
path?: string;
index?: boolean;
children?: RouteEntry[];
};
export interface CensusRow {
pattern: string;
url: string | null;
file: string;
kind: "page" | "resource";
skipped?: string;
}
const routes = (await import("../app/routes")).default as RouteEntry[];
const { db } = await import("~/db/sql");
const { ADMIN_DISCORD_ID } = await import("~/features/admin/admin-constants");
const { weaponNameSlugToId } = await import("~/utils/unslugify.server");
const { databaseTimestampToDate, weekNumberToDateRange } = await import(
"~/utils/dates"
);
const rows = await buildCensus();
const outFlagIndex = process.argv.indexOf("--out");
const outPath = outFlagIndex === -1 ? null : process.argv[outFlagIndex + 1];
const adminUser = await db
.selectFrom("User")
.select(["id"])
.where("discordId", "=", ADMIN_DISCORD_ID)
.executeTakeFirstOrThrow();
const census = {
seedNow: process.env.SEED_NOW ?? null,
adminUserId: adminUser.id,
rows,
};
if (outPath) {
fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
fs.writeFileSync(path.resolve(outPath), JSON.stringify(census, null, "\t"));
const skipped = rows.filter((row) => row.skipped);
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log(
`Census: ${rows.length} rows (${skipped.length} skipped) -> ${outPath}`,
);
for (const row of skipped) {
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log(` skipped ${row.pattern}: ${row.skipped}`);
}
} else {
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log(JSON.stringify(census, null, "\t"));
}
async function buildCensus(): Promise<CensusRow[]> {
const patterns = new Map<string, { file: string }>();
const walk = (entries: RouteEntry[], parentPath: string) => {
for (const entry of entries) {
const fullPath = entry.index
? parentPath
: joinPaths(parentPath, entry.path);
if (entry.children) {
walk(entry.children, fullPath);
}
const rendersUrl = entry.index || entry.path !== undefined;
if (rendersUrl && !patterns.has(fullPath)) {
patterns.set(fullPath, { file: entry.file });
}
}
};
walk(routes, "/");
const resolve = createParamResolver();
const result: CensusRow[] = [];
for (const [pattern, { file }] of patterns) {
const kind: CensusRow["kind"] = file.endsWith(".tsx") ? "page" : "resource";
for (const variant of expandOptionalParams(pattern)) {
const paramNames = [...variant.matchAll(/:([A-Za-z]+)/g)].map(
(match) => match[1],
);
if (paramNames.length === 0) {
result.push({ pattern: variant, url: variant, file, kind });
continue;
}
const params = await resolve(variant);
if (params === null) {
result.push({
pattern: variant,
url: null,
file,
kind,
skipped: "no seed data for this pattern's params",
});
continue;
}
let url = variant;
for (const name of paramNames) {
const value = params[name];
if (value === undefined || value === null) {
throw new Error(
`Resolver for ${variant} did not provide a value for :${name}`,
);
}
url = url.replace(`:${name}`, encodeURIComponent(String(value)));
}
result.push({ pattern: variant, url, file, kind });
}
}
return result.sort((a, b) => a.pattern.localeCompare(b.pattern));
}
function joinPaths(parent: string, child?: string) {
if (child === undefined) return parent;
if (child.startsWith("/")) return child.replace(/\/+$/, "") || "/";
return `${parent === "/" ? "" : parent}/${child}`;
}
/** `registration/:tid?` expands into rows both with and without the segment. */
function expandOptionalParams(pattern: string): string[] {
const optional = pattern.match(/\/:([A-Za-z]+)\?/);
if (!optional) return [pattern];
const without = pattern.replace(optional[0], "");
const withParam = pattern.replace(optional[0], `/:${optional[1]}`);
return [
...expandOptionalParams(without || "/"),
...expandOptionalParams(withParam),
];
}
type ParamValues = Record<string, string | number> | null;
/**
* Maps a URL pattern to representative param values from the seeded database.
* Rules are matched by pattern prefix so related routes share one lookup;
* a pattern with params that no rule covers is a hard error.
*/
function createParamResolver() {
const cache = new Map<string, Promise<ParamValues>>();
const cached = (key: string, load: () => Promise<ParamValues>) => {
let value = cache.get(key);
if (!value) {
value = load();
cache.set(key, value);
}
return value;
};
const adminUser = () =>
cached("adminUser", async () => {
const user = await db
.selectFrom("User")
.select(["id", "discordId", "customUrl"])
.where("discordId", "=", ADMIN_DISCORD_ID)
.executeTakeFirst();
if (!user) return null;
return {
id: user.id,
userId: user.id,
identifier: user.customUrl ?? user.discordId,
customUrl: user.customUrl ?? user.discordId,
};
});
const tournamentContext = () =>
cached("tournament", async () => {
const match = await db
.selectFrom("TournamentMatch")
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.select(["TournamentMatch.id as mid", "TournamentStage.tournamentId"])
.where("TournamentMatch.startedAt", "is not", null)
.orderBy("TournamentMatch.id", "asc")
.executeTakeFirst();
if (!match) return null;
const team = await db
.selectFrom("TournamentTeam")
.select(["id"])
.where("tournamentId", "=", match.tournamentId)
.orderBy("id", "asc")
.executeTakeFirst();
if (!team) return null;
return {
id: match.tournamentId,
mid: match.mid,
tid: team.id,
teamId: team.id,
bidx: 0,
};
});
const weaponSlug = () =>
cached("weaponSlug", async () => {
const slug = "splattershot";
if (weaponNameSlugToId(slug) === null) {
throw new Error(`"${slug}" no longer resolves to a weapon id`);
}
return { slug };
});
const calendarWeek = () =>
cached("calendarWeek", async () => {
const eventDate = await db
.selectFrom("CalendarEventDate")
.select(["startsAt"])
.orderBy("startsAt", "asc")
.executeTakeFirst();
if (!eventDate) return null;
const date = databaseTimestampToDate(eventDate.startsAt);
for (const year of [date.getFullYear() - 1, date.getFullYear()]) {
for (let week = 1; week <= 53; week++) {
const { startTime, endTime } = weekNumberToDateRange({ year, week });
if (date >= startTime && date <= endTime) {
return { year, week };
}
}
}
return null;
});
const rules: Array<{
matches: (pattern: string) => boolean;
params: () => Promise<ParamValues>;
}> = [
{
matches: (pattern) =>
pattern.startsWith("/u/:identifier") ||
pattern.startsWith("/api/user/:identifier") ||
pattern.startsWith("/api/user/:userId") ||
pattern.startsWith("/api/sendouq/active-match/:userId") ||
pattern.startsWith("/user-card/:id") ||
pattern === "/user-report/:id",
params: adminUser,
},
{
matches: (pattern) => pattern === "/short/:customUrl",
params: () =>
cached("shortUrlUser", async () => {
const user = await db
.selectFrom("User")
.select(["customUrl"])
.where("customUrl", "is not", null)
.orderBy("id", "asc")
.executeTakeFirst();
return user?.customUrl ? { customUrl: user.customUrl } : null;
}),
},
{
matches: (pattern) => pattern.startsWith("/badges/:id"),
params: () =>
cached("badge", async () => {
const badge = await db
.selectFrom("Badge")
.select(["id"])
.orderBy("id", "asc")
.executeTakeFirst();
return badge ? { id: badge.id } : null;
}),
},
{
matches: (pattern) => pattern.startsWith("/trophies/:id"),
params: () =>
cached("trophy", async () => {
const owner = await db
.selectFrom("TrophyOwner")
.select(["trophyId", "userId"])
.orderBy("trophyId", "asc")
.executeTakeFirst();
if (owner) return { id: owner.trophyId, userId: owner.userId };
const trophy = await db
.selectFrom("Trophy")
.select(["id"])
.orderBy("id", "asc")
.executeTakeFirst();
const admin = await adminUser();
if (!trophy || !admin) return null;
return { id: trophy.id, userId: admin.id };
}),
},
{
matches: (pattern) => pattern.startsWith("/calendar/:id"),
params: () =>
cached("calendarEvent", async () => {
const event = await db
.selectFrom("CalendarEvent")
.select(["id"])
.where("tournamentId", "is", null)
.orderBy("id", "asc")
.executeTakeFirst();
return event ? { id: event.id } : null;
}),
},
{
matches: (pattern) => pattern === "/api/calendar/:year/:week",
params: calendarWeek,
},
{
matches: (pattern) =>
pattern.startsWith("/to/:id") ||
pattern.startsWith("/api/tournament/:id"),
params: tournamentContext,
},
{
matches: (pattern) => pattern === "/api/tournament-match/:id",
params: () =>
cached("tournamentMatch", async () => {
const context = await tournamentContext();
return context ? { id: context.mid } : null;
}),
},
{
matches: (pattern) => pattern.startsWith("/org/:slug"),
params: () =>
cached("org", async () => {
const org = await db
.selectFrom("TournamentOrganization")
.select(["slug"])
.orderBy("id", "asc")
.executeTakeFirst();
return org ? { slug: org.slug } : null;
}),
},
{
matches: (pattern) => pattern === "/api/org/:id",
params: () =>
cached("orgId", async () => {
const org = await db
.selectFrom("TournamentOrganization")
.select(["id"])
.orderBy("id", "asc")
.executeTakeFirst();
return org ? { id: org.id } : null;
}),
},
{
matches: (pattern) => pattern.startsWith("/t/:customUrl"),
params: () =>
cached("team", async () => {
const team = await db
.selectFrom("Team")
.select(["customUrl"])
.orderBy("id", "asc")
.executeTakeFirst();
return team ? { customUrl: team.customUrl } : null;
}),
},
{
matches: (pattern) => pattern === "/api/team/:id",
params: () =>
cached("teamId", async () => {
const team = await db
.selectFrom("Team")
.select(["id"])
.orderBy("id", "asc")
.executeTakeFirst();
return team ? { id: team.id } : null;
}),
},
{
matches: (pattern) => pattern === "/vods/:id",
params: () =>
cached("vod", async () => {
const vod = await db
.selectFrom("UnvalidatedVideo")
.select(["id"])
.orderBy("id", "asc")
.executeTakeFirst();
return vod ? { id: vod.id } : null;
}),
},
{
matches: (pattern) =>
pattern.startsWith("/builds/:slug") || pattern === "/params/:slug",
params: weaponSlug,
},
{
matches: (pattern) => pattern === "/xsearch/player/:id",
params: () =>
cached("xrankPlayer", async () => {
const placement = await db
.selectFrom("XRankPlacement")
.select(["playerId"])
.orderBy("id", "asc")
.executeTakeFirst();
return placement ? { id: placement.playerId } : null;
}),
},
{
matches: (pattern) =>
pattern === "/q/match/:id" || pattern === "/api/sendouq/match/:matchId",
params: () =>
cached("groupMatch", async () => {
const match = await db
.selectFrom("GroupMatch")
.select(["id"])
.orderBy("id", "asc")
.executeTakeFirst();
return match ? { id: match.id, matchId: match.id } : null;
}),
},
{
matches: (pattern) => pattern === "/scrims/:id",
params: () =>
cached("scrimPost", async () => {
const post = await db
.selectFrom("ScrimPost")
.select(["id"])
.orderBy("id", "asc")
.executeTakeFirst();
return post ? { id: post.id } : null;
}),
},
{
matches: (pattern) => pattern === "/a/:slug",
params: () =>
cached("article", async () => {
const articlesDir = path.join(APP_DIR, "content", "articles");
const slug = fs
.readdirSync(articlesDir)
.filter((file) => file.endsWith(".md"))
.sort()[0]
?.replace(/\.md$/, "");
return slug ? { slug } : null;
}),
},
{
matches: (pattern) =>
pattern === "/plus/suggestions/comment/:tier/:userId",
params: () =>
cached("plusSuggestion", async () => {
const suggestion = await db
.selectFrom("PlusSuggestion")
.select(["tier", "suggestedId"])
.orderBy("id", "asc")
.executeTakeFirst();
return suggestion
? { tier: suggestion.tier, userId: suggestion.suggestedId }
: null;
}),
},
];
return async (pattern: string): Promise<ParamValues> => {
const rule = rules.find((candidate) => candidate.matches(pattern));
if (!rule) {
throw new Error(
`No param resolver covers "${pattern}" — add one to scripts/route-census.ts`,
);
}
return await rule.params();
};
}

View File

@@ -1,8 +1,14 @@
import { seed } from "~/db/seed";
import { installSeedClock } from "~/db/seed/core/frozen-clock";
const startedAt = Date.now();
// installed before the seed modules load so even module-scope dates are frozen
installSeedClock();
const startedAt = performance.now();
const { seed } = await import("~/db/seed");
await seed();
// biome-ignore lint/suspicious/noConsole: CLI script output
console.log(`Seeded in ${((Date.now() - startedAt) / 1000).toFixed(1)}s`);
console.log(
`Seeded in ${((performance.now() - startedAt) / 1000).toFixed(1)}s`,
);