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

28
tests/cpad/auth.setup.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* cPad Authentication Setup
*
* This file is matched by the `cpad-setup` Playwright project (see playwright.config.ts).
* It runs ONCE before all cpad tests, logs in as admin, and saves the browser
* storage state to tests/.auth/admin.json so subsequent tests skip the login step.
*
* Run only this setup step:
* npx playwright test --project=cpad-setup
*/
import { test as setup } from '@playwright/test';
import { loginAsAdmin, AUTH_FILE } from '../helpers/auth';
import fs from 'fs';
import path from 'path';
setup('authenticate as cPad admin', async ({ page }) => {
// Ensure the .auth directory exists
const authDir = path.dirname(AUTH_FILE);
if (!fs.existsSync(authDir)) {
fs.mkdirSync(authDir, { recursive: true });
}
await loginAsAdmin(page);
// Persist cookies + localStorage for the cpad project
await page.context().storageState({ path: AUTH_FILE });
});

149
tests/cpad/auth.spec.ts Normal file
View File

@@ -0,0 +1,149 @@
/**
* Authentication tests for the cPad Control Panel.
*
* Coverage:
* • Login page renders correctly
* • Login with valid credentials → redirected to /cp/dashboard (or /cp/)
* • Login with invalid credentials → error message shown, no redirect
* • Logout → session is destroyed, login page is shown
*
* These tests do NOT use the pre-saved storageState; they exercise the actual
* login/logout flow from scratch.
*
* Run:
* npx playwright test tests/cpad/auth.spec.ts --project=chromium
*/
import { test, expect } from '@playwright/test';
import {
ADMIN_EMAIL,
ADMIN_PASSWORD,
CP_PATH,
DASHBOARD_PATH,
attachErrorListeners,
} from '../helpers/auth';
// ─────────────────────────────────────────────────────────────────────────────
// Login page
// ─────────────────────────────────────────────────────────────────────────────
test.describe('cPad Login Page', () => {
test('login page loads and shows email + password fields', async ({ page }) => {
const { consoleErrors, networkErrors } = attachErrorListeners(page);
await page.goto(CP_PATH + '/login');
// Basic page health
await expect(page.locator('body')).toBeVisible();
const title = await page.title();
expect(title.length, 'Page title should not be empty').toBeGreaterThan(0);
// Form fields
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 expect(emailField).toBeVisible();
await expect(passwordField).toBeVisible();
await expect(submitButton).toBeVisible();
// No JS crashes on page load
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
expect(networkErrors.length, `Network errors: ${networkErrors.join(' | ')}`).toBe(0);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Successful login
// ─────────────────────────────────────────────────────────────────────────────
test.describe('cPad Successful Login', () => {
test('admin can log in and is redirected to dashboard', async ({ page }) => {
const { consoleErrors, networkErrors } = attachErrorListeners(page);
await page.goto(CP_PATH + '/login');
await page.locator('input[name="email"], input[type="email"]').first().fill(ADMIN_EMAIL);
await page.locator('input[name="password"], input[type="password"]').first().fill(ADMIN_PASSWORD);
await page.locator('button[type="submit"], input[type="submit"]').first().click();
// After successful login the URL should be within /cp and not be /login
await page.waitForURL(
(url) => url.pathname.startsWith(CP_PATH) && !url.pathname.endsWith('/login'),
{ timeout: 20_000 },
);
await expect(page.locator('body')).toBeVisible();
// No server errors
const body = await page.locator('body').textContent() ?? '';
expect(/Whoops|Server Error|500/.test(body), 'Server error page after login').toBe(false);
expect(networkErrors.length, `HTTP 5xx errors: ${networkErrors.join(' | ')}`).toBe(0);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Failed login
// ─────────────────────────────────────────────────────────────────────────────
test.describe('cPad Failed Login', () => {
test('wrong password shows error and stays on login page', async ({ page }) => {
const { networkErrors } = attachErrorListeners(page);
await page.goto(CP_PATH + '/login');
await page.locator('input[name="email"], input[type="email"]').first().fill(ADMIN_EMAIL);
await page.locator('input[name="password"], input[type="password"]').first().fill('WrongPassword999!');
await page.locator('button[type="submit"], input[type="submit"]').first().click();
// Should stay on the login page
await page.waitForLoadState('networkidle');
expect(page.url()).toContain('/login');
// No 5xx errors from a bad credentials attempt
expect(networkErrors.length, `HTTP 5xx errors: ${networkErrors.join(' | ')}`).toBe(0);
});
test('empty credentials show validation errors', async ({ page }) => {
await page.goto(CP_PATH + '/login');
// Submit without filling in anything
await page.locator('button[type="submit"], input[type="submit"]').first().click();
await page.waitForLoadState('networkidle');
// Still on login page
expect(page.url()).toContain(CP_PATH);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Logout
// ─────────────────────────────────────────────────────────────────────────────
test.describe('cPad Logout', () => {
test('logout destroys session and shows login page', async ({ page }) => {
// Log in first
await page.goto(CP_PATH + '/login');
await page.locator('input[name="email"], input[type="email"]').first().fill(ADMIN_EMAIL);
await page.locator('input[name="password"], input[type="password"]').first().fill(ADMIN_PASSWORD);
await page.locator('button[type="submit"], input[type="submit"]').first().click();
await page.waitForURL(
(url) => url.pathname.startsWith(CP_PATH) && !url.pathname.endsWith('/login'),
{ timeout: 20_000 },
);
// Perform logout via the /cp/logout route
await page.goto(CP_PATH + '/logout');
await page.waitForLoadState('networkidle');
// Should land on the login page again
expect(page.url()).toContain('/login');
// Attempting to access dashboard now should redirect back to login
await page.goto(DASHBOARD_PATH);
await page.waitForLoadState('networkidle');
expect(page.url()).toContain('/login');
});
});

View File

@@ -0,0 +1,122 @@
/**
* Dashboard tests for the cPad Control Panel.
*
* Uses the pre-saved authenticated session (tests/.auth/admin.json) so no
* explicit login is required in each test.
*
* Coverage:
* • Dashboard page loads (HTTP 200, not a Laravel error page)
* • Page title is not empty
* • No JavaScript console errors
* • No HTTP 5xx responses
* • Key dashboard elements are visible (sidebar, main content area)
* • Dashboard setup page loads
*
* Run:
* npx playwright test tests/cpad/dashboard.spec.ts --project=cpad
*/
import { test, expect } from '@playwright/test';
import { DASHBOARD_PATH, CP_PATH, attachErrorListeners } from '../helpers/auth';
test.describe('cPad Dashboard', () => {
test('dashboard page loads without errors', async ({ page }) => {
const { consoleErrors, networkErrors } = attachErrorListeners(page);
await page.goto(DASHBOARD_PATH);
await page.waitForLoadState('networkidle');
// Must not be redirected to login
expect(page.url(), 'Should not redirect to login').not.toContain('/login');
// Title check
const title = await page.title();
expect(title.length, 'Page must have a non-empty title').toBeGreaterThan(0);
// Body must be visible
await expect(page.locator('body')).toBeVisible();
// No server error page
const bodyText = await page.locator('body').textContent() ?? '';
expect(/Whoops|Server Error|5[0-9]{2}/.test(bodyText), 'Server error detected in body').toBe(false);
// No JS exceptions
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
expect(networkErrors.length, `HTTP 5xx: ${networkErrors.join(' | ')}`).toBe(0);
});
test('dashboard root redirect works', async ({ page }) => {
const { networkErrors } = attachErrorListeners(page);
// /cp redirects to /cp/dashboard
await page.goto(CP_PATH);
await page.waitForLoadState('networkidle');
expect(page.url(), 'Should not be on login page').not.toContain('/login');
expect(networkErrors.length, `HTTP 5xx: ${networkErrors.join(' | ')}`).toBe(0);
});
test('dashboard has visible sidebar or navigation', async ({ page }) => {
await page.goto(DASHBOARD_PATH);
await page.waitForLoadState('networkidle');
// Check for a nav/sidebar element — any of these common AdminLTE/Bootstrap selectors
const navSelectors = [
'nav',
'.sidebar',
'#sidebar',
'.main-sidebar',
'#main-sidebar',
'[data-testid="sidebar"]',
'.navbar',
];
let foundNav = false;
for (const sel of navSelectors) {
const count = await page.locator(sel).count();
if (count > 0) {
foundNav = true;
break;
}
}
expect(foundNav, 'Dashboard should contain a sidebar or navigation element').toBe(true);
});
test('dashboard main content area is visible', async ({ page }) => {
await page.goto(DASHBOARD_PATH);
await page.waitForLoadState('networkidle');
// Any of these indicate a main content wrapper is rendered
const contentSelectors = [
'main',
'#content',
'.content-wrapper',
'.main-content',
'[data-testid="main-content"]',
'.wrapper',
];
let foundContent = false;
for (const sel of contentSelectors) {
const count = await page.locator(sel).count();
if (count > 0) {
foundContent = true;
break;
}
}
expect(foundContent, 'Dashboard main content area should be present').toBe(true);
});
test('dashboard setup page loads', async ({ page }) => {
const { consoleErrors, networkErrors } = attachErrorListeners(page);
await page.goto(CP_PATH + '/dashboard/setup');
await page.waitForLoadState('networkidle');
expect(page.url()).not.toContain('/login');
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
expect(networkErrors.length, `Network errors: ${networkErrors.join(' | ')}`).toBe(0);
});
});

View File

@@ -0,0 +1,123 @@
/**
* Configuration module tests for cPad.
*
* Routes:
* /cp/configuration — main configuration overview (alias)
* /cp/config — canonical config route
*
* Coverage:
* • Both config URLs load without errors
* • No console/server errors
* • Configuration form is present and contains input elements
* • Smart form filler can fill the form without crashing
* • Tab-based navigation within config (if applicable) works
*
* Run:
* npx playwright test tests/cpad/modules/configuration.spec.ts --project=cpad
*/
import { test, expect } from '@playwright/test';
import { CP_PATH, attachErrorListeners } from '../../helpers/auth';
import { fillForm, hasForm } from '../../helpers/formFiller';
const CONFIG_URLS = [
`${CP_PATH}/configuration`,
`${CP_PATH}/config`,
] as const;
test.describe('cPad Configuration Module', () => {
for (const configUrl of CONFIG_URLS) {
test(`config page (${configUrl}) loads without errors`, async ({ page }) => {
const { consoleErrors, networkErrors } = attachErrorListeners(page);
await page.goto(configUrl);
await page.waitForLoadState('networkidle');
expect(page.url()).not.toContain('/login');
await expect(page.locator('body')).toBeVisible();
const bodyText = await page.locator('body').textContent() ?? '';
const hasError = /Whoops|Server Error|SQLSTATE|Call to undefined/.test(bodyText);
expect(hasError, `Server error at ${configUrl}`).toBe(false);
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
expect(networkErrors.length, `HTTP 5xx: ${networkErrors.join(' | ')}`).toBe(0);
});
}
test('configuration page (/cp/config) contains a form or settings inputs', async ({ page }) => {
await page.goto(`${CP_PATH}/config`);
await page.waitForLoadState('networkidle');
if (page.url().includes('/login')) {
test.skip(true, 'Not authenticated');
return;
}
const pageHasForm = await hasForm(page);
expect(pageHasForm, '/cp/config should contain a form or input fields').toBe(true);
});
test('smart form filler can fill configuration form without errors', async ({ page }) => {
const { networkErrors } = attachErrorListeners(page);
await page.goto(`${CP_PATH}/config`);
await page.waitForLoadState('networkidle');
if (page.url().includes('/login')) {
test.skip(true, 'Not authenticated');
return;
}
// Fill the form — should not throw
await fillForm(page);
// No 5xx errors triggered by the fill operations
expect(networkErrors.length, `HTTP 5xx during form fill: ${networkErrors.join(' | ')}`).toBe(0);
});
test('configuration tab navigation works (if tabs present)', async ({ page }) => {
const { consoleErrors, networkErrors } = attachErrorListeners(page);
await page.goto(`${CP_PATH}/config`);
await page.waitForLoadState('networkidle');
if (page.url().includes('/login')) {
test.skip(true, 'Not authenticated');
return;
}
// Look for tab buttons (Bootstrap / AdminLTE tabs)
const tabSelectors = [
'[role="tab"]',
'.nav-tabs .nav-link',
'.nav-pills .nav-link',
'a[data-toggle="tab"]',
'a[data-bs-toggle="tab"]',
];
for (const sel of tabSelectors) {
const tabs = page.locator(sel);
const count = await tabs.count();
if (count > 1) {
// Click each tab and verify no server errors
for (let i = 0; i < Math.min(count, 8); i++) {
const tab = tabs.nth(i);
if (await tab.isVisible()) {
await tab.click();
await page.waitForLoadState('domcontentloaded').catch(() => null);
const bodyText = await page.locator('body').textContent() ?? '';
const hasError = /Whoops|Server Error|SQLSTATE/.test(bodyText);
expect(hasError, `Error after clicking tab ${i} at ${sel}`).toBe(false);
}
}
break; // found tabs, done
}
}
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
expect(networkErrors.length, `HTTP 5xx: ${networkErrors.join(' | ')}`).toBe(0);
});
});

View File

@@ -0,0 +1,94 @@
/**
* Languages module tests for cPad.
*
* Routes under /cp/language/{type} — type can be: app, system
*
* Coverage:
* • Language list pages load without errors
* • No console/server errors
* • Add-language page loads
* • (Optional) CRUD flow when list data is available
*
* Run:
* npx playwright test tests/cpad/modules/languages.spec.ts --project=cpad
*/
import { test, expect } from '@playwright/test';
import { CP_PATH, attachErrorListeners } from '../../helpers/auth';
import { hasListData, tryCrudCreate } from '../../helpers/crudHelper';
const LANGUAGE_TYPES = ['app', 'system'] as const;
test.describe('cPad Languages Module', () => {
for (const type of LANGUAGE_TYPES) {
const basePath = `${CP_PATH}/language/${type}`;
test(`language list (${type}) loads without errors`, async ({ page }) => {
const { consoleErrors, networkErrors } = attachErrorListeners(page);
await page.goto(basePath);
await page.waitForLoadState('networkidle');
expect(page.url()).not.toContain('/login');
await expect(page.locator('body')).toBeVisible();
const bodyText = await page.locator('body').textContent() ?? '';
const hasError = /Whoops|Server Error|SQLSTATE|Call to undefined/.test(bodyText);
expect(hasError, `Server error on ${basePath}`).toBe(false);
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
expect(networkErrors.length, `HTTP 5xx: ${networkErrors.join(' | ')}`).toBe(0);
});
test(`language add page (${type}) loads`, async ({ page }) => {
const { consoleErrors, networkErrors } = attachErrorListeners(page);
await page.goto(`${basePath}/add`);
await page.waitForLoadState('networkidle');
expect(page.url()).not.toContain('/login');
await expect(page.locator('body')).toBeVisible();
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
expect(networkErrors.length, `HTTP 5xx: ${networkErrors.join(' | ')}`).toBe(0);
});
test(`language list (${type}) shows table or empty state`, async ({ page }) => {
await page.goto(basePath);
await page.waitForLoadState('networkidle');
// Either a data table OR a "no data" message must be present
const tableCount = await page.locator('table').count();
const emptyMsgCount = await page.locator(
':has-text("No languages"), :has-text("No records"), :has-text("Empty")',
).count();
expect(tableCount + emptyMsgCount, 'Should show a table or an empty-state message').toBeGreaterThan(0);
});
}
test('language list (app) — CRUD: add language form submission', async ({ page }) => {
const { networkErrors } = attachErrorListeners(page);
await page.goto(`${CP_PATH}/language/app/add`);
await page.waitForLoadState('networkidle');
if (page.url().includes('/login')) {
test.skip(true, 'Skipped: not authenticated');
return;
}
// Try submitting the add form
const didCreate = await tryCrudCreate(page);
if (!didCreate) {
// The add page itself is the form; submit directly
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');
}
}
expect(networkErrors.length, `HTTP 5xx after form submit: ${networkErrors.join(' | ')}`).toBe(0);
});
});

View File

@@ -0,0 +1,112 @@
/**
* Translations module tests for cPad.
*
* Routes under /cp/translation/{file}
* The most common translation file is "app".
*
* Coverage:
* • Main translation index page loads
* • Translation list page for "app" file loads
* • Add translation entry page loads
* • Translation grid page loads
* • No console/server errors on any page
* • (Optional) inline edit via CRUD helper
*
* Run:
* npx playwright test tests/cpad/modules/translations.spec.ts --project=cpad
*/
import { test, expect } from '@playwright/test';
import { CP_PATH, attachErrorListeners } from '../../helpers/auth';
/** Translation file slugs to probe — add more as needed */
const TRANSLATION_FILES = ['app'] as const;
/** Reusable page-health assertion */
async function assertPageHealthy(page: import('@playwright/test').Page, path: 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(err.message));
page.on('response', (res) => {
if (res.status() >= 500) networkErrors.push(`HTTP ${res.status()}: ${res.url()}`);
});
await page.goto(path);
await page.waitForLoadState('networkidle', { timeout: 20_000 });
expect(page.url(), `${path} must not redirect to login`).not.toContain('/login');
await expect(page.locator('body')).toBeVisible();
const bodyText = await page.locator('body').textContent() ?? '';
const hasError = /Whoops|Server Error|SQLSTATE|Call to undefined/.test(bodyText);
expect(hasError, `Server error at ${path}`).toBe(false);
expect(consoleErrors.length, `Console errors at ${path}: ${consoleErrors.join(' | ')}`).toBe(0);
expect(networkErrors.length, `HTTP 5xx at ${path}: ${networkErrors.join(' | ')}`).toBe(0);
}
test.describe('cPad Translations Module', () => {
for (const file of TRANSLATION_FILES) {
const base = `${CP_PATH}/translation/${file}`;
test(`translation main page (${file}) loads`, async ({ page }) => {
await assertPageHealthy(page, base);
});
test(`translation list page (${file}) loads`, async ({ page }) => {
await assertPageHealthy(page, `${base}/list`);
});
test(`translation add page (${file}) loads`, async ({ page }) => {
await assertPageHealthy(page, `${base}/add`);
});
test(`translation grid page (${file}) loads`, async ({ page }) => {
await assertPageHealthy(page, `${base}/grid`);
});
test(`translation list (${file}) renders content`, async ({ page }) => {
await page.goto(`${base}/list`);
await page.waitForLoadState('networkidle');
if (page.url().includes('/login')) {
test.skip(true, 'Not authenticated');
return;
}
// A table, a grid element, or an empty-state message should be present
const tableCount = await page.locator('table, .grid, [class*="grid"]').count();
const emptyMsgCount = await page.locator(
':has-text("No translations"), :has-text("No records"), :has-text("Empty")',
).count();
const inputCount = await page.locator('input[type=text], textarea').count();
expect(
tableCount + emptyMsgCount + inputCount,
'Translation list should contain table, grid, empty state, or editable fields',
).toBeGreaterThan(0);
});
test(`translation add page (${file}) contains a form`, async ({ page }) => {
await page.goto(`${base}/add`);
await page.waitForLoadState('networkidle');
if (page.url().includes('/login')) {
test.skip(true, 'Not authenticated');
return;
}
const formCount = await page.locator('form').count();
const inputCount = await page.locator('input:not([type=hidden]), textarea').count();
expect(
formCount + inputCount,
'Add translation page should contain a form or input fields',
).toBeGreaterThan(0);
});
}
});

View File

@@ -0,0 +1,95 @@
/**
* Navigation Discovery — scans the cPad sidebar and collects all /cp links.
*
* This test acts as both:
* 1. A standalone spec that validates the nav scan returns ≥ 1 link.
* 2. A data producer: it writes the discovered URLs to
* tests/.discovered/cpad-links.json so that navigation.spec.ts can
* consume them dynamically.
*
* Ignored links:
* • /cp/logout
* • javascript:void / # anchors
* • External URLs (not starting with /cp)
*
* Run:
* npx playwright test tests/cpad/navigation-discovery.spec.ts --project=cpad
*/
import { test, expect } from '@playwright/test';
import { DASHBOARD_PATH, CP_PATH } from '../helpers/auth';
import fs from 'fs';
import path from 'path';
const DISCOVERED_DIR = path.join('tests', '.discovered');
const DISCOVERED_FILE = path.join(DISCOVERED_DIR, 'cpad-links.json');
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
/** Returns true if the href should be included in navigation tests */
function isNavigableLink(href: string | null): boolean {
if (!href) return false;
if (!href.startsWith(CP_PATH)) return false;
if (href.includes('/logout')) return false;
if (href.startsWith('javascript:')) return false;
if (href === CP_PATH || href === CP_PATH + '/') return false; // exclude root (same as dashboard)
return true;
}
/** Remove query strings and anchors for clean deduplication */
function normalise(href: string): string {
try {
const u = new URL(href, 'http://placeholder');
return u.pathname;
} catch {
return href.split('?')[0].split('#')[0];
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Discovery test
// ─────────────────────────────────────────────────────────────────────────────
test.describe('cPad Navigation Discovery', () => {
test('scan sidebar and collect all /cp navigation links', async ({ page }) => {
await page.goto(DASHBOARD_PATH);
await page.waitForLoadState('networkidle');
// Ensure we're not on the login page
expect(page.url()).not.toContain('/login');
// ── Widen the scan to cover lazy-loaded submenu items ──────────────────
// Hover over sidebar nav items to expand hidden submenus
const menuItems = page.locator('.nav-item, .sidebar-item, .menu-item, li.nav-item');
const menuCount = await menuItems.count();
for (let i = 0; i < Math.min(menuCount, 30); i++) {
await menuItems.nth(i).hover().catch(() => null);
}
// ── Collect all anchor hrefs ───────────────────────────────────────────
const rawHrefs: string[] = await page.$$eval('a[href]', (anchors) =>
anchors.map((a) => (a as HTMLAnchorElement).getAttribute('href') ?? ''),
);
const discovered = [...new Set(
rawHrefs
.map((h) => normalise(h))
.filter(isNavigableLink),
)].sort();
console.log(`[discovery] Found ${discovered.length} navigable /cp links`);
discovered.forEach((l) => console.log(' •', l));
// Must find at least a few links — if not, something is wrong with auth
expect(discovered.length, 'Expected to find /cp navigation links').toBeGreaterThanOrEqual(1);
// ── Persist for navigation.spec.ts ────────────────────────────────────
if (!fs.existsSync(DISCOVERED_DIR)) {
fs.mkdirSync(DISCOVERED_DIR, { recursive: true });
}
fs.writeFileSync(DISCOVERED_FILE, JSON.stringify(discovered, null, 2), 'utf8');
console.log(`[discovery] Links saved to ${DISCOVERED_FILE}`);
});
});

View File

@@ -0,0 +1,107 @@
/**
* Navigation health test for the cPad Control Panel.
*
* Two operating modes:
*
* 1. Dynamic — reads tests/.discovered/cpad-links.json (written by
* navigation-discovery.spec.ts) and visits every link.
* Run the discovery spec first to populate this file.
*
* 2. Fallback — when the discovery file is absent, falls back to a static
* list of known /cp routes so the suite never fails silently.
*
* For every visited page the test asserts:
* • No redirect to /cp/login (i.e. session is still valid)
* • HTTP status not 5xx (detected via response interception)
* • No uncaught JavaScript exceptions
* • No browser console errors
* • Page body is visible and non-empty
* • Page does not contain Laravel error text
*
* Run:
* npx playwright test tests/cpad/navigation.spec.ts --project=cpad
*/
import { test, expect } from '@playwright/test';
import { CP_PATH, attachErrorListeners } from '../helpers/auth';
import { hasForm } from '../helpers/formFiller';
import fs from 'fs';
import path from 'path';
// ─────────────────────────────────────────────────────────────────────────────
// Link source
// ─────────────────────────────────────────────────────────────────────────────
const DISCOVERED_FILE = path.join('tests', '.discovered', 'cpad-links.json');
/** Well-known /cp pages used when the discovery file is missing */
const STATIC_FALLBACK_LINKS: string[] = [
'/cp/dashboard',
'/cp/configuration',
'/cp/config',
'/cp/language/app',
'/cp/language/system',
'/cp/translation/app',
'/cp/security/access',
'/cp/security/roles',
'/cp/security/permissions',
'/cp/security/login',
'/cp/plugins',
'/cp/user/profile',
'/cp/messages',
'/cp/api/keys',
'/cp/friendly-url',
];
function loadLinks(): string[] {
if (fs.existsSync(DISCOVERED_FILE)) {
try {
const links: string[] = JSON.parse(fs.readFileSync(DISCOVERED_FILE, 'utf8'));
if (links.length > 0) return links;
} catch { /* fall through to static list */ }
}
console.warn('[navigation] discovery file not found — using static fallback list');
return STATIC_FALLBACK_LINKS;
}
const CP_LINKS = loadLinks();
// ─────────────────────────────────────────────────────────────────────────────
// Main navigation suite
// ─────────────────────────────────────────────────────────────────────────────
test.describe('cPad Navigation Health', () => {
for (const linkPath of CP_LINKS) {
test(`page loads: ${linkPath}`, async ({ page }) => {
const { consoleErrors, networkErrors } = attachErrorListeners(page);
await page.goto(linkPath);
await page.waitForLoadState('networkidle', { timeout: 20_000 });
// ── Auth check ──────────────────────────────────────────────────────
expect(page.url(), `${linkPath} should not redirect to login`).not.toContain('/login');
// ── HTTP errors ─────────────────────────────────────────────────────
expect(networkErrors.length, `HTTP 5xx on ${linkPath}: ${networkErrors.join(' | ')}`).toBe(0);
// ── Body visibility ─────────────────────────────────────────────────
await expect(page.locator('body')).toBeVisible();
const bodyText = await page.locator('body').textContent() ?? '';
expect(bodyText.trim().length, `Body should not be empty on ${linkPath}`).toBeGreaterThan(0);
// ── Laravel / server error page ──────────────────────────────────────
const hasServerError = /Whoops[,!]|Server Error|Call to undefined function|SQLSTATE/.test(bodyText);
expect(hasServerError, `Server/Laravel error page at ${linkPath}: ${bodyText.slice(0, 200)}`).toBe(false);
// ── JS exceptions ────────────────────────────────────────────────────
expect(consoleErrors.length, `JS console errors on ${linkPath}: ${consoleErrors.join(' | ')}`).toBe(0);
// ── Form detection (informational — logged, not asserted) ─────────────
const pageHasForm = await hasForm(page);
if (pageHasForm) {
console.log(` [form] form detected on ${linkPath}`);
}
});
}
});