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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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