test(integration): multi-account calendar switch preserves selection

Docker/Playwright regression for the cross-account id-namespacing fix: log in
two accounts, enable the Pro shell, deselect a calendar, switch the active
account, and assert the deselection survives (it reset before the fix because
the previously-active account's ids flipped raw<->namespaced on the switch).

Adds the minimal test hooks: data-testid on calendar sidebar rows
(calendar-item + data-calendar-name/-visible), the settings tab buttons
(settings-tab-<id>), and a testId passthrough on ToggleSwitch used for the Pro
interface toggle. New app helpers: openCalendar (in-app nav, since a hard goto
to a deep link bounces to login), calendarRows, enableProInterface (toggles via
the settings UI - the app doesn't survive a hard reload and seeding proInterface
before login destabilises the add-account popover).

Verified green in the dockerized Stalwart harness.
This commit is contained in:
Stefan Hildebrandt
2026-08-10 22:45:28 +02:00
parent 583e20d9dc
commit ce3c5db315
6 changed files with 150 additions and 1 deletions
@@ -242,6 +242,10 @@ export function CalendarSidebarPanel({
<button
onClick={() => onToggleVisibility(cal.id)}
onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
data-testid="calendar-item"
data-calendar-name={cal.name}
data-account={cal.accountName ?? ''}
data-visible={isVisible}
className={cn(
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
"hover:bg-muted"
+1
View File
@@ -354,6 +354,7 @@ export function LayoutSettings() {
<ToggleSwitch
checked={proInterface}
onChange={(v) => updateSetting('proInterface', v)}
testId="setting-pro-interface"
/>
</SettingItem>
</SettingsSection>
+2
View File
@@ -932,6 +932,7 @@ export function SettingsApp({ linkSegments }: SettingsAppProps = {}) {
return (
<div key={tab.id}>
<button
data-testid={`settings-tab-${tab.id}`}
onClick={() => handleTabSelect(tab.id)}
className="w-full flex items-center justify-between px-5 py-3.5 text-sm text-foreground hover:bg-muted transition-colors duration-150"
>
@@ -1072,6 +1073,7 @@ export function SettingsApp({ linkSegments }: SettingsAppProps = {}) {
return (
<div key={tab.id}>
<button
data-testid={`settings-tab-${tab.id}`}
onClick={() => handleTabSelect(tab.id)}
className={cn(
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
+4 -1
View File
@@ -67,14 +67,17 @@ interface ToggleSwitchProps {
disabled?: boolean;
/** Overrides the enclosing SettingItem label as the accessible name. */
ariaLabel?: string;
/** Test hook (data-testid) for integration tests. */
testId?: string;
}
export function ToggleSwitch({ checked, onChange, disabled, ariaLabel }: ToggleSwitchProps) {
export function ToggleSwitch({ checked, onChange, disabled, ariaLabel, testId }: ToggleSwitchProps) {
const labelledBy = useContext(SettingLabelContext);
return (
<button
type="button"
role="switch"
data-testid={testId}
aria-checked={checked}
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : labelledBy}
@@ -0,0 +1,89 @@
import { test, expect, type Page } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import {
login,
addAccount,
enableProInterface,
openCalendar,
calendarItem,
calendarRows,
} from './helpers/app';
/**
* Multi-account calendar aggregation across an account switch.
*
* Regression for the cross-account id-namespacing bug: the Pro shell used to
* leave the ACTIVE account's calendar ids raw while namespacing the others, so
* switching accounts flipped the id form and reset the persisted calendar
* visibility selection (and broke subscription/mutation routing). All accounts
* are now namespaced consistently, so the selection survives a switch.
*
* Mail is unaffected (it keys per account + uses stable virtual ids), which is
* why the user saw this only in Calendar and Address Book.
*/
const { alice, bob } = ACCOUNTS;
const switcher = (page: Page) => page.locator('[data-testid="account-switcher"]').first();
/** Read an account's id from the (stable, standard-shell) switcher popover. */
async function accountId(page: Page, email: string): Promise<string> {
await switcher(page).click();
const id = await page
.locator(`[data-testid="account-option"][data-account-email="${email}"]`)
.first()
.getAttribute('data-account-id');
await page.keyboard.press('Escape');
if (!id) throw new Error(`no account id for ${email}`);
return id;
}
/**
* Switch active account robustly in the Pro shell. The switcher popover is a
* portalled, repositioning element, so a normal click can race its layout;
* force the click and verify via the switcher's data-active-account-id.
*/
async function switchTo(page: Page, id: string): Promise<void> {
await switcher(page).click();
await page.locator(`[data-testid="account-option"][data-account-id="${id}"]`).first().click({ force: true });
await expect
.poll(async () => switcher(page).getAttribute('data-active-account-id'), { timeout: 30000 })
.toBe(id);
}
test.describe('Multi-account calendar', () => {
test('an account switch preserves the calendar visibility selection', async ({ page }) => {
// Both accounts in the standard shell (stable switcher), capture their ids,
// then flip on the Pro interface so the calendar aggregates across accounts.
await login(page, alice);
await addAccount(page, bob);
const bobId = await accountId(page, bob.email);
await enableProInterface(page);
await openCalendar(page);
// Both accounts' (default) calendars aggregate into one list with distinct
// names, and every row is initially visible.
const rows = await calendarRows(page);
expect(rows.length).toBeGreaterThanOrEqual(2);
expect(new Set(rows.map((r) => r.name)).size).toBeGreaterThanOrEqual(2);
// Deselect one calendar; remember it by name.
const target = rows.find((r) => r.visible);
expect(target, 'expected at least one visible calendar').toBeTruthy();
const name = target!.name;
await calendarItem(page, name).click();
await expect(calendarItem(page, name)).toHaveAttribute('data-visible', 'false');
// Switch the active account. This alone triggered the bug: the just-
// deselected calendar belonged to the previously-active account, whose ids
// flipped raw->namespaced on the switch, so the reconciler dropped the
// selection and reset visibility to "all".
await switchTo(page, bobId);
await openCalendar(page);
// The deselection survived the switch (the calendar is still aggregated in
// and still hidden), and the list is still the cross-account set.
await expect(calendarItem(page, name)).toHaveAttribute('data-visible', 'false');
expect((await calendarRows(page)).length).toBeGreaterThanOrEqual(2);
});
});
+50
View File
@@ -378,3 +378,53 @@ export async function emailContextAction(page: Page, subject: string, testId: st
}).toPass({ timeout: 15000 });
await item.click();
}
// --- Calendar (multi-account) ---------------------------------------------
/**
* Navigate to the calendar via the in-app nav (client-side routing) and wait
* for the sidebar calendar list. A hard `page.goto('/calendar')` redirects to
* login because the deep-link SSR path runs before client auth hydrates.
*/
export async function openCalendar(page: Page): Promise<void> {
await page.locator('a[href$="/calendar"]').first().click();
await page
.locator('[data-testid="calendar-item"]')
.first()
.waitFor({ state: 'visible', timeout: 30000 });
}
/** A sidebar calendar row by its (exact) name. */
export function calendarItem(page: Page, name: string): Locator {
return page.locator(`[data-testid="calendar-item"][data-calendar-name="${name}"]`).first();
}
/** All sidebar calendars as {name, account, visible}. */
export async function calendarRows(
page: Page,
): Promise<Array<{ name: string; account: string; visible: boolean }>> {
return page.locator('[data-testid="calendar-item"]').evaluateAll((els) =>
els.map((e) => ({
name: e.getAttribute('data-calendar-name') || '',
account: e.getAttribute('data-account') || '',
visible: e.getAttribute('data-visible') === 'true',
})),
);
}
/**
* Turn on the Pro (multi-account) interface via the settings UI, so the
* calendar/contacts aggregate across accounts. Done through the real toggle
* (not a reload/seed) because the app doesn't survive a hard reload in tests
* and seeding proInterface before login destabilises the add-account popover.
*/
export async function enableProInterface(page: Page): Promise<void> {
await page.locator('a[href$="/settings"]').first().click();
await page.locator('[data-testid="settings-tab-layout"]').first().click();
const toggle = page.locator('[data-testid="setting-pro-interface"]').first();
await toggle.waitFor({ state: 'visible', timeout: 30000 });
if ((await toggle.getAttribute('aria-checked')) !== 'true') {
await toggle.click();
await expect(toggle).toHaveAttribute('aria-checked', 'true');
}
}