From 88a809bd6903a359c19f87afc9ee79b5d6b89cfa Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:56:43 +0200 Subject: [PATCH] test(integration): verify unread/starred cross views keep the acted-on row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 11-cross-view-retain.spec.ts: with the Unread cross view open, reading a mail keeps it in the list (status + unread counter update only, no removal); with the Starred cross view open, un-starring keeps it. Both force the focus/push refresh that previously dropped the row and assert it survives. Supporting test hooks: - thread-list-item: expose data-starred on the row. - email-context-menu: testId ctx-star / ctx-unstar on the star item. - integration helpers: expectEmailStarred (app.ts), setFlagged (jmap.ts). - integration policy.json: enable crossStarredViewEnabled. Verified: 08-shared-moves-style run of 11-cross-view-retain → 2/2 passed against the dockerized stack. --- components/email/email-context-menu.tsx | 1 + components/email/thread-list-item.tsx | 1 + .../tests/11-cross-view-retain.spec.ts | 105 ++++++++++++++++++ integration/tests/helpers/app.ts | 5 + integration/tests/helpers/jmap.ts | 6 + integration/webmail-config/policy.json | 1 + 6 files changed, 119 insertions(+) create mode 100644 integration/tests/11-cross-view-retain.spec.ts diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index ff77765b8..1c0fca1ca 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -372,6 +372,7 @@ export function EmailContextMenu({ label={isStarred ? t("unstar") : t("star")} onClick={() => handleAction(onToggleStar!)} disabled={!onToggleStar} + testId={isStarred ? "ctx-unstar" : "ctx-star"} /> )} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index b54733418..a4db15371 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -190,6 +190,7 @@ const SingleEmailItem = React.forwardRef( data-email-id={email.id} data-subject={email.subject || ''} data-unread={isUnread ? 'true' : 'false'} + data-starred={email.keywords?.$flagged ? 'true' : 'false'} className={cn( "relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden", resolvedRowTint ? resolvedRowTint : ( diff --git a/integration/tests/11-cross-view-retain.spec.ts b/integration/tests/11-cross-view-retain.spec.ts new file mode 100644 index 000000000..a25fd0d85 --- /dev/null +++ b/integration/tests/11-cross-view-retain.spec.ts @@ -0,0 +1,105 @@ +import { test, expect } from '@playwright/test'; +import { ACCOUNTS } from './helpers/config'; +import { sendMail } from './helpers/smtp'; +import { JmapClient } from './helpers/jmap'; +import { + login, + seedSettings, + openFolder, + forceSync, + expectEmailVisible, + expectEmailUnread, + expectEmailStarred, + emailItem, + emailContextAction, + expectFolderCountsSynced, +} from './helpers/app'; + +/** + * The Unread and Starred cross views filter on the very keyword the read/star + * actions flip. Acting on a mail from within the view (reading it in Unread, + * un-starring it in Starred) must NOT make the row vanish on the next push / + * focus refresh - only its status + the counters update; the row stays until + * the view is re-opened. See the unit coverage in + * stores/__tests__/email-store-retained-in-view.test.ts. + */ +const alice = ACCOUNTS.alice; +const UNREAD = '__cross_unread__'; +const STARRED = '__cross_starred__'; + +let seq = 0; +const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`; +const send = (s: string) => sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject: s, body: 'x' }); + +test.describe('Cross unread/starred views keep the acted-on row', () => { + let jmap: JmapClient; + + test.beforeEach(async () => { + jmap = await JmapClient.connect(alice.email, alice.password); + await jmap.reset(); + }); + + test('reading a mail in the Unread view keeps it visible (status + counter only)', async ({ page }) => { + const a = subj('cr-unread-a'); + const b = subj('cr-unread-b'); + await send(a); + await send(b); + await jmap.waitForEmail(a); + await jmap.waitForEmail(b); + + await seedSettings(page, { enableUnifiedMailbox: true, enableCrossUnreadView: true }); + await login(page, alice); + + await openFolder(page, { name: UNREAD }); + await forceSync(page); + await expectEmailVisible(page, a); + await expectEmailVisible(page, b); + await expectFolderCountsSynced(page, { name: UNREAD }, { unread: 2 }); + + // Read one from the list's context menu. + await emailContextAction(page, a, 'ctx-mark-read'); + await expectEmailUnread(page, a, false); // status updated in place + await expectEmailVisible(page, a); // and still shown + + // Force the focus/push refresh that previously dropped the read row. + await forceSync(page); + await page.waitForTimeout(1500); + + await expect(emailItem(page, a)).toHaveCount(1); // retained, not removed + await expectEmailUnread(page, a, false); + await expectEmailVisible(page, b); + // The unread counter still dropped to 1 (status + counter update happened). + await expectFolderCountsSynced(page, { name: UNREAD }, { unread: 1 }); + }); + + test('un-starring a mail in the Starred view keeps it visible', async ({ page }) => { + const a = subj('cr-star-a'); + const b = subj('cr-star-b'); + await send(a); + await send(b); + const ea = await jmap.waitForEmail(a); + const eb = await jmap.waitForEmail(b); + await jmap.setFlagged(ea.id, true); + await jmap.setFlagged(eb.id, true); + + await seedSettings(page, { enableUnifiedMailbox: true, enableCrossStarredView: true }); + await login(page, alice); + + await openFolder(page, { name: STARRED }); + await forceSync(page); + await expectEmailVisible(page, a); + await expectEmailVisible(page, b); + + // Remove the star from one via the context menu. + await emailContextAction(page, a, 'ctx-unstar'); + await expectEmailStarred(page, a, false); // status updated in place + await expectEmailVisible(page, a); // and still shown + + await forceSync(page); + await page.waitForTimeout(1500); + + await expect(emailItem(page, a)).toHaveCount(1); // retained, not removed + await expectEmailStarred(page, a, false); + await expectEmailVisible(page, b); + }); +}); diff --git a/integration/tests/helpers/app.ts b/integration/tests/helpers/app.ts index 0f4dcfd73..f9859b960 100644 --- a/integration/tests/helpers/app.ts +++ b/integration/tests/helpers/app.ts @@ -355,6 +355,11 @@ export async function expectEmailUnread(page: Page, subject: string, unread: boo await expect(emailItem(page, subject).first()).toHaveAttribute('data-unread', String(unread), { timeout }); } +/** Assert an email row's starred state (from its `data-starred` attribute). */ +export async function expectEmailStarred(page: Page, subject: string, starred: boolean, timeout = 20000): Promise { + await expect(emailItem(page, subject).first()).toHaveAttribute('data-starred', String(starred), { timeout }); +} + /** * Open an email's right-click context menu and click one of its actions. * `testId` is one of: `ctx-delete`, `ctx-spam`, `ctx-not-spam`, diff --git a/integration/tests/helpers/jmap.ts b/integration/tests/helpers/jmap.ts index ae54564aa..772821e11 100644 --- a/integration/tests/helpers/jmap.ts +++ b/integration/tests/helpers/jmap.ts @@ -264,6 +264,12 @@ export class JmapClient { ]); } + async setFlagged(emailId: string, flagged: boolean): Promise { + await this.request([ + ['Email/set', { accountId: this.accountId, update: { [emailId]: { [`keywords/$flagged`]: flagged ? true : null } } }, '0'], + ]); + } + /** Look up an email id by subject within an optional mailbox. */ async findEmailBySubject(subject: string, mailboxId?: string): Promise { const filter: Record = { subject }; diff --git a/integration/webmail-config/policy.json b/integration/webmail-config/policy.json index 32c2e803b..be8985755 100644 --- a/integration/webmail-config/policy.json +++ b/integration/webmail-config/policy.json @@ -2,6 +2,7 @@ "features": { "unifiedCrossAccountEnabled": true, "crossUnreadViewEnabled": true, + "crossStarredViewEnabled": true, "crossAllViewEnabled": true } }