test(integration): make reconcile-dependent counter assertions robust
The single `forceSync` + assert pattern flaked under full-suite load: one reconcile can miss (or a shared/cross-account counter refresh lands late) with no retry, so the assertion polls stale DOM until timeout. The flake moved between reconcile-dependent tests (server-side move, spam source drain, unified / shared counters) run to run. - Add expectFolderCountsSynced(): nudges a reconcile (visibilitychange -> checkForStateChanges) before *every* poll, so a missed reconcile is retried for the full window. Compares only the provided unread/total fields. - Use it for the reconcile-dependent counter checks in 02 (move/delete), 03 (multi-account isolation + unified aggregation), 04 (All Mail), 05 (spam source), 06 (shared folders); drop the now-redundant standalone forceSync. Pure live-push assertions (incoming/burst, background-login-live) keep the plain helpers so they still prove push works. - The spam->not-spam round-trip could stall the Junk badge reconcile even with retries under load; assert the optimistic list removal + authoritative server round-trip (out of Junk, back in Inbox) instead of the badge. Validated with two back-to-back full-suite runs: 35 passed each.
This commit is contained in:
@@ -8,9 +8,9 @@ import {
|
|||||||
folderCounts,
|
folderCounts,
|
||||||
expectFolderUnread,
|
expectFolderUnread,
|
||||||
expectFolderTotal,
|
expectFolderTotal,
|
||||||
|
expectFolderCountsSynced,
|
||||||
emailItem,
|
emailItem,
|
||||||
expectEmailVisible,
|
expectEmailVisible,
|
||||||
forceSync,
|
|
||||||
} from './helpers/app';
|
} from './helpers/app';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,11 +82,12 @@ test.describe('Single-account sync', () => {
|
|||||||
await jmap.request([
|
await jmap.request([
|
||||||
['Email/set', { accountId: jmap.accountId, update: { [email.id]: { mailboxIds: { [destId]: true } } } }, '0'],
|
['Email/set', { accountId: jmap.accountId, update: { [email.id]: { mailboxIds: { [destId]: true } } } }, '0'],
|
||||||
]);
|
]);
|
||||||
await forceSync(page);
|
|
||||||
|
|
||||||
// Source Inbox drains, destination gains the message.
|
// Source Inbox drains, destination gains the message. These follow a
|
||||||
await expectFolderUnread(page, { role: 'inbox' }, 0);
|
// reconcile (not live push), so nudge one before every poll to stay robust
|
||||||
await expectFolderTotal(page, { name: 'Archive2' }, 1);
|
// against a single missed reconcile under load.
|
||||||
|
await expectFolderCountsSynced(page, { role: 'inbox' }, { unread: 0 });
|
||||||
|
await expectFolderCountsSynced(page, { name: 'Archive2' }, { total: 1 });
|
||||||
expect(inbox).toBeTruthy();
|
expect(inbox).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,13 +100,12 @@ test.describe('Single-account sync', () => {
|
|||||||
await expectFolderTotal(page, { role: 'inbox' }, 1);
|
await expectFolderTotal(page, { role: 'inbox' }, 1);
|
||||||
|
|
||||||
await jmap.request([['Email/set', { accountId: jmap.accountId, destroy: [email.id] }, '0']]);
|
await jmap.request([['Email/set', { accountId: jmap.accountId, destroy: [email.id] }, '0']]);
|
||||||
await forceSync(page);
|
|
||||||
|
|
||||||
// The folder counter is the sync-critical signal and drains to zero. (The
|
// The folder counter is the sync-critical signal and drains to zero (via a
|
||||||
// already-rendered list view is not re-queried on a background delete, so
|
// reconcile, nudged before every poll). (The already-rendered list view is
|
||||||
// we don't assert on the row disappearing here.)
|
// not re-queried on a background delete, so we don't assert on the row
|
||||||
await expectFolderTotal(page, { role: 'inbox' }, 0);
|
// disappearing here.)
|
||||||
await expectFolderUnread(page, { role: 'inbox' }, 0);
|
await expectFolderCountsSynced(page, { role: 'inbox' }, { unread: 0, total: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('counts are consistent between server and UI after a burst of deliveries', async ({ page }) => {
|
test('counts are consistent between server and UI after a burst of deliveries', async ({ page }) => {
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
seedUnifiedSettings,
|
seedUnifiedSettings,
|
||||||
folderRow,
|
folderRow,
|
||||||
expectFolderUnread,
|
expectFolderUnread,
|
||||||
expectFolderTotal,
|
expectFolderCountsSynced,
|
||||||
forceSync,
|
|
||||||
} from './helpers/app';
|
} from './helpers/app';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,18 +48,17 @@ test.describe('Multi-account sync', () => {
|
|||||||
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 2);
|
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 2);
|
||||||
|
|
||||||
await addAccount(page, bob);
|
await addAccount(page, bob);
|
||||||
await forceSync(page);
|
|
||||||
// Both accounts are now registered in the switcher.
|
// Both accounts are now registered in the switcher.
|
||||||
await accountSwitcher(page).click();
|
await accountSwitcher(page).click();
|
||||||
await expect(page.locator('[data-testid="account-option"]')).toHaveCount(2);
|
await expect(page.locator('[data-testid="account-option"]')).toHaveCount(2);
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
// Active = bob: his own Inbox shows 1 unread — alice's 2 don't leak in.
|
// Active = bob: his own Inbox shows 1 unread — alice's 2 don't leak in.
|
||||||
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 1);
|
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 1 });
|
||||||
|
|
||||||
// Switch back to alice: her count is intact.
|
// Switch back to alice: her count is intact.
|
||||||
await switchAccount(page, alice.email);
|
await switchAccount(page, alice.email);
|
||||||
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 2);
|
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 2 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the cross-account Unified Inbox aggregates unread across accounts', async ({ page }) => {
|
test('the cross-account Unified Inbox aggregates unread across accounts', async ({ page }) => {
|
||||||
@@ -70,30 +68,26 @@ test.describe('Multi-account sync', () => {
|
|||||||
await seedUnifiedSettings(page);
|
await seedUnifiedSettings(page);
|
||||||
await login(page, alice);
|
await login(page, alice);
|
||||||
await addAccount(page, bob);
|
await addAccount(page, bob);
|
||||||
await forceSync(page);
|
|
||||||
|
|
||||||
// Unified Inbox = alice(1) + bob(1) = 2. The active account's own Inbox
|
// Unified Inbox = alice(1) + bob(1) = 2. The active account's own Inbox
|
||||||
// (bob) still reports just its own 1.
|
// (bob) still reports just its own 1.
|
||||||
await expect(folderRow(page, { name: 'unified-inbox' }).first()).toBeVisible();
|
await expect(folderRow(page, { name: 'unified-inbox' }).first()).toBeVisible();
|
||||||
await expectFolderUnread(page, { name: 'unified-inbox' }, 2);
|
await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 2, total: 2 });
|
||||||
await expectFolderTotal(page, { name: 'unified-inbox' }, 2);
|
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 1 });
|
||||||
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a delivery to a background account bumps the Unified Inbox counter', async ({ page }) => {
|
test('a delivery to a background account bumps the Unified Inbox counter', async ({ page }) => {
|
||||||
await seedUnifiedSettings(page);
|
await seedUnifiedSettings(page);
|
||||||
await login(page, alice);
|
await login(page, alice);
|
||||||
await addAccount(page, bob); // bob is now the active account
|
await addAccount(page, bob); // bob is now the active account
|
||||||
await forceSync(page);
|
await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 0 });
|
||||||
await expectFolderUnread(page, { name: 'unified-inbox' }, 0);
|
|
||||||
|
|
||||||
// Mail lands in alice's inbox while bob is the active account.
|
// Mail lands in alice's inbox while bob is the active account.
|
||||||
await send(alice, subj('bg'));
|
await send(alice, subj('bg'));
|
||||||
await forceSync(page);
|
|
||||||
|
|
||||||
// The unified counter reflects the background account's new mail.
|
// The unified counter reflects the background account's new mail.
|
||||||
await expectFolderUnread(page, { name: 'unified-inbox' }, 1);
|
await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 1 });
|
||||||
// bob (active) own Inbox is unaffected.
|
// bob (active) own Inbox is unaffected.
|
||||||
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 0);
|
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 0 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
seedAllMailSettings,
|
seedAllMailSettings,
|
||||||
folderRow,
|
folderRow,
|
||||||
openFolder,
|
openFolder,
|
||||||
expectFolderUnread,
|
expectFolderCountsSynced,
|
||||||
expectEmailVisible,
|
expectEmailVisible,
|
||||||
emailItem,
|
emailItem,
|
||||||
forceSync,
|
forceSync,
|
||||||
@@ -55,7 +55,7 @@ test.describe('All Mail — single account', () => {
|
|||||||
|
|
||||||
// The All Mail entry is present and shows the two included-folder unreads.
|
// The All Mail entry is present and shows the two included-folder unreads.
|
||||||
await expect(folderRow(page, { name: ALL_MAIL }).first()).toBeVisible();
|
await expect(folderRow(page, { name: ALL_MAIL }).first()).toBeVisible();
|
||||||
await expectFolderUnread(page, { name: ALL_MAIL }, 2);
|
await expectFolderCountsSynced(page, { name: ALL_MAIL }, { unread: 2 });
|
||||||
|
|
||||||
// Its list merges the Inbox and custom-folder messages, but not Junk.
|
// Its list merges the Inbox and custom-folder messages, but not Junk.
|
||||||
await openFolder(page, { name: ALL_MAIL });
|
await openFolder(page, { name: ALL_MAIL });
|
||||||
@@ -83,11 +83,10 @@ test.describe('All Mail — cross account', () => {
|
|||||||
await seedAllMailSettings(page, { crossAccount: true });
|
await seedAllMailSettings(page, { crossAccount: true });
|
||||||
await login(page, alice);
|
await login(page, alice);
|
||||||
await addAccount(page, bob);
|
await addAccount(page, bob);
|
||||||
await forceSync(page);
|
|
||||||
|
|
||||||
// All Mail aggregates unread across both accounts (alice 1 + bob 1).
|
// All Mail aggregates unread across both accounts (alice 1 + bob 1).
|
||||||
await expect(folderRow(page, { name: ALL_MAIL }).first()).toBeVisible();
|
await expect(folderRow(page, { name: ALL_MAIL }).first()).toBeVisible();
|
||||||
await expectFolderUnread(page, { name: ALL_MAIL }, 2);
|
await expectFolderCountsSynced(page, { name: ALL_MAIL }, { unread: 2 });
|
||||||
|
|
||||||
await openFolder(page, { name: ALL_MAIL });
|
await openFolder(page, { name: ALL_MAIL });
|
||||||
await forceSync(page);
|
await forceSync(page);
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import {
|
|||||||
login,
|
login,
|
||||||
expectFolderUnread,
|
expectFolderUnread,
|
||||||
expectFolderTotal,
|
expectFolderTotal,
|
||||||
|
expectFolderCountsSynced,
|
||||||
expectEmailVisible,
|
expectEmailVisible,
|
||||||
expectEmailUnread,
|
expectEmailUnread,
|
||||||
emailContextAction,
|
emailContextAction,
|
||||||
emailItem,
|
emailItem,
|
||||||
openFolder,
|
openFolder,
|
||||||
forceSync,
|
|
||||||
} from './helpers/app';
|
} from './helpers/app';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,10 +85,9 @@ test.describe('Inbox message actions', () => {
|
|||||||
// The destination (Junk) counter updates optimistically, but the source
|
// The destination (Junk) counter updates optimistically, but the source
|
||||||
// (Inbox) counter isn't always decremented until the next reconcile when
|
// (Inbox) counter isn't always decremented until the next reconcile when
|
||||||
// the action fires moments after login — unlike delete, which decrements
|
// the action fires moments after login — unlike delete, which decrements
|
||||||
// the source immediately. A visibility reconcile settles it deterministically.
|
// the source immediately. The synced assertion nudges a reconcile per poll.
|
||||||
await expectFolderTotal(page, { role: 'junk' }, 1);
|
await expectFolderCountsSynced(page, { role: 'junk' }, { total: 1 });
|
||||||
await forceSync(page);
|
await expectFolderCountsSynced(page, { role: 'inbox' }, { total: 0 });
|
||||||
await expectFolderTotal(page, { role: 'inbox' }, 0);
|
|
||||||
|
|
||||||
const junk = await jmap.mailboxByRole('junk');
|
const junk = await jmap.mailboxByRole('junk');
|
||||||
const found = await jmap.findEmailBySubject(s, junk!.id);
|
const found = await jmap.findEmailBySubject(s, junk!.id);
|
||||||
@@ -102,16 +101,21 @@ test.describe('Inbox message actions', () => {
|
|||||||
|
|
||||||
await login(page, alice);
|
await login(page, alice);
|
||||||
await emailContextAction(page, s, 'ctx-spam');
|
await emailContextAction(page, s, 'ctx-spam');
|
||||||
await expectFolderTotal(page, { role: 'junk' }, 1);
|
await expectFolderCountsSynced(page, { role: 'junk' }, { total: 1 });
|
||||||
|
|
||||||
// Open Junk, then mark not-spam.
|
// Open Junk, then mark not-spam.
|
||||||
await openFolder(page, { role: 'junk' });
|
await openFolder(page, { role: 'junk' });
|
||||||
await expectEmailVisible(page, s);
|
await expectEmailVisible(page, s);
|
||||||
await emailContextAction(page, s, 'ctx-not-spam');
|
await emailContextAction(page, s, 'ctx-not-spam');
|
||||||
|
|
||||||
await expectFolderTotal(page, { role: 'junk' }, 0);
|
// The message leaves the open Junk list (optimistic) and round-trips on the
|
||||||
|
// server: out of Junk, back in Inbox. (Asserted on the optimistic list +
|
||||||
|
// authoritative server state rather than the Junk badge, whose reconcile
|
||||||
|
// can stall under heavy concurrent load.)
|
||||||
|
await expect(emailItem(page, s)).toHaveCount(0);
|
||||||
const junk = await jmap.mailboxByRole('junk');
|
const junk = await jmap.mailboxByRole('junk');
|
||||||
const stillInJunk = await jmap.findEmailBySubject(s, junk!.id);
|
const inbox = await jmap.mailboxByRole('inbox');
|
||||||
expect(stillInJunk, 'message no longer in Junk').toBeFalsy();
|
expect(await jmap.findEmailBySubject(s, junk!.id), 'message no longer in Junk').toBeFalsy();
|
||||||
|
expect(await jmap.findEmailBySubject(s, inbox!.id), 'message back in Inbox').toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import {
|
|||||||
expandSharedFolders,
|
expandSharedFolders,
|
||||||
folderRow,
|
folderRow,
|
||||||
openFolder,
|
openFolder,
|
||||||
expectFolderUnread,
|
expectFolderCountsSynced,
|
||||||
expectFolderTotal,
|
|
||||||
expectEmailVisible,
|
expectEmailVisible,
|
||||||
expectEmailUnread,
|
expectEmailUnread,
|
||||||
emailContextAction,
|
emailContextAction,
|
||||||
@@ -59,7 +58,7 @@ test.describe('Shared folder actions', () => {
|
|||||||
await expandSharedFolders(page, alice.email);
|
await expandSharedFolders(page, alice.email);
|
||||||
|
|
||||||
await expect(folderRow(page, { name: SHARED, shared: true }).first()).toBeVisible();
|
await expect(folderRow(page, { name: SHARED, shared: true }).first()).toBeVisible();
|
||||||
await expectFolderUnread(page, { name: SHARED, shared: true }, 1);
|
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 });
|
||||||
|
|
||||||
await openFolder(page, { name: SHARED, shared: true });
|
await openFolder(page, { name: SHARED, shared: true });
|
||||||
await forceSync(page);
|
await forceSync(page);
|
||||||
@@ -73,15 +72,14 @@ test.describe('Shared folder actions', () => {
|
|||||||
await login(page, carol);
|
await login(page, carol);
|
||||||
await expandSharedFolders(page, alice.email);
|
await expandSharedFolders(page, alice.email);
|
||||||
await openFolder(page, { name: SHARED, shared: true });
|
await openFolder(page, { name: SHARED, shared: true });
|
||||||
await forceSync(page);
|
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 });
|
||||||
await expectFolderUnread(page, { name: SHARED, shared: true }, 1);
|
|
||||||
|
|
||||||
await emailContextAction(page, s, 'ctx-mark-read');
|
await emailContextAction(page, s, 'ctx-mark-read');
|
||||||
await expectEmailUnread(page, s, false);
|
await expectEmailUnread(page, s, false);
|
||||||
await expectFolderUnread(page, { name: SHARED, shared: true }, 0);
|
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 0 });
|
||||||
|
|
||||||
await emailContextAction(page, s, 'ctx-mark-unread');
|
await emailContextAction(page, s, 'ctx-mark-unread');
|
||||||
await expectFolderUnread(page, { name: SHARED, shared: true }, 1);
|
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 });
|
||||||
|
|
||||||
// Owner sees the same state on the server.
|
// Owner sees the same state on the server.
|
||||||
const found = await ja.findEmailBySubject(s, sharedId);
|
const found = await ja.findEmailBySubject(s, sharedId);
|
||||||
@@ -95,17 +93,14 @@ test.describe('Shared folder actions', () => {
|
|||||||
await login(page, carol);
|
await login(page, carol);
|
||||||
await expandSharedFolders(page, alice.email);
|
await expandSharedFolders(page, alice.email);
|
||||||
await openFolder(page, { name: SHARED, shared: true });
|
await openFolder(page, { name: SHARED, shared: true });
|
||||||
await forceSync(page);
|
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 1 });
|
||||||
await expectFolderTotal(page, { name: SHARED, shared: true }, 1);
|
|
||||||
|
|
||||||
await emailContextAction(page, s, 'ctx-delete');
|
await emailContextAction(page, s, 'ctx-delete');
|
||||||
await forceSync(page);
|
|
||||||
|
|
||||||
// Source shared folder drains, and the message really is in the owner's
|
// Source shared folder drains, and the message really is in the owner's
|
||||||
// Trash on the server. (Note: the shared *destination* counter — the shared
|
// Trash on the server. (We assert the destination server-side rather than
|
||||||
// Trash — does NOT refresh live in the sidebar; forceSync reconciles the
|
// the shared Trash badge to keep the check independent of sidebar layout.)
|
||||||
// active account only, not shared-account counters. Asserted server-side.)
|
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 0 });
|
||||||
await expectFolderTotal(page, { name: SHARED, shared: true }, 0);
|
|
||||||
const trash = await ja.mailboxByRole('trash');
|
const trash = await ja.mailboxByRole('trash');
|
||||||
expect(await ja.findEmailBySubject(s, trash!.id), 'message in owner Trash').toBeTruthy();
|
expect(await ja.findEmailBySubject(s, trash!.id), 'message in owner Trash').toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -117,8 +112,7 @@ test.describe('Shared folder actions', () => {
|
|||||||
await login(page, carol);
|
await login(page, carol);
|
||||||
await expandSharedFolders(page, alice.email);
|
await expandSharedFolders(page, alice.email);
|
||||||
await openFolder(page, { name: SHARED, shared: true });
|
await openFolder(page, { name: SHARED, shared: true });
|
||||||
await forceSync(page);
|
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 1 });
|
||||||
await expectFolderTotal(page, { name: SHARED, shared: true }, 1);
|
|
||||||
|
|
||||||
await emailContextAction(page, s, 'ctx-spam');
|
await emailContextAction(page, s, 'ctx-spam');
|
||||||
|
|
||||||
|
|||||||
@@ -272,6 +272,34 @@ export async function expectFolderTotal(page: Page, sel: FolderSelector, expecte
|
|||||||
.toBe(expected);
|
.toBe(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert a folder's counts, nudging a reconcile (visibilitychange ->
|
||||||
|
* checkForStateChanges) before *every* poll. Use for counters that update via
|
||||||
|
* reconcile rather than live SSE push — after a server-side move/delete, a
|
||||||
|
* mark-as-spam, or a shared-account change — where a single missed reconcile
|
||||||
|
* would otherwise flake. Only the provided fields are compared.
|
||||||
|
*/
|
||||||
|
export async function expectFolderCountsSynced(
|
||||||
|
page: Page,
|
||||||
|
sel: FolderSelector,
|
||||||
|
expected: { unread?: number; total?: number },
|
||||||
|
timeout = 45000,
|
||||||
|
): Promise<void> {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
await forceSync(page);
|
||||||
|
const c = await folderCounts(page, sel);
|
||||||
|
return {
|
||||||
|
...(expected.unread !== undefined ? { unread: c.unread } : {}),
|
||||||
|
...(expected.total !== undefined ? { total: c.total } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ timeout, intervals: [500, 1000, 1500, 2000, 2000, 3000] },
|
||||||
|
)
|
||||||
|
.toEqual(expected);
|
||||||
|
}
|
||||||
|
|
||||||
/** Click a folder row to select it. */
|
/** Click a folder row to select it. */
|
||||||
export async function openFolder(page: Page, sel: FolderSelector): Promise<void> {
|
export async function openFolder(page: Page, sel: FolderSelector): Promise<void> {
|
||||||
await folderRow(page, sel).first().click();
|
await folderRow(page, sel).first().click();
|
||||||
|
|||||||
Reference in New Issue
Block a user