test(e2e): add Playwright setup, personas and CI workflow

This commit is contained in:
Ritvik Sardana
2026-09-28 23:23:34 +05:30
parent 8650d04715
commit 75e698ea9b
13 changed files with 604 additions and 2 deletions

149
.github/workflows/ui-tests.yml vendored Normal file
View File

@@ -0,0 +1,149 @@
name: UI Tests
on:
pull_request:
branches:
- develop
paths:
- "desk/**"
- "e2e/**"
- "playwright.config.ts"
- "package.json"
- "yarn.lock"
- ".github/workflows/ui-tests.yml"
workflow_dispatch:
concurrency:
group: ui-tests-helpdesk-${{ github.event.number || github.ref }}
cancel-in-progress: true
env:
FRAPPE_BRANCH: develop
SITE: helpdesk.test
jobs:
ui-tests:
name: Playwright E2E Tests
runs-on: ubuntu-latest
timeout-minutes: 60
services:
redis-cache:
image: redis:alpine
ports:
- 13000:6379
redis-queue:
image: redis:alpine
ports:
- 11000:6379
mariadb:
image: mariadb:10.6
env:
MARIADB_ROOT_PASSWORD: root
ports:
- 3306:3306
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
steps:
- name: Clone
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
check-latest: true
- name: Add to Hosts
run: echo "127.0.0.1 $SITE" | sudo tee -a /etc/hosts
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}
restore-keys: ${{ runner.os }}-pip-
- name: Cache yarn
uses: actions/cache@v4
with:
path: ~/.cache/yarn
key: ${{ runner.os }}-yarn-ui-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-ui-
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
- name: Install MariaDB Client
run: |
sudo apt update
sudo apt install -y mariadb-client
- name: Setup Bench
run: |
pip install frappe-bench
bench init --skip-redis-config-generation --skip-assets --frappe-branch "$FRAPPE_BRANCH" --python "$(which python)" ~/frappe-bench
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
- name: Install Helpdesk
working-directory: /home/runner/frappe-bench
run: |
bench get-app telephony
bench get-app helpdesk $GITHUB_WORKSPACE
bench setup requirements --dev
bench new-site --db-root-password root --admin-password admin --install-app helpdesk $SITE
bench --site $SITE set-config host_name "http://$SITE:8000"
bench build --app helpdesk
env:
CI: "Yes"
- name: Start Frappe Server
working-directory: /home/runner/frappe-bench
run: |
sed -i 's/^watch:/# watch:/g' Procfile
sed -i 's/^schedule:/# schedule:/g' Procfile
bench start &> bench_start.log &
timeout 120 bash -c "until curl -s http://$SITE:8000 > /dev/null; do sleep 2; done"
- name: Install Playwright
run: |
yarn install --frozen-lockfile --ignore-scripts
npx playwright install --with-deps chromium
- name: Run Playwright Tests
run: npx playwright test
env:
BASE_URL: http://${{ env.SITE }}:8000
ADMIN_PASSWORD: admin
- name: Upload Playwright Report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results
path: test-results/
retention-days: 7
- name: Show Bench Logs on Failure
if: failure()
working-directory: /home/runner/frappe-bench
run: |
cat bench_start.log || true
cat logs/*.log || true

6
.gitignore vendored
View File

@@ -42,4 +42,8 @@ helpdesk/public/frontend
helpdesk/public/node_modules
helpdesk/www/helpdesk/index.html
desk/components.d.ts
desk/stats.html
desk/stats.html
# Playwright
e2e/.auth
playwright-report
test-results

39
e2e/global.setup.ts Normal file
View File

@@ -0,0 +1,39 @@
import { expect, test as setup } from "@playwright/test";
import { Api } from "./support/api";
import { seedSite } from "./support/seed";
import { siteHeader } from "./support/site";
import {
PASSWORD,
emailOf,
storageStateOf,
type PersonaKey,
} from "./support/personas";
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "admin";
const PERSONAS: PersonaKey[] = [
"admin",
"manager",
"agent",
"agent2",
"customer",
"customerManager",
];
setup("seed site and log every persona in", async ({ browser, baseURL }) => {
const admin = await Api.login(baseURL!, "Administrator", ADMIN_PASSWORD);
await seedSite(admin);
for (const key of PERSONAS) {
const context = await browser.newContext({
baseURL,
extraHTTPHeaders: siteHeader(),
});
const password = key === "admin" ? ADMIN_PASSWORD : PASSWORD;
const response = await context.request.post("/api/method/login", {
form: { usr: emailOf(key), pwd: password },
});
expect(response.ok(), `login as ${key}`).toBeTruthy();
await context.storageState({ path: storageStateOf(key) });
await context.close();
}
});

46
e2e/specs/auth.spec.ts Normal file
View File

@@ -0,0 +1,46 @@
import { expect, test, usePersona } from "../support/fixtures";
import { raiseTicket } from "../support/factories";
import { PASSWORD, personas } from "../support/personas";
test.describe("logged out", () => {
test.use({ storageState: { cookies: [], origins: [] } });
test("login returns to the requested page, then logs out", async ({ page }) => {
await page.goto("/helpdesk/tickets");
await expect(page).toHaveURL(/\/login\?redirect-to=\/helpdesk\/tickets/);
await page.getByRole("textbox", { name: "Email" }).fill(personas.agent.email);
await page.getByRole("textbox", { name: "Password" }).fill(PASSWORD);
await page.getByRole("button", { name: "Continue" }).click();
await expect(page).toHaveURL(/\/helpdesk\/tickets$/);
await expect(page.getByRole("button", { name: "Filter" })).toBeVisible();
await page.getByRole("button", { name: /^Helpdesk / }).click();
await page.getByRole("menuitem", { name: "Log out" }).click();
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("agent", () => {
usePersona("agent");
test("lands on home", async ({ page }) => {
await page.goto("/helpdesk");
await expect(page).toHaveURL(/\/helpdesk\/home$/);
});
});
test.describe("customer", () => {
usePersona("customer");
test("lands on the portal and is kept out of agent routes", async ({ page, apiAs }) => {
await page.goto("/helpdesk");
await expect(page).toHaveURL(/\/helpdesk\/my-tickets$/);
await expect(page.getByRole("button", { name: "Customers" })).toHaveCount(0);
const ticket = await raiseTicket(await apiAs("customer"));
await page.goto(`/helpdesk/tickets/${ticket.name}`);
await expect(page).toHaveURL(new RegExp(`/helpdesk/my-tickets/${ticket.name}$`));
});
});

81
e2e/support/api.ts Normal file
View File

@@ -0,0 +1,81 @@
import { request, type APIRequestContext } from "@playwright/test";
import { siteHeader } from "./site";
type Doc = Record<string, any>;
/**
* Thin Frappe REST client bound to one logged-in user.
* The session never renders a page, so Frappe issues it no CSRF token and
* unsafe requests go through without one.
*/
export class Api {
constructor(private context: APIRequestContext) {}
static async login(baseURL: string, user: string, password: string) {
const context = await request.newContext({
baseURL,
extraHTTPHeaders: siteHeader(),
});
const response = await context.post("/api/method/login", {
form: { usr: user, pwd: password },
});
if (!response.ok()) throw new Error(`Login failed for ${user}`);
return new Api(context);
}
async call<T = any>(method: string, args: Doc = {}): Promise<T> {
const body = await this.send("post", `/api/method/${method}`, args);
return body.message;
}
async get<T = Doc>(doctype: string, name: string): Promise<T> {
return (await this.send("get", this.path(doctype, name))).data;
}
async insert<T = Doc>(doctype: string, doc: Doc): Promise<T> {
return (await this.send("post", this.path(doctype), doc)).data;
}
async update<T = Doc>(doctype: string, name: string, doc: Doc): Promise<T> {
return (await this.send("put", this.path(doctype, name), doc)).data;
}
async delete(doctype: string, name: string) {
await this.send("delete", this.path(doctype, name));
}
async exists(doctype: string, filters: Doc): Promise<string | undefined> {
const rows = await this.list(doctype, { filters, limit: 1 });
return rows[0]?.name;
}
async list<T = Doc>(
doctype: string,
options: { fields?: string[]; filters?: Doc; limit?: number } = {}
): Promise<T[]> {
const params = new URLSearchParams({
fields: JSON.stringify(options.fields || ["name"]),
filters: JSON.stringify(options.filters || {}),
limit_page_length: String(options.limit || 0),
});
return (await this.send("get", `${this.path(doctype)}?${params}`)).data;
}
/** Raw request for tests that assert on failures (permission checks). */
raw(method: string, args: Doc = {}) {
return this.context.post(`/api/method/${method}`, { data: args });
}
private path(doctype: string, name?: string) {
const base = `/api/resource/${encodeURIComponent(doctype)}`;
return name ? `${base}/${encodeURIComponent(name)}` : base;
}
private async send(verb: "get" | "post" | "put" | "delete", url: string, data?: Doc) {
const response = await this.context[verb](url, data ? { data } : {});
if (!response.ok()) {
throw new Error(`${verb.toUpperCase()} ${url}: ${await response.text()}`);
}
return response.json();
}
}

