import { test, expect } from '@playwright/test' import { execFileSync } from 'node:child_process' type Fixture = { email: string password: string conversation_id: number 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` const peerEmail = `e2e-messages-peer-${token}@example.test` const ownerUsername = `e2eo${token}`.slice(0, 20) const peerUsername = `e2ep${token}`.slice(0, 20) const script = [ "use App\\Models\\User;", "use App\\Models\\Conversation;", "use App\\Models\\ConversationParticipant;", "use App\\Models\\Message;", "use Illuminate\\Support\\Facades\\Hash;", "use Illuminate\\Support\\Carbon;", `$owner = User::updateOrCreate(['email' => '${ownerEmail}'], [`, " 'name' => 'E2E Owner',", ` 'username' => '${ownerUsername}',`, " 'onboarding_step' => 'complete',", " 'email_verified_at' => now(),", " 'is_active' => 1,", " 'password' => Hash::make('password'),", "]);", `$peer = User::updateOrCreate(['email' => '${peerEmail}'], [`, " 'name' => 'E2E Peer',", ` 'username' => '${peerUsername}',`, " 'onboarding_step' => 'complete',", " 'email_verified_at' => now(),", " 'is_active' => 1,", " 'password' => Hash::make('password'),", "]);", "$conversation = Conversation::create(['type' => 'direct', 'created_by' => $owner->id]);", "ConversationParticipant::insert([", " ['conversation_id' => $conversation->id, 'user_id' => $owner->id, 'role' => 'admin', 'joined_at' => now(), 'last_read_at' => null],", " ['conversation_id' => $conversation->id, 'user_id' => $peer->id, 'role' => 'member', 'joined_at' => now(), 'last_read_at' => now()],", "]);", "$first = Message::create(['conversation_id' => $conversation->id, 'sender_id' => $peer->id, 'body' => 'Seed hello']);", "$last = Message::create(['conversation_id' => $conversation->id, 'sender_id' => $owner->id, 'body' => 'Seed latest from owner']);", "$conversation->update(['last_message_at' => $last->created_at]);", "ConversationParticipant::where('conversation_id', $conversation->id)->where('user_id', $peer->id)->update(['last_read_at' => Carbon::parse($last->created_at)->addSeconds(15)]);", "echo json_encode(['email' => $owner->email, 'password' => 'password', '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 fixture JSON from tinker output: ${raw}`) } 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[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) await page.getByRole('button', { name: 'Sign In' }).click() await page.waitForURL(/\/dashboard/) } async function waitForRealtimeConnection(page: Parameters[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' }) let fixture: Fixture test.beforeAll(() => { fixture = seedMessagingFixture() }) test('restores draft from localStorage on reload', async ({ page }) => { await login(page, fixture) await page.goto(`/messages/${fixture.conversation_id}`) const textarea = page.locator('textarea[placeholder^="Write a message"]') const draft = 'E2E draft should survive reload' await textarea.fill(draft) await expect(textarea).toHaveValue(draft) await page.reload({ waitUntil: 'domcontentloaded' }) await expect(textarea).toHaveValue(draft) const stored = await page.evaluate((key) => window.localStorage.getItem(key), `nova_draft_${fixture.conversation_id}`) expect(stored).toBe(draft) }) test('shows seen indicator on latest direct message from current user', async ({ page }) => { await login(page, fixture) await page.goto(`/messages/${fixture.conversation_id}`) 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) }