Initialize fork and rebrand app to event_manager
CI / Server (push) Has been cancelled
Linters / Frappe Linter (push) Has been cancelled
Linters / Vulnerable Dependency Check (push) Has been cancelled
UI Tests / Playwright E2E Tests (push) Has been cancelled

This commit is contained in:
2026-05-11 09:56:57 +02:00
parent f82bb803ac
commit 786cbc724f
500 changed files with 41152 additions and 2 deletions
+80
View File
@@ -0,0 +1,80 @@
import { APIRequestContext, BrowserContext, Page } from "@playwright/test";
const STORAGE_STATE_PATH = "e2e/.auth/user.json";
/**
* Login via Frappe API (faster than UI login).
* Sets cookies on the request context for subsequent API calls.
*/
export async function loginViaAPI(
request: APIRequestContext,
email = "Administrator",
password = "admin",
): Promise<void> {
const response = await request.post("/api/method/login", {
form: {
usr: email,
pwd: password,
},
});
if (!response.ok()) {
throw new Error(`Login failed: ${response.status()} ${await response.text()}`);
}
}
/**
* Login via UI (for testing the login flow itself).
*/
export async function loginViaUI(
page: Page,
email = "Administrator",
password = "admin",
): Promise<void> {
await page.goto("/login");
await page.waitForLoadState("networkidle");
await page.fill('input[data-fieldname="email"]', email);
await page.fill('input[data-fieldname="password"]', password);
await page.click('button[type="submit"]');
// Wait for redirect to desk/app
await page.waitForURL(/\/(app|desk)/, { timeout: 30000 });
}
/**
* Logout the current user.
*/
export async function logout(page: Page): Promise<void> {
await page.goto("/api/method/logout");
await page.waitForLoadState("networkidle");
}
/**
* Save authentication state for reuse across tests.
*/
export async function saveAuthState(context: BrowserContext): Promise<void> {
await context.storageState({ path: STORAGE_STATE_PATH });
}
/**
* Get the storage state path for authenticated sessions.
*/
export function getStorageStatePath() {
return STORAGE_STATE_PATH;
}
/**
* Check if user is logged in by verifying session.
*/
export async function isLoggedIn(request: APIRequestContext): Promise<boolean> {
try {
const response = await request.get("/api/method/frappe.auth.get_logged_user");
if (!response.ok()) return false;
const data = (await response.json()) as { message?: string };
return Boolean(data.message && data.message !== "Guest");
} catch {
return false;
}
}
+223
View File
@@ -0,0 +1,223 @@
import { APIRequestContext } from "@playwright/test";
import * as fs from "fs";
/**
* Frappe API response wrapper.
*/
export interface FrappeResponse<T = unknown> {
message?: T;
exc?: string;
exc_type?: string;
_server_messages?: string;
}
// Path to CSRF token file saved by auth.setup.ts
const CSRF_FILE = "e2e/.auth/csrf.json";
// Cache for CSRF token (read from file once)
let csrfTokenCache: string | null = null;
/**
* Get CSRF token from the file saved during auth setup.
* The token is extracted from window.frappe.csrf_token after login.
*/
function getCsrfToken(): string {
// Return cached token if available
if (csrfTokenCache !== null) {
return csrfTokenCache;
}
// Read token from file
try {
if (fs.existsSync(CSRF_FILE)) {
const data = JSON.parse(fs.readFileSync(CSRF_FILE, "utf-8"));
csrfTokenCache = data.csrf_token || "";
return csrfTokenCache;
}
} catch (error) {
console.warn("Failed to read CSRF token file:", error);
}
csrfTokenCache = "";
return "";
}
/**
* Create a new document via Frappe REST API.
*/
export async function createDoc<T = Record<string, unknown>>(
request: APIRequestContext,
doctype: string,
doc: Record<string, unknown>,
): Promise<T> {
const csrfToken = getCsrfToken();
const response = await request.post(`/api/resource/${doctype}`, {
data: doc,
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-Frappe-CSRF-Token": csrfToken } : {}),
},
});
if (!response.ok()) {
const error = await response.text();
throw new Error(`Failed to create ${doctype}: ${error}`);
}
const result = await response.json();
return result.data as T;
}
/**
* Get a document by name via Frappe REST API.
*/
export async function getDoc<T = Record<string, unknown>>(
request: APIRequestContext,
doctype: string,
name: string,
): Promise<T> {
const response = await request.get(
`/api/resource/${doctype}/${encodeURIComponent(name)}`,
);
if (!response.ok()) {
const error = await response.text();
throw new Error(`Failed to get ${doctype}/${name}: ${error}`);
}
const result = await response.json();
return result.data as T;
}
/**
* Update a document via Frappe REST API.
*/
export async function updateDoc<T = Record<string, unknown>>(
request: APIRequestContext,
doctype: string,
name: string,
updates: Record<string, unknown>,
): Promise<T> {
const csrfToken = getCsrfToken();
const response = await request.put(`/api/resource/${doctype}/${encodeURIComponent(name)}`, {
data: updates,
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-Frappe-CSRF-Token": csrfToken } : {}),
},
});
if (!response.ok()) {
const error = await response.text();
throw new Error(`Failed to update ${doctype}/${name}: ${error}`);
}
const result = await response.json();
return result.data as T;
}
/**
* Delete a document via Frappe REST API.
*/
export async function deleteDoc(
request: APIRequestContext,
doctype: string,
name: string,
): Promise<void> {
const csrfToken = getCsrfToken();
const response = await request.delete(`/api/resource/${doctype}/${encodeURIComponent(name)}`, {
headers: {
...(csrfToken ? { "X-Frappe-CSRF-Token": csrfToken } : {}),
},
});
if (!response.ok()) {
const error = await response.text();
throw new Error(`Failed to delete ${doctype}/${name}: ${error}`);
}
}
/**
* Call a Frappe whitelisted method.
*/
export async function callMethod<T = unknown>(
request: APIRequestContext,
method: string,
args: Record<string, unknown> = {},
): Promise<T> {
const csrfToken = getCsrfToken();
const response = await request.post(`/api/method/${method}`, {
data: args,
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-Frappe-CSRF-Token": csrfToken } : {}),
},
});
if (!response.ok()) {
const error = await response.text();
throw new Error(`Failed to call ${method}: ${error}`);
}
const result: FrappeResponse<T> = await response.json();
return result.message as T;
}
/**
* Get a list of documents via Frappe REST API.
*/
export async function getList<T = Record<string, unknown>>(
request: APIRequestContext,
doctype: string,
options: {
fields?: string[];
filters?: Record<string, unknown>;
limit?: number;
orderBy?: string;
} = {},
): Promise<T[]> {
const params = new URLSearchParams();
if (options.fields) {
params.set("fields", JSON.stringify(options.fields));
}
if (options.filters) {
params.set("filters", JSON.stringify(options.filters));
}
if (options.limit) {
params.set("limit_page_length", options.limit.toString());
}
if (options.orderBy) {
params.set("order_by", options.orderBy);
}
const response = await request.get(`/api/resource/${doctype}?${params.toString()}`);
if (!response.ok()) {
const error = await response.text();
throw new Error(`Failed to get list of ${doctype}: ${error}`);
}
const result = await response.json();
return result.data as T[];
}
/**
* Check if a document exists.
*/
export async function docExists(
request: APIRequestContext,
doctype: string,
name: string,
): Promise<boolean> {
try {
await getDoc(request, doctype, name);
return true;
} catch {
return false;
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./auth";
export * from "./frappe";
+120
View File
@@ -0,0 +1,120 @@
import { expect, Locator, Page } from "@playwright/test";
export class BookingPage {
private page: Page;
attendeeNameInput: Locator;
attendeeEmailInput: Locator;
ticketTypeSelect: Locator;
addOnCheckboxes: Locator;
private bookButton: Locator;
private addAttendeeButton: Locator;
private bookingForm: Locator;
private summarySection: Locator;
constructor(page: Page) {
this.page = page;
// Form elements
this.attendeeNameInput = page
.locator('input[placeholder*="name" i], input[name*="name" i]')
.first();
this.attendeeEmailInput = page
.locator('input[type="email"], input[placeholder*="email" i]')
.first();
this.ticketTypeSelect = page.locator('select, [data-testid="ticket-type"]').first();
// Add-ons
this.addOnCheckboxes = page.locator('input[type="checkbox"]');
// Buttons
this.bookButton = page.locator('button[type="submit"]');
this.addAttendeeButton = page.locator('button:has-text("Add Another Attendee")');
// Sections
this.bookingForm = page.locator("form");
this.summarySection = page.locator('[class*="summary" i]').first();
}
// Navigate to the booking page for a specific event.
async goto(eventRoute: string): Promise<void> {
await this.page.goto(`/dashboard/book-tickets/${eventRoute}`);
await this.page.waitForLoadState("networkidle");
}
// Wait for the booking form to fully load.
async waitForFormLoad(): Promise<void> {
await expect(this.bookingForm).toBeVisible({ timeout: 15000 });
}
// Fill in attendee details.
async fillAttendeeDetails(name: string, email: string): Promise<void> {
await this.attendeeNameInput.fill(name);
await this.attendeeEmailInput.fill(email);
}
// Select a ticket type by its visible text.
async selectTicketType(ticketTitle: string): Promise<void> {
const ticketOption = this.page.locator(`text=${ticketTitle}`).first();
if (await ticketOption.isVisible()) {
await ticketOption.click();
}
}
/**
* Toggle an add-on by its title.
*/
async toggleAddOn(addOnTitle: string): Promise<void> {
const addOnLabel = this.page.locator(`label:has-text("${addOnTitle}")`).first();
if (await addOnLabel.isVisible()) {
await addOnLabel.click();
}
}
// Add another attendee to the booking.
async addAnotherAttendee(): Promise<void> {
await this.addAttendeeButton.click();
}
// Submit the booking form.
async submit(): Promise<void> {
await this.bookButton.click();
}
// Get the booking button text.
async getBookButtonText(): Promise<string | null> {
return this.bookButton.textContent();
}
// Assert that the booking form is visible.
async expectFormVisible(): Promise<void> {
await expect(this.bookingForm).toBeVisible();
}
// Assert that ticket types are displayed.
async expectTicketTypesVisible(): Promise<void> {
// Check for any ticket-related content
const ticketContent = this.page
.locator('[class*="ticket"], select, input[type="radio"]')
.first();
await expect(ticketContent).toBeVisible({ timeout: 10000 });
}
// Assert that add-ons are displayed.
async expectAddOnsVisible(): Promise<void> {
const addOnCount = await this.addOnCheckboxes.count();
expect(addOnCount).toBeGreaterThan(0);
}
// Assert that the book button is visible with expected text.
async expectBookButtonVisible(): Promise<void> {
await expect(this.bookButton).toBeVisible();
const text = await this.bookButton.textContent();
expect(text?.match(/Book|Pay|Register/i)).toBeTruthy();
}
// Get the count of attendee forms on the page.
async getAttendeeCount(): Promise<number> {
const attendeeForms = this.page.locator('[class*="attendee"], [class*="Attendee"]');
return await attendeeForms.count();
}
}
+96
View File
@@ -0,0 +1,96 @@
import { expect, Locator, Page } from "@playwright/test";
export class CustomFormPage {
private page: Page;
private form: Locator;
private submitButton: Locator;
private successBanner: Locator;
private closedBanner: Locator;
private errorBanner: Locator;
constructor(page: Page) {
this.page = page;
this.form = page.locator("form");
this.submitButton = page.locator('button[type="submit"]').filter({ hasText: /^Submit$/ });
this.successBanner = page.locator(".bg-surface-green-1");
this.closedBanner = page.locator(".bg-surface-orange-1");
this.errorBanner = page.locator(".bg-surface-amber-1");
}
async goto(eventRoute: string, formRoute: string): Promise<void> {
await this.page.goto(`/dashboard/events/${eventRoute}/forms/${formRoute}`);
await this.page.waitForLoadState("networkidle");
}
async waitForFormLoad(): Promise<void> {
await expect(this.form).toBeVisible({ timeout: 15000 });
}
getInputByLabel(label: string): Locator {
return this.page
.locator(`label:has-text("${label}")`)
.locator("..")
.locator("input, textarea, select")
.first();
}
async submit(): Promise<void> {
await this.submitButton.click();
}
async expectFormVisible(): Promise<void> {
await expect(this.form).toBeVisible();
}
async expectFormTitle(title: string): Promise<void> {
await expect(this.page.locator(`h1:has-text("${title}")`)).toBeVisible();
}
async expectFieldVisible(label: string): Promise<void> {
await expect(this.page.locator(`label:has-text("${label}")`)).toBeVisible();
}
async expectSubmitButtonVisible(): Promise<void> {
await expect(this.submitButton).toBeVisible();
const text = await this.submitButton.textContent();
expect(text?.match(/Submit/i)).toBeTruthy();
}
async submitAndExpectResponse(): Promise<{ succeeded: boolean; status: number }> {
const responsePromise = this.page.waitForResponse(
(resp) => resp.url().includes("submit_custom_form"),
{ timeout: 20000 },
);
await this.submitButton.click();
const response = await responsePromise;
const status = response.status();
const succeeded = status === 200;
if (succeeded) {
await expect(this.successBanner).toBeVisible({ timeout: 15000 });
} else {
await expect(this.form).toBeVisible();
}
return { succeeded, status };
}
async expectSuccess(): Promise<void> {
await expect(this.successBanner).toBeVisible({ timeout: 15000 });
}
async expectClosed(): Promise<void> {
await expect(this.closedBanner).toBeVisible({ timeout: 15000 });
}
async expectNotFound(): Promise<void> {
await expect(this.errorBanner).toBeVisible({ timeout: 15000 });
}
async getFieldLabels(): Promise<string[]> {
const labels = this.page.locator("form label");
return labels.allTextContents();
}
}
+81
View File
@@ -0,0 +1,81 @@
import { expect, Locator, Page } from "@playwright/test";
export class EventProposalPage {
private page: Page;
private form: Locator;
private submitButton: Locator;
private successBanner: Locator;
private notFoundBanner: Locator;
constructor(page: Page) {
this.page = page;
this.form = page.locator("form");
this.submitButton = page.locator('button[type="submit"]').filter({ hasText: /^Submit$/ });
this.successBanner = page.locator(".bg-surface-green-1");
this.notFoundBanner = page.locator(".bg-surface-amber-1");
}
async goto(): Promise<void> {
await this.page.goto("/dashboard/event-proposal");
await this.page.waitForLoadState("networkidle");
}
async waitForFormLoad(): Promise<void> {
await expect(this.form).toBeVisible({ timeout: 15000 });
}
getInputByLabel(label: string): Locator {
return this.page
.locator(`label:has-text("${label}")`)
.locator("..")
.locator("input, textarea, select")
.first();
}
async expectFormVisible(): Promise<void> {
await expect(this.form).toBeVisible();
}
async expectBannerTitle(title: string): Promise<void> {
await expect(this.page.locator(`h1:has-text("${title}")`)).toBeVisible();
}
async expectFieldVisible(label: string): Promise<void> {
await expect(this.page.locator(`label:has-text("${label}")`)).toBeVisible();
}
async expectSubmitButtonVisible(): Promise<void> {
await expect(this.submitButton).toBeVisible();
}
async expectSuccess(): Promise<void> {
await expect(this.successBanner).toBeVisible({ timeout: 15000 });
}
async expectNotFound(): Promise<void> {
await expect(this.notFoundBanner).toBeVisible({ timeout: 15000 });
}
async submit(): Promise<void> {
await this.submitButton.click();
}
async submitAndExpectResponse(): Promise<{ succeeded: boolean; status: number }> {
const responsePromise = this.page.waitForResponse(
(resp) => resp.url().includes("submit_event_proposal"),
{ timeout: 20000 },
);
await this.submitButton.click();
const response = await responsePromise;
const status = response.status();
const succeeded = status === 200;
if (succeeded) {
await expect(this.successBanner).toBeVisible({ timeout: 15000 });
}
return { succeeded, status };
}
}
+4
View File
@@ -0,0 +1,4 @@
export { LoginPage } from "./login.page";
export { BookingPage } from "./booking.page";
export { CustomFormPage } from "./custom-form.page";
export { EventProposalPage } from "./event-proposal.page";
+69
View File
@@ -0,0 +1,69 @@
import { expect, Locator, Page } from "@playwright/test";
/**
* Page Object for the Frappe login page.
*/
export class LoginPage {
private page: Page;
private emailInput: Locator;
private passwordInput: Locator;
private submitButton: Locator;
private errorMessage: Locator;
constructor(page: Page) {
this.page = page;
// Frappe login page selectors
this.emailInput = page.locator("#login_email");
this.passwordInput = page.locator("#login_password");
this.submitButton = page.locator("button.btn-login");
this.errorMessage = page.locator(".msgprint, .alert-danger").first();
}
/**
* Navigate to the login page.
*/
async goto(): Promise<void> {
await this.page.goto("/login");
await this.page.waitForLoadState("networkidle");
}
/**
* Fill in the login form with credentials.
*/
async fillCredentials(email: string, password: string): Promise<void> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
}
/**
* Submit the login form.
*/
async submit(): Promise<void> {
await this.submitButton.click();
}
/**
* Perform a complete login.
*/
async login(email = "Administrator", password = "admin"): Promise<void> {
await this.goto();
await this.fillCredentials(email, password);
await this.submit();
await this.page.waitForURL(/\/(app|desk|event_manager)/, { timeout: 30000 });
}
/**
* Assert that login failed with an error.
*/
async expectLoginError(): Promise<void> {
await expect(this.errorMessage).toBeVisible();
}
/**
* Assert that we're on the login page.
*/
async expectToBeOnLoginPage(): Promise<void> {
await expect(this.page).toHaveURL(/.*login.*/);
}
}
+54
View File
@@ -0,0 +1,54 @@
import { test as setup, expect } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";
const authFile = "e2e/.auth/user.json";
const csrfFile = "e2e/.auth/csrf.json";
setup("authenticate", async ({ page }) => {
const authDir = path.dirname(authFile);
if (!fs.existsSync(authDir)) {
fs.mkdirSync(authDir, { recursive: true });
}
// Login via Frappe API using page.request
const loginResponse = await page.request.post("/api/method/login", {
form: {
usr: process.env.FRAPPE_USER || "Administrator",
pwd: process.env.FRAPPE_PASSWORD || "admin",
},
});
expect(loginResponse.ok()).toBeTruthy();
// Verify login succeeded by checking current user
const userResponse = await page.request.get("/api/method/frappe.auth.get_logged_user");
expect(userResponse.ok()).toBeTruthy();
const userData = (await userResponse.json()) as { message?: string };
expect(userData.message).not.toBe("Guest");
console.log(`✅ Authenticated as: ${userData.message}`);
// Navigate to app to load frappe context and get CSRF token
await page.goto("/app");
await page.waitForLoadState("networkidle");
// Wait for frappe to initialize and extract CSRF token
const csrfToken = await page.evaluate(() => {
return (window as Window & { frappe?: { csrf_token?: string } }).frappe
?.csrf_token;
});
if (csrfToken) {
// Save CSRF token to file for API helpers to use
fs.writeFileSync(csrfFile, JSON.stringify({ csrf_token: csrfToken }));
console.log(`🔐 Saved CSRF token to ${csrfFile}`);
} else {
console.warn("⚠️ Could not extract CSRF token from page");
}
// Save authentication state
await page.context().storageState({ path: authFile });
console.log(`💾 Saved auth state to ${authFile}`);
});
+59
View File
@@ -0,0 +1,59 @@
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages";
import { isLoggedIn } from "../helpers";
/**
* Tests for authentication that already have auth state from setup.
*/
test.describe("Authentication - Pre-authenticated", () => {
test("should access event_manager when authenticated", async ({ page }) => {
// Already authenticated via setup project
await page.goto("/dashboard/");
await page.waitForLoadState("networkidle");
// Should not be redirected to login
await expect(page).not.toHaveURL(/.*login.*/);
});
test("should verify session via API", async ({ request }) => {
// Already authenticated via setup project
const loggedIn = await isLoggedIn(request);
expect(loggedIn).toBe(true);
});
});
/**
* Tests for authentication that need fresh (unauthenticated) state.
* Uses storageState reset to clear any auth cookies.
*/
test.describe("Authentication - Fresh state", () => {
// Reset storage state to test without authentication
test.use({ storageState: { cookies: [], origins: [] } });
test("should login via UI", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login(process.env.FRAPPE_USER || "Administrator", process.env.FRAPPE_PASSWORD || "admin");
// Should be redirected away from login
await expect(page).not.toHaveURL(/.*login.*/);
});
test("should show error for invalid credentials", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.fillCredentials("invalid@example.com", "wrongpassword");
await loginPage.submit();
// Should stay on login page
await loginPage.expectToBeOnLoginPage();
});
test("should show login button when not authenticated", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
await expect(page.getByRole("button", { name: "Log In" }).first()).toBeVisible();
});
});
+66
View File
@@ -0,0 +1,66 @@
import { test as setup, expect } from "@playwright/test";
import { createDoc, docExists, getDoc, getList, updateDoc } from "../helpers/frappe";
interface NamedDoc {
name: string;
}
const testEventRoute = "custom-forms-e2e";
const testCategoryName = "E2E Test Category";
const testHostName = "E2E Test Host";
setup("setup custom forms on test event", async ({ request }) => {
let eventName: string;
const events = await getList<NamedDoc>(request, "Pohodex Event Manager Event", {
filters: { route: ["=", testEventRoute] },
});
if (events.length > 0) {
eventName = events[0].name;
} else {
if (!(await docExists(request, "Event Category", testCategoryName))) {
await createDoc(request, "Event Category", {
name: testCategoryName,
enabled: 1,
slug: "e2e-test-category",
});
}
if (!(await docExists(request, "Event Host", testHostName))) {
await createDoc(request, "Event Host", { name: testHostName });
}
const futureDate = new Date();
futureDate.setMonth(futureDate.getMonth() + 1);
const startDate = futureDate.toISOString().split("T")[0];
const event = await createDoc<NamedDoc>(request, "Pohodex Event Manager Event", {
title: "E2E Custom Forms Event",
category: testCategoryName,
host: testHostName,
start_date: startDate,
route: testEventRoute,
is_published: 1,
start_time: "09:00:00",
end_time: "17:00:00",
medium: "In Person",
});
eventName = event.name;
}
await updateDoc(request, "Pohodex Event Manager Event", eventName, {
custom_forms: [
{ doctype: "Pohodex Event Manager Event Form", form_doctype: "Event Feedback", route: "feedback", publish: 1 },
{ doctype: "Pohodex Event Manager Event Form", form_doctype: "Talk Proposal", route: "propose-talk", publish: 1 },
{ doctype: "Pohodex Event Manager Event Form", form_doctype: "Sponsorship Enquiry", route: "enquire-sponsorship", publish: 1 },
],
});
const updated = await getDoc<{ custom_forms: Array<{ route: string; publish: number }> }>(
request, "Pohodex Event Manager Event", eventName,
);
const publishedForms = (updated.custom_forms || []).filter((f) => f.publish);
expect(publishedForms.length).toBe(3);
console.log(`Custom forms enabled on event: ${eventName} (${publishedForms.length} forms: ${publishedForms.map((f) => f.route).join(", ")})`);
});
+341
View File
@@ -0,0 +1,341 @@
import { test, expect } from "@playwright/test";
import { CustomFormPage } from "../pages";
import { callMethod } from "../helpers/frappe";
const testEventRoute = "custom-forms-e2e";
test.describe("Event Feedback Form", () => {
test("should display feedback form with title", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "feedback");
await formPage.waitForFormLoad();
await formPage.expectFormVisible();
await formPage.expectFormTitle("Event Feedback");
});
test("should display submit button", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "feedback");
await formPage.waitForFormLoad();
await formPage.expectSubmitButtonVisible();
});
test("should display feedback text area", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "feedback");
await formPage.waitForFormLoad();
const textarea = page.locator("textarea").first();
await expect(textarea).toBeVisible();
});
test("should fill feedback form fields", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "feedback");
await formPage.waitForFormLoad();
const feedbackText = "Great event, learned a lot about testing!";
const textarea = page.locator("textarea").first();
await textarea.fill(feedbackText);
await expect(textarea).toHaveValue(feedbackText);
const phoneInput = page.locator('input[placeholder="Phone number"]');
if (await phoneInput.isVisible({ timeout: 1000 }).catch(() => false)) {
await phoneInput.fill("9876543210");
await expect(phoneInput).toHaveValue("9876543210");
}
await formPage.expectSubmitButtonVisible();
});
test("should interact with rating stars if present", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "feedback");
await formPage.waitForFormLoad();
const stars = page.locator(".cursor-pointer svg");
if (await stars.first().isVisible({ timeout: 1000 }).catch(() => false)) {
const starCount = await stars.count();
expect(starCount).toBe(5);
await stars.nth(3).click();
}
});
test("should submit feedback and get a response", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "feedback");
await formPage.waitForFormLoad();
const textarea = page.locator("textarea").first();
await textarea.fill("E2E test feedback - event was excellent!");
const phoneInput = page.locator('input[placeholder="Phone number"]');
if (await phoneInput.isVisible({ timeout: 1000 }).catch(() => false)) {
await phoneInput.fill("9876543210");
}
const stars = page.locator(".cursor-pointer svg");
if (await stars.first().isVisible({ timeout: 1000 }).catch(() => false)) {
await stars.nth(4).click();
}
const { succeeded, status } = await formPage.submitAndExpectResponse();
console.log(`Feedback submission: status=${status}, succeeded=${succeeded}`);
});
});
test.describe("Talk Proposal Form", () => {
test("should display talk proposal form with title", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "propose-talk");
await formPage.waitForFormLoad();
await formPage.expectFormVisible();
await formPage.expectFormTitle("Talk Proposal");
});
test("should display required fields", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "propose-talk");
await formPage.waitForFormLoad();
await formPage.expectFieldVisible("Title");
const requiredIndicator = page.locator('label:has-text("Title") .text-ink-red-4');
const hasRequired = await requiredIndicator.isVisible({ timeout: 1000 }).catch(() => false);
if (hasRequired) {
await expect(requiredIndicator).toBeVisible();
}
});
test("should display submit button", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "propose-talk");
await formPage.waitForFormLoad();
await formPage.expectSubmitButtonVisible();
});
test("should display description textarea", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "propose-talk");
await formPage.waitForFormLoad();
const textarea = page.locator("textarea").first();
const hasTextarea = await textarea.isVisible({ timeout: 2000 }).catch(() => false);
expect(hasTextarea).toBeTruthy();
});
test("should display phone input if present", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "propose-talk");
await formPage.waitForFormLoad();
const phoneInput = page.locator('input[placeholder="Phone number"]');
if (await phoneInput.isVisible({ timeout: 1000 }).catch(() => false)) {
await expect(phoneInput).toBeVisible();
}
});
test("should fill talk proposal form fields", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "propose-talk");
await formPage.waitForFormLoad();
const titleInput = formPage.getInputByLabel("Title");
await titleInput.fill("Introduction to E2E Testing");
await expect(titleInput).toHaveValue("Introduction to E2E Testing");
const textarea = page.locator("textarea").first();
if (await textarea.isVisible({ timeout: 1000 }).catch(() => false)) {
await textarea.fill("A comprehensive talk about writing E2E tests with Playwright.");
await expect(textarea).toHaveValue("A comprehensive talk about writing E2E tests with Playwright.");
}
const phoneInput = page.locator('input[placeholder="Phone number"]');
if (await phoneInput.isVisible({ timeout: 1000 }).catch(() => false)) {
await phoneInput.fill("1234567890");
await expect(phoneInput).toHaveValue("1234567890");
}
await formPage.expectSubmitButtonVisible();
});
test("should display speakers table with add button if present", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "propose-talk");
await formPage.waitForFormLoad();
const speakersLabel = page.locator('label:has-text("Speakers")');
if (await speakersLabel.isVisible({ timeout: 1000 }).catch(() => false)) {
await expect(speakersLabel).toBeVisible();
const addButton = page.locator('button:has-text("Add Speakers")');
await expect(addButton).toBeVisible();
}
});
test("should submit talk proposal and get a response", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "propose-talk");
await formPage.waitForFormLoad();
const titleInput = formPage.getInputByLabel("Title");
await titleInput.fill("E2E Test Talk: Automated Testing Best Practices");
const textarea = page.locator("textarea").first();
if (await textarea.isVisible({ timeout: 2000 }).catch(() => false)) {
await textarea.fill("This talk covers best practices for writing robust E2E tests.");
}
const addSpeakersButton = page.locator('button:has-text("Add Speakers")');
if (await addSpeakersButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await addSpeakersButton.click();
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible({ timeout: 5000 });
const firstNameInput = dialog.locator('label:has-text("First Name")').locator("..").locator("input").first();
await firstNameInput.fill("E2E Speaker");
const emailInput = dialog.locator('label:has-text("Email")').locator("..").locator("input").first();
await emailInput.fill("e2e-speaker@test.com");
const addButton = dialog.locator('button[type="submit"]');
await addButton.click();
}
const { succeeded, status } = await formPage.submitAndExpectResponse();
console.log(`Talk proposal submission: status=${status}, succeeded=${succeeded}`);
});
});
test.describe("Sponsorship Enquiry Form", () => {
test("should display sponsorship form with title", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "enquire-sponsorship");
await formPage.waitForFormLoad();
await formPage.expectFormVisible();
await formPage.expectFormTitle("Sponsorship Enquiry");
});
test("should display required fields", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "enquire-sponsorship");
await formPage.waitForFormLoad();
await formPage.expectFieldVisible("Company Name");
await formPage.expectFieldVisible("Company Logo");
});
test("should display submit button", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "enquire-sponsorship");
await formPage.waitForFormLoad();
await formPage.expectSubmitButtonVisible();
});
test("should display optional fields", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "enquire-sponsorship");
await formPage.waitForFormLoad();
const websiteLabel = page.locator('label:has-text("Website")');
if (await websiteLabel.isVisible({ timeout: 1000 }).catch(() => false)) {
await expect(websiteLabel).toBeVisible();
}
const phoneInput = page.locator('input[placeholder="Phone number"]');
if (await phoneInput.isVisible({ timeout: 1000 }).catch(() => false)) {
await expect(phoneInput).toBeVisible();
}
});
test("should display upload image button for company logo", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "enquire-sponsorship");
await formPage.waitForFormLoad();
const uploadButton = page.locator('button:has-text("Upload Image")');
await expect(uploadButton).toBeVisible();
});
test("should fill sponsorship enquiry form fields", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "enquire-sponsorship");
await formPage.waitForFormLoad();
const companyNameInput = formPage.getInputByLabel("Company Name");
await companyNameInput.fill("Test Corp Ltd");
await expect(companyNameInput).toHaveValue("Test Corp Ltd");
const websiteInput = formPage.getInputByLabel("Website");
if (await websiteInput.isVisible({ timeout: 1000 }).catch(() => false)) {
await websiteInput.fill("https://testcorp.example.com");
await expect(websiteInput).toHaveValue("https://testcorp.example.com");
}
const phoneInput = page.locator('input[placeholder="Phone number"]');
if (await phoneInput.isVisible({ timeout: 1000 }).catch(() => false)) {
await phoneInput.fill("5551234567");
await expect(phoneInput).toHaveValue("5551234567");
}
await formPage.expectSubmitButtonVisible();
});
test("should stay on form when submitting without required company logo", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "enquire-sponsorship");
await formPage.waitForFormLoad();
const companyNameInput = formPage.getInputByLabel("Company Name");
await companyNameInput.fill("Test Corp Without Logo");
await formPage.submit();
await formPage.expectFormVisible();
});
});
test.describe("Custom Form Edge Cases", () => {
test("should show not found for nonexistent event", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto("nonexistent-event-route-xyz", "feedback");
await formPage.expectNotFound();
});
test("should return error for invalid form route via API", async ({ request }) => {
const result = await callMethod(request, "event_manager.api.forms.get_custom_form_data", {
event_route: testEventRoute,
form_route: "invalid-route",
}).catch((err: Error) => err);
expect(result).toBeInstanceOf(Error);
});
test("should show not found for valid event but invalid form route", async ({ page }) => {
const formPage = new CustomFormPage(page);
await formPage.goto(testEventRoute, "nonexistent-form-route");
await formPage.expectNotFound();
});
});
+91
View File
@@ -0,0 +1,91 @@
import { test, expect } from "@playwright/test";
import { BookingPage } from "../pages";
// Verifies that the booking form displays correctly with ticket types, add-ons, and booking button.
test.describe("Event Registration Page", () => {
const testEventRoute = "test-event-e2e";
test("should display event booking page", async ({ page }) => {
const bookingPage = new BookingPage(page);
await bookingPage.goto(testEventRoute);
await bookingPage.waitForFormLoad();
await bookingPage.expectFormVisible();
console.log("Event booking page loaded successfully");
});
test("should display add-ons section", async ({ page }) => {
const bookingPage = new BookingPage(page);
await bookingPage.goto(testEventRoute);
await bookingPage.waitForFormLoad();
const addOnCount = await bookingPage.addOnCheckboxes.count();
console.log(`Found ${addOnCount} add-on related elements`);
if (addOnCount > 0) {
await bookingPage.expectAddOnsVisible();
console.log("Add-ons section is displayed");
} else {
console.log("No add-on elements found");
}
});
test("should display booking button", async ({ page }) => {
const bookingPage = new BookingPage(page);
await bookingPage.goto(testEventRoute);
await bookingPage.waitForFormLoad();
await bookingPage.expectBookButtonVisible();
const buttonText = await bookingPage.getBookButtonText();
console.log(`Booking button found with text: "${buttonText}"`);
});
test("should display attendee form fields", async ({ page }) => {
const bookingPage = new BookingPage(page);
await bookingPage.goto(testEventRoute);
await bookingPage.waitForFormLoad();
const hasNameInput = await bookingPage.attendeeNameInput.isVisible().catch(() => false);
const hasEmailInput = await bookingPage.attendeeEmailInput.isVisible().catch(() => false);
expect(hasNameInput && hasEmailInput).toBeTruthy();
console.log("Attendee form fields are displayed");
});
test("should fill booking form with attendee details and select add-ons", async ({ page }) => {
const bookingPage = new BookingPage(page);
await bookingPage.goto(testEventRoute);
await bookingPage.waitForFormLoad();
// Fill in attendee details
const testName = "John Doe";
const testEmail = "john.doe@example.com";
await bookingPage.fillAttendeeDetails(testName, testEmail);
// Verify the values were entered
await expect(bookingPage.attendeeNameInput).toHaveValue(testName);
await expect(bookingPage.attendeeEmailInput).toHaveValue(testEmail);
console.log("Attendee details filled successfully");
// Select add-ons if available
const addOnCount = await bookingPage.addOnCheckboxes.count();
if (addOnCount > 0) {
// Click the first add-on checkbox
await bookingPage.addOnCheckboxes.first().click();
// Verify it's checked
await expect(bookingPage.addOnCheckboxes.first()).toBeChecked();
console.log("Add-on selected successfully");
}
// Verify booking button is still visible and ready
await bookingPage.expectBookButtonVisible();
console.log("Form is ready to submit");
});
});
+22
View File
@@ -0,0 +1,22 @@
import { test as setup } from "@playwright/test";
import { createDoc, docExists, updateDoc } from "../helpers/frappe";
const testCategoryName = "E2E Test Category";
setup("setup event proposal form", async ({ request }) => {
if (!(await docExists(request, "Event Category", testCategoryName))) {
await createDoc(request, "Event Category", {
name: testCategoryName,
enabled: 1,
slug: "e2e-test-category",
});
console.log(`Created Event Category: ${testCategoryName}`);
}
await updateDoc(request, "Pohodex Event Manager Settings", "Pohodex Event Manager Settings", {
accept_event_proposals: 1,
allow_guest_event_proposals: 1,
});
console.log("Event proposal form enabled in Pohodex Event Manager Settings");
});
+154
View File
@@ -0,0 +1,154 @@
import { test, expect } from "@playwright/test";
import { EventProposalPage } from "../pages";
import { callMethod, updateDoc } from "../helpers/frappe";
test.describe("Event Proposal Form - Rendering", () => {
test("should display the proposal form", async ({ page }) => {
const proposalPage = new EventProposalPage(page);
await proposalPage.goto();
await proposalPage.waitForFormLoad();
await proposalPage.expectFormVisible();
});
test("should display the banner title", async ({ page }) => {
const proposalPage = new EventProposalPage(page);
await proposalPage.goto();
await proposalPage.waitForFormLoad();
await proposalPage.expectBannerTitle("Propose an Event");
});
test("should display the title field", async ({ page }) => {
const proposalPage = new EventProposalPage(page);
await proposalPage.goto();
await proposalPage.waitForFormLoad();
await proposalPage.expectFieldVisible("Title");
});
test("should display the submit button", async ({ page }) => {
const proposalPage = new EventProposalPage(page);
await proposalPage.goto();
await proposalPage.waitForFormLoad();
await proposalPage.expectSubmitButtonVisible();
});
});
test.describe("Event Proposal Form - Submission", () => {
test("should fill and submit a proposal", async ({ page }) => {
const proposalPage = new EventProposalPage(page);
await proposalPage.goto();
await proposalPage.waitForFormLoad();
const titleInput = proposalPage.getInputByLabel("Title");
await titleInput.fill("E2E Test: Automated Testing with Playwright");
const categorySelect = page.locator("select").first();
await categorySelect.waitFor({ state: "visible", timeout: 5000 });
await categorySelect.selectOption({ label: "E2E Test Category" });
const aboutTextarea = page
.locator('label:has-text("About the event")')
.locator("..")
.locator("textarea");
await aboutTextarea.fill("An E2E test proposal about automated testing practices.");
const { succeeded, status } = await proposalPage.submitAndExpectResponse();
console.log(`Proposal submission: status=${status}, succeeded=${succeeded}`);
});
test("should show success banner after submission", async ({ page }) => {
await page.route(/submit_event_proposal/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ message: null }),
}),
);
const proposalPage = new EventProposalPage(page);
await proposalPage.goto();
await proposalPage.waitForFormLoad();
const titleInput = proposalPage.getInputByLabel("Title");
await titleInput.fill("E2E Test: Another Proposal for Success Banner");
const categorySelect = page.locator("select").first();
await categorySelect.waitFor({ state: "visible", timeout: 5000 });
await categorySelect.selectOption({ label: "E2E Test Category" });
const aboutTextarea = page
.locator('label:has-text("About the event")')
.locator("..")
.locator("textarea");
await aboutTextarea.fill("An E2E test proposal about building better events.");
await proposalPage.submitAndExpectResponse();
await proposalPage.expectSuccess();
});
});
test.describe("Event Proposal Form - Access Control", () => {
test("should show not found when proposals are disabled", async ({ page, request }) => {
await updateDoc(request, "Pohodex Event Manager Settings", "Pohodex Event Manager Settings", {
accept_event_proposals: 0,
});
try {
const proposalPage = new EventProposalPage(page);
await proposalPage.goto();
await proposalPage.expectNotFound();
} finally {
await updateDoc(request, "Pohodex Event Manager Settings", "Pohodex Event Manager Settings", {
accept_event_proposals: 1,
});
}
});
test("should return error via API when proposals are disabled", async ({ request }) => {
await updateDoc(request, "Pohodex Event Manager Settings", "Pohodex Event Manager Settings", {
accept_event_proposals: 0,
});
try {
const result = await callMethod(
request,
"event_manager.api.forms.get_event_proposal_form_data",
).catch((err: Error) => err);
expect(result).toBeInstanceOf(Error);
} finally {
await updateDoc(request, "Pohodex Event Manager Settings", "Pohodex Event Manager Settings", {
accept_event_proposals: 1,
});
}
});
});
test.describe("Event Proposal Form - API", () => {
test("should return form data with expected shape", async ({ request }) => {
const data = await callMethod<{
form_fields: Array<{ fieldname: string }>;
banner_title: string;
form_title: string;
success_title: string;
}>(request, "event_manager.api.forms.get_event_proposal_form_data");
expect(data.form_fields).toBeInstanceOf(Array);
expect(data.form_fields.length).toBeGreaterThan(0);
expect(data.banner_title).toBeTruthy();
expect(data.form_title).toBeTruthy();
expect(data.success_title).toBeTruthy();
});
test("should not expose excluded fields in form data", async ({ request }) => {
const data = await callMethod<{
form_fields: Array<{ fieldname: string }>;
}>(request, "event_manager.api.forms.get_event_proposal_form_data");
const fieldnames = data.form_fields.map((f) => f.fieldname);
const excludedFields = ["status", "submitted_by", "host", "naming_series", "amended_from"];
for (const excluded of excludedFields) {
expect(fieldnames).not.toContain(excluded);
}
});
});
+113
View File
@@ -0,0 +1,113 @@
import { test as setup, expect } from "@playwright/test";
import { createDoc, deleteDoc, docExists, getList } from "../helpers/frappe";
interface NamedDoc {
name: string;
}
// Setup: Creates Event Category, Event Host, Pohodex Event Manager Event, Event Ticket Type, and Ticket Add-on.
setup("create test event for booking", async ({ request }) => {
const testEventTitle = "E2E Test Event";
const testEventRoute = "test-event-e2e";
const testCategoryName = "E2E Test Category";
const testHostName = "E2E Test Host";
// Clean up any existing test data first
try {
// Find the event by title
const events = await getList<NamedDoc>(request, "Pohodex Event Manager Event", {
filters: { title: ["=", testEventTitle] },
});
const existingEvent = events[0]; // getList returns array
if (existingEvent) {
// Delete sponsorship tiers first
const tiers = await getList<NamedDoc>(request, "Sponsorship Tier", {
filters: { event: ["=", existingEvent.name] },
});
for (const tier of tiers) {
await deleteDoc(request, "Sponsorship Tier", tier.name).catch(() => {});
}
// Delete ticket types
const ticketTypes = await getList<NamedDoc>(request, "Event Ticket Type", {
filters: { event: ["=", existingEvent.name] },
});
for (const tt of ticketTypes) {
await deleteDoc(request, "Event Ticket Type", tt.name).catch(() => {});
}
// Delete add-ons
const addOns = await getList<NamedDoc>(request, "Ticket Add-on", {
filters: { event: ["=", existingEvent.name] },
});
for (const ao of addOns) {
await deleteDoc(request, "Ticket Add-on", ao.name).catch(() => {});
}
// Now delete the event
await deleteDoc(request, "Pohodex Event Manager Event", existingEvent.name).catch(() => {});
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log("Cleanup: Some test data may not have existed", message);
}
// Create Event Category if it doesn't exist
if (!(await docExists(request, "Event Category", testCategoryName))) {
await createDoc(request, "Event Category", {
name: testCategoryName,
enabled: 1,
slug: "e2e-test-category",
});
console.log(`Created Event Category: ${testCategoryName}`);
}
// Create Event Host if it doesn't exist
if (!(await docExists(request, "Event Host", testHostName))) {
await createDoc(request, "Event Host", {
name: testHostName,
});
console.log(`Created Event Host: ${testHostName}`);
}
// Create Pohodex Event Manager Event
const futureDate = new Date();
futureDate.setMonth(futureDate.getMonth() + 1);
const startDate = futureDate.toISOString().split("T")[0];
const event = await createDoc<NamedDoc>(request, "Pohodex Event Manager Event", {
title: testEventTitle,
category: testCategoryName,
host: testHostName,
start_date: startDate,
route: testEventRoute,
is_published: 1,
start_time: "09:00:00",
end_time: "17:00:00",
medium: "In Person",
});
console.log(`Created Pohodex Event Manager Event: ${event.name} (route: ${testEventRoute})`);
// Create Event Ticket Type
const ticketType = await createDoc<NamedDoc>(request, "Event Ticket Type", {
event: event.name,
title: "Standard Ticket",
price: 500,
currency: "INR",
is_published: 1,
});
console.log(`Created Event Ticket Type: ${ticketType.name}`);
// Create Ticket Add-on
const addOn = await createDoc<NamedDoc>(request, "Ticket Add-on", {
event: event.name,
title: "Event T-Shirt",
price: 200,
currency: "INR",
enabled: 1,
});
console.log(`Created Ticket Add-on: ${addOn.name}`);
console.log(`Test event setup complete! Route: /dashboard/book-tickets/${testEventRoute}`);
});
+102
View File
@@ -0,0 +1,102 @@
import { test, expect } from "@playwright/test";
import { BookingPage } from "../pages";
// Unique suffix per run to avoid rate limits
const uid = Date.now();
test.describe("Guest Booking UX", () => {
test("guest details auto-fill to Attendee 1", async ({ page }) => {
const bookingPage = new BookingPage(page);
await bookingPage.goto("guest-no-otp-e2e");
await bookingPage.waitForFormLoad();
await page.locator('input[placeholder="Enter your first name"]').fill("Test");
await page.locator('input[placeholder="Enter your last name"]').fill("Guest");
await page.locator('input[placeholder="Enter your email"]').fill("test@example.com");
await page.locator('input[placeholder="Enter your email"]').blur();
await expect(page.locator('input[placeholder="Enter first name"]').first()).toHaveValue("Test");
await expect(page.locator('input[placeholder="Enter last name"]').first()).toHaveValue("Guest");
await expect(page.locator('input[placeholder="Enter email address"]').first()).toHaveValue("test@example.com");
});
});
test.describe("Guest Booking", () => {
test("guest booking without OTP", async ({ page }) => {
const email = `guest-no-otp-${uid}@test.com`;
const bookingPage = new BookingPage(page);
await bookingPage.goto("guest-no-otp-e2e");
await bookingPage.waitForFormLoad();
await page.locator('input[placeholder="Enter your first name"]').fill("Test");
await page.locator('input[placeholder="Enter your last name"]').fill("Guest");
await page.locator('input[placeholder="Enter your email"]').fill(email);
await page.locator('input[placeholder="Enter your email"]').blur();
await bookingPage.submit();
await expect(page.getByText("Booking Confirmed!")).toBeVisible({ timeout: 30000 });
});
test("guest booking with Email OTP", async ({ page }) => {
const email = `guest-email-otp-${uid}@test.com`;
const bookingPage = new BookingPage(page);
await bookingPage.goto("guest-email-otp-e2e");
await bookingPage.waitForFormLoad();
await page.locator('input[placeholder="Enter your first name"]').fill("Test");
await page.locator('input[placeholder="Enter your last name"]').fill("Guest Email");
await page.locator('input[placeholder="Enter your email"]').fill(email);
await page.locator('input[placeholder="Enter your email"]').blur();
const otpResponsePromise = page.waitForResponse(
(resp) => resp.url().includes("send_guest_booking_otp") && resp.status() === 200,
);
await bookingPage.submit();
const otpResponse = await otpResponsePromise;
const otpData = (await otpResponse.json()) as { message?: { otp?: string } };
const otp = otpData.message?.otp;
expect(otp).toBeTruthy();
await expect(page.getByText("Verify Your Email")).toBeVisible({ timeout: 10000 });
await page.locator('input[placeholder="123456"]').fill(otp!);
await page.getByRole("button", { name: "Verify & Book" }).click();
await expect(page.getByText("Booking Confirmed!")).toBeVisible({ timeout: 30000 });
});
test("guest booking with Phone OTP", async ({ page }) => {
const email = `guest-phone-otp-${uid}@test.com`;
const phone = `9${uid.toString().slice(-9)}`;
const bookingPage = new BookingPage(page);
await bookingPage.goto("guest-phone-otp-e2e");
await bookingPage.waitForFormLoad();
await page.locator('input[placeholder="Enter your first name"]').fill("Test");
await page.locator('input[placeholder="Enter your last name"]').fill("Guest Phone");
await page.locator('input[placeholder="Enter your email"]').fill(email);
await page.locator('input[placeholder="Enter your email"]').blur(); // triggers auto-fill
await page.locator('input[placeholder="Enter your phone number"]').fill(phone);
const otpResponsePromise = page.waitForResponse(
(resp) => resp.url().includes("send_guest_booking_otp") && resp.status() === 200,
);
await bookingPage.submit();
const otpResponse = await otpResponsePromise;
const otpData = (await otpResponse.json()) as { message?: { otp?: string } };
const otp = otpData.message?.otp;
expect(otp).toBeTruthy();
await expect(page.getByText("Verify Your Phone")).toBeVisible({ timeout: 10000 });
await page.locator('input[placeholder="123456"]').fill(otp!);
await page.getByRole("button", { name: "Verify & Book" }).click();
await expect(page.getByText("Booking Confirmed!")).toBeVisible({ timeout: 30000 });
});
});
+144
View File
@@ -0,0 +1,144 @@
import { test as setup } from "@playwright/test";
import { callMethod, createDoc, docExists, getList } from "../helpers/frappe";
interface NamedDoc {
name: string;
}
const testCategoryName = "E2E Test Category";
const testHostName = "E2E Test Host";
const guestEvents = [
{
title: "E2E Guest No OTP",
route: "guest-no-otp-e2e",
guest_verification_method: "None",
},
{
title: "E2E Guest Email OTP",
route: "guest-email-otp-e2e",
guest_verification_method: "Email OTP",
},
{
title: "E2E Guest Phone OTP",
route: "guest-phone-otp-e2e",
guest_verification_method: "Phone OTP",
},
];
/**
* Cancel and delete a document. Submittable docs (Event Booking, Event Ticket)
* must be cancelled (docstatus=2) before they can be deleted.
*/
async function forceCleanup(
request: Parameters<typeof callMethod>[0],
doctype: string,
name: string,
): Promise<void> {
try {
await callMethod(request, "frappe.client.cancel", { doctype, name });
} catch {
// Not submittable or already cancelled — ignore
}
await callMethod(request, "frappe.client.delete", { doctype, name });
}
setup("create guest booking test events", async ({ request }) => {
// Clean up existing guest test events by route (unique constraint).
// Must delete in order: tickets → bookings → ticket types → events
// because Frappe blocks deletion of documents with linked records.
for (const evt of guestEvents) {
try {
const events = await getList<NamedDoc>(request, "Pohodex Event Manager Event", {
filters: { route: evt.route },
});
if (!events.length) continue;
for (const existing of events) {
// Delete all linked docs in dependency order.
// Tickets and bookings are submittable (cancel before delete).
const linkedDoctypes = [
{ doctype: "Event Ticket", submittable: true },
{ doctype: "Event Booking", submittable: true },
{ doctype: "Sponsorship Tier", submittable: false },
{ doctype: "Event Ticket Type", submittable: false },
{ doctype: "Ticket Add-on", submittable: false },
];
for (const { doctype, submittable } of linkedDoctypes) {
const docs = await getList<NamedDoc>(request, doctype, {
filters: { event: existing.name },
}).catch(() => [] as NamedDoc[]);
for (const doc of docs) {
if (submittable) {
await forceCleanup(request, doctype, doc.name).catch(() => {});
} else {
await callMethod(request, "frappe.client.delete", {
doctype,
name: doc.name,
}).catch(() => {});
}
}
}
// Delete the event itself
await callMethod(request, "frappe.client.delete", {
doctype: "Pohodex Event Manager Event",
name: existing.name,
}).catch((e) => console.log(`Cleanup event ${existing.name}: ${e}`));
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`Cleanup: ${evt.title} - ${message}`);
}
}
// Ensure category and host exist (shared with event.setup.ts)
if (!(await docExists(request, "Event Category", testCategoryName))) {
await createDoc(request, "Event Category", {
name: testCategoryName,
enabled: 1,
slug: "e2e-test-category",
});
}
if (!(await docExists(request, "Event Host", testHostName))) {
await createDoc(request, "Event Host", {
name: testHostName,
});
}
const futureDate = new Date();
futureDate.setMonth(futureDate.getMonth() + 1);
const startDate = futureDate.toISOString().split("T")[0];
// Create each guest test event with a free ticket type
for (const evt of guestEvents) {
const event = await createDoc<NamedDoc>(request, "Pohodex Event Manager Event", {
title: evt.title,
category: testCategoryName,
host: testHostName,
start_date: startDate,
start_time: "09:00:00",
end_time: "17:00:00",
route: evt.route,
is_published: 1,
medium: "In Person",
allow_guest_booking: 1,
guest_verification_method: evt.guest_verification_method,
});
await createDoc<NamedDoc>(request, "Event Ticket Type", {
event: event.name,
title: "Free Ticket",
price: 0,
currency: "INR",
is_published: 1,
});
console.log(`Created: ${evt.title} (route: ${evt.route}, method: ${evt.guest_verification_method})`);
}
console.log("Guest event setup complete!");
});
+35
View File
@@ -0,0 +1,35 @@
import { test, expect } from "@playwright/test";
const FRAPPE_USER = process.env.FRAPPE_USER || "Administrator";
const FRAPPE_PASSWORD = process.env.FRAPPE_PASSWORD || "admin";
test.describe("Login Modal", () => {
test.use({ storageState: { cookies: [], origins: [] } });
test("email/password login via modal", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Log In" }).first().click();
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByText("Login to Continue")).toBeVisible();
await page.getByLabel("Email").fill(FRAPPE_USER);
await page.getByLabel("Password").fill(FRAPPE_PASSWORD);
await page.getByRole("button", { name: "Login", exact: true }).click();
await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 10000 });
await expect(page.getByRole("button", { name: "Log In" })).not.toBeVisible();
});
test("shows error for invalid credentials", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Log In" }).first().click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.getByLabel("Email").fill("wrong@example.com");
await page.getByLabel("Password").fill("wrongpassword");
await page.getByRole("button", { name: "Login", exact: true }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.locator(".bg-surface-red-2")).toBeVisible();
});
});
+127
View File
@@ -0,0 +1,127 @@
import { test as setup } from "@playwright/test";
import { callMethod, createDoc, docExists, getList } from "../helpers/frappe";
interface NamedDoc {
name: string;
}
const testCategoryName = "E2E Test Category";
const testHostName = "E2E Test Host";
const offlinePaymentEvent = {
title: "E2E Offline Payment",
route: "offline-payment-e2e",
};
async function forceCleanup(
request: Parameters<typeof callMethod>[0],
doctype: string,
name: string,
): Promise<void> {
try {
await callMethod(request, "frappe.client.cancel", { doctype, name });
} catch {
// Not submittable or already cancelled
}
await callMethod(request, "frappe.client.delete", { doctype, name });
}
setup("create offline payment test event", async ({ request }) => {
// Clean up existing test event - retry if needed
for (let attempt = 0; attempt < 2; attempt++) {
try {
const events = await getList<NamedDoc>(request, "Pohodex Event Manager Event", {
filters: { route: offlinePaymentEvent.route },
});
if (events.length === 0) break;
for (const existing of events) {
const linkedDoctypes = [
{ doctype: "Event Ticket", submittable: true },
{ doctype: "Event Booking", submittable: true },
{ doctype: "Sponsorship Tier", submittable: false },
{ doctype: "Event Ticket Type", submittable: false },
{ doctype: "Ticket Add-on", submittable: false },
{ doctype: "Offline Payment Method", submittable: false },
];
for (const { doctype, submittable } of linkedDoctypes) {
const docs = await getList<NamedDoc>(request, doctype, {
filters: { event: existing.name },
}).catch(() => [] as NamedDoc[]);
for (const doc of docs) {
if (submittable) {
await forceCleanup(request, doctype, doc.name).catch(() => {});
} else {
await callMethod(request, "frappe.client.delete", {
doctype,
name: doc.name,
}).catch(() => {});
}
}
}
await callMethod(request, "frappe.client.delete", {
doctype: "Pohodex Event Manager Event",
name: existing.name,
}).catch((e) => console.log(`Failed to delete event: ${e}`));
}
} catch (error) {
console.log(`Cleanup attempt ${attempt + 1}: ${error}`);
}
}
// Ensure category and host exist
if (!(await docExists(request, "Event Category", testCategoryName))) {
await createDoc(request, "Event Category", {
name: testCategoryName,
enabled: 1,
slug: "e2e-test-category",
});
}
if (!(await docExists(request, "Event Host", testHostName))) {
await createDoc(request, "Event Host", {
name: testHostName,
});
}
const futureDate = new Date();
futureDate.setMonth(futureDate.getMonth() + 1);
const startDate = futureDate.toISOString().split("T")[0];
// Create event
const event = await createDoc<NamedDoc>(request, "Pohodex Event Manager Event", {
title: offlinePaymentEvent.title,
category: testCategoryName,
host: testHostName,
start_date: startDate,
start_time: "09:00:00",
end_time: "17:00:00",
route: offlinePaymentEvent.route,
is_published: 1,
medium: "In Person",
});
// Create offline payment method
await createDoc<NamedDoc>(request, "Offline Payment Method", {
event: event.name,
title: "Bank Transfer",
enabled: 1,
description: "<p>Transfer to Account: 123456789</p>",
});
// Create paid ticket type
await createDoc<NamedDoc>(request, "Event Ticket Type", {
event: event.name,
title: "Standard Ticket",
price: 500,
currency: "INR",
is_published: 1,
});
console.log(`Created: ${offlinePaymentEvent.title} (route: ${offlinePaymentEvent.route})`);
console.log("Offline payment event setup complete!");
});
+86
View File
@@ -0,0 +1,86 @@
import { test, expect } from "@playwright/test";
import { BookingPage } from "../pages";
const uid = Date.now();
test.describe("Offline Payment Flow", () => {
test("complete booking with offline payment", async ({ page }) => {
const email = `offline-${uid}@test.com`;
const bookingPage = new BookingPage(page);
await bookingPage.goto("offline-payment-e2e");
await bookingPage.waitForFormLoad();
// Fill attendee details
await page.locator('input[placeholder="Enter first name"]').first().fill("Test");
await page.locator('input[placeholder="Enter last name"]').first().fill("User");
await page.locator('input[placeholder="Enter email address"]').first().fill(email);
// Submit booking form
await bookingPage.submit();
// Wait for offline payment dialog
await expect(page.getByText("Bank Transfer")).toBeVisible({ timeout: 10000 });
await expect(page.getByText("Transfer to Account: 123456789")).toBeVisible();
// Submit offline payment (no file upload required in test setup)
const submitButton = page.getByRole("button", { name: "Submit" });
await submitButton.click();
// Verify booking created with verification pending status
await expect(page.getByText("Payment Confirmation Pending")).toBeVisible({ timeout: 30000 });
});
test("offline payment dialog shows amount", async ({ page }) => {
const bookingPage = new BookingPage(page);
await bookingPage.goto("offline-payment-e2e");
await bookingPage.waitForFormLoad();
await page.locator('input[placeholder="Enter first name"]').first().fill("Amount");
await page.locator('input[placeholder="Enter last name"]').first().fill("Test");
await page.locator('input[placeholder="Enter email address"]').first().fill(`amount-${uid}@test.com`);
await bookingPage.submit();
// Wait for dialog and verify amount is displayed in dialog
const dialog = page.getByRole("dialog");
await expect(dialog.getByText("₹500.00", { exact: true })).toBeVisible({ timeout: 10000 });
});
test("can cancel offline payment dialog", async ({ page }) => {
const bookingPage = new BookingPage(page);
await bookingPage.goto("offline-payment-e2e");
await bookingPage.waitForFormLoad();
await page.locator('input[placeholder="Enter first name"]').first().fill("Cancel");
await page.locator('input[placeholder="Enter last name"]').first().fill("Test");
await page.locator('input[placeholder="Enter email address"]').first().fill(`cancel-${uid}@test.com`);
await bookingPage.submit();
await expect(page.getByText("Bank Transfer")).toBeVisible({ timeout: 10000 });
// Click cancel
await page.getByRole("button", { name: "Cancel" }).click();
// Dialog should close
await expect(page.getByText("Bank Transfer")).not.toBeVisible({ timeout: 5000 });
});
});
test.describe("Booking Details - Offline Payment", () => {
test("shows verification pending status", async ({ page }) => {
// This test assumes a booking already exists
// Navigate to bookings list
await page.goto("/dashboard/bookings");
await page.waitForLoadState("networkidle");
// Look for verification pending badge
const pendingBadge = page.locator('text=/Verification Pending|Approval Pending/i').first();
if (await pendingBadge.isVisible({ timeout: 5000 })) {
await expect(pendingBadge).toBeVisible();
}
});
});
+139
View File
@@ -0,0 +1,139 @@
import { test, expect } from "@playwright/test";
import { BookingPage } from "../pages";
import { updateDoc, getList } from "../helpers/frappe";
interface NamedDoc {
name: string;
}
test.describe("Tax Inclusive Pricing", () => {
const testEventRoute = "test-event-e2e";
test("should show tax as inclusive with correct amounts", async ({ page, request }) => {
// Find the test event
const events = await getList<NamedDoc>(request, "Pohodex Event Manager Event", {
filters: { route: ["=", testEventRoute] },
});
expect(events.length).toBeGreaterThan(0);
const eventName = events[0].name;
// Enable tax-inclusive pricing on the event
await updateDoc(request, "Pohodex Event Manager Event", eventName, {
apply_tax: 1,
tax_inclusive: 1,
tax_label: "GST",
tax_percentage: 18,
});
const bookingPage = new BookingPage(page);
await bookingPage.goto(testEventRoute);
await bookingPage.waitForFormLoad();
// Fill attendee details to trigger summary
await bookingPage.fillAttendeeDetails("Tax Test User", "taxtest@example.com");
// Wait for the booking summary to render
const summarySection = page.locator("text=Booking Summary");
await expect(summarySection).toBeVisible({ timeout: 10000 });
// Subtotal should be hidden for tax-inclusive (no discount case)
const subtotalLabel = page.locator("span:text-is('Subtotal')");
await expect(subtotalLabel).not.toBeVisible();
// Tax should NOT appear as a separate line item
const taxLineItem = page.locator("text=/GST.*18.*%/").first();
// The "Inclusive of" note contains GST 18%, but there should be no standalone tax row
const separateTaxRow = page.locator(".flex.justify-between:has(span:text-is('GST (18%)'))");
await expect(separateTaxRow).not.toBeVisible();
// Verify the "Inclusive of" note appears below the total
const inclusiveNote = page.locator("text=/Inclusive of/");
await expect(inclusiveNote).toBeVisible({ timeout: 5000 });
// Verify the note contains GST and 18%
const noteText = await inclusiveNote.textContent();
expect(noteText).toContain("GST");
expect(noteText).toContain("18%");
// Verify the total shows the ticket price (unchanged by tax)
const totalHeading = page.locator("h3:has-text('Total')");
const totalContainer = totalHeading.locator("..");
const totalText = await totalContainer.textContent();
const totalMatch = totalText?.match(/[\d,]+/);
expect(totalMatch).toBeTruthy();
expect(totalMatch![0]).toBe("500");
console.log(`Tax inclusive test passed: Total=500, note="${noteText?.trim()}"`);
});
test("should show tax as exclusive with increased total", async ({ page, request }) => {
// Find the test event
const events = await getList<NamedDoc>(request, "Pohodex Event Manager Event", {
filters: { route: ["=", testEventRoute] },
});
expect(events.length).toBeGreaterThan(0);
const eventName = events[0].name;
// Enable tax-exclusive pricing on the event
await updateDoc(request, "Pohodex Event Manager Event", eventName, {
apply_tax: 1,
tax_inclusive: 0,
tax_label: "GST",
tax_percentage: 18,
});
const bookingPage = new BookingPage(page);
await bookingPage.goto(testEventRoute);
await bookingPage.waitForFormLoad();
// Fill attendee details to trigger summary
await bookingPage.fillAttendeeDetails("Tax Test User", "taxtest@example.com");
// Wait for the booking summary to render
const summarySection = page.locator("text=Booking Summary");
await expect(summarySection).toBeVisible({ timeout: 10000 });
// Verify tax label does NOT show "Incl."
const taxLineInclusive = page.locator("text=/GST.*18.*%.*Incl/");
await expect(taxLineInclusive).not.toBeVisible();
// Verify tax line exists without "Incl."
const taxLine = page.locator("text=/GST.*18.*%/");
await expect(taxLine).toBeVisible({ timeout: 5000 });
// For exclusive tax: total should be greater than subtotal
const totalHeading = page.locator("h3:has-text('Total')");
const totalContainer = totalHeading.locator("..");
const totalText = await totalContainer.textContent();
const subtotalContainer = page.locator("span:text-is('Subtotal')").locator("..");
const subtotalText = await subtotalContainer.textContent();
const subtotalMatch = subtotalText?.match(/[\d,]+/);
const totalMatch = totalText?.match(/[\d,]+/);
expect(subtotalMatch).toBeTruthy();
expect(totalMatch).toBeTruthy();
const subtotalNum = parseInt(subtotalMatch![0].replace(/,/g, ""));
const totalNum = parseInt(totalMatch![0].replace(/,/g, ""));
// Total should be greater than subtotal for exclusive tax
expect(totalNum).toBeGreaterThan(subtotalNum);
console.log(`Tax exclusive test passed: Subtotal=${subtotalNum}, Total=${totalNum}`);
});
// Clean up: disable tax after tests
test.afterAll(async ({ request }) => {
const events = await getList<NamedDoc>(request, "Pohodex Event Manager Event", {
filters: { route: ["=", testEventRoute] },
});
if (events.length > 0) {
await updateDoc(request, "Pohodex Event Manager Event", events[0].name, {
apply_tax: 0,
tax_inclusive: 0,
});
}
});
});
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@helpers/*": ["helpers/*"],
"@pages/*": ["pages/*"]
}
},
"include": [
"**/*.ts"
],
"exclude": [
"node_modules"
]
}