import { type Page } from '@playwright/test'; import { test, expect, type Client } from '../../fixtures/multi-client'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; import { ChatRoomPage } from '../../pages/chat-room.page'; import { ChatMessagesPage, type ChatDropFilePayload } from '../../pages/chat-messages.page'; const UPLOADER_LOCAL_MISSING_TEXT = 'Your original upload could not be found on this device'; test.describe('Local attachment persistence', () => { test.describe.configure({ timeout: 180_000 }); test('remembers sent image and file across a page reload with no peer connected', async ({ createClient }) => { const scenario = await createSingleClientChatScenario(createClient); const serverName = `Persist Server ${uniqueName('persist')}`; const imageName = `${uniqueName('diagram')}.svg`; const fileName = `${uniqueName('notes')}.txt`; const imageCaption = `Persisted image ${uniqueName('caption')}`; const fileCaption = `Persisted file ${uniqueName('caption')}`; const imageAttachment = createTextFilePayload(imageName, 'image/svg+xml', buildMockSvgMarkup(imageName)); const fileAttachment = createTextFilePayload(fileName, 'text/plain', `Attachment body for ${fileName}`); await test.step('Create a server and open its room', async () => { await createServerAndOpenRoom(scenario.search, scenario.client.page, serverName, 'Local attachment persistence server'); }); await test.step('Send an image and a generic file attachment', async () => { await scenario.messages.attachFiles([imageAttachment]); await scenario.messages.sendMessage(imageCaption); await scenario.messages.expectMessageImageLoaded(imageName); await scenario.messages.attachFiles([fileAttachment]); await scenario.messages.sendMessage(fileCaption); await expect(scenario.client.page.getByText(fileName, { exact: false })).toBeVisible({ timeout: 20_000 }); }); await test.step('Wait for both attachments to be persisted locally', async () => { await waitForPersistedAttachmentBytes(scenario.client.page, 2); await waitForPersistedAttachmentRecords(scenario.client.page, 2); }); await test.step('Reload the page to simulate an application restart', async () => { await scenario.client.page.reload(); await expect(scenario.client.page).toHaveURL(/\/(room|dashboard)/, { timeout: 30_000 }); await openSavedRoomByName(scenario.client.page, serverName); await expect(scenario.messages.getMessageItemByText(imageCaption)).toBeVisible({ timeout: 20_000 }); }); await test.step('The image still renders from local storage with no peer', async () => { await scenario.messages.expectMessageImageLoaded(imageName); await expect(scenario.client.page.getByText(UPLOADER_LOCAL_MISSING_TEXT, { exact: false })).toHaveCount(0); }); await test.step('The generic file is still remembered with no missing-upload error', async () => { await expect(scenario.messages.getMessageItemByText(fileCaption)).toBeVisible({ timeout: 20_000 }); await expect(scenario.client.page.getByText(fileName, { exact: false })).toBeVisible({ timeout: 20_000 }); await expect(scenario.client.page.getByText(UPLOADER_LOCAL_MISSING_TEXT, { exact: false })).toHaveCount(0); }); }); }); interface SingleClientChatScenario { client: Client; messages: ChatMessagesPage; room: ChatRoomPage; search: ServerSearchPage; } async function createSingleClientChatScenario(createClient: () => Promise): Promise { const suffix = uniqueName('solo'); const client = await createClient(); const credentials = { username: `solo_${suffix}`, displayName: 'Solo', password: 'TestPass123!' }; const registerPage = new RegisterPage(client.page); await registerPage.goto(); await registerPage.register( credentials.username, credentials.displayName, credentials.password ); await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); return { client, messages: new ChatMessagesPage(client.page), room: new ChatRoomPage(client.page), search: new ServerSearchPage(client.page) }; } async function createServerAndOpenRoom( searchPage: ServerSearchPage, page: Page, serverName: string, description: string ): Promise { await searchPage.createServer(serverName, { description }); await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 }); await waitForCurrentRoomName(page, serverName); } async function openSavedRoomByName(page: Page, roomName: string): Promise { const roomButton = page.locator(`button[title="${roomName}"]`); await expect(roomButton).toBeVisible({ timeout: 20_000 }); await roomButton.click(); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 }); await waitForCurrentRoomName(page, roomName); } async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise { await page.waitForFunction( (expectedRoomName) => { interface RoomShape { name?: string } interface AngularDebugApi { getComponent: (element: Element) => Record; } const host = document.querySelector('app-rooms-side-panel'); const debugApi = (window as { ng?: AngularDebugApi }).ng; if (!host || !debugApi?.getComponent) { return false; } const component = debugApi.getComponent(host); const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null; return currentRoom?.name === expectedRoomName; }, roomName, { timeout } ); } interface CountOptions { databaseRole: 'attachment-files' | 'app'; storeName: string; requireSavedPath: boolean; } /** Counts records in the first matching IndexedDB store, optionally requiring a savedPath. */ async function countIndexedDbRecords(page: Page, options: CountOptions): Promise { return page.evaluate(async (countOptions: CountOptions) => { if (typeof indexedDB.databases !== 'function') { return 0; } const databases = await indexedDB.databases(); const matchingNames = databases .map((entry) => entry.name ?? '') .filter((name) => (countOptions.databaseRole === 'attachment-files' ? name.startsWith('metoyou-attachment-files') : name === 'metoyou' || name.startsWith('metoyou::'))); const countInDatabase = (databaseName: string): Promise => new Promise((resolve) => { const request = indexedDB.open(databaseName); request.onerror = () => resolve(0); request.onsuccess = () => { const database = request.result; if (!database.objectStoreNames.contains(countOptions.storeName)) { database.close(); resolve(0); return; } const getAll = database.transaction(countOptions.storeName, 'readonly') .objectStore(countOptions.storeName) .getAll(); getAll.onsuccess = () => { const records = (getAll.result as { savedPath?: string }[]) ?? []; const matching = countOptions.requireSavedPath ? records.filter((record) => !!record.savedPath) : records; resolve(matching.length); database.close(); }; getAll.onerror = () => { resolve(0); database.close(); }; }; }); const counts = await Promise.all(matchingNames.map(countInDatabase)); return counts.reduce((total, count) => total + count, 0); }, options); } /** Polls until at least `minCount` attachment byte records exist in the browser file store. */ async function waitForPersistedAttachmentBytes(page: Page, minCount: number): Promise { await expect.poll( async () => countIndexedDbRecords(page, { databaseRole: 'attachment-files', storeName: 'files', requireSavedPath: false }), { timeout: 20_000, message: 'attachment bytes should persist to IndexedDB before reload' } ).toBeGreaterThanOrEqual(minCount); } /** Polls until at least `minCount` attachment metadata records with a savedPath exist in the app database. */ async function waitForPersistedAttachmentRecords(page: Page, minCount: number): Promise { await expect.poll( async () => countIndexedDbRecords(page, { databaseRole: 'app', storeName: 'attachments', requireSavedPath: true }), { timeout: 20_000, message: 'attachment metadata with savedPath should persist before reload' } ).toBeGreaterThanOrEqual(minCount); } function createTextFilePayload(name: string, mimeType: string, content: string): ChatDropFilePayload { return { name, mimeType, base64: Buffer.from(content, 'utf8').toString('base64') }; } function buildMockSvgMarkup(label: string): string { return [ '', '', '', '', '', `${label}`, '' ].join(''); } function uniqueName(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36) .slice(2, 8)}`; }