This commit is contained in:
2026-03-20 21:17:26 +01:00
parent 1a62fcb81d
commit 29c3ff8572
229 changed files with 13147 additions and 2577 deletions

View File

@@ -0,0 +1,396 @@
import { test, expect, type Browser, type Page } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import path from 'node:path'
type DashboardFixture = {
email: string
password: string
username: string
}
const RECENT_VISITS_STORAGE_KEY = 'skinbase.dashboard.recent-visits'
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://skinbase26.test'
const VITE_MANIFEST_PATH = path.join(process.cwd(), 'public', 'build', 'manifest.json')
function ensureCompiledAssets() {
if (existsSync(VITE_MANIFEST_PATH)) {
return
}
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
execFileSync(npmCommand, ['run', 'build'], {
cwd: process.cwd(),
stdio: 'inherit',
})
}
function seedDashboardFixture(): DashboardFixture {
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
const email = `e2e-dashboard-${token}@example.test`
const username = `e2ed${token}`.slice(0, 20)
const script = [
"use App\\Models\\User;",
"use Illuminate\\Support\\Facades\\Hash;",
`$user = User::updateOrCreate(['email' => '${email}'], [`,
" 'name' => 'E2E Dashboard User',",
` 'username' => '${username}',`,
" 'onboarding_step' => 'complete',",
" 'email_verified_at' => now(),",
" 'is_active' => 1,",
" 'password' => Hash::make('password'),",
"]);",
"echo json_encode(['email' => $user->email, 'password' => 'password', 'username' => $user->username]);",
].join(' ')
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
const lines = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{') && line.endsWith('}'))
if (!jsonLine) {
throw new Error(`Unable to parse fixture JSON from tinker output: ${raw}`)
}
return JSON.parse(jsonLine) as DashboardFixture
}
function setPinnedSpacesForFixture(fixture: DashboardFixture, pinnedSpaces: string[] = []) {
const encodedPinnedSpaces = JSON.stringify(pinnedSpaces)
const script = [
"use App\\Models\\User;",
"use App\\Models\\DashboardPreference;",
`$user = User::where('email', '${fixture.email}')->firstOrFail();`,
`DashboardPreference::updateOrCreate(['user_id' => $user->id], ['pinned_spaces' => json_decode('${encodedPinnedSpaces}', true)]);`,
"echo 'ok';",
].join(' ')
execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
}
function resetBotProtectionState() {
const script = [
"use Illuminate\\Support\\Facades\\DB;",
"use Illuminate\\Support\\Facades\\Schema;",
"foreach (['forum_bot_logs', 'forum_bot_ip_blacklist', 'forum_bot_device_fingerprints', 'forum_bot_behavior_profiles'] as $table) {",
" if (Schema::hasTable($table)) {",
" DB::table($table)->delete();",
' }',
'}',
"echo 'ok';",
].join(' ')
execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
}
async function login(page: Page, fixture: DashboardFixture) {
for (let attempt = 0; attempt < 2; attempt += 1) {
await page.goto('/login')
const emailField = page.locator('input[name="email"]')
const viteManifestError = page.getByText(/Vite manifest not found/i)
const internalServerError = page.getByText('Internal Server Error')
await Promise.race([
emailField.waitFor({ state: 'visible', timeout: 8000 }),
viteManifestError.waitFor({ state: 'visible', timeout: 8000 }),
internalServerError.waitFor({ state: 'visible', timeout: 8000 }),
])
if (await viteManifestError.isVisible().catch(() => false)) {
throw new Error('Dashboard Playwright login failed because the Vite manifest is missing. Run the frontend build before running this spec.')
}
if ((await internalServerError.isVisible().catch(() => false)) && !(await emailField.isVisible().catch(() => false))) {
throw new Error('Dashboard Playwright login failed because the login page returned an internal server error before the form loaded.')
}
await emailField.fill(fixture.email)
await page.locator('input[name="password"]').fill(fixture.password)
await page.getByRole('button', { name: 'Sign In' }).click()
try {
await page.waitForURL((url) => url.pathname !== '/login', { timeout: 8000, waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: /E2E Dashboard User/i })).toBeVisible()
return
} catch {
const suspiciousActivity = page.getByText('Suspicious activity detected.')
if (attempt === 0 && (await suspiciousActivity.isVisible().catch(() => false))) {
resetBotProtectionState()
continue
}
throw new Error('Dashboard Playwright login failed before reaching an authenticated page.')
}
}
}
async function openDashboard(page: Page, fixture: DashboardFixture) {
await login(page, fixture)
await page.goto('/dashboard')
await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible()
}
async function openDashboardInFreshContext(browser: Browser, fixture: DashboardFixture) {
const context = await browser.newContext({ baseURL: BASE_URL, ignoreHTTPSErrors: true })
const page = await context.newPage()
await openDashboard(page, fixture)
return { context, page }
}
async function saveShortcutUpdate(page: Page, action: () => Promise<void>) {
const responsePromise = page.waitForResponse(
(response) => response.url().includes('/api/dashboard/preferences/shortcuts') && response.request().method() === 'PUT'
)
await action()
const response = await responsePromise
expect(response.ok()).toBeTruthy()
}
async function performShortcutUpdate(page: Page, action: () => Promise<void>) {
const responsePromise = page.waitForResponse(
(response) => response.url().includes('/api/dashboard/preferences/shortcuts') && response.request().method() === 'PUT'
)
await action()
return responsePromise
}
function shortcutToast(page: Page) {
return page.getByText(/Saving dashboard shortcuts|Dashboard shortcuts saved\./i).first()
}
async function seedRecentVisits(page: Page, items: Array<{ href: string; label: string; pinned?: boolean; lastVisitedAt?: string | null }>) {
await page.evaluate(
({ storageKey, recentItems }) => {
window.localStorage.setItem(storageKey, JSON.stringify(recentItems))
},
{ storageKey: RECENT_VISITS_STORAGE_KEY, recentItems: items }
)
}
function recentVisitsHeading(page: Page) {
return page.getByRole('heading', { name: 'Recently visited dashboard spaces' })
}
function pinnedSection(page: Page) {
return page
.locator('section')
.filter({ has: page.getByRole('heading', { name: 'Your fastest dashboard shortcuts' }) })
.first()
}
async function pinnedShortcutLabels(page: Page): Promise<string[]> {
return pinnedSection(page).locator('article h3').allTextContents()
}
async function pinShortcut(page: Page, name: string) {
await saveShortcutUpdate(page, async () => {
await page.getByRole('button', { name: `Pin ${name}` }).click()
})
}
async function unpinShortcut(page: Page, name: string) {
await saveShortcutUpdate(page, async () => {
await pinnedSection(page).getByRole('button', { name: `Unpin ${name}` }).click()
})
}
async function pinRecentShortcut(page: Page, name: string) {
await saveShortcutUpdate(page, async () => {
await page.getByRole('button', { name: `Pin ${name}` }).first().click()
})
}
test.describe('Dashboard pinned shortcuts', () => {
test.describe.configure({ mode: 'serial' })
let fixture: DashboardFixture
test.beforeAll(() => {
ensureCompiledAssets()
fixture = seedDashboardFixture()
})
test.beforeEach(() => {
resetBotProtectionState()
setPinnedSpacesForFixture(fixture, [])
})
test('pins shortcuts, preserves explicit order, and survives reload without local recents', async ({ page }) => {
await openDashboard(page, fixture)
await pinShortcut(page, 'Notifications')
await expect(shortcutToast(page)).toBeVisible()
await pinShortcut(page, 'Favorites')
await expect(pinnedSection(page)).toBeVisible()
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications', 'Favorites'])
await saveShortcutUpdate(page, async () => {
await pinnedSection(page).getByRole('button', { name: 'Move Favorites earlier' }).click()
})
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Favorites', 'Notifications'])
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey)
}, RECENT_VISITS_STORAGE_KEY)
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible()
await expect(pinnedSection(page)).toBeVisible()
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Favorites', 'Notifications'])
})
test('pinned strip matches the visual baseline', async ({ page }) => {
await openDashboard(page, fixture)
await pinShortcut(page, 'Notifications')
await pinShortcut(page, 'Favorites')
await expect(pinnedSection(page)).toBeVisible()
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications', 'Favorites'])
await expect(pinnedSection(page)).toHaveScreenshot('dashboard-pinned-strip.png', {
animations: 'disabled',
caret: 'hide',
maxDiffPixels: 50,
})
})
test('pinned strip matches the mobile visual baseline', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await openDashboard(page, fixture)
await pinShortcut(page, 'Notifications')
await pinShortcut(page, 'Favorites')
await expect(pinnedSection(page)).toBeVisible()
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications', 'Favorites'])
await expect(pinnedSection(page)).toHaveScreenshot('dashboard-pinned-strip-mobile.png', {
animations: 'disabled',
caret: 'hide',
maxDiffPixels: 50,
})
})
test('shows an error toast when shortcut persistence fails', async ({ page }) => {
await openDashboard(page, fixture)
await page.route('**/api/dashboard/preferences/shortcuts', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ message: 'Server error' }),
})
})
const response = await performShortcutUpdate(page, async () => {
await page.getByRole('button', { name: 'Pin Notifications' }).click()
})
expect(response.ok()).toBeFalsy()
await expect(page.getByText('Could not save dashboard shortcuts. Refresh and try again.')).toBeVisible()
await expect(pinnedSection(page)).toBeVisible()
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications'])
})
test('unpinning the last shortcut removes the pinned strip', async ({ page }) => {
await openDashboard(page, fixture)
await pinShortcut(page, 'Notifications')
await expect(pinnedSection(page)).toBeVisible()
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications'])
await unpinShortcut(page, 'Notifications')
await expect(pinnedSection(page)).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Pin Notifications' }).first()).toBeVisible()
})
test('saved pinned order is restored in a fresh browser context', async ({ browser, page }) => {
await openDashboard(page, fixture)
await pinShortcut(page, 'Notifications')
await pinShortcut(page, 'Favorites')
await saveShortcutUpdate(page, async () => {
await pinnedSection(page).getByRole('button', { name: 'Move Favorites earlier' }).click()
})
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Favorites', 'Notifications'])
const fresh = await openDashboardInFreshContext(browser, fixture)
try {
await fresh.page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey)
}, RECENT_VISITS_STORAGE_KEY)
await fresh.page.reload({ waitUntil: 'domcontentloaded' })
await expect(fresh.page.getByRole('heading', { name: /welcome back/i })).toBeVisible()
await expect(pinnedSection(fresh.page)).toBeVisible()
await expect.poll(() => pinnedShortcutLabels(fresh.page)).toEqual(['Favorites', 'Notifications'])
} finally {
await fresh.context.close()
}
})
test('pinning from recent cards feeds the same persisted pinned order', async ({ page }) => {
await openDashboard(page, fixture)
const now = new Date().toISOString()
await seedRecentVisits(page, [
{
href: '/dashboard/notifications',
label: 'Notifications',
pinned: false,
lastVisitedAt: now,
},
{
href: '/dashboard/favorites',
label: 'Favorites',
pinned: false,
lastVisitedAt: now,
},
])
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible()
await expect(recentVisitsHeading(page)).toBeVisible()
await pinRecentShortcut(page, 'Notifications')
await pinRecentShortcut(page, 'Favorites')
await expect(pinnedSection(page)).toBeVisible()
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications', 'Favorites'])
await page.evaluate((storageKey) => {
window.localStorage.removeItem(storageKey)
}, RECENT_VISITS_STORAGE_KEY)
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible()
await expect(pinnedSection(page)).toBeVisible()
await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications', 'Favorites'])
})
})