9
e2e/support/factories.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { Api } from "./api";
import { uid } from "./fixtures";
/** Raise a ticket the way the portal does, so the customer owns it. */
export async function raiseTicket(customer: Api, subject = `E2E ticket ${uid()}`) {
return customer.call("helpdesk.helpdesk.doctype.hd_ticket.api.new", {
doc: { subject, description: `<p>${subject} description</p>` },
});
}

56
e2e/support/fixtures.ts Normal file
View File

@@ -0,0 +1,56 @@
import { test as base, expect, type Browser, type Page } from "@playwright/test";
import { Api } from "./api";
import {
PASSWORD,
emailOf,
storageStateOf,
type PersonaKey,
} from "./personas";
import { siteHeader } from "./site";
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "admin";
interface Fixtures {
/** REST client logged in as Administrator, for seeding and DB assertions. */
api: Api;
/** REST client for any persona, e.g. to act as the customer. */
apiAs: (key: PersonaKey) => Promise<Api>;
/** A second browser page logged in as another persona. */
pageAs: (key: PersonaKey) => Promise<Page>;
}
/** Log a spec file's `page` in as one persona. */
export function usePersona(key: PersonaKey) {
test.use({ storageState: storageStateOf(key) });
}
export const test = base.extend<Fixtures>({
api: async ({ baseURL }, use) => {
await use(await Api.login(baseURL!, "Administrator", ADMIN_PASSWORD));
},
apiAs: async ({ baseURL }, use) => {
await use((key) =>
Api.login(baseURL!, emailOf(key), key === "admin" ? ADMIN_PASSWORD : PASSWORD)
);
},
pageAs: async ({ browser, baseURL }, use) => {
const contexts: Awaited<ReturnType<Browser["newContext"]>>[] = [];
await use(async (key) => {
const context = await browser.newContext({
baseURL,
extraHTTPHeaders: siteHeader(),
storageState: storageStateOf(key),
});
contexts.push(context);
return context.newPage();
});
await Promise.all(contexts.map((context) => context.close()));
},
});
export { expect };
/** Short unique suffix so specs never collide with each other or old runs. */
export function uid() {
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
}

