Save workspace changes
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
21
.deploy/artwork-evolution-release/tests/e2e/gallery.spec.ts
Normal file
21
.deploy/artwork-evolution-release/tests/e2e/gallery.spec.ts
Normal 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);
|
||||
});
|
||||
35
.deploy/artwork-evolution-release/tests/e2e/global-setup.ts
Normal file
35
.deploy/artwork-evolution-release/tests/e2e/global-setup.ts
Normal 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()
|
||||
}
|
||||
7
.deploy/artwork-evolution-release/tests/e2e/home.spec.ts
Normal file
7
.deploy/artwork-evolution-release/tests/e2e/home.spec.ts
Normal 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();
|
||||
});
|
||||
275
.deploy/artwork-evolution-release/tests/e2e/messaging.spec.ts
Normal file
275
.deploy/artwork-evolution-release/tests/e2e/messaging.spec.ts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
445
.deploy/artwork-evolution-release/tests/e2e/routes.spec.ts
Normal file
445
.deploy/artwork-evolution-release/tests/e2e/routes.spec.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user