snapshot of https://github.com/krayin/laravel-crm.git (2.2, 2026-09-18; blobs > 131072B omitted)
Some checks failed
Admin | Playwright Tests / Admin | Playwright Tests | Shard 1 Of 6 (push) Failing after 37s
Admin | Playwright Tests / Admin | Playwright Tests | Shard 2 Of 6 (push) Failing after 7s
Admin | Playwright Tests / Admin | Playwright Tests | Shard 4 Of 6 (push) Failing after 7s
Admin | Playwright Tests / Admin | Playwright Tests | Shard 6 Of 6 (push) Failing after 6s
Linting Tests / PHP 8.3 test on ubuntu-latest (push) Failing after 5s
CI / PHP 8.3 test on ubuntu-latest (push) Failing after 6s
Skills Consistency / validate-skills (push) Successful in 10s
Admin | Playwright Tests / Admin | Playwright Tests | Shard 3 Of 6 (push) Failing after 7s
Admin | Playwright Tests / Admin | Playwright Tests | Shard 5 Of 6 (push) Failing after 6s

This commit is contained in:
upstream-mirror
2026-09-18 04:37:41 +05:00
commit 135ffdb085
1274 changed files with 144880 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
/playwright-report
/test-results

View File

@@ -0,0 +1,35 @@
{
"cookies": [
{
"name": "XSRF-TOKEN",
"value": "eyJpdiI6IlAzOTFCLzFpTWxrdEswSzM4QmNOUkE9PSIsInZhbHVlIjoiNzZTUGNXeDYwWjJSVlRCbnd5NkU3dlg3NHAwZGM2ajlpTnBlMWQvRStHTkNQWkZjYml5NnFUUGpoTFFuZ3Jna01BUXRoSENFSVN5Y05RdjFvNU5IWjJYZStOTHJiNTNWVVZmbWpmMWVFaE1xWG5xTjkrZW9tZ2ZOU3J5QnNhRmMiLCJtYWMiOiI3MTJhZDM1MzZhMDU2OTVkYmRjZTEwOTRkMjkzYTIyNjcxZTk4MThiNjkwZjQ2NzIzZmUzYTJhM2Y5ZjkyMGE0IiwidGFnIjoiIn0%3D",
"domain": "192.168.15.131",
"path": "/",
"expires": 1787127131.04971,
"httpOnly": false,
"secure": false,
"sameSite": "Lax"
},
{
"name": "krayin_crm_session",
"value": "eyJpdiI6IktDWkpDVEZRcFRwYkViTzd3bXZVZlE9PSIsInZhbHVlIjoiajRTQlpyQ1BobjArZWo1dlFmMy81eXZyUGJXNW5LR2xKOWFiVGd2c0t5dWZ6V0hmdFBGQ0VpVDFyUkFraFBZa0NHbWpxMjdxdkpaWENnTldZenNFN2htSzQ2ajh3YkdmMUJMeEthK0l5YzU3cFhvcWw1aktPMVpBYjZVM0tXQ1YiLCJtYWMiOiI2NmY4Y2Q5OWQ4Yjc2YjU3ZDAwMDI5ODMyOTE0YjY3ZTM5NGJkNzYwZTdiNGVjMmQ5M2U4NjM5ZGIxNzUwZjk2IiwidGFnIjoiIn0%3D",
"domain": "192.168.15.131",
"path": "/",
"expires": 1787127131.049837,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "http://192.168.15.131",
"localStorage": [
{
"name": "phpdebugbar-height",
"value": "300"
}
]
}
]
}

View File

@@ -0,0 +1,3 @@
id,title,description,lead_value,status,lost_reason,closed_at,user_id,person_id,lead_source_id,lead_type_id,lead_pipeline_id,product,lead_pipeline_stage_id,expected_close_date
1,Updated Urgent Requirement of Product -> TaskMaster Pro - Advanced Project Management Tool,"Urgent Requirement: TaskMaster Pro – an advanced project management tool designed to enhance team collaboration, streamline task tracking, and optimize time management for small to medium-sized teams. Ideal for businesses looking to improve project efficiency and productivity.",50000,1,,2024-08-30 14:39:58,1,19,1,1,1,"price=45,quantity=2,id=1",5,2024-09-02
,Need more information About Product -> CyberGuard Pro,A client wants to know about the CyberGuard Pro Product in details and he is interested in the product.,100000,1,,,1,19,1,1,1,"price=45,quantity=2,id=2",3,2024-09-06
1 id title description lead_value status lost_reason closed_at user_id person_id lead_source_id lead_type_id lead_pipeline_id product lead_pipeline_stage_id expected_close_date
2 1 Updated Urgent Requirement of Product -> TaskMaster Pro - Advanced Project Management Tool Urgent Requirement: TaskMaster Pro – an advanced project management tool designed to enhance team collaboration, streamline task tracking, and optimize time management for small to medium-sized teams. Ideal for businesses looking to improve project efficiency and productivity. 50000 1 2024-08-30 14:39:58 1 19 1 1 1 price=45,quantity=2,id=1 5 2024-09-02
3 Need more information About Product -> CyberGuard Pro A client wants to know about the CyberGuard Pro Product in details and he is interested in the product. 100000 1 1 19 1 1 1 price=45,quantity=2,id=2 3 2024-09-06

View File

@@ -0,0 +1,3 @@
id,title,description,lead_value,status,lost_reason,closed_at,user_id,person_id,lead_source_id,lead_type_id,lead_pipeline_id,product,lead_pipeline_stage_id,expected_close_date
1,Urgent Requirement of Product -> TaskMaster Pro - Advanced Project Management Tool," Requirement: TaskMaster Pro – an advanced project management tool designed to enhance team collaboration, streamline task tracking, and optimize time management for small to medium-sized teams. Ideal for businesses looking to improve project efficiency and productivity.",50000,1,,2024-08-30 14:39:58,1,19,2,2,1,"price=45,quantity=2,id=1",5,2024-09-02
2,Need more information About Product -> Pro,A wants to know about the CyberGuard Pro Product in details and he is interested in the product.,100000,1,,,1,19,4,1,1,"price=45,quantity=2,id=2",3,2024-09-06
1 id title description lead_value status lost_reason closed_at user_id person_id lead_source_id lead_type_id lead_pipeline_id product lead_pipeline_stage_id expected_close_date
2 1 Urgent Requirement of Product -> TaskMaster Pro - Advanced Project Management Tool Requirement: TaskMaster Pro – an advanced project management tool designed to enhance team collaboration, streamline task tracking, and optimize time management for small to medium-sized teams. Ideal for businesses looking to improve project efficiency and productivity. 50000 1 2024-08-30 14:39:58 1 19 2 2 1 price=45,quantity=2,id=1 5 2024-09-02
3 2 Need more information About Product -> Pro A wants to know about the CyberGuard Pro Product in details and he is interested in the product. 100000 1 1 19 4 1 1 price=45,quantity=2,id=2 3 2024-09-06

View File

@@ -0,0 +1,3 @@
name,emails,contact_numbers,organization_id,job_title,user_id
Wilson Fisk,"[{""label"": ""work"", ""value"": ""contact@wilson.com""}, {""label"": ""home"", ""value"": ""contact.home@wilson.com""}]","[{""label"": ""work"", ""value"": ""5454445454""}]",7,Sales Executive,1
Sasha Calle,"[{""label"": ""work"", ""value"": ""contact@sasha.com""}]","[{""label"": ""work"", ""value"": ""15454445454""}]",7,Sales Representatives,1
1 name emails contact_numbers organization_id job_title user_id
2 Wilson Fisk [{"label": "work", "value": "contact@wilson.com"}, {"label": "home", "value": "contact.home@wilson.com"}] [{"label": "work", "value": "5454445454"}] 7 Sales Executive 1
3 Sasha Calle [{"label": "work", "value": "contact@sasha.com"}] [{"label": "work", "value": "15454445454"}] 7 Sales Representatives 1

View File

@@ -0,0 +1,3 @@
name,emails,contact_numbers,organization_id,job_title,user_id
Wilson Updated Fisk,"[{""label"": ""work"", ""value"": ""contact@wilson.com""}, {""label"": ""home"", ""value"": ""contact.home@wilson.com""}]","[{""label"": ""work"", ""value"": ""5454445454""}]",7,Sales Executive,1
Sasha Updated Calle,"[{""label"": ""work"", ""value"": ""contact@sasha.com""}]","[{""label"": ""work"", ""value"": ""15454445454""}]",7,Sales Representatives,1
1 name emails contact_numbers organization_id job_title user_id
2 Wilson Updated Fisk [{"label": "work", "value": "contact@wilson.com"}, {"label": "home", "value": "contact.home@wilson.com"}] [{"label": "work", "value": "5454445454"}] 7 Sales Executive 1
3 Sasha Updated Calle [{"label": "work", "value": "contact@sasha.com"}] [{"label": "work", "value": "15454445454"}] 7 Sales Representatives 1

View File

@@ -0,0 +1,3 @@
sku,name,description,quantity,price
100,TaskMaster Pro - Advanced Project Management Tool,"A project management tool designed for small to medium-sized teams. It offers task tracking, team collaboration, and time management features. It also integrates with popular communication platforms.",45,500
101,ProjectPulse - A Simple and Effective Project Management Tool,"A cloud-based project management solution that provides Gantt charts, resource allocation, and project reporting. It is designed for large enterprises and supports multi-project management with real-time analytics.",20,450
1 sku name description quantity price
2 100 TaskMaster Pro - Advanced Project Management Tool A project management tool designed for small to medium-sized teams. It offers task tracking, team collaboration, and time management features. It also integrates with popular communication platforms. 45 500
3 101 ProjectPulse - A Simple and Effective Project Management Tool A cloud-based project management solution that provides Gantt charts, resource allocation, and project reporting. It is designed for large enterprises and supports multi-project management with real-time analytics. 20 450

View File

@@ -0,0 +1,3 @@
sku,name,description,quantity,price
100,TaskMaster updated Pro - Advanced Project Management Tool,"A project management tool designed for small to medium-sized teams. It offers task tracking, team collaboration, and time management features. It also integrates with popular communication platforms.",45,500
101,ProjectPulse updated - A Simple and Effective Project Management Tool,"A cloud-based project management solution that provides Gantt charts, resource allocation, and project reporting. It is designed for large enterprises and supports multi-project management with real-time analytics.",234,450
1 sku name description quantity price
2 100 TaskMaster updated Pro - Advanced Project Management Tool A project management tool designed for small to medium-sized teams. It offers task tracking, team collaboration, and time management features. It also integrates with popular communication platforms. 45 500
3 101 ProjectPulse updated - A Simple and Effective Project Management Tool A cloud-based project management solution that provides Gantt charts, resource allocation, and project reporting. It is designed for large enterprises and supports multi-project management with real-time analytics. 234 450

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -0,0 +1,80 @@
import { test as base, expect, type Page } from "@playwright/test";
import fs from "fs";
import { ADMIN_AUTH_STATE_PATH } from "../playwright.config";
import { AdminPage as Admin } from "../pages/AdminPage";
interface AdminPage extends Page {
fillInTinymce: (iframeSelector: string, content: string) => Promise<void>;
}
type AdminFixtures = {
adminPage: AdminPage;
};
export const test = base.extend<AdminFixtures>({
adminPage: async ({ browser }, use) => {
const authExists = fs.existsSync(ADMIN_AUTH_STATE_PATH);
const context = await browser.newContext(
authExists ? { storageState: ADMIN_AUTH_STATE_PATH } : {}
);
const page = await context.newPage();
const admin = await new Admin(page)
if (!authExists) {
/**
* Authenticate the admin user.
*/
await admin.adminLogin("admin@example.com", "admin123");
/**
* Save authentication state to a file.
*/
await context.storageState({ path: ADMIN_AUTH_STATE_PATH });
} else {
/**
* Navigate to the dashboard.
*/
await page.goto("admin/dashboard");
}
if (page.url().includes("admin/login")) {
/**
* Authenticate the admin user.
*/
await admin.adminLogin("admin@example.com", "admin123");
/**
* Save authentication state to a file.
*/
await context.storageState({ path: ADMIN_AUTH_STATE_PATH });
}
/**
* Extend the page object with custom methods.}
*/
(page as AdminPage).fillInTinymce = async function (
iframeSelector: string,
content: string
) {
await page.waitForSelector(iframeSelector);
const iframe = page.frameLocator(iframeSelector);
const editorBody = iframe.locator("body");
await editorBody.click();
await editorBody.press("Control+a");
await editorBody.press("Backspace");
await editorBody.pressSequentially(content);
await expect(editorBody).toHaveText(content);
};
await use(page as AdminPage);
await context.close();
},
});
export { expect };

View File

@@ -0,0 +1,597 @@
import { LegacyCharacterEncoding } from "crypto";
import { expect, FrameLocator, Locator, Page } from "playwright/test";
type ElementType = "button" | "textbox" | "link";
export default class CoreLocators {
readonly page: Page;
// Person Locators
readonly createPersonLink: Locator;
readonly savePersonButton: Locator;
readonly personNameTextbox: Locator;
readonly personEmailTextbox: Locator;
readonly personPhoneTextbox: Locator;
readonly personJobTitleTextbox: Locator;
readonly personOrgSelectDiv: Locator;
readonly personOrgListItem: (text: string) => Locator;
// Links and buttons
readonly createOrgLink: Locator;
readonly saveOrganizationButton: Locator;
// Textboxes
readonly orgNameTextbox: Locator;
readonly orgCityTextbox: Locator;
readonly orgPostcodeTextbox: Locator;
// Textareas
readonly orgAddressTextarea: Locator;
// Select dropdowns
readonly orgCountryCombobox: Locator;
readonly orgStateSelect: Locator;
// Other UI elements
readonly orgExtraDetailsDiv: Locator;
readonly orgExampleListItem: (text: string) => Locator;
// Buttons
readonly saveQuoteButton: Locator;
readonly saveProductsButton: Locator;
readonly confirmDeleteButton: Locator;
readonly saveLeadButton: Locator;
readonly sendEmailButton: Locator;
readonly saveFileButton: Locator;
readonly saveNoteButton: Locator;
readonly saveActivityButton: Locator;
readonly signInButton: Locator;
readonly createEventButton: Locator;
readonly saveEventButton: Locator;
readonly agreeButton: Locator;
readonly createTypeButton: Locator;
readonly saveTypeButton: Locator;
readonly createGroupButton: Locator;
readonly saveGroupButton: Locator;
readonly createUserButton: Locator;
readonly saveUserButton: Locator;
readonly composeMailButton: Locator;
readonly createOrganizationButton: Locator;
readonly deleteButton: Locator;
// Links
readonly createQuoteLink: Locator;
readonly createProductLink: Locator;
readonly createLeadLink: Locator;
readonly signOutLink: Locator;
readonly createOrganizationLink: Locator;
// Textboxes - single line text input fields
readonly subjectTextbox: Locator;
readonly descriptionTextbox: Locator;
readonly searchInputField: Locator;
readonly productNameTextbox: Locator;
readonly productPriceInput: Locator;
readonly productQuantityInput: Locator;
readonly productSkuInput: Locator;
readonly leadTitleInput: Locator;
readonly leadValueInput: Locator;
readonly leadProductSelect: Locator;
readonly emailReplyToInput: Locator;
readonly emailSubjectInput: Locator;
readonly fileTitleInput: Locator;
readonly fileNameInput: Locator;
readonly noteCommentTextarea: Locator;
readonly activityTitleInput: Locator;
readonly activityLocationInput: Locator;
readonly eventNameInput: Locator;
readonly typeNameInput: Locator;
readonly groupNameInput: Locator;
readonly userNameInput: Locator;
readonly userEmailInput: Locator;
readonly userPasswordInput: Locator;
readonly userConfirmPasswordInput: Locator;
readonly organizationNameInput: Locator;
readonly organizationCityTextbox: Locator;
readonly organizationAddressTextarea: Locator;
readonly organizationPostcodeTextbox: Locator;
readonly organizationCountryCombobox: Locator;
readonly organizationStateSelect: Locator;
readonly addPersonButton: Locator;
// Textboxes - date or specialized text input
readonly expiredAtTextbox: Locator;
readonly leadExpectedCloseDateInput: Locator;
readonly activityScheduleFromInput: Locator;
readonly activityScheduleToInput: Locator;
readonly eventDateInput: Locator;
// Textareas - multi-line input fields
readonly billingAddressTextarea: Locator;
readonly shippingAddressTextarea: Locator;
readonly productDescriptionTextarea: Locator;
readonly leadDescriptionTextarea: Locator;
readonly emailReplyTextarea: Locator;
readonly fileCommentTextarea: Locator;
readonly noteTextarea: Locator;
readonly activityCommentTextarea: Locator;
readonly eventDescriptionTextarea: Locator;
readonly groupDescriptionTextarea: Locator;
// Inputs - single line, no explicit role
readonly billingCityInput: Locator;
readonly billingPostcodeInput: Locator;
readonly shippingCityInput: Locator;
readonly shippingPostcodeInput: Locator;
readonly leadEmailInput: Locator;
readonly leadPhoneNumberInput: Locator;
// Select dropdowns
readonly salesOwnerSelect: Locator;
readonly billingCountrySelect: Locator;
readonly billingStateSelect: Locator;
readonly shippingCountrySelect: Locator;
readonly shippingStateSelect: Locator;
readonly leadSourceSelect: Locator;
readonly leadTypeSelect: Locator;
readonly leadUserSelect: Locator;
readonly leadSourceLabelSelect: Locator;
readonly leadOrganizationSelect: Locator;
readonly userRoleSelect: Locator;
readonly userViewPermissionSelect: Locator;
readonly userGroupListbox: Locator;
readonly leadProductAddMoreButton: Locator;
readonly quoteLinkToLeadButton: Locator;
// Search related list item selector
readonly personListItem: (name: string) => Locator;
// Lead form - person search dropdown
readonly leadPersonSearchInput: Locator;
// Lead form - product search dropdown
readonly leadProductSearchInput: Locator;
readonly quoteAddPersonButton: Locator;
readonly addAsNewButton: Locator;
readonly quoteSelectListPerson: Locator;
// Edit and delete icons
readonly firstEditIcon: Locator;
readonly firstDeleteIcon: Locator;
// Multi select checkbox
readonly multiSelectCheckbox: Locator;
// Activity modal selectors
readonly addActivityButton: Locator;
readonly activityCallOption: Locator;
readonly activityMeetingOption: Locator;
readonly activityLunchOption: Locator;
// Login fields
readonly emailPlaceholderInput: Locator;
readonly passwordPlaceholderInput: Locator;
readonly megaSearchPlaceholder: Locator;
// Profile and logout buttons
readonly profileButton: Locator;
// Lead
readonly createLeadButton: Locator;
// Lead form
readonly titleInput: Locator;
readonly descriptionTextarea: Locator;
readonly sourceDropdown: Locator;
readonly expectedCloseDate: Locator;
readonly typeDropdown: Locator;
readonly userDropdown: Locator;
readonly searchInput: Locator;
readonly listSearchInput: Locator;
// Add person
readonly personEmailInput: Locator;
readonly personPhoneInput: Locator;
readonly successMessage: Locator;
// Add organization
readonly addOrganizationButton: Locator;
readonly organizationCreateSuccessMessage: Locator;
// General
readonly leadSuccessToast: Locator;
readonly editLeadButton: Locator;
readonly listViewButton: Locator;
readonly deleteLeadButton: Locator;
readonly noRecordsAvailable: Locator
readonly searchInputExact:Locator;
readonly listViewLeadSearch: Locator;
// Tabs
readonly mailButton: Locator;
readonly fileButton: Locator;
readonly noteButton: Locator;
readonly activityButton: Locator;
// Mail
readonly mailToInput: Locator;
readonly mailSubjectInput: Locator;
readonly mailBodyTextarea: Locator;
readonly sendMailButton: Locator;
// File
// Note
// Activity
readonly addActivityTitleInput: Locator;
readonly addActivityCommentTextarea: Locator;
readonly scheduleFromInput: Locator;
readonly scheduleToInput: Locator;
readonly locationInput: Locator;
// Validation message
readonly expectedCloseDateMustBeDateAfter: Locator;
// Other
readonly appLocator: Locator;
// Quick Add
readonly quickAddTrigger: Locator;
readonly quickAddModalTitle: Locator;
readonly quickAddTab: (tabLabel: string) => Locator;
readonly quickAddSaveButton: Locator;
readonly quickAddLeadForm: Locator;
readonly quickAddPersonForm: Locator;
readonly quickAddOrganizationForm: Locator;
readonly quickAddProductForm: Locator;
readonly quickAddMailForm: Locator;
readonly quickAddLeadTitleInput: Locator;
readonly quickAddLeadDescriptionTextarea: Locator;
readonly quickAddPersonNameInput: Locator;
readonly quickAddPersonEmailInput: Locator;
readonly quickAddPersonContactInput: Locator;
readonly quickAddOrgNameInput: Locator;
readonly quickAddProductNameInput: Locator;
readonly quickAddProductDescriptionTextarea: Locator;
readonly quickAddProductSkuInput: Locator;
readonly quickAddProductQuantityInput: Locator;
readonly quickAddProductPriceInput: Locator;
readonly quickAddMailReplyToInput: Locator;
readonly quickAddMailCcInput: Locator;
readonly quickAddMailBccInput: Locator;
readonly quickAddMailCcButton: Locator;
readonly quickAddMailBccButton: Locator;
readonly quickAddMailSubjectInput: Locator;
readonly quickAddMailEditorFrame: FrameLocator;
readonly quickAddLeadSuccessMsg: Locator;
readonly quickAddPersonSuccessMsg: Locator;
readonly quickAddOrgSuccessMsg: Locator;
readonly quickAddProductSuccessMsg: Locator;
readonly quickAddMailSuccessMsg: Locator;
constructor(page: Page) {
this.page = page;
// Buttons
this.saveQuoteButton = page.getByRole("button", { name: "Save Quote" });
this.saveProductsButton = page.getByRole("button", { name: "Save Products" });
this.confirmDeleteButton = page.locator("button.transparent-button + button.primary-button:visible");
this.saveLeadButton = page.getByRole("button", { name: "Save" });
this.sendEmailButton = page.getByRole("button", { name: "Send" });
this.saveFileButton = page.getByRole("button", { name: "Save File" });
this.saveNoteButton = page.getByRole("button", { name: "Save Note" });
this.saveActivityButton = page.getByRole("button", { name: "Save Activity" });
this.signInButton = page.getByRole("button", { name: "Sign In" });
this.createEventButton = page.getByRole("button", { name: "Create Event" });
this.saveEventButton = page.getByRole("button", { name: "Save Event" });
this.agreeButton = page.getByRole("button", { name: "Agree", exact: true });
this.createTypeButton = page.getByRole("button", { name: "Create Type" });
this.saveTypeButton = page.getByRole("button", { name: "Save Type" });
this.createGroupButton = page.getByRole("button", { name: "Create group" });
this.saveGroupButton = page.getByRole("button", { name: "Save Group" });
this.createUserButton = page.getByRole("button", { name: "Create User" });
this.saveUserButton = page.getByRole("button", { name: "Save User" });
this.composeMailButton = page.getByRole("button", { name: "Compose Mail" });
this.createOrganizationButton = page.getByRole("link", { name: "Create Organization" });
this.saveOrganizationButton = page.getByRole("button", { name: "Save Organization" });
this.deleteButton = page.getByRole("button", { name: "Delete" });
this.leadProductSelect = page.locator('.mb-4.\\!mb-0 > div > .relative.inline-block > .relative').first();
this.leadProductAddMoreButton = page.locator('//button[@class="flex max-w-max items-center gap-2 text-brandColor"]');
this.leadOrganizationSelect = page.locator("div").filter({ hasText: /^Click to add$/ }).nth(2);
this.addPersonButton = page.locator("div", { hasText: /^Click to Add$/ }).nth(1);
this.quoteAddPersonButton = page.locator(".relative.flex.items-center.justify-between").first();
// Links
this.createQuoteLink = page.getByRole("link", { name: "Create Quote" });
this.createProductLink = page.getByRole("link", { name: "Create Product" });
this.createLeadLink = page.getByRole("link", { name: "Create Lead" });
this.signOutLink = page.getByRole("link", { name: "Sign Out" });
this.createOrganizationLink = page.getByRole("link", { name: "Create Organization" });
this.addAsNewButton = page.getByText("Add as New");
this.quoteSelectListPerson = page.locator(`(//li[@class="flex cursor-pointer gap-2 p-2 transition-colors hover:bg-blue-100 dark:text-gray-300 dark:hover:bg-gray-900"])[1]`);
// Textboxes - single line text input fields
this.subjectTextbox = page.getByRole("textbox", { name: "Subject *" });
this.descriptionTextbox = page.getByRole("textbox", { name: "Description" });
this.productNameTextbox = page.getByRole("textbox", { name: "Name *" });
this.productPriceInput = page.locator('input[name="price"]');
this.productQuantityInput = page.locator('input[name="quantity"]');
this.productSkuInput = page.locator('input[name="sku"]');
this.leadTitleInput = page.locator('input[name="title"]');
this.leadValueInput = page.locator('input[name="lead_value"]');
this.emailReplyToInput = page.locator('input[name="temp-reply_to"]');
this.emailSubjectInput = page.locator('input[name="subject"]');
this.fileTitleInput = page.locator('input[name="title"]');
this.fileNameInput = page.locator('input[name="name"]');
this.noteCommentTextarea = page.locator('textarea[name="comment"]');
this.activityTitleInput = page.locator('input[name="title"]');
this.activityLocationInput = page.locator('input[name="location"]');
this.eventNameInput = page.locator('input[name="name"]');
this.typeNameInput = page.locator('input[name="name"]');
this.groupNameInput = page.locator('input[name="name"]');
this.userNameInput = page.locator('input[name="name"]');
this.userEmailInput = page.locator('input[name="email"]');
this.userPasswordInput = page.locator('input[name="password"]');
this.userConfirmPasswordInput = page.locator('input[name="confirm_password"]');
this.organizationNameInput = page.locator('input[name="name"]');
this.organizationCityTextbox = page.getByRole('textbox', { name: 'City' });
this.organizationAddressTextarea = page.locator('textarea[name="address\\[address\\]"]');
this.organizationPostcodeTextbox = page.getByRole('textbox', { name: 'Postcode' });
this.organizationCountryCombobox = page.getByRole('combobox');
this.organizationStateSelect = page.locator('select[name="address\\[state\\]"]');
// Textboxes - date or specialized text input
this.expiredAtTextbox = page.getByRole("textbox", { name: "Expired At *" });
this.leadExpectedCloseDateInput = page.locator('input[name="expected_close_date"]');
this.activityScheduleFromInput = page.locator('input[name="schedule_from"]');
this.activityScheduleToInput = page.locator('input[name="schedule_to"]');
this.eventDateInput = page.locator('input[name="date"]');
this.noRecordsAvailable = page.getByText('No Records Available.')
// Textareas - multi-line input fields
this.billingAddressTextarea = page.locator('textarea[name="billing_address\\[address\\]"]');
this.shippingAddressTextarea = page.locator('textarea[name="shipping_address\\[address\\]"]');
this.productDescriptionTextarea = page.locator('textarea[name="description"]');
this.leadDescriptionTextarea = page.locator('textarea[name="description"]');
this.emailReplyTextarea = page.locator('textarea[name="reply"]');
this.fileCommentTextarea = page.locator('textarea[name="comment"]');
this.noteTextarea = page.locator('textarea[name="comment"]');
this.activityCommentTextarea = page.locator('textarea[name="comment"]');
this.eventDescriptionTextarea = page.locator('textarea[name="description"]');
this.groupDescriptionTextarea = page.locator('textarea[name="description"]');
// Inputs - single line, no explicit role
this.billingCityInput = page.locator('input[name="billing_address\\[city\\]"]');
this.billingPostcodeInput = page.locator('input[name="billing_address\\[postcode\\]"]');
this.shippingCityInput = page.locator('input[name="shipping_address\\[city\\]"]');
this.shippingPostcodeInput = page.locator('input[name="shipping_address\\[postcode\\]"]');
this.leadEmailInput = page.locator('input[name="person[emails][0][value]"]');
this.leadPhoneNumberInput = page.locator('input[name="person[contact_numbers][0][value]"]');
// Select dropdowns
this.salesOwnerSelect = page.getByLabel("Sales Owner");
this.billingCountrySelect = page.locator('select[name="billing_address\\[country\\]"]');
this.billingStateSelect = page.locator('select[name="billing_address\\[state\\]"]');
this.shippingCountrySelect = page.locator('select[name="shipping_address\\[country\\]"]');
this.shippingStateSelect = page.locator('select[name="shipping_address\\[state\\]"]');
this.leadSourceSelect = page.locator('select[name="lead_source_id"]');
this.leadTypeSelect = page.locator('select[name="lead_type_id"]');
this.leadUserSelect = page.locator('select[name="user_id"]');
this.leadSourceLabelSelect = page.getByLabel('Source');
this.userRoleSelect = page.locator('select[name="role_id"]');
this.userViewPermissionSelect = page.locator('select[name="view_permission"]');
this.userGroupListbox = page.getByRole('listbox');
// Search related list item selector
this.personListItem = (name: string) => page.getByRole("listitem").filter({ hasText: name });
// Edit and delete icons
this.firstEditIcon = page.locator("span.cursor-pointer.icon-edit").first();
this.firstDeleteIcon = page.locator("span.cursor-pointer.icon-delete").first();
//mass delte checkbox
this.multiSelectCheckbox = page.locator('.icon-checkbox-outline').first();
this.quoteLinkToLeadButton = page.locator('div:nth-child(2) > div > .relative.inline-block > .relative');
// Activity modal selectors
this.addActivityButton = page.getByRole("button", { name: " Activity" });
this.activityCallOption = page.getByText("Call", { exact: true });
this.activityMeetingOption = page.getByText("Meeting", { exact: true });
this.activityLunchOption = page.getByText("Lunch", { exact: true });
// Login fields
this.emailPlaceholderInput = page.getByPlaceholder("Email Address");
this.passwordPlaceholderInput = page.getByPlaceholder("Password");
this.megaSearchPlaceholder = page.getByPlaceholder("Mega Search").first();
// Profile and logout buttons
this.profileButton = page.getByRole('button', { name: "E" });
// Organization Locators Initialization
this.createOrgLink = page.getByRole('link', { name: 'Create Organization' });
this.orgNameTextbox = page.getByRole('textbox', { name: 'Name *' });
this.orgAddressTextarea = page.locator('textarea[name="address\\[address\\]"]');
this.orgCountryCombobox = page.getByRole('combobox');
this.orgStateSelect = page.locator('select[name="address\\[state\\]"]');
this.orgCityTextbox = page.getByRole('textbox', { name: 'City' });
this.orgPostcodeTextbox = page.getByRole('textbox', { name: 'Postcode' });
this.orgExtraDetailsDiv = page.locator('div').filter({ hasText: /^Click to add$/ });
this.orgExampleListItem = (text: string) => page.getByRole('listitem').filter({ hasText: text });
// Person Locators Initialization
this.createPersonLink = page.getByRole('link', { name: 'Create Person' });
this.savePersonButton = page.getByRole('button', { name: 'Save Person' });
this.personNameTextbox = page.getByRole('textbox', { name: 'Name *' });
this.personEmailTextbox = page.getByRole('textbox', { name: 'Emails *' });
this.personPhoneTextbox = page.getByRole('textbox', { name: 'Contact Numbers' });
this.personJobTitleTextbox = page.getByRole('textbox', { name: 'Job Title' });
this.personOrgSelectDiv = page.locator('.relative > div > .relative').first();
this.personOrgListItem = (text: string) => page.getByRole('listitem').filter({ hasText: text });
// Lead
this.createLeadButton = page.getByRole('link', { name: 'Create Lead' });
// Lead form
this.titleInput = page.getByRole('textbox', { name: 'Title *' });
this.descriptionTextarea = page.locator('textarea[name="description"]');
this.sourceDropdown = page.locator('select[name="lead_source_id"]');
this.expectedCloseDate = page.locator('input[name="expected_close_date"]');
this.typeDropdown = page.locator('select[name="lead_type_id"]');
this.userDropdown = page.locator('select[name="user_id"]');
this.leadValueInput = page.locator('input[name="lead_value"]');
this.listViewLeadSearch= page.getByRole('textbox', { name: 'Search', exact: true });
this.searchInput = page.getByRole('textbox', { name: 'Search by Title' });
this.listSearchInput = page.getByRole('textbox', { name: 'Search' });
// Lead form - person search dropdown
this.leadPersonSearchInput = page.getByPlaceholder("Search by name, email and number");
// Lead form - product search dropdown
this.leadProductSearchInput = page.getByRole('textbox', { name: 'Product Name' });
// Add person
this.personEmailInput = page.locator('input[name="person[emails][0][value]"]');
this.personPhoneInput = page.locator('input[name="person[contact_numbers][0][value]"]');
this.successMessage = page.getByText('Success');
// Add organization
this.addOrganizationButton = page.locator('div', { hasText: /^Click to add$/ }).nth(2);
this.saveLeadButton = page.getByRole('button', { name: 'Save' });
this.organizationCreateSuccessMessage = page.getByText('Organization created successfully');
// General
this.leadSuccessToast = page.getByText('Success', { exact: true });
this.editLeadButton = page.getByRole('link', { name: '' }).first();
this.listViewButton = page.getByRole('link', { name: '' });
this.agreeButton = page.getByRole('button', { name: 'Agree', exact: true });
this.deleteLeadButton = page.locator(
'.cursor-pointer.rounded-md.p-1\\.5.text-2xl.transition-all.hover\\:bg-gray-200.dark\\:hover\\:bg-gray-800.max-sm\\:place-self-center.icon-delete'
).first();
this.searchInputField = page.getByRole("textbox", { name: "Search..." });
this.searchInputExact = page.getByRole("textbox", { name: "Search", exact: true });
// Tabs
this.mailButton = page.getByRole('button', { name: ' Mail' });
this.fileButton = page.getByRole('button', { name: ' File' });
this.noteButton = page.getByRole('button', { name: ' Note' });
this.activityButton = page.getByRole('button', { name: ' Activity' });
// Mail
this.mailToInput = page.locator('input[name="temp-reply_to"]');
this.mailSubjectInput = page.locator('input[name="subject"]');
this.mailBodyTextarea = page.locator('textarea[name="reply"]');
this.sendMailButton = page.getByRole('button', { name: 'Send' });
// File
this.fileTitleInput = page.locator('input[name="title"]');
this.fileCommentTextarea = page.locator('textarea[name="comment"]');
this.fileNameInput = page.locator('input[name="name"]');
this.saveFileButton = page.getByRole('button', { name: 'Save File' });
// Note
this.noteCommentTextarea = page.locator('textarea[name="comment"]');
this.saveNoteButton = page.getByRole('button', { name: 'Save Note' });
// Activity
this.addActivityTitleInput = page.locator('input[name="title"]');
this.addActivityCommentTextarea = page.locator('textarea[name="comment"]');
this.scheduleFromInput = page.locator('input[name="schedule_from"]');
this.scheduleToInput = page.locator('input[name="schedule_to"]');
this.locationInput = page.locator('input[name="location"]');
this.saveActivityButton = page.getByRole('button', { name: 'Save Activity' });
// Validation message
this.expectedCloseDateMustBeDateAfter = page.getByText('The expected close date must be a date after');
// Other
this.appLocator = page.locator("#app");
// Quick Add
this.quickAddTrigger = page.locator("header button.bg-brandColor").first();
this.quickAddModalTitle = page.locator("p").filter({ hasText: "Quick Add" });
this.quickAddTab = (tabLabel: string) => page.locator("span.cursor-pointer").filter({ hasText: new RegExp(`^${tabLabel}$`) }).first();
this.quickAddSaveButton = page.getByRole("button", { name: "Save", exact: true }).last();
this.quickAddLeadForm = page.locator('form:has(input[value="lead"])').first();
this.quickAddPersonForm = page.locator('form:has(input[value="person"])').first();
this.quickAddOrganizationForm = page.locator('form:has(input[value="organization"])').first();
this.quickAddProductForm = page.locator('form:has(input[value="product"])').first();
this.quickAddMailForm = page.locator('form:has(input[name="is_draft"])').first();
this.quickAddLeadTitleInput = this.quickAddLeadForm.locator('input[name="title"]');
this.quickAddLeadDescriptionTextarea = this.quickAddLeadForm.locator('textarea[name="description"]');
this.quickAddPersonNameInput = this.quickAddPersonForm.locator('input[name="name"]');
this.quickAddPersonEmailInput = this.quickAddPersonForm.locator('input[name="emails[0][value]"]');
this.quickAddPersonContactInput = this.quickAddPersonForm.locator('input[name="contact_numbers[0][value]"]');
this.quickAddOrgNameInput = this.quickAddOrganizationForm.locator('input[name="name"]');
this.quickAddProductNameInput = this.quickAddProductForm.locator('input[name="name"]');
this.quickAddProductDescriptionTextarea = this.quickAddProductForm.locator('textarea[name="description"]');
this.quickAddProductSkuInput = this.quickAddProductForm.locator('input[name="sku"]');
this.quickAddProductQuantityInput = this.quickAddProductForm.locator('input[name="quantity"]');
this.quickAddProductPriceInput = this.quickAddProductForm.locator('input[name="price"]');
this.quickAddMailReplyToInput = this.quickAddMailForm.locator('input[name="temp-reply_to"]');
this.quickAddMailCcInput = this.quickAddMailForm.locator('input[name="temp-cc"]');
this.quickAddMailBccInput = this.quickAddMailForm.locator('input[name="temp-bcc"]');
this.quickAddMailCcButton = this.quickAddMailForm.getByText('CC', { exact: true });
this.quickAddMailBccButton = this.quickAddMailForm.getByText('BCC', { exact: true });
this.quickAddMailSubjectInput = this.quickAddMailForm.locator('input[name="subject"]');
this.quickAddMailEditorFrame = this.quickAddMailForm.frameLocator('iframe.tox-edit-area__iframe');
this.quickAddLeadSuccessMsg = page.getByText("Lead created successfully.");
this.quickAddPersonSuccessMsg = page.getByText("Person created successfully.");
this.quickAddOrgSuccessMsg = page.getByText("Organization created successfully.");
this.quickAddProductSuccessMsg = page.getByText("Product created successfully.");
this.quickAddMailSuccessMsg = page.getByText("Email sent successfully.");
}
async searchByName(name: string) {
(await this.getElementByTypeAndName('textbox', 'Search')).fill(name);
await this.page.keyboard.press('Enter');
}
async getElementByTypeAndName(type: ElementType, name: string) {
await expect(this.page.getByRole(`${type}`, { name: `${name}`, exact: true })).toBeVisible();
return this.page.getByRole(`${type}`, { name: `${name}`, exact: true });
}
async selectListItmeByName(value: string) {
return this.page.getByRole('listitem').filter({ hasText: `${value}` });
}
async massDelete() {
await this.multiSelectCheckbox.click();
if(await this.deleteButton.isVisible()) {
await this.deleteButton.click();
await this.agreeButton.click();
}
await expect(this.noRecordsAvailable).toBeVisible();
}
}