110
e2e/support/personas.ts Normal file
View File

@@ -0,0 +1,110 @@
import type { Api } from "./api";
export const PASSWORD = "E2e-Helpdesk-2026!";
export const CUSTOMER_ORG = "E2E Customer Org";
export type PersonaKey =
| "admin"
| "manager"
| "agent"
| "agent2"
| "customer"
| "customerManager";
export interface Persona {
email: string;
firstName: string;
roles: string[];
kind: "agent" | "customer";
}
export const personas: Record<Exclude<PersonaKey, "admin">, Persona> = {
manager: agent("manager", "Mona", ["Agent", "Agent Manager"]),
agent: agent("agent", "Arjun", ["Agent"]),
agent2: agent("agent2", "Bela", ["Agent"]),
customer: customer("customer", "Cora", ["HD Customer"]),
customerManager: customer("customer-manager", "Dev", [
"HD Customer",
"HD Customer Manager",
]),
};
export function emailOf(key: PersonaKey) {
return key === "admin" ? "Administrator" : personas[key].email;
}
export function storageStateOf(key: PersonaKey) {
return `e2e/.auth/${key}.json`;
}
/** Idempotently create every persona with its roles, HD Agent or Contact. */
export async function seedPersonas(api: Api) {
for (const persona of Object.values(personas)) {
await ensureUser(api, persona);
if (persona.kind === "agent") await ensureAgent(api, persona);
else await ensureContact(api, persona);
}
await ensureCustomerOrg(api);
}
function agent(slug: string, firstName: string, roles: string[]): Persona {
return { email: `e2e-${slug}@example.com`, firstName, roles, kind: "agent" };
}
function customer(slug: string, firstName: string, roles: string[]): Persona {
return { email: `e2e-${slug}@example.com`, firstName, roles, kind: "customer" };
}
async function ensureUser(api: Api, persona: Persona) {
const roles = persona.roles.map((role) => ({ role }));
const fields = { roles, new_password: PASSWORD, enabled: 1 };
if (await api.exists("User", { name: persona.email })) {
await api.update("User", persona.email, fields);
return;
}
await api.insert("User", {
email: persona.email,
first_name: persona.firstName,
last_name: "E2E",
send_welcome_email: 0,
user_type: persona.kind === "agent" ? "System User" : "Website User",
...fields,
});
}
async function ensureAgent(api: Api, persona: Persona) {
const name = await api.exists("HD Agent", { user: persona.email });
if (name) return api.update("HD Agent", name, { is_active: 1 });
await api.insert("HD Agent", {
user: persona.email,
agent_name: `${persona.firstName} E2E`,
is_active: 1,
});
}
async function ensureContact(api: Api, persona: Persona) {
if (await api.exists("Contact", { email_id: persona.email })) return;
await api.insert("Contact", {
first_name: persona.firstName,
last_name: "E2E",
email_id: persona.email,
user: persona.email,
email_ids: [{ email_id: persona.email, is_primary: 1 }],
});
}
async function ensureCustomerOrg(api: Api) {
const contacts = await Promise.all(
[personas.customer, personas.customerManager].map((persona) =>
api.exists("Contact", { email_id: persona.email })
)
);
const rows = contacts.map((contact_name, index) => ({
contact_name,
is_manager: index === 1 ? 1 : 0,
}));
if (await api.exists("HD Customer", { name: CUSTOMER_ORG })) {
return api.update("HD Customer", CUSTOMER_ORG, { contacts: rows });
}
await api.insert("HD Customer", { customer_name: CUSTOMER_ORG, contacts: rows });
}

