more fixes

This commit is contained in:
2026-03-12 07:22:38 +01:00
parent 547215cbe8
commit 4f576ceb04
226 changed files with 14380 additions and 4453 deletions

93
tests/helpers/auth.ts Normal file
View File

@@ -0,0 +1,93 @@
/**
* Authentication helper for cPad Control Panel tests.
*
* Usage in tests that do NOT use the pre-saved storageState:
* import { loginAsAdmin } from '../helpers/auth';
* await loginAsAdmin(page);
*
* The cpad-setup project (auth.setup.ts) calls loginAsAdmin once and persists
* the session to tests/.auth/admin.json so all other cpad tests reuse it.
*/
import { type Page, expect } from '@playwright/test';
import path from 'path';
export const ADMIN_EMAIL = 'gregor@klevze.si';
export const ADMIN_PASSWORD = 'Gre15#10gor!1976$';
export const CP_PATH = '/cp';
export const DASHBOARD_PATH = '/cp/dashboard';
export const AUTH_FILE = path.join('tests', '.auth', 'admin.json');
/**
* Perform a full cPad login and wait for the dashboard to load.
* Verifies the redirect ends up on a /cp/dashboard URL.
*/
export async function loginAsAdmin(page: Page): Promise<void> {
// Attach console-error listener so callers can detect JS exceptions
page.on('console', (msg) => {
if (msg.type() === 'error') {
console.warn(`[CONSOLE ERROR] ${msg.text()}`);
}
});
await page.goto(CP_PATH);
// Wait for the login form to be ready
const emailField = page.locator('input[name="email"], input[type="email"]').first();
const passwordField = page.locator('input[name="password"], input[type="password"]').first();
const submitButton = page.locator('button[type="submit"], input[type="submit"]').first();
await emailField.waitFor({ state: 'visible', timeout: 15_000 });
await emailField.fill(ADMIN_EMAIL);
await passwordField.fill(ADMIN_PASSWORD);
await submitButton.click();
// After login, the panel may show a 2FA screen or land on dashboard
// Wait up to 20 s for a URL that contains /cp (but isn't the login page)
await page.waitForURL((url) => url.pathname.startsWith(CP_PATH) && !url.pathname.endsWith('/login'), {
timeout: 20_000,
});
}
/**
* Verify the current page is the cPad dashboard.
* Call this *after* loginAsAdmin to assert a successful login.
*/
export async function assertDashboard(page: Page): Promise<void> {
await expect(page).toHaveURL(new RegExp(`${CP_PATH}`));
}
/**
* Attach console-error and network-failure listeners to the page.
* Returns arrays that accumulate errors so the calling test can assert them.
*/
export function attachErrorListeners(page: Page): {
consoleErrors: string[];
networkErrors: string[];
} {
const consoleErrors: string[] = [];
const networkErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
page.on('pageerror', (err) => {
consoleErrors.push(`[pageerror] ${err.message}`);
});
page.on('response', (response) => {
const status = response.status();
if (status >= 500) {
networkErrors.push(`HTTP ${status}: ${response.url()}`);
}
});
page.on('requestfailed', (request) => {
networkErrors.push(`[requestfailed] ${request.url()}${request.failure()?.errorText}`);
});
return { consoleErrors, networkErrors };
}

147
tests/helpers/crudHelper.ts Normal file
View File

