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
2
packages/Webkul/Admin/tests/e2e-pw/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/playwright-report
|
||||
/test-results
|
||||
35
packages/Webkul/Admin/tests/e2e-pw/.state/admin-auth.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
BIN
packages/Webkul/Admin/tests/e2e-pw/data/images/10.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
packages/Webkul/Admin/tests/e2e-pw/data/images/11.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
packages/Webkul/Admin/tests/e2e-pw/data/images/12.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
packages/Webkul/Admin/tests/e2e-pw/data/images/3.webp
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
packages/Webkul/Admin/tests/e2e-pw/data/images/4.webp
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
packages/Webkul/Admin/tests/e2e-pw/data/images/6.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
packages/Webkul/Admin/tests/e2e-pw/data/images/7.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
packages/Webkul/Admin/tests/e2e-pw/data/images/8.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
packages/Webkul/Admin/tests/e2e-pw/data/images/9.webp
Normal file
|
After Width: | Height: | Size: 27 KiB |
80
packages/Webkul/Admin/tests/e2e-pw/fixtures/AdminFixtures.ts
Normal 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 };
|
||||
597
packages/Webkul/Admin/tests/e2e-pw/locator/CoreLocators.ts
Normal 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();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
146
packages/Webkul/Admin/tests/e2e-pw/pages/AclPage.ts
Normal 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)}`;
|
||||
}
|
||||
}
|
||||
47
packages/Webkul/Admin/tests/e2e-pw/pages/AdminPage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
168
packages/Webkul/Admin/tests/e2e-pw/pages/LeadPage.ts
Normal 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');
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
139
packages/Webkul/Admin/tests/e2e-pw/pages/MailPage.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
99
packages/Webkul/Admin/tests/e2e-pw/pages/OrganizationPage.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
121
packages/Webkul/Admin/tests/e2e-pw/pages/PersonsPage.ts
Normal 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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
86
packages/Webkul/Admin/tests/e2e-pw/pages/ProductPage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
199
packages/Webkul/Admin/tests/e2e-pw/pages/QuickAddPage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
204
packages/Webkul/Admin/tests/e2e-pw/pages/QuotesPage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
96
packages/Webkul/Admin/tests/e2e-pw/pages/SettingsPage.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
212
packages/Webkul/Admin/tests/e2e-pw/pages/settings/RolesPage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
236
packages/Webkul/Admin/tests/e2e-pw/pages/settings/UsersPage.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
56
packages/Webkul/Admin/tests/e2e-pw/playwright.config.ts
Normal 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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
23
packages/Webkul/Admin/tests/e2e-pw/tests/admin.spec.ts
Normal 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();
|
||||
|
||||
|
||||
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
82
packages/Webkul/Admin/tests/e2e-pw/tests/lang/lang.spec.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
56
packages/Webkul/Admin/tests/e2e-pw/tests/lead.spec.ts
Normal 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);
|
||||
|
||||
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
89
packages/Webkul/Admin/tests/e2e-pw/tests/mail.spec.ts
Normal 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();
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
||||
})
|
||||
51
packages/Webkul/Admin/tests/e2e-pw/tests/persons.spec.ts
Normal 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();
|
||||
})
|
||||
})
|
||||
31
packages/Webkul/Admin/tests/e2e-pw/tests/product.spec.ts
Normal 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();
|
||||
})
|
||||
})
|
||||
72
packages/Webkul/Admin/tests/e2e-pw/tests/quick-add.spec.ts
Normal 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");
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
55
packages/Webkul/Admin/tests/e2e-pw/tests/quotes.spec.ts
Normal 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();
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
})
|
||||
|
||||
|
||||
|
||||
})
|
||||
@@ -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();
|
||||
|
||||
});
|
||||
|
||||
|
||||
})
|
||||
@@ -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();
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
})
|
||||
})
|
||||
@@ -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();
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
117
packages/Webkul/Admin/tests/e2e-pw/tests/settings/roles.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
@@ -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();
|
||||
})
|
||||
})
|
||||
@@ -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();
|
||||
})
|
||||
})
|
||||
@@ -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();
|
||||
});
|
||||
})
|
||||
276
packages/Webkul/Admin/tests/e2e-pw/utils/acl.ts
Normal 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;
|
||||
}
|
||||
58
packages/Webkul/Admin/tests/e2e-pw/utils/address.ts
Normal 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;
|
||||
159
packages/Webkul/Admin/tests/e2e-pw/utils/customer.ts
Normal 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;
|
||||
}
|
||||
856
packages/Webkul/Admin/tests/e2e-pw/utils/faker.ts
Normal 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)];
|
||||
}
|
||||
198
packages/Webkul/Admin/tests/e2e-pw/utils/form.ts
Normal 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,
|
||||
};
|
||||
221
packages/Webkul/Admin/tests/e2e-pw/utils/gdpr.ts
Normal 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');
|
||||
}
|
||||
|
||||
17
packages/Webkul/Admin/tests/e2e-pw/utils/lang.ts
Normal 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;
|
||||
};
|
||||
49
packages/Webkul/Admin/tests/e2e-pw/utils/savedata.ts
Normal 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;
|
||||
}
|
||||
29
packages/Webkul/Admin/tests/e2e-pw/utils/tinymce.ts
Normal 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);
|
||||
}
|
||||
}
|
||||