more fixes
This commit is contained in:
28
tests/cpad/auth.setup.ts
Normal file
28
tests/cpad/auth.setup.ts
Normal 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
149
tests/cpad/auth.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
122
tests/cpad/dashboard.spec.ts
Normal file
122
tests/cpad/dashboard.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
123
tests/cpad/modules/configuration.spec.ts
Normal file
123
tests/cpad/modules/configuration.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
94
tests/cpad/modules/languages.spec.ts
Normal file
94
tests/cpad/modules/languages.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
112
tests/cpad/modules/translations.spec.ts
Normal file
112
tests/cpad/modules/translations.spec.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
95
tests/cpad/navigation-discovery.spec.ts
Normal file
95
tests/cpad/navigation-discovery.spec.ts
Normal 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}`);
|
||||
});
|
||||
});
|
||||
107
tests/cpad/navigation.spec.ts
Normal file
107
tests/cpad/navigation.spec.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user