369 lines
		
	
	
	
		
			9.5 KiB
			
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			369 lines
		
	
	
	
		
			9.5 KiB
			
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| // Copyright 2023 Signal Messenger, LLC
 | |
| // SPDX-License-Identifier: AGPL-3.0-only
 | |
| 
 | |
| import createDebug from 'debug';
 | |
| import {
 | |
|   type Device,
 | |
|   type Group,
 | |
|   PrimaryDevice,
 | |
|   type Proto,
 | |
|   StorageState,
 | |
| } from '@signalapp/mock-server';
 | |
| import { assert } from 'chai';
 | |
| import Long from 'long';
 | |
| import type { Locator, Page } from 'playwright';
 | |
| import { expect } from 'playwright/test';
 | |
| import type { SignalService } from '../protobuf';
 | |
| import { strictAssert } from '../util/assert';
 | |
| 
 | |
| const debug = createDebug('mock:test:helpers');
 | |
| 
 | |
| export function bufferToUuid(buffer: Buffer): string {
 | |
|   const hex = buffer.toString('hex');
 | |
| 
 | |
|   return [
 | |
|     hex.substring(0, 8),
 | |
|     hex.substring(8, 12),
 | |
|     hex.substring(12, 16),
 | |
|     hex.substring(16, 20),
 | |
|     hex.substring(20),
 | |
|   ].join('-');
 | |
| }
 | |
| 
 | |
| export async function typeIntoInput(
 | |
|   input: Locator,
 | |
|   text: string
 | |
| ): Promise<void> {
 | |
|   let currentValue = '';
 | |
|   let isInputElement = true;
 | |
| 
 | |
|   try {
 | |
|     currentValue = await input.inputValue();
 | |
|   } catch (e) {
 | |
|     isInputElement = false;
 | |
|     // if input is actually not an input (e.g. contenteditable)
 | |
|     currentValue = (await input.textContent()) ?? '';
 | |
|   }
 | |
| 
 | |
|   const newValue = `${currentValue}${text}`;
 | |
| 
 | |
|   await input.fill(newValue);
 | |
| 
 | |
|   // Wait to ensure that the input (and react state controlling it) has actually
 | |
|   // updated with the right value
 | |
|   if (isInputElement) {
 | |
|     await expect(input).toHaveValue(newValue);
 | |
|   } else {
 | |
|     await input.locator(`:text("${newValue}")`).waitFor();
 | |
|   }
 | |
| }
 | |
| 
 | |
