mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 15:17:43 -05:00
Differ
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,6 +18,7 @@ dump
|
||||
|
||||
apps/web-react/scripts/output/*
|
||||
!apps/web-react/scripts/output/.gitkeep
|
||||
tooling/differ/output/
|
||||
|
||||
apps/web-react/scripts/dicts/**/*
|
||||
apps/web-react/scripts/badge
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"i18n:sync": "pnpm --filter web-react run i18n:sync",
|
||||
"knip": "pnpm --filter web-react run knip",
|
||||
"checks": "pnpm --filter web-react run checks",
|
||||
"manifest:report": "pnpm --filter @sendou/codemods run manifest:report"
|
||||
"manifest:report": "pnpm --filter @sendou/codemods run manifest:report",
|
||||
"differ": "pnpm --filter @sendou/differ run diff"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.7"
|
||||
|
||||
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
@@ -392,6 +392,25 @@ importers:
|
||||
specifier: 7.0.2
|
||||
version: 7.0.2
|
||||
|
||||
tooling/differ:
|
||||
dependencies:
|
||||
'@playwright/test':
|
||||
specifier: 1.62.1
|
||||
version: 1.62.1
|
||||
pixelmatch:
|
||||
specifier: 7.1.0
|
||||
version: 7.1.0
|
||||
pngjs:
|
||||
specifier: 7.0.0
|
||||
version: 7.0.0
|
||||
devDependencies:
|
||||
'@types/pngjs':
|
||||
specifier: 6.0.5
|
||||
version: 6.0.5
|
||||
typescript:
|
||||
specifier: 7.0.2
|
||||
version: 7.0.2
|
||||
|
||||
packages:
|
||||
|
||||
'@aws-sdk/checksums@3.1000.27':
|
||||
@@ -2792,6 +2811,9 @@ packages:
|
||||
'@types/nprogress@0.2.3':
|
||||
resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==}
|
||||
|
||||
'@types/pngjs@6.0.5':
|
||||
resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==}
|
||||
|
||||
'@types/ramda@0.31.1':
|
||||
resolution: {integrity: sha512-Vt6sFXnuRpzaEj+yeutA0q3bcAsK7wdPuASIzR9LXqL4gJPyFw8im9qchlbp4ltuf3kDEIRmPJTD/Fkg60dn7g==}
|
||||
|
||||
@@ -3870,6 +3892,10 @@ packages:
|
||||
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pixelmatch@7.1.0:
|
||||
resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==}
|
||||
hasBin: true
|
||||
|
||||
pkg-types@2.3.1:
|
||||
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
|
||||
|
||||
@@ -7131,6 +7157,10 @@ snapshots:
|
||||
|
||||
'@types/nprogress@0.2.3': {}
|
||||
|
||||
'@types/pngjs@6.0.5':
|
||||
dependencies:
|
||||
'@types/node': 26.1.2
|
||||
|
||||
'@types/ramda@0.31.1':
|
||||
dependencies:
|
||||
types-ramda: 0.31.0
|
||||
@@ -8115,6 +8145,10 @@ snapshots:
|
||||
|
||||
picomatch@4.0.5: {}
|
||||
|
||||
pixelmatch@7.1.0:
|
||||
dependencies:
|
||||
pngjs: 7.0.0
|
||||
|
||||
pkg-types@2.3.1:
|
||||
dependencies:
|
||||
confbox: 0.2.4
|
||||
|
||||
@@ -4,13 +4,76 @@ Differential test harness for the React → Svelte migration (built in Phase 1 o
|
||||
`svelte-big-bang.md`). Runs `apps/web-react` (the oracle) and `apps/web` against
|
||||
the same seeded database with a frozen clock and diffs them directly:
|
||||
|
||||
- **Pixel diff** (gate) — screenshot every route in a generated census
|
||||
- **Pixel diff** (gate) — screenshot every route in the generated census
|
||||
(route × theme × viewport), strict threshold.
|
||||
- **SSR HTML diff** (gate) — normalized `<head>` comparison per route to protect
|
||||
SEO through the cutover.
|
||||
- **Resource diff** (gate) — non-HTML routes (api-public, resource routes,
|
||||
redirects) compared on status, content type, location and body.
|
||||
- **ARIA structure diff** (advisory) — Playwright ARIA snapshots as a report to
|
||||
eyeball, not a 1:1 gate.
|
||||
|
||||
The route census is generated from seed data (never hand-listed) and verified
|
||||
first React-vs-React: the harness must report zero diffs against itself before
|
||||
it judges the Svelte app.
|
||||
Until `apps/web` exists both sides serve `apps/web-react`: the React-vs-React
|
||||
run must report zero diffs before the harness judges anything (Phase 1 exit
|
||||
gate).
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
pnpm run differ # full census, both themes and viewports
|
||||
pnpm run differ -- --filter /leaderboards
|
||||
pnpm run differ -- --max-rows 20 --themes light --viewports desktop
|
||||
pnpm run differ -- --seed-now 2026-08-16T12:00:00Z --concurrency 2
|
||||
pnpm run differ -- --skip-prepare # reuse the previous build + seeded dbs
|
||||
```
|
||||
|
||||
Heads-up: a run holds two production servers plus a Chromium instance — a
|
||||
couple of GB of memory. Run it from a plain terminal, not nested under other
|
||||
memory-heavy tooling, and leave `--concurrency` at its default (1) on smaller
|
||||
machines.
|
||||
|
||||
The run writes `output/<timestamp>/`:
|
||||
|
||||
- `report.json` — every row's results plus a summary; exit code 1 when a gate
|
||||
(pixel / head / resource) failed
|
||||
- `census.json` — the generated route census
|
||||
- `artifacts/<route>/<variant>/` — `left.png` / `right.png` / `diff.png`,
|
||||
normalized head HTML, ARIA snapshots — written only for rows that differ
|
||||
|
||||
## How a run works
|
||||
|
||||
1. `scripts/ensure-e2e-build.ts` — the same production build the e2e suite
|
||||
serves (`VITE_E2E_TEST_RUN=true` baked in), reused when fresh.
|
||||
2. Migrate + seed `db-differ-seed.sqlite3` with the clock frozen at `SEED_NOW`
|
||||
(`scripts/seed.ts` installs `installSeedClock`), then copy it for each side.
|
||||
3. `scripts/route-census.ts` — walks `app/routes.ts` and resolves every dynamic
|
||||
param from the seeded database. Never hand-listed: a new route without a
|
||||
resolver is a hard error; a resolver finding no seed data marks the row
|
||||
`skipped` and the report keeps it visible (seed-coverage gap).
|
||||
4. Two servers start on ports 6873/6874 (heap-capped), one database copy each,
|
||||
both with the same baked `VITE_SITE_DOMAIN` so absolute URLs are identical.
|
||||
5. Per browser context (side × theme): log in as the seeded admin via
|
||||
`/auth/impersonate`, set the theme cookie via `/theme`, freeze the client
|
||||
clock at `SEED_NOW` (context-level Playwright clock), reseed `Math.random`
|
||||
per document, and abort every request leaving localhost (fonts and
|
||||
third-party images fail identically on both sides).
|
||||
6. Per page row: wait for hydration, kill animations, screenshot until two
|
||||
consecutive shots are identical (canvas charts settle), then compare.
|
||||
Pages taller than the capture cap are clipped — decoded RGBA of unbounded
|
||||
pages is what OOMs the process. Decoded-pixel comparison is serialized for
|
||||
the same reason; identical screenshots (the common case) skip decoding.
|
||||
|
||||
## The app contract
|
||||
|
||||
What a served app must provide for the differ (and the e2e suite) to work
|
||||
against it — the Svelte app has to implement the same surface:
|
||||
|
||||
- `data-testid="hydrated"` element once client JS is ready, carrying
|
||||
`data-router-idle="true"` when no navigation/mutation is in flight
|
||||
- `data-testid="error-page"` on the error boundary page
|
||||
- dev-controls endpoints under the e2e build flag: `POST /auth/impersonate?id=`,
|
||||
`POST /theme` (`theme=light|dark`), `POST /refresh-caches`,
|
||||
`POST /end-season`, `POST /run-routine`
|
||||
- the shared `data-testid` vocabulary used by `e2e/pages/**` (no CSS-module
|
||||
class or icon-class selectors — enforced by keeping `class*=`/`lucide-` out
|
||||
of `e2e/`)
|
||||
|
||||
18
tooling/differ/package.json
Normal file
18
tooling/differ/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@sendou/differ",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"diff": "node src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@playwright/test": "1.62.1",
|
||||
"pixelmatch": "7.1.0",
|
||||
"pngjs": "7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pngjs": "6.0.5",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
133
tooling/differ/src/cli.ts
Normal file
133
tooling/differ/src/cli.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
import { runDiff } from "./differ.ts";
|
||||
import { ensureMinio, LEFT_DB, prepare, RIGHT_DB } from "./prepare.ts";
|
||||
import { startServers, stopServers } from "./servers.ts";
|
||||
import type { Report, RunConfig, Viewport } from "./types.ts";
|
||||
|
||||
const DIFFER_DIR = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
);
|
||||
const REPO_ROOT = path.join(DIFFER_DIR, "..", "..");
|
||||
|
||||
const VIEWPORTS: Record<string, Viewport> = {
|
||||
desktop: { name: "desktop", width: 1440, height: 900 },
|
||||
mobile: { name: "mobile", width: 375, height: 667 },
|
||||
};
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
filter: { type: "string" },
|
||||
"max-rows": { type: "string" },
|
||||
themes: { type: "string", default: "light,dark" },
|
||||
viewports: { type: "string", default: "desktop,mobile" },
|
||||
concurrency: { type: "string", default: "1" },
|
||||
"seed-now": { type: "string" },
|
||||
"skip-prepare": { type: "boolean", default: false },
|
||||
out: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
const seedNow = values["seed-now"]
|
||||
? new Date(values["seed-now"]).getTime()
|
||||
: Date.now();
|
||||
if (Number.isNaN(seedNow)) {
|
||||
throw new Error(`--seed-now is not a valid date: ${values["seed-now"]}`);
|
||||
}
|
||||
|
||||
const outDir = values.out
|
||||
? path.resolve(values.out)
|
||||
: path.join(
|
||||
DIFFER_DIR,
|
||||
"output",
|
||||
new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19),
|
||||
);
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const webReactDir = path.join(REPO_ROOT, "apps", "web-react");
|
||||
|
||||
// same derivation as the e2e helpers' E2E_BASE_PORT, so the build is shared;
|
||||
// only PORT is read from .env — the rest must not leak into the differ servers
|
||||
const envPort = (() => {
|
||||
try {
|
||||
const envFile = fs.readFileSync(path.join(webReactDir, ".env"), "utf8");
|
||||
return envFile.match(/^PORT=(\d+)/m)?.[1];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
const e2eBasePort = Number(process.env.PORT || envPort || 5173) + 500;
|
||||
|
||||
const config: RunConfig = {
|
||||
repoRoot: REPO_ROOT,
|
||||
webReactDir,
|
||||
bakedSiteDomain: `http://localhost:${e2eBasePort}`,
|
||||
outDir,
|
||||
seedNow,
|
||||
leftPort: 6873,
|
||||
rightPort: 6874,
|
||||
themes: values.themes.split(","),
|
||||
viewports: values.viewports.split(",").map((name) => {
|
||||
const viewport = VIEWPORTS[name];
|
||||
if (!viewport) {
|
||||
throw new Error(
|
||||
`Unknown viewport "${name}" (known: ${Object.keys(VIEWPORTS).join(", ")})`,
|
||||
);
|
||||
}
|
||||
return viewport;
|
||||
}),
|
||||
concurrency: Number(values.concurrency),
|
||||
filter: values.filter ?? null,
|
||||
maxRows: values["max-rows"] ? Number(values["max-rows"]) : null,
|
||||
skipPrepare: values["skip-prepare"],
|
||||
};
|
||||
|
||||
const census = prepare(config);
|
||||
await ensureMinio();
|
||||
|
||||
const servers = await startServers(config, { left: LEFT_DB, right: RIGHT_DB });
|
||||
process.on("exit", () => stopServers(servers));
|
||||
process.on("SIGINT", () => {
|
||||
stopServers(servers);
|
||||
process.exit(130);
|
||||
});
|
||||
|
||||
log(`Diffing ${servers[0].baseURL} (left) vs ${servers[1].baseURL} (right)`);
|
||||
|
||||
let report: Report;
|
||||
try {
|
||||
report = await runDiff(config, census, servers);
|
||||
} finally {
|
||||
stopServers(servers);
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, "report.json"),
|
||||
JSON.stringify(report, null, "\t"),
|
||||
);
|
||||
|
||||
const { summary } = report;
|
||||
log("");
|
||||
log(`Report: ${path.join(outDir, "report.json")}`);
|
||||
log(
|
||||
`Rows: ${report.rows.length} diffed, ${report.skippedRows.length} skipped by census`,
|
||||
);
|
||||
log(`Pixel failures: ${summary.pixelFailures} (gate)`);
|
||||
log(`Head failures: ${summary.htmlFailures} (gate)`);
|
||||
log(`Resource failures: ${summary.resourceFailures} (gate)`);
|
||||
log(`ARIA changes: ${summary.ariaChanges} (advisory)`);
|
||||
log(`Error pages: ${summary.errorPages} (seed coverage, advisory)`);
|
||||
|
||||
const gateFailed =
|
||||
summary.pixelFailures + summary.htmlFailures + summary.resourceFailures > 0;
|
||||
log("");
|
||||
log(gateFailed ? "DIFFER: FAIL" : "DIFFER: PASS");
|
||||
process.exit(gateFailed ? 1 : 0);
|
||||
|
||||
function log(message: string) {
|
||||
// biome-ignore lint/suspicious/noConsole: CLI script output
|
||||
console.log(message);
|
||||
}
|
||||
628
tooling/differ/src/differ.ts
Normal file
628
tooling/differ/src/differ.ts
Normal file
@@ -0,0 +1,628 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
chromium,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
import pixelmatch from "pixelmatch";
|
||||
import { PNG } from "pngjs";
|
||||
import type { AppServer } from "./servers.ts";
|
||||
import type {
|
||||
AriaResult,
|
||||
Census,
|
||||
CensusRow,
|
||||
HtmlResult,
|
||||
PixelResult,
|
||||
Report,
|
||||
ResourceResult,
|
||||
RowResult,
|
||||
RunConfig,
|
||||
Viewport,
|
||||
} from "./types.ts";
|
||||
|
||||
const PIXELMATCH_THRESHOLD = 0.1;
|
||||
const SETTLE_INTERVAL_MS = 250;
|
||||
const SETTLE_MAX_ATTEMPTS = 5;
|
||||
const HYDRATION_TIMEOUT_MS = 15_000;
|
||||
/** Very long pages are captured only down to this height: decoded RGBA of a
|
||||
* full leaderboard page runs to hundreds of MB and gets the process killed. */
|
||||
const MAX_CAPTURE_HEIGHT = 12_000;
|
||||
|
||||
/** Belt and suspenders on top of Playwright's `animations: "disabled"`: kills
|
||||
* transitions and smooth scrolling for everything that renders after the
|
||||
* screenshot call chain starts observing the page. */
|
||||
const KILL_ANIMATIONS_CSS = `
|
||||
*, *::before, *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
scroll-behavior: auto !important;
|
||||
caret-color: transparent !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export async function runDiff(
|
||||
config: RunConfig,
|
||||
census: Census,
|
||||
servers: AppServer[],
|
||||
): Promise<Report> {
|
||||
const startedAt = new Date();
|
||||
const left = servers.find((server) => server.name === "left");
|
||||
const right = servers.find((server) => server.name === "right");
|
||||
if (!left || !right) throw new Error("Both servers must be running");
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const contexts = await createContexts(browser, config, census, [left, right]);
|
||||
|
||||
let rows = census.rows.filter((row) => !row.skipped);
|
||||
const skippedRows = census.rows.filter((row) => row.skipped);
|
||||
if (config.filter) {
|
||||
rows = rows.filter((row) => row.pattern.includes(config.filter as string));
|
||||
}
|
||||
if (config.maxRows !== null) {
|
||||
rows = rows.slice(0, config.maxRows);
|
||||
}
|
||||
|
||||
const results: RowResult[] = [];
|
||||
let nextIndex = 0;
|
||||
|
||||
const worker = async () => {
|
||||
while (nextIndex < rows.length) {
|
||||
const row = rows[nextIndex++];
|
||||
let result: RowResult;
|
||||
try {
|
||||
result = await diffRow(config, contexts, left, right, row);
|
||||
} catch (error) {
|
||||
result = {
|
||||
pattern: row.pattern,
|
||||
url: row.url as string,
|
||||
kind: row.kind,
|
||||
pixel: [
|
||||
{
|
||||
theme: "-",
|
||||
viewport: "-",
|
||||
status: "fail",
|
||||
note: `capture crashed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
html: null,
|
||||
aria: [],
|
||||
resource: null,
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
results.push(result);
|
||||
logRowResult(result);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: config.concurrency }, () => worker()));
|
||||
|
||||
await browser.close();
|
||||
|
||||
results.sort((a, b) => a.pattern.localeCompare(b.pattern));
|
||||
|
||||
return {
|
||||
startedAt: startedAt.toISOString(),
|
||||
durationMs: Date.now() - startedAt.getTime(),
|
||||
seedNow: new Date(config.seedNow).toISOString(),
|
||||
left: left.baseURL,
|
||||
right: right.baseURL,
|
||||
rows: results,
|
||||
skippedRows,
|
||||
summary: {
|
||||
pixelFailures: results
|
||||
.flatMap((row) => row.pixel)
|
||||
.filter((pixel) => pixel.status === "fail").length,
|
||||
htmlFailures: results.filter((row) => row.html?.status === "fail").length,
|
||||
resourceFailures: results.filter((row) => row.resource?.status === "fail")
|
||||
.length,
|
||||
ariaChanges: results
|
||||
.flatMap((row) => row.aria)
|
||||
.filter((aria) => aria.status === "changed").length,
|
||||
errorPages: results.filter((row) =>
|
||||
row.notes.some((note) => note.includes("error page")),
|
||||
).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type ContextKey = `${"left" | "right"}:${string}`;
|
||||
type Contexts = Map<ContextKey, BrowserContext>;
|
||||
|
||||
/**
|
||||
* One context per side x theme, logged in as the seeded admin with the theme
|
||||
* cookie set. `Math.random` is reseeded per document so both sides draw the
|
||||
* same sequence, and requests leaving localhost are aborted for determinism
|
||||
* (fonts fall back identically, third-party avatars 404 identically).
|
||||
*/
|
||||
async function createContexts(
|
||||
browser: Browser,
|
||||
config: RunConfig,
|
||||
census: Census,
|
||||
servers: AppServer[],
|
||||
): Promise<Contexts> {
|
||||
const contexts: Contexts = new Map();
|
||||
|
||||
for (const server of servers) {
|
||||
for (const theme of config.themes) {
|
||||
const context = await browser.newContext({ baseURL: server.baseURL });
|
||||
|
||||
// per context, not per page: concurrent pages installing the clock race
|
||||
// inside Playwright ("Cannot read properties of undefined")
|
||||
await context.clock.setFixedTime(config.seedNow);
|
||||
|
||||
await context.route(
|
||||
(url) => url.hostname !== "localhost" && url.hostname !== "127.0.0.1",
|
||||
(route) => route.abort(),
|
||||
);
|
||||
|
||||
await context.addInitScript(() => {
|
||||
let state = 42;
|
||||
Math.random = () => {
|
||||
state = (state * 1664525 + 1013904223) % 4294967296;
|
||||
return state / 4294967296;
|
||||
};
|
||||
});
|
||||
|
||||
const impersonate = await context.request.post(
|
||||
`/auth/impersonate?id=${census.adminUserId}`,
|
||||
);
|
||||
if (!impersonate.ok()) {
|
||||
throw new Error(
|
||||
`Impersonation on ${server.baseURL} failed with status ${impersonate.status()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const themeResponse = await context.request.post("/theme", {
|
||||
form: { theme },
|
||||
});
|
||||
if (!themeResponse.ok()) {
|
||||
throw new Error(
|
||||
`Setting theme on ${server.baseURL} failed with status ${themeResponse.status()}`,
|
||||
);
|
||||
}
|
||||
|
||||
contexts.set(`${server.name}:${theme}`, context);
|
||||
}
|
||||
}
|
||||
|
||||
return contexts;
|
||||
}
|
||||
|
||||
async function diffRow(
|
||||
config: RunConfig,
|
||||
contexts: Contexts,
|
||||
left: AppServer,
|
||||
right: AppServer,
|
||||
row: CensusRow,
|
||||
): Promise<RowResult> {
|
||||
const result: RowResult = {
|
||||
pattern: row.pattern,
|
||||
url: row.url as string,
|
||||
kind: row.kind,
|
||||
pixel: [],
|
||||
html: null,
|
||||
aria: [],
|
||||
resource: null,
|
||||
notes: [],
|
||||
};
|
||||
|
||||
const leftContext = contexts.get(
|
||||
`left:${config.themes[0]}`,
|
||||
) as BrowserContext;
|
||||
const rightContext = contexts.get(
|
||||
`right:${config.themes[0]}`,
|
||||
) as BrowserContext;
|
||||
|
||||
const [leftResponse, rightResponse] = await Promise.all([
|
||||
fetchRaw(leftContext, left.baseURL, result.url),
|
||||
fetchRaw(rightContext, right.baseURL, result.url),
|
||||
]);
|
||||
|
||||
const isHtmlPage =
|
||||
leftResponse.status === 200 &&
|
||||
leftResponse.contentType.includes("text/html") &&
|
||||
rightResponse.status === 200 &&
|
||||
rightResponse.contentType.includes("text/html");
|
||||
|
||||
if (!isHtmlPage) {
|
||||
result.resource = compareResources(
|
||||
config,
|
||||
row,
|
||||
[left.baseURL, right.baseURL],
|
||||
leftResponse,
|
||||
rightResponse,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.html = compareHeads(
|
||||
config,
|
||||
row,
|
||||
[left.baseURL, right.baseURL],
|
||||
leftResponse.body,
|
||||
rightResponse.body,
|
||||
);
|
||||
|
||||
for (const theme of config.themes) {
|
||||
for (const viewport of config.viewports) {
|
||||
const captureAria = theme === config.themes[0];
|
||||
const [leftCapture, rightCapture] = await Promise.all([
|
||||
capturePage(
|
||||
contexts.get(`left:${theme}`) as BrowserContext,
|
||||
result.url,
|
||||
viewport,
|
||||
captureAria,
|
||||
),
|
||||
capturePage(
|
||||
contexts.get(`right:${theme}`) as BrowserContext,
|
||||
result.url,
|
||||
viewport,
|
||||
captureAria,
|
||||
),
|
||||
]);
|
||||
|
||||
for (const [side, capture] of [
|
||||
["left", leftCapture],
|
||||
["right", rightCapture],
|
||||
] as const) {
|
||||
for (const note of capture.notes) {
|
||||
const fullNote = `${side} ${theme}/${viewport.name}: ${note}`;
|
||||
if (!result.notes.includes(fullNote)) result.notes.push(fullNote);
|
||||
}
|
||||
}
|
||||
|
||||
result.pixel.push(
|
||||
await comparePixels(
|
||||
config,
|
||||
row,
|
||||
theme,
|
||||
viewport,
|
||||
leftCapture,
|
||||
rightCapture,
|
||||
),
|
||||
);
|
||||
|
||||
if (captureAria) {
|
||||
result.aria.push(
|
||||
compareAria(config, row, viewport, leftCapture, rightCapture),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
interface RawResponse {
|
||||
status: number;
|
||||
contentType: string;
|
||||
location: string | null;
|
||||
body: string;
|
||||
}
|
||||
|
||||
async function fetchRaw(
|
||||
context: BrowserContext,
|
||||
baseURL: string,
|
||||
url: string,
|
||||
): Promise<RawResponse> {
|
||||
const response = await context.request.get(`${baseURL}${url}`, {
|
||||
maxRedirects: 0,
|
||||
});
|
||||
return {
|
||||
status: response.status(),
|
||||
contentType: response.headers()["content-type"] ?? "",
|
||||
location: response.headers().location ?? null,
|
||||
body: await response.text().catch(() => ""),
|
||||
};
|
||||
}
|
||||
|
||||
interface PageCapture {
|
||||
screenshot: Buffer;
|
||||
aria: string | null;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
async function capturePage(
|
||||
context: BrowserContext,
|
||||
url: string,
|
||||
viewport: Viewport,
|
||||
captureAria: boolean,
|
||||
): Promise<PageCapture> {
|
||||
const page = await context.newPage();
|
||||
const notes: string[] = [];
|
||||
|
||||
try {
|
||||
await page.setViewportSize({
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto(url, { waitUntil: "load", timeout: 30_000 });
|
||||
|
||||
const hydrated = page.getByTestId("hydrated");
|
||||
await hydrated
|
||||
.waitFor({ state: "attached", timeout: HYDRATION_TIMEOUT_MS })
|
||||
.catch(() => {
|
||||
notes.push("page never reported hydration");
|
||||
});
|
||||
|
||||
if ((await page.getByTestId("error-page").count()) > 0) {
|
||||
notes.push("rendered the error page (seed data gap?)");
|
||||
}
|
||||
|
||||
await page.addStyleTag({ content: KILL_ANIMATIONS_CSS });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
|
||||
const screenshot = await settleScreenshot(page, notes);
|
||||
const aria = captureAria ? await page.locator("body").ariaSnapshot() : null;
|
||||
|
||||
return { screenshot, aria, notes };
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Screenshots until two consecutive shots are identical, so anything still
|
||||
* animating (canvas charts, image decode) has settled before comparison. */
|
||||
async function settleScreenshot(page: Page, notes: string[]): Promise<Buffer> {
|
||||
let previous = await takeScreenshot(page);
|
||||
for (let attempt = 0; attempt < SETTLE_MAX_ATTEMPTS; attempt++) {
|
||||
await page.waitForTimeout(SETTLE_INTERVAL_MS);
|
||||
const current = await takeScreenshot(page);
|
||||
if (current.equals(previous)) return current;
|
||||
previous = current;
|
||||
}
|
||||
notes.push("screenshot never settled (still animating after retries)");
|
||||
return previous;
|
||||
}
|
||||
|
||||
async function takeScreenshot(page: Page): Promise<Buffer> {
|
||||
const pageHeight = await page.evaluate(
|
||||
() => document.documentElement.scrollHeight,
|
||||
);
|
||||
if (pageHeight <= MAX_CAPTURE_HEIGHT) {
|
||||
return page.screenshot({
|
||||
fullPage: true,
|
||||
animations: "disabled",
|
||||
caret: "hide",
|
||||
});
|
||||
}
|
||||
|
||||
const viewport = page.viewportSize();
|
||||
return page.screenshot({
|
||||
clip: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: viewport?.width ?? 1440,
|
||||
height: MAX_CAPTURE_HEIGHT,
|
||||
},
|
||||
fullPage: true,
|
||||
animations: "disabled",
|
||||
caret: "hide",
|
||||
});
|
||||
}
|
||||
|
||||
/** Serializes the decoded-pixel comparisons: decoded RGBA buffers are ~50MB+
|
||||
* each for tall pages, and letting every worker decode at once is what OOMs
|
||||
* the process. Identical buffers (the common case) never take the lock. */
|
||||
let pixelCompareLock: Promise<void> = Promise.resolve();
|
||||
|
||||
async function comparePixels(
|
||||
config: RunConfig,
|
||||
row: CensusRow,
|
||||
theme: string,
|
||||
viewport: Viewport,
|
||||
leftCapture: PageCapture,
|
||||
rightCapture: PageCapture,
|
||||
): Promise<PixelResult> {
|
||||
const base: PixelResult = { theme, viewport: viewport.name, status: "pass" };
|
||||
|
||||
if (leftCapture.screenshot.equals(rightCapture.screenshot)) {
|
||||
return { ...base, diffPixels: 0 };
|
||||
}
|
||||
|
||||
const previousLock = pixelCompareLock;
|
||||
let release = () => {};
|
||||
pixelCompareLock = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await previousLock;
|
||||
|
||||
try {
|
||||
const left = PNG.sync.read(leftCapture.screenshot);
|
||||
const right = PNG.sync.read(rightCapture.screenshot);
|
||||
|
||||
const variant = `${theme}-${viewport.name}`;
|
||||
if (left.width !== right.width || left.height !== right.height) {
|
||||
const artifacts = writeArtifacts(config, row, variant, {
|
||||
"left.png": leftCapture.screenshot,
|
||||
"right.png": rightCapture.screenshot,
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
status: "fail",
|
||||
note: `size mismatch: ${left.width}x${left.height} vs ${right.width}x${right.height}`,
|
||||
artifacts,
|
||||
};
|
||||
}
|
||||
|
||||
const diff = new PNG({ width: left.width, height: left.height });
|
||||
const diffPixels = pixelmatch(
|
||||
left.data,
|
||||
right.data,
|
||||
diff.data,
|
||||
left.width,
|
||||
left.height,
|
||||
{ threshold: PIXELMATCH_THRESHOLD },
|
||||
);
|
||||
|
||||
if (diffPixels === 0) return { ...base, diffPixels };
|
||||
|
||||
const artifacts = writeArtifacts(config, row, variant, {
|
||||
"left.png": leftCapture.screenshot,
|
||||
"right.png": rightCapture.screenshot,
|
||||
"diff.png": PNG.sync.write(diff),
|
||||
});
|
||||
return { ...base, status: "fail", diffPixels, artifacts };
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
function compareHeads(
|
||||
config: RunConfig,
|
||||
row: CensusRow,
|
||||
origins: string[],
|
||||
leftHtml: string,
|
||||
rightHtml: string,
|
||||
): HtmlResult {
|
||||
const leftHead = normalizeHead(extractHead(leftHtml), origins);
|
||||
const rightHead = normalizeHead(extractHead(rightHtml), origins);
|
||||
|
||||
if (leftHead === rightHead) return { status: "pass" };
|
||||
|
||||
const artifacts = writeArtifacts(config, row, "head", {
|
||||
"left-head.html": Buffer.from(leftHead),
|
||||
"right-head.html": Buffer.from(rightHead),
|
||||
});
|
||||
return { status: "fail", note: "SSR <head> differs", artifacts };
|
||||
}
|
||||
|
||||
function compareAria(
|
||||
config: RunConfig,
|
||||
row: CensusRow,
|
||||
viewport: Viewport,
|
||||
leftCapture: PageCapture,
|
||||
rightCapture: PageCapture,
|
||||
): AriaResult {
|
||||
const base: AriaResult = { viewport: viewport.name, status: "same" };
|
||||
if (leftCapture.aria === null || rightCapture.aria === null) {
|
||||
return { ...base, status: "skipped", note: "no snapshot captured" };
|
||||
}
|
||||
if (leftCapture.aria === rightCapture.aria) return base;
|
||||
|
||||
const artifacts = writeArtifacts(config, row, `aria-${viewport.name}`, {
|
||||
"left.aria.yml": Buffer.from(leftCapture.aria),
|
||||
"right.aria.yml": Buffer.from(rightCapture.aria),
|
||||
});
|
||||
return { ...base, status: "changed", artifacts };
|
||||
}
|
||||
|
||||
function compareResources(
|
||||
config: RunConfig,
|
||||
row: CensusRow,
|
||||
origins: string[],
|
||||
left: RawResponse,
|
||||
right: RawResponse,
|
||||
): ResourceResult {
|
||||
const problems: string[] = [];
|
||||
|
||||
if (left.status !== right.status) {
|
||||
problems.push(`status ${left.status} vs ${right.status}`);
|
||||
}
|
||||
if (left.contentType !== right.contentType) {
|
||||
problems.push(
|
||||
`content-type "${left.contentType}" vs "${right.contentType}"`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
normalizeOrigins(left.location ?? "", origins) !==
|
||||
normalizeOrigins(right.location ?? "", origins)
|
||||
) {
|
||||
problems.push(`location "${left.location}" vs "${right.location}"`);
|
||||
}
|
||||
|
||||
const leftBody = normalizeOrigins(left.body, origins);
|
||||
const rightBody = normalizeOrigins(right.body, origins);
|
||||
if (leftBody !== rightBody) {
|
||||
problems.push("body differs");
|
||||
}
|
||||
|
||||
if (problems.length === 0) {
|
||||
return {
|
||||
status: "pass",
|
||||
leftStatus: left.status,
|
||||
rightStatus: right.status,
|
||||
};
|
||||
}
|
||||
|
||||
const artifacts = writeArtifacts(config, row, "resource", {
|
||||
"left.body.txt": Buffer.from(leftBody),
|
||||
"right.body.txt": Buffer.from(rightBody),
|
||||
});
|
||||
return {
|
||||
status: "fail",
|
||||
leftStatus: left.status,
|
||||
rightStatus: right.status,
|
||||
note: problems.join("; "),
|
||||
artifacts,
|
||||
};
|
||||
}
|
||||
|
||||
function extractHead(html: string): string {
|
||||
const match = html.match(/<head[^>]*>([\s\S]*?)<\/head>/);
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
|
||||
function normalizeHead(head: string, origins: string[]): string {
|
||||
return normalizeOrigins(head, origins)
|
||||
.replace(/>\s+</g, ">\n<")
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeOrigins(text: string, origins: string[]): string {
|
||||
let normalized = text;
|
||||
for (const origin of origins) {
|
||||
normalized = normalized.replaceAll(origin, "{{ORIGIN}}");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function writeArtifacts(
|
||||
config: RunConfig,
|
||||
row: CensusRow,
|
||||
variant: string,
|
||||
files: Record<string, Buffer>,
|
||||
): string[] {
|
||||
const slug =
|
||||
row.pattern === "/"
|
||||
? "index"
|
||||
: row.pattern.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
const dir = path.join(config.outDir, "artifacts", slug, variant);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const written: string[] = [];
|
||||
for (const [name, contents] of Object.entries(files)) {
|
||||
const filePath = path.join(dir, name);
|
||||
fs.writeFileSync(filePath, contents);
|
||||
written.push(path.relative(config.outDir, filePath));
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
function logRowResult(result: RowResult) {
|
||||
const pixelFails = result.pixel.filter((p) => p.status === "fail").length;
|
||||
const failed =
|
||||
pixelFails > 0 ||
|
||||
result.html?.status === "fail" ||
|
||||
result.resource?.status === "fail";
|
||||
const ariaChanged = result.aria.some((a) => a.status === "changed");
|
||||
|
||||
const marker = failed ? "✗" : "✓";
|
||||
const details = [
|
||||
pixelFails > 0 ? `${pixelFails} pixel` : null,
|
||||
result.html?.status === "fail" ? "head" : null,
|
||||
result.resource?.status === "fail"
|
||||
? `resource (${result.resource.note})`
|
||||
: null,
|
||||
ariaChanged ? "aria~" : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole: CLI script output
|
||||
console.log(`${marker} ${result.url}${details ? ` [${details}]` : ""}`);
|
||||
}
|
||||
122
tooling/differ/src/prepare.ts
Normal file
122
tooling/differ/src/prepare.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import type { Census, RunConfig } from "./types.ts";
|
||||
|
||||
export const SEED_DB = "db-differ-seed.sqlite3";
|
||||
export const LEFT_DB = "db-differ-left.sqlite3";
|
||||
export const RIGHT_DB = "db-differ-right.sqlite3";
|
||||
|
||||
const MINIO_BUCKET_URL = "http://127.0.0.1:9000/sendou/";
|
||||
|
||||
/**
|
||||
* Produces everything the diff run consumes: the shared e2e build, a database
|
||||
* seeded at `SEED_NOW`, one copy of it per server, and the route census
|
||||
* generated from that database.
|
||||
*/
|
||||
export function prepare(config: RunConfig): Census {
|
||||
if (!config.skipPrepare) {
|
||||
log("Ensuring e2e build...");
|
||||
run(config, "node", ["scripts/ensure-e2e-build.ts"], {
|
||||
E2E_BUILD_SITE_DOMAIN: config.bakedSiteDomain,
|
||||
});
|
||||
|
||||
log(`Seeding ${SEED_DB} at ${new Date(config.seedNow).toISOString()}...`);
|
||||
for (const dbFile of [SEED_DB, LEFT_DB, RIGHT_DB]) {
|
||||
deleteDbFiles(path.join(config.webReactDir, dbFile));
|
||||
}
|
||||
run(config, "node", ["scripts/migrate.ts", "up"], { DB_PATH: SEED_DB });
|
||||
run(config, "./node_modules/.bin/vite-node", ["./scripts/seed.ts"], {
|
||||
DB_PATH: SEED_DB,
|
||||
SEED_NOW: String(config.seedNow),
|
||||
});
|
||||
|
||||
// one checkpointed file copies cleanly; the servers re-enable WAL themselves
|
||||
const seedDb = new DatabaseSync(path.join(config.webReactDir, SEED_DB));
|
||||
seedDb.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
||||
seedDb.exec("PRAGMA journal_mode = DELETE");
|
||||
seedDb.close();
|
||||
|
||||
for (const dbFile of [LEFT_DB, RIGHT_DB]) {
|
||||
fs.copyFileSync(
|
||||
path.join(config.webReactDir, SEED_DB),
|
||||
path.join(config.webReactDir, dbFile),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log("Generating route census...");
|
||||
const censusPath = path.join(config.outDir, "census.json");
|
||||
run(
|
||||
config,
|
||||
"./node_modules/.bin/vite-node",
|
||||
["./scripts/route-census.ts", "--out", censusPath],
|
||||
{ DB_PATH: SEED_DB, SEED_NOW: String(config.seedNow) },
|
||||
);
|
||||
|
||||
return JSON.parse(fs.readFileSync(censusPath, "utf8")) as Census;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeded user-submitted images live in MinIO; without it those requests 404 on
|
||||
* both sides equally, so a missing Docker daemon degrades the run instead of
|
||||
* failing it.
|
||||
*/
|
||||
export async function ensureMinio(): Promise<boolean> {
|
||||
if (await isMinioReady()) return true;
|
||||
|
||||
try {
|
||||
execSync("docker compose up -d minio", { stdio: "pipe" });
|
||||
} catch {
|
||||
log("MinIO unavailable (is Docker running?) — image requests will 404");
|
||||
return false;
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (await isMinioReady()) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
log("MinIO never became ready — image requests will 404");
|
||||
return false;
|
||||
}
|
||||
|
||||
async function isMinioReady(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(MINIO_BUCKET_URL);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function run(
|
||||
config: RunConfig,
|
||||
command: string,
|
||||
args: string[],
|
||||
env: Record<string, string>,
|
||||
) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: config.webReactDir,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`${command} ${args.join(" ")} exited with status ${result.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteDbFiles(dbPath: string) {
|
||||
for (const suffix of ["", "-wal", "-shm"]) {
|
||||
fs.rmSync(`${dbPath}${suffix}`, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function log(message: string) {
|
||||
// biome-ignore lint/suspicious/noConsole: CLI script output
|
||||
console.log(message);
|
||||
}
|
||||
122
tooling/differ/src/servers.ts
Normal file
122
tooling/differ/src/servers.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { type ChildProcess, execSync, spawn } from "node:child_process";
|
||||
import type { RunConfig } from "./types.ts";
|
||||
|
||||
export interface AppServer {
|
||||
name: "left" | "right";
|
||||
baseURL: string;
|
||||
process: ChildProcess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts one production server per side against its own copy of the seeded
|
||||
* database. Both get the same `VITE_SITE_DOMAIN` (the baked one) so any
|
||||
* absolute URL they render is identical and diffs stay clean.
|
||||
*/
|
||||
export async function startServers(
|
||||
config: RunConfig,
|
||||
dbFiles: { left: string; right: string },
|
||||
): Promise<AppServer[]> {
|
||||
killPortListeners(config.leftPort, config.rightPort);
|
||||
|
||||
const servers = (
|
||||
[
|
||||
{ name: "left", port: config.leftPort, dbFile: dbFiles.left },
|
||||
{ name: "right", port: config.rightPort, dbFile: dbFiles.right },
|
||||
] as const
|
||||
).map(({ name, port, dbFile }): AppServer => {
|
||||
const serverProcess = spawn(
|
||||
process.execPath,
|
||||
["./node_modules/@react-router/serve/bin.cjs", "./build/server/index.js"],
|
||||
{
|
||||
cwd: config.webReactDir,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
// bounded heap so two servers plus the browser cannot push the
|
||||
// machine into memory-pressure kills
|
||||
NODE_OPTIONS: "--max-old-space-size=1024",
|
||||
DB_PATH: dbFile,
|
||||
PORT: String(port),
|
||||
DISCORD_CLIENT_ID: "123",
|
||||
DISCORD_CLIENT_SECRET: "secret",
|
||||
SESSION_SECRET: "secret",
|
||||
VITE_SITE_DOMAIN: config.bakedSiteDomain,
|
||||
VITE_E2E_TEST_RUN: "true",
|
||||
STORAGE_END_POINT: "http://127.0.0.1:9000",
|
||||
STORAGE_ACCESS_KEY: "minio-user",
|
||||
STORAGE_SECRET: "minio-password",
|
||||
STORAGE_REGION: "us-east-1",
|
||||
STORAGE_BUCKET: "sendou",
|
||||
SKALOP_SYSTEM_MESSAGE_URL: "",
|
||||
SKALOP_TOKEN: "",
|
||||
// real creds must not reach the differ servers: SyncLiveStreams
|
||||
// would hit the Twitch API and overwrite seeded streams
|
||||
TWITCH_CLIENT_ID: "",
|
||||
TWITCH_CLIENT_SECRET: "",
|
||||
SQ_CANCEL_DISCORD_WEBHOOK_URL: "",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (process.env.DIFFER_DEBUG === "true") {
|
||||
serverProcess.stdout?.on("data", (data: Buffer) => {
|
||||
// biome-ignore lint/suspicious/noConsole: CLI script output
|
||||
console.log(`[${name}] ${data.toString()}`);
|
||||
});
|
||||
serverProcess.stderr?.on("data", (data: Buffer) => {
|
||||
// biome-ignore lint/suspicious/noConsole: CLI script output
|
||||
console.error(`[${name}] ${data.toString()}`);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
baseURL: `http://localhost:${port}`,
|
||||
process: serverProcess,
|
||||
};
|
||||
});
|
||||
|
||||
await Promise.all(servers.map((server) => waitForServer(server.baseURL)));
|
||||
|
||||
return servers;
|
||||
}
|
||||
|
||||
export function stopServers(servers: AppServer[]) {
|
||||
for (const server of servers) {
|
||||
server.process.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForServer(baseURL: string, timeout = 60000) {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(baseURL);
|
||||
if (response.ok || response.status === 404) return;
|
||||
} catch {
|
||||
// not up yet
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Server at ${baseURL} did not start within ${timeout}ms`);
|
||||
}
|
||||
|
||||
function killPortListeners(...ports: number[]) {
|
||||
try {
|
||||
const pids = execSync(
|
||||
`lsof -ti ${ports.map((p) => `-i :${p}`).join(" ")} || true`,
|
||||
{
|
||||
stdio: "pipe",
|
||||
},
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
if (pids === "") return;
|
||||
|
||||
execSync(`kill -9 ${pids.split("\n").join(" ")} 2>/dev/null || true`, {
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
// nothing was listening
|
||||
}
|
||||
}
|
||||
95
tooling/differ/src/types.ts
Normal file
95
tooling/differ/src/types.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
export interface CensusRow {
|
||||
pattern: string;
|
||||
url: string | null;
|
||||
file: string;
|
||||
kind: "page" | "resource";
|
||||
skipped?: string;
|
||||
}
|
||||
|
||||
export interface Census {
|
||||
seedNow: string | null;
|
||||
adminUserId: number;
|
||||
rows: CensusRow[];
|
||||
}
|
||||
|
||||
export interface Viewport {
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface RunConfig {
|
||||
repoRoot: string;
|
||||
webReactDir: string;
|
||||
/** The site domain baked into the shared e2e build; must be derived the
|
||||
* same way as `E2E_BASE_PORT` so the e2e build gets reused. */
|
||||
bakedSiteDomain: string;
|
||||
outDir: string;
|
||||
seedNow: number;
|
||||
leftPort: number;
|
||||
rightPort: number;
|
||||
themes: string[];
|
||||
viewports: Viewport[];
|
||||
concurrency: number;
|
||||
filter: string | null;
|
||||
maxRows: number | null;
|
||||
skipPrepare: boolean;
|
||||
}
|
||||
|
||||
export interface PixelResult {
|
||||
theme: string;
|
||||
viewport: string;
|
||||
status: "pass" | "fail" | "skipped";
|
||||
diffPixels?: number;
|
||||
note?: string;
|
||||
artifacts?: string[];
|
||||
}
|
||||
|
||||
export interface HtmlResult {
|
||||
status: "pass" | "fail" | "skipped";
|
||||
note?: string;
|
||||
artifacts?: string[];
|
||||
}
|
||||
|
||||
export interface AriaResult {
|
||||
viewport: string;
|
||||
status: "same" | "changed" | "skipped";
|
||||
note?: string;
|
||||
artifacts?: string[];
|
||||
}
|
||||
|
||||
export interface ResourceResult {
|
||||
status: "pass" | "fail";
|
||||
leftStatus: number;
|
||||
rightStatus: number;
|
||||
note?: string;
|
||||
artifacts?: string[];
|
||||
}
|
||||
|
||||
export interface RowResult {
|
||||
pattern: string;
|
||||
url: string;
|
||||
kind: "page" | "resource";
|
||||
pixel: PixelResult[];
|
||||
html: HtmlResult | null;
|
||||
aria: AriaResult[];
|
||||
resource: ResourceResult | null;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
startedAt: string;
|
||||
durationMs: number;
|
||||
seedNow: string;
|
||||
left: string;
|
||||
right: string;
|
||||
rows: RowResult[];
|
||||
skippedRows: CensusRow[];
|
||||
summary: {
|
||||
pixelFailures: number;
|
||||
htmlFailures: number;
|
||||
resourceFailures: number;
|
||||
ariaChanges: number;
|
||||
errorPages: number;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user