mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 07:37:55 -05:00
Differ
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
66
apps/web-react/app/db/seed/core/frozen-clock.ts
Normal file
66
apps/web-react/app/db/seed/core/frozen-clock.ts
Normal 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;
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>[] = [];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
1008
apps/web-react/output/smoke1/census.json
Normal file
1008
apps/web-react/output/smoke1/census.json
Normal file
File diff suppressed because it is too large
Load Diff
100
apps/web-react/scripts/ensure-e2e-build.ts
Normal file
100
apps/web-react/scripts/ensure-e2e-build.ts
Normal 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");
|
||||
}
|
||||
503
apps/web-react/scripts/route-census.ts
Normal file
503
apps/web-react/scripts/route-census.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user