snapshot of https://github.com/frappe/crm.git (develop, 2026-09-18; blobs > 131072B omitted)
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
This commit is contained in:
46
e2e/helpers/auth.ts
Normal file
46
e2e/helpers/auth.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { APIRequestContext, Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Login via the Frappe API (faster than UI login).
|
||||
* Sets session 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()}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout the current user.
|
||||
*/
|
||||
export async function logout(page: Page): Promise<void> {
|
||||
await page.goto('/api/method/logout')
|
||||
await page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is logged in by verifying the 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()
|
||||
return data.message && data.message !== 'Guest'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
68
e2e/helpers/crm.ts
Normal file
68
e2e/helpers/crm.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { APIRequestContext } from '@playwright/test'
|
||||
import { createDoc, deleteDoc, getList } from './frappe'
|
||||
|
||||
export const LEAD_DOCTYPE = 'CRM Lead'
|
||||
export const DEAL_DOCTYPE = 'CRM Deal'
|
||||
|
||||
export interface LeadData {
|
||||
first_name: string
|
||||
last_name?: string
|
||||
email?: string
|
||||
organization?: string
|
||||
mobile_no?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique suffix so parallel/repeat runs don't collide.
|
||||
*/
|
||||
export function uniqueSuffix(): string {
|
||||
return `${Date.now()}-${Math.floor(Math.random() * 1000)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a lead payload with unique, valid test data.
|
||||
*/
|
||||
export function buildLead(overrides: Partial<LeadData> = {}): LeadData {
|
||||
const id = uniqueSuffix()
|
||||
return {
|
||||
first_name: `E2E Lead ${id}`,
|
||||
email: `e2e-lead-${id}@example.com`,
|
||||
organization: `E2E Org ${id}`,
|
||||
mobile_no: '9999999999',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a lead via the API (used by tests that start from an existing lead).
|
||||
*/
|
||||
export async function seedLead(
|
||||
request: APIRequestContext,
|
||||
overrides: Partial<LeadData> = {},
|
||||
): Promise<{ name: string } & LeadData> {
|
||||
const data = buildLead(overrides)
|
||||
const doc = await createDoc<{ name: string }>(request, LEAD_DOCTYPE, data)
|
||||
return { ...data, name: doc.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every lead/deal created by E2E runs (matched by the e2e-* email).
|
||||
*/
|
||||
export async function cleanupE2ERecords(
|
||||
request: APIRequestContext,
|
||||
): Promise<void> {
|
||||
for (const doctype of [LEAD_DOCTYPE, DEAL_DOCTYPE]) {
|
||||
const rows = await getList<{ name: string }>(request, doctype, {
|
||||
fields: ['name'],
|
||||
filters: { email: ['like', 'e2e-%@example.com'] },
|
||||
limit: 500,
|
||||
})
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await deleteDoc(request, doctype, row.name)
|
||||
} catch {
|
||||
// best-effort cleanup; ignore already-deleted / linked rows
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
165
e2e/helpers/frappe.ts
Normal file
165
e2e/helpers/frappe.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import * as fs from 'fs'
|
||||
import { APIRequestContext } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Frappe API response wrapper.
|
||||
*/
|
||||
export interface FrappeResponse<T = unknown> {
|
||||
message?: T
|
||||
exc?: string
|
||||
exc_type?: string
|
||||
_server_messages?: string
|
||||
}
|
||||
|
||||
// CSRF token file saved by auth.setup.ts
|
||||
const CSRF_FILE = 'e2e/.auth/csrf.json'
|
||||
|
||||
let csrfTokenCache: string | null = null
|
||||
|
||||
/**
|
||||
* Read the CSRF token saved during auth setup (from window.frappe.csrf_token).
|
||||
*/
|
||||
function getCsrfToken(): string {
|
||||
if (csrfTokenCache !== null) {
|
||||
return csrfTokenCache
|
||||
}
|
||||
|
||||
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 ''
|
||||
}
|
||||
|
||||
function jsonHeaders(): Record<string, string> {
|
||||
const csrfToken = getCsrfToken()
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(csrfToken ? { 'X-Frappe-CSRF-Token': csrfToken } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a document via the Frappe REST API.
|
||||
*/
|
||||
export async function createDoc<T = Record<string, unknown>>(
|
||||
request: APIRequestContext,
|
||||
doctype: string,
|
||||
doc: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const response = await request.post(`/api/resource/${doctype}`, {
|
||||
data: doc,
|
||||
headers: jsonHeaders(),
|
||||
})
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Failed to create ${doctype}: ${await response.text()}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return result.data as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a document by name via the 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()) {
|
||||
throw new Error(
|
||||
`Failed to get ${doctype}/${name}: ${await response.text()}`,
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return result.data as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a document via the Frappe REST API.
|
||||
*/
|
||||
export async function deleteDoc(
|
||||
request: APIRequestContext,
|
||||
doctype: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const response = await request.delete(
|
||||
`/api/resource/${doctype}/${encodeURIComponent(name)}`,
|
||||
{ headers: jsonHeaders() },
|
||||
)
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`Failed to delete ${doctype}/${name}: ${await response.text()}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a Frappe whitelisted method.
|
||||
*/
|
||||
export async function callMethod<T = unknown>(
|
||||
request: APIRequestContext,
|
||||
method: string,
|
||||
args: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
const response = await request.post(`/api/method/${method}`, {
|
||||
data: args,
|
||||
headers: jsonHeaders(),
|
||||
})
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Failed to call ${method}: ${await response.text()}`)
|
||||
}
|
||||
|
||||
const result: FrappeResponse<T> = await response.json()
|
||||
return result.message as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of documents via the 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()) {
|
||||
throw new Error(
|
||||
`Failed to get list of ${doctype}: ${await response.text()}`,
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return result.data as T[]
|
||||
}
|
||||
3
e2e/helpers/index.ts
Normal file
3
e2e/helpers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './auth'
|
||||
export * from './frappe'
|
||||
export * from './crm'
|
||||
49
e2e/pages/deals.page.ts
Normal file
49
e2e/pages/deals.page.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Page, expect } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Deals list view (/crm/deals) and the "Create Deal" modal.
|
||||
*/
|
||||
export class DealsPage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/crm/deals')
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
async openCreateModal() {
|
||||
await this.page.getByRole('button', { name: 'Create', exact: true }).click()
|
||||
await expect(
|
||||
this.page.getByRole('heading', { name: 'Create Deal' }),
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the deal. "Primary email" identifies it later. Value and closure
|
||||
* date are only mandatory when a site customizes them (Property Setter), so
|
||||
* they are filled conditionally to stay portable across sites.
|
||||
*/
|
||||
async createDeal(email: string) {
|
||||
const dialog = this.page.getByRole('dialog')
|
||||
await dialog.getByPlaceholder('Primary email', { exact: true }).fill(email)
|
||||
await this.fillIfPresent(dialog, 'Expected Deal Value', '1000')
|
||||
await this.fillIfPresent(dialog, 'Expected Closure Date', '2026-12-31', true)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Create', exact: true }).click()
|
||||
await expect(
|
||||
this.page.getByRole('heading', { name: 'Create Deal' }),
|
||||
).toBeHidden()
|
||||
}
|
||||
|
||||
private async fillIfPresent(
|
||||
scope: ReturnType<Page['getByRole']>,
|
||||
placeholder: string,
|
||||
value: string,
|
||||
isDate = false,
|
||||
) {
|
||||
const field = scope.getByPlaceholder(placeholder, { exact: true })
|
||||
if (!(await field.count())) return
|
||||
await field.fill(value)
|
||||
if (isDate) await field.press('Enter')
|
||||
}
|
||||
}
|
||||
4
e2e/pages/index.ts
Normal file
4
e2e/pages/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './login.page'
|
||||
export * from './leads.page'
|
||||
export * from './lead.page'
|
||||
export * from './deals.page'
|
||||
59
e2e/pages/lead.page.ts
Normal file
59
e2e/pages/lead.page.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Page, expect } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Lead detail view (/crm/leads/:name): convert-to-deal and email compose.
|
||||
*/
|
||||
export class LeadPage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async goto(name: string) {
|
||||
await this.page.goto(`/crm/leads/${name}`)
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
/**
|
||||
* The email composer caches its draft (recipients/subject/body) in
|
||||
* localStorage. Clear it so the composer starts from this lead's own data,
|
||||
* keeping the flow deterministic across tests that share storage state.
|
||||
*/
|
||||
async clearComposerDraft() {
|
||||
await this.page.evaluate(() => window.localStorage.clear())
|
||||
await this.page.reload()
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
/** Open the Convert-to-Deal modal and confirm; resolves once it closes. */
|
||||
async convertToDeal() {
|
||||
await this.page.getByRole('button', { name: 'Convert to Deal' }).click()
|
||||
const dialog = this.page.getByRole('dialog')
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Convert to Deal' }),
|
||||
).toBeVisible()
|
||||
await dialog.getByRole('button', { name: 'Convert', exact: true }).click()
|
||||
// A successful conversion closes the modal; the created deal is then
|
||||
// asserted via the API (see convert.spec).
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Convert to Deal' }),
|
||||
).toBeHidden()
|
||||
}
|
||||
|
||||
/** Toggle the email composer open. */
|
||||
async openEmailBox() {
|
||||
await this.page.getByRole('button', { name: 'Reply' }).click()
|
||||
}
|
||||
|
||||
/** Type a body into the composer and send. Subject is pre-filled by CRM. */
|
||||
async sendEmail(body: string) {
|
||||
const editor = this.page.locator('[contenteditable="true"]').last()
|
||||
await editor.click()
|
||||
await editor.fill(body)
|
||||
// The label carries a platform-dependent shortcut hint, e.g. "Send (⌘⏎)".
|
||||
// Anchored both ends so it can't match "Send an Email" / "Send Template".
|
||||
await this.page.getByRole('button', { name: /^Send(\s*\(.*\))?$/ }).click()
|
||||
}
|
||||
|
||||
/** Switch to a named tab in the activity area (Activity, Emails, ...). */
|
||||
async openTab(name: string) {
|
||||
await this.page.getByRole('tab', { name }).click()
|
||||
}
|
||||
}
|
||||
45
e2e/pages/leads.page.ts
Normal file
45
e2e/pages/leads.page.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Locator, Page, expect } from '@playwright/test'
|
||||
import { LeadData } from '../helpers/crm'
|
||||
|
||||
/**
|
||||
* Leads list view (/crm/leads) and the "Create Lead" modal.
|
||||
*
|
||||
* Fields render via FieldLayout; the quick-entry text fields carry the
|
||||
* label itself as their placeholder (e.g. "First Name", "Email"), so we
|
||||
* drive them by placeholder scoped to the dialog for stable selectors.
|
||||
*/
|
||||
export class LeadsPage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/crm/leads')
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
async openCreateModal() {
|
||||
await this.page.getByRole('button', { name: 'Create', exact: true }).click()
|
||||
await expect(
|
||||
this.page.getByRole('heading', { name: 'Create Lead' }),
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
/** Fill the mandatory text fields and submit; resolves once the modal closes. */
|
||||
async createLead(data: LeadData) {
|
||||
const dialog = this.page.getByRole('dialog')
|
||||
await dialog.getByPlaceholder('First Name', { exact: true }).fill(data.first_name)
|
||||
if (data.last_name)
|
||||
await dialog.getByPlaceholder('Last Name', { exact: true }).fill(data.last_name)
|
||||
if (data.email)
|
||||
await dialog.getByPlaceholder('Email', { exact: true }).fill(data.email)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Create', exact: true }).click()
|
||||
await expect(
|
||||
this.page.getByRole('heading', { name: 'Create Lead' }),
|
||||
).toBeHidden()
|
||||
}
|
||||
|
||||
/** A list row matching the given text (e.g. the lead's name/email). */
|
||||
row(text: string): Locator {
|
||||
return this.page.getByRole('link').filter({ hasText: text }).first()
|
||||
}
|
||||
}
|
||||
26
e2e/pages/login.page.ts
Normal file
26
e2e/pages/login.page.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Page, expect } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Frappe's standard login page. After a successful login with
|
||||
* redirect-to=/crm the browser lands on the CRM SPA.
|
||||
*/
|
||||
export class LoginPage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/login?redirect-to=/crm')
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
async login(email = 'Administrator', password = 'admin') {
|
||||
await this.goto()
|
||||
await this.page.fill('#login_email', email)
|
||||
await this.page.fill('#login_password', password)
|
||||
await this.page.click('button.btn-login')
|
||||
await this.page.waitForURL(/\/crm/, { timeout: 30000 })
|
||||
}
|
||||
|
||||
async expectOnLoginPage() {
|
||||
await expect(this.page).toHaveURL(/.*login.*/)
|
||||
}
|
||||
}
|
||||
54
e2e/tests/auth.setup.ts
Normal file
54
e2e/tests/auth.setup.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { expect, test as setup } from '@playwright/test'
|
||||
|
||||
const authFile = 'e2e/.auth/user.json'
|
||||
const csrfFile = 'e2e/.auth/csrf.json'
|
||||
|
||||
/**
|
||||
* Authentication setup - runs once before all tests.
|
||||
* Logs in via the Frappe API, captures the CSRF token, and saves storage state.
|
||||
*/
|
||||
setup('authenticate', async ({ page }) => {
|
||||
const authDir = path.dirname(authFile)
|
||||
if (!fs.existsSync(authDir)) {
|
||||
fs.mkdirSync(authDir, { recursive: true })
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
const userResponse = await page.request.get(
|
||||
'/api/method/frappe.auth.get_logged_user',
|
||||
)
|
||||
expect(userResponse.ok()).toBeTruthy()
|
||||
const userData = await userResponse.json()
|
||||
expect(userData.message).not.toBe('Guest')
|
||||
console.log(`Authenticated as: ${userData.message}`)
|
||||
|
||||
// Load the CRM app so window.frappe.csrf_token is available.
|
||||
await page.goto('/crm')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// The CRM SPA injects boot keys onto window (see crm/www/crm.html),
|
||||
// so the token lives at window.csrf_token.
|
||||
const csrfToken = await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
csrf_token?: string
|
||||
frappe?: { csrf_token?: string }
|
||||
}
|
||||
return w.csrf_token || w.frappe?.csrf_token
|
||||
})
|
||||
|
||||
// A missing token means later API writes would fail with an opaque 417;
|
||||
// fail the setup loudly instead of proceeding with no CSRF header.
|
||||
expect(csrfToken).toBeTruthy()
|
||||
fs.writeFileSync(csrfFile, JSON.stringify({ csrf_token: csrfToken }))
|
||||
|
||||
await page.context().storageState({ path: authFile })
|
||||
})
|
||||
28
e2e/tests/convert.spec.ts
Normal file
28
e2e/tests/convert.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { LeadPage } from '../pages'
|
||||
import { cleanupE2ERecords, DEAL_DOCTYPE, getList, seedLead } from '../helpers'
|
||||
|
||||
test.describe('Lead to deal conversion', () => {
|
||||
test.afterAll(async ({ request }) => {
|
||||
await cleanupE2ERecords(request)
|
||||
})
|
||||
|
||||
test('converts an existing lead into a deal', async ({ page, request }) => {
|
||||
const lead = await seedLead(request)
|
||||
const leadPage = new LeadPage(page)
|
||||
|
||||
await leadPage.goto(lead.name)
|
||||
await leadPage.convertToDeal()
|
||||
|
||||
// A deal now carries the converted lead's email.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const rows = await getList(request, DEAL_DOCTYPE, {
|
||||
filters: { email: lead.email },
|
||||
fields: ['name'],
|
||||
})
|
||||
return rows.length
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
33
e2e/tests/deal.spec.ts
Normal file
33
e2e/tests/deal.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { DealsPage } from '../pages'
|
||||
import {
|
||||
cleanupE2ERecords,
|
||||
DEAL_DOCTYPE,
|
||||
getList,
|
||||
uniqueSuffix,
|
||||
} from '../helpers'
|
||||
|
||||
test.describe('Deal creation', () => {
|
||||
test.afterAll(async ({ request }) => {
|
||||
await cleanupE2ERecords(request)
|
||||
})
|
||||
|
||||
test('creates a deal from the deals list view', async ({ page, request }) => {
|
||||
const email = `e2e-deal-${uniqueSuffix()}@example.com`
|
||||
const deals = new DealsPage(page)
|
||||
|
||||
await deals.goto()
|
||||
await deals.openCreateModal()
|
||||
await deals.createDeal(email)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const rows = await getList(request, DEAL_DOCTYPE, {
|
||||
filters: { email },
|
||||
fields: ['name'],
|
||||
})
|
||||
return rows.length
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
44
e2e/tests/email.spec.ts
Normal file
44
e2e/tests/email.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { LeadPage } from '../pages'
|
||||
import { cleanupE2ERecords, getList, seedLead } from '../helpers'
|
||||
|
||||
test.describe('Send email from lead view', () => {
|
||||
test.afterAll(async ({ request }) => {
|
||||
await cleanupE2ERecords(request)
|
||||
})
|
||||
|
||||
test('composes and sends an email that is recorded as a Communication', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const lead = await seedLead(request)
|
||||
const body = `E2E email body ${Date.now()}`
|
||||
const leadPage = new LeadPage(page)
|
||||
|
||||
await leadPage.goto(lead.name)
|
||||
await leadPage.clearComposerDraft()
|
||||
await leadPage.openEmailBox()
|
||||
await leadPage.sendEmail(body)
|
||||
|
||||
// The send creates a Communication linked to the lead (no SMTP delivery needed).
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const rows = await getList(request, 'Communication', {
|
||||
filters: {
|
||||
reference_doctype: 'CRM Lead',
|
||||
reference_name: lead.name,
|
||||
communication_type: 'Communication',
|
||||
},
|
||||
fields: ['name'],
|
||||
})
|
||||
return rows.length
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// And it surfaces in the lead's Emails tab. With a cleared draft the
|
||||
// recipient row reflects this lead's email and renders outside the
|
||||
// sandboxed body iframe, so it is reliable to assert on.
|
||||
await leadPage.openTab('Emails')
|
||||
await expect(page.getByText(lead.email!).first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
29
e2e/tests/lead.spec.ts
Normal file
29
e2e/tests/lead.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { LeadsPage } from '../pages'
|
||||
import { buildLead, cleanupE2ERecords, getList, LEAD_DOCTYPE } from '../helpers'
|
||||
|
||||
test.describe('Lead creation', () => {
|
||||
test.afterAll(async ({ request }) => {
|
||||
await cleanupE2ERecords(request)
|
||||
})
|
||||
|
||||
test('creates a lead from the leads list view', async ({ page, request }) => {
|
||||
const lead = buildLead()
|
||||
const leads = new LeadsPage(page)
|
||||
|
||||
await leads.goto()
|
||||
await leads.openCreateModal()
|
||||
await leads.createLead(lead)
|
||||
|
||||
// The new lead is persisted and queryable via the API.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const rows = await getList(request, LEAD_DOCTYPE, {
|
||||
filters: { email: lead.email },
|
||||
fields: ['name'],
|
||||
})
|
||||
return rows.length
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
35
e2e/tests/login.spec.ts
Normal file
35
e2e/tests/login.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { LoginPage } from '../pages'
|
||||
|
||||
/**
|
||||
* Exercises the real login flow through Frappe's login page into the CRM SPA.
|
||||
* Runs without the shared auth state so it can assert the guest -> logged-in
|
||||
* transition itself.
|
||||
*/
|
||||
test.describe('Login', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
|
||||
test('logs in and lands on the CRM app', async ({ page }) => {
|
||||
const login = new LoginPage(page)
|
||||
await login.login(
|
||||
process.env.FRAPPE_USER || 'Administrator',
|
||||
process.env.FRAPPE_PASSWORD || 'admin',
|
||||
)
|
||||
|
||||
await expect(page).toHaveURL(/\/crm/)
|
||||
// The CRM shell renders its primary navigation once booted.
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Leads' }).first(),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('rejects invalid credentials', async ({ page }) => {
|
||||
const login = new LoginPage(page)
|
||||
await login.goto()
|
||||
await page.fill('#login_email', 'Administrator')
|
||||
await page.fill('#login_password', 'wrong-password')
|
||||
await page.click('button.btn-login')
|
||||
|
||||
await login.expectOnLoginPage()
|
||||
})
|
||||
})
|
||||
14
e2e/tsconfig.json
Normal file
14
e2e/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node", "@playwright/test"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user