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