Storage service tests and benches in ts/test-mock
This commit is contained in:
parent
48137a498c
commit
6281d52ec6
20 changed files with 1866 additions and 100 deletions
101
ts/test-mock/benchmarks/convo_open_bench.ts
Normal file
101
ts/test-mock/benchmarks/convo_open_bench.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
/* eslint-disable no-await-in-loop, no-console */
|
||||
|
||||
import type { PrimaryDevice } from '@signalapp/mock-server';
|
||||
|
||||
import { Bootstrap, debug, stats, RUN_COUNT, DISCARD_COUNT } from './fixtures';
|
||||
|
||||
const CONVERSATION_SIZE = 1000; // messages
|
||||
const DELAY = 50; // milliseconds
|
||||
|
||||
(async () => {
|
||||
const bootstrap = new Bootstrap({
|
||||
benchmark: true,
|
||||
});
|
||||
|
||||
await bootstrap.init();
|
||||
const app = await bootstrap.link();
|
||||
|
||||
try {
|
||||
const { server, contacts, phone, desktop } = bootstrap;
|
||||
|
||||
const [first, second] = contacts;
|
||||
|
||||
const messages = new Array<Buffer>();
|
||||
debug('encrypting');
|
||||
// Send messages from just two contacts
|
||||
for (const contact of [second, first]) {
|
||||
for (let i = 0; i < CONVERSATION_SIZE; i += 1) {
|
||||
const messageTimestamp = bootstrap.getTimestamp();
|
||||
messages.push(
|
||||
await contact.encryptText(
|
||||
desktop,
|
||||
`hello from: ${contact.profileName}`,
|
||||
{
|
||||
timestamp: messageTimestamp,
|
||||
sealed: true,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
messages.push(
|
||||
await phone.encryptSyncRead(desktop, {
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
messages: [
|
||||
{
|
||||
senderUUID: contact.device.uuid,
|
||||
timestamp: messageTimestamp,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sendQueue = async (): Promise<void> => {
|
||||
await Promise.all(messages.map(message => server.send(desktop, message)));
|
||||
};
|
||||
|
||||
const measure = async (): Promise<void> => {
|
||||
const window = await app.getWindow();
|
||||
|
||||
const leftPane = window.locator('.left-pane-wrapper');
|
||||
|
||||
const openConvo = async (contact: PrimaryDevice): Promise<void> => {
|
||||
debug('opening conversation', contact.profileName);
|
||||
const item = leftPane.locator(
|
||||
'_react=BaseConversationListItem' +
|
||||
`[title = ${JSON.stringify(contact.profileName)}]`
|
||||
);
|
||||
|
||||
await item.click();
|
||||
};
|
||||
|
||||
const deltaList = new Array<number>();
|
||||
for (let runId = 0; runId < RUN_COUNT + DISCARD_COUNT; runId += 1) {
|
||||
await openConvo(runId % 2 === 0 ? first : second);
|
||||
|
||||
debug('waiting for timing from the app');
|
||||
const { delta } = await app.waitForConversationOpen();
|
||||
|
||||
// Let render complete
|
||||
await new Promise(resolve => setTimeout(resolve, DELAY));
|
||||
|
||||
if (runId >= DISCARD_COUNT) {
|
||||
deltaList.push(delta);
|
||||
console.log('run=%d info=%j', runId - DISCARD_COUNT, { delta });
|
||||
} else {
|
||||
console.log('discarded=%d info=%j', runId, { delta });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('stats info=%j', { delta: stats(deltaList, [99, 99.8]) });
|
||||
};
|
||||
|
||||
await Promise.all([sendQueue(), measure()]);
|
||||
} finally {
|
||||
await app.close();
|
||||
await bootstrap.teardown();
|
||||
}
|
||||
})();
|
67
ts/test-mock/benchmarks/fixtures.ts
Normal file
67
ts/test-mock/benchmarks/fixtures.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
/* eslint-disable no-await-in-loop, no-console */
|
||||
|
||||
import createDebug from 'debug';
|
||||
|
||||
export const debug = createDebug('mock:benchmarks');
|
||||
|
||||
export { Bootstrap } from '../bootstrap';
|
||||
export { App } from '../playwright';
|
||||
|
||||
export type StatsType = {
|
||||
mean: number;
|
||||
stddev: number;
|
||||
[key: string]: number;
|
||||
};
|
||||
|
||||
export const RUN_COUNT = process.env.RUN_COUNT
|
||||
? parseInt(process.env.RUN_COUNT, 10)
|
||||
: 100;
|
||||
|
||||
export const GROUP_SIZE = process.env.GROUP_SIZE
|
||||
? parseInt(process.env.GROUP_SIZE, 10)
|
||||
: 8;
|
||||
|
||||
export const DISCARD_COUNT = process.env.DISCARD_COUNT
|
||||
? parseInt(process.env.DISCARD_COUNT, 10)
|
||||
: 5;
|
||||
|
||||
export function stats(
|
||||
list: ReadonlyArray<number>,
|
||||
percentiles: ReadonlyArray<number> = []
|
||||
): StatsType {
|
||||
if (list.length === 0) {
|
||||
throw new Error('Empty list given to stats');
|
||||
}
|
||||
|
||||
let mean = 0;
|
||||
let stddev = 0;
|
||||
|
||||
for (const value of list) {
|
||||
mean += value;
|
||||
stddev += value ** 2;
|
||||
}
|
||||
mean /= list.length;
|
||||
stddev /= list.length;
|
||||
|
||||
stddev -= mean ** 2;
|
||||
stddev = Math.sqrt(stddev);
|
||||
|
||||
const sorted = list.slice().sort((a, b) => a - b);
|
||||
|
||||
const result: StatsType = { mean, stddev };
|
||||
|
||||
for (const p of percentiles) {
|
||||
result[`p${p}`] = sorted[Math.floor((sorted.length * p) / 100)];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Can happen if electron exits prematurely
|
||||
process.on('unhandledRejection', reason => {
|
||||
console.error('Unhandled rejection:');
|
||||
console.error(reason);
|
||||
process.exit(1);
|
||||
});
|
184
ts/test-mock/benchmarks/group_send_bench.ts
Normal file
184
ts/test-mock/benchmarks/group_send_bench.ts
Normal file
|
@ -0,0 +1,184 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
/* eslint-disable no-await-in-loop, no-console */
|
||||
|
||||
import assert from 'assert';
|
||||
|
||||
import {
|
||||
StorageState,
|
||||
EnvelopeType,
|
||||
ReceiptType,
|
||||
} from '@signalapp/mock-server';
|
||||
import {
|
||||
Bootstrap,
|
||||
debug,
|
||||
stats,
|
||||
RUN_COUNT,
|
||||
GROUP_SIZE,
|
||||
DISCARD_COUNT,
|
||||
} from './fixtures';
|
||||
|
||||
const CONVERSATION_SIZE = 500; // messages
|
||||
const LAST_MESSAGE = 'start sending messages now';
|
||||
|
||||
(async () => {
|
||||
const bootstrap = new Bootstrap({
|
||||
benchmark: true,
|
||||
});
|
||||
|
||||
await bootstrap.init();
|
||||
|
||||
const { contacts, phone } = bootstrap;
|
||||
|
||||
const members = [...contacts].slice(0, GROUP_SIZE);
|
||||
|
||||
const group = await phone.createGroup({
|
||||
title: 'Mock Group',
|
||||
members: [phone, ...members],
|
||||
});
|
||||
|
||||
await phone.setStorageState(
|
||||
StorageState.getEmpty()
|
||||
.addGroup(group, { whitelisted: true })
|
||||
.pinGroup(group)
|
||||
);
|
||||
|
||||
const app = await bootstrap.link();
|
||||
|
||||
try {
|
||||
const { server, desktop } = bootstrap;
|
||||
const [first] = members;
|
||||
|
||||
const messages = new Array<Buffer>();
|
||||
debug('encrypting');
|
||||
// Fill left pane
|
||||
for (const contact of members.slice().reverse()) {
|
||||
const messageTimestamp = bootstrap.getTimestamp();
|
||||
|
||||
messages.push(
|
||||
await contact.encryptText(
|
||||
desktop,
|
||||
`hello from: ${contact.profileName}`,
|
||||
{
|
||||
timestamp: messageTimestamp,
|
||||
sealed: true,
|
||||
}
|
||||
)
|
||||
);
|
||||
messages.push(
|
||||
await phone.encryptSyncRead(desktop, {
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
messages: [
|
||||
{
|
||||
senderUUID: contact.device.uuid,
|
||||
timestamp: messageTimestamp,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Fill group
|
||||
for (let i = 0; i < CONVERSATION_SIZE; i += 1) {
|
||||
const contact = members[i % members.length];
|
||||
const messageTimestamp = bootstrap.getTimestamp();
|
||||
|
||||
const isLast = i === CONVERSATION_SIZE - 1;
|
||||
|
||||
messages.push(
|
||||
await contact.encryptText(
|
||||
desktop,
|
||||
isLast ? LAST_MESSAGE : `#${i} from: ${contact.profileName}`,
|
||||
{
|
||||
timestamp: messageTimestamp,
|
||||
sealed: true,
|
||||
group,
|
||||
}
|
||||
)
|
||||
);
|
||||
messages.push(
|
||||
await phone.encryptSyncRead(desktop, {
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
messages: [
|
||||
{
|
||||
senderUUID: contact.device.uuid,
|
||||
timestamp: messageTimestamp,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
debug('encrypted');
|
||||
|
||||
await Promise.all(messages.map(message => server.send(desktop, message)));
|
||||
|
||||
const window = await app.getWindow();
|
||||
|
||||
debug('opening conversation');
|
||||
{
|
||||
const leftPane = window.locator('.left-pane-wrapper');
|
||||
|
||||
const item = leftPane.locator(
|
||||
'_react=BaseConversationListItem' +
|
||||
`[title = ${JSON.stringify(group.title)}]` +
|
||||
`>> ${JSON.stringify(LAST_MESSAGE)}`
|
||||
);
|
||||
await item.click();
|
||||
}
|
||||
|
||||
const timeline = window.locator(
|
||||
'.timeline-wrapper, .ConversationView__template .react-wrapper'
|
||||
);
|
||||
|
||||
const deltaList = new Array<number>();
|
||||
for (let runId = 0; runId < RUN_COUNT + DISCARD_COUNT; runId += 1) {
|
||||
debug('finding composition input and clicking it');
|
||||
const composeArea = window.locator(
|
||||
'.composition-area-wrapper, ' +
|
||||
'.ConversationView__template .react-wrapper'
|
||||
);
|
||||
|
||||
const input = composeArea.locator('_react=CompositionInput');
|
||||
|
||||
debug('entering message text');
|
||||
await input.type(`my message ${runId}`);
|
||||
await input.press('Enter');
|
||||
|
||||
debug('waiting for message on server side');
|
||||
const { body, source, envelopeType } = await first.waitForMessage();
|
||||
assert.strictEqual(body, `my message ${runId}`);
|
||||
assert.strictEqual(source, desktop);
|
||||
assert.strictEqual(envelopeType, EnvelopeType.SenderKey);
|
||||
|
||||
debug('waiting for timing from the app');
|
||||
const { timestamp, delta } = await app.waitForMessageSend();
|
||||
|
||||
debug('sending delivery receipts');
|
||||
const delivery = await first.encryptReceipt(desktop, {
|
||||
timestamp: timestamp + 1,
|
||||
messageTimestamps: [timestamp],
|
||||
type: ReceiptType.Delivery,
|
||||
});
|
||||
|
||||
await server.send(desktop, delivery);
|
||||
|
||||
debug('waiting for message state change');
|
||||
const message = timeline.locator(
|
||||
`_react=Message[timestamp = ${timestamp}][status = "delivered"]`
|
||||
);
|
||||
await message.waitFor();
|
||||
|
||||
if (runId >= DISCARD_COUNT) {
|
||||
deltaList.push(delta);
|
||||
console.log('run=%d info=%j', runId - DISCARD_COUNT, { delta });
|
||||
} else {
|
||||
console.log('discarded=%d info=%j', runId, { delta });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('stats info=%j', { delta: stats(deltaList, [99, 99.8]) });
|
||||
} finally {
|
||||
await app.close();
|
||||
await bootstrap.teardown();
|
||||
}
|
||||
})();
|
135
ts/test-mock/benchmarks/send_bench.ts
Normal file
135
ts/test-mock/benchmarks/send_bench.ts
Normal file
|
@ -0,0 +1,135 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
/* eslint-disable no-await-in-loop, no-console */
|
||||
|
||||
import assert from 'assert';
|
||||
|
||||
import { ReceiptType } from '@signalapp/mock-server';
|
||||
|
||||
import { Bootstrap, debug, stats, RUN_COUNT, DISCARD_COUNT } from './fixtures';
|
||||
|
||||
const CONVERSATION_SIZE = 500; // messages
|
||||
|
||||
const LAST_MESSAGE = 'start sending messages now';
|
||||
|
||||
(async () => {
|
||||
const bootstrap = new Bootstrap({
|
||||
benchmark: true,
|
||||
});
|
||||
|
||||
await bootstrap.init();
|
||||
const app = await bootstrap.link();
|
||||
|
||||
try {
|
||||
const { server, contacts, phone, desktop } = bootstrap;
|
||||
|
||||
const [first] = contacts;
|
||||
|
||||
const messages = new Array<Buffer>();
|
||||
debug('encrypting');
|
||||
// Note: make it so that we receive the latest message from the first
|
||||
// contact.
|
||||
for (const contact of contacts.slice().reverse()) {
|
||||
let count = 1;
|
||||
if (contact === first) {
|
||||
count = CONVERSATION_SIZE;
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const messageTimestamp = bootstrap.getTimestamp();
|
||||
|
||||
const isLast = i === count - 1;
|
||||
|
||||
messages.push(
|
||||
await contact.encryptText(
|
||||
desktop,
|
||||
isLast ? LAST_MESSAGE : `#${i} from: ${contact.profileName}`,
|
||||
{
|
||||
timestamp: messageTimestamp,
|
||||
sealed: true,
|
||||
}
|
||||
)
|
||||
);
|
||||
messages.push(
|
||||
await phone.encryptSyncRead(desktop, {
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
messages: [
|
||||
{
|
||||
senderUUID: contact.device.uuid,
|
||||
timestamp: messageTimestamp,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(messages.map(message => server.send(desktop, message)));
|
||||
|
||||
const window = await app.getWindow();
|
||||
|
||||
debug('opening conversation');
|
||||
{
|
||||
const leftPane = window.locator('.left-pane-wrapper');
|
||||
const item = leftPane.locator(
|
||||
'_react=BaseConversationListItem' +
|
||||
`[title = ${JSON.stringify(first.profileName)}]` +
|
||||
`>> ${JSON.stringify(LAST_MESSAGE)}`
|
||||
);
|
||||
await item.click();
|
||||
}
|
||||
|
||||
const timeline = window.locator(
|
||||
'.timeline-wrapper, .ConversationView__template .react-wrapper'
|
||||
);
|
||||
|
||||
const deltaList = new Array<number>();
|
||||
for (let runId = 0; runId < RUN_COUNT + DISCARD_COUNT; runId += 1) {
|
||||
debug('finding composition input and clicking it');
|
||||
const composeArea = window.locator(
|
||||
'.composition-area-wrapper, ' +
|
||||
'.ConversationView__template .react-wrapper'
|
||||
);
|
||||
const input = composeArea.locator('_react=CompositionInput');
|
||||
|
||||
debug('entering message text');
|
||||
await input.type(`my message ${runId}`);
|
||||
await input.press('Enter');
|
||||
|
||||
debug('waiting for message on server side');
|
||||
const { body, source } = await first.waitForMessage();
|
||||
assert.strictEqual(body, `my message ${runId}`);
|
||||
assert.strictEqual(source, desktop);
|
||||
|
||||
debug('waiting for timing from the app');
|
||||
const { timestamp, delta } = await app.waitForMessageSend();
|
||||
|
||||
debug('sending delivery receipt');
|
||||
const delivery = await first.encryptReceipt(desktop, {
|
||||
timestamp: timestamp + 1,
|
||||
messageTimestamps: [timestamp],
|
||||
type: ReceiptType.Delivery,
|
||||
});
|
||||
|
||||
await server.send(desktop, delivery);
|
||||
|
||||
debug('waiting for message state change');
|
||||
const message = timeline.locator(
|
||||
`_react=Message[timestamp = ${timestamp}][status = "delivered"]`
|
||||
);
|
||||
await message.waitFor();
|
||||
|
||||
if (runId >= DISCARD_COUNT) {
|
||||
deltaList.push(delta);
|
||||
console.log('run=%d info=%j', runId - DISCARD_COUNT, { delta });
|
||||
} else {
|
||||
console.log('discarded=%d info=%j', runId, { delta });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('stats info=%j', { delta: stats(deltaList, [99, 99.8]) });
|
||||
} finally {
|
||||
await app.close();
|
||||
await bootstrap.teardown();
|
||||
}
|
||||
})();
|
133
ts/test-mock/benchmarks/startup_bench.ts
Normal file
133
ts/test-mock/benchmarks/startup_bench.ts
Normal file
|
@ -0,0 +1,133 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
/* eslint-disable no-await-in-loop, no-console */
|
||||
|
||||
import { ReceiptType } from '@signalapp/mock-server';
|
||||
|
||||
import { debug, Bootstrap, stats, RUN_COUNT } from './fixtures';
|
||||
|
||||
const MESSAGE_BATCH_SIZE = 1000; // messages
|
||||
|
||||
const ENABLE_RECEIPTS = Boolean(process.env.ENABLE_RECEIPTS);
|
||||
|
||||
(async () => {
|
||||
const bootstrap = new Bootstrap({
|
||||
benchmark: true,
|
||||
});
|
||||
|
||||
await bootstrap.init();
|
||||
await bootstrap.linkAndClose();
|
||||
|
||||
try {
|
||||
const { server, contacts, phone, desktop } = bootstrap;
|
||||
|
||||
const messagesPerSec = new Array<number>();
|
||||
|
||||
for (let runId = 0; runId < RUN_COUNT; runId += 1) {
|
||||
// Generate messages
|
||||
const messagePromises = new Array<Promise<Buffer>>();
|
||||
debug('started generating messages');
|
||||
|
||||
for (let i = 0; i < MESSAGE_BATCH_SIZE; i += 1) {
|
||||
const contact = contacts[Math.floor(i / 2) % contacts.length];
|
||||
const direction = i % 2 ? 'message' : 'reply';
|
||||
|
||||
const messageTimestamp = bootstrap.getTimestamp();
|
||||
|
||||
if (direction === 'message') {
|
||||
messagePromises.push(
|
||||
contact.encryptText(
|
||||
desktop,
|
||||
`Ping from mock server ${i + 1} / ${MESSAGE_BATCH_SIZE}`,
|
||||
{
|
||||
timestamp: messageTimestamp,
|
||||
sealed: true,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
if (ENABLE_RECEIPTS) {
|
||||
messagePromises.push(
|
||||
phone.encryptSyncRead(desktop, {
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
messages: [
|
||||
{
|
||||
senderUUID: contact.device.uuid,
|
||||
timestamp: messageTimestamp,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
messagePromises.push(
|
||||
phone.encryptSyncSent(
|
||||
desktop,
|
||||
`Pong from mock server ${i + 1} / ${MESSAGE_BATCH_SIZE}`,
|
||||
{
|
||||
timestamp: messageTimestamp,
|
||||
destinationUUID: contact.device.uuid,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
if (ENABLE_RECEIPTS) {
|
||||
messagePromises.push(
|
||||
contact.encryptReceipt(desktop, {
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
messageTimestamps: [messageTimestamp],
|
||||
type: ReceiptType.Delivery,
|
||||
})
|
||||
);
|
||||
messagePromises.push(
|
||||
contact.encryptReceipt(desktop, {
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
messageTimestamps: [messageTimestamp],
|
||||
type: ReceiptType.Read,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
debug('ended generating messages');
|
||||
|
||||
const messages = await Promise.all(messagePromises);
|
||||
|
||||
// Open the flood gates
|
||||
{
|
||||
debug('got synced, sending messages');
|
||||
|
||||
// Queue all messages
|
||||
const queue = async (): Promise<void> => {
|
||||
await Promise.all(
|
||||
messages.map(message => {
|
||||
return server.send(desktop, message);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
const app = await bootstrap.startApp();
|
||||
const appLoadedInfo = await app.waitUntilLoaded();
|
||||
|
||||
console.log('run=%d info=%j', runId, appLoadedInfo);
|
||||
|
||||
messagesPerSec.push(appLoadedInfo.messagesPerSec);
|
||||
|
||||
await app.close();
|
||||
};
|
||||
|
||||
await Promise.all([queue(), run()]);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute human-readable statistics
|
||||
if (messagesPerSec.length !== 0) {
|
||||
console.log('stats info=%j', { messagesPerSec: stats(messagesPerSec) });
|
||||
}
|
||||
} finally {
|
||||
await bootstrap.teardown();
|
||||
}
|
||||
})();
|
286
ts/test-mock/bootstrap.ts
Normal file
286
ts/test-mock/bootstrap.ts
Normal file
|
@ -0,0 +1,286 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import assert from 'assert';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import createDebug from 'debug';
|
||||
|
||||
import type { Device, PrimaryDevice } from '@signalapp/mock-server';
|
||||
import { Server, loadCertificates } from '@signalapp/mock-server';
|
||||
import { App } from './playwright';
|
||||
import * as durations from '../util/durations';
|
||||
|
||||
const debug = createDebug('mock:bootstrap');
|
||||
|
||||
const ELECTRON = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'node_modules',
|
||||
'.bin',
|
||||
'electron'
|
||||
);
|
||||
const CI_SCRIPT = path.join(__dirname, '..', '..', 'ci.js');
|
||||
|
||||
const CLOSE_TIMEOUT = 10 * 1000;
|
||||
|
||||
const CONTACT_FIRST_NAMES = [
|
||||
'Alice',
|
||||
'Bob',
|
||||
'Charlie',
|
||||
'Paul',
|
||||
'Steve',
|
||||
'William',
|
||||
];
|
||||
const CONTACT_LAST_NAMES = [
|
||||
'Smith',
|
||||
'Brown',
|
||||
'Jones',
|
||||
'Miller',
|
||||
'Davis',
|
||||
'Lopez',
|
||||
'Gonazales',
|
||||
];
|
||||
|
||||
const CONTACT_NAMES = new Array<string>();
|
||||
for (const firstName of CONTACT_FIRST_NAMES) {
|
||||
for (const lastName of CONTACT_LAST_NAMES) {
|
||||
CONTACT_NAMES.push(`${firstName} ${lastName}`);
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_CONTACTS = CONTACT_NAMES.length;
|
||||
|
||||
export type BootstrapOptions = Readonly<{
|
||||
extraConfig?: Record<string, unknown>;
|
||||
benchmark?: boolean;
|
||||
|
||||
linkedDevices?: number;
|
||||
contactCount?: number;
|
||||
}>;
|
||||
|
||||
type BootstrapInternalOptions = Pick<BootstrapOptions, 'extraConfig'> &
|
||||
Readonly<{
|
||||
benchmark: boolean;
|
||||
linkedDevices: number;
|
||||
contactCount: number;
|
||||
}>;
|
||||
|
||||
//
|
||||
// Bootstrap is a class that prepares mock server and desktop for running
|
||||
// tests/benchmarks.
|
||||
//
|
||||
// In general, the usage pattern is:
|
||||
//
|
||||
// const bootstrap = new Bootstrap();
|
||||
// await bootstrap.init();
|
||||
// const app = await bootstrap.link();
|
||||
// await bootstrap.teardown();
|
||||
//
|
||||
// Once initialized `bootstrap` variable will have following useful properties:
|
||||
//
|
||||
// - `server` - a mock server instance
|
||||
// - `desktop` - a linked device representing currently running desktop instance
|
||||
// - `phone` - a primary device representing desktop's primary
|
||||
// - `contacts` - a list of primary devices for contacts that are synced over
|
||||
// through contact sync
|
||||
//
|
||||
// `bootstrap.getTimestamp()` could be used to generate consecutive timestamp
|
||||
// for sending messages.
|
||||
//
|
||||
// All phone numbers and uuids for all contacts and ourselves are random and not
|
||||
// the same between different test runs.
|
||||
//
|
||||
export class Bootstrap {
|
||||
public readonly server = new Server();
|
||||
|
||||
private readonly options: BootstrapInternalOptions;
|
||||
private privContacts?: ReadonlyArray<PrimaryDevice>;
|
||||
private privPhone?: PrimaryDevice;
|
||||
private privDesktop?: Device;
|
||||
private storagePath?: string;
|
||||
private timestamp: number = Date.now() - durations.MONTH;
|
||||
|
||||
constructor(options: BootstrapOptions = {}) {
|
||||
this.options = {
|
||||
linkedDevices: 5,
|
||||
contactCount: MAX_CONTACTS,
|
||||
benchmark: false,
|
||||
|
||||
...options,
|
||||
};
|
||||
|
||||
assert(this.options.contactCount <= MAX_CONTACTS);
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
debug('initializing');
|
||||
|
||||
await this.server.listen(0);
|
||||
|
||||
const { port } = this.server.address();
|
||||
debug('started server on port=%d', port);
|
||||
|
||||
const contactNames = CONTACT_NAMES.slice(0, this.options.contactCount);
|
||||
|
||||
this.privContacts = await Promise.all(
|
||||
contactNames.map(async profileName => {
|
||||
const primary = await this.server.createPrimaryDevice({ profileName });
|
||||
|
||||
for (let i = 0; i < this.options.linkedDevices; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.server.createSecondaryDevice(primary);
|
||||
}
|
||||
|
||||
return primary;
|
||||
})
|
||||
);
|
||||
|
||||
this.privPhone = await this.server.createPrimaryDevice({
|
||||
profileName: 'Mock',
|
||||
contacts: this.contacts,
|
||||
});
|
||||
|
||||
this.storagePath = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-signal-'));
|
||||
|
||||
debug('setting storage path=%j', this.storagePath);
|
||||
}
|
||||
|
||||
public async teardown(): Promise<void> {
|
||||
debug('tearing down');
|
||||
|
||||
await Promise.race([
|
||||
this.storagePath
|
||||
? fs.rm(this.storagePath, { recursive: true })
|
||||
: Promise.resolve(),
|
||||
this.server.close(),
|
||||
new Promise(resolve => setTimeout(resolve, CLOSE_TIMEOUT).unref()),
|
||||
]);
|
||||
}
|
||||
|
||||
public async link(): Promise<App> {
|
||||
debug('linking');
|
||||
|
||||
const app = await this.startApp();
|
||||
|
||||
const provision = await this.server.waitForProvision();
|
||||
|
||||
const provisionURL = await app.waitForProvisionURL();
|
||||
|
||||
this.privDesktop = await provision.complete({
|
||||
provisionURL,
|
||||
primaryDevice: this.phone,
|
||||
});
|
||||
|
||||
debug('new desktop device %j', this.desktop.debugId);
|
||||
|
||||
const desktopKey = await this.desktop.popSingleUseKey();
|
||||
await this.phone.addSingleUseKey(this.desktop, desktopKey);
|
||||
|
||||
for (const contact of this.contacts) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const contactKey = await this.desktop.popSingleUseKey();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await contact.addSingleUseKey(this.desktop, contactKey);
|
||||
}
|
||||
|
||||
await this.phone.waitForSync(this.desktop);
|
||||
this.phone.resetSyncState(this.desktop);
|
||||
|
||||
debug('synced with %j', this.desktop.debugId);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
public async linkAndClose(): Promise<void> {
|
||||
const app = await this.link();
|
||||
|
||||
debug('closing the app after link');
|
||||
await app.close();
|
||||
}
|
||||
|
||||
public async startApp(): Promise<App> {
|
||||
assert(
|
||||
this.storagePath !== undefined,
|
||||
'Bootstrap has to be initialized first, see: bootstrap.init()'
|
||||
);
|
||||
|
||||
debug('starting the app');
|
||||
|
||||
const { port } = this.server.address();
|
||||
|
||||
const app = new App({
|
||||
main: ELECTRON,
|
||||
args: [CI_SCRIPT],
|
||||
config: await this.generateConfig(port),
|
||||
});
|
||||
|
||||
await app.start();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
public getTimestamp(): number {
|
||||
const result = this.timestamp;
|
||||
this.timestamp += 1;
|
||||
return result;
|
||||
}
|
||||
|
||||
//
|
||||
// Getters
|
||||
//
|
||||
|
||||
public get phone(): PrimaryDevice {
|
||||
assert(
|
||||
this.privPhone,
|
||||
'Bootstrap has to be initialized first, see: bootstrap.init()'
|
||||
);
|
||||
return this.privPhone;
|
||||
}
|
||||
|
||||
public get desktop(): Device {
|
||||
assert(
|
||||
this.privDesktop,
|
||||
'Bootstrap has to be linked first, see: bootstrap.link()'
|
||||
);
|
||||
return this.privDesktop;
|
||||
}
|
||||
|
||||
public get contacts(): ReadonlyArray<PrimaryDevice> {
|
||||
assert(
|
||||
this.privContacts,
|
||||
'Bootstrap has to be initialized first, see: bootstrap.init()'
|
||||
);
|
||||
return this.privContacts;
|
||||
}
|
||||
|
||||
//
|
||||
// Private
|
||||
//
|
||||
|
||||
private async generateConfig(port: number): Promise<string> {
|
||||
const url = `https://mock.signal.org:${port}`;
|
||||
return JSON.stringify({
|
||||
...(await loadCertificates()),
|
||||
|
||||
forcePreloadBundle: this.options.benchmark,
|
||||
enableCI: true,
|
||||
|
||||
buildExpiration: Date.now() + durations.MONTH,
|
||||
storagePath: this.storagePath,
|
||||
storageProfile: 'mock',
|
||||
serverUrl: url,
|
||||
storageUrl: url,
|
||||
directoryUrl: url,
|
||||
cdn: {
|
||||
'0': url,
|
||||
'2': url,
|
||||
},
|
||||
updatesEnabled: false,
|
||||
|
||||
...this.options.extraConfig,
|
||||
});
|
||||
}
|
||||
}
|
85
ts/test-mock/playwright.ts
Normal file
85
ts/test-mock/playwright.ts
Normal file
|
@ -0,0 +1,85 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ElectronApplication, Page } from 'playwright';
|
||||
import { _electron as electron } from 'playwright';
|
||||
|
||||
export type AppLoadedInfoType = Readonly<{
|
||||
loadTime: number;
|
||||
messagesPerSec: number;
|
||||
}>;
|
||||
|
||||
export type MessageSendInfoType = Readonly<{
|
||||
timestamp: number;
|
||||
delta: number;
|
||||
}>;
|
||||
|
||||
export type ConversationOpenInfoType = Readonly<{
|
||||
delta: number;
|
||||
}>;
|
||||
|
||||
export type AppOptionsType = Readonly<{
|
||||
main: string;
|
||||
args: ReadonlyArray<string>;
|
||||
config: string;
|
||||
}>;
|
||||
|
||||
export class App {
|
||||
private privApp: ElectronApplication | undefined;
|
||||
|
||||
constructor(private readonly options: AppOptionsType) {}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
this.privApp = await electron.launch({
|
||||
executablePath: this.options.main,
|
||||
args: this.options.args.slice(),
|
||||
env: {
|
||||
...process.env,
|
||||
SIGNAL_CI_CONFIG: this.options.config,
|
||||
},
|
||||
locale: 'en',
|
||||
});
|
||||
}
|
||||
|
||||
public async waitForProvisionURL(): Promise<string> {
|
||||
return this.waitForEvent('provisioning-url');
|
||||
}
|
||||
|
||||
public async waitUntilLoaded(): Promise<AppLoadedInfoType> {
|
||||
return this.waitForEvent('app-loaded');
|
||||
}
|
||||
|
||||
public async waitForMessageSend(): Promise<MessageSendInfoType> {
|
||||
return this.waitForEvent('message:send-complete');
|
||||
}
|
||||
|
||||
public async waitForConversationOpen(): Promise<ConversationOpenInfoType> {
|
||||
return this.waitForEvent('conversation:open');
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
await this.app.close();
|
||||
}
|
||||
|
||||
public async getWindow(): Promise<Page> {
|
||||
return this.app.firstWindow();
|
||||
}
|
||||
|
||||
private async waitForEvent<T>(event: string): Promise<T> {
|
||||
const window = await this.getWindow();
|
||||
|
||||
const result = await window.evaluate(
|
||||
`window.CI.waitForEvent(${JSON.stringify(event)})`
|
||||
);
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
private get app(): ElectronApplication {
|
||||
if (!this.privApp) {
|
||||
throw new Error('Call ElectronWrap.start() first');
|
||||
}
|
||||
|
||||
return this.privApp;
|
||||
}
|
||||
}
|
123
ts/test-mock/storage/archive_test.ts
Normal file
123
ts/test-mock/storage/archive_test.ts
Normal file
|
@ -0,0 +1,123 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import * as durations from '../../util/durations';
|
||||
import type { App, Bootstrap } from './fixtures';
|
||||
import { initStorage, debug } from './fixtures';
|
||||
|
||||
describe('storage service', function needsName() {
|
||||
this.timeout(durations.MINUTE);
|
||||
|
||||
let bootstrap: Bootstrap;
|
||||
let app: App;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ bootstrap, app } = await initStorage());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
await bootstrap.teardown();
|
||||
});
|
||||
|
||||
it('should archive/unarchive contacts', async () => {
|
||||
const { phone, contacts } = bootstrap;
|
||||
const [firstContact] = contacts;
|
||||
|
||||
const window = await app.getWindow();
|
||||
|
||||
const leftPane = window.locator('.left-pane-wrapper');
|
||||
const conversationStack = window.locator('.conversation-stack');
|
||||
|
||||
debug('archiving contact');
|
||||
{
|
||||
const state = await phone.expectStorageState('consistency check');
|
||||
|
||||
await phone.setStorageState(
|
||||
state
|
||||
.updateContact(firstContact, { archived: true })
|
||||
.unpin(firstContact)
|
||||
);
|
||||
await phone.sendFetchStorage({
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
});
|
||||
|
||||
await leftPane
|
||||
.locator(
|
||||
'_react=ConversationListItem' +
|
||||
`[title = ${JSON.stringify(firstContact.profileName)}]`
|
||||
)
|
||||
.waitFor({ state: 'hidden' });
|
||||
|
||||
await leftPane
|
||||
.locator('button.module-conversation-list__item--archive-button')
|
||||
.waitFor();
|
||||
}
|
||||
|
||||
debug('unarchiving pinned contact');
|
||||
{
|
||||
const state = await phone.expectStorageState('consistency check');
|
||||
|
||||
await phone.setStorageState(
|
||||
state.updateContact(firstContact, { archived: false }).pin(firstContact)
|
||||
);
|
||||
await phone.sendFetchStorage({
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
});
|
||||
|
||||
await leftPane
|
||||
.locator(
|
||||
'_react=ConversationListItem' +
|
||||
'[isPinned = true]' +
|
||||
`[title = ${JSON.stringify(firstContact.profileName)}]`
|
||||
)
|
||||
.waitFor();
|
||||
|
||||
await leftPane
|
||||
.locator('button.module-conversation-list__item--archive-button')
|
||||
.waitFor({ state: 'hidden' });
|
||||
}
|
||||
|
||||
debug('archive pinned contact in the app');
|
||||
{
|
||||
const state = await phone.expectStorageState('consistency check');
|
||||
|
||||
await leftPane
|
||||
.locator(
|
||||
'_react=ConversationListItem' +
|
||||
`[title = ${JSON.stringify(firstContact.profileName)}]`
|
||||
)
|
||||
.click();
|
||||
|
||||
const moreButton = conversationStack.locator(
|
||||
'button.module-ConversationHeader__button--more'
|
||||
);
|
||||
await moreButton.click();
|
||||
|
||||
const archiveButton = conversationStack.locator(
|
||||
'.react-contextmenu-item >> "Archive"'
|
||||
);
|
||||
await archiveButton.click();
|
||||
|
||||
const newState = await phone.waitForStorageState({
|
||||
after: state,
|
||||
});
|
||||
assert.ok(!(await newState.isPinned(firstContact)), 'contact not pinned');
|
||||
const record = await newState.getContact(firstContact);
|
||||
assert.ok(record, 'contact record not found');
|
||||
assert.ok(record?.archived, 'contact archived');
|
||||
|
||||
// AccountRecord + ContactRecord
|
||||
const { added, removed } = newState.diff(state);
|
||||
assert.strictEqual(added.length, 2, 'only two records must be added');
|
||||
assert.strictEqual(removed.length, 2, 'only two records must be removed');
|
||||
}
|
||||
|
||||
debug('Verifying the final manifest version');
|
||||
const finalState = await phone.expectStorageState('consistency check');
|
||||
|
||||
assert.strictEqual(finalState.version, 4);
|
||||
});
|
||||
});
|
98
ts/test-mock/storage/fixtures.ts
Normal file
98
ts/test-mock/storage/fixtures.ts
Normal file
|
@ -0,0 +1,98 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import createDebug from 'debug';
|
||||
import type { Group, PrimaryDevice } from '@signalapp/mock-server';
|
||||
import { StorageState, Proto } from '@signalapp/mock-server';
|
||||
import { App } from '../playwright';
|
||||
import { Bootstrap } from '../bootstrap';
|
||||
|
||||
export const debug = createDebug('mock:test-storage');
|
||||
|
||||
export { App, Bootstrap };
|
||||
|
||||
const GROUP_SIZE = 8;
|
||||
|
||||
export type InitStorageResultType = Readonly<{
|
||||
bootstrap: Bootstrap;
|
||||
app: App;
|
||||
group: Group;
|
||||
members: Array<PrimaryDevice>;
|
||||
}>;
|
||||
|
||||
//
|
||||
// This function creates an initial storage service state that includes:
|
||||
//
|
||||
// - All contacts from contact sync (first contact pinned)
|
||||
// - A pinned group with GROUP_SIZE members (from the contacts)
|
||||
// - Account with e164 and profileKey
|
||||
//
|
||||
// In addition to above, this function will queue one incoming message in the
|
||||
// group, and one for the first contact (so that both will appear in the left
|
||||
// pane).
|
||||
export async function initStorage(): Promise<InitStorageResultType> {
|
||||
// Creates primary device, contacts
|
||||
const bootstrap = new Bootstrap();
|
||||
|
||||
await bootstrap.init();
|
||||
|
||||
// Populate storage service
|
||||
const { contacts, phone } = bootstrap;
|
||||
|
||||
const [firstContact] = contacts;
|
||||
|
||||
const members = [...contacts].slice(0, GROUP_SIZE);
|
||||
|
||||
const group = await phone.createGroup({
|
||||
title: 'Mock Group',
|
||||
members: [phone, ...members],
|
||||
});
|
||||
|
||||
let state = StorageState.getEmpty();
|
||||
|
||||
state = state.updateAccount({
|
||||
profileKey: phone.profileKey.serialize(),
|
||||
e164: phone.device.number,
|
||||
});
|
||||
|
||||
state = state
|
||||
.addGroup(group, {
|
||||
whitelisted: true,
|
||||
})
|
||||
.pinGroup(group);
|
||||
|
||||
for (const contact of contacts) {
|
||||
state = state.addContact(contact, {
|
||||
identityState: Proto.ContactRecord.IdentityState.VERIFIED,
|
||||
whitelisted: true,
|
||||
|
||||
identityKey: contact.publicKey.serialize(),
|
||||
profileKey: contact.profileKey.serialize(),
|
||||
});
|
||||
}
|
||||
|
||||
state = state.pin(firstContact);
|
||||
|
||||
await phone.setStorageState(state);
|
||||
|
||||
// Link new device
|
||||
const app = await bootstrap.link();
|
||||
|
||||
const { desktop } = bootstrap;
|
||||
|
||||
// Send a message to the group and the first contact
|
||||
const contactSend = contacts[0].sendText(desktop, 'hello from contact', {
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
sealed: true,
|
||||
});
|
||||
|
||||
const groupSend = members[0].sendText(desktop, 'hello in group', {
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
sealed: true,
|
||||
group,
|
||||
});
|
||||
|
||||
await Promise.all([contactSend, groupSend]);
|
||||
|
||||
return { bootstrap, app, group, members };
|
||||
}
|
115
ts/test-mock/storage/message_request_test.ts
Normal file
115
ts/test-mock/storage/message_request_test.ts
Normal file
|
@ -0,0 +1,115 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import * as durations from '../../util/durations';
|
||||
import type { App, Bootstrap } from './fixtures';
|
||||
import { initStorage, debug } from './fixtures';
|
||||
|
||||
describe('storage service', function needsName() {
|
||||
this.timeout(durations.MINUTE);
|
||||
|
||||
let bootstrap: Bootstrap;
|
||||
let app: App;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ bootstrap, app } = await initStorage());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
await bootstrap.teardown();
|
||||
});
|
||||
|
||||
it('should handle message request state changes', async () => {
|
||||
const { phone, desktop, server } = bootstrap;
|
||||
|
||||
debug('Creating stranger');
|
||||
const stranger = await server.createPrimaryDevice({
|
||||
profileName: 'Mysterious Stranger',
|
||||
});
|
||||
|
||||
const ourKey = await desktop.popSingleUseKey();
|
||||
await stranger.addSingleUseKey(desktop, ourKey);
|
||||
|
||||
debug('Sending a message from a stranger');
|
||||
await stranger.sendText(desktop, 'Hello!', {
|
||||
withProfileKey: true,
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
});
|
||||
|
||||
const window = await app.getWindow();
|
||||
|
||||
const leftPane = window.locator('.left-pane-wrapper');
|
||||
const conversationStack = window.locator('.conversation-stack');
|
||||
|
||||
debug('Opening conversation with a stranger');
|
||||
await leftPane
|
||||
.locator(
|
||||
'_react=ConversationListItem' +
|
||||
`[title = ${JSON.stringify(stranger.profileName)}]`
|
||||
)
|
||||
.click();
|
||||
|
||||
const initialState = await phone.expectStorageState('initial state');
|
||||
assert.strictEqual(initialState.version, 1);
|
||||
assert.isUndefined(initialState.getContact(stranger));
|
||||
|
||||
debug('Accept conversation from a stranger');
|
||||
await conversationStack
|
||||
.locator('.module-message-request-actions button >> "Accept"')
|
||||
.click();
|
||||
|
||||
debug('Verify that storage state was updated');
|
||||
{
|
||||
const nextState = await phone.waitForStorageState({
|
||||
after: initialState,
|
||||
});
|
||||
assert.strictEqual(nextState.version, 2);
|
||||
assert.isTrue(nextState.getContact(stranger)?.whitelisted);
|
||||
|
||||
// ContactRecord
|
||||
const { added, removed } = nextState.diff(initialState);
|
||||
assert.strictEqual(added.length, 1, 'only one record must be added');
|
||||
assert.strictEqual(removed.length, 0, 'no records should be removed');
|
||||
}
|
||||
|
||||
// Stranger should receive our profile key
|
||||
{
|
||||
const { body, source, dataMessage } = await stranger.waitForMessage();
|
||||
assert.strictEqual(body, '', 'profile key message has no body');
|
||||
assert.strictEqual(
|
||||
source,
|
||||
desktop,
|
||||
'profile key message has valid source'
|
||||
);
|
||||
assert.isTrue(
|
||||
phone.profileKey
|
||||
.serialize()
|
||||
.equals(dataMessage.profileKey ?? new Uint8Array(0)),
|
||||
'profile key message has correct profile key'
|
||||
);
|
||||
}
|
||||
|
||||
debug('Enter message text');
|
||||
const composeArea = window.locator(
|
||||
'.composition-area-wrapper, ' +
|
||||
'.ConversationView__template .react-wrapper'
|
||||
);
|
||||
const input = composeArea.locator('_react=CompositionInput');
|
||||
|
||||
await input.type('hello stranger!');
|
||||
await input.press('Enter');
|
||||
|
||||
{
|
||||
const { body, source } = await stranger.waitForMessage();
|
||||
assert.strictEqual(body, 'hello stranger!', 'text message has body');
|
||||
assert.strictEqual(source, desktop, 'text message has valid source');
|
||||
}
|
||||
|
||||
debug('Verifying the final manifest version');
|
||||
const finalState = await phone.expectStorageState('consistency check');
|
||||
assert.strictEqual(finalState.version, 2);
|
||||
});
|
||||
});
|
160
ts/test-mock/storage/pin_unpin_test.ts
Normal file
160
ts/test-mock/storage/pin_unpin_test.ts
Normal file
|
@ -0,0 +1,160 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import type { Group } from '@signalapp/mock-server';
|
||||
|
||||
import * as durations from '../../util/durations';
|
||||
import type { App, Bootstrap } from './fixtures';
|
||||
import { initStorage, debug } from './fixtures';
|
||||
|
||||
describe('storage service', function needsName() {
|
||||
this.timeout(durations.MINUTE);
|
||||
|
||||
let bootstrap: Bootstrap;
|
||||
let app: App;
|
||||
let group: Group;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ bootstrap, app, group } = await initStorage());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
await bootstrap.teardown();
|
||||
});
|
||||
|
||||
it('should pin/unpin groups', async () => {
|
||||
const { phone, desktop, contacts } = bootstrap;
|
||||
|
||||
const window = await app.getWindow();
|
||||
|
||||
const leftPane = window.locator('.left-pane-wrapper');
|
||||
const conversationStack = window.locator('.conversation-stack');
|
||||
|
||||
debug('Verifying that the group is pinned on startup');
|
||||
await leftPane
|
||||
.locator(
|
||||
'_react=ConversationListItem' +
|
||||
'[isPinned = true] ' +
|
||||
`[title = ${JSON.stringify(group.title)}]`
|
||||
)
|
||||
.waitFor();
|
||||
|
||||
debug('Unpinning group via storage service');
|
||||
{
|
||||
const state = await phone.expectStorageState('initial state');
|
||||
|
||||
await phone.setStorageState(state.unpinGroup(group));
|
||||
await phone.sendFetchStorage({
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
});
|
||||
|
||||
await leftPane
|
||||
.locator(
|
||||
'_react=ConversationListItem' +
|
||||
'[isPinned = false] ' +
|
||||
`[title = ${JSON.stringify(group.title)}]`
|
||||
)
|
||||
.waitFor();
|
||||
}
|
||||
|
||||
debug('Pinning group in the app');
|
||||
{
|
||||
const state = await phone.expectStorageState('consistency check');
|
||||
|
||||
const convo = leftPane.locator(
|
||||
'_react=ConversationListItem' +
|
||||
'[isPinned = false] ' +
|
||||
`[title = ${JSON.stringify(group.title)}]`
|
||||
);
|
||||
await convo.click();
|
||||
|
||||
const moreButton = conversationStack.locator(
|
||||
'button.module-ConversationHeader__button--more'
|
||||
);
|
||||
await moreButton.click();
|
||||
|
||||
const pinButton = conversationStack.locator(
|
||||
'.react-contextmenu-item >> "Pin Conversation"'
|
||||
);
|
||||
await pinButton.click();
|
||||
|
||||
const newState = await phone.waitForStorageState({
|
||||
after: state,
|
||||
});
|
||||
assert.isTrue(await newState.isGroupPinned(group), 'group not pinned');
|
||||
|
||||
// AccountRecord
|
||||
const { added, removed } = newState.diff(state);
|
||||
assert.strictEqual(added.length, 1, 'only one record must be added');
|
||||
assert.strictEqual(removed.length, 1, 'only one record must be removed');
|
||||
}
|
||||
|
||||
debug('Pinning > 4 conversations');
|
||||
{
|
||||
// We already have one group and first contact pinned so we need three
|
||||
// more.
|
||||
const toPin = contacts.slice(1, 4);
|
||||
|
||||
// To do that we need them to appear in the left pane, though.
|
||||
for (const [i, contact] of toPin.entries()) {
|
||||
const isLast = i === toPin.length - 1;
|
||||
|
||||
debug('sending a message to contact=%d', i);
|
||||
await contact.sendText(desktop, 'Hello!', {
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
});
|
||||
|
||||
const state = await phone.expectStorageState('consistency check');
|
||||
|
||||
debug('pinning contact=%d', i);
|
||||
const convo = leftPane.locator(
|
||||
'_react=ConversationListItem' +
|
||||
`[title = ${JSON.stringify(contact.profileName)}]`
|
||||
);
|
||||
await convo.click();
|
||||
|
||||
const moreButton = conversationStack.locator(
|
||||
'button.module-ConversationHeader__button--more'
|
||||
);
|
||||
await moreButton.click();
|
||||
|
||||
const pinButton = conversationStack.locator(
|
||||
'.react-contextmenu-item >> "Pin Conversation"'
|
||||
);
|
||||
await pinButton.click();
|
||||
|
||||
if (isLast) {
|
||||
// Storage state shouldn't be updated because we failed to pin
|
||||
await window
|
||||
.locator('.Toast >> "You can only pin up to 4 chats"')
|
||||
.waitFor();
|
||||
break;
|
||||
}
|
||||
|
||||
debug('verifying storage state change contact=%d', i);
|
||||
const newState = await phone.waitForStorageState({
|
||||
after: state,
|
||||
});
|
||||
assert.isTrue(await newState.isPinned(contact), 'contact not pinned');
|
||||
|
||||
// AccountRecord
|
||||
const { added, removed } = newState.diff(state);
|
||||
assert.strictEqual(added.length, 1, 'only one record must be added');
|
||||
assert.strictEqual(
|
||||
removed.length,
|
||||
1,
|
||||
'only one record must be removed'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
debug('Verifying the final manifest version');
|
||||
const finalState = await phone.expectStorageState('consistency check');
|
||||
|
||||
assert.strictEqual(finalState.version, 5);
|
||||
});
|
||||
});
|
|
@ -167,6 +167,27 @@
|
|||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-01-27T20:06:59.988Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/@malept/flatpak-bundler/node_modules/debug/src/browser.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/@malept/flatpak-bundler/node_modules/debug/src/common.js",
|
||||
"line": "\tcreateDebug.enable(createDebug.load());",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/@malept/flatpak-bundler/node_modules/debug/src/node.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "eval",
|
||||
"path": "node_modules/@protobufjs/inquire/index.js",
|
||||
|
@ -595,6 +616,28 @@
|
|||
"updated": "2020-08-28T16:12:19.904Z",
|
||||
"reasonDetail": "isn't jquery"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/agent-base/node_modules/debug/src/browser.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/agent-base/node_modules/debug/src/common.js",
|
||||
"line": "\tcreateDebug.enable(createDebug.load());",
|
||||
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
|
||||
"updated": "2022-02-11T21:58:24.827Z",
|
||||
"reasonDetail": "<optional>"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/agent-base/node_modules/debug/src/node.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-wrap(",
|
||||
"path": "node_modules/asar/node_modules/commander/index.js",
|
||||
|
@ -1255,6 +1298,27 @@
|
|||
"reasonCategory": "falseMatch",
|
||||
"updated": "2018-09-19T18:13:29.628Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/electron-notarize/node_modules/debug/src/browser.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/electron-notarize/node_modules/debug/src/common.js",
|
||||
"line": "\tcreateDebug.enable(createDebug.load());",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/electron-notarize/node_modules/debug/src/node.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-append(",
|
||||
"path": "node_modules/enquirer/lib/prompts/autocomplete.js",
|
||||
|
@ -1544,6 +1608,27 @@
|
|||
"reasonCategory": "falseMatch",
|
||||
"updated": "2020-09-11T17:24:56.124Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/get-uri/node_modules/debug/src/browser.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/get-uri/node_modules/debug/src/common.js",
|
||||
"line": "\tcreateDebug.enable(createDebug.load());",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/get-uri/node_modules/debug/src/node.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-$(",
|
||||
"path": "node_modules/global-dirs/index.js",
|
||||
|
@ -1670,6 +1755,48 @@
|
|||
"reasonCategory": "falseMatch",
|
||||
"updated": "2021-11-13T01:38:33.299Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/http-proxy-agent/node_modules/debug/src/browser.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/http-proxy-agent/node_modules/debug/src/common.js",
|
||||
"line": "\tcreateDebug.enable(createDebug.load());",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/http-proxy-agent/node_modules/debug/src/node.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/https-proxy-agent/node_modules/debug/src/browser.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/https-proxy-agent/node_modules/debug/src/common.js",
|
||||
"line": "\tcreateDebug.enable(createDebug.load());",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/https-proxy-agent/node_modules/debug/src/node.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-$(",
|
||||
"path": "node_modules/immutable/dist/immutable.min.js",
|
||||
|
@ -4303,6 +4430,13 @@
|
|||
"reasonCategory": "falseMatch",
|
||||
"updated": "2019-03-09T00:08:44.242Z"
|
||||
},
|
||||
{
|
||||
"rule": "eval",
|
||||
"path": "node_modules/micro/node_modules/depd/index.js",
|
||||
"line": " var deprecatedfn = eval('(function (' + args + ') {\\n' +",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "DOM-innerHTML",
|
||||
"path": "node_modules/min-document/serialize.js",
|
||||
|
@ -4469,6 +4603,27 @@
|
|||
"reasonCategory": "falseMatch",
|
||||
"updated": "2020-09-14T16:19:54.461Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/pac-proxy-agent/node_modules/debug/src/browser.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/pac-proxy-agent/node_modules/debug/src/common.js",
|
||||
"line": "\tcreateDebug.enable(createDebug.load());",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/pac-proxy-agent/node_modules/debug/src/node.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "DOM-innerHTML",
|
||||
"path": "node_modules/package-json/node_modules/@sindresorhus/is/dist/index.js",
|
||||
|
@ -5138,6 +5293,27 @@
|
|||
"reasonCategory": "falseMatch",
|
||||
"updated": "2018-09-15T00:38:04.183Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/proxy-agent/node_modules/debug/src/browser.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/proxy-agent/node_modules/debug/src/common.js",
|
||||
"line": "\tcreateDebug.enable(createDebug.load());",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/proxy-agent/node_modules/debug/src/node.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "DOM-document.write(",
|
||||
"path": "node_modules/qrcode-generator/sample.js",
|
||||
|
@ -6292,6 +6468,27 @@
|
|||
"reasonCategory": "falseMatch",
|
||||
"updated": "2018-09-15T00:38:04.183Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/socks-proxy-agent/node_modules/debug/src/browser.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/socks-proxy-agent/node_modules/debug/src/common.js",
|
||||
"line": "\tcreateDebug.enable(createDebug.load());",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/socks-proxy-agent/node_modules/debug/src/node.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-append(",
|
||||
"path": "node_modules/socks/build/client/socksclient.js",
|
||||
|
@ -6395,6 +6592,27 @@
|
|||
"reasonCategory": "falseMatch",
|
||||
"updated": "2020-04-25T01:47:02.583Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/sumchecker/node_modules/debug/src/browser.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/sumchecker/node_modules/debug/src/common.js",
|
||||
"line": "\tcreateDebug.enable(createDebug.load());",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "node_modules/sumchecker/node_modules/debug/src/node.js",
|
||||
"line": "function load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2022-02-11T21:58:24.827Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-append(",
|
||||
"path": "node_modules/table/dist/createStream.js",
|
||||
|
|
|
@ -111,6 +111,7 @@ const excludedFilesRegexp = RegExp(
|
|||
'^node_modules/esbuild/.+',
|
||||
'^node_modules/@babel/.+',
|
||||
'^node_modules/@chanzuckerberg/axe-storybook-testing/.+',
|
||||
'^node_modules/@signalapp/mock-server/.+',
|
||||
'^node_modules/@svgr/.+',
|
||||
'^node_modules/@types/.+',
|
||||
'^node_modules/@webassemblyjs/.+',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue