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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user