test(integration): dockerized webmail⇆Stalwart Playwright sync suite

Add an end-to-end integration harness that runs the webmail against a real
Stalwart mail server in Docker and drives it with Playwright, focused on the
mail/folder synchronisation behaviour (unread/total counters, folder-list
sync, account-scoped Unified Mailbox) that the unified-mailbox work touches.

- integration/ stack: Stalwart (declarative bootstrap: alice/bob/carol,
  submission + IMAP listeners, permissive CORS) + webmail (dev mode, so the
  browser's plaintext cross-origin JMAP calls aren't blocked by the prod CSP).
- Helpers: dependency-free SMTP submit client, JMAP client for seeding /
  inspecting server state, and page helpers (login, add/switch account,
  locale-independent folder-counter reads).
- Specs: login, single-account sync (receive/read/move/delete/folder-create/
  burst) and multi-account (per-account isolation + cross-account Unified
  Inbox aggregation + background-account delivery). 12 tests, all green.
- Add focused data-testid hooks to the mail UI (folder rows + counters, email
  list items, account switcher, composer) for stable selectors.
- Exclude examples/ and integration/ from tsconfig/eslint/.dockerignore.
This commit is contained in:
Stefan Hildebrandt
2026-07-11 21:15:13 +02:00
parent bc11450f3f
commit b8809c2e69
31 changed files with 1443 additions and 5 deletions
+6 -2
View File
@@ -2041,7 +2041,7 @@ export function EmailComposer({
};
return (
<div ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
<div ref={composerRootRef} data-testid="email-composer" className={cn("flex h-full bg-background", className)}>
<PluginSlot
name="composer-sidebar"
className="hidden md:flex shrink-0 h-full overflow-hidden border-e border-border"
@@ -2099,6 +2099,7 @@ export function EmailComposer({
disabled={!canSend || isSending}
title={getSendTooltip()}
size="sm"
data-testid="composer-send"
className="md:hidden h-9 px-4"
>
<Send className="w-4 h-4 me-1.5" />
@@ -2227,7 +2228,7 @@ export function EmailComposer({
</div>
{/* To field */}
<div className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
<div data-testid="composer-to" className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('to')}:</span>
<RecipientChipInput
chips={to}
@@ -2372,6 +2373,7 @@ export function EmailComposer({
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
<Input
ref={subjectInputRef}
data-testid="composer-subject"
type="text"
placeholder={t('subject_placeholder')}
value={subject}
@@ -2594,6 +2596,7 @@ export function EmailComposer({
onClick={() => handleSend()}
disabled={!canSend || isSending}
title={getSendTooltip()}
data-testid="composer-send"
className="rounded-e-none border-e border-primary-foreground/20"
>
<Send className="w-4 h-4 me-2" />
@@ -2632,6 +2635,7 @@ export function EmailComposer({
onClick={() => handleSend()}
disabled={!canSend || isSending}
title={getSendTooltip()}
data-testid="composer-send"
className="hidden md:inline-flex"
>
<Send className="w-4 h-4 me-2" />
+4
View File
@@ -166,6 +166,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
ref={ref}
{...dragHandlers}
{...longPressHandlers}
data-testid="email-list-item"
data-email-id={email.id}
data-subject={email.subject || ''}
data-unread={isUnread ? 'true' : 'false'}
className={cn(
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
resolvedColorTag ? resolvedColorTag : (
+6
View File
@@ -151,6 +151,8 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
<button
ref={buttonRef}
onClick={() => setOpen(!open)}
data-testid="account-switcher"
data-active-account-id={activeAccountId ?? undefined}
className={cn(
"flex items-center gap-2 rounded-md transition-colors",
variant === "rail"
@@ -213,6 +215,9 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
>
<button
onClick={() => handleSwitch(account.id)}
data-testid="account-option"
data-account-id={account.id}
data-account-email={account.email || account.username}
className={cn(
"w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors",
isActive ? "bg-accent/50" : "hover:bg-muted",
@@ -274,6 +279,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
<div className="border-t border-border">
<button
onClick={handleAddAccount}
data-testid="add-account"
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
role="menuitem"
>
+26 -2
View File
@@ -221,8 +221,13 @@ function SidebarRowCounts({
) : null;
return (
<span className="ms-2 flex-shrink-0 flex items-baseline gap-1" title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}>
<span
className="ms-2 flex-shrink-0 flex items-baseline gap-1"
title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}
data-testid="folder-counts"
data-unread={unreadCount}
data-total={totalCount}
>
{unreadNode}
{unreadCount > 0 && totalCount > 0 && (
<span className="text-xs text-muted-foreground/60">/</span>
@@ -250,6 +255,10 @@ interface SidebarRowProps {
isValidDropTarget?: boolean;
isInvalidDropTarget?: boolean;
onContextMenu?: (e: React.MouseEvent) => void;
/** Stable identifiers for integration tests (not user-visible). */
testRole?: string | null;
testName?: string;
testMailboxId?: string;
}
function SidebarRow({
@@ -270,6 +279,9 @@ function SidebarRow({
isValidDropTarget,
isInvalidDropTarget,
onContextMenu,
testRole,
testName,
testMailboxId,
}: SidebarRowProps) {
const t = useTranslations('sidebar');
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
@@ -278,6 +290,10 @@ function SidebarRow({
<div
{...(dropHandlers || {})}
onContextMenu={onContextMenu}
data-testid="folder-row"
data-folder-role={testRole ?? undefined}
data-folder-name={testName ?? undefined}
data-mailbox-id={testMailboxId ?? undefined}
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
@@ -478,6 +494,9 @@ function MailboxTreeItem({
<SidebarRow
icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, roleKey)} />}
label={label}
testRole={node.role}
testName={node.name}
testMailboxId={node.id}
depth={node.depth}
isSelected={isSelected}
isVirtual={isVirtualNode}
@@ -1038,6 +1057,9 @@ export function Sidebar({
key={unifiedId}
icon={<Icon className={getIconClass(isSelected, false, colorfulSidebarIcons, count.role)} />}
label={t(`unified_${count.role}`)}
testRole={count.role}
testName={`unified-${count.role}`}
testMailboxId={unifiedId}
depth={0}
isSelected={isSelected}
unread={count.unreadEmails}
@@ -1059,6 +1081,8 @@ export function Sidebar({
key={id}
icon={<Icon className={getIconClass(isSelected, false, colorfulSidebarIcons)} />}
label={label}
testName={id}
testMailboxId={id}
depth={0}
isSelected={isSelected}
unread={unread}