397 lines
14 KiB
TypeScript
397 lines
14 KiB
TypeScript
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'])
|
|
})
|
|
})
|