test(integration): verify unread/starred cross views keep the acted-on row

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.
This commit is contained in:
Stefan Hildebrandt
2026-08-09 22:17:58 +02:00
parent 7a3a262c87
commit 88a809bd69
6 changed files with 119 additions and 0 deletions
+1
View File
@@ -372,6 +372,7 @@ export function EmailContextMenu({
label={isStarred ? t("unstar") : t("star")}
onClick={() => handleAction(onToggleStar!)}
disabled={!onToggleStar}
testId={isStarred ? "ctx-unstar" : "ctx-star"}
/>
)}
+1
View File
@@ -190,6 +190,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
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 : (
@@ -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);
});
});
+5
View File
@@ -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<void> {
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`,
+6
View File
@@ -264,6 +264,12 @@ export class JmapClient {
]);
}
async setFlagged(emailId: string, flagged: boolean): Promise<void> {
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<any | undefined> {
const filter: Record<string, unknown> = { subject };
+1
View File
@@ -2,6 +2,7 @@
"features": {
"unifiedCrossAccountEnabled": true,
"crossUnreadViewEnabled": true,
"crossStarredViewEnabled": true,
"crossAllViewEnabled": true
}
}