245 lines
9.5 KiB
TypeScript
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)}`;
|
|
}
|