Files
SkinbaseNova/tests/e2e/collections-smart.spec.ts
2026-03-28 19:15:39 +01:00

789 lines
35 KiB
TypeScript

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()
})
})