29
e2e/support/seed.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { Api } from "./api";
import { seedPersonas } from "./personas";
export const OUTGOING_EMAIL = "e2e-support@example.com";
/** Baseline every spec relies on; safe to run on an already seeded site. */
export async function seedSite(api: Api) {
await seedPersonas(api);
await ensureOutgoingEmailAccount(api);
await api.update("HD Settings", "HD Settings", {
persona_captured: 1,
setup_complete: 1,
});
}
// SMTP is never reached: without a password Frappe skips the connection check
// and queued mails just sit in the Email Queue.
async function ensureOutgoingEmailAccount(api: Api) {
if (await api.exists("Email Account", { email_id: OUTGOING_EMAIL })) return;
await api.insert("Email Account", {
email_account_name: "E2E Support",
email_id: OUTGOING_EMAIL,
smtp_server: "smtp.example.invalid",
no_smtp_authentication: 1,
enable_outgoing: 1,
default_outgoing: 1,
enable_incoming: 0,
});
}

5
e2e/support/site.ts Normal file
View File

@@ -0,0 +1,5 @@
/** Lets a local run target one bench site without an /etc/hosts entry. */
export function siteHeader(): Record<string, string> | undefined {
const site = process.env.SITE_NAME;
return site ? { "X-Frappe-Site-Name": site } : undefined;
}

