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