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) { 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) { 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 { 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']) }) })