View File

@@ -0,0 +1,146 @@
import { expect, Locator, Page } from "@playwright/test";
import CoreLocators from "../locator/CoreLocators";
import { QuickAddPage } from "./QuickAddPage";
/**
* Page object for asserting what a signed-in user is allowed to see and reach.
*
* The three enforcement layers are kept as separate methods on purpose, because they answer
* different questions and a suite that conflates them proves less than it looks:
*
* - `expectNavVisible` / `expectNavHidden` — menu filtering (presentation only)
* - `expectControlHidden` — Blade `bouncer()->hasPermission()` (presentation only)
* - the route probes in `utils/acl.ts` — the middleware (the real security boundary)
*/
export class AclPage extends CoreLocators {
readonly page: Page;
readonly unauthorizedHeading: Locator;
readonly datagridRows: Locator;
constructor(page: Page) {
super(page);
this.page = page;
this.unauthorizedHeading = page.getByText("401 Unauthorized");
this.datagridRows = page.locator("div.row.grid.grid-cols-\\[2fr_1fr\\]");
}
/**
* A left-navigation entry, matched by the path it links to.
*
* The label is not a usable anchor: each sidebar link wraps an icon span and a `<p>`, so its
* accessible name does not match the menu label cleanly. The href comes straight from the menu
* config's route, which is also what `Menu::getItems()` filters on — so this asserts against
* the same thing the code decides.
*/
navItem(path: string): Locator {
return this.page.locator(`nav a[href$="${path}"], aside a[href$="${path}"]`);
}
async expectNavVisible(path: string): Promise<void> {
await expect(
this.navItem(path).first(),
`a navigation entry for ${path} should appear for a user holding its permission`
).toBeVisible();
}
async expectNavHidden(path: string): Promise<void> {
await expect(
this.navItem(path),
`a navigation entry for ${path} must not appear without its permission`
).toHaveCount(0);
}
/**
* Assert a control is absent from the page. Used for create/edit/delete buttons that Blade
* wraps in a permission check.
*/
async expectControlHidden(name: string): Promise<void> {
await expect(
this.page.getByRole("button", { name }),
`"${name}" must not be rendered without its permission`
).toHaveCount(0);
}
async expectControlVisible(name: string): Promise<void> {
await expect(
this.page.getByRole("button", { name }).first(),
`"${name}" should be rendered for a user holding its permission`
).toBeVisible();
}
/**
* Assert the browser landed on the unauthorized page rather than the requested screen.
*/
async expectUnauthorizedPage(): Promise<void> {
await expect(this.unauthorizedHeading.first()).toBeVisible();
}
/**
* Visit a listing and return the visible row text, so a scope assertion can check which
* records the grid actually returned.
*/
async visibleRowText(path: string): Promise<string> {
await this.page.goto(path);
await this.page.waitForLoadState("networkidle");
return (await this.page.locator("body").innerText()).toLowerCase();
}
/**
* Assert a listing contains a record's identifying text.
*/
async expectRecordListed(path: string, title: string): Promise<void> {
const body = await this.visibleRowText(path);
expect(body, `"${title}" should be visible in ${path} for this data scope`).toContain(
title.toLowerCase()
);
}
/**
* Assert a listing does not contain a record's identifying text — the core view-permission
* assertion.
*/
async expectRecordNotListed(path: string, title: string): Promise<void> {
const body = await this.visibleRowText(path);
expect(body, `"${title}" must be hidden in ${path} for this data scope`).not.toContain(
title.toLowerCase()
);
}
/**
* Create a person owned by whoever is signed in.
*
* Quick add is used rather than the full create form for one reason: `PersonController::store()`
* assigns `user_id` from the acting user only on the `quick_add` path. Submitting the standard
* form without picking a Sales Owner saves the person with a null owner, which makes it
* invisible to every non-global user — including its own creator — and would make these scope
* assertions fail for a reason that has nothing to do with `view_permission`.
*/
async createPerson(name: string, email: string, phone?: string): Promise<void> {
const quickAdd = new QuickAddPage(this.page);
await this.page.goto("admin/dashboard").catch(() => undefined);
await quickAdd.createPerson({
name,
email,
/**
* Email and phone are both unique across persons, so a shared literal would fail
* validation with "The value has already been taken." from the second person onward.
*/
contactNumber: phone ?? AclPage.uniquePhone(),
});
}
/**
* A phone number unique to this call, within the 10 digits the form accepts.
*/
static uniquePhone(): string {
return `9${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 900 + 100)}`;
}
}

View File

