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

1
.gitignore vendored
View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -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
View File

@@ -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

View File

@@ -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/`)

View 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
View 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);
}

View 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}]` : ""}`);
}

View 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);
}

View 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
}
}

View 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;
};
}