Groups: On log fetch error from current revision, use joined_at_version
This commit is contained in:
parent
78a95c23bb
commit
a435b21a56
7 changed files with 92 additions and 32 deletions
5
.github/workflows/benchmark.yml
vendored
5
.github/workflows/benchmark.yml
vendored
|
@ -65,6 +65,7 @@ jobs:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
RUN_COUNT: 10
|
RUN_COUNT: 10
|
||||||
ELECTRON_ENABLE_STACK_DUMPING: on
|
ELECTRON_ENABLE_STACK_DUMPING: on
|
||||||
|
DEBUG: 'mock:benchmarks'
|
||||||
ARTIFACTS_DIR: artifacts/startup
|
ARTIFACTS_DIR: artifacts/startup
|
||||||
|
|
||||||
- name: Run send benchmarks
|
- name: Run send benchmarks
|
||||||
|
@ -78,6 +79,7 @@ jobs:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
RUN_COUNT: 100
|
RUN_COUNT: 100
|
||||||
ELECTRON_ENABLE_STACK_DUMPING: on
|
ELECTRON_ENABLE_STACK_DUMPING: on
|
||||||
|
# DEBUG: 'mock:benchmarks'
|
||||||
ARTIFACTS_DIR: artifacts/send
|
ARTIFACTS_DIR: artifacts/send
|
||||||
|
|
||||||
- name: Run group send benchmarks
|
- name: Run group send benchmarks
|
||||||
|
@ -93,6 +95,7 @@ jobs:
|
||||||
RUN_COUNT: 100
|
RUN_COUNT: 100
|
||||||
CONVERSATION_SIZE: 500
|
CONVERSATION_SIZE: 500
|
||||||
ELECTRON_ENABLE_STACK_DUMPING: on
|
ELECTRON_ENABLE_STACK_DUMPING: on
|
||||||
|
# DEBUG: 'mock:benchmarks'
|
||||||
ARTIFACTS_DIR: artifacts/group-send
|
ARTIFACTS_DIR: artifacts/group-send
|
||||||
|
|
||||||
- name: Run large group send benchmarks with delivery receipts
|
- name: Run large group send benchmarks with delivery receipts
|
||||||
|
@ -112,6 +115,7 @@ jobs:
|
||||||
RUN_COUNT: 20
|
RUN_COUNT: 20
|
||||||
CONVERSATION_SIZE: 50
|
CONVERSATION_SIZE: 50
|
||||||
ELECTRON_ENABLE_STACK_DUMPING: on
|
ELECTRON_ENABLE_STACK_DUMPING: on
|
||||||
|
# DEBUG: 'mock:benchmarks'
|
||||||
ARTIFACTS_DIR: artifacts/large-group-send
|
ARTIFACTS_DIR: artifacts/large-group-send
|
||||||
|
|
||||||
- name: Run conversation open benchmarks
|
- name: Run conversation open benchmarks
|
||||||
|
@ -126,6 +130,7 @@ jobs:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
RUN_COUNT: 100
|
RUN_COUNT: 100
|
||||||
ELECTRON_ENABLE_STACK_DUMPING: on
|
ELECTRON_ENABLE_STACK_DUMPING: on
|
||||||
|
# DEBUG: 'mock:benchmarks'
|
||||||
ARTIFACTS_DIR: artifacts/convo-open
|
ARTIFACTS_DIR: artifacts/convo-open
|
||||||
|
|
||||||
- name: Upload benchmark logs on failure
|
- name: Upload benchmark logs on failure
|
||||||
|
|
76
ts/groups.ts
76
ts/groups.ts
|
@ -751,7 +751,7 @@ export async function buildAddMembersChange(
|
||||||
addPendingMembers.push(addPendingMemberAction);
|
addPendingMembers.push(addPendingMemberAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
const doesMemberNeedUnban = conversation.bannedMembersV2?.find(
|
const doesMemberNeedUnban = conversation.bannedMembersV2?.some(
|
||||||
bannedMember => bannedMember.serviceId === serviceId
|
bannedMember => bannedMember.serviceId === serviceId
|
||||||
);
|
);
|
||||||
if (doesMemberNeedUnban) {
|
if (doesMemberNeedUnban) {
|
||||||
|
@ -1020,7 +1020,7 @@ export function _maybeBuildAddBannedMemberActions({
|
||||||
'addMembersBanned' | 'deleteMembersBanned'
|
'addMembersBanned' | 'deleteMembersBanned'
|
||||||
> {
|
> {
|
||||||
const doesMemberNeedBan =
|
const doesMemberNeedBan =
|
||||||
!group.bannedMembersV2?.find(member => member.serviceId === serviceId) &&
|
!group.bannedMembersV2?.some(member => member.serviceId === serviceId) &&
|
||||||
serviceId !== ourAci;
|
serviceId !== ourAci;
|
||||||
if (!doesMemberNeedBan) {
|
if (!doesMemberNeedBan) {
|
||||||
return {};
|
return {};
|
||||||
|
@ -1191,7 +1191,7 @@ export function buildAddMember({
|
||||||
actions.version = (group.revision || 0) + 1;
|
actions.version = (group.revision || 0) + 1;
|
||||||
actions.addMembers = [addMember];
|
actions.addMembers = [addMember];
|
||||||
|
|
||||||
const doesMemberNeedUnban = group.bannedMembersV2?.find(
|
const doesMemberNeedUnban = group.bannedMembersV2?.some(
|
||||||
member => member.serviceId === serviceId
|
member => member.serviceId === serviceId
|
||||||
);
|
);
|
||||||
if (doesMemberNeedUnban) {
|
if (doesMemberNeedUnban) {
|
||||||
|
@ -3501,7 +3501,7 @@ async function getGroupUpdates({
|
||||||
const ourAci = window.storage.user.getCheckedAci();
|
const ourAci = window.storage.user.getCheckedAci();
|
||||||
|
|
||||||
const isInitialCreationMessage = isFirstFetch && newRevision === 0;
|
const isInitialCreationMessage = isFirstFetch && newRevision === 0;
|
||||||
const weAreAwaitingApproval = (group.pendingAdminApprovalV2 || []).find(
|
const weAreAwaitingApproval = (group.pendingAdminApprovalV2 || []).some(
|
||||||
item => item.aci === ourAci
|
item => item.aci === ourAci
|
||||||
);
|
);
|
||||||
const isOneVersionUp =
|
const isOneVersionUp =
|
||||||
|
@ -3565,18 +3565,18 @@ async function getGroupUpdates({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const areWeMember = (group.membersV2 || []).some(item => item.aci === ourAci);
|
||||||
(!isFirstFetch || isNumber(newRevision)) &&
|
const isReJoin = !isFirstFetch && !areWeMember;
|
||||||
window.Flags.GV2_ENABLE_CHANGE_PROCESSING
|
|
||||||
) {
|
if (window.Flags.GV2_ENABLE_CHANGE_PROCESSING) {
|
||||||
try {
|
try {
|
||||||
return await updateGroupViaLogs({
|
return await updateGroupViaLogs({
|
||||||
group,
|
group,
|
||||||
newRevision,
|
newRevision,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const nextStep = isFirstFetch
|
const nextStep = isReJoin
|
||||||
? `fetching logs since ${newRevision}`
|
? 'attempting to fetch from re-join revision'
|
||||||
: 'fetching full state';
|
: 'fetching full state';
|
||||||
|
|
||||||
if (error.code === TEMPORAL_AUTH_REJECTED_CODE) {
|
if (error.code === TEMPORAL_AUTH_REJECTED_CODE) {
|
||||||
|
@ -3595,6 +3595,30 @@ async function getGroupUpdates({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isReJoin && window.Flags.GV2_ENABLE_CHANGE_PROCESSING) {
|
||||||
|
try {
|
||||||
|
return await updateGroupViaLogs({
|
||||||
|
group,
|
||||||
|
newRevision,
|
||||||
|
isReJoin,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === TEMPORAL_AUTH_REJECTED_CODE) {
|
||||||
|
// We will fail over to the updateGroupViaState call below
|
||||||
|
log.info(
|
||||||
|
`getGroupUpdates/${logId}: Temporal credential failure, now fetching full state`
|
||||||
|
);
|
||||||
|
} else if (error.code === GROUP_ACCESS_DENIED_CODE) {
|
||||||
|
// We will fail over to the updateGroupViaState call below
|
||||||
|
log.info(
|
||||||
|
`getGroupUpdates/${logId}: Log access denied, now fetching full state`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (window.Flags.GV2_ENABLE_STATE_PROCESSING) {
|
if (window.Flags.GV2_ENABLE_STATE_PROCESSING) {
|
||||||
try {
|
try {
|
||||||
return await updateGroupViaState({
|
return await updateGroupViaState({
|
||||||
|
@ -3887,9 +3911,11 @@ async function updateGroupViaSingleChange({
|
||||||
async function updateGroupViaLogs({
|
async function updateGroupViaLogs({
|
||||||
group,
|
group,
|
||||||
newRevision,
|
newRevision,
|
||||||
|
isReJoin,
|
||||||
}: {
|
}: {
|
||||||
group: ConversationAttributesType;
|
group: ConversationAttributesType;
|
||||||
newRevision: number | undefined;
|
newRevision: number | undefined;
|
||||||
|
isReJoin?: boolean;
|
||||||
}): Promise<UpdatesResultType> {
|
}): Promise<UpdatesResultType> {
|
||||||
const logId = idForLogging(group.groupId);
|
const logId = idForLogging(group.groupId);
|
||||||
const { publicParams, secretParams } = group;
|
const { publicParams, secretParams } = group;
|
||||||
|
@ -3900,15 +3926,15 @@ async function updateGroupViaLogs({
|
||||||
throw new Error('updateGroupViaLogs: group was missing secretParams!');
|
throw new Error('updateGroupViaLogs: group was missing secretParams!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentRevision = isReJoin ? undefined : group.revision;
|
||||||
|
let includeFirstState = true;
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
`updateGroupViaLogs/${logId}: Getting group delta from ` +
|
`updateGroupViaLogs/${logId}: Getting group delta from ` +
|
||||||
`${group.revision ?? '?'} to ${newRevision ?? '?'} for group ` +
|
`${currentRevision ?? '?'} to ${newRevision ?? '?'} for group ` +
|
||||||
`groupv2(${group.groupId})...`
|
`groupv2(${group.groupId})...`
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentRevision = group.revision;
|
|
||||||
let includeFirstState = true;
|
|
||||||
|
|
||||||
// The range is inclusive so make sure that we always request the revision
|
// The range is inclusive so make sure that we always request the revision
|
||||||
// that we are currently at since we might want the latest full state in
|
// that we are currently at since we might want the latest full state in
|
||||||
// `integrateGroupChanges`.
|
// `integrateGroupChanges`.
|
||||||
|
@ -4217,9 +4243,13 @@ async function integrateGroupChange({
|
||||||
|
|
||||||
const isFirstFetch = !isNumber(group.revision);
|
const isFirstFetch = !isNumber(group.revision);
|
||||||
const ourAci = window.storage.user.getCheckedAci();
|
const ourAci = window.storage.user.getCheckedAci();
|
||||||
const weAreAwaitingApproval = (group.pendingAdminApprovalV2 || []).find(
|
const weAreAwaitingApproval = (group.pendingAdminApprovalV2 || []).some(
|
||||||
item => item.aci === ourAci
|
item => item.aci === ourAci
|
||||||
);
|
);
|
||||||
|
const weAreInGroup = (group.membersV2 || []).some(
|
||||||
|
item => item.aci === ourAci
|
||||||
|
);
|
||||||
|
const isReJoin = !isFirstFetch && !weAreInGroup;
|
||||||
|
|
||||||
// These need to be populated from the groupChange. But we might not get one!
|
// These need to be populated from the groupChange. But we might not get one!
|
||||||
let isChangeSupported = false;
|
let isChangeSupported = false;
|
||||||
|
@ -4347,6 +4377,7 @@ async function integrateGroupChange({
|
||||||
isChangePresent: Boolean(groupChange),
|
isChangePresent: Boolean(groupChange),
|
||||||
isChangeSupported,
|
isChangeSupported,
|
||||||
isFirstFetch,
|
isFirstFetch,
|
||||||
|
isReJoin,
|
||||||
isSameVersion,
|
isSameVersion,
|
||||||
isMoreThanOneVersionUp,
|
isMoreThanOneVersionUp,
|
||||||
weAreAwaitingApproval,
|
weAreAwaitingApproval,
|
||||||
|
@ -4366,13 +4397,14 @@ async function integrateGroupChange({
|
||||||
} = await applyGroupState({
|
} = await applyGroupState({
|
||||||
group: attributes,
|
group: attributes,
|
||||||
groupState: decryptedGroupState,
|
groupState: decryptedGroupState,
|
||||||
sourceServiceId: isFirstFetch ? sourceServiceId : undefined,
|
sourceServiceId: isFirstFetch || isReJoin ? sourceServiceId : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupChangeMessages = extractDiffs({
|
const groupChangeMessages = extractDiffs({
|
||||||
old: attributes,
|
old: attributes,
|
||||||
current: newAttributes,
|
current: newAttributes,
|
||||||
sourceServiceId: isFirstFetch ? sourceServiceId : undefined,
|
sourceServiceId: isFirstFetch || isReJoin ? sourceServiceId : undefined,
|
||||||
|
isReJoin,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newProfileKeys = profileKeysToMap(newProfileKeysList);
|
const newProfileKeys = profileKeysToMap(newProfileKeysList);
|
||||||
|
@ -4419,15 +4451,17 @@ async function integrateGroupChange({
|
||||||
function extractDiffs({
|
function extractDiffs({
|
||||||
current,
|
current,
|
||||||
dropInitialJoinMessage,
|
dropInitialJoinMessage,
|
||||||
|
isReJoin,
|
||||||
old,
|
old,
|
||||||
sourceServiceId,
|
|
||||||
promotedAciToPniMap,
|
promotedAciToPniMap,
|
||||||
|
sourceServiceId,
|
||||||
}: {
|
}: {
|
||||||
current: ConversationAttributesType;
|
current: ConversationAttributesType;
|
||||||
dropInitialJoinMessage?: boolean;
|
dropInitialJoinMessage?: boolean;
|
||||||
|
isReJoin?: boolean;
|
||||||
old: ConversationAttributesType;
|
old: ConversationAttributesType;
|
||||||
sourceServiceId?: ServiceIdString;
|
|
||||||
promotedAciToPniMap?: ReadonlyMap<AciString, PniString>;
|
promotedAciToPniMap?: ReadonlyMap<AciString, PniString>;
|
||||||
|
sourceServiceId?: ServiceIdString;
|
||||||
}): Array<GroupChangeMessageType> {
|
}): Array<GroupChangeMessageType> {
|
||||||
const logId = idForLogging(old.groupId);
|
const logId = idForLogging(old.groupId);
|
||||||
const details: Array<GroupV2ChangeDetailType> = [];
|
const details: Array<GroupV2ChangeDetailType> = [];
|
||||||
|
@ -4941,11 +4975,11 @@ function extractDiffs({
|
||||||
timerNotification = {
|
timerNotification = {
|
||||||
...generateBasicMessage(),
|
...generateBasicMessage(),
|
||||||
type: 'timer-notification',
|
type: 'timer-notification',
|
||||||
sourceServiceId,
|
sourceServiceId: isReJoin ? undefined : sourceServiceId,
|
||||||
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
||||||
expirationTimerUpdate: {
|
expirationTimerUpdate: {
|
||||||
expireTimer,
|
expireTimer,
|
||||||
sourceServiceId,
|
sourceServiceId: isReJoin ? undefined : sourceServiceId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -264,9 +264,9 @@ export class ConversationModel extends window.Backbone
|
||||||
|
|
||||||
throttledBumpTyping?: () => void;
|
throttledBumpTyping?: () => void;
|
||||||
|
|
||||||
throttledFetchSMSOnlyUUID?: () => Promise<void> | undefined;
|
throttledFetchSMSOnlyUUID?: () => Promise<void>;
|
||||||
|
|
||||||
throttledMaybeMigrateV1Group?: () => Promise<void> | undefined;
|
throttledMaybeMigrateV1Group?: () => Promise<void>;
|
||||||
|
|
||||||
throttledGetProfiles?: () => Promise<void>;
|
throttledGetProfiles?: () => Promise<void>;
|
||||||
|
|
||||||
|
|
|
@ -4297,6 +4297,7 @@ function onConversationOpened(
|
||||||
| SetQuotedMessageActionType
|
| SetQuotedMessageActionType
|
||||||
> {
|
> {
|
||||||
return async (dispatch, getState) => {
|
return async (dispatch, getState) => {
|
||||||
|
const promises: Array<Promise<void>> = [];
|
||||||
const conversation = window.ConversationController.get(conversationId);
|
const conversation = window.ConversationController.get(conversationId);
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
throw new Error('onConversationOpened: Conversation not found');
|
throw new Error('onConversationOpened: Conversation not found');
|
||||||
|
@ -4328,7 +4329,7 @@ function onConversationOpened(
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
drop(loadAndUpdate());
|
promises.push(loadAndUpdate());
|
||||||
|
|
||||||
dispatch(setComposerFocus(conversation.id));
|
dispatch(setComposerFocus(conversation.id));
|
||||||
|
|
||||||
|
@ -4341,17 +4342,17 @@ function onConversationOpened(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
drop(conversation.fetchLatestGroupV2Data());
|
promises.push(conversation.fetchLatestGroupV2Data());
|
||||||
strictAssert(
|
strictAssert(
|
||||||
conversation.throttledMaybeMigrateV1Group !== undefined,
|
conversation.throttledMaybeMigrateV1Group !== undefined,
|
||||||
'Conversation model should be initialized'
|
'Conversation model should be initialized'
|
||||||
);
|
);
|
||||||
drop(conversation.throttledMaybeMigrateV1Group());
|
promises.push(conversation.throttledMaybeMigrateV1Group());
|
||||||
strictAssert(
|
strictAssert(
|
||||||
conversation.throttledFetchSMSOnlyUUID !== undefined,
|
conversation.throttledFetchSMSOnlyUUID !== undefined,
|
||||||
'Conversation model should be initialized'
|
'Conversation model should be initialized'
|
||||||
);
|
);
|
||||||
drop(conversation.throttledFetchSMSOnlyUUID());
|
promises.push(conversation.throttledFetchSMSOnlyUUID());
|
||||||
|
|
||||||
const ourAci = window.textsecure.storage.user.getAci();
|
const ourAci = window.textsecure.storage.user.getAci();
|
||||||
if (
|
if (
|
||||||
|
@ -4367,13 +4368,18 @@ function onConversationOpened(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
drop(conversation.updateVerified());
|
promises.push(conversation.updateVerified());
|
||||||
|
|
||||||
replaceAttachments(
|
replaceAttachments(
|
||||||
conversation.get('id'),
|
conversation.get('id'),
|
||||||
conversation.get('draftAttachments') || []
|
conversation.get('draftAttachments') || []
|
||||||
)(dispatch, getState, undefined);
|
)(dispatch, getState, undefined);
|
||||||
dispatch(resetComposer(conversationId));
|
dispatch(resetComposer(conversationId));
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
if (window.SignalCI) {
|
||||||
|
window.SignalCI.handleEvent('conversationOpenComplete', null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,6 @@ import {
|
||||||
} from './fixtures';
|
} from './fixtures';
|
||||||
import { stats } from '../../util/benchmark/stats';
|
import { stats } from '../../util/benchmark/stats';
|
||||||
import { sleep } from '../../util/sleep';
|
import { sleep } from '../../util/sleep';
|
||||||
import { MINUTE } from '../../util/durations';
|
|
||||||
import { typeIntoInput } from '../helpers';
|
import { typeIntoInput } from '../helpers';
|
||||||
|
|
||||||
const LAST_MESSAGE = 'start sending messages now';
|
const LAST_MESSAGE = 'start sending messages now';
|
||||||
|
@ -31,8 +30,9 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise<void> => {
|
||||||
|
|
||||||
const members = [...contacts].slice(0, GROUP_SIZE);
|
const members = [...contacts].slice(0, GROUP_SIZE);
|
||||||
|
|
||||||
|
const GROUP_NAME = 'Mock Group';
|
||||||
const group = await phone.createGroup({
|
const group = await phone.createGroup({
|
||||||
title: 'Mock Group',
|
title: GROUP_NAME,
|
||||||
members: [phone, ...members],
|
members: [phone, ...members],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -115,10 +115,18 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise<void> => {
|
||||||
const item = leftPane
|
const item = leftPane
|
||||||
.locator(
|
.locator(
|
||||||
'.module-conversation-list__item--contact-or-conversation' +
|
'.module-conversation-list__item--contact-or-conversation' +
|
||||||
`>> text="${LAST_MESSAGE}"`
|
`>> text="${GROUP_NAME}"`
|
||||||
)
|
)
|
||||||
.first();
|
.first();
|
||||||
await item.click({ timeout: 3 * MINUTE });
|
await item.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
debug('finding message in timeline');
|
||||||
|
{
|
||||||
|
const item = window
|
||||||
|
.locator(`.module-message >> text="${LAST_MESSAGE}"`)
|
||||||
|
.first();
|
||||||
|
await item.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
const deltaList = new Array<number>();
|
const deltaList = new Array<number>();
|
||||||
|
|
|
@ -184,6 +184,10 @@ export class App extends EventEmitter {
|
||||||
return this.waitForEvent('unlinkCleanupComplete');
|
return this.waitForEvent('unlinkCleanupComplete');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async waitForConversationOpenComplete(): Promise<void> {
|
||||||
|
return this.waitForEvent('conversationOpenComplete');
|
||||||
|
}
|
||||||
|
|
||||||
// EventEmitter types
|
// EventEmitter types
|
||||||
|
|
||||||
public override on(type: 'close', callback: () => void): this;
|
public override on(type: 'close', callback: () => void): this;
|
||||||
|
|
|
@ -84,6 +84,9 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
|
||||||
|
|
||||||
debug('Opening group');
|
debug('Opening group');
|
||||||
await leftPane.locator(`[data-testid="${group.id}"]`).click();
|
await leftPane.locator(`[data-testid="${group.id}"]`).click();
|
||||||
|
|
||||||
|
debug('Wait for conversation open fetches to complete');
|
||||||
|
await app.waitForConversationOpenComplete();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async function (this: Mocha.Context) {
|
afterEach(async function (this: Mocha.Context) {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue