test(e2e): add Playwright setup, personas and CI workflow
This commit is contained in:
149
.github/workflows/ui-tests.yml
vendored
Normal file
149
.github/workflows/ui-tests.yml
vendored
Normal 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
6
.gitignore
vendored
@@ -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
39
e2e/global.setup.ts
Normal 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
46
e2e/specs/auth.spec.ts
Normal 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
81
e2e/support/api.ts
Normal 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
9
e2e/support/factories.ts
Normal 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
56
e2e/support/fixtures.ts
Normal 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
110
e2e/support/personas.ts
Normal 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
29
e2e/support/seed.ts
Normal 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
5
e2e/support/site.ts
Normal 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;
|
||||
}
|
||||
@@ -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
37
playwright.config.ts
Normal 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"],
|
||||
},
|
||||
],
|
||||
});
|
||||
31
yarn.lock
31
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user