@@ -0,0 +1,147 @@
/**
* CRUD Helper for cPad module tests.
*
* Provides reusable functions that detect and execute common CRUD operations
* (Create, Edit, Delete) through the cPad UI without hard-coding selectors
* for every individual module.
*
* Strategy:
* • "Create" — look for button text: Create / Add / New
* • "Edit" — look for button/link text: Edit, or table action icons
* • "Delete" — look for button/link text: Delete / Remove (with confirmation handling)
*
* Each function returns false if the appropriate trigger could not be found,
* allowing callers to skip gracefully when a module lacks a given operation.
*/
import { type Page, expect } from '@playwright/test';
import { fillForm } from './formFiller';
// Selectors used to locate CRUD triggers (order = priority)
const CREATE_SELECTORS = [
'a:has-text("Create")',
'a:has-text("Add")',
'a:has-text("New")',
'button:has-text("Create")',
'button:has-text("Add")',
'button:has-text("New")',
'[data-testid="btn-create"]',
'[data-testid="btn-add"]',
];
const EDIT_SELECTORS = [
'a:has-text("Edit")',
'button:has-text("Edit")',
'td a[href*="/edit/"]',
'[data-testid="btn-edit"]',
];
const DELETE_SELECTORS = [
'a:has-text("Delete")',
'button:has-text("Delete")',
'a:has-text("Remove")',
'button:has-text("Remove")',
'[data-testid="btn-delete"]',
];
/**
* Try to find and click a Create/Add/New button, fill the resulting form,
* submit it, and verify the page does not show a 500 error.
*
* @returns true if a create button was found and the flow completed without crashing.
*/
export async function tryCrudCreate(page: Page): Promise<boolean> {
for (const selector of CREATE_SELECTORS) {
const btn = page.locator(selector).first();
if (await btn.count() > 0 && await btn.isVisible()) {
await btn.click();
// Wait for either a form or a new page section to appear
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => null);
// Fill any visible form
await fillForm(page);
// Look for a submit button
const submit = page.locator('button[type=submit], input[type=submit]').first();
if (await submit.count() > 0 && await submit.isVisible()) {
await submit.click();
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => null);
// Fail if a Laravel/server error page appeared
const body = await page.locator('body').textContent() ?? '';
const hasServerError = /Whoops|Server Error|500|SQLSTATE|Call to undefined/i.test(body);
expect(hasServerError, `Server error after create on ${page.url()}`).toBe(false);
}
return true;
}
}
return false;
}
/**
* Try to find and click the first Edit button/link on the page, fill the form,
* and submit.
*
* @returns true if an edit trigger was found.
*/
export async function tryCrudEdit(page: Page): Promise<boolean> {
for (const selector of EDIT_SELECTORS) {
const btn = page.locator(selector).first();
if (await btn.count() > 0 && await btn.isVisible()) {
await btn.click();
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => null);
await fillForm(page);
const submit = page.locator('button[type=submit], input[type=submit]').first();
if (await submit.count() > 0 && await submit.isVisible()) {
await submit.click();
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => null);
const body = await page.locator('body').textContent() ?? '';
const hasServerError = /Whoops|Server Error|500|SQLSTATE|Call to undefined/i.test(body);
expect(hasServerError, `Server error after edit on ${page.url()}`).toBe(false);
}
return true;
}
}
return false;
}
/**
* Try to click the first delete trigger, handle a confirmation dialog if one
* appears, and assert no server error is displayed.
*
* @returns true if a delete trigger was found.
*/
export async function tryCrudDelete(page: Page): Promise<boolean> {
for (const selector of DELETE_SELECTORS) {
const btn = page.locator(selector).first();
if (await btn.count() > 0 && await btn.isVisible()) {
// Some delete links pop a browser confirm() dialog
page.once('dialog', (dialog) => dialog.accept());
await btn.click();
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => null);
const body = await page.locator('body').textContent() ?? '';
const hasServerError = /Whoops|Server Error|500|SQLSTATE|Call to undefined/i.test(body);
expect(hasServerError, `Server error after delete on ${page.url()}`).toBe(false);
return true;
}
}
return false;
}
/**
* Quick check whether the page contains any list/table data (i.e. the module
* has records to operate on).
*/
export async function hasListData(page: Page): Promise<boolean> {
const tableRows = await page.locator('table tbody tr').count();
return tableRows > 0;
}

127
tests/helpers/formFiller.ts Normal file
View File

