Files
Toju/e2e/tests/chat/local-attachment-persistence.spec.ts

245 lines
9.5 KiB
TypeScript

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<Client>): Promise<SingleClientChatScenario> {
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<void> {
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<void> {
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<void> {
await page.waitForFunction(
(expectedRoomName) => {
interface RoomShape { name?: string }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
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<number> {
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<number> => new Promise<number>((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<void> {
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<void> {
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 [
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120" viewBox="0 0 160 120">',
'<rect width="160" height="120" rx="18" fill="#0f172a" />',
'<circle cx="38" cy="36" r="18" fill="#38bdf8" />',
'<rect x="66" y="28" width="64" height="16" rx="8" fill="#f8fafc" />',
'<rect x="24" y="74" width="112" height="12" rx="6" fill="#22c55e" />',
`<text x="24" y="104" fill="#e2e8f0" font-size="12" font-family="Arial, sans-serif">${label}</text>`,
'</svg>'
].join('');
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}