@@ -0,0 +1,6 @@
|
||||
# Auth state from Playwright setup
|
||||
.auth/state.json
|
||||
|
||||
# Playwright test results and reports
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -0,0 +1,29 @@
|
||||
import {expect, test as setup} from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Authenticates once against the running Ghost instance and saves
|
||||
* the session to a storage-state file so all visual-regression specs
|
||||
* can reuse it without logging in again.
|
||||
*
|
||||
* Expects the default Ghost development credentials:
|
||||
* Email: ghost-author@example.com
|
||||
* Password: Sl1m3rson99
|
||||
*/
|
||||
const AUTH_FILE = './e2e/visual-regression/.auth/state.json';
|
||||
|
||||
setup('authenticate', async ({page}) => {
|
||||
const email = process.env.GHOST_ADMIN_EMAIL || 'ghost-author@example.com';
|
||||
const password = process.env.GHOST_ADMIN_PASSWORD || 'Sl1m3rson99';
|
||||
|
||||
await page.goto('/ghost/#/signin');
|
||||
await page.waitForLoadState('load');
|
||||
|
||||
await page.getByRole('textbox', {name: 'Email address'}).fill(email);
|
||||
await page.getByRole('textbox', {name: 'Password'}).fill(password);
|
||||
await page.getByRole('button', {name: /Sign in/}).click();
|
||||
|
||||
// Wait for the admin to fully load (sidebar with navigation links)
|
||||
await expect(page.getByRole('link', {name: 'Posts'})).toBeVisible({timeout: 30_000});
|
||||
|
||||
await page.context().storageState({path: AUTH_FILE});
|
||||
});
|
||||
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 91 KiB |
@@ -0,0 +1,241 @@
|
||||
import {expect, test} from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Visual regression baselines for Ghost Admin.
|
||||
*
|
||||
* Each test navigates to a Ghost Admin screen, hides dynamic content
|
||||
* (timestamps, notifications, avatars, animations) so they don't cause
|
||||
* false diffs, and captures a full-page screenshot.
|
||||
*
|
||||
* Usage:
|
||||
* # Generate / update baselines (run on main branch BEFORE migration)
|
||||
* npx playwright test -c e2e/visual-regression --update-snapshots
|
||||
*
|
||||
* # Compare against baselines (run AFTER migration changes)
|
||||
* npx playwright test -c e2e/visual-regression
|
||||
*/
|
||||
|
||||
interface Screen {
|
||||
name: string;
|
||||
path: string;
|
||||
/** Optional selector to wait for before screenshotting */
|
||||
waitFor?: string;
|
||||
/** Extra time to wait after load (ms) — use sparingly */
|
||||
extraWait?: number;
|
||||
/** If true, capture viewport-only screenshot instead of full page */
|
||||
viewportOnly?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full-page screens
|
||||
// ---------------------------------------------------------------------------
|
||||
const SCREENS: Screen[] = [
|
||||
// Core Ember pages (HIGH risk — CSS class collisions with .flex, .hidden, etc.)
|
||||
{name: 'dashboard', path: '/ghost/#/dashboard'},
|
||||
{name: 'posts-list', path: '/ghost/#/posts'},
|
||||
{name: 'pages-list', path: '/ghost/#/pages'},
|
||||
{name: 'tags-list', path: '/ghost/#/tags'},
|
||||
{name: 'tags-new', path: '/ghost/#/tags/new', extraWait: 1000},
|
||||
{name: 'members-list', path: '/ghost/#/members'},
|
||||
{name: 'members-activity', path: '/ghost/#/members-activity', extraWait: 1000},
|
||||
|
||||
// Editor
|
||||
{name: 'editor-new-post', path: '/ghost/#/editor/post', extraWait: 2000},
|
||||
|
||||
// Settings (full page — captures top portion)
|
||||
{name: 'settings', path: '/ghost/#/settings'},
|
||||
|
||||
// Analytics / Stats pages (React)
|
||||
{name: 'analytics-overview', path: '/ghost/#/stats', extraWait: 1500},
|
||||
{name: 'analytics-web', path: '/ghost/#/stats/web', extraWait: 1500},
|
||||
{name: 'analytics-growth', path: '/ghost/#/stats/growth', extraWait: 1500},
|
||||
{name: 'analytics-newsletters', path: '/ghost/#/stats/newsletters', extraWait: 1500},
|
||||
|
||||
// ActivityPub pages (React)
|
||||
{name: 'activitypub-inbox', path: '/ghost/#/activitypub', extraWait: 1500},
|
||||
{name: 'activitypub-feed', path: '/ghost/#/activitypub/feed', extraWait: 1500},
|
||||
{name: 'activitypub-profile', path: '/ghost/#/activitypub/profile', extraWait: 1500},
|
||||
{name: 'activitypub-notifications', path: '/ghost/#/activitypub/notifications', extraWait: 1500}
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings sections — scrolled into view individually
|
||||
// ---------------------------------------------------------------------------
|
||||
interface SettingsSection {
|
||||
name: string;
|
||||
/** The data-testid attribute on the section's TopLevelGroup */
|
||||
testId: string;
|
||||
}
|
||||
|
||||
const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
// General
|
||||
{name: 'settings-title-description', testId: 'title-and-description'},
|
||||
{name: 'settings-timezone', testId: 'timezone'},
|
||||
{name: 'settings-publication-language', testId: 'publication-language'},
|
||||
{name: 'settings-staff', testId: 'users'},
|
||||
{name: 'settings-social-accounts', testId: 'social-accounts'},
|
||||
|
||||
// Site
|
||||
{name: 'settings-design', testId: 'design'},
|
||||
{name: 'settings-theme', testId: 'theme'},
|
||||
{name: 'settings-navigation', testId: 'navigation'},
|
||||
{name: 'settings-announcement-bar', testId: 'announcement-bar'},
|
||||
|
||||
// Membership
|
||||
{name: 'settings-portal', testId: 'portal'},
|
||||
{name: 'settings-tiers', testId: 'tiers'},
|
||||
{name: 'settings-analytics', testId: 'analytics'},
|
||||
|
||||
// Email newsletters
|
||||
{name: 'settings-enable-newsletters', testId: 'enable-newsletters'},
|
||||
{name: 'settings-newsletters', testId: 'newsletters'},
|
||||
{name: 'settings-default-recipients', testId: 'default-recipients'},
|
||||
{name: 'settings-mailgun', testId: 'mailgun'},
|
||||
|
||||
// Growth
|
||||
{name: 'settings-recommendations', testId: 'recommendations'},
|
||||
{name: 'settings-embed-signup-form', testId: 'embed-signup-form'},
|
||||
|
||||
// Advanced
|
||||
{name: 'settings-integrations', testId: 'integrations'},
|
||||
{name: 'settings-migration', testId: 'migrationtools'},
|
||||
{name: 'settings-code-injection', testId: 'code-injection'},
|
||||
{name: 'settings-labs', testId: 'labs'},
|
||||
{name: 'settings-history', testId: 'history'}
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSS injected into every page to hide flaky dynamic content
|
||||
// ---------------------------------------------------------------------------
|
||||
const HIDE_DYNAMIC_CONTENT = `
|
||||
/* Timestamps and relative dates */
|
||||
[data-testid="timestamp"],
|
||||
[data-test-date],
|
||||
time,
|
||||
.gh-content-entry-date,
|
||||
.gh-members-list-joined,
|
||||
.gh-post-list-updated,
|
||||
.gh-post-list-date {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
/* Notifications and toasts */
|
||||
.gh-notification,
|
||||
.gh-alerts,
|
||||
[data-testid="toast"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Animations and transitions */
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0s !important;
|
||||
animation-delay: 0s !important;
|
||||
transition-duration: 0s !important;
|
||||
transition-delay: 0s !important;
|
||||
}
|
||||
|
||||
/* Avatars (can vary between runs) */
|
||||
.gh-member-avatar img,
|
||||
.gh-author-avatar img {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
/* Loading spinners */
|
||||
.gh-loading-spinner,
|
||||
.gh-loading-orb {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Charts and graphs (data-dependent, flaky) */
|
||||
.recharts-surface,
|
||||
.recharts-wrapper,
|
||||
canvas {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
/* Scrollbars (platform-dependent rendering) */
|
||||
::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
}
|
||||
* {
|
||||
scrollbar-width: none !important;
|
||||
}
|
||||
|
||||
/* Editor carets and cursors */
|
||||
.koenig-cursor,
|
||||
.ProseMirror .ProseMirror-cursor,
|
||||
[data-lexical-editor] .cursor,
|
||||
.kg-prose caret-color {
|
||||
caret-color: transparent !important;
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full-page screen tests
|
||||
// ---------------------------------------------------------------------------
|
||||
for (const screen of SCREENS) {
|
||||
test(`visual baseline: ${screen.name}`, async ({page}) => {
|
||||
await page.goto(screen.path);
|
||||
await page.waitForLoadState('load');
|
||||
|
||||
if (screen.waitFor) {
|
||||
await page.waitForSelector(screen.waitFor, {timeout: 15_000});
|
||||
}
|
||||
|
||||
if (screen.extraWait) {
|
||||
await page.waitForTimeout(screen.extraWait);
|
||||
}
|
||||
|
||||
await page.addStyleTag({content: HIDE_DYNAMIC_CONTENT});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(page).toHaveScreenshot(`${screen.name}.png`, {
|
||||
fullPage: !screen.viewportOnly,
|
||||
maxDiffPixelRatio: 0.001
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings section tests — scroll to each section and capture viewport
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('settings sections', () => {
|
||||
for (const section of SETTINGS_SECTIONS) {
|
||||
test(`visual baseline: ${section.name}`, async ({page}) => {
|
||||
await page.goto('/ghost/#/settings');
|
||||
await page.waitForLoadState('load');
|
||||
|
||||
// Wait for Settings React app to mount
|
||||
await page.waitForSelector('[data-testid="title-and-description"]', {timeout: 15_000});
|
||||
|
||||
// Extra settle time for settings to fully render
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
await page.addStyleTag({content: HIDE_DYNAMIC_CONTENT});
|
||||
|
||||
// Scroll the section into view within the settings scroller
|
||||
const scrolled = await page.evaluate((testId) => {
|
||||
const sectionEl = document.querySelector(`[data-testid="${testId}"]`);
|
||||
if (!sectionEl) {
|
||||
return false;
|
||||
}
|
||||
sectionEl.scrollIntoView({block: 'start'});
|
||||
return true;
|
||||
}, section.testId);
|
||||
|
||||
if (!scrolled) {
|
||||
// Section might not exist (conditional on config like Stripe)
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow scroll + re-render to settle
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(page).toHaveScreenshot(`${section.name}.png`, {
|
||||
fullPage: false,
|
||||
maxDiffPixelRatio: 0.001
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import {defineConfig} from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Visual regression test config for TailwindCSS migration.
|
||||
*
|
||||
* Runs against a live `pnpm dev` Ghost instance (localhost:2368).
|
||||
* Start Ghost first, then:
|
||||
*
|
||||
* # Capture/update golden baselines
|
||||
* npx playwright test -c e2e/visual-regression --update-snapshots
|
||||
*
|
||||
* # Compare current state against baselines
|
||||
* npx playwright test -c e2e/visual-regression
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './',
|
||||
testMatch: '**/*.spec.ts',
|
||||
timeout: 90_000,
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
maxDiffPixelRatio: 0.001,
|
||||
animations: 'disabled'
|
||||
}
|
||||
},
|
||||
retries: 0,
|
||||
workers: 1, // sequential — screenshots must be deterministic
|
||||
reporter: [['list', {printSteps: true}], ['html', {open: 'never'}]],
|
||||
use: {
|
||||
baseURL: process.env.GHOST_URL || 'http://localhost:2368',
|
||||
viewport: {width: 1440, height: 900},
|
||||
actionTimeout: 10_000,
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure'
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'auth-setup',
|
||||
testMatch: 'auth.setup.ts'
|
||||
},
|
||||
{
|
||||
name: 'visual-regression',
|
||||
testMatch: 'capture-baselines.spec.ts',
|
||||
dependencies: ['auth-setup'],
|
||||
use: {
|
||||
storageState: './e2e/visual-regression/.auth/state.json'
|
||||
}
|
||||
}
|
||||
],
|
||||
snapshotPathTemplate: '{testDir}/baselines/{arg}{ext}'
|
||||
});
|
||||