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

View File

@@ -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"),
};
}