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');
|
|
}
|