| export async function expectItemsWithText(
 | |
|   items: Locator,
 | |
|   expected: ReadonlyArray<string | RegExp>
 | |
| ): Promise<void> {
 | |
|   // Wait for each message to appear in case they're not all there yet
 | |
|   for (const [index, message] of expected.entries()) {
 | |
|     const nth = items.nth(index);
 | |
|     // eslint-disable-next-line no-await-in-loop
 | |
|     await nth.waitFor();
 | |
|     // eslint-disable-next-line no-await-in-loop
 | |
|     const text = await nth.innerText();
 | |
|     const log = `Expect item at index ${index} to match`;
 | |
|     if (typeof message === 'string') {
 | |
|       assert.strictEqual(text, message, log);
 | |
|     } else {
 | |
|       assert.match(text, message, log);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const innerTexts = await items.allInnerTexts();
 | |
|   assert.deepEqual(
 | |
|     innerTexts.length,
 | |
|     expected.length,
 | |
|     `Expect correct number of items\nActual:\n${innerTexts
 | |
|       .map(text => `  - "${text}"\n`)
 | |
|       .join('')}\nExpected:\n${expected
 | |
|       .map(text => `  - ${text.toString()}\n`)
 | |
|       .join('')}`
 | |
|   );
 | |
| }
 | |
| 
 | |
| export async function expectSystemMessages(
 | |
|   context: Page | Locator,
 | |
|   expected: ReadonlyArray<string | RegExp>
 | |
| ): Promise<void> {
 | |
|   await expectItemsWithText(
 | |
|     context.locator('.SystemMessage__contents'),
 | |
|     expected
 | |
|   );
 | |
| }
 | |
| 
 | |
| function getDevice(author: PrimaryDevice | Device): Device {
 | |
|   return author instanceof PrimaryDevice ? author.device : author;
 | |
| }
 | |
| 
 | |
| type GroupInfo = {
 | |
|   group: Group;
 | |
|   members: Array<PrimaryDevice>;
 | |
| };
 | |
| 
 | |
| function maybeWrapInSyncMessage({
 | |
|   isSync,
 | |
|   to,
 | |
|   sentTo,
 | |
|   dataMessage,
 | |
| }: {
 | |
|   isSync: boolean;
 | |
|   to: PrimaryDevice | Device;
 | |
|   sentTo?: Array<PrimaryDevice | Device>;
 | |
|   dataMessage: Proto.IDataMessage;
 | |
| }): Proto.IContent {
 | |
|   return isSync
 | |
|     ? {
 | |
|         syncMessage: {
 | |
|           sent: {
 | |
|             destinationServiceId: getDevice(to).aci,
 | |
|             message: dataMessage,
 | |
|             timestamp: dataMessage.timestamp,
 | |
|             unidentifiedStatus: (sentTo ?? [to]).map(contact => ({
 | |
|               destinationServiceId: getDevice(contact).aci,
 | |
|               destination: getDevice(contact).number,
 | |
|             })),
 | |
|           },
 | |
|         },
 | |
|       }
 | |
|     : { dataMessage };
 | |
| }
 | |
| 
 | |
| function isToGroup(to: Device | PrimaryDevice | GroupInfo): to is GroupInfo {
 | |
|   return 'group' in to;
 | |
| }
 | |
| 
 | |
| export function sendTextMessage({
 | |
|   from,
 | |
|   to,
 | |
|   text,
 | |
|   attachments,
 | |
|   desktop,
 | |
|   timestamp = Date.now(),
 | |
| }: {
 | |
|   from: PrimaryDevice;
 | |
|   to: PrimaryDevice | Device | GroupInfo;
 | |
|   text: string;
 | |
|   attachments?: Array<Proto.IAttachmentPointer>;
 | |
|   desktop: Device;
 | |
|   timestamp?: number;
 | |
| }): Promise<void> {
 | |
|   const isSync = from.secondaryDevices.includes(desktop);
 | |
|   const toDevice = isSync || isToGroup(to) ? desktop : getDevice(to);
 | |
|   const groupInfo = isToGroup(to) ? to : undefined;
 | |
|   return from.sendRaw(
 | |
|     toDevice,
 | |
|     maybeWrapInSyncMessage({
 | |
|       isSync,
 | |
|       to: to as PrimaryDevice,
 | |
|       dataMessage: {
 | |
|         body: text,
 | |
|         attachments,
 | |
|         timestamp: Long.fromNumber(timestamp),
 | |
|         groupV2: groupInfo
 | |
|           ? {
 | |
|               masterKey: groupInfo.group.masterKey,
 | |
|               revision: groupInfo.group.revision,
 | |
|             }
 | |
|           : undefined,
 | |
|       },
 | |
|       sentTo: groupInfo ? groupInfo.members : [to as PrimaryDevice | Device],
 | |
|     }),
 | |
|     { timestamp }
 | |
|   );
 | |
| }
 | |
| 
 | |
| export function sendReaction({
 | |
|   from,
 | |
|   to,
 | |
|   targetAuthor,
 | |
|   targetMessageTimestamp,
 | |
|   emoji = '👍',
 | |
|   reactionTimestamp = Date.now(),
 | |
|   desktop,
 | |
| }: {
 | |
|   from: PrimaryDevice;
 | |
|   to: PrimaryDevice | Device;
 | |
|   targetAuthor: PrimaryDevice | Device;
 | |
|   targetMessageTimestamp: number;
 | |
|   emoji: string;
 | |
|   reactionTimestamp?: number;
 | |
|   desktop: Device;
 | |
| }): Promise<void> {
 | |
|   const isSync = from.secondaryDevices.includes(desktop);
 | |
|   return from.sendRaw(
 | |
|     isSync ? desktop : getDevice(to),
 | |
|     maybeWrapInSyncMessage({
 | |
|       isSync,
 | |
|       to,
 | |
|       dataMessage: {
 | |
|         timestamp: Long.fromNumber(reactionTimestamp),
 | |
|         reaction: {
 | |
|           emoji,
 | |
|           targetAuthorAci: getDevice(targetAuthor).aci,
 | |
|           targetTimestamp: Long.fromNumber(targetMessageTimestamp),
 | |
|         },
 | |
|       },
 | |
|     }),
 | |
|     {
 | |
|       timestamp: reactionTimestamp,
 | |
|     }
 | |
|   );
 | |
| }
 | |
| 
 | |
| async function getStorageState(phone: PrimaryDevice) {
 | |
|   return (await phone.getStorageState()) ?? StorageState.getEmpty();
 | |
| }
 | |
| 
 | |
| export async function createGroup(
 | |
|   phone: PrimaryDevice,
 | |
|   otherMembers: Array<PrimaryDevice>,
 | |
|   groupTitle: string
 | |
| ): Promise<Group> {
 | |
|   const group = await phone.createGroup({
 | |
|     title: groupTitle,
 | |
|     members: [phone, ...otherMembers],
 | |
|   });
 | |
|   let state = await getStorageState(phone);
 | |
| 
 | |
|   state = state
 | |
|     .addGroup(group, {
 | |
|       whitelisted: true,
 | |
|     })
 | |
|     .pinGroup(group);
 | |
| 
 | |
|   // Finally whitelist and pin contacts
 | |
|   for (const member of otherMembers) {
 | |
|     state = state.addContact(member, {
 | |
|       whitelisted: true,
 | |
|       serviceE164: member.device.number,
 | |
|       identityKey: member.publicKey.serialize(),
 | |
|       profileKey: member.profileKey.serialize(),
 | |
|       givenName: member.profileName,
 | |
|     });
 | |
|   }
 | |
|   await phone.setStorageState(state);
 | |
|   return group;
 | |
| }
 | |
| 
 | |
| export async function clickOnConversation(
 | |
|   page: Page,
 | |
|   contact: PrimaryDevice
 | |
| ): Promise<void> {
 | |
|   const leftPane = page.locator('#LeftPane');
 | |
|   await leftPane.getByTestId(contact.device.aci).click();
 | |
| }
 | |
| export async function pinContact(
 | |
|   phone: PrimaryDevice,
 | |
|   contact: PrimaryDevice
 | |
| ): Promise<void> {
 | |
|   const state = await getStorageState(phone);
 | |
|   state.pin(contact);
 | |
|   await phone.setStorageState(state);
 | |
| }
 | |
| 
 | |
| export function acceptConversation(page: Page): Promise<void> {
 | |
|   return page
 | |
|     .locator('.module-message-request-actions button >> "Accept"')
 | |
|     .click();
 | |
| }
 | |
| 
 | |
| export function getTimeline(page: Page): Locator {
 | |
|   return page.locator('.module-timeline__messages__container');
 | |
| }
 | |
| 
 | |
| export function getMessageInTimelineByTimestamp(
 | |
|   page: Page,
 | |
|   timestamp: number
 | |
| ): Locator {
 | |
|   return getTimeline(page).getByTestId(`${timestamp}`);
 | |
| }
 | |
| 
 | |
| export function getTimelineMessageWithText(page: Page, text: string): Locator {
 | |
|   return getTimeline(page).locator('.module-message').filter({ hasText: text });
 | |
| }
 | |
| 
 | |
| export async function composerAttachImages(
 | |
|   page: Page,
 | |
|   filePaths: ReadonlyArray<string>
 | |
| ): Promise<void> {
 | |
|   const AttachmentInput = page.getByTestId('attachfile-input');
 | |
| 
 | |
|   const AttachmentsList = page.locator('.module-attachments');
 | |
|   const AttachmentsListImage = AttachmentsList.locator('.module-image');
 | |
|   const AttachmentsListImageLoaded = AttachmentsListImage.locator(
 | |
|     '.module-image__image'
 | |
|   );
 | |
| 
 | |
|   debug('setting input files');
 | |
|   await AttachmentInput.setInputFiles(filePaths);
 | |
| 
 | |
|   debug(`waiting for ${filePaths.length} items`);
 | |
|   await AttachmentsListImage.nth(filePaths.length - 1).waitFor();
 | |
| 
 | |
|   await Promise.all(
 | |
|     filePaths.map(async (_, index) => {
 | |
|       debug(`waiting for ${index} image to render in attachments list`);
 | |
|       await AttachmentsListImageLoaded.nth(index).waitFor({
 | |
|         state: 'visible',
 | |
|       });
 | |
|     })
 | |
|   );
 | |
| }
 | |
| 
 | |
| export async function sendMessageWithAttachments(
 | |
|   page: Page,
 | |
|   receiver: PrimaryDevice,
 | |
|   text: string,
 | |
|   filePaths: Array<string>
 | |
| ): Promise<Array<SignalService.IAttachmentPointer>> {
 | |
|   await composerAttachImages(page, filePaths);
 | |
| 
 | |
|   debug('sending message');
 | |
|   const input = await waitForEnabledComposer(page);
 | |
|   await typeIntoInput(input, text);
 | |
|   await input.press('Enter');
 | |
| 
 | |
|   const Message = getTimelineMessageWithText(page, text);
 | |
|   const MessageImageLoaded = Message.locator('.module-image__image');
 | |
| 
 | |
|   await Message.waitFor();
 | |
| 
 | |
|   await Promise.all(
 | |
|     filePaths.map(async (_, index) => {
 | |
|       debug(`waiting for ${index} image to render in timeline`);
 | |
|       await MessageImageLoaded.nth(index).waitFor({
 | |
|         state: 'visible',
 | |
|       });
 | |
|     })
 | |
|   );
 | |
| 
 | |
|   debug('get received message data');
 | |
|   const receivedMessage = await receiver.waitForMessage();
 | |
|   const attachments = receivedMessage.dataMessage.attachments ?? [];
 | |
|   strictAssert(
 | |
|     attachments.length === filePaths.length,
 | |
|     'attachments must exist'
 | |
|   );
 | |
| 
 | |
|   return attachments;
 | |
| }
 | |
| 
 | |
| export async function waitForEnabledComposer(page: Page): Promise<Locator> {
 | |
|   const composeArea = page.locator(
 | |
|     '.composition-area-wrapper, .Inbox__conversation .ConversationView'
 | |
|   );
 | |
|   const composeContainer = composeArea.locator(
 | |
|     '[data-testid=CompositionInput][data-enabled=true]'
 | |
|   );
 | |
|   await composeContainer.waitFor();
 | |
| 
 | |
|   return composeContainer.locator('.ql-editor');
 | |
| }
 | 
