update
This commit is contained in:
396
tests/e2e/dashboard-shortcuts.spec.ts
Normal file
396
tests/e2e/dashboard-shortcuts.spec.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user