fix: Bug - Local files should be remembered by client
This commit is contained in:
244
e2e/tests/chat/local-attachment-persistence.spec.ts
Normal file
244
e2e/tests/chat/local-attachment-persistence.spec.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user