View File

@@ -3,7 +3,9 @@
"scripts": {
"postinstall": "cd desk && yarn install",
"dev": "cd desk && yarn dev",
"build": "cd desk && yarn build"
"build": "cd desk && yarn build",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"resolutions": {
"cheerio": "1.0.0-rc.12",
@@ -11,5 +13,9 @@
"prosemirror-state": "1.4.4",
"prosemirror-view": "1.41.9",
"prosemirror-transform": "1.12.0"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"@types/node": "^26.6.3"
}
}

37
playwright.config.ts Normal file
View File

@@ -0,0 +1,37 @@
import { defineConfig, devices } from "@playwright/test";
import { siteHeader } from "./e2e/support/site";
/**
* E2E suite for the Helpdesk SPA, run against a real bench site.
* `global.setup.ts` seeds personas and saves one logged-in session per persona.
*/
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI
? [["github"], ["html", { open: "never" }]]
: "list",
timeout: 60_000,
expect: { timeout: 10_000 },
use: {
baseURL: process.env.BASE_URL || "http://helpdesk.test:8000",
extraHTTPHeaders: siteHeader(),
trace: "retain-on-failure",
screenshot: "only-on-failure",
actionTimeout: 15_000,
navigationTimeout: 30_000,
...devices["Desktop Chrome"],
viewport: { width: 1440, height: 900 },
},
projects: [
{ name: "setup", testMatch: /global\.setup\.ts/ },
{
name: "chromium",
testMatch: /.*\.spec\.ts/,
dependencies: ["setup"],
},
],
});

View File

@@ -1426,6 +1426,13 @@
resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda"
integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==
"@playwright/test@^1.63.0":
version "1.63.0"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.63.0.tgz#808020c46e8b6b91f9d628cd0ebcbb32bdfd8b6c"
integrity sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==
dependencies:
playwright "1.63.0"
"@polka/url@^1.0.0-next.24":
version "1.0.0-next.29"
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1"
@@ -2262,6 +2269,13 @@
dependencies:
undici-types "~7.18.0"
"@types/node@^26.6.3":
version "26.6.3"
resolved "https://registry.yarnpkg.com/@types/node/-/node-26.6.3.tgz#411dccb5fc25687c958fe35b6ca3741d48a8d13c"
integrity sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==
dependencies:
undici-types "~8.9.0"
"@types/resolve@1.17.1":
version "1.17.1"
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
@@ -5358,6 +5372,18 @@ pkg-types@^2.1.0, pkg-types@^2.3.0:
exsolve "^1.0.7"
pathe "^2.0.3"
playwright-core@1.63.0:
version "1.63.0"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.63.0.tgz#e57665bc32846c213ac39a1e4d5bc6228e76b376"
integrity sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==
playwright@1.63.0:
version "1.63.0"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.63.0.tgz#99b56f9f69b1b70c44f00bf84b2fe52348ae2511"
integrity sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==
dependencies:
playwright-core "1.63.0"
possible-typed-array-names@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae"
@@ -6672,6 +6698,11 @@ undici-types@~7.18.0:
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9"
integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==
undici-types@~8.9.0:
version "8.9.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.9.0.tgz#e240d97c8b5d85e5347ce73d25865c7906c1ec9f"
integrity sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==
unicode-canonical-property-names-ecmascript@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz#cb3173fe47ca743e228216e4a3ddc4c84d628cc2"