mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -354,6 +354,7 @@ export function LayoutSettings() {
|
||||
<ToggleSwitch
|
||||
checked={proInterface}
|
||||
onChange={(v) => updateSetting('proInterface', v)}
|
||||
testId="setting-pro-interface"
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user