Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,788 @@
import { test, expect, type Page } from '@playwright/test'
import { execFileSync } from 'node:child_process'
type CollectionFixture = {
email: string
password: string
username: string
adminEmail: string
adminPassword: string
adminUsername: string
foreignUsername: string
artworkTitle: string
secondArtworkTitle: string
relatedArtworkTitle: string
foreignArtworkTitle: string
aiTagValue: string
styleValue: string
colorValue: string
secondColorValue: string
smartCollectionTitle: string
manualCollectionTitle: string
studioCollectionTitle: string
studioCollectionId: number
studioCollectionSlug: string
saveableCollectionTitle: string
saveableCollectionSlug: string
}
function seedCollectionFixture(): CollectionFixture {
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
const email = `e2e-collections-${token}@example.test`
const username = `e2ec${token}`.slice(0, 20)
const adminEmail = `e2e-collections-admin-${token}@example.test`
const adminUsername = `e2eadmin${token}`.slice(0, 20)
const artworkTitle = `Owned Neon City ${token}`
const secondArtworkTitle = `Owned Sunset Mirage ${token}`
const relatedArtworkTitle = `Owned Neon Echo ${token}`
const foreignArtworkTitle = `Foreign Neon City ${token}`
const aiTagValue = `neon-signal-${token}`
const styleValue = 'digital-painting'
const colorValue = 'blue-tones'
const secondColorValue = 'orange-tones'
const smartCollectionTitle = `Playwright Smart Collection ${token}`
const manualCollectionTitle = `Playwright Manual Collection ${token}`
const studioCollectionTitle = `Studio Workflow Collection ${token}`
const studioCollectionSlug = `studio-workflow-${token}`
const saveableCollectionTitle = `Saved Inspiration Collection ${token}`
const saveableCollectionSlug = `saved-inspiration-${token}`
const script = [
"use App\\Models\\Artwork;",
"use App\\Models\\Collection;",
"use App\\Models\\Tag;",
"use App\\Models\\User;",
"use Illuminate\\Support\\Facades\\DB;",
"use Illuminate\\Support\\Facades\\Hash;",
`$user = User::create([`,
" 'name' => 'E2E Collections User',",
` 'email' => '${email}',`,
` 'username' => '${username}',`,
" 'onboarding_step' => 'complete',",
" 'email_verified_at' => now(),",
" 'is_active' => 1,",
" 'password' => Hash::make('password'),",
"]);",
"$otherUser = User::create([",
" 'name' => 'E2E Foreign User',",
` 'email' => 'foreign-${email}',`,
` 'username' => 'f${username}'.substr(md5('${token}'), 0, 3),`,
" 'onboarding_step' => 'complete',",
" 'email_verified_at' => now(),",
" 'is_active' => 1,",
" 'password' => Hash::make('password'),",
"]);",
"$admin = User::create([",
" 'name' => 'E2E Collections Admin',",
` 'email' => '${adminEmail}',`,
` 'username' => '${adminUsername}',`,
" 'role' => 'admin',",
" 'onboarding_step' => 'complete',",
" 'email_verified_at' => now(),",
" 'is_active' => 1,",
" 'password' => Hash::make('password'),",
"]);",
"$tag = Tag::firstOrCreate(['slug' => 'cyberpunk'], ['name' => 'Cyberpunk', 'usage_count' => 0, 'is_active' => true]);",
"$warmTag = Tag::firstOrCreate(['slug' => 'sunset'], ['name' => 'Sunset', 'usage_count' => 0, 'is_active' => true]);",
`$styleTag = Tag::firstOrCreate(['slug' => '${styleValue}'], ['name' => 'Digital Painting', 'usage_count' => 0, 'is_active' => true]);`,
`$colorTag = Tag::firstOrCreate(['slug' => '${colorValue}'], ['name' => 'Blue Tones', 'usage_count' => 0, 'is_active' => true]);`,
`$secondColorTag = Tag::firstOrCreate(['slug' => '${secondColorValue}'], ['name' => 'Orange Tones', 'usage_count' => 0, 'is_active' => true]);`,
`$artwork = Artwork::create([`,
" 'user_id' => $user->id,",
` 'title' => '${artworkTitle}',`,
` 'slug' => 'owned-neon-city-${token}',`,
" 'description' => 'Owned smart collection preview artwork.',",
` 'blip_caption' => 'AI marker ${aiTagValue}',`,
" 'file_name' => 'image.jpg',",
" 'file_path' => 'uploads/artworks/image.jpg',",
` 'hash' => '${token}abcdef',`,
" 'thumb_ext' => 'webp',",
" 'file_ext' => 'jpg',",
" 'file_size' => 12345,",
" 'mime_type' => 'image/jpeg',",
" 'width' => 800,",
" 'height' => 600,",
" 'is_public' => true,",
" 'is_approved' => true,",
" 'published_at' => now()->subDay(),",
"]);",
"$secondArtwork = Artwork::create([",
" 'user_id' => $user->id,",
` 'title' => '${secondArtworkTitle}',`,
` 'slug' => 'owned-sunset-mirage-${token}',`,
" 'description' => 'Second owned artwork for tabbed studio and split suggestion coverage.',",
" 'blip_caption' => 'Warm sunset marker',",
" 'file_name' => 'image.jpg',",
" 'file_path' => 'uploads/artworks/image.jpg',",
` 'hash' => 'aa${token}cdef',`,
" 'thumb_ext' => 'webp',",
" 'file_ext' => 'jpg',",
" 'file_size' => 12345,",
" 'mime_type' => 'image/jpeg',",
" 'width' => 800,",
" 'height' => 600,",
" 'is_public' => true,",
" 'is_approved' => true,",
" 'published_at' => now()->subHours(18),",
"]);",
"$relatedArtwork = Artwork::create([",
" 'user_id' => $user->id,",
` 'title' => '${relatedArtworkTitle}',`,
` 'slug' => 'owned-neon-echo-${token}',`,
" 'description' => 'Unattached related artwork for merge idea coverage.',",
" 'blip_caption' => 'Echo marker for related artwork coverage',",
" 'file_name' => 'image.jpg',",
" 'file_path' => 'uploads/artworks/image.jpg',",
` 'hash' => 'bb${token}efgh',`,
" 'thumb_ext' => 'webp',",
" 'file_ext' => 'jpg',",
" 'file_size' => 12345,",
" 'mime_type' => 'image/jpeg',",
" 'width' => 800,",
" 'height' => 600,",
" 'is_public' => true,",
" 'is_approved' => true,",
" 'published_at' => now()->subHours(12),",
"]);",
"$foreignArtwork = Artwork::create([",
" 'user_id' => $otherUser->id,",
` 'title' => '${foreignArtworkTitle}',`,
` 'slug' => 'foreign-neon-city-${token}',`,
" 'description' => 'Foreign artwork should not appear in owner smart previews.',",
` 'blip_caption' => 'Foreign marker outsider-${token}',`,
" 'file_name' => 'image.jpg',",
" 'file_path' => 'uploads/artworks/image.jpg',",
` 'hash' => 'ff${token}abcd',`,
" 'thumb_ext' => 'webp',",
" 'file_ext' => 'jpg',",
" 'file_size' => 12345,",
" 'mime_type' => 'image/jpeg',",
" 'width' => 800,",
" 'height' => 600,",
" 'is_public' => true,",
" 'is_approved' => true,",
" 'published_at' => now()->subDay(),",
"]);",
"$artwork->tags()->syncWithoutDetaching([$tag->id => ['source' => 'user', 'confidence' => null]]);",
"$artwork->tags()->syncWithoutDetaching([$styleTag->id => ['source' => 'ai', 'confidence' => 0.98]]);",
"$artwork->tags()->syncWithoutDetaching([$colorTag->id => ['source' => 'ai', 'confidence' => 0.97]]);",
"$secondArtwork->tags()->syncWithoutDetaching([$warmTag->id => ['source' => 'user', 'confidence' => null]]);",
"$secondArtwork->tags()->syncWithoutDetaching([$secondColorTag->id => ['source' => 'ai', 'confidence' => 0.96]]);",
"$relatedArtwork->tags()->syncWithoutDetaching([$tag->id => ['source' => 'user', 'confidence' => null]]);",
"$foreignArtwork->tags()->syncWithoutDetaching([$tag->id => ['source' => 'user', 'confidence' => null]]);",
"$foreignArtwork->tags()->syncWithoutDetaching([$styleTag->id => ['source' => 'ai', 'confidence' => 0.99]]);",
"$foreignArtwork->tags()->syncWithoutDetaching([$colorTag->id => ['source' => 'ai', 'confidence' => 0.99]]);",
"$studioCollection = Collection::create([",
" 'user_id' => $user->id,",
` 'title' => '${studioCollectionTitle}',`,
` 'slug' => '${studioCollectionSlug}',`,
" 'type' => 'community',",
" 'description' => 'Seeded collection for tabbed studio and moderation coverage.',",
" 'subtitle' => 'Prepared for Playwright edit-mode coverage',",
" 'summary' => 'Includes two distinct theme clusters plus a related spare artwork.',",
" 'collaboration_mode' => 'invite_only',",
" 'allow_submissions' => true,",
" 'allow_comments' => true,",
" 'allow_saves' => true,",
" 'moderation_status' => 'active',",
" 'visibility' => 'public',",
" 'mode' => 'manual',",
" 'sort_mode' => 'manual',",
" 'artworks_count' => 2,",
" 'comments_count' => 0,",
" 'saves_count' => 0,",
" 'collaborators_count' => 1,",
" 'is_featured' => true,",
" 'featured_at' => now()->subHour(),",
" 'views_count' => 12,",
" 'likes_count' => 4,",
" 'followers_count' => 3,",
" 'shares_count' => 1,",
" 'published_at' => now()->subDay(),",
" 'last_activity_at' => now()->subHour(),",
"]);",
"DB::table('collection_members')->insert([",
" 'collection_id' => $studioCollection->id,",
" 'user_id' => $user->id,",
" 'invited_by_user_id' => $user->id,",
" 'role' => 'owner',",
" 'status' => 'active',",
" 'invited_at' => now()->subDay(),",
" 'accepted_at' => now()->subDay(),",
" 'created_at' => now()->subDay(),",
" 'updated_at' => now()->subDay(),",
"]);",
"DB::table('collection_artwork')->insert([",
" [",
" 'collection_id' => $studioCollection->id,",
" 'artwork_id' => $artwork->id,",
" 'order_num' => 0,",
" 'created_at' => now(),",
" 'updated_at' => now(),",
" ],",
" [",
" 'collection_id' => $studioCollection->id,",
" 'artwork_id' => $secondArtwork->id,",
" 'order_num' => 1,",
" 'created_at' => now(),",
" 'updated_at' => now(),",
" ],",
"]);",
"$studioCollection->forceFill(['cover_artwork_id' => $artwork->id])->save();",
"$foreignCollection = Collection::create([",
" 'user_id' => $otherUser->id,",
` 'title' => '${saveableCollectionTitle}',`,
` 'slug' => '${saveableCollectionSlug}',`,
" 'type' => 'community',",
" 'description' => 'A foreign collection used for saved collection browser coverage.',",
" 'collaboration_mode' => 'closed',",
" 'allow_submissions' => false,",
" 'allow_comments' => true,",
" 'allow_saves' => true,",
" 'moderation_status' => 'active',",
" 'visibility' => 'public',",
" 'mode' => 'manual',",
" 'sort_mode' => 'manual',",
" 'artworks_count' => 1,",
" 'comments_count' => 0,",
" 'saves_count' => 0,",
" 'collaborators_count' => 1,",
" 'is_featured' => false,",
" 'views_count' => 0,",
" 'likes_count' => 0,",
" 'followers_count' => 0,",
" 'shares_count' => 0,",
" 'published_at' => now()->subDay(),",
" 'last_activity_at' => now()->subDay(),",
"]);",
"DB::table('collection_artwork')->insert([",
" 'collection_id' => $foreignCollection->id,",
" 'artwork_id' => $foreignArtwork->id,",
" 'order_num' => 0,",
" 'created_at' => now(),",
" 'updated_at' => now(),",
"]);",
"$foreignCollection->forceFill(['cover_artwork_id' => $foreignArtwork->id])->save();",
"echo json_encode([",
" 'email' => $user->email,",
" 'password' => 'password',",
" 'username' => $user->username,",
" 'adminEmail' => $admin->email,",
" 'adminPassword' => 'password',",
" 'adminUsername' => $admin->username,",
" 'foreignUsername' => $otherUser->username,",
` 'artworkTitle' => '${artworkTitle}',`,
` 'secondArtworkTitle' => '${secondArtworkTitle}',`,
` 'relatedArtworkTitle' => '${relatedArtworkTitle}',`,
` 'foreignArtworkTitle' => '${foreignArtworkTitle}',`,
` 'aiTagValue' => '${aiTagValue}',`,
` 'styleValue' => '${styleValue}',`,
` 'colorValue' => '${colorValue}',`,
` 'secondColorValue' => '${secondColorValue}',`,
` 'smartCollectionTitle' => '${smartCollectionTitle}',`,
` 'manualCollectionTitle' => '${manualCollectionTitle}',`,
` 'studioCollectionTitle' => '${studioCollectionTitle}',`,
" 'studioCollectionId' => $studioCollection->id,",
` 'studioCollectionSlug' => '${studioCollectionSlug}',`,
` 'saveableCollectionTitle' => '${saveableCollectionTitle}',`,
` 'saveableCollectionSlug' => '${saveableCollectionSlug}',`,
"]);",
].join(' ')
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
const jsonLine = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.reverse()
.find((line) => line.startsWith('{') && line.endsWith('}'))
if (!jsonLine) {
throw new Error(`Unable to parse collection fixture JSON from tinker output: ${raw}`)
}
return JSON.parse(jsonLine) as CollectionFixture
}
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, credentials: { email: string; password: string }) {
for (let attempt = 0; attempt < 2; attempt += 1) {
await page.goto('/login')
const emailField = page.locator('input[name="email"]')
const suspiciousActivity = page.getByText('Suspicious activity detected.')
await emailField.waitFor({ state: 'visible', timeout: 8000 })
await emailField.fill(credentials.email)
await page.locator('input[name="password"]').fill(credentials.password)
await page.getByRole('button', { name: 'Sign In' }).click()
try {
await page.waitForURL((url) => url.pathname !== '/login', { timeout: 8000, waitUntil: 'domcontentloaded' })
return
} catch {
if (attempt === 0 && await suspiciousActivity.isVisible().catch(() => false)) {
resetBotProtectionState()
continue
}
throw new Error('Collection Playwright login failed before reaching an authenticated page.')
}
}
}
async function dismissCookieBanner(page: Page) {
const essentialOnlyButton = page.getByRole('button', { name: 'Essential only' })
const acceptAllButton = page.getByRole('button', { name: 'Accept all' })
if (await essentialOnlyButton.isVisible().catch(() => false)) {
await essentialOnlyButton.click()
return
}
if (await acceptAllButton.isVisible().catch(() => false)) {
await acceptAllButton.click()
}
}
async function waitForCollectionManagePage(page: Page) {
await expect.poll(() => new URL(page.url()).pathname, { timeout: 30000 }).toMatch(/\/settings\/collections\/\d+$/)
await expect(titleInput(page)).toBeVisible({ timeout: 10000 })
}
async function waitForPublicCollectionPage(page: Page) {
await expect.poll(() => new URL(page.url()).pathname, { timeout: 30000 }).toMatch(/^\/@.+\/collections\//)
}
async function createCollectionAndWaitForRedirect(page: Page) {
const createPath = new URL(page.url()).pathname
const storeResponsePromise = page.waitForResponse((response) => {
const request = response.request()
return request.method() === 'POST'
&& /\/settings\/collections$/.test(new URL(response.url()).pathname)
})
await page.getByRole('button', { name: 'Create Collection' }).click()
const storeResponse = await storeResponsePromise
expect(storeResponse.ok()).toBeTruthy()
await expect.poll(() => new URL(page.url()).pathname, { timeout: 30000 }).not.toBe(createPath)
await waitForCollectionManagePage(page)
await expect(titleInput(page)).toBeVisible({ timeout: 10000 })
}
function titleInput(page: Page) {
return page.locator('input[placeholder="Dark Fantasy Series"]')
}
function subtitleInput(page: Page) {
return page.locator('input[placeholder="A moody archive of midnight environments"]')
}
function summaryInput(page: Page) {
return page.locator('input[placeholder="Best performing sci-fi wallpapers from the last year"]')
}
function artworkPickerSection(page: Page) {
return page.locator('section').filter({ has: page.getByRole('heading', { name: /^add artworks$/i }) }).first()
}
function attachedArtworksSection(page: Page) {
return page.locator('section').filter({ has: page.getByRole('heading', { name: /arrange the showcase order/i }) }).first()
}
function smartBuilderSection(page: Page) {
return page.locator('section').filter({ has: page.getByRole('heading', { name: /define collection rules/i }) }).first()
}
function smartPreviewSection(page: Page) {
return page.locator('section').filter({ has: page.getByRole('heading', { name: /matching artworks/i }) }).first()
}
function studioTab(page: Page, label: string) {
return page.locator('button').filter({ hasText: new RegExp(label, 'i') }).first()
}
async function openStudioTab(page: Page, label: string) {
const tab = studioTab(page, label)
await tab.scrollIntoViewIfNeeded()
await tab.click()
}
async function attachSelectedArtworks(page: Page) {
const attachResponsePromise = page.waitForResponse((response) => {
const request = response.request()
return request.method() === 'POST'
&& /\/settings\/collections\/\d+\/artworks$/.test(new URL(response.url()).pathname)
})
await page.getByRole('button', { name: /add selected/i }).click()
const attachResponse = await attachResponsePromise
expect(attachResponse.ok()).toBeTruthy()
}
test.describe('Smart collections', () => {
test.describe.configure({ mode: 'serial' })
test.setTimeout(90000)
let fixture: CollectionFixture
test.beforeAll(() => {
fixture = seedCollectionFixture()
})
test.beforeEach(() => {
resetBotProtectionState()
})
test('owner can preview and create a smart collection', async ({ page }) => {
await login(page, fixture)
await page.goto('/settings/collections/create?mode=smart')
await dismissCookieBanner(page)
await expect(page.getByRole('heading', { name: /create a v2 collection/i })).toBeVisible()
await expect(page.getByRole('heading', { name: /define collection rules/i })).toBeVisible()
await titleInput(page).fill(fixture.smartCollectionTitle)
await subtitleInput(page).fill('A browser-tested smart showcase')
await summaryInput(page).fill('Built end to end through Playwright')
const smartBuilder = smartBuilderSection(page)
if (await smartBuilder.getByText(/add at least one rule/i).isVisible().catch(() => false)) {
await smartBuilder.getByRole('button', { name: /add rule/i }).click()
}
await smartBuilder.locator('select').nth(0).selectOption('ai_tag')
await smartBuilder.locator('input[placeholder="Enter a value"]').fill(fixture.aiTagValue)
const previewSection = smartPreviewSection(page)
const previewResponsePromise = page.waitForResponse(
(response) => response.url().includes('/settings/collections/smart-preview') && response.request().method() === 'POST'
)
await previewSection.getByRole('button', { name: /refresh preview/i }).click()
const previewResponse = await previewResponsePromise
expect(previewResponse.ok()).toBeTruthy()
const previewRequest = previewResponse.request().postDataJSON()
expect(previewRequest?.smart_rules_json?.rules?.[0]?.field).toBe('ai_tag')
expect(previewRequest?.smart_rules_json?.rules?.[0]?.value).toBe(fixture.aiTagValue)
const previewPayload = await previewResponse.json()
expect(previewPayload?.preview?.count).toBe(1)
expect(previewPayload?.preview?.artworks?.data?.some((artwork: { title?: string }) => artwork.title === fixture.artworkTitle)).toBeTruthy()
expect(previewPayload?.preview?.artworks?.data?.some((artwork: { title?: string }) => artwork.title === fixture.foreignArtworkTitle)).toBeFalsy()
await createCollectionAndWaitForRedirect(page)
await expect(titleInput(page)).toHaveValue(fixture.smartCollectionTitle)
await expect(page.getByText(/smart collection/i).first()).toBeVisible()
await page.getByRole('link', { name: /view public page/i }).click()
await waitForPublicCollectionPage(page)
await expect(page.getByRole('heading', { name: fixture.smartCollectionTitle })).toBeVisible()
const publicArtworkHeading = page.getByRole('heading', { name: fixture.artworkTitle })
await publicArtworkHeading.scrollIntoViewIfNeeded()
await expect(publicArtworkHeading).toBeVisible()
await expect(page.getByText(fixture.foreignArtworkTitle)).toHaveCount(0)
})
test('owner can preview style and color smart rules without leaking foreign artworks', async ({ page }) => {
await login(page, fixture)
await page.goto('/settings/collections/create?mode=smart')
await dismissCookieBanner(page)
await expect(page.getByRole('heading', { name: /define collection rules/i })).toBeVisible()
await titleInput(page).fill(`${fixture.smartCollectionTitle} Style Color`)
const smartBuilder = smartBuilderSection(page)
if (await smartBuilder.getByText(/add at least one rule/i).isVisible().catch(() => false)) {
await smartBuilder.getByRole('button', { name: /add rule/i }).click()
}
const selects = smartBuilder.locator('select')
await selects.nth(0).selectOption('style')
await selects.nth(2).selectOption(fixture.styleValue)
const previewSection = smartPreviewSection(page)
let previewResponsePromise = page.waitForResponse(
(response) => response.url().includes('/settings/collections/smart-preview') && response.request().method() === 'POST'
)
await previewSection.getByRole('button', { name: /refresh preview/i }).click()
let previewResponse = await previewResponsePromise
expect(previewResponse.ok()).toBeTruthy()
let previewRequest = previewResponse.request().postDataJSON()
expect(previewRequest?.smart_rules_json?.rules?.[0]?.field).toBe('style')
expect(previewRequest?.smart_rules_json?.rules?.[0]?.value).toBe(fixture.styleValue)
let previewPayload = await previewResponse.json()
expect(previewPayload?.preview?.count).toBe(1)
expect(previewPayload?.preview?.artworks?.data?.some((artwork: { title?: string }) => artwork.title === fixture.artworkTitle)).toBeTruthy()
expect(previewPayload?.preview?.artworks?.data?.some((artwork: { title?: string }) => artwork.title === fixture.foreignArtworkTitle)).toBeFalsy()
await selects.nth(0).selectOption('color')
await selects.nth(2).selectOption(fixture.colorValue)
previewResponsePromise = page.waitForResponse(
(response) => response.url().includes('/settings/collections/smart-preview') && response.request().method() === 'POST'
)
await previewSection.getByRole('button', { name: /refresh preview/i }).click()
previewResponse = await previewResponsePromise
expect(previewResponse.ok()).toBeTruthy()
previewRequest = previewResponse.request().postDataJSON()
expect(previewRequest?.smart_rules_json?.rules?.[0]?.field).toBe('color')
expect(previewRequest?.smart_rules_json?.rules?.[0]?.value).toBe(fixture.colorValue)
previewPayload = await previewResponse.json()
expect(previewPayload?.preview?.count).toBe(1)
expect(previewPayload?.preview?.artworks?.data?.some((artwork: { title?: string }) => artwork.title === fixture.artworkTitle)).toBeTruthy()
expect(previewPayload?.preview?.artworks?.data?.some((artwork: { title?: string }) => artwork.title === fixture.foreignArtworkTitle)).toBeFalsy()
})
test('owner can create a manual collection and attach artworks', async ({ page }) => {
await login(page, fixture)
await page.goto('/settings/collections/create')
await dismissCookieBanner(page)
await expect(page.getByRole('heading', { name: /create a v2 collection/i })).toBeVisible()
await titleInput(page).fill(fixture.manualCollectionTitle)
await subtitleInput(page).fill('A hand-picked browser-tested showcase')
await summaryInput(page).fill('Created manually and populated through the picker')
await createCollectionAndWaitForRedirect(page)
await expect(titleInput(page)).toHaveValue(fixture.manualCollectionTitle)
await expect(page.getByText(/^manual$/i).first()).toBeVisible()
await openStudioTab(page, 'Artworks')
await expect(page.getByRole('heading', { name: /arrange the showcase order/i })).toBeVisible()
const picker = artworkPickerSection(page)
await picker.getByRole('button').filter({ hasText: fixture.artworkTitle }).first().click()
await attachSelectedArtworks(page)
const attachedSection = attachedArtworksSection(page)
await expect(attachedSection.getByText(fixture.artworkTitle)).toBeVisible()
await openStudioTab(page, 'Details')
await expect(titleInput(page)).toBeVisible()
await expect(page.getByLabel('Cover Artwork')).toBeEnabled()
await page.getByLabel('Cover Artwork').selectOption({ label: fixture.artworkTitle })
await page.getByRole('button', { name: /save changes/i }).click()
await expect(page.getByText('Collection updated.')).toBeVisible()
await page.getByRole('link', { name: /view public page/i }).click()
await waitForPublicCollectionPage(page)
await expect(page.getByRole('heading', { name: fixture.manualCollectionTitle })).toBeVisible()
const manualPublicArtworkHeading = page.getByRole('heading', { name: fixture.artworkTitle })
await manualPublicArtworkHeading.scrollIntoViewIfNeeded()
await expect(manualPublicArtworkHeading).toBeVisible()
})
test('owner can request and apply AI assistant suggestions on the manage page', async ({ page }) => {
await login(page, fixture)
await page.goto('/settings/collections/create')
await dismissCookieBanner(page)
await titleInput(page).fill(`${fixture.manualCollectionTitle} AI`)
await subtitleInput(page).fill('Prepared for AI assistant coverage')
await summaryInput(page).fill('Initial summary before AI suggestions are applied')
await createCollectionAndWaitForRedirect(page)
await openStudioTab(page, 'Artworks')
await expect(page.getByRole('heading', { name: /arrange the showcase order/i })).toBeVisible()
const picker = artworkPickerSection(page)
await picker.getByRole('button').filter({ hasText: fixture.artworkTitle }).first().click()
await attachSelectedArtworks(page)
await expect(attachedArtworksSection(page).getByText(fixture.artworkTitle)).toBeVisible()
await openStudioTab(page, 'AI Suggestions')
await expect(page.getByRole('heading', { name: /review-only suggestions/i })).toBeVisible()
const aiSection = page.locator('section').filter({ hasText: /AI Assistant/ }).filter({ has: page.getByRole('button', { name: /suggest title/i }) }).first()
await aiSection.scrollIntoViewIfNeeded()
await expect(aiSection.getByRole('button', { name: /suggest title/i })).toBeVisible()
let aiResponsePromise = page.waitForResponse((response) => response.url().includes('/ai/suggest-title') && response.request().method() === 'POST')
await aiSection.getByRole('button', { name: /suggest title/i }).click()
let aiResponse = await aiResponsePromise
expect(aiResponse.ok()).toBeTruthy()
await expect(aiSection.getByRole('button', { name: /use title/i })).toBeVisible()
await aiSection.getByRole('button', { name: /use title/i }).click()
aiResponsePromise = page.waitForResponse((response) => response.url().includes('/ai/suggest-summary') && response.request().method() === 'POST')
await aiSection.getByRole('button', { name: /suggest summary/i }).click()
aiResponse = await aiResponsePromise
expect(aiResponse.ok()).toBeTruthy()
await expect(aiSection.getByRole('button', { name: /use summary/i })).toBeVisible()
await aiSection.getByRole('button', { name: /use summary/i }).click()
aiResponsePromise = page.waitForResponse((response) => response.url().includes('/ai/suggest-cover') && response.request().method() === 'POST')
await aiSection.getByRole('button', { name: /suggest cover/i }).click()
aiResponse = await aiResponsePromise
expect(aiResponse.ok()).toBeTruthy()
await expect(aiSection.getByRole('button', { name: /use cover/i })).toBeVisible()
await aiSection.getByRole('button', { name: /use cover/i }).click()
await openStudioTab(page, 'Details')
await expect(titleInput(page)).not.toHaveValue(`${fixture.manualCollectionTitle} AI`)
await expect(summaryInput(page)).not.toHaveValue('Initial summary before AI suggestions are applied')
await page.getByRole('button', { name: /save changes/i }).click()
await expect(page.getByText('Collection updated.')).toBeVisible()
})
test('owner can navigate the tabbed studio and request split and merge AI suggestions', async ({ page }) => {
await login(page, fixture)
await page.goto(`/settings/collections/${fixture.studioCollectionId}`)
await dismissCookieBanner(page)
await waitForCollectionManagePage(page)
await expect(studioTab(page, 'Details')).toBeVisible()
await expect(studioTab(page, 'Artworks')).toBeVisible()
await expect(studioTab(page, 'Members')).toBeVisible()
await expect(studioTab(page, 'Submissions')).toBeVisible()
await expect(studioTab(page, 'Settings')).toBeVisible()
await expect(studioTab(page, 'Discussion')).toBeVisible()
await expect(studioTab(page, 'AI Suggestions')).toBeVisible()
await openStudioTab(page, 'Artworks')
await expect(page.getByRole('heading', { name: /arrange the showcase order/i })).toBeVisible()
await expect(page.getByText(fixture.artworkTitle)).toBeVisible()
await expect(page.getByText(fixture.secondArtworkTitle)).toBeVisible()
await openStudioTab(page, 'Members')
const membersSection = page.locator('section').filter({ has: page.getByRole('heading', { name: /team access/i }) }).first()
await expect(membersSection.getByRole('heading', { name: /team access/i })).toBeVisible()
await expect(membersSection.getByText(/owner/i)).toBeVisible()
await expect(membersSection.getByText(/e2e collections user/i)).toBeVisible()
await openStudioTab(page, 'Submissions')
await expect(page.getByRole('heading', { name: /incoming artworks/i })).toBeVisible()
await expect(page.getByText(/no submissions yet\./i)).toBeVisible()
await openStudioTab(page, 'Discussion')
await expect(page.getByRole('heading', { name: /recent comments/i })).toBeVisible()
await expect(page.getByText(/no comments yet\./i)).toBeVisible()
await openStudioTab(page, 'Settings')
await expect(page.getByText(/use the details tab for metadata and publishing options/i)).toBeVisible()
await openStudioTab(page, 'AI Suggestions')
await expect(page.getByRole('heading', { name: /review-only suggestions/i })).toBeVisible()
let aiResponsePromise = page.waitForResponse((response) => response.url().includes('/ai/suggest-split-themes') && response.request().method() === 'POST')
await page.getByRole('button', { name: /suggest split/i }).click()
let aiResponse = await aiResponsePromise
expect(aiResponse.ok()).toBeTruthy()
const splitPayload = await aiResponse.json()
expect(Array.isArray(splitPayload?.suggestion?.splits)).toBeTruthy()
expect(splitPayload.suggestion.splits.some((split: { title?: string }) => /digital painting/i.test(split.title || ''))).toBeTruthy()
expect(splitPayload.suggestion.splits.some((split: { title?: string }) => /orange tones/i.test(split.title || ''))).toBeTruthy()
await expect(page.getByText(/split suggestion/i)).toBeVisible()
aiResponsePromise = page.waitForResponse((response) => response.url().includes('/ai/suggest-merge-idea') && response.request().method() === 'POST')
await page.getByRole('button', { name: /suggest merge idea/i }).click()
aiResponse = await aiResponsePromise
expect(aiResponse.ok()).toBeTruthy()
const mergePayload = await aiResponse.json()
expect(mergePayload?.suggestion?.idea?.title).toBeTruthy()
expect(mergePayload?.suggestion?.idea?.summary).toBeTruthy()
await expect(page.getByText(mergePayload.suggestion.idea.title)).toBeVisible()
})
test('admin can moderate the seeded collection from the moderation tab', async ({ page }) => {
await login(page, { email: fixture.adminEmail, password: fixture.adminPassword })
await page.goto(`/settings/collections/${fixture.studioCollectionId}`)
await dismissCookieBanner(page)
await waitForCollectionManagePage(page)
await expect(studioTab(page, 'Moderation')).toBeVisible()
await openStudioTab(page, 'Moderation')
await expect(page.getByRole('heading', { name: /admin controls/i })).toBeVisible()
let moderationResponsePromise = page.waitForResponse((response) => /\/api\/admin\/collections\/\d+\/moderation$/.test(new URL(response.url()).pathname) && response.request().method() === 'PATCH')
await page.locator('select').filter({ has: page.locator('option[value="restricted"]') }).first().selectOption('restricted')
let moderationResponse = await moderationResponsePromise
expect(moderationResponse.ok()).toBeTruthy()
await expect(page.getByText(/moderation state updated to restricted\./i)).toBeVisible()
await expect(page.getByText(/current state:\s*restricted/i)).toBeVisible()
const allowCommentsToggle = page.locator('label').filter({ hasText: 'Allow comments' }).locator('input[type="checkbox"]')
await expect(allowCommentsToggle).toBeChecked()
const interactionsResponsePromise = page.waitForResponse((response) => /\/api\/admin\/collections\/\d+\/interactions$/.test(new URL(response.url()).pathname) && response.request().method() === 'PATCH')
await allowCommentsToggle.uncheck()
const interactionsResponse = await interactionsResponsePromise
expect(interactionsResponse.ok()).toBeTruthy()
await expect(page.getByText(/collection interaction settings updated\./i)).toBeVisible()
await expect(allowCommentsToggle).not.toBeChecked()
const unfeatureResponsePromise = page.waitForResponse((response) => /\/api\/admin\/collections\/\d+\/unfeature$/.test(new URL(response.url()).pathname) && response.request().method() === 'POST')
await page.getByRole('button', { name: /remove featured placement/i }).click()
const unfeatureResponse = await unfeatureResponsePromise
expect(unfeatureResponse.ok()).toBeTruthy()
await expect(page.getByText(/collection removed from featured placement by moderation action\./i)).toBeVisible()
await openStudioTab(page, 'Details')
await expect(page.getByText(/^featured$/i)).toHaveCount(0)
})
test('viewer can save a public collection and see it in saved collections', async ({ page }) => {
await login(page, fixture)
await page.goto(`/@${fixture.foreignUsername}/collections/${fixture.saveableCollectionSlug}`)
await dismissCookieBanner(page)
await expect(page.getByRole('heading', { name: fixture.saveableCollectionTitle })).toBeVisible()
const saveResponsePromise = page.waitForResponse((response) => response.url().includes('/collections/') && /\/save$/.test(new URL(response.url()).pathname) && response.request().method() === 'POST')
await page.getByRole('button', { name: /^save collection$/i }).click()
const saveResponse = await saveResponsePromise
expect(saveResponse.ok()).toBeTruthy()
await expect(page.locator('button').filter({ hasText: /^saved$/i }).first()).toBeVisible()
await page.goto('/me/saved/collections')
await expect(page.getByRole('heading', { name: /saved collections/i })).toBeVisible()
await expect(page.getByText(fixture.saveableCollectionTitle)).toBeVisible()
})
})

View File

@@ -0,0 +1,173 @@
import { test, expect, 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 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-mobile-${token}@example.test`
const username = `e2em${token}`.slice(0, 20)
const script = [
'use App\\Models\\User;',
'use Illuminate\\Support\\Facades\\Hash;',
`$user = User::updateOrCreate(['email' => '${email}'], [`,
" 'name' => 'E2E Mobile 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 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 internalServerError = page.getByText('Internal Server Error')
await Promise.race([
emailField.waitFor({ state: 'visible', timeout: 8000 }),
internalServerError.waitFor({ state: 'visible', timeout: 8000 }),
])
if ((await internalServerError.isVisible().catch(() => false)) && !(await emailField.isVisible().catch(() => false))) {
throw new Error('Dashboard mobile layout login failed because the login page returned an internal server error.')
}
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 Mobile 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 mobile layout login failed before reaching an authenticated page.')
}
}
}
async function expectNoHorizontalOverflow(page: Page) {
const dimensions = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}))
expect(
dimensions.scrollWidth,
`Expected no horizontal overflow, but scrollWidth=${dimensions.scrollWidth} and clientWidth=${dimensions.clientWidth}`
).toBeLessThanOrEqual(dimensions.clientWidth + 1)
}
test.describe('Dashboard mobile layout', () => {
test.describe.configure({ mode: 'serial' })
test.use({ viewport: { width: 390, height: 844 } })
let fixture: DashboardFixture
test.beforeAll(() => {
ensureCompiledAssets()
fixture = seedDashboardFixture()
})
test.beforeEach(() => {
resetBotProtectionState()
})
test('dashboard home fits mobile width', async ({ page }) => {
await login(page, fixture)
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible()
await page.waitForLoadState('networkidle')
await expectNoHorizontalOverflow(page)
await expect(page.getByRole('heading', { name: 'Your dashboard snapshot' })).toBeVisible()
})
test('followers page fits mobile width', async ({ page }) => {
await login(page, fixture)
await page.goto('/dashboard/followers', { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('heading', { name: 'People Following Me' })).toBeVisible()
await page.waitForLoadState('networkidle')
await expectNoHorizontalOverflow(page)
await expect(page.getByRole('link', { name: /discover creators/i })).toBeVisible()
})
test('following page fits mobile width', async ({ page }) => {
await login(page, fixture)
await page.goto('/dashboard/following', { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('heading', { name: 'People I Follow' })).toBeVisible()
await page.waitForLoadState('networkidle')
await expectNoHorizontalOverflow(page)
await expect(page.getByRole('link', { name: /my followers/i })).toBeVisible()
})
})

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

View File

@@ -0,0 +1,21 @@
import { test, expect } from '@playwright/test';
test('public /browse shows 5 (or more) columns on large screen', async ({ page }) => {
// use a very wide viewport to emulate a large desktop where 5 columns should fit
await page.setViewportSize({ width: 2000, height: 1200 });
await page.goto('/browse');
await page.waitForSelector('[data-gallery-grid]');
// hide sidebar and force gallery width so we can assert column layout in CI
await page.addStyleTag({ content: 'aside#sidebar{display:none !important} main{width:100% !important} [data-gallery-grid].force-5{grid-template-columns: repeat(5, minmax(0,1fr)) !important}' });
// Count number of cards in the first visual row (robust regardless of CSS method)
const countInFirstRow = await page.$$eval('[data-gallery-grid] > .nova-card', (cards) => {
if (!cards || cards.length === 0) return 0;
const rects = cards.map(c => c.getBoundingClientRect());
const firstTop = rects[0].top;
return rects.filter(r => Math.abs(r.top - firstTop) < 2).length;
});
console.log('cards in first row:', countInFirstRow);
expect(countInFirstRow).toBeGreaterThanOrEqual(5);
});

View File

@@ -0,0 +1,35 @@
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import path from 'node:path'
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 warmBladeViews() {
execFileSync('php', ['artisan', 'view:clear'], {
cwd: process.cwd(),
stdio: 'inherit',
})
execFileSync('php', ['artisan', 'view:cache'], {
cwd: process.cwd(),
stdio: 'inherit',
})
}
export default async function globalSetup() {
ensureCompiledAssets()
warmBladeViews()
}

View File

@@ -0,0 +1,7 @@
import { test, expect } from '@playwright/test';
test('home page loads and shows legacy page container', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/Skinbase/i);
await expect(page.locator('#homepage-root')).toBeVisible();
});

View File

@@ -0,0 +1,275 @@
import { test, expect } from '@playwright/test'
import { execFileSync } from 'node:child_process'
type Fixture = {
email: string
password: string
conversation_id: number
latest_message_id: number
}
type InsertedMessage = {
id: number
body: string
}
type ConversationSeed = {
conversation_id: number
latest_message_id: number
}
function seedMessagingFixture(): Fixture {
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
const ownerEmail = `e2e-messages-owner-${token}@example.test`
const peerEmail = `e2e-messages-peer-${token}@example.test`
const ownerUsername = `e2eo${token}`.slice(0, 20)
const peerUsername = `e2ep${token}`.slice(0, 20)
const script = [
"use App\\Models\\User;",
"use App\\Models\\Conversation;",
"use App\\Models\\ConversationParticipant;",
"use App\\Models\\Message;",
"use Illuminate\\Support\\Facades\\Hash;",
"use Illuminate\\Support\\Carbon;",
`$owner = User::updateOrCreate(['email' => '${ownerEmail}'], [`,
" 'name' => 'E2E Owner',",
` 'username' => '${ownerUsername}',`,
" 'onboarding_step' => 'complete',",
" 'email_verified_at' => now(),",
" 'is_active' => 1,",
" 'password' => Hash::make('password'),",
"]);",
`$peer = User::updateOrCreate(['email' => '${peerEmail}'], [`,
" 'name' => 'E2E Peer',",
` 'username' => '${peerUsername}',`,
" 'onboarding_step' => 'complete',",
" 'email_verified_at' => now(),",
" 'is_active' => 1,",
" 'password' => Hash::make('password'),",
"]);",
"$conversation = Conversation::create(['type' => 'direct', 'created_by' => $owner->id]);",
"ConversationParticipant::insert([",
" ['conversation_id' => $conversation->id, 'user_id' => $owner->id, 'role' => 'admin', 'joined_at' => now(), 'last_read_at' => null],",
" ['conversation_id' => $conversation->id, 'user_id' => $peer->id, 'role' => 'member', 'joined_at' => now(), 'last_read_at' => now()],",
"]);",
"$first = Message::create(['conversation_id' => $conversation->id, 'sender_id' => $peer->id, 'body' => 'Seed hello']);",
"$last = Message::create(['conversation_id' => $conversation->id, 'sender_id' => $owner->id, 'body' => 'Seed latest from owner']);",
"$conversation->update(['last_message_at' => $last->created_at]);",
"ConversationParticipant::where('conversation_id', $conversation->id)->where('user_id', $peer->id)->update(['last_read_at' => Carbon::parse($last->created_at)->addSeconds(15)]);",
"echo json_encode(['email' => $owner->email, 'password' => 'password', 'conversation_id' => $conversation->id, 'latest_message_id' => $last->id]);",
].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 Fixture
}
function insertReconnectRecoveryMessage(conversationId: number): InsertedMessage {
const token = `${Date.now()}-${Math.floor(Math.random() * 100000)}`
const body = `Reconnect recovery ${token}`
const script = [
"use App\\Models\\Conversation;",
"use App\\Models\\ConversationParticipant;",
"use App\\Models\\Message;",
`$conversation = Conversation::query()->findOrFail(${conversationId});`,
"$senderId = ConversationParticipant::query()->where('conversation_id', $conversation->id)->where('role', 'member')->value('user_id');",
`$message = Message::create(['conversation_id' => $conversation->id, 'sender_id' => $senderId, 'body' => '${body}']);`,
"$conversation->update(['last_message_id' => $message->id, 'last_message_at' => $message->created_at]);",
"echo json_encode(['id' => $message->id, 'body' => $message->body]);",
].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 inserted message JSON from tinker output: ${raw}`)
}
return JSON.parse(jsonLine) as InsertedMessage
}
function seedAdditionalConversation(conversationId: number): ConversationSeed {
const script = [
"use App\\Models\\Conversation;",
"use App\\Models\\ConversationParticipant;",
"use App\\Models\\Message;",
"use Illuminate\\Support\\Carbon;",
"use Illuminate\\Support\\Facades\\Cache;",
`$original = Conversation::query()->findOrFail(${conversationId});`,
"$ownerId = (int) $original->created_by;",
"$peerId = (int) ConversationParticipant::query()->where('conversation_id', $original->id)->where('user_id', '!=', $ownerId)->value('user_id');",
"$conversation = Conversation::create(['type' => 'direct', 'created_by' => $ownerId]);",
"ConversationParticipant::insert([",
" ['conversation_id' => $conversation->id, 'user_id' => $ownerId, 'role' => 'admin', 'joined_at' => now(), 'last_read_at' => null],",
" ['conversation_id' => $conversation->id, 'user_id' => $peerId, 'role' => 'member', 'joined_at' => now(), 'last_read_at' => now()],",
"]);",
"$first = Message::create(['conversation_id' => $conversation->id, 'sender_id' => $peerId, 'body' => 'Seed hello']);",
"$last = Message::create(['conversation_id' => $conversation->id, 'sender_id' => $ownerId, 'body' => 'Seed latest from owner']);",
"$conversation->update(['last_message_id' => $last->id, 'last_message_at' => $last->created_at]);",
"ConversationParticipant::where('conversation_id', $conversation->id)->where('user_id', $peerId)->update(['last_read_at' => Carbon::parse($last->created_at)->addSeconds(15)]);",
"foreach ([$ownerId, $peerId] as $uid) {",
" $versionKey = 'messages:conversations:version:' . $uid;",
" Cache::add($versionKey, 1, now()->addDay());",
" Cache::increment($versionKey);",
"}",
"echo json_encode(['conversation_id' => $conversation->id, 'latest_message_id' => $last->id]);",
].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 additional conversation JSON from tinker output: ${raw}`)
}
return JSON.parse(jsonLine) as ConversationSeed
}
async function login(page: Parameters<typeof test>[0]['page'], fixture: Fixture) {
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://skinbase26.test'
await page.context().addCookies([
{
name: 'e2e_bot_bypass',
value: '1',
url: baseUrl,
},
])
await page.goto('/login')
await page.locator('input[name="email"]').fill(fixture.email)
await page.locator('input[name="password"]').fill(fixture.password)
await page.getByRole('button', { name: 'Sign In' }).click()
await page.waitForURL(/\/dashboard/)
}
async function waitForRealtimeConnection(page: Parameters<typeof test>[0]['page']) {
await page.waitForFunction(() => {
const echo = window.Echo
const connection = echo?.connector?.pusher?.connection
return Boolean(echo && connection && typeof connection.emit === 'function')
}, { timeout: 10000 })
}
test.describe('Messaging UI', () => {
test.describe.configure({ mode: 'serial' })
let fixture: Fixture
test.beforeAll(() => {
fixture = seedMessagingFixture()
})
test('restores draft from localStorage on reload', async ({ page }) => {
await login(page, fixture)
await page.goto(`/messages/${fixture.conversation_id}`)
const textarea = page.locator('textarea[placeholder^="Write a message"]')
const draft = 'E2E draft should survive reload'
await textarea.fill(draft)
await expect(textarea).toHaveValue(draft)
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(textarea).toHaveValue(draft)
const stored = await page.evaluate((key) => window.localStorage.getItem(key), `nova_draft_${fixture.conversation_id}`)
expect(stored).toBe(draft)
})
test('shows seen indicator on latest direct message from current user', async ({ page }) => {
await login(page, fixture)
await page.goto(`/messages/${fixture.conversation_id}`)
await expect(page.locator(`#message-${fixture.latest_message_id}`)).toContainText('Seed latest from owner')
await expect(page.locator('text=/^Seen\\s.+\\sago$/')).toBeVisible()
})
test('reconnect recovery fetches missed messages through delta without duplicates', async ({ page }) => {
await login(page, fixture)
await page.goto(`/messages/${fixture.conversation_id}`)
await waitForRealtimeConnection(page)
await expect(page.locator(`#message-${fixture.latest_message_id}`)).toContainText('Seed latest from owner')
const inserted = insertReconnectRecoveryMessage(fixture.conversation_id)
await page.evaluate(() => {
const connection = window.Echo?.connector?.pusher?.connection
connection?.emit?.('connected')
})
await expect(page.locator(`#message-${inserted.id}`)).toContainText(inserted.body)
await expect(page.locator(`#message-${inserted.id}`)).toHaveCount(1)
await page.evaluate(() => {
const connection = window.Echo?.connector?.pusher?.connection
connection?.emit?.('connected')
})
await expect(page.locator(`#message-${inserted.id}`)).toHaveCount(1)
})
test('reconnect recovery keeps sidebar unread summary consistent', async ({ page }) => {
await login(page, fixture)
await page.route(/\/api\/messages\/\d+\/read$/, (route) => route.abort())
const isolatedConversation = seedAdditionalConversation(fixture.conversation_id)
await page.goto(`/messages/${isolatedConversation.conversation_id}`)
const unreadStat = page.getByTestId('messages-stat-unread')
const unreadBefore = parseUnreadCount(await unreadStat.innerText())
await waitForRealtimeConnection(page)
const inserted = insertReconnectRecoveryMessage(isolatedConversation.conversation_id)
await page.evaluate(() => {
const connection = window.Echo?.connector?.pusher?.connection
connection?.emit?.('connected')
})
await expect(page.locator(`#message-${inserted.id}`)).toContainText(inserted.body)
const unreadAfter = parseUnreadCount(await unreadStat.innerText())
expect(unreadAfter).toBeGreaterThan(unreadBefore)
})
})
function parseUnreadCount(text: string): number {
const digits = (text.match(/\d+/g) ?? []).join('')
return Number.parseInt(digits || '0', 10)
}

View File

@@ -0,0 +1,230 @@
import { test, expect, type Page } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import { existsSync, statSync } from 'node:fs'
import path from 'node:path'
type NovaCardFixture = {
email: string
password: string
username: string
}
const VITE_MANIFEST_PATH = path.join(process.cwd(), 'public', 'build', 'manifest.json')
const FRONTEND_SOURCES = [
path.join(process.cwd(), 'resources', 'js', 'Pages', 'Studio', 'StudioCardEditor.jsx'),
path.join(process.cwd(), 'resources', 'js', 'components', 'nova-cards', 'NovaCardCanvasPreview.jsx'),
]
function needsFrontendBuild() {
if (!existsSync(VITE_MANIFEST_PATH)) {
return true
}
const manifestUpdatedAt = statSync(VITE_MANIFEST_PATH).mtimeMs
return FRONTEND_SOURCES.some((filePath) => existsSync(filePath) && statSync(filePath).mtimeMs > manifestUpdatedAt)
}
function ensureCompiledAssets() {
if (!needsFrontendBuild()) {
return
}
if (process.platform === 'win32') {
execFileSync('cmd.exe', ['/c', 'npm', 'run', 'build'], {
cwd: process.cwd(),
stdio: 'inherit',
})
return
}
execFileSync('npm', ['run', 'build'], {
cwd: process.cwd(),
stdio: 'inherit',
})
}
function ensureNovaCardSeedData() {
execFileSync('php', ['artisan', 'db:seed', '--class=Database\\Seeders\\NovaCardCategorySeeder', '--no-interaction'], {
cwd: process.cwd(),
stdio: 'inherit',
})
execFileSync('php', ['artisan', 'db:seed', '--class=Database\\Seeders\\NovaCardTemplateSeeder', '--no-interaction'], {
cwd: process.cwd(),
stdio: 'inherit',
})
}
function seedNovaCardFixture(): NovaCardFixture {
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
const email = `e2e-nova-cards-${token}@example.test`
const username = `e2enc${token}`.slice(0, 20)
const script = [
'use App\\Models\\User;',
'use Illuminate\\Support\\Facades\\Hash;',
`$user = User::updateOrCreate(['email' => '${email}'], [`,
" 'name' => 'E2E Nova Cards 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 NovaCardFixture
}
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: NovaCardFixture) {
for (let attempt = 0; attempt < 2; attempt += 1) {
await page.goto('/login')
const emailField = page.locator('input[name="email"]')
const internalServerError = page.getByText('Internal Server Error')
await Promise.race([
emailField.waitFor({ state: 'visible', timeout: 8000 }),
internalServerError.waitFor({ state: 'visible', timeout: 8000 }),
])
if ((await internalServerError.isVisible().catch(() => false)) && !(await emailField.isVisible().catch(() => false))) {
throw new Error('Nova Cards mobile editor login failed because the login page returned an internal server error.')
}
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 Nova Cards 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('Nova Cards mobile editor login failed before reaching an authenticated page.')
}
}
}
async function expectNoHorizontalOverflow(page: Page) {
const dimensions = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}))
expect(
dimensions.scrollWidth,
`Expected no horizontal overflow, but scrollWidth=${dimensions.scrollWidth} and clientWidth=${dimensions.clientWidth}`
).toBeLessThanOrEqual(dimensions.clientWidth + 1)
}
function fieldInsideLabel(page: Page, labelText: string, element: 'input' | 'textarea' | 'select' = 'input') {
return page.locator('label', { hasText: labelText }).locator(element).first()
}
test.describe('Nova Cards mobile editor', () => {
test.describe.configure({ mode: 'serial' })
test.use({ viewport: { width: 390, height: 844 } })
let fixture: NovaCardFixture
test.beforeAll(() => {
ensureCompiledAssets()
ensureNovaCardSeedData()
fixture = seedNovaCardFixture()
})
test.beforeEach(() => {
resetBotProtectionState()
})
test('step navigation exposes the mobile editor flow', async ({ page }) => {
await login(page, fixture)
await page.goto('/studio/cards/create', { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('heading', { name: 'Structured card creation with live preview and autosave.' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled({ timeout: 15000 })
await expect(page.getByText('Step 1 / 6')).toBeVisible()
await expect(page.getByText('Choose the canvas shape and basic direction.')).toBeVisible()
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible()
await expect(fieldInsideLabel(page, 'Quote text', 'textarea')).toBeHidden()
await expectNoHorizontalOverflow(page)
await page.getByRole('button', { name: 'Next', exact: true }).click()
await expect(page.getByText('Step 2 / 6')).toBeVisible()
await expect(page.getByText('Pick the visual foundation for the card.')).toBeVisible()
await expect(page.getByRole('combobox', { name: 'Overlay' })).toBeVisible()
await expect(page.getByRole('combobox', { name: 'Focal position' })).toBeVisible()
await page.getByRole('button', { name: 'Next', exact: true }).click()
await expect(page.getByText('Step 3 / 6')).toBeVisible()
await expect(fieldInsideLabel(page, 'Quote text', 'textarea')).toBeVisible()
await fieldInsideLabel(page, 'Title').fill('Mobile editor flow card')
await fieldInsideLabel(page, 'Quote text', 'textarea').fill('Mobile step preview quote')
await fieldInsideLabel(page, 'Author').fill('Playwright')
await page.getByRole('button', { name: 'Next', exact: true }).click()
await expect(page.getByText('Step 4 / 6')).toBeVisible()
await expect(page.getByRole('combobox', { name: 'Text width' })).toBeVisible()
await expect(page.getByRole('combobox', { name: 'Line height' })).toBeVisible()
await page.getByRole('button', { name: 'Next', exact: true }).click()
await expect(page.getByText('Step 5 / 6')).toBeVisible()
await expect(page.getByText('Check the live composition before publish.')).toBeVisible()
await expect(page.getByText('Mobile step preview quote').first()).toBeVisible()
await expect(page.getByText('Playwright').first()).toBeVisible()
await page.getByRole('button', { name: 'Next', exact: true }).click()
await expect(page.getByText('Step 6 / 6')).toBeVisible()
await expect(page.getByRole('combobox', { name: 'Visibility' })).toBeVisible()
await expect(page.getByText('Draft actions')).toBeVisible()
await expect(fieldInsideLabel(page, 'Quote text', 'textarea')).toBeHidden()
await page.getByRole('button', { name: 'Back', exact: true }).click()
await expect(page.getByText('Step 5 / 6')).toBeVisible()
await expect(page.getByText('Mobile step preview quote').first()).toBeVisible()
await expectNoHorizontalOverflow(page)
})
})

View File

@@ -0,0 +1,255 @@
import { test, expect, type Page } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import path from 'node:path'
type ReportingFixture = {
viewerEmail: string
viewerPassword: string
cardId: number
cardPath: string
}
const VITE_MANIFEST_PATH = path.join(process.cwd(), 'public', 'build', 'manifest.json')
function ensureCompiledAssets() {
if (existsSync(VITE_MANIFEST_PATH)) {
return
}
if (process.platform === 'win32') {
execFileSync('cmd.exe', ['/c', 'npm', 'run', 'build'], {
cwd: process.cwd(),
stdio: 'inherit',
})
return
}
execFileSync('npm', ['run', 'build'], {
cwd: process.cwd(),
stdio: 'inherit',
})
}
function ensureNovaCardSeedData() {
execFileSync('php', ['artisan', 'db:seed', '--class=Database\\Seeders\\NovaCardCategorySeeder', '--no-interaction'], {
cwd: process.cwd(),
stdio: 'inherit',
})
execFileSync('php', ['artisan', 'db:seed', '--class=Database\\Seeders\\NovaCardTemplateSeeder', '--no-interaction'], {
cwd: process.cwd(),
stdio: 'inherit',
})
}
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',
})
}
function seedReportingFixture(): ReportingFixture {
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
const viewerEmail = `e2e-nova-report-${token}@example.test`
const viewerUsername = `nrviewer${token}`.slice(0, 20)
const creatorEmail = `e2e-nova-report-creator-${token}@example.test`
const creatorUsername = `nrcreator${token}`.slice(0, 20)
const cardSlug = `nova-report-card-${token}`
const script = [
'use App\\Models\\NovaCard;',
'use App\\Models\\NovaCardCategory;',
'use App\\Models\\NovaCardTemplate;',
'use App\\Models\\User;',
'use Illuminate\\Support\\Facades\\Hash;',
`$viewer = User::updateOrCreate(['email' => '${viewerEmail}'], [`,
" 'name' => 'E2E Nova Report Viewer',",
` 'username' => '${viewerUsername}',`,
" 'onboarding_step' => 'complete',",
" 'email_verified_at' => now(),",
" 'is_active' => 1,",
" 'password' => Hash::make('password'),",
']);',
`$creator = User::updateOrCreate(['email' => '${creatorEmail}'], [`,
" 'name' => 'E2E Nova Report Creator',",
` 'username' => '${creatorUsername}',`,
" 'onboarding_step' => 'complete',",
" 'email_verified_at' => now(),",
" 'is_active' => 1,",
" 'password' => Hash::make('password'),",
']);',
'$category = NovaCardCategory::query()->orderBy("id")->first();',
'$template = NovaCardTemplate::query()->orderBy("id")->first();',
'$card = NovaCard::query()->create([',
' "user_id" => $creator->id,',
' "category_id" => $category->id,',
' "template_id" => $template->id,',
" 'title' => 'Playwright reportable card',",
` 'slug' => '${cardSlug}',`,
" 'quote_text' => 'Reported via Playwright.',",
" 'format' => NovaCard::FORMAT_SQUARE,",
' "project_json" => [',
' "content" => ["title" => "Playwright reportable card", "quote_text" => "Reported via Playwright."],',
' "layout" => ["layout" => "quote_heavy", "position" => "center", "alignment" => "center", "padding" => "comfortable", "max_width" => "balanced"],',
' "typography" => ["font_preset" => "modern-sans", "text_color" => "#ffffff", "accent_color" => "#e0f2fe", "quote_size" => 72, "author_size" => 28, "letter_spacing" => 0, "line_height" => 1.2, "shadow_preset" => "soft"],',
' "background" => ["type" => "gradient", "gradient_preset" => "midnight-nova", "gradient_colors" => ["#0f172a", "#1d4ed8"], "overlay_style" => "dark-soft", "focal_position" => "center", "blur_level" => 0, "opacity" => 50],',
' "decorations" => [],',
' ],',
" 'render_version' => 2,",
" 'schema_version' => 2,",
" 'background_type' => 'gradient',",
" 'visibility' => NovaCard::VISIBILITY_PUBLIC,",
" 'status' => NovaCard::STATUS_PUBLISHED,",
" 'moderation_status' => NovaCard::MOD_APPROVED,",
" 'allow_download' => true,",
" 'allow_remix' => true,",
" 'published_at' => now()->subMinute(),",
']);',
'echo json_encode([',
' "viewerEmail" => $viewer->email,',
' "viewerPassword" => "password",',
' "cardId" => $card->id,',
' "cardPath" => "/cards/{$card->slug}-{$card->id}",',
']);',
].join(' ')
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
const jsonLine = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.reverse()
.find((line) => line.startsWith('{') && line.endsWith('}'))
if (!jsonLine) {
throw new Error(`Unable to parse Nova Card reporting fixture JSON: ${raw}`)
}
return JSON.parse(jsonLine) as ReportingFixture
}
function reportCount(cardId: number, viewerEmail: string): number {
const script = [
'use App\\Models\\Report;',
'use App\\Models\\User;',
`$viewer = User::query()->where('email', '${viewerEmail}')->first();`,
'if (! $viewer) { echo 0; return; }',
`echo Report::query()->where('reporter_id', $viewer->id)->where('target_type', 'nova_card')->where('target_id', ${cardId})->count();`,
].join(' ')
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
const parsed = Number.parseInt(raw.trim().split(/\s+/).pop() || '0', 10)
return Number.isNaN(parsed) ? 0 : parsed
}
async function login(page: Page, fixture: ReportingFixture) {
for (let attempt = 0; attempt < 2; attempt += 1) {
await page.goto('/login')
const emailField = page.locator('input[name="email"]')
const internalServerError = page.getByText('Internal Server Error')
await Promise.race([
emailField.waitFor({ state: 'visible', timeout: 8000 }),
internalServerError.waitFor({ state: 'visible', timeout: 8000 }),
])
if ((await internalServerError.isVisible().catch(() => false)) && !(await emailField.isVisible().catch(() => false))) {
throw new Error('Nova Cards reporting login failed because the login page returned an internal server error.')
}
await emailField.fill(fixture.viewerEmail)
await page.locator('input[name="password"]').fill(fixture.viewerPassword)
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 Nova Report Viewer/i })).toBeVisible()
return
} catch {
const suspiciousActivity = page.getByText('Suspicious activity detected.')
if (attempt === 0 && (await suspiciousActivity.isVisible().catch(() => false))) {
resetBotProtectionState()
continue
}
throw new Error('Nova Cards reporting login failed before reaching an authenticated page.')
}
}
}
test.describe('Nova Cards reporting', () => {
test.describe.configure({ mode: 'serial' })
let fixture: ReportingFixture
test.beforeAll(() => {
ensureCompiledAssets()
ensureNovaCardSeedData()
fixture = seedReportingFixture()
})
test.beforeEach(() => {
resetBotProtectionState()
})
test('authenticated viewers can submit a report from the public card page', async ({ page }) => {
await login(page, fixture)
await page.goto(fixture.cardPath, { waitUntil: 'domcontentloaded' })
await expect(page.locator('[data-card-report]')).toBeVisible()
const dialogExpectations = [
{ type: 'prompt', message: 'Why are you reporting this card?', value: 'Playwright report reason' },
{ type: 'prompt', message: 'Add extra details for moderators (optional)', value: 'Playwright detail for moderation context.' },
{ type: 'alert', message: 'Report submitted. Thank you.' },
]
let handledDialogs = 0
const dialogHandler = async (dialog: Parameters<Page['on']>[1] extends (event: infer T) => any ? T : never) => {
const expected = dialogExpectations[handledDialogs]
expect(expected).toBeTruthy()
expect(dialog.type()).toBe(expected.type)
expect(dialog.message()).toBe(expected.message)
if (expected.type === 'prompt') {
await dialog.accept(expected.value)
} else {
await dialog.accept()
}
handledDialogs += 1
}
page.on('dialog', dialogHandler)
await page.locator('[data-card-report]').click()
await expect.poll(() => handledDialogs, { timeout: 10000 }).toBe(3)
await expect.poll(() => reportCount(fixture.cardId, fixture.viewerEmail), { timeout: 10000 }).toBe(1)
page.off('dialog', dialogHandler)
})
})

View File

@@ -0,0 +1,445 @@
/**
* Route Health Check Suite
*
* Visits every publicly accessible URL in the application and verifies:
* - HTTP status is not 4xx / 5xx (or the expected status for auth-guarded pages)
* - No Laravel error page is rendered ("Whoops", "Server Error", stack traces)
* - No uncaught JavaScript exceptions (window.onerror / unhandledrejection)
* - No browser console errors
* - Page has a non-empty <title> and a visible <body>
*
* Auth-guarded routes are tested to confirm they redirect cleanly to /login
* rather than throwing an error.
*
* Run:
* npx playwright test tests/e2e/routes.spec.ts
* npx playwright test tests/e2e/routes.spec.ts --reporter=html
*/
import { test, expect, type Page } from '@playwright/test';
// ─────────────────────────────────────────────────────────────────────────────
// Route registry
// ─────────────────────────────────────────────────────────────────────────────
interface RouteFixture {
/** URL path (relative to baseURL) */
url: string;
/** Human-readable label shown in test output */
label: string;
/**
* When set, assert page.url() contains this string after navigation.
* When absent, no URL assertion is made (redirects are tolerated by default).
*/
expectUrlContains?: string;
/** When true, expect the browser to land on the login page */
requiresAuth?: boolean;
/** Skip entirely — use for routes that need real DB fixtures not guaranteed in CI */
skip?: boolean;
/** Additional text that MUST be present somewhere on the page */
bodyContains?: string;
}
// Public routes — must return 200 with no errors
const PUBLIC_ROUTES: RouteFixture[] = [
// ── Core ──────────────────────────────────────────────────────────────────
{ url: '/', label: 'Home page' },
{ url: '/home', label: 'Home (alias)' },
{ url: '/blank', label: 'Blank template' },
// ── Browse / gallery ──────────────────────────────────────────────────────
{ url: '/browse', label: 'Browse' },
{ url: '/browse-categories', label: 'Browse Categories' },
{ url: '/categories', label: 'Categories' },
{ url: '/sections', label: 'Sections' },
{ url: '/featured', label: 'Featured artworks' },
{ url: '/featured-artworks', label: 'Featured artworks (alias)' },
// ── Uploads ──────────────────────────────────────────────────────────────
{ url: '/uploads/latest', label: 'Latest uploads (new)' },
{ url: '/uploads/daily', label: 'Daily uploads (new)' },
{ url: '/daily-uploads', label: 'Daily uploads (legacy)' },
{ url: '/latest', label: 'Latest (legacy)' },
// ── Community ────────────────────────────────────────────────────────────
{ url: '/members/photos', label: 'Member photos (new)' },
{ url: '/authors/top', label: 'Top authors (new)' },
{ url: '/comments/latest', label: 'Latest comments (new)' },
{ url: '/comments/monthly', label: 'Monthly commentators (new)' },
{ url: '/downloads/today', label: 'Today downloads (new)' },
{ url: '/top-authors', label: 'Top authors (legacy)', expectUrlContains: '/creators/top' },
{ url: '/top-favourites', label: 'Top favourites (legacy)', expectUrlContains: '/discover/top-rated' },
{ url: '/today-downloads', label: 'Today downloads (legacy)' },
{ url: '/today-in-history', label: 'Today in history', expectUrlContains: '/discover/on-this-day' },
{ url: '/monthly-commentators',label: 'Monthly commentators (legacy)' },
{ url: '/latest-comments', label: 'Latest comments (legacy)' },
{ url: '/interviews', label: 'Interviews', expectUrlContains: '/stories' },
{ url: '/chat', label: 'Chat' },
// ── Forum ────────────────────────────────────────────────────────────────
{ url: '/forum', label: 'Forum index' },
// ── Content type roots ───────────────────────────────────────────────────
{ url: '/photography', label: 'Photography root' },
{ url: '/wallpapers', label: 'Wallpapers root' },
{ url: '/skins', label: 'Skins root' },
// ── Discover ──────────────────────────────────────────────────────────────
{ url: '/discover/trending', label: 'Discover: Trending' },
{ url: '/discover/fresh', label: 'Discover: Fresh' },
{ url: '/discover/top-rated', label: 'Discover: Top Rated' },
{ url: '/discover/most-downloaded', label: 'Discover: Most Downloaded' },
{ url: '/discover/on-this-day', label: 'Discover: On This Day' },
// ── Creators ──────────────────────────────────────────────────────────────
{ url: '/creators/top', label: 'Creators: Top' },
{ url: '/creators/rising', label: 'Creators: Rising' },
{ url: '/stories', label: 'Creator Stories' },
// ── Tags ──────────────────────────────────────────────────────────────────
{ url: '/tags', label: 'Tags index' },
// ── Auth pages (guest-only, publicly accessible) ─────────────────────────
{ url: '/login', label: 'Login page' },
{ url: '/register', label: 'Register page' },
{ url: '/forgot-password', label: 'Forgot password' },
];
// Auth-guarded routes — unauthenticated visitors must land on /login
const AUTH_ROUTES: RouteFixture[] = [
{ url: '/dashboard', label: 'Dashboard', requiresAuth: true },
{ url: '/dashboard/profile', label: 'Dashboard profile', requiresAuth: true },
{ url: '/studio/artworks', label: 'Studio artworks', requiresAuth: true },
{ url: '/dashboard/gallery', label: 'Dashboard gallery', requiresAuth: true },
{ url: '/dashboard/favorites', label: 'Dashboard favorites', requiresAuth: true },
{ url: '/upload', label: 'Upload page', requiresAuth: true },
{ url: '/statistics', label: 'Statistics', requiresAuth: true },
{ url: '/recieved-comments', label: 'Received comments', requiresAuth: true },
{ url: '/mybuddies', label: 'My buddies', requiresAuth: true },
{ url: '/buddies', label: 'Buddies', requiresAuth: true },
{ url: '/manage', label: 'Manage', requiresAuth: true },
{ url: '/dashboard/awards', label: 'Dashboard awards', requiresAuth: true },
];
// Routes that should 404 (to ensure 404 handling is clean and doesn't 500)
const NOT_FOUND_ROUTES: RouteFixture[] = [
{ url: '/this-page-does-not-exist-xyz-9999', label: '404 — unknown path' },
{ url: '/art/999999999/no-such-artwork', label: '404 — unknown artwork' },
];
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
/** Console message origins we choose to tolerate (CSP reports, hot-reload, etc.) */
const IGNORED_CONSOLE_PATTERNS: RegExp[] = [
/\[vite\]/i,
/\[HMR\]/i,
/favicon\.ico/i,
/Failed to load resource.*hot/i,
/content security policy/i,
/sourcemappingurl/i,
// Missing image/asset files in dev environment (thumbnails not present locally)
/Failed to load resource: the server responded with a status of 404/i,
];
/**
* Text fragments whose presence in page HTML indicates Laravel rendered
* an error page (debug mode or a production error view).
*/
const ERROR_PAGE_SIGNALS: string[] = [
'Whoops!',
'Server Error',
'Symfony\\Component\\',
'ErrorException',
'QueryException',
'ParseError',
];
interface PageProbe {
jsErrors: string[];
consoleErrors: string[];
}
/** Wire up collectors for JS errors before navigating. */
function attachProbes(page: Page): PageProbe {
const probe: PageProbe = { jsErrors: [], consoleErrors: [] };
page.on('pageerror', (err) => {
probe.jsErrors.push(err.message);
});
page.on('console', (msg) => {
if (msg.type() !== 'error') return;
const text = msg.text();
if (IGNORED_CONSOLE_PATTERNS.some((re) => re.test(text))) return;
probe.consoleErrors.push(text);
});
return probe;
}
/** Assert the probe found no problems. */
function expectCleanProbe(probe: PageProbe) {
expect(
probe.jsErrors,
`Uncaught JS exceptions: ${probe.jsErrors.join(' | ')}`
).toHaveLength(0);
expect(
probe.consoleErrors,
`Browser console errors: ${probe.consoleErrors.join(' | ')}`
).toHaveLength(0);
}
/** Check the rendered HTML for Laravel / server-side error signals. */
async function expectNoErrorPage(page: Page) {
const html = await page.content();
for (const signal of ERROR_PAGE_SIGNALS) {
expect(
html,
`Error page signal found in HTML: "${signal}"`
).not.toContain(signal);
}
}
/** Check the page has a <title> and visible <body>. */
async function expectMeaningfulPage(page: Page) {
const title = await page.title();
expect(title.trim(), 'Page <title> must not be empty').not.toBe('');
await expect(page.locator('body'), '<body> must be visible').toBeVisible();
}
// ─────────────────────────────────────────────────────────────────────────────
// Test suites
// ─────────────────────────────────────────────────────────────────────────────
// ── 1. Public routes ──────────────────────────────────────────────────────────
test.describe('Public routes — 200, no errors', () => {
for (const route of PUBLIC_ROUTES) {
const testFn = route.skip ? test.skip : test;
testFn(route.label, async ({ page }) => {
const probe = attachProbes(page);
const response = await page.goto(route.url, { waitUntil: 'domcontentloaded' });
// ── Status check ──────────────────────────────────────────────────────
const status = response?.status() ?? 0;
expect(
status,
`${route.url} returned HTTP ${status} — expected 200`
).toBe(200);
// ── Optional URL assertion ─────────────────────────────────────────────
if (route.expectUrlContains) {
expect(page.url()).toContain(route.expectUrlContains);
}
// ── Page content checks ───────────────────────────────────────────────
await expectNoErrorPage(page);
await expectMeaningfulPage(page);
if (route.bodyContains) {
await expect(page.locator('body')).toContainText(route.bodyContains);
}
// ── JS probe results ──────────────────────────────────────────────────
expectCleanProbe(probe);
});
}
});
// ── 2. Auth-guarded routes ────────────────────────────────────────────────────
test.describe('Auth-guarded routes — redirect to /login cleanly', () => {
for (const route of AUTH_ROUTES) {
test(route.label, async ({ page }) => {
const probe = attachProbes(page);
// Follow redirects; we expect to land on the login page
const response = await page.goto(route.url, { waitUntil: 'domcontentloaded' });
// Final page must not be an error page
const status = response?.status() ?? 0;
expect(
status,
`${route.url} auth redirect resulted in HTTP ${status} — expected 200 (login page)`
).toBe(200);
// Must have redirected to login
expect(
page.url(),
`${route.url} did not redirect to /login`
).toContain('/login');
await expectNoErrorPage(page);
await expectMeaningfulPage(page);
expectCleanProbe(probe);
});
}
});
// ── 3. 404 handling ───────────────────────────────────────────────────────────
test.describe('404 routes — clean error page, not a 500', () => {
for (const route of NOT_FOUND_ROUTES) {
test(route.label, async ({ page }) => {
const probe = attachProbes(page);
const response = await page.goto(route.url, { waitUntil: 'domcontentloaded' });
const status = response?.status() ?? 0;
// Must be 404 — never 500
expect(
status,
`${route.url} returned HTTP ${status} — expected 404, not 500`
).toBe(404);
// 404 pages are fine, but they must not be a 500 crash
const html = await page.content();
const crashSignals = [
'Whoops!',
'Symfony\\Component\\',
'ErrorException',
'QueryException',
];
for (const signal of crashSignals) {
expect(
html,
`500-level signal "${signal}" found on a ${status} page`
).not.toContain(signal);
}
expectCleanProbe(probe);
});
}
});
// ── 4. Spot-check: critical pages render expected landmarks ──────────────────
test.describe('Landmark spot-checks', () => {
test('Home page — has gallery section', async ({ page }) => {
const probe = attachProbes(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.locator('#homepage-root section').first()).toBeVisible();
expectCleanProbe(probe);
});
test('Sections page — renders content-type section headings', async ({ page }) => {
const probe = attachProbes(page);
await page.goto('/sections', { waitUntil: 'domcontentloaded' });
// Should have at least one section anchor
const sections = page.locator('[id^="section-"]');
await expect(sections.first()).toBeVisible();
expectCleanProbe(probe);
});
test('Browse-categories page — renders category links', async ({ page }) => {
const probe = attachProbes(page);
await page.goto('/browse-categories', { waitUntil: 'domcontentloaded' });
await expect(page.locator('a[href]').first()).toBeVisible();
expectCleanProbe(probe);
});
test('Forum — renders forum index', async ({ page }) => {
const probe = attachProbes(page);
await page.goto('/forum', { waitUntil: 'domcontentloaded' });
// Forum has either category rows or a "no threads" message
await expect(page.locator('body')).toBeVisible();
await expectNoErrorPage(page);
expectCleanProbe(probe);
});
test('Authors top — renders leaderboard', async ({ page }) => {
const probe = attachProbes(page);
await page.goto('/authors/top', { waitUntil: 'domcontentloaded' });
await expectNoErrorPage(page);
await expectMeaningfulPage(page);
expectCleanProbe(probe);
});
test('Daily uploads — date strip renders', async ({ page }) => {
const probe = attachProbes(page);
await page.goto('/uploads/daily', { waitUntil: 'domcontentloaded' });
await expectNoErrorPage(page);
// Date strip should have multiple buttons
const strip = page.locator('#dateStrip button');
await expect(strip.first()).toBeVisible();
const count = await strip.count();
expect(count, 'Daily uploads date strip should have 15 tabs').toBe(15);
expectCleanProbe(probe);
});
test('Daily uploads — AJAX endpoint returns HTML fragment', async ({ page }) => {
const probe = attachProbes(page);
const today = new Date().toISOString().split('T')[0];
const response = await page.goto(`/uploads/daily?ajax=1&datum=${today}`, {
waitUntil: 'domcontentloaded',
});
expect(
response?.status(),
'Daily uploads AJAX endpoint should return 200'
).toBe(200);
// Response should not be an error page
const html = await page.content();
expect(html).not.toContain('Whoops!');
expect(html).not.toContain('Server Error');
expectCleanProbe(probe);
});
test('Login page loads and has form', async ({ page }) => {
const probe = attachProbes(page);
await page.goto('/login', { waitUntil: 'domcontentloaded' });
await expect(page.locator('form[method="POST"]')).toBeVisible();
await expect(page.locator('input[type="email"], input[name="email"]')).toBeVisible();
await expect(page.locator('input[type="password"]')).toBeVisible();
expectCleanProbe(probe);
});
test('Register page loads and has form', async ({ page }) => {
const probe = attachProbes(page);
await page.goto('/register', { waitUntil: 'domcontentloaded' });
await expect(page.locator('form[method="POST"]')).toBeVisible();
expectCleanProbe(probe);
});
test('Photography root loads', async ({ page }) => {
const probe = attachProbes(page);
await page.goto('/photography', { waitUntil: 'domcontentloaded' });
await expectNoErrorPage(page);
await expectMeaningfulPage(page);
expectCleanProbe(probe);
});
test('Wallpapers root loads', async ({ page }) => {
const probe = attachProbes(page);
await page.goto('/wallpapers', { waitUntil: 'domcontentloaded' });
await expectNoErrorPage(page);
await expectMeaningfulPage(page);
expectCleanProbe(probe);
});
});
// ── 5. Navigation performance — no route should hang ─────────────────────────
test.describe('Response time — no page should take over 8 s', () => {
const SLOW_THRESHOLD_MS = 8000;
const PERF_ROUTES = [
'/', '/uploads/latest', '/comments/latest', '/authors/top',
'/sections', '/forum', '/browse-categories',
];
for (const url of PERF_ROUTES) {
test(`${url} responds within ${SLOW_THRESHOLD_MS}ms`, async ({ page }) => {
const start = Date.now();
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: SLOW_THRESHOLD_MS + 2000 });
const elapsed = Date.now() - start;
expect(
elapsed,
`${url} took ${elapsed}ms — over the ${SLOW_THRESHOLD_MS}ms threshold`
).toBeLessThan(SLOW_THRESHOLD_MS);
});
}
});

View File

@@ -0,0 +1,173 @@
import { test, expect, type Page } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import path from 'node:path'
type UploadFixture = {
email: string
password: string
username: string
}
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 seedUploadFixture(): UploadFixture {
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
const email = `e2e-upload-${token}@example.test`
const username = `e2eu${token}`.slice(0, 20)
const script = [
'use App\\Models\\User;',
'use Illuminate\\Support\\Facades\\Hash;',
`$user = User::updateOrCreate(['email' => '${email}'], [`,
" 'name' => 'E2E Upload 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 UploadFixture
}
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: UploadFixture) {
for (let attempt = 0; attempt < 2; attempt += 1) {
await page.goto('/login')
const emailField = page.locator('input[name="email"]')
const internalServerError = page.getByText('Internal Server Error')
await Promise.race([
emailField.waitFor({ state: 'visible', timeout: 8000 }),
internalServerError.waitFor({ state: 'visible', timeout: 8000 }),
])
if ((await internalServerError.isVisible().catch(() => false)) && !(await emailField.isVisible().catch(() => false))) {
throw new Error('Upload Playwright login failed because the login page returned an internal server error.')
}
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 Upload 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('Upload Playwright login failed before reaching an authenticated page.')
}
}
}
async function expectNoHorizontalOverflow(page: Page) {
const dimensions = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}))
expect(
dimensions.scrollWidth,
`Expected no horizontal overflow, but scrollWidth=${dimensions.scrollWidth} and clientWidth=${dimensions.clientWidth}`
).toBeLessThanOrEqual(dimensions.clientWidth + 1)
}
async function dismissCookieConsent(page: Page) {
const essentialOnly = page.getByRole('button', { name: 'Essential only' })
if (await essentialOnly.isVisible().catch(() => false)) {
await essentialOnly.click({ force: true })
}
}
test.describe('Upload page layout', () => {
test.describe.configure({ mode: 'serial' })
let fixture: UploadFixture
test.beforeAll(() => {
ensureCompiledAssets()
fixture = seedUploadFixture()
})
test.beforeEach(() => {
resetBotProtectionState()
})
test('upload page loads updated studio shell', async ({ page }) => {
await login(page, fixture)
await page.goto('/upload', { waitUntil: 'domcontentloaded' })
await dismissCookieConsent(page)
await expect(page.getByText('Skinbase Upload Studio')).toBeVisible()
await expect(page.getByText('Before you start')).toBeVisible()
await expect(page.getByRole('heading', { level: 2, name: 'Upload your artwork' })).toBeVisible()
await expect(page.getByLabel('Upload file input')).toBeAttached()
await expect(page.getByRole('button', { name: /start upload/i })).toBeDisabled()
})
test('upload page fits mobile width', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await login(page, fixture)
await page.goto('/upload', { waitUntil: 'domcontentloaded' })
await dismissCookieConsent(page)
await page.waitForLoadState('networkidle')
await expect(page.getByText('Skinbase Upload Studio')).toBeVisible()
await expectNoHorizontalOverflow(page)
await expect(page.getByRole('button', { name: /start upload/i })).toBeVisible()
})
})