Initialize fork and rebrand app to event_manager
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./auth";
|
||||
export * from "./frappe";
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.*/);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(", ")})`);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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!");
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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!");
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user