@@ -0,0 +1,127 @@
/**
* Smart Form Filler helper for cPad tests.
*
* Detects all interactive form controls on a page (or within a locator scope)
* and fills them with sensible test values so form-validation tests do not
* need to enumerate every field manually.
*
* Supported field types:
* text, email, number, url, tel, search, password, date, time, textarea,
* checkbox, radio, select, file (skipped)
*
* Hidden fields and readonly fields are ignored.
*/
import { type Locator, type Page } from '@playwright/test';
/** Seed text used for text-like inputs */
const RANDOM_TEXT = `Test_${Date.now()}`;
const RANDOM_EMAIL = 'playwright-test@test.com';
const RANDOM_NUM = '42';
const RANDOM_URL = 'https://skinbase.test';
const RANDOM_DATE = '2025-01-01';
const RANDOM_TIME = '09:00';
const RANDOM_TEXTAREA = `Automated test content generated at ${new Date().toISOString()}.`;
/**
* Fill all detectable form controls inside `scope` (defaults to the whole page).
*
* @param page Playwright Page object (used for evaluate calls)
* @param scope Optional Locator to restrict filling to a specific container
*/
export async function fillForm(page: Page, scope?: Locator): Promise<void> {
const root: Page | Locator = scope ?? page;
// ── text / email / url / tel / search / password / date / time ──────────
const textInputs = root.locator(
'input:not([type=hidden]):not([type=submit]):not([type=button])' +
':not([type=reset]):not([type=file]):not([type=checkbox]):not([type=radio])' +
':not([readonly]):not([disabled])',
);
const inputCount = await textInputs.count();
for (let i = 0; i < inputCount; i++) {
const input = textInputs.nth(i);
const type = (await input.getAttribute('type'))?.toLowerCase() ?? 'text';
const name = (await input.getAttribute('name')) ?? '';
// Skip honeypot / internal fields whose names suggest they must stay empty
if (/honeypot|_token|csrf/i.test(name)) continue;
try {
await input.scrollIntoViewIfNeeded();
switch (type) {
case 'email':
await input.fill(RANDOM_EMAIL);
break;
case 'number':
case 'range':
await input.fill(RANDOM_NUM);
break;
case 'url':
await input.fill(RANDOM_URL);
break;
case 'date':
await input.fill(RANDOM_DATE);
break;
case 'time':
await input.fill(RANDOM_TIME);
break;
case 'password':
await input.fill(`Pw@${Date.now()}`);
break;
default:
await input.fill(RANDOM_TEXT);
}
} catch {
// Field might have become detached or invisible — skip silently
}
}
// ── textarea ─────────────────────────────────────────────────────────────
const textareas = root.locator('textarea:not([readonly]):not([disabled])');
const taCount = await textareas.count();
for (let i = 0; i < taCount; i++) {
try {
await textareas.nth(i).scrollIntoViewIfNeeded();
await textareas.nth(i).fill(RANDOM_TEXTAREA);
} catch { /* skip */ }
}
// ── select ────────────────────────────────────────────────────────────────
const selects = root.locator('select:not([disabled])');
const selCount = await selects.count();
for (let i = 0; i < selCount; i++) {
try {
// Pick the first non-empty option
const firstOption = await selects.nth(i).locator('option:not([value=""])').first().getAttribute('value');
if (firstOption !== null) {
await selects.nth(i).selectOption(firstOption);
}
} catch { /* skip */ }
}
// ── checkboxes (toggle unchecked → checked) ───────────────────────────────
const checkboxes = root.locator('input[type=checkbox]:not([disabled])');
const cbCount = await checkboxes.count();
for (let i = 0; i < cbCount; i++) {
try {
const isChecked = await checkboxes.nth(i).isChecked();
if (!isChecked) {
await checkboxes.nth(i).check();
}
} catch { /* skip */ }
}
}
/**
* Detect whether the given scope contains a visible, submittable form.
* Returns true if any <form>, submit button, or text input is found.
*/
export async function hasForm(scope: Page | Locator): Promise<boolean> {
const formCount = await scope.locator('form').count();
const submitCount = await scope.locator('button[type=submit], input[type=submit]').count();
const inputCount = await scope.locator('input:not([type=hidden])').count();
return formCount > 0 || submitCount > 0 || inputCount > 0;
}