sendou.ink/app/utils/playwright-form.ts
2026-01-18 18:21:19 +02:00

259 lines
7.9 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, type Page } from "@playwright/test";
import type { z } from "zod";
import { formRegistry } from "~/form/fields";
import type { FormField } from "~/form/types";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function loadTranslations(): Record<string, Record<string, string>> {
const localesPath = path.resolve(__dirname, "../../locales/en");
return {
forms: JSON.parse(
fs.readFileSync(path.join(localesPath, "forms.json"), "utf-8"),
),
common: JSON.parse(
fs.readFileSync(path.join(localesPath, "common.json"), "utf-8"),
),
};
}
const translations = loadTranslations();
function resolveTranslation(key: string): string {
// Handle keys like "common:forms.name" or "team:newTeam.header"
const [namespace, translationPath] = key.includes(":")
? key.split(":")
: ["common", key];
const nsTranslations = translations[namespace];
if (!nsTranslations) {
return key;
}
const value = nsTranslations[translationPath as keyof typeof nsTranslations];
return typeof value === "string" ? value : key;
}
type Inferred<T extends z.ZodRawShape> = z.infer<z.ZodObject<T>>;
type FillableKeys<T extends z.ZodRawShape> = {
[K in keyof Inferred<T>]-?: string extends Inferred<T>[K] ? K : never;
}[keyof Inferred<T>];
type CheckableKeys<T extends z.ZodRawShape> = {
[K in keyof Inferred<T>]-?: Inferred<T>[K] extends boolean ? K : never;
}[keyof Inferred<T>];
type SelectableKeys<T extends z.ZodRawShape> = {
[K in keyof Inferred<T>]-?: Inferred<T>[K] extends string | null | undefined
? K
: never;
}[keyof Inferred<T>];
type FormFieldHelpers<T extends z.ZodRawShape> = {
fill: (name: FillableKeys<T>, value: string) => Promise<void>;
check: (name: CheckableKeys<T>) => Promise<void>;
uncheck: (name: CheckableKeys<T>) => Promise<void>;
checkItems: (name: keyof Inferred<T>, itemValues: string[]) => Promise<void>;
select: (name: SelectableKeys<T>, optionText: string) => Promise<void>;
selectUser: (name: keyof Inferred<T>, userName: string) => Promise<void>;
selectWeapons: (
name: keyof Inferred<T>,
weaponNames: string[],
) => Promise<void>;
setDateTime: (name: keyof Inferred<T>, date: Date) => Promise<void>;
setDate: (name: keyof Inferred<T>, date: Date) => Promise<void>;
submit: () => Promise<void>;
getLabel: <K extends keyof Inferred<T>>(name: K) => string;
getItemLabel: (name: keyof Inferred<T>, itemValue: string) => string;
};
export function createFormHelpers<T extends z.ZodRawShape>(
page: Page,
schema: z.ZodObject<T>,
options?: { submitTestId?: string },
): FormFieldHelpers<T> {
const submitTestId = options?.submitTestId ?? "submit-button";
const getFieldMetadata = (name: string): FormField | undefined => {
const fieldSchema = schema.shape[name];
if (!fieldSchema) return undefined;
return formRegistry.get(fieldSchema) as FormField | undefined;
};
const getLabel = (name: string): string => {
const metadata = getFieldMetadata(name);
if (!metadata || !("label" in metadata) || !metadata.label) {
throw new Error(`No label found for field: ${name}`);
}
return resolveTranslation(metadata.label);
};
const getItemLabel = (name: string, itemValue: string): string => {
const metadata = getFieldMetadata(name);
if (!metadata || !("items" in metadata) || !Array.isArray(metadata.items)) {
throw new Error(`No items found for field: ${name}`);
}
const item = metadata.items.find(
(i: { value: string }) => i.value === itemValue,
);
if (!item || typeof item.label !== "string") {
throw new Error(`No item found with value: ${itemValue}`);
}
return resolveTranslation(item.label);
};
return {
getLabel(name) {
return getLabel(String(name));
},
getItemLabel(name, itemValue) {
return getItemLabel(String(name), itemValue);
},
async fill(name, value) {
const label = getLabel(String(name));
await page.getByLabel(label).fill(value);
},
async check(name) {
const label = getLabel(String(name));
const locator = page.getByLabel(label);
const isChecked = await locator.isChecked();
if (!isChecked) {
await locator.click({ force: true });
}
},
async uncheck(name) {
const label = getLabel(String(name));
const locator = page.getByLabel(label);
const isChecked = await locator.isChecked();
if (isChecked) {
await locator.click({ force: true });
}
},
async checkItems(name, itemValues) {
const metadata = getFieldMetadata(String(name));
if (
!metadata ||
!("items" in metadata) ||
!Array.isArray(metadata.items)
) {
throw new Error(`No items found for field: ${String(name)}`);
}
for (const item of metadata.items as Array<{
value: string;
label: string;
}>) {
const itemLabelText = resolveTranslation(item.label);
const locator = page.getByLabel(itemLabelText);
const isChecked = await locator.isChecked();
const shouldBeChecked = itemValues.includes(item.value);
if (shouldBeChecked && !isChecked) {
await locator.click();
} else if (!shouldBeChecked && isChecked) {
await locator.click();
}
}
},
async select(name, optionValue) {
const label = getLabel(String(name));
const locator = page.getByLabel(label);
const tagName = await locator.evaluate((el) => el.tagName.toLowerCase());
const metadata = getFieldMetadata(String(name));
let resolvedOptionText = optionValue;
if (metadata && "items" in metadata && Array.isArray(metadata.items)) {
const item = metadata.items.find(
(i: { value: string }) => i.value === optionValue,
);
if (item && typeof item.label === "string") {
resolvedOptionText = resolveTranslation(item.label);
}
}
if (tagName === "select") {
await locator.selectOption(resolvedOptionText);
} else {
await locator.click();
await page.getByRole("option", { name: resolvedOptionText }).click();
}
},
async selectUser(name, userName) {
const label = getLabel(String(name));
const comboboxButton = page.getByLabel(label, { exact: true });
const searchInput = page.getByTestId("user-search-input");
const option = page.getByTestId("user-search-item").first();
await expect(comboboxButton).not.toBeDisabled();
await comboboxButton.click();
await searchInput.fill(userName);
await expect(option).toBeVisible();
await page.keyboard.press("Enter");
},
async selectWeapons(_name, weaponNames) {
for (const weaponName of weaponNames) {
await page.getByTestId("weapon-select").click();
await page.getByPlaceholder("Search weapons...").fill(weaponName);
await page
.getByRole("listbox", { name: "Suggestions" })
.getByTestId(`weapon-select-option-${weaponName}`)
.click();
}
},
async setDateTime(name, date) {
const label = getLabel(String(name));
const hours = date.getHours();
const fillSpinbutton = async (spinName: string, value: string) => {
await page
.getByRole("spinbutton", {
name: new RegExp(`^${spinName}, ${label}`),
})
.fill(value);
};
await fillSpinbutton("year", date.getFullYear().toString());
await fillSpinbutton("month", (date.getMonth() + 1).toString());
await fillSpinbutton("day", date.getDate().toString());
await fillSpinbutton("hour", String(hours % 12 || 12));
await fillSpinbutton(
"minute",
date.getMinutes().toString().padStart(2, "0"),
);
await fillSpinbutton("AM/PM", hours >= 12 ? "PM" : "AM");
},
async setDate(name, date) {
const label = getLabel(String(name));
const fillSpinbutton = async (spinName: string, value: string) => {
await page
.getByRole("spinbutton", {
name: new RegExp(`^${spinName}, ${label}`),
})
.fill(value);
};
await fillSpinbutton("year", date.getFullYear().toString());
await fillSpinbutton("month", (date.getMonth() + 1).toString());
await fillSpinbutton("day", date.getDate().toString());
},
async submit() {
await page.getByTestId(submitTestId).click();
},
};
}