diff --git a/integration/tests/02-mail-sync.spec.ts b/integration/tests/02-mail-sync.spec.ts index f03453f8..4f0829e3 100644 --- a/integration/tests/02-mail-sync.spec.ts +++ b/integration/tests/02-mail-sync.spec.ts @@ -8,9 +8,9 @@ import { folderCounts, expectFolderUnread, expectFolderTotal, + expectFolderCountsSynced, emailItem, expectEmailVisible, - forceSync, } from './helpers/app'; /** @@ -82,11 +82,12 @@ test.describe('Single-account sync', () => { await jmap.request([ ['Email/set', { accountId: jmap.accountId, update: { [email.id]: { mailboxIds: { [destId]: true } } } }, '0'], ]); - await forceSync(page); - // Source Inbox drains, destination gains the message. - await expectFolderUnread(page, { role: 'inbox' }, 0); - await expectFolderTotal(page, { name: 'Archive2' }, 1); + // Source Inbox drains, destination gains the message. These follow a + // reconcile (not live push), so nudge one before every poll to stay robust + // against a single missed reconcile under load. + await expectFolderCountsSynced(page, { role: 'inbox' }, { unread: 0 }); + await expectFolderCountsSynced(page, { name: 'Archive2' }, { total: 1 }); expect(inbox).toBeTruthy(); }); @@ -99,13 +100,12 @@ test.describe('Single-account sync', () => { await expectFolderTotal(page, { role: 'inbox' }, 1); 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 - // already-rendered list view is not re-queried on a background delete, so - // we don't assert on the row disappearing here.) - await expectFolderTotal(page, { role: 'inbox' }, 0); - await expectFolderUnread(page, { role: 'inbox' }, 0); + // The folder counter is the sync-critical signal and drains to zero (via a + // reconcile, nudged before every poll). (The already-rendered list view is + // not re-queried on a background delete, so we don't assert on the row + // disappearing here.) + await expectFolderCountsSynced(page, { role: 'inbox' }, { unread: 0, total: 0 }); }); test('counts are consistent between server and UI after a burst of deliveries', async ({ page }) => { diff --git a/integration/tests/03-multi-account.spec.ts b/integration/tests/03-multi-account.spec.ts index 6639ce39..4c6a7614 100644 --- a/integration/tests/03-multi-account.spec.ts +++ b/integration/tests/03-multi-account.spec.ts @@ -10,8 +10,7 @@ import { seedUnifiedSettings, folderRow, expectFolderUnread, - expectFolderTotal, - forceSync, + expectFolderCountsSynced, } from './helpers/app'; /** @@ -49,18 +48,17 @@ test.describe('Multi-account sync', () => { await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 2); await addAccount(page, bob); - await forceSync(page); // Both accounts are now registered in the switcher. await accountSwitcher(page).click(); await expect(page.locator('[data-testid="account-option"]')).toHaveCount(2); await page.keyboard.press('Escape'); // 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. 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 }) => { @@ -70,30 +68,26 @@ test.describe('Multi-account sync', () => { await seedUnifiedSettings(page); await login(page, alice); await addAccount(page, bob); - await forceSync(page); // Unified Inbox = alice(1) + bob(1) = 2. The active account's own Inbox // (bob) still reports just its own 1. await expect(folderRow(page, { name: 'unified-inbox' }).first()).toBeVisible(); - await expectFolderUnread(page, { name: 'unified-inbox' }, 2); - await expectFolderTotal(page, { name: 'unified-inbox' }, 2); - await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 1); + await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 2, total: 2 }); + await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 1 }); }); test('a delivery to a background account bumps the Unified Inbox counter', async ({ page }) => { await seedUnifiedSettings(page); await login(page, alice); await addAccount(page, bob); // bob is now the active account - await forceSync(page); - await expectFolderUnread(page, { name: 'unified-inbox' }, 0); + await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 0 }); // Mail lands in alice's inbox while bob is the active account. await send(alice, subj('bg')); - await forceSync(page); // 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. - await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 0); + await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 0 }); }); }); diff --git a/integration/tests/04-all-mail.spec.ts b/integration/tests/04-all-mail.spec.ts index ed376142..3cb3294f 100644 --- a/integration/tests/04-all-mail.spec.ts +++ b/integration/tests/04-all-mail.spec.ts @@ -8,7 +8,7 @@ import { seedAllMailSettings, folderRow, openFolder, - expectFolderUnread, + expectFolderCountsSynced, expectEmailVisible, emailItem, forceSync, @@ -55,7 +55,7 @@ test.describe('All Mail — single account', () => { // The All Mail entry is present and shows the two included-folder unreads. 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. await openFolder(page, { name: ALL_MAIL }); @@ -83,11 +83,10 @@ test.describe('All Mail — cross account', () => { await seedAllMailSettings(page, { crossAccount: true }); await login(page, alice); await addAccount(page, bob); - await forceSync(page); // All Mail aggregates unread across both accounts (alice 1 + bob 1). 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 forceSync(page); diff --git a/integration/tests/05-actions.spec.ts b/integration/tests/05-actions.spec.ts index 0e6babf5..86c7cb2a 100644 --- a/integration/tests/05-actions.spec.ts +++ b/integration/tests/05-actions.spec.ts @@ -6,12 +6,12 @@ import { login, expectFolderUnread, expectFolderTotal, + expectFolderCountsSynced, expectEmailVisible, expectEmailUnread, emailContextAction, emailItem, openFolder, - forceSync, } from './helpers/app'; /** @@ -85,10 +85,9 @@ test.describe('Inbox message actions', () => { // The destination (Junk) counter updates optimistically, but the source // (Inbox) counter isn't always decremented until the next reconcile when // the action fires moments after login — unlike delete, which decrements - // the source immediately. A visibility reconcile settles it deterministically. - await expectFolderTotal(page, { role: 'junk' }, 1); - await forceSync(page); - await expectFolderTotal(page, { role: 'inbox' }, 0); + // the source immediately. The synced assertion nudges a reconcile per poll. + await expectFolderCountsSynced(page, { role: 'junk' }, { total: 1 }); + await expectFolderCountsSynced(page, { role: 'inbox' }, { total: 0 }); const junk = await jmap.mailboxByRole('junk'); const found = await jmap.findEmailBySubject(s, junk!.id); @@ -102,16 +101,21 @@ test.describe('Inbox message actions', () => { await login(page, alice); 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. await openFolder(page, { role: 'junk' }); await expectEmailVisible(page, s); 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 stillInJunk = await jmap.findEmailBySubject(s, junk!.id); - expect(stillInJunk, 'message no longer in Junk').toBeFalsy(); + const inbox = await jmap.mailboxByRole('inbox'); + 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(); }); }); diff --git a/integration/tests/06-shared-folders.spec.ts b/integration/tests/06-shared-folders.spec.ts index 68dc864e..66f8b7e5 100644 --- a/integration/tests/06-shared-folders.spec.ts +++ b/integration/tests/06-shared-folders.spec.ts @@ -7,8 +7,7 @@ import { expandSharedFolders, folderRow, openFolder, - expectFolderUnread, - expectFolderTotal, + expectFolderCountsSynced, expectEmailVisible, expectEmailUnread, emailContextAction, @@ -59,7 +58,7 @@ test.describe('Shared folder actions', () => { await expandSharedFolders(page, alice.email); 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 forceSync(page); @@ -73,15 +72,14 @@ test.describe('Shared folder actions', () => { await login(page, carol); await expandSharedFolders(page, alice.email); await openFolder(page, { name: SHARED, shared: true }); - await forceSync(page); - await expectFolderUnread(page, { name: SHARED, shared: true }, 1); + await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 }); await emailContextAction(page, s, 'ctx-mark-read'); 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 expectFolderUnread(page, { name: SHARED, shared: true }, 1); + await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 }); // Owner sees the same state on the server. const found = await ja.findEmailBySubject(s, sharedId); @@ -95,17 +93,14 @@ test.describe('Shared folder actions', () => { await login(page, carol); await expandSharedFolders(page, alice.email); await openFolder(page, { name: SHARED, shared: true }); - await forceSync(page); - await expectFolderTotal(page, { name: SHARED, shared: true }, 1); + await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 1 }); await emailContextAction(page, s, 'ctx-delete'); - await forceSync(page); // 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 — does NOT refresh live in the sidebar; forceSync reconciles the - // active account only, not shared-account counters. Asserted server-side.) - await expectFolderTotal(page, { name: SHARED, shared: true }, 0); + // Trash on the server. (We assert the destination server-side rather than + // the shared Trash badge to keep the check independent of sidebar layout.) + await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 0 }); const trash = await ja.mailboxByRole('trash'); 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 expandSharedFolders(page, alice.email); await openFolder(page, { name: SHARED, shared: true }); - await forceSync(page); - await expectFolderTotal(page, { name: SHARED, shared: true }, 1); + await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 1 }); await emailContextAction(page, s, 'ctx-spam'); diff --git a/integration/tests/helpers/app.ts b/integration/tests/helpers/app.ts index b8562a42..41d63498 100644 --- a/integration/tests/helpers/app.ts +++ b/integration/tests/helpers/app.ts @@ -272,6 +272,34 @@ export async function expectFolderTotal(page: Page, sel: FolderSelector, expecte .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 { + 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. */ export async function openFolder(page: Page, sel: FolderSelector): Promise { await folderRow(page, sel).first().click();