optimizations
This commit is contained in:
788
tests/e2e/collections-smart.spec.ts
Normal file
788
tests/e2e/collections-smart.spec.ts
Normal file
@@ -0,0 +1,788 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
type CollectionFixture = {
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
adminEmail: string
|
||||
adminPassword: string
|
||||
adminUsername: string
|
||||
foreignUsername: string
|
||||
artworkTitle: string
|
||||
secondArtworkTitle: string
|
||||
relatedArtworkTitle: string
|
||||
foreignArtworkTitle: string
|
||||
aiTagValue: string
|
||||
styleValue: string
|
||||
colorValue: string
|
||||
secondColorValue: string
|
||||
smartCollectionTitle: string
|
||||
manualCollectionTitle: string
|
||||
studioCollectionTitle: string
|
||||
studioCollectionId: number
|
||||
studioCollectionSlug: string
|
||||
saveableCollectionTitle: string
|
||||
saveableCollectionSlug: string
|
||||
}
|
||||
|
||||
function seedCollectionFixture(): CollectionFixture {
|
||||
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
|
||||
const email = `e2e-collections-${token}@example.test`
|
||||
const username = `e2ec${token}`.slice(0, 20)
|
||||
const adminEmail = `e2e-collections-admin-${token}@example.test`
|
||||
const adminUsername = `e2eadmin${token}`.slice(0, 20)
|
||||
const artworkTitle = `Owned Neon City ${token}`
|
||||
const secondArtworkTitle = `Owned Sunset Mirage ${token}`
|
||||
const relatedArtworkTitle = `Owned Neon Echo ${token}`
|
||||
const foreignArtworkTitle = `Foreign Neon City ${token}`
|
||||
const aiTagValue = `neon-signal-${token}`
|
||||
const styleValue = 'digital-painting'
|
||||
const colorValue = 'blue-tones'
|
||||
const secondColorValue = 'orange-tones'
|
||||
const smartCollectionTitle = `Playwright Smart Collection ${token}`
|
||||
const manualCollectionTitle = `Playwright Manual Collection ${token}`
|
||||
const studioCollectionTitle = `Studio Workflow Collection ${token}`
|
||||
const studioCollectionSlug = `studio-workflow-${token}`
|
||||
const saveableCollectionTitle = `Saved Inspiration Collection ${token}`
|
||||
const saveableCollectionSlug = `saved-inspiration-${token}`
|
||||
|
||||
const script = [
|
||||
"use App\\Models\\Artwork;",
|
||||
"use App\\Models\\Collection;",
|
||||
"use App\\Models\\Tag;",
|
||||
"use App\\Models\\User;",
|
||||
"use Illuminate\\Support\\Facades\\DB;",
|
||||
"use Illuminate\\Support\\Facades\\Hash;",
|
||||
`$user = User::create([`,
|
||||
" 'name' => 'E2E Collections User',",
|
||||
` 'email' => '${email}',`,
|
||||
` 'username' => '${username}',`,
|
||||
" 'onboarding_step' => 'complete',",
|
||||
" 'email_verified_at' => now(),",
|
||||
" 'is_active' => 1,",
|
||||
" 'password' => Hash::make('password'),",
|
||||
"]);",
|
||||
"$otherUser = User::create([",
|
||||
" 'name' => 'E2E Foreign User',",
|
||||
` 'email' => 'foreign-${email}',`,
|
||||
` 'username' => 'f${username}'.substr(md5('${token}'), 0, 3),`,
|
||||
" 'onboarding_step' => 'complete',",
|
||||
" 'email_verified_at' => now(),",
|
||||
" 'is_active' => 1,",
|
||||
" 'password' => Hash::make('password'),",
|
||||
"]);",
|
||||
"$admin = User::create([",
|
||||
" 'name' => 'E2E Collections Admin',",
|
||||
` 'email' => '${adminEmail}',`,
|
||||
` 'username' => '${adminUsername}',`,
|
||||
" 'role' => 'admin',",
|
||||
" 'onboarding_step' => 'complete',",
|
||||
" 'email_verified_at' => now(),",
|
||||
" 'is_active' => 1,",
|
||||
" 'password' => Hash::make('password'),",
|
||||
"]);",
|
||||
"$tag = Tag::firstOrCreate(['slug' => 'cyberpunk'], ['name' => 'Cyberpunk', 'usage_count' => 0, 'is_active' => true]);",
|
||||
"$warmTag = Tag::firstOrCreate(['slug' => 'sunset'], ['name' => 'Sunset', 'usage_count' => 0, 'is_active' => true]);",
|
||||
`$styleTag = Tag::firstOrCreate(['slug' => '${styleValue}'], ['name' => 'Digital Painting', 'usage_count' => 0, 'is_active' => true]);`,
|
||||
`$colorTag = Tag::firstOrCreate(['slug' => '${colorValue}'], ['name' => 'Blue Tones', 'usage_count' => 0, 'is_active' => true]);`,
|
||||
`$secondColorTag = Tag::firstOrCreate(['slug' => '${secondColorValue}'], ['name' => 'Orange Tones', 'usage_count' => 0, 'is_active' => true]);`,
|
||||
`$artwork = Artwork::create([`,
|
||||
" 'user_id' => $user->id,",
|
||||
` 'title' => '${artworkTitle}',`,
|
||||
` 'slug' => 'owned-neon-city-${token}',`,
|
||||
" 'description' => 'Owned smart collection preview artwork.',",
|
||||
` 'blip_caption' => 'AI marker ${aiTagValue}',`,
|
||||
" 'file_name' => 'image.jpg',",
|
||||
" 'file_path' => 'uploads/artworks/image.jpg',",
|
||||
` 'hash' => '${token}abcdef',`,
|
||||
" 'thumb_ext' => 'webp',",
|
||||
" 'file_ext' => 'jpg',",
|
||||
" 'file_size' => 12345,",
|
||||
" 'mime_type' => 'image/jpeg',",
|
||||
" 'width' => 800,",
|
||||
" 'height' => 600,",
|
||||
" 'is_public' => true,",
|
||||
" 'is_approved' => true,",
|
||||
" 'published_at' => now()->subDay(),",
|
||||
"]);",
|
||||
"$secondArtwork = Artwork::create([",
|
||||
" 'user_id' => $user->id,",
|
||||
` 'title' => '${secondArtworkTitle}',`,
|
||||
` 'slug' => 'owned-sunset-mirage-${token}',`,
|
||||
" 'description' => 'Second owned artwork for tabbed studio and split suggestion coverage.',",
|
||||
" 'blip_caption' => 'Warm sunset marker',",
|
||||
" 'file_name' => 'image.jpg',",
|
||||
" 'file_path' => 'uploads/artworks/image.jpg',",
|
||||
` 'hash' => 'aa${token}cdef',`,
|
||||
" 'thumb_ext' => 'webp',",
|
||||
" 'file_ext' => 'jpg',",
|
||||
" 'file_size' => 12345,",
|
||||
" 'mime_type' => 'image/jpeg',",
|
||||
" 'width' => 800,",
|
||||
" 'height' => 600,",
|
||||
" 'is_public' => true,",
|
||||
" 'is_approved' => true,",
|
||||
" 'published_at' => now()->subHours(18),",
|
||||
"]);",
|
||||
"$relatedArtwork = Artwork::create([",
|
||||
" 'user_id' => $user->id,",
|
||||
` 'title' => '${relatedArtworkTitle}',`,
|
||||
` 'slug' => 'owned-neon-echo-${token}',`,
|
||||
" 'description' => 'Unattached related artwork for merge idea coverage.',",
|
||||
" 'blip_caption' => 'Echo marker for related artwork coverage',",
|
||||
" 'file_name' => 'image.jpg',",
|
||||
" 'file_path' => 'uploads/artworks/image.jpg',",
|
||||
` 'hash' => 'bb${token}efgh',`,
|
||||
" 'thumb_ext' => 'webp',",
|
||||
" 'file_ext' => 'jpg',",
|
||||
" 'file_size' => 12345,",
|
||||
" 'mime_type' => 'image/jpeg',",
|
||||
" 'width' => 800,",
|
||||
" 'height' => 600,",
|
||||
" 'is_public' => true,",
|
||||
" 'is_approved' => true,",
|
||||
" 'published_at' => now()->subHours(12),",
|
||||
"]);",
|
||||
"$foreignArtwork = Artwork::create([",
|
||||
" 'user_id' => $otherUser->id,",
|
||||
` 'title' => '${foreignArtworkTitle}',`,
|
||||
` 'slug' => 'foreign-neon-city-${token}',`,
|
||||
" 'description' => 'Foreign artwork should not appear in owner smart previews.',",
|
||||
` 'blip_caption' => 'Foreign marker outsider-${token}',`,
|
||||
" 'file_name' => 'image.jpg',",
|
||||
" 'file_path' => 'uploads/artworks/image.jpg',",
|
||||
` 'hash' => 'ff${token}abcd',`,
|
||||
" 'thumb_ext' => 'webp',",
|
||||
" 'file_ext' => 'jpg',",
|
||||
" 'file_size' => 12345,",
|
||||
" 'mime_type' => 'image/jpeg',",
|
||||
" 'width' => 800,",
|
||||
" 'height' => 600,",
|
||||
" 'is_public' => true,",
|
||||
" 'is_approved' => true,",
|
||||
" 'published_at' => now()->subDay(),",
|
||||
"]);",
|
||||
"$artwork->tags()->syncWithoutDetaching([$tag->id => ['source' => 'user', 'confidence' => null]]);",
|
||||
"$artwork->tags()->syncWithoutDetaching([$styleTag->id => ['source' => 'ai', 'confidence' => 0.98]]);",
|
||||
"$artwork->tags()->syncWithoutDetaching([$colorTag->id => ['source' => 'ai', 'confidence' => 0.97]]);",
|
||||
"$secondArtwork->tags()->syncWithoutDetaching([$warmTag->id => ['source' => 'user', 'confidence' => null]]);",
|
||||
"$secondArtwork->tags()->syncWithoutDetaching([$secondColorTag->id => ['source' => 'ai', 'confidence' => 0.96]]);",
|
||||
"$relatedArtwork->tags()->syncWithoutDetaching([$tag->id => ['source' => 'user', 'confidence' => null]]);",
|
||||
"$foreignArtwork->tags()->syncWithoutDetaching([$tag->id => ['source' => 'user', 'confidence' => null]]);",
|
||||
"$foreignArtwork->tags()->syncWithoutDetaching([$styleTag->id => ['source' => 'ai', 'confidence' => 0.99]]);",
|
||||
"$foreignArtwork->tags()->syncWithoutDetaching([$colorTag->id => ['source' => 'ai', 'confidence' => 0.99]]);",
|
||||
"$studioCollection = Collection::create([",
|
||||
" 'user_id' => $user->id,",
|
||||
` 'title' => '${studioCollectionTitle}',`,
|
||||
` 'slug' => '${studioCollectionSlug}',`,
|
||||
" 'type' => 'community',",
|
||||
" 'description' => 'Seeded collection for tabbed studio and moderation coverage.',",
|
||||
" 'subtitle' => 'Prepared for Playwright edit-mode coverage',",
|
||||
" 'summary' => 'Includes two distinct theme clusters plus a related spare artwork.',",
|
||||
" 'collaboration_mode' => 'invite_only',",
|
||||
" 'allow_submissions' => true,",
|
||||
" 'allow_comments' => true,",
|
||||
" 'allow_saves' => true,",
|
||||
" 'moderation_status' => 'active',",
|
||||
" 'visibility' => 'public',",
|
||||
" 'mode' => 'manual',",
|
||||
" 'sort_mode' => 'manual',",
|
||||
" 'artworks_count' => 2,",
|
||||
" 'comments_count' => 0,",
|
||||
" 'saves_count' => 0,",
|
||||
" 'collaborators_count' => 1,",
|
||||
" 'is_featured' => true,",
|
||||
" 'featured_at' => now()->subHour(),",
|
||||
" 'views_count' => 12,",
|
||||
" 'likes_count' => 4,",
|
||||
" 'followers_count' => 3,",
|
||||
" 'shares_count' => 1,",
|
||||
" 'published_at' => now()->subDay(),",
|
||||
" 'last_activity_at' => now()->subHour(),",
|
||||
"]);",
|
||||
"DB::table('collection_members')->insert([",
|
||||
" 'collection_id' => $studioCollection->id,",
|
||||
" 'user_id' => $user->id,",
|
||||
" 'invited_by_user_id' => $user->id,",
|
||||
" 'role' => 'owner',",
|
||||
" 'status' => 'active',",
|
||||
" 'invited_at' => now()->subDay(),",
|
||||
" 'accepted_at' => now()->subDay(),",
|
||||
" 'created_at' => now()->subDay(),",
|
||||
" 'updated_at' => now()->subDay(),",
|
||||
"]);",
|
||||
"DB::table('collection_artwork')->insert([",
|
||||
" [",
|
||||
" 'collection_id' => $studioCollection->id,",
|
||||
" 'artwork_id' => $artwork->id,",
|
||||
" 'order_num' => 0,",
|
||||
" 'created_at' => now(),",
|
||||
" 'updated_at' => now(),",
|
||||
" ],",
|
||||
" [",
|
||||
" 'collection_id' => $studioCollection->id,",
|
||||
" 'artwork_id' => $secondArtwork->id,",
|
||||
" 'order_num' => 1,",
|
||||
" 'created_at' => now(),",
|
||||
" 'updated_at' => now(),",
|
||||
" ],",
|
||||
"]);",
|
||||
"$studioCollection->forceFill(['cover_artwork_id' => $artwork->id])->save();",
|
||||
"$foreignCollection = Collection::create([",
|
||||
" 'user_id' => $otherUser->id,",
|
||||
` 'title' => '${saveableCollectionTitle}',`,
|
||||
` 'slug' => '${saveableCollectionSlug}',`,
|
||||
" 'type' => 'community',",
|
||||
" 'description' => 'A foreign collection used for saved collection browser coverage.',",
|
||||
" 'collaboration_mode' => 'closed',",
|
||||
" 'allow_submissions' => false,",
|
||||
" 'allow_comments' => true,",
|
||||
" 'allow_saves' => true,",
|
||||
" 'moderation_status' => 'active',",
|
||||
" 'visibility' => 'public',",
|
||||
" 'mode' => 'manual',",
|
||||
" 'sort_mode' => 'manual',",
|
||||
" 'artworks_count' => 1,",
|
||||
" 'comments_count' => 0,",
|
||||
" 'saves_count' => 0,",
|
||||
" 'collaborators_count' => 1,",
|
||||
" 'is_featured' => false,",
|
||||
" 'views_count' => 0,",
|
||||
" 'likes_count' => 0,",
|
||||
" 'followers_count' => 0,",
|
||||
" 'shares_count' => 0,",
|
||||
" 'published_at' => now()->subDay(),",
|
||||
" 'last_activity_at' => now()->subDay(),",
|
||||
"]);",
|
||||
"DB::table('collection_artwork')->insert([",
|
||||
" 'collection_id' => $foreignCollection->id,",
|
||||
" 'artwork_id' => $foreignArtwork->id,",
|
||||
" 'order_num' => 0,",
|
||||
" 'created_at' => now(),",
|
||||
" 'updated_at' => now(),",
|
||||
"]);",
|
||||
"$foreignCollection->forceFill(['cover_artwork_id' => $foreignArtwork->id])->save();",
|
||||
"echo json_encode([",
|
||||
" 'email' => $user->email,",
|
||||
" 'password' => 'password',",
|
||||
" 'username' => $user->username,",
|
||||
" 'adminEmail' => $admin->email,",
|
||||
" 'adminPassword' => 'password',",
|
||||
" 'adminUsername' => $admin->username,",
|
||||
" 'foreignUsername' => $otherUser->username,",
|
||||
` 'artworkTitle' => '${artworkTitle}',`,
|
||||
` 'secondArtworkTitle' => '${secondArtworkTitle}',`,
|
||||
` 'relatedArtworkTitle' => '${relatedArtworkTitle}',`,
|
||||
` 'foreignArtworkTitle' => '${foreignArtworkTitle}',`,
|
||||
` 'aiTagValue' => '${aiTagValue}',`,
|
||||
` 'styleValue' => '${styleValue}',`,
|
||||
` 'colorValue' => '${colorValue}',`,
|
||||
` 'secondColorValue' => '${secondColorValue}',`,
|
||||
` 'smartCollectionTitle' => '${smartCollectionTitle}',`,
|
||||
` 'manualCollectionTitle' => '${manualCollectionTitle}',`,
|
||||
` 'studioCollectionTitle' => '${studioCollectionTitle}',`,
|
||||
" 'studioCollectionId' => $studioCollection->id,",
|
||||
` 'studioCollectionSlug' => '${studioCollectionSlug}',`,
|
||||
` 'saveableCollectionTitle' => '${saveableCollectionTitle}',`,
|
||||
` 'saveableCollectionSlug' => '${saveableCollectionSlug}',`,
|
||||
"]);",
|
||||
].join(' ')
|
||||
|
||||
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
const jsonLine = raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.reverse()
|
||||
.find((line) => line.startsWith('{') && line.endsWith('}'))
|
||||
|
||||
if (!jsonLine) {
|
||||
throw new Error(`Unable to parse collection fixture JSON from tinker output: ${raw}`)
|
||||
}
|
||||
|
||||
return JSON.parse(jsonLine) as CollectionFixture
|
||||
}
|
||||
|
||||
function resetBotProtectionState() {
|
||||
const script = [
|
||||
"use Illuminate\\Support\\Facades\\DB;",
|
||||
"use Illuminate\\Support\\Facades\\Schema;",
|
||||
"foreach (['forum_bot_logs', 'forum_bot_ip_blacklist', 'forum_bot_device_fingerprints', 'forum_bot_behavior_profiles'] as $table) {",
|
||||
" if (Schema::hasTable($table)) {",
|
||||
" DB::table($table)->delete();",
|
||||
' }',
|
||||
'}',
|
||||
"echo 'ok';",
|
||||
].join(' ')
|
||||
|
||||
execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
}
|
||||
|
||||
async function login(page: Page, credentials: { email: string; password: string }) {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
await page.goto('/login')
|
||||
|
||||
const emailField = page.locator('input[name="email"]')
|
||||
const suspiciousActivity = page.getByText('Suspicious activity detected.')
|
||||
|
||||
await emailField.waitFor({ state: 'visible', timeout: 8000 })
|
||||
await emailField.fill(credentials.email)
|
||||
await page.locator('input[name="password"]').fill(credentials.password)
|
||||
await page.getByRole('button', { name: 'Sign In' }).click()
|
||||
|
||||
try {
|
||||
await page.waitForURL((url) => url.pathname !== '/login', { timeout: 8000, waitUntil: 'domcontentloaded' })
|
||||
return
|
||||
} catch {
|
||||
if (attempt === 0 && await suspiciousActivity.isVisible().catch(() => false)) {
|
||||
resetBotProtectionState()
|
||||
continue
|
||||
}
|
||||
|
||||
throw new Error('Collection Playwright login failed before reaching an authenticated page.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function dismissCookieBanner(page: Page) {
|
||||
const essentialOnlyButton = page.getByRole('button', { name: 'Essential only' })
|
||||
const acceptAllButton = page.getByRole('button', { name: 'Accept all' })
|
||||
|
||||
if (await essentialOnlyButton.isVisible().catch(() => false)) {
|
||||
await essentialOnlyButton.click()
|
||||
return
|
||||
}
|
||||
|
||||
if (await acceptAllButton.isVisible().catch(() => false)) {
|
||||
await acceptAllButton.click()
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCollectionManagePage(page: Page) {
|
||||
await expect.poll(() => new URL(page.url()).pathname, { timeout: 30000 }).toMatch(/\/settings\/collections\/\d+$/)
|
||||
await expect(titleInput(page)).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
async function waitForPublicCollectionPage(page: Page) {
|
||||
await expect.poll(() => new URL(page.url()).pathname, { timeout: 30000 }).toMatch(/^\/@.+\/collections\//)
|
||||
}
|
||||
|
||||
async function createCollectionAndWaitForRedirect(page: Page) {
|
||||
const createPath = new URL(page.url()).pathname
|
||||
const storeResponsePromise = page.waitForResponse((response) => {
|
||||
const request = response.request()
|
||||
return request.method() === 'POST'
|
||||
&& /\/settings\/collections$/.test(new URL(response.url()).pathname)
|
||||
})
|
||||
|
||||
await page.getByRole('button', { name: 'Create Collection' }).click()
|
||||
|
||||
const storeResponse = await storeResponsePromise
|
||||
expect(storeResponse.ok()).toBeTruthy()
|
||||
|
||||
await expect.poll(() => new URL(page.url()).pathname, { timeout: 30000 }).not.toBe(createPath)
|
||||
await waitForCollectionManagePage(page)
|
||||
await expect(titleInput(page)).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
function titleInput(page: Page) {
|
||||
return page.locator('input[placeholder="Dark Fantasy Series"]')
|
||||
}
|
||||
|
||||
function subtitleInput(page: Page) {
|
||||
return page.locator('input[placeholder="A moody archive of midnight environments"]')
|
||||
}
|
||||
|
||||
function summaryInput(page: Page) {
|
||||
return page.locator('input[placeholder="Best performing sci-fi wallpapers from the last year"]')
|
||||
}
|
||||
|
||||
function artworkPickerSection(page: Page) {
|
||||
return page.locator('section').filter({ has: page.getByRole('heading', { name: /^add artworks$/i }) }).first()
|
||||
}
|
||||
|
||||
function attachedArtworksSection(page: Page) {
|
||||
return page.locator('section').filter({ has: page.getByRole('heading', { name: /arrange the showcase order/i }) }).first()
|
||||
}
|
||||
|
||||
function smartBuilderSection(page: Page) {
|
||||
return page.locator('section').filter({ has: page.getByRole('heading', { name: /define collection rules/i }) }).first()
|
||||
}
|
||||
|
||||
function smartPreviewSection(page: Page) {
|
||||
return page.locator('section').filter({ has: page.getByRole('heading', { name: /matching artworks/i }) }).first()
|
||||
}
|
||||
|
||||
function studioTab(page: Page, label: string) {
|
||||
return page.locator('button').filter({ hasText: new RegExp(label, 'i') }).first()
|
||||
}
|
||||
|
||||
async function openStudioTab(page: Page, label: string) {
|
||||
const tab = studioTab(page, label)
|
||||
await tab.scrollIntoViewIfNeeded()
|
||||
await tab.click()
|
||||
}
|
||||
|
||||
async function attachSelectedArtworks(page: Page) {
|
||||
const attachResponsePromise = page.waitForResponse((response) => {
|
||||
const request = response.request()
|
||||
return request.method() === 'POST'
|
||||
&& /\/settings\/collections\/\d+\/artworks$/.test(new URL(response.url()).pathname)
|
||||
})
|
||||
|
||||
await page.getByRole('button', { name: /add selected/i }).click()
|
||||
|
||||
const attachResponse = await attachResponsePromise
|
||||
expect(attachResponse.ok()).toBeTruthy()
|
||||
}
|
||||
|
||||
test.describe('Smart collections', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
test.setTimeout(90000)
|
||||
|
||||
let fixture: CollectionFixture
|
||||
|
||||
test.beforeAll(() => {
|
||||
fixture = seedCollectionFixture()
|
||||
})
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetBotProtectionState()
|
||||
})
|
||||
|
||||
test('owner can preview and create a smart collection', async ({ page }) => {
|
||||
await login(page, fixture)
|
||||
|
||||
await page.goto('/settings/collections/create?mode=smart')
|
||||
await dismissCookieBanner(page)
|
||||
await expect(page.getByRole('heading', { name: /create a v2 collection/i })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: /define collection rules/i })).toBeVisible()
|
||||
|
||||
await titleInput(page).fill(fixture.smartCollectionTitle)
|
||||
await subtitleInput(page).fill('A browser-tested smart showcase')
|
||||
await summaryInput(page).fill('Built end to end through Playwright')
|
||||
|
||||
const smartBuilder = smartBuilderSection(page)
|
||||
|
||||
if (await smartBuilder.getByText(/add at least one rule/i).isVisible().catch(() => false)) {
|
||||
await smartBuilder.getByRole('button', { name: /add rule/i }).click()
|
||||
}
|
||||
|
||||
await smartBuilder.locator('select').nth(0).selectOption('ai_tag')
|
||||
await smartBuilder.locator('input[placeholder="Enter a value"]').fill(fixture.aiTagValue)
|
||||
|
||||
const previewSection = smartPreviewSection(page)
|
||||
const previewResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes('/settings/collections/smart-preview') && response.request().method() === 'POST'
|
||||
)
|
||||
|
||||
await previewSection.getByRole('button', { name: /refresh preview/i }).click()
|
||||
|
||||
const previewResponse = await previewResponsePromise
|
||||
expect(previewResponse.ok()).toBeTruthy()
|
||||
|
||||
const previewRequest = previewResponse.request().postDataJSON()
|
||||
expect(previewRequest?.smart_rules_json?.rules?.[0]?.field).toBe('ai_tag')
|
||||
expect(previewRequest?.smart_rules_json?.rules?.[0]?.value).toBe(fixture.aiTagValue)
|
||||
|
||||
const previewPayload = await previewResponse.json()
|
||||
expect(previewPayload?.preview?.count).toBe(1)
|
||||
expect(previewPayload?.preview?.artworks?.data?.some((artwork: { title?: string }) => artwork.title === fixture.artworkTitle)).toBeTruthy()
|
||||
expect(previewPayload?.preview?.artworks?.data?.some((artwork: { title?: string }) => artwork.title === fixture.foreignArtworkTitle)).toBeFalsy()
|
||||
|
||||
await createCollectionAndWaitForRedirect(page)
|
||||
await expect(titleInput(page)).toHaveValue(fixture.smartCollectionTitle)
|
||||
await expect(page.getByText(/smart collection/i).first()).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: /view public page/i }).click()
|
||||
await waitForPublicCollectionPage(page)
|
||||
|
||||
await expect(page.getByRole('heading', { name: fixture.smartCollectionTitle })).toBeVisible()
|
||||
const publicArtworkHeading = page.getByRole('heading', { name: fixture.artworkTitle })
|
||||
await publicArtworkHeading.scrollIntoViewIfNeeded()
|
||||
await expect(publicArtworkHeading).toBeVisible()
|
||||
await expect(page.getByText(fixture.foreignArtworkTitle)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('owner can preview style and color smart rules without leaking foreign artworks', async ({ page }) => {
|
||||
await login(page, fixture)
|
||||
|
||||
await page.goto('/settings/collections/create?mode=smart')
|
||||
await dismissCookieBanner(page)
|
||||
await expect(page.getByRole('heading', { name: /define collection rules/i })).toBeVisible()
|
||||
|
||||
await titleInput(page).fill(`${fixture.smartCollectionTitle} Style Color`)
|
||||
|
||||
const smartBuilder = smartBuilderSection(page)
|
||||
|
||||
if (await smartBuilder.getByText(/add at least one rule/i).isVisible().catch(() => false)) {
|
||||
await smartBuilder.getByRole('button', { name: /add rule/i }).click()
|
||||
}
|
||||
|
||||
const selects = smartBuilder.locator('select')
|
||||
await selects.nth(0).selectOption('style')
|
||||
await selects.nth(2).selectOption(fixture.styleValue)
|
||||
|
||||
const previewSection = smartPreviewSection(page)
|
||||
let previewResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes('/settings/collections/smart-preview') && response.request().method() === 'POST'
|
||||
)
|
||||
|
||||
await previewSection.getByRole('button', { name: /refresh preview/i }).click()
|
||||
|
||||
let previewResponse = await previewResponsePromise
|
||||
expect(previewResponse.ok()).toBeTruthy()
|
||||
|
||||
let previewRequest = previewResponse.request().postDataJSON()
|
||||
expect(previewRequest?.smart_rules_json?.rules?.[0]?.field).toBe('style')
|
||||
expect(previewRequest?.smart_rules_json?.rules?.[0]?.value).toBe(fixture.styleValue)
|
||||
|
||||
let previewPayload = await previewResponse.json()
|
||||
expect(previewPayload?.preview?.count).toBe(1)
|
||||
expect(previewPayload?.preview?.artworks?.data?.some((artwork: { title?: string }) => artwork.title === fixture.artworkTitle)).toBeTruthy()
|
||||
expect(previewPayload?.preview?.artworks?.data?.some((artwork: { title?: string }) => artwork.title === fixture.foreignArtworkTitle)).toBeFalsy()
|
||||
|
||||
await selects.nth(0).selectOption('color')
|
||||
await selects.nth(2).selectOption(fixture.colorValue)
|
||||
|
||||
previewResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes('/settings/collections/smart-preview') && response.request().method() === 'POST'
|
||||
)
|
||||
|
||||
await previewSection.getByRole('button', { name: /refresh preview/i }).click()
|
||||
|
||||
previewResponse = await previewResponsePromise
|
||||
expect(previewResponse.ok()).toBeTruthy()
|
||||
|
||||
previewRequest = previewResponse.request().postDataJSON()
|
||||
expect(previewRequest?.smart_rules_json?.rules?.[0]?.field).toBe('color')
|
||||
expect(previewRequest?.smart_rules_json?.rules?.[0]?.value).toBe(fixture.colorValue)
|
||||
|
||||
previewPayload = await previewResponse.json()
|
||||
expect(previewPayload?.preview?.count).toBe(1)
|
||||
expect(previewPayload?.preview?.artworks?.data?.some((artwork: { title?: string }) => artwork.title === fixture.artworkTitle)).toBeTruthy()
|
||||
expect(previewPayload?.preview?.artworks?.data?.some((artwork: { title?: string }) => artwork.title === fixture.foreignArtworkTitle)).toBeFalsy()
|
||||
})
|
||||
|
||||
test('owner can create a manual collection and attach artworks', async ({ page }) => {
|
||||
await login(page, fixture)
|
||||
|
||||
await page.goto('/settings/collections/create')
|
||||
await dismissCookieBanner(page)
|
||||
await expect(page.getByRole('heading', { name: /create a v2 collection/i })).toBeVisible()
|
||||
|
||||
await titleInput(page).fill(fixture.manualCollectionTitle)
|
||||
await subtitleInput(page).fill('A hand-picked browser-tested showcase')
|
||||
await summaryInput(page).fill('Created manually and populated through the picker')
|
||||
|
||||
await createCollectionAndWaitForRedirect(page)
|
||||
await expect(titleInput(page)).toHaveValue(fixture.manualCollectionTitle)
|
||||
await expect(page.getByText(/^manual$/i).first()).toBeVisible()
|
||||
|
||||
await openStudioTab(page, 'Artworks')
|
||||
await expect(page.getByRole('heading', { name: /arrange the showcase order/i })).toBeVisible()
|
||||
|
||||
const picker = artworkPickerSection(page)
|
||||
await picker.getByRole('button').filter({ hasText: fixture.artworkTitle }).first().click()
|
||||
await attachSelectedArtworks(page)
|
||||
|
||||
const attachedSection = attachedArtworksSection(page)
|
||||
await expect(attachedSection.getByText(fixture.artworkTitle)).toBeVisible()
|
||||
|
||||
await openStudioTab(page, 'Details')
|
||||
await expect(titleInput(page)).toBeVisible()
|
||||
await expect(page.getByLabel('Cover Artwork')).toBeEnabled()
|
||||
|
||||
await page.getByLabel('Cover Artwork').selectOption({ label: fixture.artworkTitle })
|
||||
await page.getByRole('button', { name: /save changes/i }).click()
|
||||
|
||||
await expect(page.getByText('Collection updated.')).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: /view public page/i }).click()
|
||||
await waitForPublicCollectionPage(page)
|
||||
|
||||
await expect(page.getByRole('heading', { name: fixture.manualCollectionTitle })).toBeVisible()
|
||||
const manualPublicArtworkHeading = page.getByRole('heading', { name: fixture.artworkTitle })
|
||||
await manualPublicArtworkHeading.scrollIntoViewIfNeeded()
|
||||
await expect(manualPublicArtworkHeading).toBeVisible()
|
||||
})
|
||||
|
||||
test('owner can request and apply AI assistant suggestions on the manage page', async ({ page }) => {
|
||||
await login(page, fixture)
|
||||
|
||||
await page.goto('/settings/collections/create')
|
||||
await dismissCookieBanner(page)
|
||||
|
||||
await titleInput(page).fill(`${fixture.manualCollectionTitle} AI`)
|
||||
await subtitleInput(page).fill('Prepared for AI assistant coverage')
|
||||
await summaryInput(page).fill('Initial summary before AI suggestions are applied')
|
||||
|
||||
await createCollectionAndWaitForRedirect(page)
|
||||
|
||||
await openStudioTab(page, 'Artworks')
|
||||
await expect(page.getByRole('heading', { name: /arrange the showcase order/i })).toBeVisible()
|
||||
|
||||
const picker = artworkPickerSection(page)
|
||||
await picker.getByRole('button').filter({ hasText: fixture.artworkTitle }).first().click()
|
||||
await attachSelectedArtworks(page)
|
||||
await expect(attachedArtworksSection(page).getByText(fixture.artworkTitle)).toBeVisible()
|
||||
|
||||
await openStudioTab(page, 'AI Suggestions')
|
||||
await expect(page.getByRole('heading', { name: /review-only suggestions/i })).toBeVisible()
|
||||
|
||||
const aiSection = page.locator('section').filter({ hasText: /AI Assistant/ }).filter({ has: page.getByRole('button', { name: /suggest title/i }) }).first()
|
||||
await aiSection.scrollIntoViewIfNeeded()
|
||||
await expect(aiSection.getByRole('button', { name: /suggest title/i })).toBeVisible()
|
||||
|
||||
let aiResponsePromise = page.waitForResponse((response) => response.url().includes('/ai/suggest-title') && response.request().method() === 'POST')
|
||||
await aiSection.getByRole('button', { name: /suggest title/i }).click()
|
||||
let aiResponse = await aiResponsePromise
|
||||
expect(aiResponse.ok()).toBeTruthy()
|
||||
await expect(aiSection.getByRole('button', { name: /use title/i })).toBeVisible()
|
||||
await aiSection.getByRole('button', { name: /use title/i }).click()
|
||||
|
||||
aiResponsePromise = page.waitForResponse((response) => response.url().includes('/ai/suggest-summary') && response.request().method() === 'POST')
|
||||
await aiSection.getByRole('button', { name: /suggest summary/i }).click()
|
||||
aiResponse = await aiResponsePromise
|
||||
expect(aiResponse.ok()).toBeTruthy()
|
||||
await expect(aiSection.getByRole('button', { name: /use summary/i })).toBeVisible()
|
||||
await aiSection.getByRole('button', { name: /use summary/i }).click()
|
||||
|
||||
aiResponsePromise = page.waitForResponse((response) => response.url().includes('/ai/suggest-cover') && response.request().method() === 'POST')
|
||||
await aiSection.getByRole('button', { name: /suggest cover/i }).click()
|
||||
aiResponse = await aiResponsePromise
|
||||
expect(aiResponse.ok()).toBeTruthy()
|
||||
await expect(aiSection.getByRole('button', { name: /use cover/i })).toBeVisible()
|
||||
await aiSection.getByRole('button', { name: /use cover/i }).click()
|
||||
|
||||
await openStudioTab(page, 'Details')
|
||||
await expect(titleInput(page)).not.toHaveValue(`${fixture.manualCollectionTitle} AI`)
|
||||
await expect(summaryInput(page)).not.toHaveValue('Initial summary before AI suggestions are applied')
|
||||
|
||||
await page.getByRole('button', { name: /save changes/i }).click()
|
||||
await expect(page.getByText('Collection updated.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('owner can navigate the tabbed studio and request split and merge AI suggestions', async ({ page }) => {
|
||||
await login(page, fixture)
|
||||
|
||||
await page.goto(`/settings/collections/${fixture.studioCollectionId}`)
|
||||
await dismissCookieBanner(page)
|
||||
await waitForCollectionManagePage(page)
|
||||
|
||||
await expect(studioTab(page, 'Details')).toBeVisible()
|
||||
await expect(studioTab(page, 'Artworks')).toBeVisible()
|
||||
await expect(studioTab(page, 'Members')).toBeVisible()
|
||||
await expect(studioTab(page, 'Submissions')).toBeVisible()
|
||||
await expect(studioTab(page, 'Settings')).toBeVisible()
|
||||
await expect(studioTab(page, 'Discussion')).toBeVisible()
|
||||
await expect(studioTab(page, 'AI Suggestions')).toBeVisible()
|
||||
|
||||
await openStudioTab(page, 'Artworks')
|
||||
await expect(page.getByRole('heading', { name: /arrange the showcase order/i })).toBeVisible()
|
||||
await expect(page.getByText(fixture.artworkTitle)).toBeVisible()
|
||||
await expect(page.getByText(fixture.secondArtworkTitle)).toBeVisible()
|
||||
|
||||
await openStudioTab(page, 'Members')
|
||||
const membersSection = page.locator('section').filter({ has: page.getByRole('heading', { name: /team access/i }) }).first()
|
||||
await expect(membersSection.getByRole('heading', { name: /team access/i })).toBeVisible()
|
||||
await expect(membersSection.getByText(/owner/i)).toBeVisible()
|
||||
await expect(membersSection.getByText(/e2e collections user/i)).toBeVisible()
|
||||
|
||||
await openStudioTab(page, 'Submissions')
|
||||
await expect(page.getByRole('heading', { name: /incoming artworks/i })).toBeVisible()
|
||||
await expect(page.getByText(/no submissions yet\./i)).toBeVisible()
|
||||
|
||||
await openStudioTab(page, 'Discussion')
|
||||
await expect(page.getByRole('heading', { name: /recent comments/i })).toBeVisible()
|
||||
await expect(page.getByText(/no comments yet\./i)).toBeVisible()
|
||||
|
||||
await openStudioTab(page, 'Settings')
|
||||
await expect(page.getByText(/use the details tab for metadata and publishing options/i)).toBeVisible()
|
||||
|
||||
await openStudioTab(page, 'AI Suggestions')
|
||||
await expect(page.getByRole('heading', { name: /review-only suggestions/i })).toBeVisible()
|
||||
|
||||
let aiResponsePromise = page.waitForResponse((response) => response.url().includes('/ai/suggest-split-themes') && response.request().method() === 'POST')
|
||||
await page.getByRole('button', { name: /suggest split/i }).click()
|
||||
let aiResponse = await aiResponsePromise
|
||||
expect(aiResponse.ok()).toBeTruthy()
|
||||
const splitPayload = await aiResponse.json()
|
||||
expect(Array.isArray(splitPayload?.suggestion?.splits)).toBeTruthy()
|
||||
expect(splitPayload.suggestion.splits.some((split: { title?: string }) => /digital painting/i.test(split.title || ''))).toBeTruthy()
|
||||
expect(splitPayload.suggestion.splits.some((split: { title?: string }) => /orange tones/i.test(split.title || ''))).toBeTruthy()
|
||||
await expect(page.getByText(/split suggestion/i)).toBeVisible()
|
||||
|
||||
aiResponsePromise = page.waitForResponse((response) => response.url().includes('/ai/suggest-merge-idea') && response.request().method() === 'POST')
|
||||
await page.getByRole('button', { name: /suggest merge idea/i }).click()
|
||||
aiResponse = await aiResponsePromise
|
||||
expect(aiResponse.ok()).toBeTruthy()
|
||||
const mergePayload = await aiResponse.json()
|
||||
expect(mergePayload?.suggestion?.idea?.title).toBeTruthy()
|
||||
expect(mergePayload?.suggestion?.idea?.summary).toBeTruthy()
|
||||
await expect(page.getByText(mergePayload.suggestion.idea.title)).toBeVisible()
|
||||
})
|
||||
|
||||
test('admin can moderate the seeded collection from the moderation tab', async ({ page }) => {
|
||||
await login(page, { email: fixture.adminEmail, password: fixture.adminPassword })
|
||||
|
||||
await page.goto(`/settings/collections/${fixture.studioCollectionId}`)
|
||||
await dismissCookieBanner(page)
|
||||
await waitForCollectionManagePage(page)
|
||||
|
||||
await expect(studioTab(page, 'Moderation')).toBeVisible()
|
||||
await openStudioTab(page, 'Moderation')
|
||||
await expect(page.getByRole('heading', { name: /admin controls/i })).toBeVisible()
|
||||
|
||||
let moderationResponsePromise = page.waitForResponse((response) => /\/api\/admin\/collections\/\d+\/moderation$/.test(new URL(response.url()).pathname) && response.request().method() === 'PATCH')
|
||||
await page.locator('select').filter({ has: page.locator('option[value="restricted"]') }).first().selectOption('restricted')
|
||||
let moderationResponse = await moderationResponsePromise
|
||||
expect(moderationResponse.ok()).toBeTruthy()
|
||||
await expect(page.getByText(/moderation state updated to restricted\./i)).toBeVisible()
|
||||
await expect(page.getByText(/current state:\s*restricted/i)).toBeVisible()
|
||||
|
||||
const allowCommentsToggle = page.locator('label').filter({ hasText: 'Allow comments' }).locator('input[type="checkbox"]')
|
||||
await expect(allowCommentsToggle).toBeChecked()
|
||||
const interactionsResponsePromise = page.waitForResponse((response) => /\/api\/admin\/collections\/\d+\/interactions$/.test(new URL(response.url()).pathname) && response.request().method() === 'PATCH')
|
||||
await allowCommentsToggle.uncheck()
|
||||
const interactionsResponse = await interactionsResponsePromise
|
||||
expect(interactionsResponse.ok()).toBeTruthy()
|
||||
await expect(page.getByText(/collection interaction settings updated\./i)).toBeVisible()
|
||||
await expect(allowCommentsToggle).not.toBeChecked()
|
||||
|
||||
const unfeatureResponsePromise = page.waitForResponse((response) => /\/api\/admin\/collections\/\d+\/unfeature$/.test(new URL(response.url()).pathname) && response.request().method() === 'POST')
|
||||
await page.getByRole('button', { name: /remove featured placement/i }).click()
|
||||
const unfeatureResponse = await unfeatureResponsePromise
|
||||
expect(unfeatureResponse.ok()).toBeTruthy()
|
||||
await expect(page.getByText(/collection removed from featured placement by moderation action\./i)).toBeVisible()
|
||||
|
||||
await openStudioTab(page, 'Details')
|
||||
await expect(page.getByText(/^featured$/i)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('viewer can save a public collection and see it in saved collections', async ({ page }) => {
|
||||
await login(page, fixture)
|
||||
|
||||
await page.goto(`/@${fixture.foreignUsername}/collections/${fixture.saveableCollectionSlug}`)
|
||||
await dismissCookieBanner(page)
|
||||
await expect(page.getByRole('heading', { name: fixture.saveableCollectionTitle })).toBeVisible()
|
||||
|
||||
const saveResponsePromise = page.waitForResponse((response) => response.url().includes('/collections/') && /\/save$/.test(new URL(response.url()).pathname) && response.request().method() === 'POST')
|
||||
await page.getByRole('button', { name: /^save collection$/i }).click()
|
||||
const saveResponse = await saveResponsePromise
|
||||
expect(saveResponse.ok()).toBeTruthy()
|
||||
await expect(page.locator('button').filter({ hasText: /^saved$/i }).first()).toBeVisible()
|
||||
|
||||
await page.goto('/me/saved/collections')
|
||||
await expect(page.getByRole('heading', { name: /saved collections/i })).toBeVisible()
|
||||
await expect(page.getByText(fixture.saveableCollectionTitle)).toBeVisible()
|
||||
})
|
||||
})
|
||||
35
tests/e2e/global-setup.ts
Normal file
35
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()
|
||||
}
|
||||
@@ -8,6 +8,16 @@ type Fixture = {
|
||||
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`
|
||||
@@ -68,7 +78,95 @@ function seedMessagingFixture(): Fixture {
|
||||
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)
|
||||
@@ -76,6 +174,15 @@ async function login(page: Parameters<typeof test>[0]['page'], fixture: Fixture)
|
||||
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' })
|
||||
|
||||
@@ -110,4 +217,59 @@ test.describe('Messaging UI', () => {
|
||||
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)
|
||||
}
|
||||
|
||||
230
tests/e2e/nova-cards-mobile-editor.spec.ts
Normal file
230
tests/e2e/nova-cards-mobile-editor.spec.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
type NovaCardFixture = {
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
}
|
||||
|
||||
const VITE_MANIFEST_PATH = path.join(process.cwd(), 'public', 'build', 'manifest.json')
|
||||
const FRONTEND_SOURCES = [
|
||||
path.join(process.cwd(), 'resources', 'js', 'Pages', 'Studio', 'StudioCardEditor.jsx'),
|
||||
path.join(process.cwd(), 'resources', 'js', 'components', 'nova-cards', 'NovaCardCanvasPreview.jsx'),
|
||||
]
|
||||
|
||||
function needsFrontendBuild() {
|
||||
if (!existsSync(VITE_MANIFEST_PATH)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const manifestUpdatedAt = statSync(VITE_MANIFEST_PATH).mtimeMs
|
||||
|
||||
return FRONTEND_SOURCES.some((filePath) => existsSync(filePath) && statSync(filePath).mtimeMs > manifestUpdatedAt)
|
||||
}
|
||||
|
||||
function ensureCompiledAssets() {
|
||||
if (!needsFrontendBuild()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
execFileSync('cmd.exe', ['/c', 'npm', 'run', 'build'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
execFileSync('npm', ['run', 'build'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
}
|
||||
|
||||
function ensureNovaCardSeedData() {
|
||||
execFileSync('php', ['artisan', 'db:seed', '--class=Database\\Seeders\\NovaCardCategorySeeder', '--no-interaction'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
execFileSync('php', ['artisan', 'db:seed', '--class=Database\\Seeders\\NovaCardTemplateSeeder', '--no-interaction'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
}
|
||||
|
||||
function seedNovaCardFixture(): NovaCardFixture {
|
||||
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
|
||||
const email = `e2e-nova-cards-${token}@example.test`
|
||||
const username = `e2enc${token}`.slice(0, 20)
|
||||
|
||||
const script = [
|
||||
'use App\\Models\\User;',
|
||||
'use Illuminate\\Support\\Facades\\Hash;',
|
||||
`$user = User::updateOrCreate(['email' => '${email}'], [`,
|
||||
" 'name' => 'E2E Nova Cards User',",
|
||||
` 'username' => '${username}',`,
|
||||
" 'onboarding_step' => 'complete',",
|
||||
" 'email_verified_at' => now(),",
|
||||
" 'is_active' => 1,",
|
||||
" 'password' => Hash::make('password'),",
|
||||
']);',
|
||||
"echo json_encode(['email' => $user->email, 'password' => 'password', 'username' => $user->username]);",
|
||||
].join(' ')
|
||||
|
||||
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
const lines = raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{') && line.endsWith('}'))
|
||||
if (!jsonLine) {
|
||||
throw new Error(`Unable to parse fixture JSON from tinker output: ${raw}`)
|
||||
}
|
||||
|
||||
return JSON.parse(jsonLine) as NovaCardFixture
|
||||
}
|
||||
|
||||
function resetBotProtectionState() {
|
||||
const script = [
|
||||
'use Illuminate\\Support\\Facades\\DB;',
|
||||
'use Illuminate\\Support\\Facades\\Schema;',
|
||||
"foreach (['forum_bot_logs', 'forum_bot_ip_blacklist', 'forum_bot_device_fingerprints', 'forum_bot_behavior_profiles'] as $table) {",
|
||||
' if (Schema::hasTable($table)) {',
|
||||
' DB::table($table)->delete();',
|
||||
' }',
|
||||
'}',
|
||||
"echo 'ok';",
|
||||
].join(' ')
|
||||
|
||||
execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
}
|
||||
|
||||
async function login(page: Page, fixture: NovaCardFixture) {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
await page.goto('/login')
|
||||
const emailField = page.locator('input[name="email"]')
|
||||
const internalServerError = page.getByText('Internal Server Error')
|
||||
|
||||
await Promise.race([
|
||||
emailField.waitFor({ state: 'visible', timeout: 8000 }),
|
||||
internalServerError.waitFor({ state: 'visible', timeout: 8000 }),
|
||||
])
|
||||
|
||||
if ((await internalServerError.isVisible().catch(() => false)) && !(await emailField.isVisible().catch(() => false))) {
|
||||
throw new Error('Nova Cards mobile editor login failed because the login page returned an internal server error.')
|
||||
}
|
||||
|
||||
await emailField.fill(fixture.email)
|
||||
await page.locator('input[name="password"]').fill(fixture.password)
|
||||
await page.getByRole('button', { name: 'Sign In' }).click()
|
||||
|
||||
try {
|
||||
await page.waitForURL((url) => url.pathname !== '/login', { timeout: 8000, waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('button', { name: /E2E Nova Cards User/i })).toBeVisible()
|
||||
return
|
||||
} catch {
|
||||
const suspiciousActivity = page.getByText('Suspicious activity detected.')
|
||||
if (attempt === 0 && (await suspiciousActivity.isVisible().catch(() => false))) {
|
||||
resetBotProtectionState()
|
||||
continue
|
||||
}
|
||||
|
||||
throw new Error('Nova Cards mobile editor login failed before reaching an authenticated page.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function expectNoHorizontalOverflow(page: Page) {
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}))
|
||||
|
||||
expect(
|
||||
dimensions.scrollWidth,
|
||||
`Expected no horizontal overflow, but scrollWidth=${dimensions.scrollWidth} and clientWidth=${dimensions.clientWidth}`
|
||||
).toBeLessThanOrEqual(dimensions.clientWidth + 1)
|
||||
}
|
||||
|
||||
function fieldInsideLabel(page: Page, labelText: string, element: 'input' | 'textarea' | 'select' = 'input') {
|
||||
return page.locator('label', { hasText: labelText }).locator(element).first()
|
||||
}
|
||||
|
||||
test.describe('Nova Cards mobile editor', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
test.use({ viewport: { width: 390, height: 844 } })
|
||||
|
||||
let fixture: NovaCardFixture
|
||||
|
||||
test.beforeAll(() => {
|
||||
ensureCompiledAssets()
|
||||
ensureNovaCardSeedData()
|
||||
fixture = seedNovaCardFixture()
|
||||
})
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetBotProtectionState()
|
||||
})
|
||||
|
||||
test('step navigation exposes the mobile editor flow', async ({ page }) => {
|
||||
await login(page, fixture)
|
||||
await page.goto('/studio/cards/create', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Structured card creation with live preview and autosave.' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled({ timeout: 15000 })
|
||||
await expect(page.getByText('Step 1 / 6')).toBeVisible()
|
||||
await expect(page.getByText('Choose the canvas shape and basic direction.')).toBeVisible()
|
||||
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible()
|
||||
await expect(fieldInsideLabel(page, 'Quote text', 'textarea')).toBeHidden()
|
||||
|
||||
await expectNoHorizontalOverflow(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Next', exact: true }).click()
|
||||
await expect(page.getByText('Step 2 / 6')).toBeVisible()
|
||||
await expect(page.getByText('Pick the visual foundation for the card.')).toBeVisible()
|
||||
await expect(page.getByRole('combobox', { name: 'Overlay' })).toBeVisible()
|
||||
await expect(page.getByRole('combobox', { name: 'Focal position' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Next', exact: true }).click()
|
||||
await expect(page.getByText('Step 3 / 6')).toBeVisible()
|
||||
await expect(fieldInsideLabel(page, 'Quote text', 'textarea')).toBeVisible()
|
||||
await fieldInsideLabel(page, 'Title').fill('Mobile editor flow card')
|
||||
await fieldInsideLabel(page, 'Quote text', 'textarea').fill('Mobile step preview quote')
|
||||
await fieldInsideLabel(page, 'Author').fill('Playwright')
|
||||
|
||||
await page.getByRole('button', { name: 'Next', exact: true }).click()
|
||||
await expect(page.getByText('Step 4 / 6')).toBeVisible()
|
||||
await expect(page.getByRole('combobox', { name: 'Text width' })).toBeVisible()
|
||||
await expect(page.getByRole('combobox', { name: 'Line height' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Next', exact: true }).click()
|
||||
await expect(page.getByText('Step 5 / 6')).toBeVisible()
|
||||
await expect(page.getByText('Check the live composition before publish.')).toBeVisible()
|
||||
await expect(page.getByText('Mobile step preview quote').first()).toBeVisible()
|
||||
await expect(page.getByText('Playwright').first()).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Next', exact: true }).click()
|
||||
await expect(page.getByText('Step 6 / 6')).toBeVisible()
|
||||
await expect(page.getByRole('combobox', { name: 'Visibility' })).toBeVisible()
|
||||
await expect(page.getByText('Draft actions')).toBeVisible()
|
||||
await expect(fieldInsideLabel(page, 'Quote text', 'textarea')).toBeHidden()
|
||||
|
||||
await page.getByRole('button', { name: 'Back', exact: true }).click()
|
||||
await expect(page.getByText('Step 5 / 6')).toBeVisible()
|
||||
await expect(page.getByText('Mobile step preview quote').first()).toBeVisible()
|
||||
await expectNoHorizontalOverflow(page)
|
||||
})
|
||||
})
|
||||
255
tests/e2e/nova-cards-reporting.spec.ts
Normal file
255
tests/e2e/nova-cards-reporting.spec.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
type ReportingFixture = {
|
||||
viewerEmail: string
|
||||
viewerPassword: string
|
||||
cardId: number
|
||||
cardPath: string
|
||||
}
|
||||
|
||||
const VITE_MANIFEST_PATH = path.join(process.cwd(), 'public', 'build', 'manifest.json')
|
||||
|
||||
function ensureCompiledAssets() {
|
||||
if (existsSync(VITE_MANIFEST_PATH)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
execFileSync('cmd.exe', ['/c', 'npm', 'run', 'build'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
execFileSync('npm', ['run', 'build'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
}
|
||||
|
||||
function ensureNovaCardSeedData() {
|
||||
execFileSync('php', ['artisan', 'db:seed', '--class=Database\\Seeders\\NovaCardCategorySeeder', '--no-interaction'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
execFileSync('php', ['artisan', 'db:seed', '--class=Database\\Seeders\\NovaCardTemplateSeeder', '--no-interaction'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
}
|
||||
|
||||
function resetBotProtectionState() {
|
||||
const script = [
|
||||
'use Illuminate\\Support\\Facades\\DB;',
|
||||
'use Illuminate\\Support\\Facades\\Schema;',
|
||||
"foreach (['forum_bot_logs', 'forum_bot_ip_blacklist', 'forum_bot_device_fingerprints', 'forum_bot_behavior_profiles'] as $table) {",
|
||||
' if (Schema::hasTable($table)) {',
|
||||
' DB::table($table)->delete();',
|
||||
' }',
|
||||
'}',
|
||||
"echo 'ok';",
|
||||
].join(' ')
|
||||
|
||||
execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
}
|
||||
|
||||
function seedReportingFixture(): ReportingFixture {
|
||||
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
|
||||
const viewerEmail = `e2e-nova-report-${token}@example.test`
|
||||
const viewerUsername = `nrviewer${token}`.slice(0, 20)
|
||||
const creatorEmail = `e2e-nova-report-creator-${token}@example.test`
|
||||
const creatorUsername = `nrcreator${token}`.slice(0, 20)
|
||||
const cardSlug = `nova-report-card-${token}`
|
||||
|
||||
const script = [
|
||||
'use App\\Models\\NovaCard;',
|
||||
'use App\\Models\\NovaCardCategory;',
|
||||
'use App\\Models\\NovaCardTemplate;',
|
||||
'use App\\Models\\User;',
|
||||
'use Illuminate\\Support\\Facades\\Hash;',
|
||||
`$viewer = User::updateOrCreate(['email' => '${viewerEmail}'], [`,
|
||||
" 'name' => 'E2E Nova Report Viewer',",
|
||||
` 'username' => '${viewerUsername}',`,
|
||||
" 'onboarding_step' => 'complete',",
|
||||
" 'email_verified_at' => now(),",
|
||||
" 'is_active' => 1,",
|
||||
" 'password' => Hash::make('password'),",
|
||||
']);',
|
||||
`$creator = User::updateOrCreate(['email' => '${creatorEmail}'], [`,
|
||||
" 'name' => 'E2E Nova Report Creator',",
|
||||
` 'username' => '${creatorUsername}',`,
|
||||
" 'onboarding_step' => 'complete',",
|
||||
" 'email_verified_at' => now(),",
|
||||
" 'is_active' => 1,",
|
||||
" 'password' => Hash::make('password'),",
|
||||
']);',
|
||||
'$category = NovaCardCategory::query()->orderBy("id")->first();',
|
||||
'$template = NovaCardTemplate::query()->orderBy("id")->first();',
|
||||
'$card = NovaCard::query()->create([',
|
||||
' "user_id" => $creator->id,',
|
||||
' "category_id" => $category->id,',
|
||||
' "template_id" => $template->id,',
|
||||
" 'title' => 'Playwright reportable card',",
|
||||
` 'slug' => '${cardSlug}',`,
|
||||
" 'quote_text' => 'Reported via Playwright.',",
|
||||
" 'format' => NovaCard::FORMAT_SQUARE,",
|
||||
' "project_json" => [',
|
||||
' "content" => ["title" => "Playwright reportable card", "quote_text" => "Reported via Playwright."],',
|
||||
' "layout" => ["layout" => "quote_heavy", "position" => "center", "alignment" => "center", "padding" => "comfortable", "max_width" => "balanced"],',
|
||||
' "typography" => ["font_preset" => "modern-sans", "text_color" => "#ffffff", "accent_color" => "#e0f2fe", "quote_size" => 72, "author_size" => 28, "letter_spacing" => 0, "line_height" => 1.2, "shadow_preset" => "soft"],',
|
||||
' "background" => ["type" => "gradient", "gradient_preset" => "midnight-nova", "gradient_colors" => ["#0f172a", "#1d4ed8"], "overlay_style" => "dark-soft", "focal_position" => "center", "blur_level" => 0, "opacity" => 50],',
|
||||
' "decorations" => [],',
|
||||
' ],',
|
||||
" 'render_version' => 2,",
|
||||
" 'schema_version' => 2,",
|
||||
" 'background_type' => 'gradient',",
|
||||
" 'visibility' => NovaCard::VISIBILITY_PUBLIC,",
|
||||
" 'status' => NovaCard::STATUS_PUBLISHED,",
|
||||
" 'moderation_status' => NovaCard::MOD_APPROVED,",
|
||||
" 'allow_download' => true,",
|
||||
" 'allow_remix' => true,",
|
||||
" 'published_at' => now()->subMinute(),",
|
||||
']);',
|
||||
'echo json_encode([',
|
||||
' "viewerEmail" => $viewer->email,',
|
||||
' "viewerPassword" => "password",',
|
||||
' "cardId" => $card->id,',
|
||||
' "cardPath" => "/cards/{$card->slug}-{$card->id}",',
|
||||
']);',
|
||||
].join(' ')
|
||||
|
||||
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
const jsonLine = raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.reverse()
|
||||
.find((line) => line.startsWith('{') && line.endsWith('}'))
|
||||
|
||||
if (!jsonLine) {
|
||||
throw new Error(`Unable to parse Nova Card reporting fixture JSON: ${raw}`)
|
||||
}
|
||||
|
||||
return JSON.parse(jsonLine) as ReportingFixture
|
||||
}
|
||||
|
||||
function reportCount(cardId: number, viewerEmail: string): number {
|
||||
const script = [
|
||||
'use App\\Models\\Report;',
|
||||
'use App\\Models\\User;',
|
||||
`$viewer = User::query()->where('email', '${viewerEmail}')->first();`,
|
||||
'if (! $viewer) { echo 0; return; }',
|
||||
`echo Report::query()->where('reporter_id', $viewer->id)->where('target_type', 'nova_card')->where('target_id', ${cardId})->count();`,
|
||||
].join(' ')
|
||||
|
||||
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
const parsed = Number.parseInt(raw.trim().split(/\s+/).pop() || '0', 10)
|
||||
return Number.isNaN(parsed) ? 0 : parsed
|
||||
}
|
||||
|
||||
async function login(page: Page, fixture: ReportingFixture) {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
await page.goto('/login')
|
||||
const emailField = page.locator('input[name="email"]')
|
||||
const internalServerError = page.getByText('Internal Server Error')
|
||||
|
||||
await Promise.race([
|
||||
emailField.waitFor({ state: 'visible', timeout: 8000 }),
|
||||
internalServerError.waitFor({ state: 'visible', timeout: 8000 }),
|
||||
])
|
||||
|
||||
if ((await internalServerError.isVisible().catch(() => false)) && !(await emailField.isVisible().catch(() => false))) {
|
||||
throw new Error('Nova Cards reporting login failed because the login page returned an internal server error.')
|
||||
}
|
||||
|
||||
await emailField.fill(fixture.viewerEmail)
|
||||
await page.locator('input[name="password"]').fill(fixture.viewerPassword)
|
||||
await page.getByRole('button', { name: 'Sign In' }).click()
|
||||
|
||||
try {
|
||||
await page.waitForURL((url) => url.pathname !== '/login', { timeout: 8000, waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('button', { name: /E2E Nova Report Viewer/i })).toBeVisible()
|
||||
return
|
||||
} catch {
|
||||
const suspiciousActivity = page.getByText('Suspicious activity detected.')
|
||||
if (attempt === 0 && (await suspiciousActivity.isVisible().catch(() => false))) {
|
||||
resetBotProtectionState()
|
||||
continue
|
||||
}
|
||||
|
||||
throw new Error('Nova Cards reporting login failed before reaching an authenticated page.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Nova Cards reporting', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: ReportingFixture
|
||||
|
||||
test.beforeAll(() => {
|
||||
ensureCompiledAssets()
|
||||
ensureNovaCardSeedData()
|
||||
fixture = seedReportingFixture()
|
||||
})
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetBotProtectionState()
|
||||
})
|
||||
|
||||
test('authenticated viewers can submit a report from the public card page', async ({ page }) => {
|
||||
await login(page, fixture)
|
||||
await page.goto(fixture.cardPath, { waitUntil: 'domcontentloaded' })
|
||||
|
||||
await expect(page.locator('[data-card-report]')).toBeVisible()
|
||||
|
||||
const dialogExpectations = [
|
||||
{ type: 'prompt', message: 'Why are you reporting this card?', value: 'Playwright report reason' },
|
||||
{ type: 'prompt', message: 'Add extra details for moderators (optional)', value: 'Playwright detail for moderation context.' },
|
||||
{ type: 'alert', message: 'Report submitted. Thank you.' },
|
||||
]
|
||||
|
||||
let handledDialogs = 0
|
||||
|
||||
const dialogHandler = async (dialog: Parameters<Page['on']>[1] extends (event: infer T) => any ? T : never) => {
|
||||
const expected = dialogExpectations[handledDialogs]
|
||||
expect(expected).toBeTruthy()
|
||||
expect(dialog.type()).toBe(expected.type)
|
||||
expect(dialog.message()).toBe(expected.message)
|
||||
|
||||
if (expected.type === 'prompt') {
|
||||
await dialog.accept(expected.value)
|
||||
} else {
|
||||
await dialog.accept()
|
||||
}
|
||||
|
||||
handledDialogs += 1
|
||||
}
|
||||
|
||||
page.on('dialog', dialogHandler)
|
||||
|
||||
await page.locator('[data-card-report]').click()
|
||||
|
||||
await expect.poll(() => handledDialogs, { timeout: 10000 }).toBe(3)
|
||||
await expect.poll(() => reportCount(fixture.cardId, fixture.viewerEmail), { timeout: 10000 }).toBe(1)
|
||||
|
||||
page.off('dialog', dialogHandler)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user