@@ -0,0 +1,47 @@
import { Page, Locator, expect } from "@playwright/test";
import CoreLocators from "../locator/CoreLocators";
export class AdminPage extends CoreLocators {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly signInButton: Locator;
readonly megaSearchInput: Locator;
readonly profileButton: Locator;
readonly signOutLink: Locator;
constructor(page: Page) {
super(page)
this.page = page;
this.emailInput = page.getByPlaceholder("Email Address");
this.passwordInput = page.getByPlaceholder("Password");
this.signInButton = page.getByRole("button", { name: "Sign In" });
this.megaSearchInput = page.getByPlaceholder("Mega Search").first();
this.profileButton = page.getByRole("button", { name: "E" });
this.signOutLink = page.getByRole("link", { name: "Sign Out" });
}
async adminLogin(email: string, password: string) {
await this.page.goto('admin/login');
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
async verifyLogin() {
await expect(this.megaSearchInput).toBeVisible();
}
async adminLogout() {
await this.page.waitForTimeout(1000);
await this.profileButton.click();
await this.signOutLink.click();
}
async verifyLogout() {
await expect(this.passwordInput.first()).toBeVisible();
}
}

View File

@@ -0,0 +1,168 @@
import { Page, Locator, expect } from "@playwright/test";
import CoreLocators from "../locator/CoreLocators";
import { buildPersonData, PersonData } from "./PersonsPage";
import { buildProductData, ProductData, ProductPage } from "./ProductPage";
import { generateDescription, generateName } from "../utils/faker";
export type LeadData = {
title: string;
description: string;
value: string;
expectedCloseDate: string; // ISO date string
person: PersonData;
organizationName: string;
product: ProductData;
};
const leadPersonData: PersonData = buildPersonData();
const leadProductData: ProductData = buildProductData();
export const leadData: LeadData = {
title: generateName(),
description: generateDescription(),
value: (Math.floor(Math.random() * 10000)).toString(),
expectedCloseDate: "2028-12-31",
/**
* The lead flow owns its person outright, so it never collides with the one the person or
* quote specs create.
*/
person: leadPersonData,
/**
* The lead flow owns its product too — the quote and product specs each create one of their own.
*/
product: leadProductData,
organizationName: leadPersonData.organizationName
};
export class LeadPage extends CoreLocators {
readonly page: Page;
constructor(page: Page) {
super(page);
this.page = page;
}
async navigateToLeadList() {
await this.page.goto("admin/leads");
}
async getLeadByTitle(title: string) {
return this.page.getByRole('link', { name: ` ${title}` });
}
async createLead(leadData: LeadData) {
const productPage = new ProductPage(this.page);
await productPage.navigateToProductPage();
await productPage.createProductLink.click()
await productPage.productForm(leadData.product);
await this.navigateToLeadList();
await this.createLeadButton.first().click();;
await this.titleInput.fill(leadData.title);
await this.descriptionTextarea.fill(leadData.description);
await this.sourceDropdown.selectOption("1");
await this.typeDropdown.selectOption("1");
await this.userDropdown.selectOption("1");
await this.leadValueInput.fill(leadData.value);
await this.addPersonButton.click();
await this.leadPersonSearchInput.fill(leadData.person.name);
await this.page.waitForTimeout(1000);
const personItem = (await this.selectListItmeByName(leadData.person.name)).first();
await this.page.waitForTimeout(1000);
const isPersonAlreadyPresent = await personItem.isVisible();
if (isPersonAlreadyPresent) {
await personItem.click();
console.log("person allready present");
}
else {
await this.addAsNewButton.click();
await this.personEmailInput.fill(leadData.person.emails);
await this.personPhoneInput.fill(leadData.person.contactNumber);
await this.addOrganizationButton.click(); // this an issue add organiztion is not working if person allready present so that i have added this into else condition.
await this.searchInputField.fill(leadData.title);
await this.addAsNewButton.click();
}
// await this.leadProductAddMoreButton.click();
await this.leadProductSelect.click();
await this.leadProductSearchInput.fill(leadData.product.name);
await this.page.waitForTimeout(1000);
await this.page.locator(`//li[@class="cursor-pointer px-4 py-2 text-gray-800 transition-colors hover:bg-blue-100 dark:text-white dark:hover:bg-gray-900"]`).first().click();
(await this.getElementByTypeAndName('button', "Save")).click();
await this.searchInput.fill(leadData.title);
await this.page.keyboard.press('Enter');
await expect(this.page.getByText(leadData.title).first()).toBeVisible();
await expect(this.leadSuccessToast).toBeVisible();
}
async updateLead(leadData: LeadData) {
// Now update the lead with new data
// Fill updated lead data
// Use locators from LeadPage via page1 context to fill fields
await this.titleInput.fill(leadData.title);
await this.descriptionTextarea.fill(leadData.description);
await this.sourceDropdown.selectOption("1");
await this.typeDropdown.selectOption("1");
await this.userDropdown.selectOption("1");
await this.leadValueInput.fill("1000");
// await this.leadProductAddMoreButton.click();
await this.leadProductSelect.click();
await this.leadProductSearchInput.fill(leadData.product.name);
await this.page.waitForTimeout(1000);
await this.page.locator(`//li[@class="cursor-pointer px-4 py-2 text-gray-800 transition-colors hover:bg-blue-100 dark:text-white dark:hover:bg-gray-900"]`).first().click();
(await this.getElementByTypeAndName('button', "Save")).click();
await this.searchInput.fill(leadData.title);
await this.page.keyboard.press('Enter');
await expect(((await this.getLeadByTitle(leadData.title)).first())).toBeVisible();
await expect(this.leadSuccessToast).toBeVisible();
}
async deleteLead(leadData: LeadData) {
await this.listViewButton.click();
await this.page.waitForTimeout(1000);
await this.listViewLeadSearch.fill(leadData.title);
await this.page.keyboard.press('Enter');
await this.page.waitForTimeout(1000);
await this.deleteLeadButton.isVisible();
await this.deleteLeadButton.click();
await this.agreeButton.click();
await this.listViewLeadSearch.fill(leadData.title);
await this.page.keyboard.press('Enter');
await expect(this.deleteLeadButton).not.toBeVisible();
}
async searchLead(title: string) {
await this.navigateToLeadList();
await this.searchInput.fill(title);
await this.page.keyboard.press('Enter');
await this.page.waitForTimeout(1000);
await (await this.getLeadByTitle(title)).first().click();
await this.page.waitForLoadState('domcontentloaded');
const page1Promise = this.page.waitForEvent('popup');
await this.editLeadButton.click();
const page1 = await page1Promise;
const leadPage = new LeadPage(page1);
return leadPage;
}
async listView() {
await this.page.goto('admin/leads?view_type=table');
await this.page.waitForLoadState('domcontentloaded');
}
}

View File

@@ -0,0 +1,139 @@
import { Page, Locator, FrameLocator, expect } from '@playwright/test';
import CoreLocators from '../locator/CoreLocators';
import { generateDescription, generateEmail, generateName } from '../utils/faker';
export type MailData = {
replyTo: string;
subject: string;
body: string;
};
export const mailData: MailData = {
replyTo: generateEmail(),
subject: generateName(),
body: generateDescription().slice(0, 50),
};
export type emailType = 'bcc' | 'cc' | 'normal'|'both';
export class MailPage extends CoreLocators {
readonly page: Page;
readonly composeEmailButton: Locator;
readonly replyToInput: Locator;
readonly subjectInput: Locator;
readonly sendButton: Locator;
readonly successMessage: Locator;
readonly editorFrame: FrameLocator;
readonly saveDraftButton: Locator;
readonly moveToTrashButton: Locator;
readonly ccMailInput: Locator;
readonly bccMailInput: Locator;
readonly ccButton: Locator;
readonly bccButton: Locator;
constructor(page: Page) {
super(page);
this.page = page;
this.composeEmailButton = page.getByRole('button', { name: 'Compose Mail' });
this.replyToInput = page.getByLabel(/reply to/i).or(page.locator('input[name="temp-reply_to"]'));
this.subjectInput = page.getByLabel(/subject/i).or(page.locator('input[name="subject"]'));
this.sendButton = page.getByRole("button", { name: "Send" });
this.successMessage = page.getByText("Email sent successfully.");
this.editorFrame = page.frameLocator("iframe.tox-edit-area__iframe");
this.saveDraftButton = page.getByRole("button", { name: "Draft" });
this.moveToTrashButton = page.getByRole('button', { name: 'Moved To Trash' })
this.ccMailInput = page.locator('input[name="temp-cc"]');
this.bccMailInput = page.locator('input[name="temp-bcc"]');
this.ccButton = page.getByText('CC', { exact: true });
this.bccButton = page.getByText('BCC', { exact: true });
}
async navigateToMailInboxPage() {
await this.page.goto("admin/mail/inbox");
}
async navigateToMailDraftPage() {
await this.page.goto("admin/mail/draft");
}
async navigateToMailTrashedPage() {
await this.page.goto("admin/mail/trash");
}
async composeEmail(mailData: MailData, type: emailType) {
await this.composeEmailButton.click();
await this.page.waitForTimeout(500);
if (type === 'cc') {
await this.ccButton.click();
await this.ccMailInput.waitFor({ state: 'visible', timeout: 5000 });
await this.ccMailInput.fill("cc" + mailData.replyTo);
} else if (type === 'bcc') {
await this.bccButton.click();
await this.bccMailInput.waitFor({ state: 'visible', timeout: 5000 });
await this.bccMailInput.fill("bcc" + mailData.replyTo);
} else if (type === 'both') {
await this.ccButton.click();
await this.bccButton.click();
await this.bccMailInput.waitFor({ state: 'visible', timeout: 5000 });
await this.ccMailInput.waitFor({ state: 'visible', timeout: 5000 });
await this.ccMailInput.fill("cc" + mailData.replyTo);
await this.bccMailInput.fill("bcc" + mailData.replyTo);
}
await this.replyToInput.fill(mailData.replyTo);
await this.subjectInput.fill(mailData.subject);
await this.editorFrame.locator('body').fill(mailData.body);
await this.sendButton.click();
}
async saveDraftEmail(mailData: MailData, type: emailType) {
await this.composeEmailButton.click();
await this.page.waitForTimeout(500);
if (type === 'cc') {
await this.ccButton.click();
await this.ccMailInput.waitFor({ state: 'visible', timeout: 5000 });
await this.ccMailInput.fill("cc" + mailData.replyTo);
} else if (type === 'bcc') {
await this.bccButton.click();
await this.bccMailInput.waitFor({ state: 'visible', timeout: 5000 });
await this.bccMailInput.fill("bcc" + mailData.replyTo);
} else if (type === 'both') {
await this.ccButton.click();
await this.bccButton.click();
await this.ccMailInput.waitFor({ state: 'visible', timeout: 5000 });
await this.ccMailInput.fill("cc" + mailData.replyTo);
await this.bccMailInput.waitFor({ state: 'visible', timeout: 5000 });
await this.bccMailInput.fill("bcc" + mailData.replyTo);
}
await this.replyToInput.fill(mailData.replyTo);
await this.subjectInput.fill(mailData.subject);
await this.editorFrame.locator('body').fill(mailData.body);
await this.saveDraftButton.click();
await this.navigateToMailDraftPage();
await this.searchEmail(mailData.subject);
await expect(this.page.getByRole('paragraph').filter({ hasText: mailData.subject }).first()).toBeVisible();
}
async movedEmailToTrash() {
await this.page.waitForTimeout(500);
await this.multiSelectCheckbox.click();
await this.page.waitForTimeout(500);
await this.moveToTrashButton.click();
await this.agreeButton.click();
}
async searchEmail(subject: string) {
await this.searchInputExact.fill(subject);
await this.page.keyboard.press('Enter');
}
async deleteEmailFromTrash(subject: string) {
await this.searchEmail(subject);
await this.page.waitForTimeout(500);
await this.multiSelectCheckbox.click();
await this.page.waitForTimeout(500);
await this.deleteButton.click();
await this.agreeButton.click();
}
}

View File

@@ -0,0 +1,99 @@
import { expect, Locator, Page } from "playwright/test";
import CoreLocators from "../locator/CoreLocators";
import { generateFirstName, generateLocation, generateName } from "../utils/faker";
export type OrganizationData = {
name: string;
address: string;
country: string; // country code, e.g. 'IN'
state: string; // state code, e.g. 'DL'
city: string;
postcode: string;
};
export const organizationData: OrganizationData = {
name: generateName(),
address: generateLocation() ,
country: "IN", // India country code
state: "DL", // Delhi state code
city: "New Delhi",
postcode: "110015",
};
export default class OrganizationPage extends CoreLocators{
readonly page:Page
readonly firstOrganization:Locator;
constructor(page:Page)
{
super(page)
this.page=page
this.firstOrganization= page.locator(`(//p[@class="break-words"])[1]`);
}
async navigateToOrganization()
{
await this.page.goto("admin/contacts/organizations");
}
async createOrganization(orgData:OrganizationData) {
await this.createOrgLink.click();
// Fill organization details
await this.orgNameTextbox.fill(orgData.name);
await this.orgAddressTextarea.fill(orgData.address);
await this.orgCountryCombobox.selectOption(orgData.country);
await this.orgStateSelect.selectOption(orgData.state);
await this.orgCityTextbox.fill(orgData.city);
await this.orgPostcodeTextbox.fill(orgData.postcode);
await this.orgExtraDetailsDiv.nth(2).click();
await this.searchInputField.fill('exampl');
await this.orgExampleListItem('Example').click();
// Save organization
await this.saveOrganizationButton.click();
await expect(this.organizationCreateSuccessMessage.first()).toBeVisible();
// await this.searchInputExact.fill(orgData.name);
// await this.page.keyboard.press('Enter');
console.log("There’s an issue: the organization search isn’t working and needs to be fixed.");
await expect(this.page.getByText(orgData.name).first()).toBeVisible();
}
async updateOrganization(orgData:OrganizationData)
{
await this.firstEditIcon.click();
// Fill organization details
await this.orgNameTextbox.fill(orgData.name);
await this.orgAddressTextarea.fill(orgData.address);
await this.orgCountryCombobox.first().selectOption(orgData.country);
await this.orgStateSelect.selectOption(orgData.state);
await this.orgCityTextbox.fill(orgData.city);
await this.orgPostcodeTextbox.fill(orgData.postcode);
// Save organization
await this.saveOrganizationButton.click();
return orgData;
}
async deleteOrganization(){
await this.firstDeleteIcon.click();
await this.agreeButton.click();
}
}

View File

@@ -0,0 +1,121 @@
import { expect, Locator, Page } from "playwright/test";
import CoreLocators from "../locator/CoreLocators";
import { generateEmail, generateFullName, generateName, generatePhoneNumber } from "../utils/faker";
import { organizationData } from "../pages/OrganizationPage";
export type PersonData = {
name: string;
emails: string; // array to hold multiple emails
contactNumber: string; // array to hold multiple contact numbers
jobTitle: string;
salesOwnerId: string; // id or value representing Sales Owner
organizationName: string;
};
/**
* Build a person with an email and contact number unique to this call.
*
* Both fields are unique in Krayin, so two persons built from one shared object cannot both be
* saved — the second submit fails with "The value has already been taken." Each spec that creates
* a person therefore needs its own instance, while keeping that instance stable *within* the spec
* so the create / edit / delete cases all address the same record.
*/
export function buildPersonData(overrides: Partial<PersonData> = {}): PersonData {
return {
name: generateName(),
emails: generateEmail(),
contactNumber: generatePhoneNumber(),
jobTitle: generateName(),
salesOwnerId: "1", // Example sales owner id
organizationName: organizationData.name,
...overrides,
};
}
/**
* Default instance kept for existing importers. Do not create a person from this in more than one
* spec — build a fresh instance instead.
*/
export const personData: PersonData = buildPersonData();
export default class PersonsPage extends CoreLocators {
readonly page: Page;
readonly firstPerson:Locator;
constructor(page: Page) {
super(page),
this.page = page
this.firstPerson= page.locator(`(//div[@class="flex flex-col gap-1.5 dark:text-gray-300"])[1]`);
}
async navigageToPersonsPage() {
await this.page.goto("admin/contacts/persons");
}
async createPerson(personData: PersonData) {
await this.createPersonLink.click();
// Fill person details (use first email/phone if multiple provided)
await this.personNameTextbox.fill(personData.name);
if (personData.emails && personData.emails.length > 0) {
await this.personEmailTextbox.fill(personData.emails);
}
if (personData.contactNumber && personData.contactNumber.length > 0) {
await this.personPhoneTextbox.fill(personData.contactNumber);
}
await this.personJobTitleTextbox.fill(personData.jobTitle || "");
// Assign organization (if provided)
if (personData.organizationName) {
await this.personOrgSelectDiv.click();
await this.searchInputField.fill("Examp");
await this.personOrgListItem("Examp").click();
}
// Save person
await this.savePersonButton.click();
await this.searchInputExact.fill(personData.name);
await this.page.keyboard.press('Enter');
await expect(this.page.getByText(personData.name).first()).toBeVisible();
}
async updatePerson(personData: PersonData) {
await this.searchInputExact.fill(personData.name);
await this.page.keyboard.press('Enter');
await this.firstEditIcon.click();
// Fill person details (use first email/phone if multiple provided)
await this.personNameTextbox.fill(personData.name);
if (personData.emails && personData.emails.length > 0) {
await this.personEmailTextbox.fill(personData.emails);
}
if (personData.contactNumber && personData.contactNumber.length > 0) {
await this.personPhoneTextbox.fill(personData.contactNumber);
}
await this.personJobTitleTextbox.fill(personData.jobTitle || "");
// Assign organization (if provided)
if (personData.organizationName) {
await this.personOrgSelectDiv.click();
await this.searchInputField.fill("Examp");
await this.personOrgListItem("Examp").click();
}
// Save person
await this.savePersonButton.click();
await this.searchInputExact.fill(personData.name);
await expect(this.page.getByText(personData.name).first()).toBeVisible();
}
async personDelete() {
await this.firstDeleteIcon.click();
await this.agreeButton.click();
await expect(this.successMessage.first()).toBeVisible();
}
async personMassDelete() {
await this.multiSelectCheckbox.click();
await this.deleteButton.click();
await this.agreeButton.click();
await expect(this.successMessage.first()).toBeVisible();
}
}

View File

@@ -0,0 +1,86 @@
import { expect, Page } from "playwright/test";
import CoreLocators from "../locator/CoreLocators";
import { generateDescription, generateName, generateSKU } from "../utils/faker";
import { saveData } from "../utils/savedata";
export type ProductData = {
name: string,
description: string,
sku: string,
price: string,
priceInDollars?: string, // Optional if price is stored in a different currency
quantity: string,
}
/**
* Build a product with a SKU unique to this call.
*
* SKU is unique in Krayin, so two products built from one shared object cannot both be saved. The
* product, lead and quote specs each create a product, so each needs its own instance — sharing one
* makes whichever spec runs later fail on the product step.
*/
export function buildProductData(overrides: Partial<ProductData> = {}): ProductData {
return {
name: generateName(),
description: generateDescription(),
sku: generateSKU(),
price: Math.floor(Math.random() * 1000).toString(),
quantity: Math.floor(Math.random() * 100).toString(),
...overrides,
};
}
/**
* Default instance kept for existing importers. Do not create a product from this in more than one
* spec — build a fresh instance instead.
*/
export const productData: ProductData = buildProductData();
export class ProductPage extends CoreLocators {
page: Page;
constructor(page: Page) {
super(page);
this.page = page
}
async navigateToProductPage() {
await this.page.goto("admin/products");
}
async productForm(productdata: ProductData) {
// Fill product form using this
await this.productNameTextbox.waitFor({ state: "visible" });
await this.productNameTextbox.click();
await this.productNameTextbox.fill(productdata.name);
await this.productDescriptionTextarea.fill("");
await this.productDescriptionTextarea.type(productdata.description);
await this.productSkuInput.fill(productdata.sku);
await this.productPriceInput.fill(productdata.price);
await this.productQuantityInput.fill(productdata.quantity);
// Save product
await this.saveProductsButton.click();
await expect(this.successMessage.first()).toBeVisible();
saveData("productData", productdata);
// await this.searchProduct(productdata.name); /* searching is not working now once the issue is fixed uncomment this line */
// await expect(this.page.getByText(productdata.name).first()).toBeVisible();
}
async searchProduct(productName: string) {
await this.searchInputExact.fill(productName);
await this.page.keyboard.press('Enter');
}
async deleteProduct()
{
await this.firstDeleteIcon.click();
await this.agreeButton.click();
await expect(this.successMessage.first()).toBeVisible();
}
}

View File

@@ -0,0 +1,199 @@
import { Page, expect } from "@playwright/test";
import CoreLocators from "../locator/CoreLocators";
import {
generateDescription,
generateEmail,
generateFullName,
generateName,
generatePhoneNumber,
generateRandomNumericString,
generateSKU,
} from "../utils/faker";
export type QuickAddTab = "Lead" | "Person" | "Organization" | "Product" | "Email";
export type QuickAddLeadData = {
title: string;
description: string;
};
export type QuickAddPersonData = {
name: string;
email: string;
contactNumber: string;
};
export type QuickAddOrganizationData = {
name: string;
};
export type QuickAddProductData = {
name: string;
description: string;
sku: string;
quantity: string;
price: string;
};
export type QuickAddMailData = {
replyTo: string;
subject: string;
body: string;
};
export const quickAddLeadData: QuickAddLeadData = {
title: generateName(),
description: generateDescription(),
};
export const quickAddPersonData: QuickAddPersonData = {
name: generateFullName(),
email: generateEmail(),
contactNumber: generatePhoneNumber(),
};
export const quickAddOrganizationData: QuickAddOrganizationData = {
name: generateName() + " Inc",
};
export const quickAddProductData: QuickAddProductData = {
name: "Product " + generateName(),
description: generateDescription(),
sku: generateSKU(),
quantity: generateRandomNumericString(2, 10, 50),
price: generateRandomNumericString(3, 100, 500),
};
export const quickAddMailData: QuickAddMailData = {
replyTo: generateEmail(),
subject: generateName(),
body: generateDescription().slice(0, 50),
};
export class QuickAddPage extends CoreLocators {
readonly page: Page;
constructor(page: Page) {
super(page);
this.page = page;
}
async openQuickAddModal() {
await this.page.goto("admin/dashboard");
await this.quickAddTrigger.waitFor({ state: "visible" });
await this.quickAddTrigger.click();
await expect(this.quickAddModalTitle).toBeVisible();
}
async selectQuickAddTab(tabLabel: QuickAddTab) {
await this.quickAddTab(tabLabel).click();
}
async submitQuickAdd() {
await this.quickAddSaveButton.click();
}
/**
* Every quick add tab exposed by the seeded admin role must be reachable.
*/
async verifyQuickAddTabs(tabs: QuickAddTab[]) {
await this.openQuickAddModal();
for (const tab of tabs) {
await expect(this.quickAddTab(tab)).toBeVisible();
}
}
async createLead(leadData: QuickAddLeadData) {
await this.openQuickAddModal();
await this.selectQuickAddTab("Lead");
await this.quickAddLeadTitleInput.fill(leadData.title);
await this.quickAddLeadDescriptionTextarea.fill(leadData.description);
await this.submitQuickAdd();
await expect(this.quickAddLeadSuccessMsg).toBeVisible();
await this.page.goto("admin/leads");
await this.searchInput.fill(leadData.title);
await this.page.keyboard.press("Enter");
await expect(this.page.getByText(leadData.title).first()).toBeVisible();
}
async createPerson(personData: QuickAddPersonData) {
await this.openQuickAddModal();
await this.selectQuickAddTab("Person");
await this.quickAddPersonNameInput.fill(personData.name);
await this.quickAddPersonEmailInput.fill(personData.email);
await this.quickAddPersonContactInput.fill(personData.contactNumber);
await this.submitQuickAdd();
await expect(this.quickAddPersonSuccessMsg).toBeVisible();
await this.page.goto("admin/contacts/persons");
await this.searchByName(personData.name);
await expect(this.page.getByText(personData.name).first()).toBeVisible();
}
async createOrganization(organizationData: QuickAddOrganizationData) {
await this.openQuickAddModal();
await this.selectQuickAddTab("Organization");
await this.quickAddOrgNameInput.fill(organizationData.name);
await this.submitQuickAdd();
await expect(this.quickAddOrgSuccessMsg).toBeVisible();
await this.page.goto("admin/contacts/organizations");
await this.searchByName(organizationData.name);
await expect(this.page.getByText(organizationData.name).first()).toBeVisible();
}
async createProduct(productData: QuickAddProductData) {
await this.openQuickAddModal();
await this.selectQuickAddTab("Product");
await this.quickAddProductNameInput.fill(productData.name);
await this.quickAddProductDescriptionTextarea.fill(productData.description);
await this.quickAddProductSkuInput.fill(productData.sku);
await this.quickAddProductQuantityInput.fill(productData.quantity);
await this.quickAddProductPriceInput.fill(productData.price);
await this.submitQuickAdd();
await expect(this.quickAddProductSuccessMsg).toBeVisible();
await this.page.goto("admin/products");
await this.searchByName(productData.name);
await expect(this.page.getByText(productData.name).first()).toBeVisible();
}
async createMail(mailData: QuickAddMailData) {
await this.openQuickAddModal();
await this.selectQuickAddTab("Email");
await this.quickAddMailReplyToInput.fill(mailData.replyTo);
await this.quickAddMailSubjectInput.fill(mailData.subject);
await this.quickAddMailEditorFrame.locator("body").fill(mailData.body);
await this.submitQuickAdd();
await expect(this.quickAddMailSuccessMsg).toBeVisible();
}
/**
* Validation errors must keep the modal mounted instead of closing it.
*/
async submitEmptyForm(tabLabel: QuickAddTab) {
await this.openQuickAddModal();
await this.selectQuickAddTab(tabLabel);
await this.submitQuickAdd();
await expect(this.quickAddModalTitle).toBeVisible();
}
}

View File

@@ -0,0 +1,204 @@
import { expect, Locator, Page } from "@playwright/test";
import CoreLocators from "../locator/CoreLocators";
import { buildProductData, ProductData } from "./ProductPage";
import { generateDescription, generateFullName } from "../utils/faker";
import PersonsPage, { buildPersonData, PersonData } from "./PersonsPage";
export type QuoteData = {
subject: string;
description: string;
salesOwnerId: string;
expiredAt: string; // expiration date string
person: PersonData;
leadName: string;
address: string;
countryCode: string; // e.g. 'IN'
stateCode: string; // e.g. 'DL'
city: string;
postcode: string;
product: ProductData;
// Add quote items type here if needed
};
const quotePersonData: PersonData = buildPersonData();
const quoteProductData: ProductData = buildProductData();
export const quoteData: QuoteData = {
subject: generateFullName(),
description: generateDescription(),
salesOwnerId: "1",
expiredAt: "2026-02-28",
/**
* A person of its own, for the same reason the lead flow has one.
*/
person: quotePersonData,
leadName: "Corporate Website Lead",
address: "Plot 45, Industrial Area",
countryCode: "IN",
stateCode: "DL",
city: "New Delhi",
postcode: "110020",
product: quoteProductData,
// Optionally add quote items array if needed
};
export class QuotesPage extends CoreLocators {
readonly page: Page;
// Navigation links/buttons
readonly createQuoteLink: Locator;
readonly saveQuoteButton: Locator;
// Form fields
readonly subjectTextbox: Locator;
readonly descriptionTextbox: Locator;
readonly salesOwnerSelect: Locator;
readonly expiredAtTextbox: Locator;
// Person search and selection
readonly searchTextbox: Locator;
// Billing address fields
readonly billingAddressTextarea: Locator;
readonly billingCountrySelect: Locator;
readonly billingStateSelect: Locator;
readonly billingCityInput: Locator;
readonly billingPostcodeInput: Locator;
// Shipping address fields
readonly shippingAddressTextarea: Locator;
readonly shippingCountrySelect: Locator;
readonly shippingStateSelect: Locator;
readonly shippingCityInput: Locator;
readonly shippingPostcodeInput: Locator;
// Confirmation/Sucess notification locator
constructor(page: Page) {
super(page);
this.page = page;
this.subjectTextbox = page.getByRole("textbox", { name: "Subject *" });
this.descriptionTextbox = page.getByRole("textbox", { name: "Description" });
this.searchTextbox = page.getByRole("textbox", { name: "Search..." });
this.expiredAtTextbox = page.getByRole("textbox", { name: "Expired At *" });
this.billingAddressTextarea = page.locator('textarea[name="billing_address\\[address\\]"]');
this.shippingAddressTextarea = page.locator('textarea[name="shipping_address\\[address\\]"]');
this.billingCityInput = page.locator('input[name="billing_address\\[city\\]"]');
this.billingPostcodeInput = page.locator('input[name="billing_address\\[postcode\\]"]');
this.shippingCityInput = page.locator('input[name="shipping_address\\[city\\]"]');
this.shippingPostcodeInput = page.locator('input[name="shipping_address\\[postcode\\]"]');
this.salesOwnerSelect = page.getByLabel("Sales Owner");
this.billingCountrySelect = page.locator('select[name="billing_address\\[country\\]"]');
this.billingStateSelect = page.locator('select[name="billing_address\\[state\\]"]');
this.shippingCountrySelect = page.locator('select[name="shipping_address\\[country\\]"]');
this.shippingStateSelect = page.locator('select[name="shipping_address\\[state\\]"]');
this.saveQuoteButton = page.getByRole("button", { name: "Save Quote" });
this.createQuoteLink = page.getByRole("link", { name: "Create Quote" });
this.createQuoteLink = page.getByRole("link", { name: "Create Quote" });
}
async navigateToQuotesPage() {
await this.page.goto("admin/quotes");
}
async createQuote(quoteData: QuoteData) {
await this.navigateToQuotesPage();
// Fill Quote Basics
await this.createQuoteLink.click();
await this.subjectTextbox.fill(quoteData.subject);
await this.descriptionTextbox.fill(quoteData.description);
await this.salesOwnerSelect.selectOption(quoteData.salesOwnerId);
await this.expiredAtTextbox.fill(quoteData.expiredAt);
// Link to Person - Click to add and select person by name
await this.quoteAddPersonButton.click();
await this.searchTextbox.fill(quoteData.person.name);
await this.page.waitForTimeout(1000);
await this.quoteSelectListPerson.click(); // or select existing if applicable
// // Link to Lead - Click to add and select lead by name
// await this.quoteLinkToLeadButton.click();
// await this.searchTextbox.fill(quoteData.leadName);
// await this.addAsNewButton.click(); // or select existing
// Fill Billing Address
await this.billingAddressTextarea.fill(quoteData.address);
await this.billingCountrySelect.selectOption(quoteData.countryCode);
await this.billingStateSelect.selectOption(quoteData.stateCode);
await this.billingCityInput.fill(quoteData.city);
await this.billingPostcodeInput.fill(quoteData.postcode);
// Fill Shipping Address
await this.shippingAddressTextarea.fill(quoteData.address);
await this.shippingCountrySelect.selectOption(quoteData.countryCode);
await this.shippingStateSelect.selectOption(quoteData.stateCode);
await this.shippingCityInput.fill(quoteData.city);
await this.shippingPostcodeInput.fill(quoteData.postcode);
// Quote item filling
await this.page.getByText('Click to Add', { exact: true }).first().click();
await this.page.getByPlaceholder("Search Products").fill(quoteData.product.name);
await this.page.waitForTimeout(1000);
await this.page.getByText(quoteData.product.name, { exact: true }).first().click();
// Save the quote
await this.saveQuoteButton.click();
}
async updateQuote(quoteData:QuoteData)
{
await this.firstEditIcon.click();
await this.subjectTextbox.fill(quoteData.subject);
await this.descriptionTextbox.fill(quoteData.description);
await this.salesOwnerSelect.selectOption(quoteData.salesOwnerId);
await this.expiredAtTextbox.fill(quoteData.expiredAt);
// Link to Person - Click to add and select person by name
await this.quoteAddPersonButton.click();
await this.searchTextbox.fill(quoteData.person.name);
await this.page.waitForTimeout(1000);
await this.quoteSelectListPerson.click(); // or select existing if applicable
// Link to Lead - Click to add and select lead by name
// await this.quoteLinkToLeadButton.click();
// await this.searchTextbox.fill(quoteData.leadName);
// await this.addAsNewButton.click(); // or select existing
// Fill Billing Address
await this.billingAddressTextarea.fill(quoteData.address);
await this.billingCountrySelect.selectOption(quoteData.countryCode);
await this.billingStateSelect.selectOption(quoteData.stateCode);
await this.billingCityInput.fill(quoteData.city);
await this.billingPostcodeInput.fill(quoteData.postcode);
// Save the quote
await this.saveQuoteButton.click();
}
async deleteQuote()
{
await this.firstDeleteIcon.click();
await this.agreeButton.click();
await expect(this.successMessage.first()).toBeVisible();
}
}

View File

@@ -0,0 +1,96 @@
import { Page } from "playwright/test";
import CoreLocators from "../locator/CoreLocators";
export class SettingsPage extends CoreLocators {
readonly page: Page;
constructor(page: Page) {
super(page);
this.page = page;
}
// Warehouses
async navigateToWarehouses() {
await this.page.goto("admin/settings/warehouses");
}
// Automation
async navigateToAutomation() {
await this.page.goto("admin/settings/automation");
}
// Attributes
async navigateToAttributes() {
await this.page.goto("admin/settings/attributes");
}
// Email Templates
async navigateToEmailTemplates() {
await this.page.goto("admin/settings/email-templates");
}
// Events
async navigateToEvents() {
await this.page.goto("admin/settings/marketing/events");
}
// Campaigns
async navigateToCampaigns() {
await this.page.goto("admin/settings/marketing/campaigns");
}
// Webhooks
async navigateToWebhooks() {
await this.page.goto("admin/settings/webhooks");
}
// Workflows
async navigateToWorkflows() {
await this.page.goto("admin/settings/workflows");
}
// Data Transfer
async navigateToDataTransfer() {
await this.page.goto("admin/settings/data-transfer/imports");
}
// Other Settings
async navigateToOtherSettings() {
await this.page.goto("admin/settings/other-settings");
}
// Web Forms
async navigateToWebForms() {
await this.page.goto("admin/settings/web-forms");
}
// Tags
async navigateToTags() {
await this.page.goto("admin/settings/tags");
}
// Groups
async navigateToGroups() {
await this.page.goto("admin/settings/groups");
}
// Roles
async navigateToRoles() {
await this.page.goto("admin/settings/roles");
}
// Users / Agents
async navigateToUsers() {
await this.page.goto("admin/settings/users");
}
// Pipelines
async navigateToPipelines() {
await this.page.goto("admin/settings/pipelines");
}
// Sources
async navigateToSources() {
await this.page.goto("admin/settings/sources");
}
}

View File

@@ -0,0 +1,78 @@
import { expect, Locator, Page } from "playwright/test";
import { SettingsPage } from "../SettingsPage";
import { generateFirstName, generateRandomNumericString } from "../../utils/faker";
export type AttributeData = {
name: string,
code: string,
}
export const attributeData: AttributeData = {
name: generateFirstName(),
code: generateRandomNumericString(),
}
export class AttributesPage extends SettingsPage {
readonly page: Page;
readonly createAttributeLink: Locator;
readonly nameTextbox: Locator;
readonly codeTextbox: Locator;
readonly typeDropdown: Locator;
readonly entityTypeDropdown: Locator;
readonly validationDropdown: Locator;
readonly saveAttributeButton: Locator;
readonly successMessage: Locator;
readonly firstEditButton: Locator;
readonly firstDeleteButton: Locator;
readonly deleteConfirmButton: Locator;
constructor(page: Page) {
super(page);
this.page = page;
this.createAttributeLink = page.getByRole('link', { name: 'Create Attribute' });
this.nameTextbox = page.getByRole('textbox', { name: 'Name' });
this.codeTextbox = page.getByRole('textbox', { name: 'Code' });
this.typeDropdown = page.locator('#type');
this.entityTypeDropdown = page.locator('#entity_type');
this.validationDropdown = page.locator('#validation');
this.saveAttributeButton = page.getByRole('button', { name: 'Save Attribute' });
this.successMessage = page.getByText('Success', { exact: true });
this.firstEditButton = page.locator('//span[contains(@class,"icon-edit")]').first();
this.firstDeleteButton = page.locator('//span[contains(@class,"icon-delete")]').first();
this.deleteConfirmButton = page.getByRole('button', { name: 'Agree', exact: true });
}
async createAttribute(attributeData: AttributeData) {
await this.createAttributeLink.click();
await this.nameTextbox.fill(attributeData.name);
await this.codeTextbox.fill(attributeData.name.toLowerCase());
await this.typeDropdown.selectOption({ value: 'textarea' });
await this.entityTypeDropdown.selectOption({ value: 'leads' });
await this.saveAttributeButton.click();
await this.searchAttribute(attributeData.name);
await expect(this.page.getByText(attributeData.name).first()).toBeVisible();
}
async updateAttribute(attributeData: AttributeData) {
await this.firstEditButton.click();
await this.nameTextbox.fill(attributeData.name);
await this.saveAttributeButton.click();
await this.searchAttribute(attributeData.name);
await expect(this.page.getByText(attributeData.name).first()).toBeVisible();
}
async searchAttribute(name: string) {
await this.searchInputExact.fill(name);
await this.page.keyboard.press('Enter');
}
async deleteAttribute()
{
await this.firstDeleteButton.click();
await this.agreeButton.click();
await expect(this.successMessage.first()).toBeVisible();
}
}

View File

@@ -0,0 +1,13 @@
import { Page } from "playwright/test";
import { SettingsPage } from "../SettingsPage";
export class AutomationPage extends SettingsPage {
readonly page: Page;
constructor(page: Page) {
super(page);
this.page = page;
}
}

View File

@@ -0,0 +1,114 @@
import { Page, Locator, expect } from '@playwright/test';
import { generateFullName, generateLastName, generateRandomNumericString } from '../../utils/faker';
import { SettingsPage } from '../SettingsPage';
import { eventdata, EventData } from './EventsPage';
export type CampaignData = {
name: string;
subject: string;
emailTemplateId: string;
event: EventData;
active: number;
}
export const campaignData: CampaignData = {
name: generateFullName(),
subject: generateLastName(),
emailTemplateId: generateRandomNumericString(),
event: eventdata,
active: 1,
}
export class CampaignsPage extends SettingsPage {
readonly page: Page;
// Campaign locators
readonly createCampaignButton: Locator;
readonly campaignNameInput: Locator;
readonly campaignSubjectInput: Locator;
readonly emailTemplateDropdown: Locator;
readonly eventDropdown: Locator;
readonly activeToggle: Locator;
readonly saveCampaignButton: Locator;
readonly firstCampaignEditButton: Locator;
readonly firstCampaignDeleteButton: Locator;
readonly confirmDeleteButton: Locator;
readonly successCreatedMessage: Locator;
readonly successUpdatedMessage: Locator;
readonly successDeletedMessage: Locator;
constructor(page: Page) {
super(page);
this.page = page;
this.createCampaignButton = page.getByRole('button', { name: 'Create Campaigns' })
this.campaignNameInput = page.locator('input[name="name"]');
this.campaignSubjectInput = page.getByRole('textbox', { name: 'Subject *' });
this.emailTemplateDropdown = page.locator('#marketing_template_id');
this.eventDropdown = page.getByLabel('Event');
this.activeToggle = page.locator('.peer.h-5');
this.saveCampaignButton = page.getByRole('button', { name: 'Save Activity' });
this.firstCampaignEditButton = page.locator('.row > div:nth-child(6) > a').first();
this.firstCampaignDeleteButton = page.locator('xpath=(//span[contains(@class, "icon-delete")])[1]');
this.confirmDeleteButton = page.getByRole('button', { name: 'Agree', exact: true });
this.successCreatedMessage = page.getByText(/(created) successfully\./).first();
this.successUpdatedMessage = page.getByText(/(updated) successfully\./).first();
this.successDeletedMessage = page.getByText(/(deleted) successfully\./).first();
}
async gotoCampaigns() {
await this.page.goto("admin/settings/marketing/campaigns");
}
async createCampaign(data: CampaignData) {
await this.createCampaignButton.click();
await this.campaignNameInput.fill(data.name);
await this.campaignSubjectInput.fill(data.subject);
await this.campaignSubjectInput.click(); // For validation if needed
await this.campaignSubjectInput.fill(data.subject);
await this.eventDropdown.selectOption({ label: data.event.name });
await this.emailTemplateDropdown.selectOption({ value: "1" });
if (data.active) {
// Only toggle if not already active, this might require check logic per app specifics
await this.activeToggle.click();
}
await this.saveCampaignButton.click();
await expect(this.successCreatedMessage).toBeVisible();
await this.searchByName(data.name);
await expect(this.page.getByText(data.name).first()).toBeVisible();
}
async editCampaign(data: CampaignData) {
await this.firstCampaignEditButton.click();
await this.campaignNameInput.fill(data.name);
await this.campaignSubjectInput.fill(data.subject);
await this.campaignSubjectInput.click(); // For validation if needed
await this.campaignSubjectInput.fill(data.subject);
await this.eventDropdown.selectOption({ label: data.event.name });
await this.emailTemplateDropdown.selectOption({ value: "1" });
if (data.active) {
// Only toggle if not already active, this might require check logic per app specifics
await this.activeToggle.click();
}
await this.saveCampaignButton.click();
await expect(this.successUpdatedMessage).toBeVisible();
await this.searchInputExact.fill(data.name);
await this.page.keyboard.press('Enter');
await expect(this.page.getByText(data.name).first()).toBeVisible();
}
async deleteCampaign() {
await this.firstCampaignDeleteButton.click();
await this.confirmDeleteButton.click();
await expect(this.successDeletedMessage).toBeVisible();
}
}

View File

@@ -0,0 +1,134 @@
import { Page, Locator, expect } from '@playwright/test';
import { SettingsPage } from '../SettingsPage';
import path from 'path';
import fs from 'fs';
import { parse } from 'fast-csv';
import { format } from '@fast-csv/format';
export type EntityType = 'leads' | 'products' | 'persons';
export class DataTransferPage extends SettingsPage {
readonly page: Page;
// Locators
readonly importPageUrl = 'admin/settings/data-transfer/imports';
readonly createImportLink: Locator;
readonly fileInput: Locator;
readonly processQueueCheckbox: Locator;
readonly processQueueInput: Locator;
readonly entityTypeSelect: Locator;
readonly saveImportButton: Locator;
readonly validateButton: Locator;
readonly runImportButton: Locator;
readonly importRecordsText: Locator;
readonly editIcon: Locator;
readonly deleteIcon: Locator;
readonly confirmDeleteButton: Locator;
constructor(page: Page) {
super(page);
this.page = page;
this.createImportLink = page.locator('a.primary-button');
this.fileInput = page.locator('input[name="file"]');
this.processQueueCheckbox = page.locator('label[for="process_in_queue"]');
this.processQueueInput = page.locator('input[name="process_in_queue"]');
this.entityTypeSelect = page.locator('#import-type');
this.saveImportButton = page.getByRole('button', { name: 'Save Import' });
this.validateButton = page.locator('//button[contains(.,"Validate")]');
this.runImportButton = page.getByRole('button', { name: 'Import' });
this.importRecordsText = page.getByRole('paragraph').filter({ hasText: /^leads$/ });
this.editIcon = page.locator('.icon-edit').first();
this.deleteIcon = page.locator('.icon-delete').first();
this.confirmDeleteButton = page.getByRole('button', { name: 'Agree', exact: true });
}
async createImport(csvFilePath: string, entityType: EntityType) {
await this.createImportLink.click();
await this.setInputFiles('input[name="file"]', csvFilePath);
await this.uncheckProcessQueue();
await this.entityTypeSelect.selectOption(entityType);
await this.saveImportButton.click();
await this.page.locator('//button[contains(.,"Validate")]').click();
await this.runImportButton.click();
// Wait for success record appearance
await expect(this.successMessage.first()).toBeVisible();
}
async updateImport(csvFilePath: string) {
await this.editIcon.click();
await this.setInputFiles('input[name="file"]', csvFilePath);
await this.uncheckProcessQueue();
await this.saveImportButton.click();
// Validation and import
await this.page.locator('//button[@class="primary-button place-self-start"]').click();
await expect(this.page.getByText('Your import is valid. Click')).toBeVisible();
await this.runImportButton.click();
// Wait for success message
await expect(this.page.getByText('Congratulations! Your import')).toBeVisible();
}
async deleteImport() {
await this.deleteIcon.click();
await this.confirmDeleteButton.click();
// Confirm deletion and presence of 'No Records Available.'
await expect(this.successMessage.first()).toBeVisible();
}
/**
* The import must run synchronously in every test, otherwise the records are
* only queued and the assertions race the queue worker. The switch is toggled
* through its label because the real checkbox is visually hidden, and it is
* clicked only when checked so the state stays deterministic on both the
* create form (off by default) and the edit form (reflects the saved import).
*/
async uncheckProcessQueue() {
await this.processQueueInput.waitFor({ state: 'attached' });
if (await this.processQueueInput.isChecked()) {
await this.processQueueCheckbox.click();
}
await expect(this.processQueueInput).not.toBeChecked();
}
// Utility method for setting files, with fallback to Playwright's method
async setInputFiles(selector: string, filePath: string) {
await this.page.setInputFiles(selector, filePath);
}
async updateCsv(filePath: string,column:string, newOrgId: string): Promise<void> {
return new Promise((resolve, reject) => {
const rows: any[] = [];
fs.createReadStream(filePath)
.pipe(parse({ headers: true }))
.on('error', reject)
.on('data', (row) => {
row[column] = newOrgId; // only update this field
rows.push(row);
})
.on('end', () => {
const writeStream = fs.createWriteStream(filePath);
const csvStream = format({ headers: true });
csvStream
.pipe(writeStream)
.on('finish', resolve)
.on('error', reject);
rows.forEach(row => csvStream.write(row));
csvStream.end();
});
});
}
}

View File

@@ -0,0 +1,69 @@
import { Page, Locator, FrameLocator, expect } from '@playwright/test';
import { SettingsPage } from '../SettingsPage';
import { generateDescription, generateName } from '../../utils/faker';
export type EmailTemplateData = {
subject: string;
content: string;
name: string;
};
export const emailTemplateData: EmailTemplateData = {
subject: generateName(),
content: generateDescription(),
name: generateName(),
};
export class EmailTemplatePage extends SettingsPage {
readonly page: Page;
readonly createButton: Locator;
readonly editButton: Locator;
readonly deleteButton: Locator;
readonly subjectInput: Locator;
readonly contentInput: FrameLocator;
readonly nameInput: Locator;
readonly placeholderSelect: Locator;
readonly saveButton: Locator;
readonly deleteConfirmButton: Locator;
readonly listingUrl: string;
constructor(page: Page) {
super(page);
this.page = page;
this.listingUrl = 'admin/settings/email-templates';
this.createButton = page.getByRole('link', { name: 'Create Email Template' });
this.editButton = page.locator('//span[@class="cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800 max-sm:place-self-center icon-edit"]').first();
this.deleteButton = page.locator('//span[@class="cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800 max-sm:place-self-center icon-delete"]').first();
this.subjectInput = page.getByRole('textbox', { name: 'Subject' });
this.contentInput = page.frameLocator(`iframe[id="content_ifr"]`);
this.nameInput = page.getByRole('textbox', { name: 'Name' });
this.placeholderSelect = page.locator('#placeholder');
this.saveButton = page.getByRole('button', { name: 'Save Email Template' });
this.deleteConfirmButton = page.getByRole('button', { name: 'Delete' });
}
async createTemplate(emailTemplateData: EmailTemplateData) {
await this.createButton.click();
await this.subjectInput.fill(emailTemplateData.subject);
await this.nameInput.fill(emailTemplateData.name);
await this.contentInput.locator('body').fill(emailTemplateData.content);
await this.saveButton.click();
}
async updateEmailTemplate(emailTemplateData: EmailTemplateData) {
await this.editButton.click();
await this.subjectInput.fill(emailTemplateData.subject);
await this.contentInput.locator('body').fill(emailTemplateData.content);
await this.saveButton.click();
}
async deleteEmailTemplate() {
await this.firstDeleteIcon.click();
await this.agreeButton.click();
await expect(this.successMessage.first()).toBeVisible();
}
async searchEmailTemplate(name: string) {
await this.searchInputField.fill(name);
await this.page.keyboard.press('Enter');
}
}

View File

@@ -0,0 +1,82 @@
import { Page, Locator, expect } from '@playwright/test';
import { SettingsPage } from '../SettingsPage';
import { generateDescription, generateFullName, generateRandomDateTime } from '../../utils/faker';
export type EventData = {
name: string,
description: string,
date: string
}
export const eventdata: EventData = {
name: generateFullName(),
description: generateDescription(),
date: "2027-11-18",
}
export class EventsPage extends SettingsPage {
readonly page: Page;
readonly createEventButton: Locator;
readonly nameInput: Locator;
readonly descriptionTextarea: Locator;
readonly dateInput: Locator;
readonly saveEventButton: Locator;
readonly editFirstEventButton: Locator;
readonly deleteFirstEventButton: Locator;
readonly confirmDeleteButton: Locator;
readonly successMessage: Locator;
constructor(page: Page) {
super(page);
this.page = page;
this.createEventButton = page.getByRole('button', { name: 'Create Event' });
this.nameInput = page.locator('input[name="name"]');
this.descriptionTextarea = page.locator('textarea[name="description"]');
this.dateInput = page.locator('input[name="date"]');
this.saveEventButton = page.getByRole('button', { name: 'Save Event' });
this.editFirstEventButton = page.locator('.row > div:nth-child(6) > a').first();
this.deleteFirstEventButton = page.locator('div:nth-child(6) > a:nth-child(2)').first();
this.confirmDeleteButton = page.getByRole('button', { name: 'Agree', exact: true });
this.successMessage = page.getByText(/Event (created|updated|deleted) successfully./);
}
async createEvent(eventdata: EventData) {
await this.createEventButton.click();
await this.nameInput.fill(eventdata.name);
await this.descriptionTextarea.fill(eventdata.name);
await this.dateInput.fill(eventdata.date);
await this.saveEventButton.click();
await expect(this.successMessage).toBeVisible();
}
async massDeleteEvents() {
await this.multiSelectCheckbox.click()
await this.deleteButton.click();
await this.agreeButton.click();
await expect(this.noRecordsAvailable).toBeVisible();
}
async editEvents(eventData: EventData) {
await this.firstEditIcon.click();
await this.nameInput.fill(eventdata.name);
await this.descriptionTextarea.fill(eventdata.name);
await this.dateInput.fill(eventdata.date);
await this.saveEventButton.click();
await expect(this.successMessage).toBeVisible();
}
async deleteEvent() {
await this.deleteFirstEventButton.click();
await this.agreeButton.click();
await expect(this.successMessage).toBeVisible();
}
async eventSearch(name: string) {
await this.searchInputExact.fill(name);
await this.page.keyboard.press('Enter');
}
}

View File

@@ -0,0 +1,71 @@
// groupPage.js
import { expect, Locator, Page } from '@playwright/test';
import { SettingsPage } from '../SettingsPage';
import { generateDescription, generateFirstName, generateName } from '../../utils/faker';
export type GroupData={
name:string,
description:string
}
export const groupData:GroupData={
name: generateFirstName()+" Group",
description: generateDescription().slice(0, 60),
}
export class GroupPage extends SettingsPage {
readonly page:Page;
readonly createGroupButton: Locator;
readonly saveGroupButton: Locator;
readonly nameInput: Locator;
readonly descriptionTextarea: Locator;
readonly successMessageGroupCreated: Locator;
readonly successMessageGroupUpdated: Locator;
constructor(page:Page) {
super(page);
this.page = page;
// Buttons
this.createGroupButton = page.getByRole("button", { name: "Create group" });
this.saveGroupButton = page.getByRole("button", { name: "Save Group" });
// Form fields
this.nameInput = page.locator('input[name="name"]');
this.descriptionTextarea = page.locator('textarea[name="description"]');
// Notifications
this.successMessageGroupCreated = page.getByText("Group created successfully.");
this.successMessageGroupUpdated = page.getByText("Group updated successfully.");
}
async createGroup(groupData:GroupData): Promise<void>
{
await this.createGroupButton.click();
await this.nameInput.fill(groupData.name);
await this.descriptionTextarea.fill(groupData.description);
await this.saveGroupButton.click();
await expect(this.successMessageGroupCreated.first()).toBeVisible();
await this.searchGroup(groupData.name);
await expect(this.page.getByText(groupData.name).first()).toBeVisible();
}
async updateGroup(groupData:GroupData): Promise<void>
{
await this.firstEditIcon.click();
await this.nameInput.fill(groupData.name);
await this.descriptionTextarea.fill(groupData.description);
await this.saveGroupButton.click();
await expect(this.successMessageGroupUpdated.first()).toBeVisible();
await this.searchGroup(groupData.name);
await expect(this.page.getByText(groupData.name).first()).toBeVisible();
}
async deleteGroup(): Promise<void>
{
await this.firstDeleteIcon.click();
await this.agreeButton.click();
await expect(this.successMessage.first()).toBeVisible();
}
async searchGroup(name:string): Promise<void>
{
await this.searchInputExact.fill(name);
await this.page.keyboard.press('Enter');
}
}

View File

@@ -0,0 +1,13 @@
import { Page } from "playwright/test";
import { SettingsPage } from "../SettingsPage";
export class OtherSettingsPage extends SettingsPage {
readonly page: Page;
constructor(page: Page) {
super(page);
this.page = page;
}
}

View File

@@ -0,0 +1,13 @@
import { Page } from "playwright/test";
import { SettingsPage } from "../SettingsPage";
export class PipelinesPage extends SettingsPage {
readonly page: Page;
constructor(page: Page) {
super(page);
this.page = page;
}
}

View File

@@ -0,0 +1,212 @@
import { expect, Locator, Page } from "@playwright/test";
import { SettingsPage } from "../SettingsPage";
import { generateDescription, generateFirstName } from "../../utils/faker";
import { PermissionType, runSuffix } from "../../utils/acl";
export type RoleData = {
name: string;
description: string;
permissionType: PermissionType;
/**
* ACL keys to tick in the access-control tree. Only read when `permissionType` is `custom`.
*/
permissions?: string[];
};
/**
* Build role data with a unique name, so repeated runs never collide in the listing.
*/
export function buildRoleData(overrides: Partial<RoleData> = {}): RoleData {
return {
name: `${generateFirstName()} Role ${runSuffix()}`,
description: generateDescription().slice(0, 60),
permissionType: "custom",
permissions: [],
...overrides,
};
}
export class RolesPage extends SettingsPage {
readonly page: Page;
readonly createRoleButton: Locator;
readonly saveRoleButton: Locator;
readonly nameInput: Locator;
readonly descriptionTextarea: Locator;
readonly permissionTypeSelect: Locator;
readonly createSuccessMessage: Locator;
readonly updateSuccessMessage: Locator;
readonly deleteSuccessMessage: Locator;
readonly permissionsExceedOwnError: Locator;
readonly currentRoleEditError: Locator;
readonly currentRoleDeleteError: Locator;
readonly beingUsedError: Locator;
constructor(page: Page) {
super(page);
this.page = page;
this.createRoleButton = page.getByRole("link", { name: "Create Roles" });
this.saveRoleButton = page.getByRole("button", { name: "Save Role" });
this.nameInput = page.locator('input[name="name"]');
this.descriptionTextarea = page.locator('textarea[name="description"]');
this.permissionTypeSelect = page.locator('select[name="permission_type"]');
this.createSuccessMessage = page.getByText("Role created successfully.");
this.updateSuccessMessage = page.getByText("Role updated successfully.");
this.deleteSuccessMessage = page.getByText("Role deleted successfully.");
this.permissionsExceedOwnError = page.getByText(
"You can only create or edit roles whose permissions are the same as or fewer than your own."
);
this.currentRoleEditError = page.getByText("Can not edit the role assigned to your own account.");
this.currentRoleDeleteError = page.getByText("Can not delete role assigned to the current user.");
this.beingUsedError = page.getByText("Role can not be deleted, as this is being used in admin user.");
}
/**
* The access-control tree renders each ACL key as a hidden checkbox carrying the key as its
* `value`.
*
* The `id` is not usable as an anchor: the tree component prefixes it with a token regenerated
* on every render (`mttr4p8q_leads.edit`), so only `value` is stable across page loads. The
* input itself is `hidden`, so the wrapping label is what a user actually clicks.
*/
permissionCheckbox(key: string): Locator {
return this.page.locator(`input[name="permissions[]"][value="${key}"]`);
}
permissionLabel(key: string): Locator {
return this.page.locator(`label:has(input[name="permissions[]"][value="${key}"])`);
}
async navigateToCreateRole(): Promise<void> {
await this.page.goto("admin/settings/roles/create");
}
/**
* Tick an ACL key in the tree.
*
* Selection is hierarchical in both directions: ticking a key also ticks its ancestors AND its
* whole subtree. Ticking `leads` therefore grants `leads.delete` too.
*
* Pass only the most specific keys a role should hold and let the ancestors follow — listing a
* parent alongside its child silently widens the role far beyond what the test intended.
*/
async selectPermission(key: string): Promise<void> {
const label = this.permissionLabel(key);
await expect(label, `permission "${key}" should be offered in the tree`).toBeVisible();
if (!(await this.permissionCheckbox(key).isChecked())) {
await label.click();
}
}
async deselectPermission(key: string): Promise<void> {
if (await this.permissionCheckbox(key).isChecked()) {
await this.permissionLabel(key).click();
}
}
async selectPermissions(keys: string[]): Promise<void> {
for (const key of keys) {
await this.selectPermission(key);
}
}
/**
* Whether an ACL key is offered at all. `Acl::getAuthorizedItems()` prunes the tree to the
* acting user's own permissions, so a key being absent is itself the assertion in the
* escalation suites.
*/
async isPermissionOffered(key: string): Promise<boolean> {
return (await this.permissionLabel(key).count()) > 0;
}
async fillRoleForm(role: RoleData): Promise<void> {
await this.nameInput.fill(role.name);
await this.descriptionTextarea.fill(role.description);
await this.permissionTypeSelect.selectOption(role.permissionType);
if (role.permissionType === "custom") {
await this.selectPermissions(role.permissions ?? []);
}
}
/**
* Create a role through the form and confirm it lands in the listing.
*/
async createRole(role: RoleData): Promise<void> {
await this.navigateToCreateRole();
await this.fillRoleForm(role);
await this.saveRoleButton.click();
await expect(this.createSuccessMessage.first()).toBeVisible();
await this.searchRole(role.name);
await expect(this.page.getByText(role.name).first()).toBeVisible();
}
/**
* Submit the create form without asserting the outcome, for cases where the expected result is
* a refusal rather than a new role.
*/
async submitRoleForm(role: RoleData): Promise<void> {
await this.navigateToCreateRole();
await this.fillRoleForm(role);
await this.saveRoleButton.click();
}
async searchRole(name: string): Promise<void> {
await this.navigateToRoles();
await this.searchInputExact.fill(name);
await this.page.keyboard.press("Enter");
await this.page.waitForLoadState("networkidle");
}
/**
* Open a named role's edit form by clicking its row's edit icon after searching for it, so the
* test never depends on the role's position in an unfiltered listing.
*/
async openRoleForEdit(name: string): Promise<void> {
await this.searchRole(name);
await this.firstEditIcon.click();
await this.page.waitForLoadState("networkidle");
}
async updateRole(name: string, changes: Partial<RoleData>): Promise<void> {
await this.openRoleForEdit(name);
if (changes.name) {
await this.nameInput.fill(changes.name);
}
if (changes.description) {
await this.descriptionTextarea.fill(changes.description);
}
if (changes.permissionType) {
await this.permissionTypeSelect.selectOption(changes.permissionType);
}
if (changes.permissions?.length) {
await this.selectPermissions(changes.permissions);
}
await this.saveRoleButton.click();
}
async deleteRole(name: string): Promise<void> {
await this.searchRole(name);
await this.firstDeleteIcon.click();
await this.agreeButton.click();
}
}

View File

@@ -0,0 +1,39 @@
import { expect, Locator, Page } from "playwright/test";
import { SettingsPage } from "../SettingsPage";
export class SourcesPage extends SettingsPage {
readonly page: Page;
readonly createSourceLink:Locator;
readonly saveSourceButton:Locator;
readonly thisName:Locator;
constructor(page: Page) {
super(page);
this.page = page;
this.createSourceLink=page.getByRole('button', { name: 'Create Source' });
this.saveSourceButton=page.getByRole('button', { name: 'Save Source' })
this.thisName=page.getByRole('textbox', { name: 'Name' });
}
async searchSource(thisName:string)
{
await this.searchInputExact.fill(thisName)
await this.page.keyboard.press('Enter');
}
async sourceForm(sourceName:string)
{
await this.thisName.fill(sourceName);
await this.saveSourceButton.click();
await expect(this.successMessage.first()).toBeVisible();
await this.searchSource(sourceName);
await expect(this.page.getByRole('paragraph').filter({ hasText: sourceName }).first()).toBeVisible();
}
async deleteSource()
{
await this.firstDeleteIcon.click()
await this.agreeButton.click();
await expect(this.successMessage.first()).toBeVisible();
}
}

View File

@@ -0,0 +1,64 @@
import { expect, Page, Locator } from '@playwright/test';
import { generateName } from '../../utils/faker';
import { SettingsPage } from '../SettingsPage';
export class TagsPage extends SettingsPage {
readonly page: Page;
// Locators
readonly gotoTagsPageButton: Locator;
readonly createTagButton: Locator;
readonly tagNameInput: Locator;
readonly saveTagButton: Locator;
readonly tagsUpdatedMessage: Locator;
readonly tagsCreatedMessage: Locator;
readonly tagsDeletedMessage: Locator;
readonly firstEditButton: Locator;
readonly firstDeleteButton: Locator;
readonly confirmDeleteButton: Locator;
constructor(page: Page) {
super(page);
this.page = page;
// Initialize locators
this.gotoTagsPageButton = page.getByRole('link', { name: /Tags/i });
this.createTagButton = page.getByRole('button', { name: 'Create Tag' });
this.tagNameInput = page.getByRole('textbox', { name: 'Name' });
this.saveTagButton = page.getByRole('button', { name: 'Save Tag' });
this.tagsCreatedMessage = page.getByText(/Tag created successfully\./);
this.tagsUpdatedMessage = page.getByText(/Tag updated successfully\./);
this.tagsDeletedMessage = page.getByText(/Tag deleted successfully\./);
this.firstEditButton = page.locator('//span[@class="icon-edit cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800 max-sm:place-self-center"]').first();
this.firstDeleteButton = page.locator('//span[@class="icon-delete cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800 max-sm:place-self-center"]').first();
this.confirmDeleteButton = page.getByRole('button', { name: 'Agree', exact: true });
}
async createTag(name:string) {
await this.createTagButton.click();
await this.tagNameInput.fill(name);
await this.saveTagButton.click();
await expect(this.tagsCreatedMessage).toBeVisible();
}
async udpateTag(newName: string) {
await this.firstEditButton.click();
await this.tagNameInput.fill(newName);
await this.saveTagButton.click();
await expect(this.tagsUpdatedMessage).toBeVisible();
}
async deleteTag() {
await this.firstDeleteButton.click();
await this.confirmDeleteButton.click();
await expect(this.tagsDeletedMessage).toBeVisible();
}
async seacrhTag(name:string): Promise<void>
{
await this.searchInputExact.fill(name);
await this.page.keyboard.press('Enter');
}
// Optional: Any utility methods like search, verify list, etc., can be added here.
}

View File

@@ -0,0 +1,236 @@
import { expect, Locator, Page } from "@playwright/test";
import { SettingsPage } from "../SettingsPage";
import { generateEmail, generateFullName } from "../../utils/faker";
import { TEST_PASSWORD, ViewPermission, runSuffix } from "../../utils/acl";
export type UserData = {
name: string;
email: string;
password: string;
roleName: string;
viewPermission: ViewPermission;
/**
* Group names to associate. Required by the form when `viewPermission` is `group`.
*/
groups?: string[];
status?: boolean;
};
/**
* Build user data with a unique email, so a re-run never trips the unique constraint.
*/
export function buildUserData(overrides: Partial<UserData> = {}): UserData {
const suffix = runSuffix();
return {
name: `${generateFullName()} ${suffix}`,
email: `acl.${suffix}.${generateEmail()}`,
password: TEST_PASSWORD,
roleName: "Administrator",
viewPermission: "global",
groups: [],
status: true,
...overrides,
};
}
export class UsersPage extends SettingsPage {
readonly page: Page;
readonly createUserButton: Locator;
readonly saveUserButton: Locator;
readonly nameInput: Locator;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly confirmPasswordInput: Locator;
readonly roleSelect: Locator;
readonly viewPermissionSelect: Locator;
readonly groupSelect: Locator;
readonly statusCheckbox: Locator;
readonly createSuccessMessage: Locator;
readonly updateSuccessMessage: Locator;
readonly deleteSuccessMessage: Locator;
readonly unauthorizedMessage: Locator;
readonly roleExceedsOwnMessage: Locator;
readonly scopeExceedsOwnMessage: Locator;
readonly ownPrivilegesMessage: Locator;
constructor(page: Page) {
super(page);
this.page = page;
this.createUserButton = page.getByRole("button", { name: "Create User" });
this.saveUserButton = page.getByRole("button", { name: "Save User" });
this.nameInput = page.locator('input[name="name"]');
this.emailInput = page.locator('input[name="email"]');
this.passwordInput = page.locator('input[name="password"]');
this.confirmPasswordInput = page.locator('input[name="confirm_password"]');
this.roleSelect = page.locator('select[name="role_id"]');
this.viewPermissionSelect = page.locator('select[name="view_permission"]');
this.groupSelect = page.locator('select[name="groups[]"]');
/**
* The form ships a hidden `status` input carrying `0` alongside the real checkbox, so the
* name alone is ambiguous — the id is what identifies the control the user toggles.
*/
this.statusCheckbox = page.locator('input#status[type="checkbox"]');
this.createSuccessMessage = page.getByText("User created successfully.");
this.updateSuccessMessage = page.getByText("User updated successfully.");
this.deleteSuccessMessage = page.getByText("User deleted successfully.");
this.unauthorizedMessage = page.getByText("This action is unauthorized.");
this.roleExceedsOwnMessage = page.getByText(
"You can only assign a role whose permissions are the same as or fewer than your own."
);
this.scopeExceedsOwnMessage = page.getByText("You cannot grant a data scope wider than your own.");
this.ownPrivilegesMessage = page.getByText("You cannot change your own role or data scope.");
}
async openCreateModal(): Promise<void> {
await this.navigateToUsers();
await this.createUserButton.click();
await expect(this.nameInput).toBeVisible();
}
async fillUserForm(user: UserData): Promise<void> {
await this.nameInput.fill(user.name);
await this.emailInput.fill(user.email);
await this.passwordInput.fill(user.password);
await this.confirmPasswordInput.fill(user.password);
await this.roleSelect.selectOption({ label: user.roleName });
await this.viewPermissionSelect.selectOption(user.viewPermission);
/**
* The group field is only rendered for the `group` and `individual` scopes, and is
* mandatory for `group`.
*/
if (user.groups?.length) {
await this.groupSelect.selectOption(user.groups.map((label) => ({ label })));
}
/**
* Status is set explicitly rather than only when disabling: the create modal renders the
* toggle unchecked, so a user left at the default is created inactive and cannot sign in
* at all — which would fail every downstream authorization assertion for the wrong reason.
*/
await this.setStatus(user.status ?? true);
}
/**
* Toggle the status control to a known state rather than blind-clicking, so a form that
* already defaults to the wanted value is not flipped away from it.
*/
async setStatus(active: boolean): Promise<void> {
/**
* The checkbox is `sr-only`, so it is toggled through its own element with `force` rather
* than by a normal click, which Playwright would refuse as targeting a hidden element.
*/
if (active) {
await this.statusCheckbox.check({ force: true });
} else {
await this.statusCheckbox.uncheck({ force: true });
}
}
/**
* Create a user through the modal and confirm the success flash.
*/
async createUser(user: UserData): Promise<void> {
await this.openCreateModal();
await this.fillUserForm(user);
await this.saveUserButton.click();
await expect(this.createSuccessMessage.first()).toBeVisible();
}
/**
* Submit the modal without asserting success, for cases where a refusal is the expectation.
*/
async submitUserForm(user: UserData): Promise<void> {
await this.openCreateModal();
await this.fillUserForm(user);
await this.saveUserButton.click();
}
async searchUser(term: string): Promise<void> {
await this.navigateToUsers();
await this.searchInputExact.fill(term);
await this.page.keyboard.press("Enter");
await this.page.waitForLoadState("networkidle");
}
async openUserForEdit(term: string): Promise<void> {
await this.searchUser(term);
await this.firstEditIcon.click();
await expect(this.nameInput).toBeVisible();
}
/**
* Change an existing user's role and/or scope through the edit modal.
*/
async updateUser(term: string, changes: Partial<UserData>): Promise<void> {
await this.openUserForEdit(term);
if (changes.name) {
await this.nameInput.fill(changes.name);
}
if (changes.roleName) {
await this.roleSelect.selectOption({ label: changes.roleName });
}
if (changes.viewPermission) {
await this.viewPermissionSelect.selectOption(changes.viewPermission);
}
if (changes.groups?.length) {
await this.groupSelect.selectOption(changes.groups.map((label) => ({ label })));
}
if (changes.status !== undefined) {
await this.setStatus(changes.status);
}
await this.saveUserButton.click();
}
/**
* Deactivate a user, which the middleware treats as a session-level revocation on that user's
* next request.
*/
async deactivateUser(term: string): Promise<void> {
await this.updateUser(term, { status: false });
await expect(this.updateSuccessMessage.first()).toBeVisible();
}
async deleteUser(term: string): Promise<void> {
await this.searchUser(term);
await this.firstDeleteIcon.click();
await this.agreeButton.click();
}
/**
* Role names offered in the assignment dropdown. A non-administrator must never be offered a
* role broader than their own.
*/
async offeredRoleNames(): Promise<string[]> {
return this.roleSelect.locator("option").allInnerTexts();
}
/**
* Scope values offered in the view-permission dropdown.
*/
async offeredScopes(): Promise<string[]> {
return this.viewPermissionSelect.locator("option").evaluateAll((options) =>
options.map((option) => (option as HTMLOptionElement).value)
);
}
}

View File

@@ -0,0 +1,104 @@
// pages/WarehousePage.ts
import { Page, Locator, expect } from '@playwright/test';
import { SettingsPage } from '../SettingsPage';
import { generateDescription, generateEmail, generateLastName, generateLocation, generateName, generatePhoneNumber, generateRandomNumericString } from '../../utils/faker';
export type WarehouseData = {
name: string;
description: string;
contactName: string;
contactEmail: string;
contactAddress: string;
country: string;
state: string;
city: string;
zipcode: string;
phone: string;
};
export const warehouseData: WarehouseData = {
name: generateLastName(),
description: generateDescription().slice(0,20),
contactName: generateLastName(),
contactEmail:generateEmail(),
contactAddress: 'Plot 123, Industrial Area, Meerut, UP 250001',
country: 'India',
state: 'Uttar Pradesh',
city: 'Meerut',
zipcode: '250001',
phone: generatePhoneNumber(),
};
export class WarehousePage extends SettingsPage {
readonly page: Page;
readonly warehouseCreateLink: Locator;
readonly warehouseNameInput: Locator;
readonly warehouseAddressInput: Locator;
readonly saveButton: Locator;
readonly firstEditButton: Locator;
readonly firstDeleteButton: Locator;
readonly successMessage: Locator;
readonly warehousesTable: Locator;
readonly contactNameInput: Locator;
readonly contactEmailInput: Locator;
readonly contactPhoneInput: Locator;
readonly contactAddressTextarea: Locator;
readonly countrySelect: Locator;
readonly stateSelect: Locator;
readonly cityInput: Locator;
readonly zipcodeInput: Locator;
readonly warehouseDescriptionInput: Locator;
constructor(page: Page) {
super(page);
this.page = page;
this.warehouseCreateLink = page.getByRole('link', { name: 'Create Warehouse' })
this.warehouseNameInput = page.getByRole('textbox', { name: 'Name *', exact: true });
this.warehouseAddressInput = page.locator('textarea[name="contact_address[address]"]');
this.saveButton = page.getByRole('button', { name: 'Save Warehouse' })
this.firstEditButton = page.locator('span.cursor-pointer.icon-edit').first();
this.firstDeleteButton = page.locator('span.cursor-pointer.icon-delete').first();
this.successMessage = page.getByText(/Warehouse.*successfully/);
this.warehousesTable = page.getByRole('table');
this.contactNameInput = page.getByRole('textbox', { name: 'Contact Name *' });
this.contactEmailInput = page.getByRole('textbox', { name: 'Contact Emails *' })
this.contactPhoneInput = page.getByRole('textbox', { name: 'Contact Numbers' })
this.contactAddressTextarea = page.locator('textarea[name="contact_address[address]"]');
this.countrySelect = page.locator('select[name="contact_address[country]"]');
this.stateSelect = page.locator('select[name="contact_address[state]"]');
this.cityInput = page.getByRole('textbox', { name: 'City' });
this.zipcodeInput = page.getByRole('textbox', { name: 'Postcode' });
this.warehouseDescriptionInput = page.getByRole('textbox', { name: 'Description' });
}
async warehouseForm(warehouseData: WarehouseData) {
await this.warehouseNameInput.fill(warehouseData.name);
await this.warehouseDescriptionInput.fill(warehouseData.description);
await this.warehouseAddressInput.fill(warehouseData.contactAddress);
await this.contactNameInput.fill(warehouseData.contactName);
await this.contactEmailInput.fill(warehouseData.contactEmail);
await this.contactPhoneInput.fill(warehouseData.phone);
await this.contactAddressTextarea.fill(warehouseData.contactAddress);
await this.countrySelect.selectOption({ label: warehouseData.country });
await this.stateSelect.selectOption({ label: warehouseData.state });
await this.cityInput.fill(warehouseData.city);
await this.zipcodeInput.fill(warehouseData.zipcode);
await this.saveButton.click();
await expect(this.successMessage.first()).toBeVisible();
await this.searchWarehouse(warehouseData.contactName);
await expect(this.page.getByRole('paragraph').filter({ hasText: warehouseData.contactName }).first()).toBeVisible();
}
async searchWarehouse(warehouseName:string)
{
await this.searchInputExact.fill(warehouseName);
await this.page.keyboard.press('Enter');
}
async deleteWarehouse()
{
await this.firstDeleteButton.click()
await this.agreeButton.click();
await expect(this.successMessage.first()).toBeVisible();
}
}

View File

@@ -0,0 +1,13 @@
import { Page } from "playwright/test";
import { SettingsPage } from "../SettingsPage";
export class WebFormsPage extends SettingsPage {
readonly page: Page;
constructor(page: Page) {
super(page);
this.page = page;
}
}

View File

@@ -0,0 +1,100 @@
import { Page, Locator, expect } from '@playwright/test';
import { SettingsPage } from '../SettingsPage';
import { generateDescription, generateFirstName } from '../../utils/faker';
export type WebhookData = {
url: string;
name: string;
description: string;
entity: string;
};
export const webhookData: WebhookData = {
url: 'https://example.com/webhook',
name: generateFirstName(),
description: generateDescription().slice(0, 50),
entity: 'persons',
};
export class WebhookPage extends SettingsPage {
readonly page: Page;
readonly createButton: Locator;
readonly editButton: Locator;
readonly deleteButton: Locator;
readonly urlEndpointInput: Locator;
readonly nameInput: Locator;
readonly descriptionInput: Locator;
readonly entityTypeSelect: Locator;
readonly queryParamKeyInput: Locator;
readonly queryParamValueInput: Locator;
readonly contentTypeRadio: Locator;
readonly payloadKeyInput: Locator;
readonly payloadValueInput: Locator;
readonly saveButton: Locator;
readonly deleteConfirmButton: Locator;
readonly listingUrl: string;
constructor(page: Page) {
super(page);
this.page = page;
this.listingUrl = 'admin/settings/webhooks';
// Main actions
this.createButton = page.getByRole('link', { name: 'Create Webhook' });
this.editButton = page.locator('(//span[@class="cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800 max-sm:place-self-center icon-edit"])[1]');
this.deleteButton = page.locator('(//span[@class="cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800 max-sm:place-self-center icon-delete"])[1]');
// Form fields
this.urlEndpointInput = page.getByRole('textbox', { name: 'Url Endpoint' });
this.nameInput = page.getByRole('textbox', { name: 'Name' });
this.descriptionInput = page.getByRole('textbox', { name: 'Description' });
this.entityTypeSelect = page.locator('#entity_type');
this.queryParamKeyInput = page.locator('input[name="query_params[0][key]"]');
this.queryParamValueInput = page.locator('[id="query_params[0][value]"]');
this.contentTypeRadio = page.getByRole('radio', { name: 'x-www-form-urlencoded' });
this.payloadKeyInput = page.locator('input[name="payload[0][key]"]');
this.payloadValueInput = page.locator('input[name="payload[0][value]"]');
this.saveButton = page.getByRole('button', { name: 'Save Webhook' });
this.deleteConfirmButton = page.getByRole('button', { name: 'Agree', exact: true });
}
async createWebhook(webhookData: WebhookData) {
await this.createButton.click();
await this.urlEndpointInput.fill(webhookData.url);
await this.nameInput.fill(webhookData.name);
await this.entityTypeSelect.selectOption(webhookData.entity);
await this.descriptionInput.fill(webhookData.description);
await this.queryParamKeyInput.fill('key');
await this.queryParamValueInput.fill('value');
await this.contentTypeRadio.check();
await this.payloadKeyInput.fill('key');
await this.payloadValueInput.fill('value');
await this.saveButton.click();
await expect(this.successMessage.first()).toBeVisible();
}
async updateFirstWebhook(webhookData: WebhookData) {
await this.editButton.click();
await this.urlEndpointInput.fill(webhookData.url);
await this.nameInput.fill(webhookData.name);
await this.descriptionInput.fill(webhookData.description);
await this.entityTypeSelect.selectOption(webhookData.entity);
await this.queryParamKeyInput.fill('updatedKey');
await this.queryParamValueInput.fill('updatedValue');
await this.payloadKeyInput.fill('updatedPayloadKey');
await this.payloadValueInput.fill('updatedPayloadValue');
await this.saveButton.click();
await this.searchWebhook(webhookData.name);
await expect(this.page.locator('p.break-words', { hasText: webhookData.url }).first()).toBeVisible();
}
async deleteWebhook() {
await this.deleteButton.click();
await this.deleteConfirmButton.click();
await expect(this.successMessage.first()).toBeVisible();
}
async searchWebhook(name: string) {
await this.searchInputExact.fill(name);
await this.page.keyboard.press('Enter');
}
}

View File

@@ -0,0 +1,13 @@
import { Page } from "playwright/test";
import { SettingsPage } from "../SettingsPage";
export class WorkflowsPage extends SettingsPage {
readonly page: Page;
constructor(page: Page) {
super(page);
this.page = page;
}
}

View File

@@ -0,0 +1,56 @@
import { defineConfig, devices } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const TESTS_ROOT_PATH = __dirname;
export const STATE_DIR_PATH = `${ TESTS_ROOT_PATH }/.state/`;
export const ADMIN_AUTH_STATE_PATH = `${ STATE_DIR_PATH }/admin-auth.json`;
dotenv.config({ path: path.resolve(__dirname, "../../../../../.env") });
export default defineConfig({
testDir: "./tests",
timeout: 120 * 1000,
expect: { timeout: 20 * 1000 },
outputDir: "./test-results",
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: 0,
reportSlowTests: null,
reporter: [
[
"html",
{
outputFolder: "./playwright-report",
},
],
],
use: {
baseURL: `${process.env.APP_URL}/`.replace(/\/+$/, "/"),
screenshot: { mode: "only-on-failure", fullPage: true },
video: "retain-on-failure",
trace: "retain-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});

View File

@@ -0,0 +1,23 @@
import { test, expect } from "../fixtures/AdminFixtures";
import { AdminPage } from "../pages/AdminPage";
test.describe("admin mangement", async () => {
test("verify admin login", async ({ adminPage }) => {
expect(adminPage.waitForURL("admin/dashboard"));
})
test("verify admin logout", async ({ adminPage }) => {
const admin = await new AdminPage(adminPage);
await admin.adminLogout();
await admin.verifyLogout();
})
})

View File

@@ -0,0 +1,82 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { test, expect } from '@playwright/test';
// Resolve file paths relative to this test file
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Absolute path to /lang directory
const LANG_DIR = path.resolve(
__dirname,
'../../../../src/Resources/lang'
);
const BASE_LANG = 'en';
// Helper to extract just the keys from app.php
function getNormalizedKeys(filePath: string): string[] {
const raw = fs.readFileSync(filePath, 'utf-8');
const clean = raw
.replace(/<\?php\s*/g, '')
.replace(/return\s*\[/, '')
.replace(/\];/, '')
.trim();
return clean
.split('\n')
.map(line => line.trim())
.filter(line => line.includes('=>') && !line.startsWith('//'))
.map(line => {
// Extract the key before =>
const match = line.match(/^['"](.+?)['"]\s*=>/);
return match ? match[1] : null;
})
.filter(Boolean) as string[];
}
test('All language files must match number of keys and key names with English app.php', () => {
const baseFile = path.join(LANG_DIR, BASE_LANG, 'app.php');
const baseKeys = getNormalizedKeys(baseFile);
// All locales except the base one
const locales = fs
.readdirSync(LANG_DIR)
.filter(locale => locale !== BASE_LANG && fs.existsSync(path.join(LANG_DIR, locale, 'app.php')));
// Array to collect any locales that have issues
let failedLocales: { locale: string; missingKeys: string[]; extraKeys: string[] }[] = [];
for (const locale of locales) {
const filePath = path.join(LANG_DIR, locale, 'app.php');
const keys = getNormalizedKeys(filePath);
const missingKeys = baseKeys.filter(key => !keys.includes(key));
const extraKeys = keys.filter(key => !baseKeys.includes(key));
if (missingKeys.length > 0 || extraKeys.length > 0) {
failedLocales.push({ locale, missingKeys, extraKeys });
}
}
if (failedLocales.length > 0) {
for (const { locale, missingKeys, extraKeys } of failedLocales) {
console.error(` ${locale}/app.php has issues:`);
if (missingKeys.length) {
console.error(` Missing keys (${missingKeys.length}):`);
for (const key of missingKeys) console.error(` - ${key}`);
}
if (extraKeys.length) {
console.error(` Extra keys (${extraKeys.length}):`);
for (const key of extraKeys) console.error(` + ${key}`);
}
}
// Fail the test
expect(failedLocales).toEqual([]);
} else {
console.log('All language files have matching keys and counts with en/app.php');
}
});

View File

@@ -0,0 +1,56 @@
import { test, expect } from "../fixtures/AdminFixtures";
import { LeadData, leadData, LeadPage } from "../pages/LeadPage";
import PersonsPage from "../pages/PersonsPage";
import { generateDescription, generateEmail, generateName, generatePhoneNumber, generateSKU } from "../utils/faker";
test.describe("lead management", async () => {
const updatedLeadData={
title: generateName(),
description: generateDescription(),
value: (Math.floor(Math.random() * 10000)).toString(),
expectedCloseDate: "2028-12-31",
person: leadData.person,
product: leadData.product,
organizationName: leadData.person.organizationName
}
test("should create a new lead", async ({ adminPage }) => {
const leadPage = new LeadPage(adminPage);
const personPage = new PersonsPage(adminPage);
await personPage.navigageToPersonsPage();
/**
* Create the person the lead form will then select. It has to be the lead flow's own
* person, not the shared one, or this collides with the person spec's record.
*/
await personPage.createPerson(leadData.person);
await leadPage.createLead(leadData);
});
test("should update an existing lead", async ({ adminPage }) => {
const lead=new LeadPage(adminPage);
await lead.navigateToLeadList();
const updatelead = await lead.searchLead(leadData.title);
await updatelead.updateLead(updatedLeadData);
});
test("user should able to delete the lead", async ({ adminPage }) => {
const leadPage = new LeadPage(adminPage);
await leadPage.navigateToLeadList();
await leadPage.deleteLead(updatedLeadData);
})
})

View File

@@ -0,0 +1,89 @@
import { expect, test } from "../fixtures/AdminFixtures";
import { mailData, MailPage } from "../pages/MailPage";
test.describe("mail management", async () => {
test("should compose and send an email", async ({ adminPage }) => {
const mailPage = new MailPage(adminPage);
await mailPage.navigateToMailInboxPage();
await mailPage.composeEmail(mailData,'normal');
});
test("should compose and send an email with cc", async ({ adminPage }) => {
const mailPage = new MailPage(adminPage);
await mailPage.navigateToMailInboxPage();
await mailPage.composeEmail(mailData,'cc');
});
test("should compose and send an email with bcc", async ({ adminPage }) => {
const mailPage = new MailPage(adminPage);
await mailPage.navigateToMailInboxPage();
await mailPage.composeEmail(mailData,'bcc');
});
test("should compose and send an email with cc and bcc", async ({ adminPage }) => {
const mailPage = new MailPage(adminPage);
await mailPage.navigateToMailInboxPage();
await mailPage.composeEmail(mailData,'both');
});
test("should able to save normal draft email", async ({ adminPage }) => {
const mailPage = new MailPage(adminPage);
await mailPage.navigateToMailInboxPage();
await mailPage.searchEmail(mailData.subject);
await mailPage.saveDraftEmail(mailData,'normal');
});
test("should able to save cc draft email", async ({ adminPage }) => {
const mailPage = new MailPage(adminPage);
await mailPage.navigateToMailInboxPage();
await mailPage.searchEmail(mailData.subject);
await mailPage.saveDraftEmail(mailData,'cc');
});
test("should able to save bcc draft email", async ({ adminPage }) => {
const mailPage = new MailPage(adminPage);
await mailPage.navigateToMailInboxPage();
await mailPage.searchEmail(mailData.subject);
await mailPage.saveDraftEmail(mailData,'bcc');
});
test("should able to save cc and bcc draft email", async ({ adminPage }) => {
const mailPage = new MailPage(adminPage);
await mailPage.navigateToMailInboxPage();
await mailPage.searchEmail(mailData.subject);
await mailPage.saveDraftEmail(mailData,'both');
});
test("should able to move email to trash", async ({ adminPage }) => {
const mailPage = new MailPage(adminPage);
await mailPage.navigateToMailInboxPage();
await mailPage.saveDraftEmail(mailData,'normal');
await mailPage.navigateToMailDraftPage();
await mailPage.searchEmail(mailData.subject);
await mailPage.movedEmailToTrash();
await mailPage.navigateToMailTrashedPage();
await expect(adminPage.getByRole('paragraph').filter({ hasText: mailData.subject }).first()).toBeVisible();
});
test("should able to delete email from trash", async ({ adminPage }) => {
const mailPage = new MailPage(adminPage);
await mailPage.navigateToMailInboxPage();
await mailPage.saveDraftEmail(mailData,'normal');
await mailPage.navigateToMailDraftPage();
await mailPage.searchEmail(mailData.subject);
await mailPage.movedEmailToTrash();
await mailPage.navigateToMailTrashedPage();
await mailPage.deleteEmailFromTrash(mailData.subject);
await expect(adminPage.getByRole('paragraph').filter({ hasText: mailData.subject }).first()).not.toBeVisible();
});
});

View File

@@ -0,0 +1,51 @@
import { test } from "../fixtures/AdminFixtures";
import OrganizationPage, { organizationData } from "../pages/OrganizationPage";
test.describe("organization mangement",async()=>{
test("verify organization create",async({adminPage})=>{
const organizationPage=new OrganizationPage(adminPage);
await organizationPage.navigateToOrganization();
await organizationPage.createOrganization(organizationData);
})
test('verify update organization',async({adminPage})=>{
const organizationPage=new OrganizationPage(adminPage);
await organizationPage.navigateToOrganization();
await organizationPage.updateOrganization(organizationData);
})
test("verify delete organization",async({adminPage})=>{
const organizationPage=new OrganizationPage(adminPage);
await organizationPage.navigateToOrganization();
await organizationPage.searchByName(organizationData.name);
await organizationPage.deleteOrganization();
})
test("verify mass delete organization",async({adminPage})=>{
const organizationPage=new OrganizationPage(adminPage);
await organizationPage.navigateToOrganization();
/* create organization before delete*/
await organizationPage.createOrganization(organizationData);
/* than mass delete organization*/
await organizationPage.massDelete();
})
})

View File

@@ -0,0 +1,51 @@
import { expect, test } from "../fixtures/AdminFixtures";
import PersonsPage, { buildPersonData } from "../pages/PersonsPage";
test.describe("person managment", async () => {
/**
* This spec builds its own person rather than importing the shared `personData`. Email and
* contact number are unique in Krayin, and the lead and quote specs create a person too — with
* one shared object all three submit the same email and the later ones fail validation with
* "The value has already been taken."
*
* The instance is created once for the whole describe so that create, edit and delete all
* address the same record.
*/
const personData = buildPersonData();
test("verify create person", async ({ adminPage }) => {
const personPage = new PersonsPage(adminPage);
await personPage.navigageToPersonsPage();
await personPage.createPerson(personData);
})
test('verify edit person', async ({ adminPage }) => {
const person = new PersonsPage(adminPage);
await person.navigageToPersonsPage();
await person.updatePerson(personData);
})
test('verify person delete', async ({ adminPage }) => {
const person = new PersonsPage(adminPage);
await person.navigageToPersonsPage();
await person.searchByName(personData.name);
await person.personDelete();
})
test('verify mass delete person', async ({ adminPage }) => {
const person = new PersonsPage(adminPage);
/**
* A person of its own: the record created above has just been deleted, but reusing its
* email would still collide if the delete case is skipped or run out of order.
*/
const massDeletePersonData = buildPersonData();
await person.navigageToPersonsPage();
await person.createPerson(massDeletePersonData);
await person.personMassDelete();
})
})

View File

@@ -0,0 +1,31 @@
import { test } from "../fixtures/AdminFixtures";
import { productData, ProductPage } from "../pages/ProductPage";
test.describe("Product mangement", async () => {
test("verify create product", async ({ adminPage }) => {
const product = new ProductPage(adminPage);
await product.navigateToProductPage();
await product.createProductLink.click();
await product.productForm(productData);
});
test("verify edit product", async ({ adminPage }) => {
const product = new ProductPage(adminPage);
await product.navigateToProductPage();
await product.firstEditIcon.click();
const updatedProductData = {
...productData,
name: productData.name + " - Updated",
price: (parseInt(productData.price) + 100).toString(),
}
await product.productForm(updatedProductData);
})
test("verify delete product", async ({ adminPage }) => {
const product = new ProductPage(adminPage);
await product.navigateToProductPage();
await product.deleteProduct();
})
})

View File

@@ -0,0 +1,72 @@
import { test } from "../fixtures/AdminFixtures";
import {
QuickAddPage,
quickAddLeadData,
quickAddMailData,
quickAddOrganizationData,
quickAddPersonData,
quickAddProductData,
} from "../pages/QuickAddPage";
test.describe("quick add modal", async () => {
test("should open the quick add modal from the header plus button", async ({ adminPage }) => {
const quickAddPage = new QuickAddPage(adminPage);
/**
* The seeded admin role has all five quick create permissions.
*/
await quickAddPage.verifyQuickAddTabs(["Lead", "Person", "Organization", "Product", "Email"]);
});
test("should create a lead via quick add", async ({ adminPage }) => {
const quickAddPage = new QuickAddPage(adminPage);
await quickAddPage.createLead(quickAddLeadData);
});
test("should create a person via quick add", async ({ adminPage }) => {
const quickAddPage = new QuickAddPage(adminPage);
await quickAddPage.createPerson(quickAddPersonData);
});
test("should create an organization via quick add", async ({ adminPage }) => {
const quickAddPage = new QuickAddPage(adminPage);
await quickAddPage.createOrganization(quickAddOrganizationData);
});
test("should create a product via quick add", async ({ adminPage }) => {
const quickAddPage = new QuickAddPage(adminPage);
await quickAddPage.createProduct(quickAddProductData);
});
test("should send an email via quick add", async ({ adminPage }) => {
const quickAddPage = new QuickAddPage(adminPage);
await quickAddPage.createMail(quickAddMailData);
});
test("should keep modal open and surface validation errors when fields are empty", async ({ adminPage }) => {
const quickAddPage = new QuickAddPage(adminPage);
await quickAddPage.submitEmptyForm("Lead");
});
});

View File

@@ -0,0 +1,55 @@
import { title } from "process";
import { test, expect } from "../fixtures/AdminFixtures";
import { AdminPage } from "../pages/AdminPage";
import { LeadData, LeadPage } from "../pages/LeadPage";
import OrganizationPage, { OrganizationData } from "../pages/OrganizationPage";
import PersonsPage, { PersonData } from "../pages/PersonsPage";
import { ProductData, productData, ProductPage } from "../pages/ProductPage";
import { quoteData, QuotesPage, } from "../pages/QuotesPage";
import { generateDescription, generateEmail, generateLocation, generateName, generatePhoneNumber, generateSKU } from "../utils/faker";
test.describe("quotes mangement", async () => {
test("verify create quote", async ({ adminPage }) => {
const person = new PersonsPage(adminPage);
const product = new ProductPage(adminPage);
await person.navigageToPersonsPage();
await person.createPerson(quoteData.person);
await product.navigateToProductPage();
await product.createProductLink.click();
/**
* The quote flow's own product, so this does not collide with the product the lead and product
* specs create earlier in the same worker.
*/
await product.productForm(quoteData.product);
const quote = new QuotesPage(adminPage);
await quote.navigateToQuotesPage();
await quote.createQuote(quoteData);
await expect(quote.successMessage.first()).toBeVisible({ timeout: 10000 });
})
test('verify updated quote', async ({ adminPage }) => {
const updateQuoteData = {
...quoteData,
subject: quoteData.subject + " - Updated",
title:"updated title",
quoteNumber:"updated quote number",
description:"updated description",
notes:"updated notes",
}
const quote = new QuotesPage(adminPage);
await quote.navigateToQuotesPage();
await quote.searchByName(quoteData.subject);
await quote.updateQuote(updateQuoteData);
})
test('verify delete quote', async ({ adminPage }) => {
const quote = new QuotesPage(adminPage);
await quote.navigateToQuotesPage();
await quote.searchByName(quoteData.subject);
await quote.deleteQuote();
})
})

View File

@@ -0,0 +1,245 @@
import { expect, test } from "../../fixtures/AdminFixtures";
import { AclPage } from "../../pages/AclPage";
import { buildRoleData, RolesPage } from "../../pages/settings/RolesPage";
import { buildUserData, UsersPage } from "../../pages/settings/UsersPage";
import {
ACL,
ADMIN_EMAIL,
ADMIN_PASSWORD,
ALLOWLISTED_ROUTES,
GUARDED_ROUTES,
Session,
TEST_PASSWORD,
closeSessions,
expectAllowed,
expectDenied,
getRoute,
postRoute,
signInExpectingSuccess,
} from "../../utils/acl";
/**
* The ACL gate: which actions a role may perform.
*
* Every case here fixes the data scope at `global`, so a refusal can only come from the action
* gate. Record-level scoping is covered separately in `view-permission.spec.ts`.
*
* The suite deliberately asserts at all three layers, because only one of them is a security
* boundary:
*
* - menu and button visibility -> presentation, may hide a control
* - direct GET / POST -> the middleware, must refuse
*
* A test that only checks a hidden button proves nothing about the endpoint behind it.
*/
test.describe.serial("acl action authorization", () => {
/**
* A deliberately narrow role: leads read-only, nothing else. It holds `leads.view` but neither
* `leads.create`, `leads.edit` nor `leads.delete`, which makes it useful for both the
* "permission present" and "permission absent" halves of the matrix.
*/
const leadsReaderRole = buildRoleData({
permissions: [ACL.leadsView],
});
const leadsReader = buildUserData({
roleName: leadsReaderRole.name,
viewPermission: "global",
});
let readerSession: Session | undefined;
test.beforeAll(async ({ browser, baseURL }) => {
const admin = await signInExpectingSuccess(browser, baseURL, ADMIN_EMAIL, ADMIN_PASSWORD);
try {
await new RolesPage(admin.page).createRole(leadsReaderRole);
await new UsersPage(admin.page).createUser(leadsReader);
} finally {
await closeSessions(admin);
}
readerSession = await signInExpectingSuccess(
browser,
baseURL,
leadsReader.email,
TEST_PASSWORD
);
});
test.afterAll(async () => {
await closeSessions(readerSession);
});
test("an administrator reaches every guarded route", async ({ browser, baseURL }) => {
const admin = await signInExpectingSuccess(browser, baseURL, ADMIN_EMAIL, ADMIN_PASSWORD);
try {
for (const route of GUARDED_ROUTES) {
const response = await getRoute(admin, route.path);
expectAllowed(response, `${route.label} for an administrator`);
}
} finally {
await closeSessions(admin);
}
});
test("a held permission grants the route", async () => {
const response = await getRoute(readerSession!, "admin/leads");
expectAllowed(response, "lead listing for a user holding 'leads'");
});
test("the dashboard is refused without its permission", async () => {
/**
* The reader role holds no `dashboard` key, so even the landing screen must be refused —
* this is the case that proves the middleware is not treating any route as implicitly
* public for an authenticated user.
*/
const response = await getRoute(readerSession!, "admin/dashboard");
expectDenied(response, "dashboard without the 'dashboard' permission");
});
/**
* Every guarded route the reader role does not hold must be refused on a direct GET, with no
* reliance on the UI having hidden a link to it.
*/
for (const route of GUARDED_ROUTES.filter(
(candidate) => ![ACL.leads, ACL.leadsView].includes(candidate.permission as never)
)) {
test(`refuses a direct GET to ${route.path} without '${route.permission}'`, async () => {
const response = await getRoute(readerSession!, route.path);
expectDenied(response, route.label);
});
}
test("refuses a direct POST to lead create without 'leads.create'", async () => {
/**
* Replaying the form submit by hand is the case a hidden button cannot cover. The payload
* is intentionally incomplete: authorization must refuse before validation ever runs, so a
* 422 here would itself be a failure.
*/
const response = await postRoute(readerSession!, "admin/leads/create", {
title: "unauthorized lead",
});
expectDenied(response, "lead store without 'leads.create'");
expect(response.status(), "authorization must refuse before validation").not.toBe(422);
});
test("refuses a direct POST to user create without the settings permission", async () => {
const response = await postRoute(readerSession!, "admin/settings/users/create", {
name: "unauthorized user",
email: `escalation-${Date.now()}@example.invalid`,
});
expectDenied(response, "user store without 'settings.user.users.create'");
});
test("refuses mass-delete without the delete permission", async () => {
/**
* The URL segment is `mass-destroy`; `mass_delete` is only the route's name. Posting the
* name instead reaches a different route entirely and answers 405, which would mask
* whatever the real endpoint does.
*/
const response = await postRoute(readerSession!, "admin/leads/mass-destroy", {
"indices[]": "1",
});
expectDenied(response, "lead mass delete without 'leads.delete'");
});
test("hides navigation entries the role does not hold", async () => {
const aclPage = new AclPage(readerSession!.page);
await readerSession!.page.goto("admin/leads");
await aclPage.expectNavVisible("/admin/leads");
/**
* Paths rather than labels, matching the routes in the menu config that `Menu::getItems()`
* filters through `bouncer()->hasPermission()`.
*/
for (const path of [
"/admin/quotes",
"/admin/contacts/persons",
"/admin/products",
"/admin/settings/settings",
]) {
await aclPage.expectNavHidden(path);
}
});
test("hides create and delete controls the role does not hold", async () => {
const aclPage = new AclPage(readerSession!.page);
await readerSession!.page.goto("admin/leads");
/**
* The role holds `leads` and `leads.view` only, so the listing renders but its write
* controls must not.
*/
await aclPage.expectControlHidden("Create Lead");
});
test("allows self-service account routes for any authenticated user", async () => {
/**
* These sit on the middleware's allow-list. They must stay reachable for a role that holds
* almost nothing, otherwise a restricted user cannot manage their own profile.
*/
for (const path of ALLOWLISTED_ROUTES) {
const response = await getRoute(readerSession!, path);
expectAllowed(response, `allow-listed route ${path}`);
}
});
test("refuses an unmapped route under a feature the role does not hold", async () => {
/**
* `admin.settings.users.search` is a real route with no entry of its own in `acl.php`. The
* middleware fails closed by authorizing it against the nearest mapped ancestor, so a role
* without any `settings.user.users` permission must not be able to enumerate users through
* it.
*
* A URL that simply does not exist would 404 before the middleware ever ran and would
* prove nothing, which is why this uses a route that genuinely resolves.
*/
const response = await getRoute(readerSession!, "admin/settings/users/search");
expectDenied(response, "an unmapped route under a feature the role lacks");
});
test("allows an unmapped route under a feature the role does hold", async () => {
/**
* The other half of the fallback: `admin.leads.search` is equally unmapped, but the reader
* holds `leads`, so the ancestor lookup must let it through. Without this case a middleware
* that denied every unmapped route would look correct.
*/
const response = await getRoute(readerSession!, "admin/leads/search");
expectAllowed(response, "an unmapped route under a feature the role holds");
});
test("refuses everything once the account is deactivated", async ({ browser, baseURL }) => {
const admin = await signInExpectingSuccess(browser, baseURL, ADMIN_EMAIL, ADMIN_PASSWORD);
try {
await new UsersPage(admin.page).deactivateUser(leadsReader.email);
} finally {
await closeSessions(admin);
}
/**
* The existing session must not survive deactivation — the middleware tears it down on the
* next request rather than waiting for the cookie to expire.
*/
const response = await getRoute(readerSession!, "admin/leads");
expectDenied(response, "an existing session after the account was deactivated");
});
});

View File

@@ -0,0 +1,149 @@
import { test, expect } from "../../fixtures/AdminFixtures";
/**
* Security regressions around admin authentication, exercised through the browser.
*
* Each case here maps to a fix: brute-force throttling that counts only failures, a logout that
* tears the whole session down, a forgot-password form that does not disclose which addresses are
* registered, authentication actually being required, and the installer staying shut on an
* installed application.
*/
const LOGIN_URL = "admin/login";
/**
* Submit the login form and hand back the POST response, so the status code can be asserted
* directly rather than inferred from what the page happens to render.
*/
async function submitLogin(page, email: string, password: string) {
await page.goto(LOGIN_URL);
await page.locator('input[name="email"]').fill(email);
await page.locator('input[name="password"]').fill(password);
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes("/admin/login") && r.request().method() === "POST"
),
page.getByRole("button", { name: "Sign In" }).click(),
]);
return response;
}
test.describe("admin authentication hardening", () => {
test("throttles repeated failed logins", async ({ page }) => {
/**
* An address that cannot match an account, so a real administrator is never locked out by
* this test and no account state is touched.
*/
const email = `brute-force-probe-${Date.now()}@example.invalid`;
for (let attempt = 1; attempt <= 5; attempt++) {
const response = await submitLogin(page, email, "wrong-password");
expect(response.status(), `attempt ${attempt} should not be throttled yet`).not.toBe(429);
}
const blocked = await submitLogin(page, email, "wrong-password");
expect(blocked.status()).toBe(429);
});
test("does not throttle a correct sign-in", async ({ browser, baseURL }) => {
/**
* Counting every attempt rather than only the failures would throttle this suite itself,
* which signs in for each of its cases from a single address.
*
* Each sign-in runs in its own browser context: visiting the login page while already
* signed in redirects to the dashboard, so reusing one context would leave no form to fill.
*/
for (let attempt = 1; attempt <= 3; attempt++) {
const context = await browser.newContext({ baseURL });
const page = await context.newPage();
try {
const response = await submitLogin(page, "admin@example.com", "admin123");
expect(response.status(), `sign-in ${attempt} should not be throttled`).not.toBe(429);
await expect(page).toHaveURL(/\/admin\/dashboard/);
} finally {
await context.close();
}
}
});
test("requires authentication for admin pages", async ({ page }) => {
await page.context().clearCookies();
await page.goto("admin/dashboard");
await expect(page).toHaveURL(/\/admin\/login/);
});
test("ends the session on logout so the dashboard is no longer reachable", async ({ adminPage }) => {
await expect(adminPage).toHaveURL(/\/admin\/dashboard/);
const profileToggle = adminPage.getByRole("banner").getByRole("button").last();
await profileToggle.click();
await adminPage.getByRole("link", { name: "Sign Out" }).click();
await expect(adminPage).toHaveURL(/\/admin\/login/);
/**
* Returning to a page that required authentication must not serve it again — the session
* is invalidated on logout, not merely forgotten.
*/
await adminPage.goto("admin/dashboard");
await expect(adminPage).toHaveURL(/\/admin\/login/);
});
test("does not disclose whether an email belongs to an account", async ({ page }) => {
await page.goto("admin/forget-password");
await page.locator('input[name="email"]').fill(`unknown-${Date.now()}@example.invalid`);
await page.getByRole("button", { name: "Reset" }).click();
/**
* The old behaviour answered "this email does not exist", turning the form into a way of
* testing which addresses are registered users.
*/
await expect(page.getByText(/does not exist|not exist|no user|not found/i)).toHaveCount(0);
});
});
test.describe("installer lockdown", () => {
/**
* The installer API overwrites the administrator account and re-runs migrations against live
* data. On an installed application every one of these must be refused.
*/
const installerEndpoints = [
"install/api/admin-config-setup",
"install/api/run-migration",
"install/api/run-seeder",
"install/api/env-file-setup",
];
test("redirects the installer page away on an installed application", async ({ page }) => {
await page.goto("install");
await expect(page).not.toHaveURL(/\/install$/);
});
for (const endpoint of installerEndpoints) {
test(`refuses ${endpoint}`, async ({ page }) => {
const response = await page.request.post(endpoint, {
failOnStatusCode: false,
maxRedirects: 0,
});
expect(
response.status(),
`${endpoint} must not execute on an installed application`
).not.toBe(200);
});
}
});

View File

@@ -0,0 +1,333 @@
import { expect, test } from "../../fixtures/AdminFixtures";
import { GroupPage } from "../../pages/settings/GroupsPage";
import { buildRoleData, RolesPage } from "../../pages/settings/RolesPage";
import { buildUserData, UsersPage } from "../../pages/settings/UsersPage";
import {
ACL,
ADMIN_EMAIL,
ADMIN_PASSWORD,
Session,
TEST_PASSWORD,
closeSessions,
expectDenied,
findRecordId,
postRoute,
runSuffix,
signInExpectingSuccess,
} from "../../utils/acl";
/**
* Privilege escalation: a delegated administrator must never be able to grant, to anyone including
* themselves, more authority than they already hold.
*
* The acting user throughout is a "manager" who can administer roles and users but holds only a
* narrow slice of the lead permissions. Every case asks the same question from a different angle:
* can that manager end up with `leads.delete`, a full administrator role, or a wider data scope
* than the `group` scope they were given?
*/
test.describe.serial("privilege escalation guards", () => {
const suffix = runSuffix();
/**
* The manager can administer roles and users, and holds leads read + edit — but deliberately
* not `leads.delete`, which becomes the permission every escalation attempt tries to obtain.
*/
const managerRole = buildRoleData({
name: `ACL Manager Role ${suffix}`,
/**
* Leaf keys only. Listing `leads` here would tick the whole subtree and hand the manager
* the very `leads.delete` every case below is trying to prove they cannot obtain.
*/
permissions: [
ACL.dashboard,
ACL.leadsView,
ACL.leadsEdit,
ACL.settingsRolesCreate,
ACL.settingsRolesEdit,
ACL.settingsUsersCreate,
ACL.settingsUsersEdit,
],
});
/**
* The manager is scoped to `group`, which the user form requires a group for
* (`required_if:view_permission,group`) — so the group has to exist before the account does.
*/
const managerGroup = {
name: `ACL Manager Group ${suffix}`,
description: "Group backing the escalation suite's manager account",
};
const manager = buildUserData({
name: `ACL Manager ${suffix}`,
roleName: managerRole.name,
viewPermission: "group",
groups: [managerGroup.name],
});
let managerSession: Session | undefined;
/**
* The manager's own role id, needed to build payloads that are *valid* — the controller
* validates before it authorizes, so an incomplete payload answers 422 and never reaches the
* escalation guard the test is trying to exercise.
*/
let managerRoleId: number | null = null;
/**
* The manager's own user id, so the self-edit cases target the real account. Resolved from the
* users datagrid JSON, because the listing renders its edit control without a scrapable id.
*/
let managerUserId: number | null = null;
/**
* The manager's group id, required to keep a `group`-scoped update payload valid.
*/
let managerGroupId: number | null = null;
test.beforeAll(async ({ browser, baseURL }) => {
const admin = await signInExpectingSuccess(browser, baseURL, ADMIN_EMAIL, ADMIN_PASSWORD);
try {
const groupPage = new GroupPage(admin.page);
await groupPage.navigateToGroups();
await groupPage.createGroup(managerGroup);
await new RolesPage(admin.page).createRole(managerRole);
await new UsersPage(admin.page).createUser(manager);
managerRoleId = await findRecordId(
admin,
"admin/settings/roles",
"name",
managerRole.name
);
managerUserId = await findRecordId(
admin,
"admin/settings/users",
"name",
manager.name
);
managerGroupId = await findRecordId(
admin,
"admin/settings/groups",
"name",
managerGroup.name
);
} finally {
await closeSessions(admin);
}
managerSession = await signInExpectingSuccess(browser, baseURL, manager.email, TEST_PASSWORD);
});
test.afterAll(async () => {
await closeSessions(managerSession);
});
test.describe("the access-control tree is pruned to the actor's own permissions", () => {
test("offers permissions the manager holds", async () => {
const rolesPage = new RolesPage(managerSession!.page);
await rolesPage.navigateToCreateRole();
await rolesPage.permissionTypeSelect.selectOption("custom");
expect(
await rolesPage.isPermissionOffered(ACL.leadsEdit),
"a permission the manager holds should be grantable"
).toBeTruthy();
});
test("hides permissions the manager does not hold", async () => {
const rolesPage = new RolesPage(managerSession!.page);
await rolesPage.navigateToCreateRole();
await rolesPage.permissionTypeSelect.selectOption("custom");
/**
* `Acl::getAuthorizedItems()` prunes the tree, so `leads.delete` must not even be
* offered. The tree is only the first line — the server-side cases below prove it is
* not the only one.
*/
expect(
await rolesPage.isPermissionOffered(ACL.leadsDelete),
"a permission the manager lacks must not be offered"
).toBeFalsy();
});
});
test("a manager may create a role that is a subset of their own", async () => {
const rolesPage = new RolesPage(managerSession!.page);
const subsetRole = buildRoleData({
name: `ACL Subset Role ${suffix}`,
permissions: [ACL.leads, ACL.leadsView],
});
await rolesPage.createRole(subsetRole);
});
test("refuses a role carrying a permission the manager lacks", async () => {
/**
* The tree never offers `leads.delete`, so this posts the form directly — the case that
* matters, because a pruned UI is not an authorization control.
*/
const response = await postRoute(managerSession!, "admin/settings/roles/create", {
name: `Escalated Role ${suffix}`,
description: "attempts to grant a permission the actor lacks",
permission_type: "custom",
"permissions[]": ACL.leadsDelete,
});
expectDenied(response, "a role granting 'leads.delete' from an actor who lacks it");
});
test("refuses promoting a role to full administrator", async () => {
const response = await postRoute(managerSession!, "admin/settings/roles/create", {
name: `Escalated Admin Role ${suffix}`,
description: "attempts to create a full administrator role",
permission_type: "all",
});
expectDenied(response, "a full administrator role created by a non-administrator");
});
test("refuses editing a role broader than the manager's own", async () => {
const rolesPage = new RolesPage(managerSession!.page);
/**
* The seeded Administrator role holds `permission_type = all`, so it is strictly broader
* than the manager's. Editing it would be the shortest path to full control.
*/
await rolesPage.searchRole("Administrator");
/**
* The update route is a PUT, so the verb is spoofed through `_method` exactly as the admin
* panel's own forms do — posting without it answers 405 and never reaches the guard.
*/
const response = await postRoute(managerSession!, "admin/settings/roles/edit/1", {
_method: "put",
name: "Administrator",
description: "tampered by a non-administrator",
permission_type: "all",
});
expectDenied(response, "editing a role broader than the actor's own");
});
test("refuses assigning the administrator role to a new user", async () => {
/**
* `individual` scope keeps the payload valid without a group (the form requires one only
* for `group`), so the only thing left for the controller to object to is the role.
*/
const response = await postRoute(managerSession!, "admin/settings/users/create", {
name: `Escalated User ${suffix}`,
email: `escalated.${suffix}@example.test`,
password: TEST_PASSWORD,
confirm_password: TEST_PASSWORD,
role_id: "1",
view_permission: "individual",
});
expectDenied(response, "assigning the administrator role by a non-administrator");
});
test("refuses granting a data scope wider than the manager's own", async () => {
/**
* The manager's scope is `group`. Creating a `global` user would hand that account
* visibility the manager does not have, and is refused by the scope ceiling.
*/
const response = await postRoute(managerSession!, "admin/settings/users/create", {
name: `Wider Scope User ${suffix}`,
email: `wider.${suffix}@example.test`,
password: TEST_PASSWORD,
confirm_password: TEST_PASSWORD,
role_id: String(managerRoleId ?? ""),
view_permission: "global",
});
expectDenied(response, "granting 'global' scope from a 'group'-scoped actor");
});
test("allows granting a scope at or below the manager's own", async () => {
const usersPage = new UsersPage(managerSession!.page);
const narrowerUser = buildUserData({
name: `Narrower Scope User ${suffix}`,
roleName: managerRole.name,
viewPermission: "individual",
});
/**
* The ceiling narrows, it does not forbid delegation outright — a manager must still be
* able to create accounts at or below their own authority, or role administration becomes
* unusable.
*/
await usersPage.createUser(narrowerUser);
});
test("refuses changing the manager's own role", async () => {
expect(managerUserId, "the manager's own user id should resolve").not.toBeNull();
const response = await postRoute(
managerSession!,
`admin/settings/users/edit/${managerUserId}`,
{
_method: "put",
name: manager.name,
email: manager.email,
role_id: "1",
view_permission: "group",
"groups[]": String(managerGroupId ?? ""),
}
);
expectDenied(response, "a user changing their own role");
});
test("refuses widening the manager's own data scope", async () => {
expect(managerUserId, "the manager's own user id should resolve").not.toBeNull();
const response = await postRoute(
managerSession!,
`admin/settings/users/edit/${managerUserId}`,
{
_method: "put",
name: manager.name,
email: manager.email,
role_id: String(managerRoleId ?? ""),
view_permission: "global",
}
);
expectDenied(response, "a user widening their own data scope");
});
test("refuses editing the role assigned to the manager's own account", async () => {
const rolesPage = new RolesPage(managerSession!.page);
await rolesPage.openRoleForEdit(managerRole.name);
/**
* Nobody, administrators included, may edit the role attached to their own account — the
* one guard that closes the loop on every other ceiling in this suite.
*/
await expect(rolesPage.currentRoleEditError.first()).toBeVisible();
});
test("the manager never gains the permission every attempt was after", async () => {
/**
* The closing assertion: after all of the above, a direct mass-delete must still be
* refused. If any earlier guard leaked, this is where it shows.
*/
const response = await postRoute(managerSession!, "admin/leads/mass-destroy", {
"indices[]": "1",
});
expectDenied(response, "lead mass delete after every escalation attempt");
});
});

View File

@@ -0,0 +1,283 @@
import { expect, test } from "../../fixtures/AdminFixtures";
import { AclPage } from "../../pages/AclPage";
import { GroupPage, groupData } from "../../pages/settings/GroupsPage";
import { buildRoleData, RolesPage } from "../../pages/settings/RolesPage";
import { buildUserData, UsersPage } from "../../pages/settings/UsersPage";
import {
ACL,
ADMIN_EMAIL,
ADMIN_PASSWORD,
Session,
TEST_PASSWORD,
closeSessions,
expectAllowed,
expectDenied,
getRoute,
findRecordId,
runSuffix,
signInExpectingSuccess,
} from "../../utils/acl";
/**
* The data-scope gate: whose records an action may touch.
*
* Every user in this suite holds an identical, generous ACL role, so a record being hidden can
* only be the work of `view_permission`. That separation is the whole point — a suite that varies
* both dimensions at once cannot attribute a refusal to either.
*
* Shape of the fixture:
*
* globalUser -> view_permission = global -> sees every person
* groupUserA -> view_permission = group (G) -> sees A's and B's people
* groupUserB -> view_permission = group (G) -> sees A's and B's people
* individualUser -> view_permission = individual -> sees only their own
*/
test.describe.serial("view permission data scoping", () => {
const suffix = runSuffix();
/**
* Only leaf keys are listed — the tree pulls in `contacts` and `contacts.persons` as ancestors.
* `contacts.persons.view` is included deliberately: without it, the "opened directly by id"
* case would be refused by the ACL gate and prove nothing about data scope.
*/
const contactsRole = buildRoleData({
permissions: [
ACL.dashboard,
ACL.contactsPersonsQuickCreate,
ACL.contactsPersonsView,
],
});
const sharedGroup = {
name: `ACL Scope Group ${suffix}`,
description: "Group shared by the view-permission suite",
};
const globalUser = buildUserData({ roleName: contactsRole.name, viewPermission: "global" });
const groupUserA = buildUserData({
roleName: contactsRole.name,
viewPermission: "group",
groups: [sharedGroup.name],
});
const groupUserB = buildUserData({
roleName: contactsRole.name,
viewPermission: "group",
groups: [sharedGroup.name],
});
const individualUser = buildUserData({
roleName: contactsRole.name,
viewPermission: "individual",
});
/**
* One person per owner, named distinctly so a listing assertion is unambiguous.
*/
const personOfGlobal = `Global Person ${suffix}`;
const personOfGroupA = `GroupA Person ${suffix}`;
const personOfGroupB = `GroupB Person ${suffix}`;
const personOfIndividual = `Individual Person ${suffix}`;
let globalSession: Session | undefined;
let groupASession: Session | undefined;
let groupBSession: Session | undefined;
let individualSession: Session | undefined;
test.beforeAll(async ({ browser, baseURL }) => {
const admin = await signInExpectingSuccess(browser, baseURL, ADMIN_EMAIL, ADMIN_PASSWORD);
try {
const groupPage = new GroupPage(admin.page);
await groupPage.navigateToGroups();
await groupPage.createGroup(sharedGroup);
await new RolesPage(admin.page).createRole(contactsRole);
const usersPage = new UsersPage(admin.page);
await usersPage.createUser(globalUser);
await usersPage.createUser(groupUserA);
await usersPage.createUser(groupUserB);
await usersPage.createUser(individualUser);
} finally {
await closeSessions(admin);
}
globalSession = await signInExpectingSuccess(browser, baseURL, globalUser.email, TEST_PASSWORD);
groupASession = await signInExpectingSuccess(browser, baseURL, groupUserA.email, TEST_PASSWORD);
groupBSession = await signInExpectingSuccess(browser, baseURL, groupUserB.email, TEST_PASSWORD);
individualSession = await signInExpectingSuccess(
browser,
baseURL,
individualUser.email,
TEST_PASSWORD
);
/**
* Each person is created by the user who should own it. Creating them as an administrator
* and reassigning would leave ownership to the form's default, which is exactly the
* behaviour under test.
*/
await new AclPage(globalSession.page).createPerson(personOfGlobal, `g.${suffix}@example.test`);
await new AclPage(groupASession.page).createPerson(personOfGroupA, `ga.${suffix}@example.test`);
await new AclPage(groupBSession.page).createPerson(personOfGroupB, `gb.${suffix}@example.test`);
await new AclPage(individualSession.page).createPerson(
personOfIndividual,
`i.${suffix}@example.test`
);
});
test.afterAll(async () => {
await closeSessions(globalSession, groupASession, groupBSession, individualSession);
});
test("global scope sees records owned by everyone", async () => {
const aclPage = new AclPage(globalSession!.page);
for (const person of [personOfGlobal, personOfGroupA, personOfGroupB, personOfIndividual]) {
await aclPage.expectRecordListed("admin/contacts/persons", person);
}
});
test("individual scope sees only its own records", async () => {
const aclPage = new AclPage(individualSession!.page);
await aclPage.expectRecordListed("admin/contacts/persons", personOfIndividual);
for (const person of [personOfGlobal, personOfGroupA, personOfGroupB]) {
await aclPage.expectRecordNotListed("admin/contacts/persons", person);
}
});
test("group scope sees records owned by fellow group members", async () => {
const aclPage = new AclPage(groupASession!.page);
/**
* A and B share one group, so each must see the other's record — this is the assertion that
* separates group scope from individual scope.
*/
await aclPage.expectRecordListed("admin/contacts/persons", personOfGroupA);
await aclPage.expectRecordListed("admin/contacts/persons", personOfGroupB);
});
test("group scope does not see records owned outside the group", async () => {
const aclPage = new AclPage(groupASession!.page);
for (const person of [personOfGlobal, personOfIndividual]) {
await aclPage.expectRecordNotListed("admin/contacts/persons", person);
}
});
test("group scope is symmetric between members", async () => {
const aclPage = new AclPage(groupBSession!.page);
await aclPage.expectRecordListed("admin/contacts/persons", personOfGroupA);
await aclPage.expectRecordListed("admin/contacts/persons", personOfGroupB);
});
test("a record created under a scope is owned, not orphaned", async () => {
/**
* A person created with a null owner is invisible to every non-global user, including the
* one who created it. Re-reading the creator's own listing is what catches that.
*/
const aclPage = new AclPage(individualSession!.page);
await aclPage.expectRecordListed("admin/contacts/persons", personOfIndividual);
});
/**
* KNOWN DEFECT — marked fixme so it is recorded without failing the suite.
*
* `PersonController::store()` assigns `user_id` from the acting user only when the request
* carries `quick_add`. A person created through the standard create form without picking a
* Sales Owner is therefore saved with `user_id = null` and disappears from its own creator's
* listing the moment that creator is scoped to `group` or `individual`.
*
* `LeadRepository::create()` already guards exactly this case for the person it creates, with a
* comment naming the same failure mode, so the standard form path is missing a guard its
* sibling paths have.
*
* Verified on branch 2.2: five persons created this way all persisted with `user_id = NULL`,
* and the individual-scope creator's listing reported "0 - 0 of 0".
*/
test.fixme("a person created through the standard form is owned by its creator", async () => {
const aclPage = new AclPage(individualSession!.page);
const orphanCandidate = `Standard Form Person ${suffix}`;
await individualSession!.page.goto("admin/contacts/persons/create");
await individualSession!.page.waitForLoadState("networkidle");
await aclPage.personNameTextbox.fill(orphanCandidate);
await aclPage.personEmailTextbox.fill(`sf.${suffix}@example.test`);
await aclPage.personPhoneTextbox.fill(AclPage.uniquePhone());
await aclPage.savePersonButton.click();
await individualSession!.page.waitForLoadState("networkidle");
await aclPage.expectRecordListed("admin/contacts/persons", orphanCandidate);
});
test("the action gate stays open while the scope narrows the result", async () => {
/**
* A narrowed scope must not turn into a 401 on the listing itself — the user still holds
* `contacts.persons`, so the page renders and simply contains fewer rows.
*/
const response = await getRoute(individualSession!, "admin/contacts/persons");
expectAllowed(response, "person listing for an individual-scope user holding the permission");
});
test("the user listing is itself scoped by view permission", async () => {
/**
* `UserDataGrid` runs the same `getAuthorizedUserIds()` filter, so a restricted user must
* not enumerate the whole staff list. Without the settings permission the request is
* refused outright, which is an equally acceptable outcome here.
*/
const response = await getRoute(individualSession!, "admin/settings/users");
expectDenied(response, "user listing for a role without the settings permission");
});
test("a person owned by another user cannot be opened directly", async () => {
/**
* Listing filters hide a record; `preventUnauthorizedAccess()` is what stops it being
* fetched by id anyway.
*
* The id is resolved from the datagrid's JSON as the global user, because the person
* listing renders no per-row links to scrape. Replaying that id as the individual user is
* the actual assertion.
*/
const personId = await findRecordId(
globalSession!,
"admin/contacts/persons",
"person_name",
personOfGroupA
);
expect(
personId,
"the global user should be able to resolve another owner's person id"
).not.toBeNull();
const response = await getRoute(individualSession!, `admin/contacts/persons/view/${personId}`);
expectDenied(response, "another owner's person fetched directly by id");
});
test("a scoped user cannot resolve another owner's record through the grid json", async () => {
/**
* The same lookup run as the individual user must come back empty — the grid's JSON is
* scoped by the identical `getAuthorizedUserIds()` filter that hides the row in the UI.
*/
const personId = await findRecordId(
individualSession!,
"admin/contacts/persons",
"person_name",
personOfGroupA
);
expect(personId, "another owner's person must not appear in a scoped grid response").toBeNull();
});
});

View File

@@ -0,0 +1,31 @@
import { generateFullName, generateSKU } from "../../utils/faker";
import { attributeData, AttributesPage } from "../../pages/settings/AttributesPage";
import { test } from "../../fixtures/AdminFixtures";
import { AdminPage } from "../../pages/AdminPage";
test.describe("attribute managment ", async () => {
test("should create an attribute", async ({ adminPage }) => {
const attributePage = new AttributesPage(adminPage);
await attributePage.navigateToAttributes();
await attributePage.createAttribute(attributeData);
});
test("verify update attribute", async ({ adminPage }) => {
const attributePage = new AttributesPage(adminPage)
await attributePage.navigateToAttributes();
await attributePage.searchAttribute(attributeData.name.toLowerCase()
);
await attributePage.updateAttribute(attributeData);
})
test("verify delete attribute",async({adminPage})=>{
const attributePage= new AttributesPage(adminPage);
await attributePage.navigateToAttributes();
await attributePage.searchAttribute(attributeData.name);
await attributePage.deleteAttribute();
})
})

View File

@@ -0,0 +1,44 @@
import { test } from "../../fixtures/AdminFixtures";
import { AdminPage } from "../../pages/AdminPage";
import { CampaignData, campaignData, CampaignsPage } from "../../pages/settings/CampaignsPage";
import { eventdata, EventsPage } from "../../pages/settings/EventsPage";
test.describe('campaign management ', () => {
const updatedCampaignData: CampaignData = {
name: "updated name",
event: eventdata,
subject: "updated subject",
emailTemplateId: "hslafuieiwfnew",
active: 1
}
test('verify campaing create', async ({ adminPage }) => {
/*before creating campaign created event for the campaing */
const event = await new EventsPage(adminPage);
await event.navigateToEvents();
await event.createEvent(campaignData.event);
/*creating campaing after created event*/
const campaign = new CampaignsPage(adminPage);
await campaign.navigateToCampaigns();
await campaign.createCampaign(campaignData);
})
test('verify udpate campaign', async ({ adminPage }) => {
const campaign = new CampaignsPage(adminPage);
await campaign.navigateToCampaigns();
await campaign.searchByName(campaignData.name);
await campaign.editCampaign(campaignData);
})
test('verify delete campaign', async ({ adminPage }) => {
const campaign = new CampaignsPage(adminPage);
await campaign.navigateToCampaigns();
await campaign.searchByName(campaignData.name);
await campaign.deleteCampaign();
});
})

View File

@@ -0,0 +1,135 @@
import path from "path";
import { fileURLToPath } from "url";
import { expect, test } from "../../fixtures/AdminFixtures";
import { DataTransferPage } from "../../pages/settings/DataTransferPage";
import OrganizationPage, { organizationData } from "../../pages/OrganizationPage";
import PersonsPage, { buildPersonData } from "../../pages/PersonsPage";
import { allowedNodeEnvironmentFlags } from "process";
const updatedCsvFileName = 'leads_updated.csv';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const csvFileName ={
leads: 'leads.csv',
updatedLeads: 'leads_updated.csv',
persons: 'persons.csv',
updatedPersons: 'persons_updated.csv',
products: 'products.csv',
updatedProducts: 'products_updated.csv',
}
test.describe("data transfer product management",async()=>{
test("verify create import product",async({adminPage})=>{
const dataTransferPage=new DataTransferPage(adminPage );
const csvPath = path.resolve(__dirname, `../../data/data-transfer/${csvFileName.products}`);
await dataTransferPage.navigateToDataTransfer();
await dataTransferPage.createImport(csvPath,'products');
}
)
test("verify update import product",async({adminPage})=>{
const updatedCsvPath = path.resolve(__dirname, `../../data/data-transfer/${csvFileName.updatedProducts}`);
const dataTransferPage=new DataTransferPage(adminPage );
await dataTransferPage.navigateToDataTransfer();
await dataTransferPage.updateImport(updatedCsvPath);
await expect(dataTransferPage.successMessage.first()).toBeVisible();
}
)
test("verify delete import product",async({adminPage})=>{
const dataTransferPage=new DataTransferPage(adminPage );
await dataTransferPage.navigateToDataTransfer();
await dataTransferPage.deleteImport();
await expect(dataTransferPage.successMessage.first()).toBeVisible();
}
)
})
test.describe("data transfer lead management",async()=>{
let personId:string | null;
test("verify create import lead",async({adminPage})=>{
const csvPath = path.resolve(__dirname, `../../data/data-transfer/${csvFileName.leads}`);
const person = new PersonsPage(adminPage);
const organization=new OrganizationPage(adminPage);
await person.navigageToPersonsPage();
/**
* Its own person: email and contact number are unique in Krayin, so sharing one object with
* the person, lead and quote specs makes whichever runs later fail validation.
*/
await person.createPerson(buildPersonData());
personId = await person.firstPerson.textContent();
const dataTransferPage=new DataTransferPage(adminPage );
await dataTransferPage.updateCsv(csvPath, 'person_id', personId?.toString() || '1' );
await dataTransferPage.navigateToDataTransfer();
await dataTransferPage.createImport(csvPath,'leads');
}
)
test("verify update import lead",async({adminPage})=>{
const updatedCsvPath = path.resolve(__dirname, `../../data/data-transfer/${csvFileName.updatedLeads}`);
const person = new PersonsPage(adminPage);
const dataTransferPage=new DataTransferPage(adminPage );
await person.navigageToPersonsPage();
personId = await person.firstPerson.textContent();
await dataTransferPage.updateCsv(updatedCsvPath, 'person_id', personId?.toString() || '1' );
await dataTransferPage.navigateToDataTransfer();
await dataTransferPage.updateImport(updatedCsvPath);
await expect(dataTransferPage.successMessage.first()).toBeVisible();
}
)
test("verify delete import lead",async({adminPage})=>{
const dataTransferPage=new DataTransferPage(adminPage );
await dataTransferPage.navigateToDataTransfer();
await dataTransferPage.deleteImport();
await expect(dataTransferPage.successMessage.first()).toBeVisible();
}
)
})
test.describe("data transfer person management",async()=>{
let organizationId:string | null;
test("verify create import person",async({adminPage})=>{
const csvPath = path.resolve(__dirname, `../../data/data-transfer/${csvFileName.persons}`);
const organization=new OrganizationPage(adminPage);
await organization.navigateToOrganization();
await organization.createOrganization(organizationData);
const dataTransferPage=new DataTransferPage(adminPage );
organizationId=await organization.firstOrganization.textContent();
await dataTransferPage.updateCsv(csvPath, 'organization_id', organizationId?.toString() || '1' );
await dataTransferPage.navigateToDataTransfer();
await dataTransferPage.createImport(csvPath,'persons');
}
)
test("verify update import person",async({adminPage})=>{
const updatedCsvPath = path.resolve(__dirname, `../../data/data-transfer/${csvFileName.updatedPersons}`);
const dataTransferPage=new DataTransferPage(adminPage );
await dataTransferPage.updateCsv(updatedCsvPath, 'organization_id', organizationId?.toString() || '1' );
await dataTransferPage.navigateToDataTransfer();
await dataTransferPage.updateImport(updatedCsvPath);
await expect(dataTransferPage.successMessage.first()).toBeVisible();
}
)
test("verify delete import person",async({adminPage})=>{
const dataTransferPage=new DataTransferPage(adminPage );
await dataTransferPage.navigateToDataTransfer();
await dataTransferPage.deleteImport();
await expect(dataTransferPage.successMessage.first()).toBeVisible();
}
)
})

View File

@@ -0,0 +1,22 @@
import { test } from "../../fixtures/AdminFixtures";
import { emailTemplateData, EmailTemplatePage } from "../../pages/settings/EmailTemplatesPage";
test.describe("email template management", () => {
test("verify create an email template", async ({ adminPage }) => {
const emailTemplatePage = new EmailTemplatePage(adminPage);
await emailTemplatePage.navigateToEmailTemplates();
await emailTemplatePage.createTemplate(emailTemplateData);
});
test("verify update an email template", async ({ adminPage }) => {
const emailTemplatePage = new EmailTemplatePage(adminPage);
await emailTemplatePage.navigateToEmailTemplates();
await emailTemplatePage.updateEmailTemplate(emailTemplateData);
});
test("verify delete an email template", async ({ adminPage }) => {
const emailTemplatePage = new EmailTemplatePage(adminPage);
await emailTemplatePage.navigateToEmailTemplates();
await emailTemplatePage.deleteEmailTemplate();
});
});

View File

@@ -0,0 +1,28 @@
import { env } from "process";
import { test } from "../../fixtures/AdminFixtures";
import { eventdata, EventsPage } from "../../pages/settings/EventsPage";
import { AdminPage } from "../../pages/AdminPage";
test.describe('event management', () => {
test('create event', async ({ adminPage }) => {
const event = await new EventsPage(adminPage);
await event.navigateToEvents();
await event.createEvent(eventdata);
})
test('verify update event', async ({ adminPage }) => {
const event = await new EventsPage(adminPage);
await event.navigateToEvents();
await event.searchByName(eventdata.name);
await event.editEvents(eventdata);
})
test('verify delete event', async ({ adminPage }) => {
const event = await new EventsPage(adminPage);
await event.navigateToEvents();
await event.searchByName(eventdata.name);
await event.deleteEvent();
})
})

View File

@@ -0,0 +1,25 @@
import { test } from "../../fixtures/AdminFixtures";
import { groupData, GroupPage } from "../../pages/settings/GroupsPage";
test.describe("group managment",async()=>{
test("verify create group",async({adminPage})=>{
const groupPage=new GroupPage(adminPage);
await groupPage.navigateToGroups();
await groupPage.createGroup(groupData);
})
test("verify update group",async({adminPage})=>{
const groupPage=new GroupPage(adminPage);
await groupPage.navigateToGroups();
const updatedGroupData={
name: groupData.name+" Updated",
description: groupData.description+" Updated"
}
await groupPage.updateGroup(updatedGroupData);
})
test("verify delete group",async({adminPage})=>{
const groupPage=new GroupPage(adminPage);
await groupPage.navigateToGroups();
})
})

View File

@@ -0,0 +1,117 @@
import { expect, test } from "../../fixtures/AdminFixtures";
import { buildRoleData, RolesPage } from "../../pages/settings/RolesPage";
import { ACL } from "../../utils/acl";
/**
* Role management CRUD, plus the access-control tree behaviour the ACL suites depend on.
*
* These run as an administrator, so nothing here is an authorization assertion — the escalation
* rules are covered in `tests/security/privilege-escalation.spec.ts`.
*/
test.describe.serial("role management", () => {
const role = buildRoleData({
permissions: [ACL.leads, ACL.leadsView],
});
test("creates a custom role with selected permissions", async ({ adminPage }) => {
const rolesPage = new RolesPage(adminPage);
await rolesPage.createRole(role);
});
test("creates a full administrator role", async ({ adminPage }) => {
const rolesPage = new RolesPage(adminPage);
const adminRole = buildRoleData({ permissionType: "all" });
await rolesPage.createRole(adminRole);
/**
* Selecting `all` must hide the permission tree entirely — the checkboxes are only
* meaningful for a custom role.
*/
await rolesPage.navigateToCreateRole();
await rolesPage.permissionTypeSelect.selectOption("all");
await expect(rolesPage.permissionLabel(ACL.leads)).toHaveCount(0);
});
test("requires a name and a description", async ({ adminPage }) => {
const rolesPage = new RolesPage(adminPage);
await rolesPage.navigateToCreateRole();
await rolesPage.saveRoleButton.click();
/**
* The form must not submit — staying on the create URL is what proves validation fired.
*/
await expect(adminPage).toHaveURL(/\/admin\/settings\/roles\/create/);
});
test("requires at least one permission for a custom role", async ({ adminPage }) => {
const rolesPage = new RolesPage(adminPage);
const emptyRole = buildRoleData({ permissions: [] });
await rolesPage.submitRoleForm(emptyRole);
await expect(rolesPage.createSuccessMessage).toHaveCount(0);
});
test("selecting a child permission also selects its parent", async ({ adminPage }) => {
const rolesPage = new RolesPage(adminPage);
await rolesPage.navigateToCreateRole();
await rolesPage.permissionTypeSelect.selectOption("custom");
await rolesPage.selectPermission(ACL.leadsDelete);
/**
* The tree's default selection type is hierarchical, so a role can never hold
* `leads.delete` without also holding `leads`. Middleware route resolution relies on this.
*/
await expect(rolesPage.permissionCheckbox(ACL.leads)).toBeChecked();
});
test("updates a role", async ({ adminPage }) => {
const rolesPage = new RolesPage(adminPage);
await rolesPage.updateRole(role.name, {
description: "Updated by the authorization suite",
permissions: [ACL.leadsEdit],
});
await expect(rolesPage.updateSuccessMessage.first()).toBeVisible();
});
test("refuses to delete a role that is assigned to a user", async ({ adminPage }) => {
const rolesPage = new RolesPage(adminPage);
/**
* The seeded Administrator role is assigned to the signed-in account, so it is covered by
* both the "in use" guard and the "your own role" guard.
*/
await rolesPage.deleteRole("Administrator");
await expect(rolesPage.deleteSuccessMessage).toHaveCount(0);
});
test("refuses to edit the role assigned to the current account", async ({ adminPage }) => {
const rolesPage = new RolesPage(adminPage);
await rolesPage.openRoleForEdit("Administrator");
/**
* The guard redirects back to the listing with a flash rather than opening the form, so a
* user can never widen their own role.
*/
await expect(rolesPage.currentRoleEditError.first()).toBeVisible();
});
test("deletes an unused role", async ({ adminPage }) => {
const rolesPage = new RolesPage(adminPage);
await rolesPage.deleteRole(role.name);
await expect(rolesPage.deleteSuccessMessage.first()).toBeVisible();
});
});

View File

@@ -0,0 +1,31 @@
import { verify } from "crypto";
import { expect, test } from "../../fixtures/AdminFixtures";
import { SourcesPage } from "../../pages/settings/SourcesPage";
import { generateFirstName } from "../../utils/faker";
test.describe("source managment",async()=>{
const sourceName=generateFirstName();
const updateSourceName=sourceName+"udated";
test('verify create source',async({adminPage})=>{
const source=new SourcesPage(adminPage);
await source.navigateToSources();
await source.createSourceLink.click();
await source.sourceForm(sourceName);
})
test('verify update source',async({adminPage})=>{
const source = new SourcesPage(adminPage);
await source.navigateToSources();
await source.searchSource(sourceName);
await source.firstEditIcon.click();
await source.sourceForm(updateSourceName);
})
test("verify delete source",async ({adminPage})=>{
const source= new SourcesPage(adminPage);
await source.navigateToSources();
await source.searchSource(updateSourceName);
await source.deleteSource();
})
})

View File

@@ -0,0 +1,34 @@
import { expect, test } from "../../fixtures/AdminFixtures";
import { TagsPage } from "../../pages/settings/TagsPage";
test.describe("tag management",async()=>{
test("verify create tag",async({adminPage})=>{
const tagsPage=new TagsPage(adminPage);
await tagsPage.navigateToTags();
const tagName="Tag "+Math.floor(Math.random()*1000);
await tagsPage.createTag(tagName);
await tagsPage.seacrhTag(tagName);
await expect(adminPage.getByText(tagName).first()).toBeVisible();
})
test("verify update tag",async({adminPage})=>{
const tagsPage=new TagsPage(adminPage);
await tagsPage.navigateToTags();
const tagName="Tag "+Math.floor(Math.random()*1000);
await tagsPage.createTag(tagName);
const updatedTagName=tagName+" Updated";
await tagsPage.udpateTag(updatedTagName);
await tagsPage.seacrhTag(updatedTagName);
await expect(adminPage.getByText(updatedTagName).first()).toBeVisible();
})
test("verify delete tag",async({adminPage})=>{
const tagsPage=new TagsPage(adminPage);
await tagsPage.navigateToTags();
const tagName="Tag "+Math.floor(Math.random()*1000);
await tagsPage.createTag(tagName);
await tagsPage.seacrhTag(tagName);
await expect(adminPage.getByText(tagName).first()).toBeVisible();
await tagsPage.deleteTag();
await tagsPage.seacrhTag(tagName);
await expect(adminPage.getByText(tagName).first()).toBeHidden();
})
})

View File

@@ -0,0 +1,37 @@
import { test } from "../../fixtures/AdminFixtures";
import { AdminPage } from "../../pages/AdminPage";
import { WarehouseData, warehouseData, WarehousePage } from "../../pages/settings/WarehousesPage";
import { generateDescription, generateEmail, generateLastName, generatePhoneNumber } from "../../utils/faker";
test.describe('warehouse management', async () => {
const updateWareshousedata:WarehouseData={
name: generateLastName(),
description: generateDescription().slice(0,20),
contactName: generateLastName(),
contactEmail:generateEmail(),
contactAddress: 'Plot 123, Industrial Area, Meerut, UP 250001',
country: 'India',
state: 'Uttar Pradesh',
city: 'Meerut',
zipcode: '250001',
phone: generatePhoneNumber(),
}
test('create warehouse', async ({ adminPage }) => {
const warehouse = new WarehousePage(adminPage);
await warehouse.navigateToWarehouses();
await warehouse.warehouseCreateLink.click();
await warehouse.warehouseForm(warehouseData);
})
test('verify update warehouse',async({adminPage})=>{
const warehouse= new WarehousePage(adminPage);
await warehouse.navigateToWarehouses();
await warehouse.firstEditButton.click();
await warehouse.warehouseForm(updateWareshousedata);
})
test('verify delete warehouse',async({adminPage})=>{
const warehouse= new WarehousePage(adminPage);
await warehouse.navigateToWarehouses();
await warehouse.deleteWarehouse();
})
})

View File

@@ -0,0 +1,23 @@
import { test } from "../../fixtures/AdminFixtures";
import { webhookData, WebhookPage } from "../../pages/settings/WebhooksPage";
test.describe("webhook management", () => {
test("verify create a webhook", async ({ adminPage }) => {
const webhookPage = new WebhookPage(adminPage);
await webhookPage.navigateToWebhooks();
await webhookPage.createWebhook(webhookData);
});
test("verify update a webhook", async ({ adminPage }) => {
const webhookPage = new WebhookPage(adminPage);
await webhookPage.navigateToWebhooks();
await webhookPage.searchWebhook(webhookData.name);
await webhookPage.updateFirstWebhook(webhookData);
});
test("verify delete a webhook", async ({ adminPage }) => {
const webhookPage = new WebhookPage(adminPage);
await webhookPage.navigateToWebhooks();
await webhookPage.searchWebhook(webhookData.name);
await webhookPage.deleteWebhook();
});
})

View File

@@ -0,0 +1,276 @@
import { Browser, BrowserContext, expect, Page, APIResponse } from "@playwright/test";
import { AdminPage } from "../pages/AdminPage";
/**
* Shared vocabulary and helpers for the authorization suites.
*
* Krayin authorization has two orthogonal dimensions and the helpers below are split along the
* same seam:
*
* - ACL -> which actions a role may perform (roles.permission_type + roles.permissions)
* - View scope -> whose records those actions touch (users.view_permission)
*
* Everything here works against a signed-in browser context so a probe carries the same session
* cookies the UI would, which is the only way a direct-request test proves anything about the
* middleware.
*/
export const ADMIN_EMAIL = "admin@example.com";
export const ADMIN_PASSWORD = "admin123";
/**
* Password handed to every user these suites create. Kept above Krayin's six character minimum.
*/
export const TEST_PASSWORD = "password123";
export type ViewPermission = "global" | "group" | "individual";
export type PermissionType = "all" | "custom";
/**
* ACL keys used across the suites, named rather than repeated as string literals so a rename in
* `Admin/Config/acl.php` breaks in one place.
*/
export const ACL = {
dashboard: "dashboard",
leads: "leads",
leadsCreate: "leads.create",
leadsView: "leads.view",
leadsEdit: "leads.edit",
leadsDelete: "leads.delete",
quotes: "quotes",
quotesCreate: "quotes.create",
contacts: "contacts",
contactsPersons: "contacts.persons",
contactsPersonsCreate: "contacts.persons.create",
contactsPersonsView: "contacts.persons.view",
contactsPersonsQuickCreate: "contacts.persons.create.quick-create",
contactsPersonsDelete: "contacts.persons.delete",
settings: "settings",
settingsGroups: "settings.user.groups",
settingsRoles: "settings.user.roles",
settingsRolesCreate: "settings.user.roles.create",
settingsRolesEdit: "settings.user.roles.edit",
settingsRolesDelete: "settings.user.roles.delete",
settingsUsers: "settings.user.users",
settingsUsersCreate: "settings.user.users.create",
settingsUsersEdit: "settings.user.users.edit",
settingsUsersDelete: "settings.user.users.delete",
} as const;
/**
* Admin routes exercised by the direct-request probes, paired with the ACL key that must guard
* them. Paths are relative so they resolve against the configured `baseURL`.
*/
export const GUARDED_ROUTES: Array<{ path: string; permission: string; label: string }> = [
{ path: "admin/leads", permission: ACL.leads, label: "lead listing" },
{ path: "admin/leads/create", permission: ACL.leadsCreate, label: "lead create form" },
{ path: "admin/quotes", permission: ACL.quotes, label: "quote listing" },
{ path: "admin/quotes/create", permission: ACL.quotesCreate, label: "quote create form" },
{ path: "admin/contacts/persons", permission: ACL.contactsPersons, label: "person listing" },
{ path: "admin/settings/roles", permission: ACL.settingsRoles, label: "role listing" },
{ path: "admin/settings/roles/create", permission: ACL.settingsRolesCreate, label: "role create form" },
{ path: "admin/settings/users", permission: ACL.settingsUsers, label: "user listing" },
{ path: "admin/settings/groups", permission: ACL.settingsGroups, label: "group listing" },
];
/**
* Routes every authenticated user may reach regardless of role, mirroring the middleware's
* allow-list. A regression that widens the allow-list shows up as a route appearing here that
* should not.
*/
export const ALLOWLISTED_ROUTES = [
"admin/account",
];
export type Session = {
context: BrowserContext;
page: Page;
email: string;
};
/**
* Sign in as an arbitrary user in a brand new browser context.
*
* A separate context per user is deliberate: the admin fixture's storage state must not leak into
* a test that is meant to act as a restricted user, and reusing one context would let an earlier
* session's cookies answer for a later assertion.
*/
export async function signInAs(
browser: Browser,
baseURL: string | undefined,
email: string,
password: string
): Promise<Session> {
const context = await browser.newContext({ baseURL });
const page = await context.newPage();
await new AdminPage(page).adminLogin(email, password);
await page.waitForLoadState("networkidle");
return { context, page, email };
}
/**
* Sign in and assert the attempt actually authenticated, so a suite never proceeds to
* authorization assertions while silently signed out — an unauthenticated request is redirected to
* the login page and would pass a naive "not 200" check for the wrong reason.
*
* The landing URL is deliberately not pinned to the dashboard: a user whose role lacks the
* `dashboard` permission is dropped on the first screen they can reach (`/admin/leads`, say), so
* leaving the login page is what actually signals success.
*/
export async function signInExpectingSuccess(
browser: Browser,
baseURL: string | undefined,
email: string,
password: string
): Promise<Session> {
const session = await signInAs(browser, baseURL, email, password);
await expect(session.page, `${email} should be signed in`).not.toHaveURL(/\/admin\/login/);
return session;
}
/**
* Laravel accepts the `XSRF-TOKEN` cookie echoed back as an `X-XSRF-TOKEN` header, which is how
* the admin panel's own axios calls pass CSRF verification. Reusing that keeps a POST probe
* failing on authorization rather than on a missing token.
*/
export async function csrfHeaders(context: BrowserContext): Promise<Record<string, string>> {
const cookies = await context.cookies();
const token = cookies.find((cookie) => cookie.name === "XSRF-TOKEN");
return {
"X-XSRF-TOKEN": token ? decodeURIComponent(token.value) : "",
"X-Requested-With": "XMLHttpRequest",
};
}
/**
* Issue a GET against an admin route using the session's cookies, without following redirects, so
* the raw status is what gets asserted.
*/
export async function getRoute(session: Session, path: string): Promise<APIResponse> {
return session.page.request.get(path, {
failOnStatusCode: false,
maxRedirects: 0,
});
}
/**
* Issue a POST against an admin route with CSRF headers attached.
*/
export async function postRoute(
session: Session,
path: string,
form: Record<string, string> = {}
): Promise<APIResponse> {
return session.page.request.post(path, {
failOnStatusCode: false,
maxRedirects: 0,
headers: await csrfHeaders(session.context),
form,
});
}
/**
* Assert a response was refused by authorization.
*
* 401 is what `Bouncer::allow()` aborts with. A redirect to the login page (302) is accepted only
* as a session-level refusal; a 200 always fails, because rendering the page means the guard did
* not run.
*/
export function expectDenied(response: APIResponse, label: string): void {
expect(
response.status(),
`${label} must be refused for a user without the permission (got ${response.status()})`
).not.toBe(200);
expect(
[401, 403, 302, 419].includes(response.status()),
`${label} should be refused with 401/403 or a redirect, got ${response.status()}`
).toBeTruthy();
}
/**
* Assert a response was served.
*/
export function expectAllowed(response: APIResponse, label: string): void {
expect(response.status(), `${label} should be reachable`).toBe(200);
}
/**
* Close every session opened by a test, tolerating one that failed to open.
*/
export async function closeSessions(...sessions: Array<Session | undefined>): Promise<void> {
for (const session of sessions) {
await session?.context.close();
}
}
/**
* A run-scoped suffix, so records created by one run never collide with a previous one and a
* failed run leaves behind identifiable rows.
*/
export function runSuffix(): string {
return `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000)}`;
}
/**
* Resolve a record id out of a datagrid's own JSON response.
*
* The person listing renders no per-row links, so there is no href to scrape for an id. The grid
* populates itself from this endpoint, so asking it directly is both more stable than parsing the
* DOM and closer to what the page actually does.
*
* Returns `null` when the record is not present for this session — which for a scoped user is a
* meaningful answer, not a failure.
*/
export async function findRecordId(
session: Session,
path: string,
column: string,
value: string
): Promise<number | null> {
const response = await session.page.request.get(
`${path}?pagination[page]=1&pagination[per_page]=200`,
{
headers: { "X-Requested-With": "XMLHttpRequest", Accept: "application/json" },
failOnStatusCode: false,
}
);
if (response.status() !== 200) {
return null;
}
const body = await response.json();
/**
* A grid column is not always a scalar: the users grid renders its name cell from an object
* (`{ image, name }`), which the Blade reads as `record.name.name`. Unwrap that shape before
* comparing, or every user lookup silently misses.
*/
const cellText = (cell: unknown): string => {
if (cell && typeof cell === "object" && "name" in (cell as Record<string, unknown>)) {
return String((cell as Record<string, unknown>).name ?? "").trim();
}
return String(cell ?? "").trim();
};
const match = (body.records ?? []).find(
(record: Record<string, unknown>) => cellText(record[column]) === value
);
return match ? Number(match.id) : null;
}

View File

@@ -0,0 +1,58 @@
import * as forms from './form';
import { generateRandomNumericString } from './faker';
const address = async (page) => {
await page.fill('input[name="billing.company_name"]', forms.form.lastName);
await page.fill('input[name="billing.first_name"]', forms.form.firstName);
await page.fill('input[name="billing.last_name"]', forms.form.lastName);
await page.fill('input[name="billing.email"]', forms.form.email);
await page.fill('input[name="billing.address.[0]"]', forms.form.firstName);
await page.selectOption('select[name="billing.country"]', 'IN');
await page.selectOption('select[name="billing.state"]', 'UP');
await page.fill('input[name="billing.city"]', forms.form.lastName);
await page.fill('input[name="billing.postcode"]', generateRandomNumericString(6));
await page.fill('input[name="billing.phone"]', forms.form.phone);
const exists = await page.$$('input[name="billing.save_address"]');
if (exists.length != 0) {
await page.click('input[name="billing.save_address"] + label');
await page.press('input[name="billing.phone"]', 'Enter');
} else {
const checkbox = await page.$$('input[name="billing.use_for_shipping"]');
if (Math.floor(Math.random() * 20) % 3 == 1 ? false : true) {
if (! checkbox[0].isChecked()) {
await page.click('input[name="billing.use_for_shipping"] + label');
}
} else {
if (checkbox[0].isChecked()) {
await page.click('input[name="billing.use_for_shipping"] + label');
}
await page.fill('input[name="shipping.company_name"]', forms.form.lastName);
await page.fill('input[name="shipping.first_name"]', forms.form.firstName);
await page.fill('input[name="shipping.last_name"]', forms.form.lastName);
await page.fill('input[name="shipping.email"]', forms.form.email);
await page.fill('input[name="shipping.address.[0]"]', forms.form.firstName);
await page.selectOption('select[name="shipping.country"]', 'IN');
await page.selectOption('select[name="shipping.state"]', 'UP');
await page.fill('input[name="shipping.city"]', forms.form.lastName);
await page.fill('input[name="shipping.postcode"]', '201301');
await page.fill('input[name="shipping.phone"]', forms.form.phone);
}
const nextButton = await page.$$('button.primary-button:visible');
await nextButton[0].click();
}
const output = await forms.testForm(page)
if (output == null) {
return address(page);
} else {
return 'done';
}
}
export default address;

View File

@@ -0,0 +1,159 @@
import { expect } from "../setup";
import {
generateName,
generateFirstName,
generateLastName,
generateEmail,
generateDescription,
} from "./faker";
export async function register(page) {
const credentials = {
firstName: generateFirstName(),
lastName: generateLastName(),
email: generateEmail(),
password: "admin123",
};
await page.getByPlaceholder("First Name").fill(credentials.firstName);
await page.getByPlaceholder("Last Name").fill(credentials.lastName);
await page.getByPlaceholder("email@example.com").fill(credentials.email);
await page
.getByPlaceholder("Password", { exact: true })
.fill(credentials.password);
await page.getByPlaceholder("Confirm Password").fill(credentials.password);
const isAgreementVisible = await page.locator("#agreement").nth(1).isVisible();
if (isAgreementVisible) {
await page.getByText("I agree with this statement.").click();
}
const isNewsletterVisible = await page
.locator("#main form div")
.filter({ hasText: "Subscribe to newsletter" })
.locator("label")
.first()
.isVisible();
if (isNewsletterVisible) {
await page
.locator("#main form div")
.filter({ hasText: "Subscribe to newsletter" })
.locator("label")
.first()
.click();
}
await page.getByRole("button", { name: "Register" }).click();
await expect(
page
.getByText(
"Account created successfully, an e-mail has been sent for verification."
)
.first()
).toBeVisible();
return credentials;
}
export async function loginAsCustomer(page) {
const credentials = await register(page);
await page.goto("customer/login");
await page.getByPlaceholder("email@example.com").click();
await page.getByPlaceholder("email@example.com").fill(credentials.email);
await page.getByPlaceholder("email@example.com").press("Tab");
await page.getByPlaceholder("Password").fill(credentials.password);
await page.getByRole("button", { name: "Sign In" }).click();
return credentials;
}
export async function addAddress(page) {
await page.getByLabel("Profile").click();
await page.getByRole("link", { name: "Profile" }).click();
await page.getByRole("link", { name: " Address " }).click();
await page.getByRole("link", { name: "Add Address" }).click();
await page.getByPlaceholder("Company Name").click();
await page.getByPlaceholder("Company Name").fill(generateName());
await page.getByPlaceholder("Company Name").press("Tab");
await page.getByPlaceholder("First Name").fill(generateFirstName());
await page.getByPlaceholder("First Name").press("Tab");
await page.getByPlaceholder("Last Name").fill(generateLastName());
await page.getByPlaceholder("Last Name").press("Tab");
await page
.getByPlaceholder("Email", { exact: true })
.fill("test@example.com");
await page.getByPlaceholder("Email", { exact: true }).press("Tab");
await page.getByPlaceholder("Vat ID").press("Tab");
await page.getByPlaceholder("Street Address").fill("Demo");
await page.getByPlaceholder("Street Address").press("Tab");
await page.getByLabel("Country").selectOption("DZ");
await page.getByPlaceholder("State").click();
await page.getByPlaceholder("State").fill("any");
await page.getByPlaceholder("City").click();
await page.getByPlaceholder("City").fill("any");
await page.getByPlaceholder("Post Code").click();
await page.getByPlaceholder("Post Code").fill("123456");
await page.getByPlaceholder("Phone").click();
await page.getByPlaceholder("Phone").fill("9876543210");
await page
.locator("#main form div")
.filter({ hasText: "Set as Default" })
.locator("label")
.first()
.click();
await page
.locator("#main form div")
.filter({ hasText: "Set as Default" })
.locator("label")
.first()
.click();
await page.getByRole("button", { name: "Save" }).click();
await expect(
page.getByText("Address have been successfully added.").first()
).toBeVisible();
}
export async function addWishlist(page) {
await page.locator(".action-items > span").first().click();
await page
.locator(
"div:nth-child(9) > div:nth-child(2) > div > .-mt-9 > .action-items > span"
)
.first()
.click();
await expect(
page.getByText("Item Successfully Added To Wishlist").first()
).toBeVisible();
}
export async function addReview(page) {
const review = {
title: generateName(),
comment: generateDescription(),
};
await page
.locator("#main div")
.filter({ hasText: "New Products View All New" })
.getByLabel("Arctic Touchscreen Winter")
.click();
await page.getByRole("button", { name: "Reviews" }).click();
await page.locator("#review-tab").getByText("Write a Review").click();
await page.locator("#review-tab span").nth(3).click();
await page.locator("#review-tab span").nth(4).click();
await page.getByPlaceholder("Title").fill(review.title);
await page.getByPlaceholder("Comment").fill(review.comment);
await page.getByRole("button", { name: "Submit Review" }).click();
await expect(
page.getByText("Review submitted successfully.").first()
).toBeVisible();
return review;
}

View File

@@ -0,0 +1,856 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const usedNames = new Set();
const usedEmails = new Set();
const usedNumbers = new Set();
const usedSlugs = new Set();
const usedCurrencies = new Set();
const usedLocales = new Set();
export function generateName() {
const adjectives = [
"Cool",
"Smart",
"Fast",
"Sleek",
"Innovative",
"Shiny",
"Bold",
"Elegant",
"Epic",
"Mystic",
"Brilliant",
"Luminous",
];
const nouns = [
"Star",
"Vision",
"Echo",
"Spark",
"Horizon",
"Nova",
"Shadow",
"Wave",
"Pulse",
"Vortex",
"Zenith",
"Element",
];
let name = "";
do {
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
name = `${adj} ${noun}`;
} while (usedNames.has(name));
usedNames.add(name);
return name;
}
export function generateFirstName() {
const firstNames = [
"James",
"Emma",
"Liam",
"Olivia",
"Noah",
"Ava",
"William",
"Sophia",
"Benjamin",
"Isabella",
"Lucas",
"Mia",
];
return firstNames[Math.floor(Math.random() * firstNames.length)];
}
export function generateLastName() {
const lastNames = [
"Smith",
"Johnson",
"Brown",
"Williams",
"Jones",
"Garcia",
"Miller",
"Davis",
"Rodriguez",
"Martinez",
"Hernandez",
"Lopez",
];
return lastNames[Math.floor(Math.random() * lastNames.length)];
}
export function generateFullName() {
return `${generateFirstName()} ${generateLastName()}`;
}
export function generateEmail() {
const adjectives = [
"Cool",
"Smart",
"Fast",
"Sleek",
"Innovative",
"Shiny",
"Bold",
"Elegant",
"Epic",
"Mystic",
"Brilliant",
"Luminous",
];
const nouns = [
"Star",
"Vision",
"Echo",
"Spark",
"Horizon",
"Nova",
"Shadow",
"Wave",
"Pulse",
"Vortex",
"Zenith",
"Element",
];
let email = "";
do {
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
const number = Math.floor(1000 + Math.random() * 9000);
email = `${adj}${noun}${number}@example.com`.toLowerCase();
} while (usedEmails.has(email));
usedEmails.add(email);
return email;
}
export function generatePhoneNumber() {
let phoneNumber;
do {
phoneNumber = Math.floor(6000000000 + Math.random() * 4000000000);
} while (usedNumbers.has(phoneNumber));
usedNumbers.add(phoneNumber);
return `${phoneNumber}`;
}
export function generateSKU() {
const letters = Array.from({ length: 3 }, () =>
String.fromCharCode(65 + Math.floor(Math.random() * 26))
).join("");
const numbers = Math.floor(1000 + Math.random() * 9000);
return `${letters}${numbers}`;
}
export function generateSlug(delimiter = "-") {
let slug;
do {
const name = generateName();
const randomStr = Math.random().toString(36).substring(2, 8);
slug = `${name
.toLowerCase()
.replace(/\s+/g, delimiter)}${delimiter}${randomStr}`;
} while (usedSlugs.has(slug));
usedSlugs.add(slug);
return slug;
}
export function generateDescription(length = 255) {
const phrases = [
"An innovative and sleek design.",
"Built for speed and efficiency.",
"Experience the future today.",
"A perfect blend of style and power.",
"Engineered to perfection.",
"Designed for those who dream big.",
"Unleash creativity with this masterpiece.",
"A game-changer in every way.",
"Smart, fast, and reliable.",
"The perfect companion for your journey.",
"Crafted with precision and excellence.",
"Innovation that redefines possibilities.",
"Enhancing your experience like never before.",
"Where technology meets elegance.",
"Power, performance, and perfection combined.",
"Redefining the way you experience the world.",
"A masterpiece of engineering and design.",
"Unmatched quality and exceptional performance.",
"Designed to elevate your lifestyle.",
"Beyond expectations, beyond limits.",
];
let description = "";
while (description.length < length) {
let phrase = phrases[Math.floor(Math.random() * phrases.length)];
if (description.length + phrase.length <= length) {
description += (description ? " " : "") + phrase;
} else {
description +=
" " + phrase.substring(0, length - description.length);
break;
}
}
return description.trim();
}
/**
* Generates a random numeric string with specified length or within a numeric range.
*
* @param {number} [length=10] - The length of the random string (used only when min and max are undefined)
* @param {number|null|undefined} [min=undefined] - Minimum value (inclusive) when generating a number in range
* @param {number|null|undefined} [max=undefined] - Maximum value (inclusive) when generating a number in range
* @returns {string} - Random numeric string
*/
export function generateRandomNumericString(length: number = 10, min?: number | null, max?: number | null): string {
// Generate a number within range
if (min !== null && min !== undefined && max !== null && max !== undefined) {
// Input validation
if (!Number.isInteger(min) || !Number.isInteger(max)) {
throw new Error("Min and max must be integers when provided.");
}
if (min > max) {
throw new Error("Min value cannot be greater than max value.");
}
// Generate a random number within the range and convert to string
return Math.floor(Math.random() * (max - min + 1) + min).toString();
}
// Generate a random string of specified length
if (!Number.isInteger(length) || length <= 0) {
throw new Error("Length must be a positive integer.");
}
// More efficient method for generating random digits
let result = '';
for (let i = 0; i < length; i++) {
result += Math.floor(Math.random() * 10);
}
// Ensure first character is not zero for a consistent length string
if (length > 1 && result[0] === '0') {
result = String(1 + Math.floor(Math.random() * 9)) + result.substring(1);
}
return result;
}
export function generateHostname() {
const words = [
"tech",
"cloud",
"byte",
"stream",
"nexus",
"core",
"pulse",
"data",
"sync",
"wave",
"hub",
"zone",
];
const domains = [".com", ".net", ".io", ".ai", ".xyz", ".co"];
const part1 = words[Math.floor(Math.random() * words.length)];
const part2 = words[Math.floor(Math.random() * words.length)];
const domain = domains[Math.floor(Math.random() * domains.length)];
return `https://${part1}${part2}${domain}`;
}
export function generateCurrency() {
const currencies = [
{
name: "US Dollar",
code: "USD",
symbol: "$",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Euro",
code: "EUR",
symbol: "€",
decimalDigits: "2",
groupSeparator: ".",
decimalSeparator: ",",
},
{
name: "British Pound",
code: "GBP",
symbol: "£",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Japanese Yen",
code: "JPY",
symbol: "¥",
decimalDigits: "0",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Australian Dollar",
code: "AUD",
symbol: "A$",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Canadian Dollar",
code: "CAD",
symbol: "C$",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Swiss Franc",
code: "CHF",
symbol: "CHF",
decimalDigits: "2",
groupSeparator: "'",
decimalSeparator: ".",
},
{
name: "Chinese Yuan",
code: "CNY",
symbol: "¥",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Indian Rupee",
code: "INR",
symbol: "₹",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "South Korean Won",
code: "KRW",
symbol: "₩",
decimalDigits: "0",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Mexican Peso",
code: "MXN",
symbol: "MX$",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Russian Ruble",
code: "RUB",
symbol: "₽",
decimalDigits: "2",
groupSeparator: " ",
decimalSeparator: ",",
},
{
name: "Brazilian Real",
code: "BRL",
symbol: "R$",
decimalDigits: "2",
groupSeparator: ".",
decimalSeparator: ",",
},
{
name: "South African Rand",
code: "ZAR",
symbol: "R",
decimalDigits: "2",
groupSeparator: " ",
decimalSeparator: ",",
},
{
name: "New Zealand Dollar",
code: "NZD",
symbol: "NZ$",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Singapore Dollar",
code: "SGD",
symbol: "S$",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Hong Kong Dollar",
code: "HKD",
symbol: "HK$",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Norwegian Krone",
code: "NOK",
symbol: "kr",
decimalDigits: "2",
groupSeparator: " ",
decimalSeparator: ",",
},
{
name: "Swedish Krona",
code: "SEK",
symbol: "kr",
decimalDigits: "2",
groupSeparator: " ",
decimalSeparator: ",",
},
{
name: "Danish Krone",
code: "DKK",
symbol: "kr",
decimalDigits: "2",
groupSeparator: ".",
decimalSeparator: ",",
},
{
name: "Polish Złoty",
code: "PLN",
symbol: "zł",
decimalDigits: "2",
groupSeparator: " ",
decimalSeparator: ",",
},
{
name: "Turkish Lira",
code: "TRY",
symbol: "₺",
decimalDigits: "2",
groupSeparator: ".",
decimalSeparator: ",",
},
{
name: "Thai Baht",
code: "THB",
symbol: "฿",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Indonesian Rupiah",
code: "IDR",
symbol: "Rp",
decimalDigits: "0",
groupSeparator: ".",
decimalSeparator: ",",
},
{
name: "Malaysian Ringgit",
code: "MYR",
symbol: "RM",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Philippine Peso",
code: "PHP",
symbol: "₱",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Israeli Shekel",
code: "ILS",
symbol: "₪",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Saudi Riyal",
code: "SAR",
symbol: "﷼",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "UAE Dirham",
code: "AED",
symbol: "د.إ",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Czech Koruna",
code: "CZK",
symbol: "Kč",
decimalDigits: "2",
groupSeparator: " ",
decimalSeparator: ",",
},
{
name: "Hungarian Forint",
code: "HUF",
symbol: "Ft",
decimalDigits: "0",
groupSeparator: " ",
decimalSeparator: ",",
},
{
name: "Romanian Leu",
code: "RON",
symbol: "lei",
decimalDigits: "2",
groupSeparator: ".",
decimalSeparator: ",",
},
{
name: "Bulgarian Lev",
code: "BGN",
symbol: "лв",
decimalDigits: "2",
groupSeparator: " ",
decimalSeparator: ",",
},
{
name: "Croatian Kuna",
code: "HRK",
symbol: "kn",
decimalDigits: "2",
groupSeparator: ".",
decimalSeparator: ",",
},
{
name: "Icelandic Króna",
code: "ISK",
symbol: "kr",
decimalDigits: "0",
groupSeparator: ".",
decimalSeparator: ",",
},
{
name: "Ukrainian Hryvnia",
code: "UAH",
symbol: "₴",
decimalDigits: "2",
groupSeparator: " ",
decimalSeparator: ",",
},
{
name: "Pakistani Rupee",
code: "PKR",
symbol: "₨",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Bangladeshi Taka",
code: "BDT",
symbol: "৳",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Sri Lankan Rupee",
code: "LKR",
symbol: "Rs",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Nepalese Rupee",
code: "NPR",
symbol: "₨",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Kuwaiti Dinar",
code: "KWD",
symbol: "د.ك",
decimalDigits: "3",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Qatari Riyal",
code: "QAR",
symbol: "ر.ق",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Omani Rial",
code: "OMR",
symbol: "ر.ع.",
decimalDigits: "3",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Bahraini Dinar",
code: "BHD",
symbol: "ب.د",
decimalDigits: "3",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Argentine Peso",
code: "ARS",
symbol: "$",
decimalDigits: "2",
groupSeparator: ".",
decimalSeparator: ",",
},
{
name: "Chilean Peso",
code: "CLP",
symbol: "$",
decimalDigits: "0",
groupSeparator: ".",
decimalSeparator: ",",
},
{
name: "Colombian Peso",
code: "COP",
symbol: "$",
decimalDigits: "0",
groupSeparator: ".",
decimalSeparator: ",",
},
{
name: "Peruvian Sol",
code: "PEN",
symbol: "S/.",
decimalDigits: "2",
groupSeparator: ",",
decimalSeparator: ".",
},
{
name: "Venezuelan Bolívar",
code: "VES",
symbol: "Bs.",
decimalDigits: "2",
groupSeparator: ".",
decimalSeparator: ",",
},
];
if (usedCurrencies.size >= currencies.length) {
throw new Error("All currencies have been used.");
}
let currency;
do {
const randomIndex = Math.floor(Math.random() * currencies.length);
currency = currencies[randomIndex];
} while (usedCurrencies.has(currency.code));
usedCurrencies.add(currency.code);
return currency;
}
export function generateLocale() {
const locales = [
{
name: "English",
code: "en",
direction: "LTR"
},
{
name: "French",
code: "fr",
direction: "LTR"
},
{
name: "Spanish",
code: "es",
direction: "LTR"
},
{
name: "Arabic",
code: "ar",
direction: "RTL"
},
{
name: "Hebrew",
code: "he",
direction: "RTL"
},
{
name: "Japanese",
code: "ja",
direction: "LTR"
},
{
name: "Chinese",
code: "zh_CN",
direction: "LTR"
},
{
name: "Hindi",
code: "hi_IN",
direction: "LTR"
},
{
name: "Bengali",
code: "bn",
direction: "LTR"
},
{
name: "German",
code: "de",
direction: "LTR"
},
{
name: "Persian",
code: "fa",
direction: "LTR"
},
{
name: "Italian",
code: "it",
direction: "LTR"
},
{
name: "Dutch",
code: "nl",
direction: "LTR"
},
];
if (usedLocales.size >= locales.length) {
throw new Error("All locales have been used.");
}
let locale;
do {
const randomIndex = Math.floor(Math.random() * locales.length);
locale = locales[randomIndex];
} while (usedLocales.has(locale.code));
usedLocales.add(locale.code);
return locale;
}
export function randomElement(array) {
return array[Math.floor(Math.random() * array.length)];
}
export function getImageFile(
directory = path.resolve(__dirname, "../data/images/")
) {
if (!fs.existsSync(directory)) {
throw new Error(`Directory does not exist: ${directory}`);
}
const files = fs.readdirSync(directory);
const imageFiles = files.filter((file) =>
/\.(gif|jpeg|jpg|png|svg|webp)$/i.test(file)
);
if (!imageFiles.length) {
throw new Error("No image files found in the directory.");
}
const randomIndex = Math.floor(Math.random() * imageFiles.length);
return path.join(directory, imageFiles[randomIndex]);
}
export function generateRandomDateTime(end = new Date(new Date().setFullYear(new Date().getFullYear() + 10))) {
const start = new Date();
const date = new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
export function generateLocation() {
const location = [
"New York",
"Los Angeles",
"Chicago",
"Houston",
"Phoenix",
"Philadelphia",
"San Antonio",
"San Diego",
"Dallas",
"San Jose",
"Austin",
"Jacksonville",
"San Francisco",
"Indianapolis",
"Columbus",
"Fort Worth",
];
return location[Math.floor(Math.random() * location.length)];
}

View File

@@ -0,0 +1,198 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function getRandomImageFile(
directory = path.resolve(__dirname, "../data/images/")
) {
if (!fs.existsSync(directory)) {
throw new Error(`Directory does not exist: ${directory}`);
}
const files = fs.readdirSync(directory);
const imageFiles = files.filter((file) =>
/\.(gif|jpeg|jpg|png|svg|webp)$/i.test(file)
);
if (!imageFiles.length) {
throw new Error("No image files found in the directory.");
}
const randomIndex = Math.floor(Math.random() * imageFiles.length);
return path.join(directory, imageFiles[randomIndex]);
}
const testForm = async (page) => {
const getError = await page
.waitForSelector(".text-red-600.text-xs.italic", { timeout: 2000 })
.catch(() => null);
var message = "";
if (getError) {
const errors = await page.$$(".text-red-600.text-xs.italic");
for (let error of errors) {
message = await error.evaluate((el) => el.innerText);
}
return null;
} else {
const iconExists = await page
.waitForSelector(
".flex.items-center.break-all.text-sm > .icon-toast-done.rounded-full.bg-white.text-2xl",
{ timeout: 5000 }
)
.catch(() => null);
if (iconExists) {
const messages = await page.$$(
".flex.items-center.break-all.text-sm > .icon-toast-done.rounded-full.bg-white.text-2xl"
);
const icons = await page.$$(
".flex.items-center.break-all.text-sm + .cursor-pointer.underline"
);
message = await messages[0].evaluate(
(el) => el.parentNode.innerText
);
await icons[0].click();
return null;
} else {
return page.url();
}
}
};
const form = {
firstName: `Test${Math.random().toString(36).substring(7)}`,
lastName: `User${Math.random().toString(36).substring(7)}`,
email: `test${Math.random().toString(36).substring(7)}@example.com`,
phone: `555${Math.floor(Math.random() * 10000000)
.toString()
.padStart(7, "0")}`,
};
function generateRandomPassword(minLength, maxLength) {
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()";
const length =
Math.floor(Math.random() * (maxLength - minLength + 1)) + minLength;
let password = "";
for (let i = 0; i < length; i++) {
password += characters.charAt(
Math.floor(Math.random() * characters.length)
);
}
return password;
}
function generateRandomStringWithSpaces(length) {
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < length; i++) {
if (Math.random() < 0.1) {
result += " ";
} else {
const randomIndex = Math.floor(Math.random() * characters.length);
result += characters[randomIndex];
}
}
return result;
}
function generateRandomProductName() {
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
let result = "";
for (let i = 1; i < 3; i++) {
if (Math.random() < 0.1) {
result += " ";
} else {
const randomIndex = Math.floor(Math.random() * characters.length);
result += characters[randomIndex];
}
}
return result;
}
function generateRandomHtmlContent() {
const elements = ["span", "a", "em", "strong", "b", "i", "u"];
const randomElement = elements[Math.floor(Math.random() * elements.length)];
const randomText = Math.random().toString(36).substring(7);
const randomHref = `https://example.com/${Math.random()
.toString(36)
.substring(7)}`;
switch (randomElement) {
case "a":
return `<a href="${randomHref}">${randomText}</a>`;
case "span":
return `<span>${randomText}</span>`;
case "em":
return `<em>${randomText}</em>`;
case "strong":
return `<strong>${randomText}</strong>`;
case "b":
return `<b>${randomText}</b>`;
case "i":
return `<i>${randomText}</i>`;
case "u":
return `<u>${randomText}</u>`;
default:
return `<span>${randomText}</span>`;
}
}
async function fillParagraphWithRandomHtml(multi) {
const randomHtmlContent = new Array(multi)
.fill("")
.map(() => generateRandomHtmlContent())
.join(" ");
return randomHtmlContent;
}
function generateRandomUrl() {
const protocols = ["http", "https"];
const domain = `example${Math.random().toString(36).substring(7)}.com`;
const path = `/${Math.random().toString(36).substring(7)}`;
const protocol = protocols[Math.floor(Math.random() * protocols.length)];
return `${protocol}://${domain}${path}`;
}
function generateRandomDateTimeRange() {
// Generate valid "from" and "to" dates
const fromDate = new Date(2024, 9, 29, 12, 0, 0); // Start: 2024-10-29 12:00:00
const toDate = new Date(2024, 10, 29, 12, 0, 0); // End: 2024-11-29 12:00:00
// Randomize within the range
const randomFrom = new Date(
fromDate.getTime() +
Math.random() * (toDate.getTime() - fromDate.getTime() - 86400000)
); // Ensure `to` is at least 1 day ahead
const randomTo = new Date(
randomFrom.getTime() +
Math.random() * (toDate.getTime() - randomFrom.getTime())
);
return {
from: randomFrom.toISOString().replace("T", " ").split(".")[0],
to: randomTo.toISOString().replace("T", " ").split(".")[0],
};
}
export {
form,
getRandomImageFile,
testForm,
generateRandomPassword,
generateRandomStringWithSpaces,
fillParagraphWithRandomHtml,
generateRandomProductName,
generateRandomUrl,
generateRandomDateTimeRange,
};

View File

@@ -0,0 +1,221 @@
import { expect } from "../setup";
import { generateDescription } from "../utils/faker";
export async function enableGDPR(adminPage) {
/**
* Navigating to the gdpr configuration page.
*/
await adminPage.goto("admin/configuration/general/gdpr");
const isEnabled = adminPage.locator("label > div").first().check();
/**
* If not enabled, then we enable it.
*/
if (!isEnabled) {
const gdprsettingToggle = adminPage.locator("label > div").first();
await gdprsettingToggle.waitFor({ state: "visible", timeout: 5000 });
await adminPage.locator("label > div").first().click();
}
/**
* Verifying enable state.
*/
const toggleInput = adminPage.locator("label > div").first();
await expect(toggleInput).toBeChecked();
/**
* Click the 'Save Configuration' button.
*/
await adminPage.getByRole("button", { name: "Save Configuration" }).click();
}
export async function disableGDPR(adminPage) {
/**
* Navigating to the gdpr configuration page.
*/
await adminPage.goto("admin/configuration/general/gdpr");
const isDisabled = adminPage.locator("label > div").first().uncheck();
/**
* If not disabled, then we disable it.
*/
if (!isDisabled) {
const gdprsettingToggle = adminPage.locator("label > div").first();
await gdprsettingToggle.waitFor({ state: "visible", timeout: 5000 });
await adminPage.locator("label > div").first().click();
}
/**
* Verifying disable state.
*/
const toggleInput = adminPage.locator("label > div").first();
await expect(toggleInput).not.toBeChecked();
/**
* Click the 'Save Configuration' button.
*/
await adminPage.getByRole("button", { name: "Save Configuration" }).click();
}
export async function enableGDPRAgreement(adminPage) {
const agreement = {
checkboxLabel: "I agree with this statement.",
content: generateDescription(),
};
/**
* Navigating to the gdpr configuration page.
*/
await adminPage.goto("admin/configuration/general/gdpr");
const isEnabled = adminPage
.locator("div:nth-child(4) > div > .mb-4 > .relative > div")
.first()
.check();
/**
* If not enabled, then we enable it.
*/
if (!isEnabled) {
const gdprsettingToggle = adminPage
.locator("div:nth-child(4) > div > .mb-4 > .relative > div")
.first();
await gdprsettingToggle.waitFor({ state: "visible", timeout: 5000 });
await adminPage
.locator("div:nth-child(4) > div > .mb-4 > .relative > div")
.first()
.click();
}
/**
* Verifying enable state.
*/
const toggleInput = adminPage
.locator("div:nth-child(4) > div > .mb-4 > .relative > div")
.first();
await expect(toggleInput).toBeChecked();
/**
* Fill the agreement checkbox label.
*/
await adminPage
.getByRole("textbox", { name: "Agreement Checkbox Label" })
.click();
await adminPage
.getByRole("textbox", {
name: "Agreement Checkbox Label",
})
.fill(agreement.checkboxLabel);
await adminPage.click('button[type="submit"].primary-button:visible');
/**
* Fill the agreement content.
*/
await adminPage.waitForSelector('#general_gdpr__agreement__agreement_content__ifr', { state: 'visible' });
await adminPage.frameLocator('#general_gdpr__agreement__agreement_content__ifr').locator('body').clear();
await adminPage.fillInTinymce(
"#general_gdpr__agreement__agreement_content__ifr",
agreement.content
);
/**
* Save the configuration.
*/
await adminPage.click('button[type="submit"].primary-button:visible');
return agreement;
}
export async function disableGDPRAgreement(adminPage) {
/**
* Navigating to the gdpr configuration page.
*/
await adminPage.goto("admin/configuration/general/gdpr");
const isDisabled = adminPage
.locator("div:nth-child(4) > div > .mb-4 > .relative > div")
.first()
.uncheck();
/**
* If not disabled, then we disable it.
*/
if (!isDisabled) {
const gdprsettingToggle = adminPage
.locator("div:nth-child(4) > div > .mb-4 > .relative > div")
.first();
await gdprsettingToggle.waitFor({ state: "visible", timeout: 5000 });
await adminPage
.locator("div:nth-child(4) > div > .mb-4 > .relative > div")
.first()
.click();
}
/**
* Verifying disable state.
*/
const toggleInput = adminPage
.locator("div:nth-child(4) > div > .mb-4 > .relative > div")
.first();
await expect(toggleInput).not.toBeChecked();
/**
* Click the 'Save Configuration' button.
*/
await adminPage.getByRole("button", { name: "Save Configuration" }).click();
}
export async function enableCookiesNotice(adminPage, position = "bottom-left") {
/**
* Navigating to the gdpr configuration page.
*/
await adminPage.goto("admin/configuration/general/gdpr");
const isEnabled = adminPage
.locator("div:nth-child(6) > div > .mb-4 > .relative > div")
.check();
/**
* If not enabled, then we enable it.
*/
if (!isEnabled) {
const gdprsettingToggle = adminPage.locator(
"div:nth-child(6) > div > .mb-4 > .relative > div"
);
await gdprsettingToggle.waitFor({ state: "visible", timeout: 5000 });
await adminPage
.locator("div:nth-child(6) > div > .mb-4 > .relative > div")
.click();
}
/**
* Verifying enable state.
*/
const toggleInput = adminPage.locator(
"div:nth-child(6) > div > .mb-4 > .relative > div"
);
await expect(toggleInput).toBeChecked();
await adminPage
.locator('select[name="general[gdpr][cookie][position]"]')
.selectOption(position);
await adminPage
.getByRole("textbox", {
name: "Static Block Identifier",
})
.fill("cookie block");
await adminPage
.getByRole("textbox", {
name: "Description",
})
.fill("this website uses cookies to ensure you");
/**
* Save the configuration.
*/
await adminPage.click('button[type="submit"].primary-button:visible');
}

View File

@@ -0,0 +1,17 @@
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Absolute path to /lang directory
const LANG_DIR = path.resolve(
__dirname,
'../../../../../src/Resources/lang/'
);
export const fetchConfig= async () => {
const res = await fetch(LANG_DIR);
const appConfig = await res.json();
return appConfig;
};

View File

@@ -0,0 +1,49 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataFile = path.resolve(
__dirname,
"../test-data/runtime.json"
);
function readJson(): Record<string, any> {
if (!fs.existsSync(dataFile)) {
return {};
}
try {
return JSON.parse(fs.readFileSync(dataFile, "utf-8"));
} catch {
// Corrupt or partial write protection
return {};
}
}
function writeJson(data: Record<string, any>) {
const dir = path.dirname(dataFile);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(dataFile, JSON.stringify(data, null, 2));
}
export function saveData(key: string, value: unknown): void {
const data = readJson();
data[key] = value;
writeJson(data);
}
export function getData<T>(key: string): T {
const data = readJson();
if (!(key in data)) {
throw new Error(`❌ Runtime data key not found: "${key}"`);
}
return data[key] as T;
}

View File

@@ -0,0 +1,29 @@
import { Page, expect } from "@playwright/test";
export class CommonPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
/**
* Fill TinyMCE editor inside an iframe
* @param iframeSelector - iframe CSS or XPath selector
* @param content - text content to insert into TinyMCE
*/
async fillInTinymce(iframeSelector: string, content: string) {
await this.page.waitForSelector(iframeSelector);
const iframe = this.page.frameLocator(iframeSelector);
const editorBody = iframe.locator("body");
await expect(editorBody).toBeVisible();
await editorBody.click();
await editorBody.press("Control+A"); // Select all existing content
await editorBody.press("Backspace"); // Clear existing content
await editorBody.pressSequentially(content);
await expect(editorBody).toHaveText(content);
}
}