feat: consolidate shared UI and harden browser authority for release

This commit is contained in:
2026-09-08 01:35:05 +02:00
parent ac40774785
commit b75ca34295
143 changed files with 8664 additions and 752 deletions
@@ -0,0 +1,25 @@
import AddressBookPage from "../../../govoplan-addresses/webui/src/features/addressbook/AddressBookPage";
import { generatedTranslations } from "../../../govoplan-addresses/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { AuthInfo } from "../src/types";
import "../../../govoplan-addresses/webui/src/styles/addresses.css";
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
export default function AddressExplorerScenario() {
const params = new URLSearchParams(window.location.search);
const auth: AuthInfo = {
user: { id: "address-fixture-user", account_id: "address-fixture-account", email: "address-fixture@example.test" },
tenant: { id: "address-fixture-tenant", name: "Address fixture", slug: "address-fixture" },
scopes: params.has("read-only") ? ["addresses:contact:read"] : [
"addresses:contact:read", "addresses:contact:write", "addresses:contact:delete",
"addresses:address_book:write", "addresses:address_book:delete",
"addresses:address_list:write", "addresses:address_list:delete",
"addresses:sync:read", "addresses:sync:write", "addresses:governance:read"
],
roles: [], groups: [], profile_loaded: true, groups_loaded: true, roles_loaded: true
};
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
<AddressBookPage settings={settings} auth={auth} />
</PlatformLanguageProvider>;
}
@@ -0,0 +1,34 @@
import { useState } from "react";
import AttachmentsDataPage from "../../../govoplan-campaign/webui/src/features/campaigns/AttachmentsDataPage";
import { generatedTranslations as campaignTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
import ManagedFileChooser from "../../../govoplan-files/webui/src/features/files/components/ManagedFileChooser";
import { listFileSpaces } from "../../../govoplan-files/webui/src/api/files";
import { generatedTranslations as filesTranslations } from "../../../govoplan-files/webui/src/i18n/generatedTranslations";
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import type { AuthInfo, PlatformWebModule } from "../src/types";
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
import "../../../govoplan-files/webui/src/styles/file-manager.css";
const auth: AuthInfo = { user: { id: "user-1", account_id: "account-1", email: "fixture@example.test" },
tenant: { id: "tenant-1", name: "Fixture", slug: "fixture" }, scopes: ["campaigns:write", "files:file:read"],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
export default function CampaignAttachmentsScenario() {
const [available, setAvailable] = useState(true);
const modules: PlatformWebModule[] = [
{ id: "campaigns", label: "Campaign", version: "fixture" },
{ id: "files", label: "Files", version: "fixture", uiCapabilities: available ? {
"files.fileExplorer": { ManagedFileChooser, listFileSpaces }
} : {} }
];
return <PlatformModulesProvider modules={modules}>
<PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[campaignTranslations, filesTranslations]}>
<button type="button" onClick={() => setAvailable(value => !value)}>Toggle Files capability</button>
<ConcurrencyConflictProvider>
<AttachmentsDataPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} campaignId="campaign-attachments" />
</ConcurrencyConflictProvider>
</PlatformLanguageProvider>
</PlatformModulesProvider>;
}
@@ -0,0 +1,31 @@
import { useState } from "react";
import { apiPostJson } from "../src/api/client";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import BulkMessageReviewDialog from "../../../govoplan-campaign/webui/src/features/campaigns/review/BulkMessageReviewDialog";
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
function job(id: string, overrides: Record<string, unknown> = {}) {
return { id, build_status: "built", validation_status: "needs_review", recipient_email: `${id}@example.test`, subject: `Frozen ${id}`,
review_decision: { eligible: true, category_key: "same-attachment-condition", reason_required: true, issue_codes: ["attachment_match_empty"] }, ...overrides };
}
export default function CampaignBulkReviewScenario() {
const params = new URLSearchParams(window.location.search);
const [open, setOpen] = useState(false);
const [buildToken, setBuildToken] = useState("build-one");
const rows = [...Array.from({ length: params.has("large") ? 205 : 3 }, (_, index) => job(`job-${String(index).padStart(3, "0")}`)),
job("reviewed", { reviewed: true }), job("blocked", { validation_status: "blocked", review_decision: { eligible: false } }),
job("expected-exclusion", { validation_status: "excluded", review_decision: { eligible: false } }),
job("allowed-zero", { validation_status: "warning", review_decision: { eligible: false } }),
job("other-category", { review_decision: { eligible: true, category_key: "other-condition", reason_required: false, issue_codes: ["address_warning"] } })];
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
<main><button onClick={() => setOpen(true)}>Open grouped review</button>
<button data-testid="replace-build" onClick={() => setBuildToken("build-two")}>Replace build</button>
{open && <BulkMessageReviewDialog rows={rows} buildToken={buildToken} disabled={params.has("read-only")}
onClose={() => setOpen(false)} onAccept={async (selection) => {
await apiPostJson({ apiBaseUrl: "", apiKey: "", accessToken: "" }, "/api/v1/conformance/review-state", selection);
}} />}
</main>
</PlatformLanguageProvider>;
}
@@ -0,0 +1,10 @@
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import CampaignDeliveryPolicyPanel from "../../../govoplan-campaign/webui/src/features/admin/CampaignDeliveryPolicyPanel";
export default function CampaignDeliveryPolicyScenario() {
const params = new URLSearchParams(window.location.search);
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"}>
<CampaignDeliveryPolicyPanel settings={{ apiBaseUrl: "", accessToken: "", apiKey: "" }}
scope={params.get("scope") === "tenant" ? "tenant" : "system"} canWrite={!params.has("readonly")} />
</PlatformLanguageProvider>;
}
@@ -0,0 +1,19 @@
import ReviewSendPage from "../../../govoplan-campaign/webui/src/features/campaigns/ReviewSendPage";
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import type { AuthInfo, PlatformWebModule } from "../src/types";
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
const modules: PlatformWebModule[] = [{ id: "campaigns", label: "Campaign", version: "fixture" }];
const auth: AuthInfo = { user: { id: "sender", account_id: "sender-account", email: "sender@example.test" },
tenant: { id: "tenant", name: "Fixture", slug: "fixture" }, scopes: ["campaigns:campaign:read", "campaigns:recipient:read", "campaigns:campaign:review",
"campaigns:campaign:validate", "campaigns:campaign:build", "campaigns:campaign:send", "campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:control"],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
export default function CampaignDeliveryProgressScenario() {
const params = new URLSearchParams(window.location.search);
return <PlatformModulesProvider modules={modules}>
<PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
<ReviewSendPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} campaignId="delivery-campaign" />
</PlatformLanguageProvider>
</PlatformModulesProvider>;
}
@@ -0,0 +1,25 @@
// Real optional-module settings surface with test-owned HTTP responses only.
import MailSettingsPage from "../../../govoplan-campaign/webui/src/features/campaigns/MailSettingsPage";
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import type { PlatformWebModule } from "../src/types";
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
const settings = { apiBaseUrl: "", accessToken: "", apiKey: "" };
const modules: PlatformWebModule[] = [
{ id: "campaigns", label: "Campaign", version: "fixture" },
{ id: "mail", label: "Mail", version: "fixture" }
];
export default function CampaignMailSettingsScenario() {
const language = new URLSearchParams(window.location.search).get("language") ?? "en";
return <PlatformModulesProvider modules={modules}>
<PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[generatedTranslations]}>
<ConcurrencyConflictProvider>
<MailSettingsPage settings={settings} campaignId="campaign-mail" />
</ConcurrencyConflictProvider>
</PlatformLanguageProvider>
</PlatformModulesProvider>;
}
@@ -0,0 +1,66 @@
import { useCallback, useState } from "react";
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { useCampaignDraftEditor } from "../../../govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignDraftEditor";
import { getCampaignVersion, type CampaignVersionDetail } from "../../../govoplan-campaign/webui/src/api/campaigns";
import {
HeaderAddressEditorDialog, RecipientAddressEditorDialog, entryWithAddressValues, getAddressColumn,
type HeaderAddressValues
} from "../../../govoplan-campaign/webui/src/features/campaigns/recipients/RecipientAddressEditor";
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
const settings = { apiBaseUrl: "", accessToken: "", apiKey: "" };
const initialAddresses = [
{ name: "Alpha", email: "alpha@example.test" },
{ name: "Beta", email: "beta@example.test" },
{ name: "Zulu", email: "zulu@example.test" }
];
const initialVersion: CampaignVersionDetail = {
id: "order-version", campaign_id: "order-campaign", version_number: 1, edit_revision: 1, strong_etag: '"order-version:1"',
current_flow: "manual", current_step: "recipients", workflow_state: "editing", is_complete: false,
raw_json: {
campaign: { name: "Recipient ordering" }, server: {},
recipients: { allow_individual_to: true, to: initialAddresses },
entries: { defaults: {}, inline: [{ id: "entry-1", name: "Alpha", email: "alpha@example.test", to: initialAddresses }] }
}, editor_state: {}, updated_at: "2026-09-07T10:00:00Z"
};
export default function CampaignRecipientOrderScenario() {
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
<ConcurrencyConflictProvider><RecipientOrderEditor /></ConcurrencyConflictProvider>
</PlatformLanguageProvider>;
}
function RecipientOrderEditor() {
const [version, setVersion] = useState(initialVersion);
const [error, setError] = useState("");
const [dialog, setDialog] = useState<"entry" | "header" | null>(null);
const reload = useCallback(async () => setVersion(await getCampaignVersion(settings, "order-campaign", "order-version")), []);
const editor = useCampaignDraftEditor({ settings, campaignId: "order-campaign", version, locked: false,
reload, setError, currentStep: "recipients", unsavedTitle: "Unsaved recipient order", unsavedMessage: "Save or discard the campaign draft?" });
const entries = editor.displayDraft.entries as { defaults: Record<string, unknown>; inline: Record<string, unknown>[] };
const recipients = editor.displayDraft.recipients as Record<string, unknown>;
const entry = entries?.inline?.[0] ?? {};
return <main>
<button onClick={() => setDialog("entry")}>Edit individual addresses</button>
<button onClick={() => setDialog("header")}>Edit global addresses</button>
<button disabled={!editor.dirty || editor.saving} onClick={() => void editor.saveDraft()}>Save campaign</button>
<output data-testid="recipient-order">{JSON.stringify(entry.to)}</output>
<output data-testid="global-order">{JSON.stringify(recipients?.to)}</output>
<output data-testid="primary-address">{String(entry.email ?? "")}</output>
<output data-testid="recipient-dirty">{String(editor.dirty)}</output>
<output data-testid="recipient-error">{editor.localError || error}</output>
{dialog === "entry" && <RecipientAddressEditorDialog
entry={entry} index={0} locked={false} recipientsSection={recipients} entryDefaults={entries.defaults ?? {}}
onSave={(values, merges) => {
// Same owning helper as RecipientDataPage; no test copy of ordering or
// legacy primary-address/merge-field normalization.
editor.patch(["entries", "inline"], [entryWithAddressValues(entry, values, merges)]);
setDialog(null);
}} onClose={() => setDialog(null)} />}
{dialog === "header" && <HeaderAddressEditorDialog title="Global To addresses"
columns={[getAddressColumn("to")]} values={{ to: recipients.to } as HeaderAddressValues} locked={false}
onSave={(values) => { editor.patch(["recipients", "to"], values.to); setDialog(null); }} onClose={() => setDialog(null)} />}
</main>;
}
@@ -0,0 +1,23 @@
import CampaignReportPage from "../../../govoplan-campaign/webui/src/features/campaigns/CampaignReportPage";
import { useState } from "react";
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import type { AuthInfo, PlatformWebModule } from "../src/types";
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
const modules: PlatformWebModule[] = [{ id: "campaigns", label: "Campaign", version: "fixture" }];
export default function CampaignReportScenario() {
const params = new URLSearchParams(window.location.search);
const [account, setAccount] = useState("reporter");
const read = ["campaigns:campaign:read", "campaigns:recipient:read", "campaigns:report:read"];
const auth: AuthInfo = { user: { id: account, account_id: `${account}-account`, email: "reporter@example.test" },
tenant: { id: "tenant", name: "Fixture", slug: "fixture" }, scopes: params.has("read-only") ? read :
[...read, "campaigns:campaign:send", "campaigns:campaign:retry", "campaigns:campaign:queue", "campaigns:campaign:reconcile"],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
return <PlatformModulesProvider modules={modules}>
<PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
{params.has("switch-account") && <button type="button" onClick={() => setAccount("another-reporter")}>Switch fixture account</button>}
<CampaignReportPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} campaignId="report-campaign" />
</PlatformLanguageProvider>
</PlatformModulesProvider>;
}
@@ -0,0 +1,20 @@
import ValidationDetails from "../../../govoplan-campaign/webui/src/features/campaigns/review/ValidationDetails";
import RepeatedFilesDetails from "../../../govoplan-campaign/webui/src/features/campaigns/review/RepeatedFilesDetails";
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { useState } from "react";
const issues = Array.from({ length: 12 }, (_, index) => [
{ code: "missing_required_attachment", severity: "warning", path: `/entries/recipient-${index + 1}/attachments/0`, message: `Missing attachment for recipient ${index + 1}.` },
{ code: "missing_attachment_coverage", severity: "info", path: `/entries/recipient-${index + 1}`, message: `Policy excludes recipient ${index + 1} without attachments.` }
]).flat();
const findings = Array.from({ length: 12 }, (_, index) => ({ file_name: `repeated-file-${index + 1}.pdf`, file_fingerprint: `file-${index}`, use_count: 3, disposition: "allowed" }));
export default function CampaignReviewDetailsScenario() {
const [count, setCount] = useState(12);
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
<button onClick={() => setCount(2)}>Reduce fixture details</button>
<section aria-label="Validation details fixture"><ValidationDetails issues={issues.slice(0, count * 2)} /></section>
<section aria-label="Repeated files fixture"><RepeatedFilesDetails findings={findings.slice(0, count)} /></section>
</PlatformLanguageProvider>;
}
@@ -0,0 +1,24 @@
import ReviewSendPage from "../../../govoplan-campaign/webui/src/features/campaigns/ReviewSendPage";
import { useSearchParams } from "react-router";
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import type { AuthInfo, PlatformWebModule } from "../src/types";
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
const auth: AuthInfo = { user: { id: "reviewer", account_id: "reviewer-account", email: "reviewer@example.test" },
tenant: { id: "tenant", name: "Fixture", slug: "fixture" }, scopes: ["campaigns:campaign:read", "campaigns:campaign:review", "campaigns:campaign:build"],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
const modules: PlatformWebModule[] = [{ id: "campaigns", label: "Campaign", version: "fixture" }];
export default function CampaignReviewScenario() {
const params = new URLSearchParams(window.location.search);
const [, setSearchParams] = useSearchParams();
const fixtureAuth = params.has("read-only") ? { ...auth, scopes: ["campaigns:campaign:read"] } : auth;
return <PlatformModulesProvider modules={modules}>
<PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
{params.has("race") && <button onClick={() => setSearchParams((current) => {
const next = new URLSearchParams(current); next.set("version", "replacement-version"); return next;
})}>Switch fixture version</button>}
<ReviewSendPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={fixtureAuth} campaignId="review-campaign" />
</PlatformLanguageProvider>
</PlatformModulesProvider>;
}
@@ -0,0 +1,44 @@
import { useCallback, useState } from "react";
import { Link } from "react-router";
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { useCampaignDraftEditor } from "../../../govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignDraftEditor";
import { getCampaignVersion, type CampaignVersionDetail } from "../../../govoplan-campaign/webui/src/api/campaigns";
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
const settings = { apiBaseUrl: "", accessToken: "", apiKey: "" };
const initialVersion: CampaignVersionDetail = {
id: "version-a", campaign_id: "campaign-a", version_number: 1, edit_revision: 1, strong_etag: '"version-a:1"',
current_flow: "manual", current_step: "template", workflow_state: "editing", is_complete: false,
raw_json: { campaign: { name: "Original" }, template: { subject: "Original subject", text: "" }, server: {} },
editor_state: {}, updated_at: "2026-09-07T10:00:00Z"
};
export default function CampaignSavingScenario() {
return <PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[generatedTranslations]}>
<ConcurrencyConflictProvider><SavingEditor /></ConcurrencyConflictProvider>
</PlatformLanguageProvider>;
}
function SavingEditor() {
const [version, setVersion] = useState(initialVersion);
const [error, setError] = useState("");
const [result, setResult] = useState("");
const reload = useCallback(async () => setVersion(await getCampaignVersion(settings, "campaign-a", "version-a")), []);
const editor = useCampaignDraftEditor({ settings, campaignId: "campaign-a", version, locked: false,
reload, setError, currentStep: "template", unsavedTitle: "Unsaved fixture changes", unsavedMessage: "Save or discard the draft?" });
const subject = (editor.displayDraft.template as Record<string, unknown>)?.subject ?? "";
return <main>
<label>Subject<input aria-label="Subject" value={String(subject)} onChange={(event) => editor.patch(["template", "subject"], event.target.value)} /></label>
<button disabled={editor.saving} onClick={() => void editor.saveDraft().then((saved) => setResult(String(saved)))}>Save draft</button>
<button disabled={editor.saving} onClick={() => { void editor.saveDraft(); void editor.saveDraft(); }}>Request save twice</button>
<button disabled={editor.saving} onClick={() => void editor.discardDraft()}>Discard draft</button>
<button onClick={() => setVersion({ ...initialVersion, raw_json: { ...initialVersion.raw_json, template: { subject: "Background refresh" } } })}>Background refresh</button>
<Link to="/?campaign-saving&elsewhere">Other route</Link>
<output data-testid="save-busy">{String(editor.saving)}</output>
<output data-testid="save-dirty">{String(editor.dirty)}</output>
<output data-testid="save-status">{editor.saveState}</output>
<output data-testid="save-error">{editor.localError || error}</output>
<output data-testid="save-result">{result}</output>
</main>;
}
@@ -0,0 +1,28 @@
// Exercise the owning Campaign hook, with network responses controlled by tests.
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import { useCampaignWorkspaceData } from "../../../govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignWorkspaceData";
export default function CampaignWorkspaceScenario() {
const [campaignId, setCampaignId] = useState("campaign-a");
const [accessToken, setAccessToken] = useState("token-a");
const [, setSearchParams] = useSearchParams();
const settings = useMemo(() => ({ apiBaseUrl: "", apiKey: "", accessToken }), [accessToken]);
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeVersions: true });
return <main>
<button onClick={() => void reload()}>Reload workspace</button>
<button onClick={() => void reload({ force: true })}>Force reload workspace</button>
<button onClick={() => setCampaignId("campaign-b")}>Switch campaign</button>
<button onClick={() => setAccessToken("token-b")}>Switch identity</button>
<button onClick={() => setSearchParams((current) => {
const next = new URLSearchParams(current);
next.set("version", "selected-version");
return next;
})}>Select version</button>
<output data-testid="workspace-campaign">{data.campaign?.id ?? "none"}</output>
<output data-testid="workspace-version">{data.currentVersion?.id ?? "none"}</output>
<output data-testid="workspace-revision">{data.currentVersion?.edit_revision ?? 0}</output>
<output data-testid="workspace-loading">{String(loading)}</output>
<output data-testid="workspace-error">{error}</output>
</main>;
}
+101
View File
@@ -1,6 +1,34 @@
import { useMemo, useState } from "react";
import { CalendarDays, FileText, Folder, GitBranch, Inbox, ListChecks, Mail, Search, ShieldCheck } from "lucide-react";
import { useLocation } from "react-router";
import DialogLayoutScenario from "./DialogLayoutScenario";
import DataGridLayoutScenario from "./DataGridLayoutScenario";
import NavigationLayoutScenario from "./NavigationLayoutScenario";
import ManagedArchiveScenario from "./ManagedArchiveScenario";
import FilesToolbarScenario from "./FilesToolbarScenario";
import CredentialReferencesScenario from "./CredentialReferencesScenario";
import FormControlLayoutScenario from "./FormControlLayoutScenario";
import CampaignWorkspaceScenario from "./CampaignWorkspaceScenario";
import CampaignReportScenario from "./CampaignReportScenario";
import ModuleLayoutScenario from "./ModuleLayoutScenario";
import HelpCenterScenario from "./HelpCenterScenario";
import NotificationFilterScenario from "./NotificationFilterScenario";
import MultiSelectFilterScenario from "./MultiSelectFilterScenario";
import SearchFiltersScenario from "./SearchFiltersScenario";
import AddressExplorerScenario from "./AddressExplorerScenario";
import MailFolderExplorerScenario from "./MailFolderExplorerScenario";
import MailToolbarScenario from "./MailToolbarScenario";
import CampaignDeliveryProgressScenario from "./CampaignDeliveryProgressScenario";
import CampaignSavingScenario from "./CampaignSavingScenario";
import CampaignMailSettingsScenario from "./CampaignMailSettingsScenario";
import CampaignAttachmentsScenario from "./CampaignAttachmentsScenario";
import CampaignRecipientOrderScenario from "./CampaignRecipientOrderScenario";
import CampaignReviewScenario from "./CampaignReviewScenario";
import CampaignBulkReviewScenario from "./CampaignBulkReviewScenario";
import CampaignReviewDetailsScenario from "./CampaignReviewDetailsScenario";
import CampaignDeliveryPolicyScenario from "./CampaignDeliveryPolicyScenario";
import MailCredentialPolicyScenario from "./MailCredentialPolicyScenario";
import type { NavigationPreferenceScope } from "../src/components/navigationPreferenceLayout";
import FormInstancePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormInstancePage";
import FormsRuntimePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormsRuntimePage";
import PublicFormPage from "../../../govoplan-forms-runtime/webui/src/features/forms/PublicFormPage";
@@ -28,6 +56,7 @@ import StatePanel from "../src/components/StatePanel";
import WorkspaceFrame from "../src/components/WorkspaceFrame";
import WorkspaceLayout from "../src/components/WorkspaceLayout";
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
import WysiwygEditor from "../src/components/WysiwygEditor";
import BreadcrumbBar from "../src/layout/BreadcrumbBar";
import HelpMenu from "../src/layout/HelpMenu";
import IconRail from "../src/layout/IconRail";
@@ -54,6 +83,49 @@ export default function ConformanceApp() {
const [editorDirty, setEditorDirty] = useState(true);
const [metricDrilldown, setMetricDrilldown] = useState("");
if (new URLSearchParams(location.search).has("credential-references")) return <CredentialReferencesScenario />;
if (new URLSearchParams(location.search).has("files-toolbar")) return <FilesToolbarScenario />;
if (new URLSearchParams(location.search).has("form-control-layout")) return <FormControlLayoutScenario />;
if (new URLSearchParams(location.search).has("campaign-workspace")) return <CampaignWorkspaceScenario />;
if (new URLSearchParams(location.search).has("campaign-report")) return <CampaignReportScenario />;
if (new URLSearchParams(location.search).has("module-layouts")) return <ModuleLayoutScenario />;
if (new URLSearchParams(location.search).has("help-center")) return <HelpCenterScenario />;
if (new URLSearchParams(location.search).has("notification-filter")) return <NotificationFilterScenario />;
if (new URLSearchParams(location.search).has("multi-select-filter")) return <MultiSelectFilterScenario />;
if (new URLSearchParams(location.search).has("search-filters")) return <SearchFiltersScenario />;
if (new URLSearchParams(location.search).has("address-explorer")) return <AddressExplorerScenario />;
if (new URLSearchParams(location.search).has("mail-folder-explorer")) return <MailFolderExplorerScenario />;
if (new URLSearchParams(location.search).has("mail-toolbar")) return <MailToolbarScenario />;
if (new URLSearchParams(location.search).has("campaign-delivery-progress")) return <CampaignDeliveryProgressScenario />;
if (new URLSearchParams(location.search).has("campaign-saving")) return <CampaignSavingScenario />;
if (new URLSearchParams(location.search).has("campaign-mail-settings")) return <CampaignMailSettingsScenario />;
if (new URLSearchParams(location.search).has("campaign-attachments")) return <CampaignAttachmentsScenario />;
if (new URLSearchParams(location.search).has("campaign-recipient-order")) return <CampaignRecipientOrderScenario />;
if (new URLSearchParams(location.search).has("campaign-review")) return <CampaignReviewScenario />;
if (new URLSearchParams(location.search).has("campaign-bulk-review")) return <CampaignBulkReviewScenario />;
if (new URLSearchParams(location.search).has("campaign-review-details")) return <CampaignReviewDetailsScenario />;
if (new URLSearchParams(location.search).has("campaign-delivery-policy")) return <CampaignDeliveryPolicyScenario />;
if (new URLSearchParams(location.search).has("mail-credential-policy")) return <MailCredentialPolicyScenario />;
if (new URLSearchParams(location.search).has("data-grid-layout")) return <DataGridLayoutScenario />;
if (new URLSearchParams(location.search).has("managed-archive")) {
const params = new URLSearchParams(location.search);
return <ManagedArchiveScenario language={params.get("language") ?? "en"} downloadAllowed={!params.has("no-download")} />;
}
if (new URLSearchParams(location.search).has("navigation-layout")) {
const params = new URLSearchParams(location.search);
return <NavigationLayoutScenario scope={(params.get("scope") ?? "user") as NavigationPreferenceScope} language={params.get("language") ?? "en"} disabled={params.has("disabled")} />;
}
if (new URLSearchParams(location.search).has("dialog-layout")) {
const params = new URLSearchParams(location.search);
return <DialogLayoutScenario templates={params.get("fixture") === "templates"} language={params.get("language") ?? "de"} />;
}
if (new URLSearchParams(location.search).has("wysiwyg-lifecycle")) {
return <WysiwygLifecycleScenario source={new URLSearchParams(location.search).get("mode") === "source"} />;
}
if (new URLSearchParams(location.search).has("product-navigation")) {
return <ProductNavigationScenario />;
}
@@ -200,6 +272,35 @@ export default function ConformanceApp() {
);
}
function WysiwygLifecycleScenario({ source }: { source: boolean }) {
const initialHtml = source
? '<table style="width: 100%"><tbody><tr><td>Legacy template</td></tr></tbody></table>'
: "<p>Legacy <i>template</i></p>";
const [value, setValue] = useState(initialHtml);
const [changeCount, setChangeCount] = useState(0);
const [disabled, setDisabled] = useState(false);
const [mounted, setMounted] = useState(true);
return <main className="conformance-root">
<h1>Rich-text lifecycle fixture</h1>
<Button onClick={() => setDisabled((current) => !current)}>Toggle read-only</Button>
<Button onClick={() => setMounted((current) => !current)}>Toggle editor mount</Button>
<Button onClick={() => setValue(initialHtml.replace("Legacy", "Reloaded"))}>Load another value</Button>
<output data-testid="wysiwyg-change-count">{changeCount}</output>
<pre data-testid="wysiwyg-controlled-value">{value}</pre>
{mounted && <WysiwygEditor
value={value}
disabled={disabled}
ariaLabel="Rich-text fixture content"
labels={{ visual: "Visual fixture mode", source: "Source fixture mode" }}
onChange={(nextValue) => {
setValue(nextValue);
setChangeCount((current) => current + 1);
}}
/>}
</main>;
}
function ProductNavigationScenario() {
const projection = useMemo(
() => projectProductNavigation(
@@ -0,0 +1,18 @@
// Exercise the real shared credential editor with Mail's optional capability.
// API fixtures supply public metadata only; this never loads or saves secrets.
import CredentialEnvelopeManager from "../src/components/CredentialEnvelopeManager";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import { mailCredentialReferenceSelectors } from "../../../govoplan-mail/webui/src/features/mail/mailReferenceProviders";
import type { ApiSettings, PlatformWebModule } from "../src/types";
const settings: ApiSettings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
const modules: PlatformWebModule[] = [{
id: "mail", label: "Mail", version: "1",
uiCapabilities: { "core.credentialReferenceSelectors": mailCredentialReferenceSelectors }
}];
export default function CredentialReferencesScenario() {
return <PlatformModulesProvider modules={modules}>
<CredentialEnvelopeManager settings={settings} scopeType="tenant" canWrite />
</PlatformModulesProvider>;
}
@@ -0,0 +1,66 @@
import { useMemo, useState } from "react";
import { ArrowDown, ArrowUp, Eye, Plus, Trash2 } from "lucide-react";
import Button from "../src/components/Button";
import DataGrid, { DataGridEmptyAction, type DataGridColumn, type DataGridResizeBehavior } from "../src/components/table/DataGrid";
import TableActionGroup from "../src/components/table/TableActionGroup";
type Row = { id: string; name: string; detail: string };
const rows: Row[] = [{ id: "alpha", name: "Alpha", detail: "Long configured field value ".repeat(20) }];
/** Genuine shared grid: intentionally undersized legacy action preference. */
export default function DataGridLayoutScenario() {
const mode = new URLSearchParams(window.location.search).get("mode") ?? "cover";
const behavior: DataGridResizeBehavior = mode === "free" || mode === "constrained" ? mode : "cover";
const composite = mode === "composite";
const [width, setWidth] = useState(900);
const [mounted, setMounted] = useState(true);
const [empty, setEmpty] = useState(false);
const [extraAction, setExtraAction] = useState(false);
const [clicked, setClicked] = useState("");
const columns = useMemo<DataGridColumn<Row>[]>(() => [
{ id: "name", header: "Name", width: 260, minWidth: 180, resizable: true, value: (row) => row.name },
{ id: "detail", header: "Details", width: 360, minWidth: 220, resizable: true, value: (row) => row.detail },
{
id: "actions", header: "Actions", width: mode === "oversized" ? 500 : 72,
minWidth: mode === "oversized" ? 500 : undefined,
columnType: composite ? "actions" : undefined, sticky: behavior === "free" ? undefined : "end",
render: (row) => {
const group = <TableActionGroup actions={[
{ id: "inspect", label: `Inspect ${row.name}`, icon: <Eye />, onClick: () => setClicked(`Inspect ${row.name}`) },
{ id: "up", label: "Move up", icon: <ArrowUp />, disabledReason: "Already first", onClick: () => undefined },
{ id: "down", label: "Move down", icon: <ArrowDown />, onClick: () => setClicked("Move down") },
{ id: "remove", label: "Remove row", icon: <Trash2 />, onClick: () => setClicked("Remove row") },
extraAction && { id: "add", label: "Add below", icon: <Plus />, onClick: () => setClicked("Add below") }
]} />;
return composite ? <div role="group" aria-label="Composite controls" style={{ display: "flex", gap: 4, width: "100%" }}>
<Button className="table-action-button" aria-label="Extra control" onClick={() => setClicked("Extra control")}><Plus /></Button>
{group}
</div> : group;
}
}
], [behavior, composite, extraAction, mode]);
return (
<main style={{ padding: 16, minWidth: 0 }}>
<h1>Data grid layout conformance</h1>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
<Button onClick={() => setWidth(320)}>Narrow grid</Button>
<Button onClick={() => setWidth(900)}>Wide grid</Button>
<Button onClick={() => setMounted((value) => !value)}>Toggle grid mount</Button>
<Button onClick={() => setEmpty((value) => !value)}>Toggle empty rows</Button>
<Button onClick={() => setExtraAction((value) => !value)}>Toggle extra action</Button>
</div>
<output data-testid="clicked-action">{clicked}</output>
<div data-testid="grid-container" style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", width, maxWidth: "100%", minWidth: 0 }}>
{mounted && <DataGrid
id={`layout-conformance-${behavior}`}
rows={empty ? [] : rows}
columns={columns}
getRowKey={(row) => row.id}
initialFit={behavior === "free" ? "content" : "container"}
resizeBehavior={behavior}
emptyAction={<DataGridEmptyAction onAdd={() => setClicked("Add first row")} />}
/>}
</div>
</main>
);
}
@@ -0,0 +1,48 @@
import { useState } from "react";
import TemplatesPage from "../../../govoplan-templates/webui/src/features/templates/TemplatesPage";
import { generatedTranslations as templateTranslations } from "../../../govoplan-templates/webui/src/i18n/generatedTranslations";
import "../../../govoplan-templates/webui/src/styles/templates.css";
import Button from "../src/components/Button";
import Dialog from "../src/components/Dialog";
import { DialogForm, DialogSection } from "../src/components/DialogAnatomy";
import { FormGrid } from "../src/components/ContentGrid";
import FormField from "../src/components/FormField";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { AuthInfo } from "../src/types";
const fixtureAuth: AuthInfo = {
user: { id: "fixture-user", account_id: "fixture-account", email: "fixture@example.test" },
tenant: { id: "fixture-tenant", name: "Fixture", slug: "fixture" },
scopes: ["templates:template:read", "templates:template:write"],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
};
export default function DialogLayoutScenario({ templates = false, language = "de" }: { templates?: boolean; language?: string }) {
const [open, setOpen] = useState(true);
if (templates) return <PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[templateTranslations]}>
<TemplatesPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={fixtureAuth} />
</PlatformLanguageProvider>;
return <main className="conformance-root">
<Button onClick={() => setOpen(true)}>Open layout fixture</Button>
<Dialog
open={open}
title={`LangeDialogüberschriftOhneTrennzeichen${"Zusatz".repeat(12)}`}
description={`Referenz:${"abcdef0123456789".repeat(12)}`}
onClose={() => setOpen(false)}
footer={<><Button>Abbrechen</Button><Button variant="primary">Änderungen speichern</Button></>}
>
<DialogForm onSubmit={(event) => event.preventDefault()}>
<DialogSection>
<FormGrid columns={2} collapseAt="standard">
<FormField label={`Feldname${"Lang".repeat(20)}`}><input defaultValue={"Langer Wert ".repeat(20)} /></FormField>
<FormField label="Auswahl"><select defaultValue="long"><option value="long">{"Lange Auswahlliste ".repeat(20)}</option></select></FormField>
</FormGrid>
</DialogSection>
<FormField label="Mehrzeiliger Inhalt"><textarea defaultValue={"Nachweistext ".repeat(60)} rows={5} /></FormField>
<div style={{ maxWidth: "100%", minWidth: 0, overflowX: "auto" }} data-testid="dialog-local-scroll">
<table style={{ width: 1400 }}><tbody><tr><td>Absichtlich breite Tabelle</td><td>Letzte Tabellenspalte</td></tr></tbody></table>
</div>
</DialogForm>
</Dialog>
</main>;
}
@@ -0,0 +1,28 @@
// Real owning Files module with fixture-only authentication; tests intercept every API call.
import { useLocation } from "react-router";
import { useState } from "react";
import FilesPage from "../../../govoplan-files/webui/src/features/files/FilesPage";
import { generatedTranslations } from "../../../govoplan-files/webui/src/i18n/generatedTranslations";
import "../../../govoplan-files/webui/src/styles/file-manager.css";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { AuthInfo } from "../src/types";
const fullAuth: AuthInfo = {
user: { id: "fixture-user", account_id: "fixture-account", email: "fixture@example.test" },
tenant: { id: "fixture-tenant", name: "Fixture", slug: "fixture" },
scopes: ["files:file:read", "files:file:download", "files:file:upload", "files:file:organize", "files:file:delete", "files:file:share", "access:role:read"],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
};
const readOnlyAuth = { ...fullAuth, scopes: ["files:file:read"] };
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
export default function FilesToolbarScenario() {
const parameters = new URLSearchParams(useLocation().search);
const [nextSession, setNextSession] = useState(false);
const auth = parameters.has("read-only") ? readOnlyAuth : fullAuth;
return <PlatformLanguageProvider preferredLanguageCode={parameters.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
{parameters.has("session-switch") && <button type="button" onClick={() => setNextSession(true)}>Switch fixture session</button>}
<FilesPage settings={nextSession ? { ...settings, accessToken: "fixture-next-session" } : settings}
auth={nextSession ? { ...auth, user: { ...auth.user, id: "fixture-next-user" } } : auth} />
</PlatformLanguageProvider>;
}
@@ -0,0 +1,52 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import { FormGrid, FormLayout, GridItem } from "../src/components/ContentGrid";
import FormField from "../src/components/FormField";
import PasswordField from "../src/components/PasswordField";
import ToggleSwitch from "../src/components/ToggleSwitch";
import TableActionGroup from "../src/components/table/TableActionGroup";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { AttachmentRulesDataGrid } from "../../../govoplan-campaign/webui/src/features/campaigns/components/AttachmentRulesOverlay";
import type { AttachmentRule } from "../../../govoplan-campaign/webui/src/features/campaigns/utils/attachments";
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
/** Real shared controls and Campaign attachment editor; no API writes. */
export default function FormControlLayoutScenario() {
const [secret, setSecret] = useState("");
const [checked, setChecked] = useState(false);
const [rules, setRules] = useState<AttachmentRule[]>([]);
return <PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[generatedTranslations]}>
<main style={{ padding: 16, minWidth: 0 }}>
<h1>Shared form-control layout</h1>
<FormGrid columns={2} data-testid="mixed-form-grid">
<FormField label="Password" help="Leave blank to retain the configured secret.">
<PasswordField value={secret} onValueChange={setSecret} generator />
</FormField>
<ToggleSwitch label="Remove configured secret" checked={checked} onChange={setChecked} disabled={Boolean(secret)} />
<FormField label={"Long translated field label with additional context ".repeat(4)}>
<input aria-label="Long label field" />
</FormField>
<FormField label="Short label"><input aria-label="Short label field" /></FormField>
<FormField label="Wrapped field"><input aria-label="Wrapped field" /></FormField>
<GridItem><ToggleSwitch label="Wrapped switch" checked={checked} onChange={setChecked} /></GridItem>
</FormGrid>
<FormLayout columns={2} data-testid="mixed-form-layout" onSubmit={(event) => event.preventDefault()}>
<FormField label="Other setting"><input aria-label="Other setting" /></FormField>
<ToggleSwitch label="Other switch" checked={checked} onChange={setChecked} />
</FormLayout>
<h2>Global attachments</h2>
<div data-testid="global-attachments">
<AttachmentRulesDataGrid id="layout-global-attachments" rules={rules}
settings={{ apiBaseUrl: "", accessToken: "", apiKey: "" }} campaignId="fixture-campaign"
basePaths={[{ id: "fixture-source", name: "Fixture source", path: "fixtures" }]}
onChange={setRules} />
</div>
<h2>Compact actions resist broad consumer styles</h2>
<style>{".fixture-broad-button-style .btn { width: 100%; }"}</style>
<div className="fixture-broad-button-style" data-testid="compact-action">
<TableActionGroup actions={[{ id: "add", label: "Add fixture item", icon: <Plus />, onClick: () => setChecked(true) }]} />
</div>
</main>
</PlatformLanguageProvider>;
}
+9
View File
@@ -0,0 +1,9 @@
import DocsPage from "../../../govoplan-docs/webui/src/features/docs/DocsPage";
import { generatedTranslations } from "../../../govoplan-docs/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
export default function HelpCenterScenario() {
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
<DocsPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} />
</PlatformLanguageProvider>;
}
@@ -0,0 +1,16 @@
import { MailProfilePolicyEditor } from "../../../govoplan-mail/webui/src/features/mail/MailProfilePolicyEditor";
import { generatedTranslations } from "../../../govoplan-mail/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { MailProfileScope } from "../src/types";
import "../../../govoplan-mail/webui/src/styles/mail-profiles.css";
export default function MailCredentialPolicyScenario() {
const params = new URLSearchParams(window.location.search);
const scope = (params.get("scope") ?? "tenant") as MailProfileScope;
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
<MailProfilePolicyEditor settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }}
scopeType={scope} scopeId={scope === "system" || scope === "tenant" ? null : "fixture-target"}
profiles={[]} canWrite={!params.has("read-only")} locked={params.has("locked")}
onSaved={params.has("refresh-failure") ? async () => { throw new Error("Synthetic dependent refresh failed"); } : undefined} />
</PlatformLanguageProvider>;
}
@@ -0,0 +1,19 @@
import MailboxPage from "../../../govoplan-mail/webui/src/features/mail/MailboxPage";
import { generatedTranslations } from "../../../govoplan-mail/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { AuthInfo } from "../src/types";
import "../../../govoplan-mail/webui/src/styles/mail-profiles.css";
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
const auth: AuthInfo = {
user: { id: "mail-tree-user", account_id: "mail-tree-account", email: "mail-tree@example.test" },
tenant: { id: "mail-tree-tenant", name: "Mail tree fixture", slug: "mail-tree" },
scopes: ["mail:mailbox:read", "mail:profile:read", "mail:profile:use"],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
};
export default function MailFolderExplorerScenario() {
return <PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[generatedTranslations]}>
<MailboxPage settings={settings} auth={auth} />
</PlatformLanguageProvider>;
}
+23
View File
@@ -0,0 +1,23 @@
import { useState } from "react";
import MailboxPage from "../../../govoplan-mail/webui/src/features/mail/MailboxPage";
import { generatedTranslations } from "../../../govoplan-mail/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { AuthInfo } from "../src/types";
import "../../../govoplan-mail/webui/src/styles/mail-profiles.css";
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
export default function MailToolbarScenario() {
const parameters = new URLSearchParams(window.location.search);
const [tenant, setTenant] = useState("first");
const auth: AuthInfo = {
user: { id: "mail-toolbar-user", account_id: "mail-toolbar-account", email: "mail-toolbar@example.test" },
tenant: { id: `mail-toolbar-${tenant}`, name: "Mailbox toolbar fixture", slug: tenant },
scopes: ["mail:mailbox:read", "mail:profile:read", "mail:profile:use", ...(parameters.has("bounce-allowed") ? ["mail:bounce:read"] : [])],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
};
return <PlatformLanguageProvider preferredLanguageCode={parameters.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
{parameters.has("switch-tenant") && <button type="button" onClick={() => setTenant("second")}>Switch fixture tenant</button>}
<MailboxPage settings={settings} auth={auth} />
</PlatformLanguageProvider>;
}
@@ -0,0 +1,22 @@
// Conformance-only composition: render the owning module, never a test copy.
import FilesPage from "../../../govoplan-files/webui/src/features/files/FilesPage";
import { generatedTranslations } from "../../../govoplan-files/webui/src/i18n/generatedTranslations";
import "../../../govoplan-files/webui/src/styles/file-manager.css";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { AuthInfo } from "../src/types";
const fixtureAuth: AuthInfo = {
user: { id: "fixture-user", account_id: "fixture-account", email: "fixture@example.test" },
tenant: { id: "fixture-tenant", name: "Fixture", slug: "fixture" },
scopes: ["files:file:read", "files:file:download", "files:file:upload"],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
};
export default function ManagedArchiveScenario({ language = "en", downloadAllowed = true }: { language?: string; downloadAllowed?: boolean }) {
const auth = downloadAllowed ? fixtureAuth : {
...fixtureAuth, scopes: fixtureAuth.scopes.filter((scope) => scope !== "files:file:download")
};
return <PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[generatedTranslations]}>
<FilesPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} />
</PlatformLanguageProvider>;
}
@@ -0,0 +1,49 @@
import CommitteePage from "../../../govoplan-committee/webui/src/features/committee/CommitteePage";
import VotingPage from "../../../govoplan-voting/webui/src/features/voting/VotingPage";
import SchedulingPage from "../../../govoplan-scheduling/webui/src/features/scheduling/SchedulingPage";
import RiskCompliancePage from "../../../govoplan-risk-compliance/webui/src/features/riskCompliance/RiskCompliancePage";
import OrganizationsPage from "../../../govoplan-organizations/webui/src/features/organizations/OrganizationsPage";
import TypedRelationshipsPanel from "../../../govoplan-idm/webui/src/features/TypedRelationshipsPanel";
import { generatedTranslations as committeeTranslations } from "../../../govoplan-committee/webui/src/i18n/generatedTranslations";
import { generatedTranslations as votingTranslations } from "../../../govoplan-voting/webui/src/i18n/generatedTranslations";
import { generatedTranslations as schedulingTranslations } from "../../../govoplan-scheduling/webui/src/i18n/generatedTranslations";
import { generatedTranslations as riskTranslations } from "../../../govoplan-risk-compliance/webui/src/i18n/generatedTranslations";
import { generatedTranslations as organizationTranslations } from "../../../govoplan-organizations/webui/src/i18n/generatedTranslations";
import { generatedTranslations as idmTranslations } from "../../../govoplan-idm/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { AuthInfo } from "../src/types";
import "../../../govoplan-committee/webui/src/styles/committee.css";
import "../../../govoplan-voting/webui/src/styles/voting.css";
import "../../../govoplan-scheduling/webui/src/styles/scheduling.css";
import "../../../govoplan-risk-compliance/webui/src/styles/risk-compliance.css";
import "../../../govoplan-organizations/webui/src/styles/organizations.css";
import "../../../govoplan-idm/webui/src/styles/idm.css";
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
export default function ModuleLayoutScenario() {
const params = new URLSearchParams(window.location.search);
const auth: AuthInfo = {
user: { id: "layout-user", account_id: "layout-account", email: "layout@example.test" },
tenant: { id: "layout-tenant", name: "Layout fixture", slug: "layout" },
scopes: params.has("read-only") ? ["idm:relationship:read"] : [
"committee:workspace:write", "voting:ballot:manage", "scheduling:schedule:write",
"risk_compliance:sanctions:review", "risk_compliance:sanctions:screen", "risk_compliance:workspace:read",
"organizations:model:read", "organizations:model:write", "organizations:unit:write", "organizations:function:write",
"idm:relationship:read", "idm:relationship:write"
],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
};
const context = { settings, auth };
const module = params.get("module-layouts");
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[committeeTranslations, votingTranslations, schedulingTranslations, riskTranslations, organizationTranslations, idmTranslations]}>
<div data-testid="module-layout-fixture" style={{ height: "100vh", minWidth: 0 }}>
{module === "committee" && <CommitteePage {...context} />}
{module === "voting" && <VotingPage {...context} />}
{module === "scheduling" && <SchedulingPage {...context} />}
{module === "risk" && <RiskCompliancePage {...context} />}
{module === "organizations" && <OrganizationsPage {...context} />}
{module === "idm" && <TypedRelationshipsPanel {...context} />}
</div>
</PlatformLanguageProvider>;
}
@@ -0,0 +1,34 @@
import { useState } from "react";
import Button from "../src/components/Button";
import Dialog from "../src/components/Dialog";
import MultiSelectFilter from "../src/components/MultiSelectFilter";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
const options = [
{ value: "first", label: "First tag" },
{ value: "long", label: "X".repeat(80) },
{ value: "last", label: "Last tag" }
];
export default function MultiSelectFilterScenario() {
const [open, setOpen] = useState(false);
const [selection, setSelection] = useState<string[] | null>(null);
const filter = <MultiSelectFilter label="Fixture tags" options={options} value={selection} onChange={setSelection} />;
return <PlatformLanguageProvider preferredLanguageCode="en">
<main className="content-pad">
<Button onClick={() => setOpen(true)}>Open filter dialog</Button>
<Button>Outside action</Button>
{filter}
<output aria-label="Selected tags">{selection === null ? "all" : JSON.stringify(selection)}</output>
<Dialog open={open} title="Filter owner" onClose={() => setOpen(false)} size="small"
panelStyle={{ transform: "translateZ(0)" }}
footer={<Button onClick={() => setOpen(false)}>Done</Button>}
>
<div style={{ overflow: "hidden", maxHeight: 100 }}>
<p>The filter escapes this clipped and transformed dialog.</p>
{filter}
</div>
</Dialog>
</main>
</PlatformLanguageProvider>;
}
@@ -0,0 +1,33 @@
import { useState } from "react";
import { Folder, LayoutDashboard, Mail } from "lucide-react";
import NavigationPreferenceEditor from "../src/components/NavigationPreferenceEditor";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import IconRail from "../src/layout/IconRail";
import type { NavigationPreferences, PlatformNavItem, ProductAreaContribution } from "../src/types";
import type { NavigationPreferenceScope } from "../src/components/navigationPreferenceLayout";
const items: PlatformNavItem[] = [
{ to: "/dashboard", label: "Dashboard", surfaceId: "dashboard", navigationId: "dashboard", icon: LayoutDashboard, navigationLocked: true, navigationLayers: { module: { order: 0, visible: true, locked: false }, system: { order: 0, visible: true, locked: true }, tenant: { order: 0, visible: true, locked: true } } },
{ to: "/files", label: "Files", surfaceId: "files", navigationId: "files", icon: Folder, order: 1 },
{ to: "/mail", label: "Mail", surfaceId: "mail", navigationId: "mail", icon: Mail, order: 2 }
];
const areas: ProductAreaContribution[] = [
{ id: "content", moduleId: "files", label: "Documents", iconName: "files", surfaceIds: ["files"], order: 1 },
{ id: "communication", moduleId: "mail", label: "Communication", iconName: "mail", surfaceIds: ["mail"], order: 2 }
];
export default function NavigationLayoutScenario({ scope = "user", disabled = false, language = "en" }: { scope?: NavigationPreferenceScope; disabled?: boolean; language?: string }) {
const [value, setValue] = useState<NavigationPreferences | null>(() => new URLSearchParams(window.location.search).has("unavailable") ? {
contract_version: "1", order: ["dashboard", "optional.navigation.absent", "files", "mail"], hidden: [], separators: []
} : null);
return <PlatformLanguageProvider preferredLanguageCode={language}>
<div style={{ display: "grid", gridTemplateColumns: "auto minmax(0, 1fr)", minWidth: 0 }}>
<IconRail navItems={items} productAreas={areas} presentation={{ navigation: value }} />
<main style={{ minWidth: 0, padding: 16 }}>
<h1>Navigation editor fixture</h1>
<NavigationPreferenceEditor items={items} productAreas={areas} value={value} onChange={setValue} scope={scope} disabled={disabled} />
<output data-testid="navigation-draft" style={{ display: "none" }}>{JSON.stringify(value)}</output>
</main>
</div>
</PlatformLanguageProvider>;
}
@@ -0,0 +1,28 @@
import NotificationCenterPage from "../../../govoplan-notifications/webui/src/features/notifications/NotificationCenterPage";
import { generatedTranslations } from "../../../govoplan-notifications/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { AuthInfo } from "../src/types";
import { useEffect, useState } from "react";
import "../../../govoplan-notifications/webui/src/styles/notifications.css";
const auth: AuthInfo = {
user: { id: "filter-user", account_id: "filter-account", email: "filter@example.test" },
tenant: { id: "filter-tenant", name: "Filter fixture", slug: "filter" },
scopes: ["notifications:notification:read"], roles: [], groups: [],
profile_loaded: true, roles_loaded: true, groups_loaded: true,
};
export default function NotificationFilterScenario() {
const params = new URLSearchParams(window.location.search);
const [account, setAccount] = useState("filter-user");
useEffect(() => {
const changeAccount = () => setAccount("other-user");
window.addEventListener("conformance-notification-account", changeAccount);
return () => window.removeEventListener("conformance-notification-account", changeAccount);
}, []);
const scopedAuth = { ...auth, user: { ...auth.user, id: account },
scopes: params.has("write") ? [...auth.scopes, "notifications:notification:write", "notifications:delivery:dispatch"] : auth.scopes };
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
<NotificationCenterPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: account }} auth={scopedAuth} />
</PlatformLanguageProvider>;
}
+91 -8
View File
@@ -1,11 +1,85 @@
// Narrow facade used only by the conformance build. It lets optional modules
// exercise their real task surfaces without pulling the composed application's
// generated module catalogue into this isolated test bundle.
export { apiFetch, apiPath } from "../src/api/client";
export { ApiError, apiDownload, apiFetch, apiGetList, apiPath, apiPost, apiPostJson, apiQuery, apiUrl, authHeaders, csrfToken } from "../src/api/client";
export { fetchAuthGroups } from "../src/api/auth";
export { default as FormSection } from "../src/components/FormSection";
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "../src/api/mailContracts";
export type * from "../src/api/mailContracts";
export type * from "../src/types";
export { default as FieldLabel } from "../src/components/help/FieldLabel";
export { default as PasswordField } from "../src/components/PasswordField";
export { default as ResourceAccessExplanation } from "../src/components/ResourceAccessExplanation";
export { default as ExplorerTree } from "../src/components/ExplorerTree";
export type { ExplorerTreeNodeContext } from "../src/components/ExplorerTree";
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions, DataGridPaginationBar } from "../src/components/table/DataGrid";
export type { DataGridColumn, DataGridQueryState, DataGridListOption } from "../src/components/table/DataGrid";
export { default as TableActionGroup } from "../src/components/table/TableActionGroup";
export { revisionConflictFromError, threeWayMerge } from "../src/api/concurrency";
export { useConcurrencyConflictResolver } from "../src/components/ConcurrencyConflictDialog";
export { useRegisterUnsavedChanges, UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard";
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "../src/components/UnsavedChangesGuard";
export { useDeltaWatermarks } from "../src/utils/deltaHooks";
export { default as ReferenceSelect } from "../src/components/ReferenceSelect";
export { unavailableReferenceOption } from "../src/components/ReferenceSelect";
export { filterSearchableSelectOptions } from "../src/components/SearchableSelect";
export type { ReferenceOptionProvider, ReferenceOption, CredentialReferenceSelectorContext, CredentialReferenceSelectorsUiCapability } from "../src/components/ReferenceSelect";
export { apiReferenceOptionProvider } from "../src/platform/referenceProviders";
export { fetchResourceAccessExplanation, fetchResourceAccessExplanationSubjects } from "../src/api/resourceAccess";
export type { AccessDecisionProvenanceItem, ResourceAccessExplanationUser, ResourceAccessExplanationResponse, ResourceAccessExplanationSubjectsResponse } from "../src/api/resourceAccess";
export { default as ActionBlockerHint } from "../src/components/ActionBlockerHint";
export type { ActionBlockerReason } from "../src/components/ActionBlockerHint";
export { default as MessageDisplayPanel } from "../src/components/MessageDisplayPanel";
export type { MessageDisplayAttachment } from "../src/components/MessageDisplayPanel";
export { default as GuidedReviewList } from "../src/components/GuidedReviewList";
export { default as InlineHelp } from "../src/components/help/InlineHelp";
export { default as ActionToolbar } from "../src/components/ActionToolbar";
export { ToolbarGroup } from "../src/components/ActionToolbar";
export { default as MultiSelectFilter } from "../src/components/MultiSelectFilter";
export { default as CountBadge } from "../src/components/CountBadge";
export { default as Button } from "../src/components/Button";
export { default as Card } from "../src/components/Card";
export { default as MetricGrid } from "../src/components/MetricGrid";
export { default as MetricCard } from "../src/components/MetricCard";
export { default as PageActionBar } from "../src/components/PageActionBar";
export { default as PageLayout } from "../src/components/PageLayout";
export { default as PageTitle } from "../src/components/PageTitle";
export { default as AdminIconButton } from "../src/components/admin/AdminIconButton";
export { default as ModuleSubnav } from "../src/layout/ModuleSubnav";
export type { ModuleSubnavGroup } from "../src/layout/ModuleSubnav";
export { default as DateTimeField } from "../src/components/DateTimeField";
export { default as PeoplePicker } from "../src/components/people/PeoplePicker";
export type { PeoplePickerItem, PeoplePickerSearch, PeoplePickerSearchGroup } from "../src/components/people/peoplePickerTypes";
export type { FormatDateTimeOptions } from "../src/utils/datetime";
export { default as SearchableSelect } from "../src/components/SearchableSelect";
export type { SearchableSelectOption } from "../src/components/SearchableSelect";
export { FormLayout } from "../src/components/ContentGrid";
export { apiPatchJson, isApiError } from "../src/api/client";
export { hasAnyScope } from "../src/utils/permissions";
export { useEffectiveView, useViewSurfaces } from "../src/platform/ViewContext";
export { isViewSurfaceVisible } from "../src/platform/views";
export { MailServerFolderLookupResultView } from "../src/components/mail/MailServerSettingsPanel";
export type { MailServerFolderLookupResult } from "../src/components/mail/MailServerSettingsPanel";
export { default as AdminSelectionList } from "../src/components/admin/AdminSelectionList";
export { default as AdminPageLayout } from "../src/components/admin/AdminPageLayout";
export { adminErrorMessage } from "../src/components/admin/adminUtils";
export { default as ConnectionTree } from "../src/components/ConnectionTree";
export type { ConnectionTreeColumn } from "../src/components/ConnectionTree";
export { default as StageRail } from "../src/components/StageRail";
export type { StageRailTone } from "../src/components/StageRail";
export { default as PolicyLockedHint } from "../src/components/PolicyLockedHint";
export { default as PolicyPathHelp, normalizePolicySourcePathItems } from "../src/components/PolicyPathHelp";
export type { NormalizedPolicySourcePathItem } from "../src/components/PolicyPathHelp";
export { default as PolicySourcePath } from "../src/components/PolicySourcePath";
export type { PolicySourcePathItem } from "../src/components/PolicySourcePath";
export { PolicyRow, PolicyTable } from "../src/components/PolicyTable";
export { default as MailServerSettingsPanel, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, normalizeMailImapFolderMappings, normalizeMailServerSecurity } from "../src/components/mail/MailServerSettingsPanel";
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerImapSettings, MailServerSmtpSettings } from "../src/components/mail/MailServerSettingsPanel";
export { mergeDeltaRows } from "../src/utils/delta";
export { ReferenceMultiSelect, customReferenceOption, staticReferenceOptionProvider } from "../src/components/ReferenceSelect";
export { platformModuleReferenceProvider } from "../src/platform/referenceProviders";
export { default as ConfirmDialog } from "../src/components/ConfirmDialog";
export { default as ContentSection } from "../src/components/ContentSection";
export { default as DescriptionList, DescriptionItem } from "../src/components/DescriptionList";
export { default as Dialog } from "../src/components/Dialog";
export { DialogForm, DialogSection } from "../src/components/DialogAnatomy";
@@ -13,12 +87,14 @@ export { default as DismissibleAlert } from "../src/components/DismissibleAlert"
export { default as DocumentationHelpLink } from "../src/components/help/DocumentationHelpLink";
export type { DocumentationHelpReference } from "../src/components/help/documentationHelp";
export { default as FileDropZone } from "../src/components/FileDropZone";
export { default as FilterBar } from "../src/components/FilterBar";
export { default as FormField } from "../src/components/FormField";
export { FormGrid } from "../src/components/ContentGrid";
export { FormGrid, default as ContentGrid } from "../src/components/ContentGrid";
export { default as IconButton } from "../src/components/IconButton";
export { default as LoadingFrame } from "../src/components/LoadingFrame";
export { default as LoadingIndicator } from "../src/components/LoadingIndicator";
export { default as PageScrollViewport } from "../src/components/PageScrollViewport";
export { default as SegmentedControl } from "../src/components/SegmentedControl";
export {
default as SelectionList,
SelectionListItem,
@@ -29,29 +105,36 @@ export { default as StatusBadge } from "../src/components/StatusBadge";
export { default as ToggleSwitch } from "../src/components/ToggleSwitch";
export {
useGuardedNavigate,
useUnsavedChanges,
useUnsavedDraftGuard
} from "../src/components/UnsavedChangesGuard";
export {
i18nMessage,
usePlatformLanguage
} from "../src/i18n/LanguageContext";
export { usePlatformModuleInstalled } from "../src/platform/ModuleContext";
export { usePlatformModuleInstalled, usePlatformUiCapability, usePlatformUiCapabilities, usePlatformModules } from "../src/platform/ModuleContext";
export {
dispatchQuickAccessResult,
quickAccessLaunchState
} from "../src/platform/launchContext";
export { hasScope } from "../src/utils/permissions";
export { formatDateTime } from "../src/utils/datetime";
export { insertAfter, moveArrayItem } from "../src/utils/arrayOrder";
export { addressesFromValue, dedupeAddresses, parseMailboxAddressText } from "../src/utils/emailAddresses";
export type { MailboxAddress } from "../src/utils/emailAddresses";
export { default as WorkspaceActionBar } from "../src/components/WorkspaceActionBar";
export { default as WorkspaceFrame } from "../src/components/WorkspaceFrame";
export { default as WorkspaceLayout } from "../src/components/WorkspaceLayout";
export type {
ApiSettings,
GlobalSearchProps,
SearchContextContribution,
SearchContextsUiCapability,
DeltaDeletedItem,
FilesManagedFileLinkTarget,
AuthInfo,
PlatformRouteContext,
PlatformTranslations,
QuickAccessRailProps,
QuickAccessToolsUiCapability
} from "../src/types";
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
void capabilityName;
return [];
}
@@ -0,0 +1,39 @@
import SearchPage from "../../../govoplan-search/webui/src/features/search/SearchPage";
import GlobalSearch from "../../../govoplan-search/webui/src/components/GlobalSearch";
import { searchFilterTranslations } from "../../../govoplan-search/webui/src/i18n/searchFilterTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import type { AuthInfo, PlatformWebModule } from "../src/types";
import { useEffect, useMemo, useState } from "react";
import "../../../govoplan-search/webui/src/styles/search.css";
const auth: AuthInfo = {
user: { id: "search-user", account_id: "search-account", email: "search@example.test" },
tenant: { id: "search-tenant", name: "Search fixture", slug: "search" },
scopes: ["search:result:read"], roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true,
};
const modules: PlatformWebModule[] = [
{ id: "files", label: "Files", version: "1", uiCapabilities: { "search.contexts": { contexts: [{ id: "files.current", moduleId: "files", label: "Current files", pathPrefixes: ["/files"], resourceTypes: ["file"] }] } } },
{ id: "mail", label: "Mail", version: "1" },
];
export default function SearchFiltersScenario() {
const params = new URLSearchParams(window.location.search);
const [account, setAccount] = useState("search-user");
const accessToken = params.has("same-token") ? "search-user" : account;
const settings = useMemo(() => ({ apiBaseUrl: "", apiKey: "", accessToken }), [accessToken]);
const scopedAuth = useMemo(() => ({ ...auth,
user: { ...auth.user, id: account, account_id: `${account}-account` },
tenant: { ...auth.tenant, id: `${account}-tenant` },
}), [account]);
useEffect(() => {
const changeAccount = () => setAccount("other-user");
window.addEventListener("conformance-search-account", changeAccount);
return () => window.removeEventListener("conformance-search-account", changeAccount);
}, []);
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[searchFilterTranslations]}>
<PlatformModulesProvider modules={modules}>
{params.has("overlay") ? <GlobalSearch settings={settings} auth={scopedAuth} /> : <SearchPage settings={settings} auth={scopedAuth} />}
</PlatformModulesProvider>
</PlatformLanguageProvider>;
}
+1 -1
View File
@@ -39,7 +39,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<PlatformModulesProvider modules={CONFORMANCE_MODULES}>
<PlatformLanguageProvider
preferredLanguageCode="de"
preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? (["navigation-layout", "managed-archive"].some((key) => new URLSearchParams(window.location.search).has(key)) ? "en" : "de")}
moduleTranslations={[formsRuntimeTranslations, productSurfaceTranslations]}>
<UnsavedChangesProvider>
<Routes>
@@ -0,0 +1,100 @@
import { expect, test, type Page } from "@playwright/test";
async function mockAddresses(page: Page) {
const errors: string[] = [];
const writes: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
const timestamp = "2026-09-01T12:00:00Z";
await page.route(url => url.pathname.startsWith("/api/"), async route => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (request.method() !== "GET") writes.push(path);
let body: unknown = {};
if (path === "/api/v1/addresses/address-books") body = { address_books: [{
id: "book-fixture", name: "Fixture address book", scope_type: "tenant",
source_kind: "local", read_only: false, contact_count: 0, created_at: timestamp, updated_at: timestamp
}] };
else if (path === "/api/v1/addresses/address-lists") body = { address_lists: [{
id: "list-fixture", address_book_id: "book-fixture", name: "Fixture address list",
source_kind: "local", read_only: false, entry_count: 0, created_at: timestamp, updated_at: timestamp
}] };
else if (path === "/api/v1/addresses/sync-sources") body = { sync_sources: [] };
else if (path === "/api/v1/addresses/contacts") body = { contacts: [], total: 0, limit: 50, offset: 0 };
else if (path.endsWith("/entries")) body = { entries: [] };
return route.fulfill({ json: body });
});
return { errors, writes };
}
test("Addressbook keeps Reload/New together and moves specialist actions out of the narrow tree header", async ({ page }) => {
const fixture = await mockAddresses(page);
await page.setViewportSize({ width: 1360, height: 900 });
await page.goto("/?address-explorer&language=en&theme=light");
const toolbar = page.getByRole("toolbar", { name: "Address book actions", exact: true });
const reload = toolbar.locator('[data-page-action-slot="reload"] button');
const create = toolbar.getByRole("button", { name: "Add address book", exact: true });
await expect(reload).toBeEnabled();
await expect(create).toBeVisible();
const [reloadBox, createBox] = await Promise.all([reload.boundingBox(), create.boundingBox()]);
expect(createBox!.x).toBeGreaterThan(1100);
expect(Math.abs(createBox!.y - reloadBox!.y)).toBeLessThan(3);
expect(createBox!.x - reloadBox!.x - reloadBox!.width).toBeLessThan(20);
await expect(page.locator('.address-tree-header button')).toHaveCount(1);
await toolbar.getByRole("button", { name: "Import / export", exact: true }).click();
const transfer = page.getByRole("dialog", { name: "Import / export", exact: true });
await expect(transfer.getByRole("button", { name: "Import contacts", exact: true })).toBeVisible();
await expect(transfer.getByRole("button", { name: "Export address book", exact: true })).toBeVisible();
await expect(transfer.getByRole("combobox", { name: "vCard export version" })).toBeVisible();
await page.keyboard.press("Escape");
await toolbar.getByRole("button", { name: "Connections", exact: true }).click();
const connections = page.getByRole("dialog", { name: "Connections", exact: true });
await expect(connections.getByRole("button", { name: "Connect CardDAV", exact: true })).toBeVisible();
await expect(connections.getByRole("button", { name: "Connect LDAP or Active Directory", exact: true })).toBeVisible();
await expect(connections.getByRole("button", { name: "Run sync", exact: true })).toBeDisabled();
expect(fixture.errors).toEqual([]);
expect(fixture.writes).toEqual([]);
});
test("Addressbook labels select only; folder buttons toggle, including after Reload", async ({ page }) => {
const fixture = await mockAddresses(page);
await page.goto("/?address-explorer&language=en&theme=light");
const root = page.locator('.address-tree-list > .explorer-tree-children > div').first();
const label = root.locator(':scope > .explorer-tree-node-wrap > .explorer-tree-node');
const folder = root.locator(':scope > .explorer-tree-node-wrap > .explorer-tree-toggle');
await expect(folder).toHaveAttribute("aria-expanded", "true");
await label.click();
await expect(label).toHaveAttribute("aria-current", "true");
await expect(folder).toHaveAttribute("aria-expanded", "true");
await expect(page.getByText("Select an address book in this group.", { exact: false })).toBeVisible();
await folder.click();
await expect(folder).toHaveAttribute("aria-expanded", "false");
await label.click();
await expect(folder).toHaveAttribute("aria-expanded", "false");
await page.locator('[data-page-action-slot="reload"] button').click();
await expect(page.locator('[data-page-action-slot="reload"] button')).toBeEnabled();
await expect(folder).toHaveAttribute("aria-expanded", "false");
await expect(label).toHaveAttribute("aria-current", "true");
await folder.click();
const book = page.locator('.address-tree-list .explorer-tree-node').filter({ hasText: "Fixture address book" });
await book.click();
await expect(book).toHaveAttribute("aria-current", "true");
await page.locator('.address-tree-header').getByRole("button", { name: "Manage", exact: true }).click();
const manage = page.getByRole("dialog", { name: "Manage selected book or list", exact: true });
await expect(manage.getByRole("button", { name: "Add address list", exact: true })).toBeVisible();
await expect(manage.getByRole("button", { name: "Edit address book", exact: true })).toBeVisible();
await expect(manage.locator('.form-section-separated').getByRole("button", { name: "Delete address book", exact: true })).toBeVisible();
expect(fixture.errors).toEqual([]);
expect(fixture.writes).toEqual([]);
});
test("Addressbook specialist controls retain permission blockers in German", async ({ page }) => {
const fixture = await mockAddresses(page);
await page.goto("/?address-explorer&language=de&read-only&theme=light");
await expect(page.getByRole("button", { name: "Adressbuch hinzufügen", exact: true })).toBeDisabled();
await page.getByRole("button", { name: "Import / Export", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Import / Export", exact: true });
await expect(dialog.getByRole("button", { name: "Kontakte importieren", exact: true })).toBeDisabled();
await expect(dialog.getByRole("heading", { name: "In das ausgewählte Adressbuch importieren", exact: true })).toBeVisible();
expect(fixture.errors).toEqual([]);
expect(fixture.writes).toEqual([]);
});
@@ -0,0 +1,84 @@
import { expect, test, type Page } from "@playwright/test";
async function install(page: Page) {
const errors: string[] = [];
page.on("pageerror", error => { errors.push(error.message); console.error("Attachment fixture:", error.message); });
let revision = 1;
const zip = { enabled: true, archives: [{ id: "zip-1", name: "recipient.zip", method: "zip_standard", password_enabled: true }] };
let raw = { campaign: { name: "Fixture" }, template: { subject: "Fixture", text: "" }, server: {},
attachments: { base_paths: [{ id: "source-1", name: "Source", path: ".", source: "managed:user:user-1", allow_individual: true }],
global: [], zip } };
const writes: Record<string, any>[] = [];
const version = () => ({ id: "version-attachments", campaign_id: "campaign-attachments", version_number: 1,
edit_revision: revision, strong_etag: `"version-attachments:${revision}"`, editor_state: {},
current_flow: "manual", current_step: "files", workflow_state: "editing", is_complete: false,
updated_at: "2026-09-07T10:00:00Z", raw_json: raw });
await page.route((url) => url.pathname.startsWith("/api/"), async route => {
const request = route.request(); const url = new URL(request.url());
if (request.method() === "GET") {
if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: {
campaign: { id: "campaign-attachments", name: "Fixture", current_version_id: "version-attachments", status: "draft" },
versions: [version()], current_version: version(), summary: null, deleted: [], full: true, has_more: false, watermark: `w${revision}`
} });
if (url.pathname === "/api/v1/files/spaces") return route.fulfill({ json: { spaces: [{ id: "space-1", label: "My files", space_type: "managed", owner_type: "user", owner_id: "user-1" }] } });
if (url.pathname === "/api/v1/files/folders") return route.fulfill({ json: { folders: [], next_cursor: null } });
if (url.pathname === "/api/v1/files") return route.fulfill({ json: { files: [], next_cursor: null } });
if (url.pathname === "/api/v1/files/delta") return route.fulfill({ json: { files: [], folders: [{ id: "folder-1", owner_type: "user", owner_id: "user-1", path: "letters", name: "letters" }], deleted: [], full: true, has_more: false, watermark: "files-w1" } });
if (url.pathname.endsWith("/versions/version-attachments")) return route.fulfill({ json: version() });
if (url.pathname.endsWith("/archive-encryption-policy")) return route.fulfill({ json: {
available: true, allowed_password_encryption_methods: ["aes"], allowed_password_delivery_channels: ["separate_mail"],
policy_hash: "fixture", source_path: [], diagnostics: [], reason: "Legacy ZipCrypto is blocked by policy.", legacy_label: "Legacy ZipCrypto"
} });
return route.fulfill({ json: {} });
}
if (request.method() === "POST" && url.pathname.endsWith("/autosave")) {
const body = request.postDataJSON(); writes.push(body);
raw = body.campaign_json; revision++;
return route.fulfill({ json: version() });
}
return route.abort();
});
await page.goto("/?campaign-attachments");
await expect(page.locator("#campaign-attachment-sources .chooser-display-input")).toBeEnabled();
return { writes, errors, zip };
}
test("actual Files chooser opens repeatedly from attachment path by click and keyboard", async ({ page }) => {
const fixture = await install(page);
const input = page.locator("#campaign-attachment-sources .chooser-display-input");
for (const action of ["click", "Enter", "Space", "click"]) {
if (action === "click") await input.click();
else { await input.focus(); await page.keyboard.press(action); }
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByRole("dialog").getByRole("button", { name: /Use.*folder|Select.*folder/i })).toBeEnabled();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
}
expect(fixture.writes).toHaveLength(0);
expect(fixture.errors).toEqual([]);
});
test("temporarily unavailable Files capability does not silently turn managed paths into text edits", async ({ page }) => {
const fixture = await install(page);
await page.getByRole("button", { name: "Toggle Files capability" }).click();
await expect(page.locator("#campaign-attachment-sources .chooser-display-input")).toBeDisabled();
await expect(page.getByText(/The Files browser is currently unavailable/).first()).toBeVisible();
await page.getByRole("button", { name: "Toggle Files capability" }).click();
await page.locator("#campaign-attachment-sources .chooser-display-input").click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape");
expect(fixture.writes).toHaveLength(0);
expect(fixture.errors).toEqual([]);
});
test("attachment source corrections save without changing or reauthorizing legacy ZIP configuration", async ({ page }) => {
const fixture = await install(page);
await page.locator('#campaign-attachment-sources input[placeholder="Campaign files"]').fill("Updated source name");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].campaign_json.attachments.zip).toEqual(fixture.zip);
expect(fixture.writes[0].campaign_json.attachments.base_paths[0].name).toBe("Updated source name");
await page.reload();
await expect(page.locator('#campaign-attachment-sources input[placeholder="Campaign files"]')).toHaveValue("Updated source name");
expect(fixture.errors).toEqual([]);
});
@@ -0,0 +1,103 @@
import { expect, test, type Page } from "@playwright/test";
async function setup(page: Page, options: { firstFailure?: boolean; delay?: Promise<void> } = {}) {
const writes: Record<string, unknown>[] = [];
await page.route("**/api/v1/conformance/review-state", async (route) => {
writes.push(route.request().postDataJSON());
if (options.delay) await options.delay;
if (options.firstFailure && writes.length === 1) {
await route.fulfill({ status: 409, json: { detail: "Synthetic review revision conflict" } }); return;
}
await route.fulfill({ json: { saved: true } });
});
return writes;
}
async function openAttachmentGroup(page: Page, query = "") {
page.on("pageerror", (error) => { throw error; });
await page.goto(`/?campaign-bulk-review${query}`);
await page.getByRole("button", { name: "Open grouped review" }).click();
if (await page.getByRole("combobox").isEnabled()) await page.getByRole("combobox").selectOption("same-attachment-condition");
return page.getByRole("dialog");
}
test("grouped review accepts only exact selected eligible IDs with shared required reason", async ({ page }) => {
const writes = await setup(page);
const dialog = await openAttachmentGroup(page);
await expect(dialog.getByRole("checkbox")).toHaveCount(3);
await expect(dialog.getByRole("button", { name: "Accept 3 selected messages" })).toBeDisabled();
await dialog.getByRole("checkbox", { name: "job-001@example.test" }).focus();
await page.keyboard.press("Space");
await dialog.getByRole("textbox").fill("The optional file is intentionally omitted for these recipients.");
await dialog.getByRole("button", { name: "Accept 2 selected messages" }).click();
await expect(dialog).not.toBeVisible();
expect(writes).toEqual([{ buildToken: "build-one", categoryKey: "same-attachment-condition", jobIds: ["job-000", "job-002"], reason: "The optional file is intentionally omitted for these recipients." }]);
});
test("failed grouped save retains reason and selection, and retries explicitly", async ({ page }) => {
const writes = await setup(page, { firstFailure: true });
const dialog = await openAttachmentGroup(page);
await dialog.getByRole("textbox").fill("Accepted after inspecting frozen evidence.");
await dialog.getByRole("checkbox", { name: "job-001@example.test" }).focus();
await page.keyboard.press("Space");
await dialog.getByRole("button", { name: "Accept 2 selected messages" }).click();
await expect(dialog.getByRole("alert")).toContainText("Synthetic review revision conflict");
await expect(dialog.getByRole("textbox")).toHaveValue("Accepted after inspecting frozen evidence.");
await expect(dialog.getByRole("checkbox", { name: "job-001@example.test" })).not.toBeChecked();
expect(writes).toHaveLength(1);
await dialog.getByRole("button", { name: "Accept 2 selected messages" }).click();
await expect(dialog).not.toBeVisible();
expect(writes).toHaveLength(2);
expect(writes[1]).toEqual(writes[0]);
});
test("grouped save blocks double clicks and dismissal until acknowledged", async ({ page }) => {
let acknowledge!: () => void;
const writes = await setup(page, { delay: new Promise<void>((resolve) => { acknowledge = resolve; }) });
const dialog = await openAttachmentGroup(page);
await dialog.getByRole("textbox").fill("Shared operational exception.");
const accept = dialog.getByRole("button", { name: "Accept 3 selected messages" });
await accept.evaluate((button: HTMLButtonElement) => { button.click(); button.click(); });
await expect.poll(() => writes.length).toBe(1);
await expect(dialog.getByRole("button", { name: /^(Cancel|Abbrechen)$/ })).toBeDisabled();
await page.keyboard.press("Escape");
await expect(dialog).toBeVisible();
acknowledge();
await expect(dialog).not.toBeVisible();
});
test("grouped review is bounded and clearly counts the remaining matching messages", async ({ page }) => {
const writes = await setup(page);
const dialog = await openAttachmentGroup(page, "&large");
await expect(dialog.getByRole("checkbox")).toHaveCount(200);
await expect(dialog).toContainText("Showing 200 of 205 matching unreviewed messages in this category; 200 selected.");
await dialog.getByRole("textbox").fill("Confirmed shared exception.");
await dialog.getByRole("button", { name: "Accept 200 selected messages" }).click();
await expect(dialog).not.toBeVisible();
expect(writes[0].jobIds).toHaveLength(200);
});
test("a replaced build or missing permission cannot be bulk accepted", async ({ page }) => {
const writes = await setup(page);
let dialog = await openAttachmentGroup(page);
await dialog.getByRole("textbox").fill("Do not save stale build evidence.");
await page.getByTestId("replace-build").evaluate((button: HTMLButtonElement) => button.click());
await expect(dialog.getByRole("alert")).toContainText("The build changed");
await expect(dialog.getByRole("button", { name: "Accept 3 selected messages" })).toBeDisabled();
dialog = await openAttachmentGroup(page, "&read-only");
await expect(dialog.getByRole("button", { name: /Accept .* selected messages/ })).toBeDisabled();
expect(writes).toHaveLength(0);
});
test("German bulk review labels and category changes preserve explicit scope", async ({ page }) => {
const writes = await setup(page);
const dialog = await openAttachmentGroup(page, "&language=de");
await expect(dialog).toHaveAccessibleName("Gleichartige Prüfbedingungen bestätigen");
await expect(dialog.getByRole("button", { name: "3 ausgewählte Nachrichten bestätigen" })).toBeDisabled();
await dialog.getByRole("combobox").selectOption("other-condition");
await expect(dialog.getByRole("checkbox")).toHaveCount(1);
await expect(dialog.getByRole("textbox", { name: "Gemeinsamer Prüfvermerk" })).toHaveValue("");
await dialog.getByRole("button", { name: "1 ausgewählte Nachrichten bestätigen" }).click();
await expect(dialog).not.toBeVisible();
expect(writes[0]).toMatchObject({ categoryKey: "other-condition", jobIds: ["other-category"], reason: "" });
});
@@ -0,0 +1,107 @@
import { expect, test, type Page } from "@playwright/test";
async function fixture(page: Page, options: { language?: string; scope?: string; readonly?: boolean; ceiling?: number; reject?: number; hold?: boolean } = {}) {
let value: number | null = null;
let revision = 1;
let reject = options.reject;
let release: (() => void) | undefined;
let rejectRead = false;
const writes: any[] = [];
const errors: string[] = [];
const inherited = options.ceiling ?? 25;
const maximum = options.ceiling ?? 500;
page.on("pageerror", (cause) => errors.push(cause.message));
const state = () => ({ scope: options.scope ?? "system", synchronous_send_max_recipients: value,
revision: String(revision).padStart(64, "0"), max_configurable_recipients: maximum,
effective_max_recipients: value ?? inherited, inherited_max_recipients: inherited,
absolute_max_recipients: 500, deployment_ceiling_explicit: Boolean(options.ceiling), deployment_max_recipients: maximum });
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
if (!route.request().url().includes("/campaigns/settings/delivery-policy/")) return route.fulfill({ status: 404, json: { detail: "Not found" } });
if (route.request().method() === "GET") return route.fulfill(rejectRead ? { status: 503, json: { detail: "Fixture reload unavailable" } } : { json: state() });
expect(route.request().method()).toBe("PUT");
const body = route.request().postDataJSON(); writes.push(body);
if (options.hold) await new Promise<void>((resolve) => { release = resolve; });
if (reject) {
const status = reject; reject = undefined;
return route.fulfill({ status, json: { detail: "Fixture policy save rejected; draft was not saved" } });
}
value = body.synchronous_send_max_recipients; revision += 1;
return route.fulfill({ json: state() });
});
await page.goto(`/?campaign-delivery-policy&language=${options.language ?? "en"}&scope=${options.scope ?? "system"}${options.readonly ? "&readonly" : ""}`);
await expect(page.getByRole("spinbutton")).toHaveValue(String(inherited));
return { writes, errors, release: () => release?.(), failRead: () => { rejectRead = true; } };
}
for (const language of ["en", "de"]) {
test(`administrator deliberately raises implicit 25 to 200 and reloads saved policy in ${language}`, async ({ page }) => {
const f = await fixture(page, { language });
const save = page.getByRole("button", { name: language === "de" ? "Speichern" : "Save", exact: true });
await expect(save).toBeDisabled();
await page.getByRole("checkbox").press("Space");
await page.getByRole("spinbutton").fill("200");
await save.click();
await expect(save).toBeDisabled();
expect(f.writes).toEqual([{ synchronous_send_max_recipients: 200, expected_revision: "1".padStart(64, "0") }]);
await page.getByRole("button", { name: language === "de" ? "Gespeicherte Versandrichtlinie neu laden" : "Reload saved delivery policy", exact: true }).click();
await expect(page.getByRole("spinbutton")).toHaveValue("200");
await expect(page.getByRole("checkbox")).not.toBeChecked();
expect(f.errors).toEqual([]);
});
}
for (const status of [422, 409]) {
test(`policy save ${status} retains draft for an explicit retry`, async ({ page }) => {
const f = await fixture(page, { reject: status });
await page.getByRole("checkbox").press("Space");
await page.getByRole("spinbutton").fill("200");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByRole("alert").filter({ hasText: "Fixture policy save rejected" })).toBeVisible();
await expect(page.getByRole("spinbutton")).toHaveValue("200");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
expect(f.writes).toHaveLength(2);
expect(f.writes[1].synchronous_send_max_recipients).toBe(200);
});
}
test("explicit ceiling prevents invalid save and clearing override submits inheritance", async ({ page }) => {
const f = await fixture(page, { scope: "tenant", ceiling: 40 });
await page.getByRole("checkbox").press("Space");
await page.getByRole("spinbutton").fill("41");
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
expect(f.writes).toHaveLength(0);
await page.getByRole("spinbutton").fill("0");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
await page.getByRole("checkbox").press("Space");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
expect(f.writes.map((body) => body.synchronous_send_max_recipients)).toEqual([0, null]);
});
test("read-only policy cannot be edited or saved", async ({ page }) => {
const f = await fixture(page, { readonly: true });
await expect(page.getByRole("checkbox")).toBeDisabled();
await expect(page.getByRole("spinbutton")).toBeDisabled();
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
expect(f.writes).toHaveLength(0);
});
test("pending policy save disables edits and reload; failed refresh preserves acknowledged value", async ({ page }) => {
const f = await fixture(page, { hold: true });
await page.getByRole("checkbox").press("Space");
await page.getByRole("spinbutton").fill("200");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect.poll(() => f.writes.length).toBe(1);
await expect(page.getByRole("spinbutton")).toBeDisabled();
await expect(page.getByRole("button", { name: "Reload saved delivery policy", exact: true })).toBeDisabled();
f.release();
await expect(page.getByRole("spinbutton")).toBeEnabled();
f.failRead();
await page.getByRole("button", { name: "Reload saved delivery policy", exact: true }).click();
await expect(page.getByRole("alert").filter({ hasText: "Fixture reload unavailable" })).toBeVisible();
await expect(page.getByRole("spinbutton")).toHaveValue("200");
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
expect(f.writes).toHaveLength(1);
});
@@ -0,0 +1,187 @@
import { expect, test, type Page } from "@playwright/test";
async function install(page: Page, options: {
kind?: "smtp" | "imap"; language?: "en" | "de"; holdWrite?: Promise<void>; interrupt?: boolean; failRefresh?: boolean;
diagnostics?: boolean; authoring?: boolean; holdPreview?: Promise<void>;
} = {}) {
const kind = options.kind ?? "smtp";
const errors: string[] = []; page.on("pageerror", error => { errors.push(error.message); console.error("Delivery fixture:", error.message); });
const writes: { path: string; payload: Record<string, any> }[] = [];
const wholeReads: string[] = [];
let progressReads = 0; let previews = 0; let acknowledged = false; let active = false; let validated = false;
const jobs = Array.from({ length: 4 }, (_value, index) => ({ id: `delivery-job-${index + 1}`, entry_index: index + 1, entry_id: `entry-${index + 1}`,
recipient_email: `recipient-${index + 1}@example.test`, subject: `Delivery message ${index + 1}`, build_status: "built", validation_status: "ready", queue_status: "draft",
send_status: kind === "imap" ? "smtp_accepted" : "not_queued", imap_status: options.diagnostics ? ["pending", "appending", "failed", "outcome_unknown"][index] : kind === "imap" ? "pending" : "not_requested",
resolved_recipients: { to: [{ email: `recipient-${index + 1}@example.test` }] }, issues: [], attachments: [], review_decision: { eligible: false }, attempt_count: kind === "imap" ? 1 : 0
}));
const version = () => ({ id: "delivery-version", campaign_id: "delivery-campaign", version_number: 1, edit_revision: 1, strong_etag: '"delivery-version:1"',
review_build_token: "delivery-build", locked_at: options.authoring && !validated ? null : "2026-09-07T10:00:00Z", workflow_state: options.authoring && !validated ? "draft" : "built",
validation_summary: options.authoring && !validated ? null : { ok: true, warning_count: 0, error_count: 0, issues: [] },
build_summary: options.authoring ? null : { built_count: 4, blocked_count: 0 }, execution_snapshot_hash: "frozen-snapshot",
raw_json: { campaign: { name: "Delivery fixture" }, server: { mail_profile_id: "profile-fixture" },
delivery: { rate_limit: { messages_per_minute: 30 }, imap_append_sent: { enabled: kind === "imap", folder: "Sent" } },
entries: { inline: jobs.map(job => ({ id: job.entry_id, email: job.recipient_email, active: true })) } },
editor_state: { review_send: { review_build_token: "delivery-build", inspection_complete: true, reviewed_message_keys: [], issue_decisions: [] } }
});
const baseJobs = (url?: URL) => {
const states = url?.searchParams.getAll("imap_status") ?? [];
const selected = states.length ? jobs.filter(job => states.includes(job.imap_status)) : jobs;
return { jobs: selected, page: 1, page_size: 200, total: selected.length, total_unfiltered: 4, pages: 1, counts: {}, filtered_counts: {},
review: { blocking_count: 0, required_count: 0, bulk_acceptable_count: 0, reviewed_required_count: 0, inspection_complete: true } };
};
await page.route(url => url.pathname.startsWith("/api/"), async route => {
const request = route.request(); const url = new URL(request.url());
if (request.method() === "GET") {
if (/\/(workspace\/delta|summary|jobs|jobs\/delta)$/.test(url.pathname)) wholeReads.push(url.pathname);
if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: {
campaign: { id: "delivery-campaign", name: "Delivery fixture", current_version_id: "delivery-version", status: "draft" },
versions: [version()], current_version: version(), summary: { cards: { jobs_total: 4, sent: kind === "imap" ? 4 : 0, failed: 0 },
delivery: { background_workers_enabled: false }, status_counts: { send: kind === "imap" ? { smtp_accepted: 4 } : { not_queued: 4 },
imap: kind === "imap" ? options.diagnostics ? { pending: 1, appending: 1, failed: 1, outcome_unknown: 1 } : { pending: 4 } : {} }, attachments: {} },
deleted: [], full: true, has_more: false, watermark: "delivery-workspace"
} });
if (url.pathname.endsWith("/delivery-options")) return route.fulfill({ json: { worker_queue_available: false,
synchronous_send: { allowed: true, eligible_recipient_job_count: 4, policy: { max_recipient_jobs: 200 } }, approval_gate: { configured: false, available: false } } });
if (url.pathname.endsWith("/delivery-progress")) {
progressReads++;
if (acknowledged && options.failRefresh) return route.fulfill({ status: 503, json: { detail: "Fixture progress unavailable" } });
return route.fulfill({ json: { campaign_id: "delivery-campaign", version_id: "delivery-version", generated_at: "2026-09-07T10:00:00Z", total_jobs: 4,
smtp: { total: 4, processed: acknowledged ? 4 : 1, accepted: acknowledged || kind === "imap" ? 4 : 1, active: active && kind === "smtp" ? 1 : 0, pending: acknowledged ? 0 : active ? 2 : 3, failed: 0, outcome_unknown: 0, excluded: 0, paused: 0, cancelled: 0 },
imap: { total: 4, processed: acknowledged ? 4 : 1, appended: acknowledged ? 4 : 1, active: active && kind === "imap" ? 1 : 0, pending: acknowledged ? 0 : active ? 2 : 3, failed: 0, outcome_unknown: 0, excluded: 0 },
status_counts: { send: {}, queue: {}, imap: {} }
} });
}
if (url.pathname.endsWith("/jobs/delta")) {
if (acknowledged && options.failRefresh) return route.fulfill({ status: 503, json: { detail: "Fixture diagnostics unavailable" } });
return route.fulfill({ json: { ...baseJobs(url), full: true, has_more: false, deleted: [], watermark: "diagnostic-watermark" } });
}
if (url.pathname.endsWith("/jobs")) {
if (acknowledged && options.failRefresh) return route.fulfill({ status: 503, json: { detail: "Fixture diagnostics unavailable" } });
return route.fulfill({ json: baseJobs(url) });
}
return route.fulfill({ json: {} });
}
if (url.pathname.endsWith("/attachments/preview")) {
previews++;
if (previews === 1 && options.holdPreview) await options.holdPreview;
const unlinked = options.authoring && previews >= 2 && !validated;
const file = { id: "newly-unlinked-file", filename: "letter.pdf", display_path: "letters/letter.pdf", linked_to_campaign: false };
return route.fulfill({ json: { campaign_id: "delivery-campaign", version_id: "delivery-version", shared_file_count: 0,
rules: [], linkable_files: unlinked ? [file] : [], unused_shared_files: [] } });
}
const payload = request.postDataJSON(); writes.push({ path: url.pathname, payload });
if (url.pathname.endsWith("/validate")) { validated = true; return route.fulfill({ json: { ok: true, issues: [] } }); }
if (url.pathname.endsWith("/send-now") || url.pathname.endsWith("/append-sent")) {
active = true;
if (options.holdWrite) await options.holdWrite;
active = false;
if (options.interrupt) return route.fulfill({ status: 503, json: { detail: "Fixture request interrupted; verify stored outcomes." } });
acknowledged = true;
for (const job of jobs) { job.send_status = "smtp_accepted"; if (kind === "imap") job.imap_status = "appended"; }
return route.fulfill({ json: { result: { attempted_count: 4, sent_count: 4, failed_count: 0, outcome_unknown_count: 0, paused_count: 0,
pending_count: 4, appended_count: 4, processed_count: 4, results: [] } } });
}
return route.abort();
});
await page.goto(`/?campaign-delivery-progress&language=${options.language ?? "en"}`);
if (options.authoring) await expect.poll(() => previews).toBe(1);
else await expect(page.getByRole("table", { name: "campaign-delivery-campaign-workflow-built-messages", exact: true }).getByText("Delivery message 1", { exact: true })).toBeVisible();
return { writes, wholeReads, errors, get progressReads() { return progressReads; }, get previews() { return previews; } };
}
async function start(page: Page, kind: "smtp" | "imap") {
if (kind === "smtp") {
await page.getByRole("button", { name: /^(Send now|Jetzt senden)$/ }).click();
await page.getByRole("alertdialog").getByRole("button", { name: /^(Send now|Jetzt senden)$/ }).click();
} else await page.getByRole("button", { name: /^(Append pending IMAP now|Ausstehende IMAP-Kopien jetzt anhängen)$/ }).click();
}
for (const language of ["en", "de"] as const) for (const kind of ["smtp", "imap"] as const) test(`${language}: actual ${kind} request shows live active progress without refreshing the underlying workflow`, async ({ page }) => {
let release!: () => void; const held = new Promise<void>(resolve => { release = resolve; });
const fixture = await install(page, { kind, language, holdWrite: held });
await start(page, kind);
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect.poll(() => fixture.writes.length).toBe(1);
const wholeReads = fixture.wholeReads.length;
await expect.poll(() => fixture.progressReads).toBeGreaterThanOrEqual(2);
await expect(dialog.getByText(language === "de" ? "In Bearbeitung" : "In progress", { exact: true })).toBeVisible();
await expect(dialog.getByRole("status").first()).toContainText(/1.*4/);
expect(fixture.wholeReads.length).toBe(wholeReads);
await page.keyboard.press("Escape"); await expect(dialog).toBeVisible();
expect(fixture.writes).toHaveLength(1);
release();
await expect(dialog.getByRole("button", { name: /Close|Schließen/, exact: true }).last()).toBeEnabled();
expect(fixture.errors).toEqual([]);
});
for (const kind of ["smtp", "imap"] as const) test(`${kind}: failed display refresh preserves acknowledged success`, async ({ page }) => {
const fixture = await install(page, { kind, failRefresh: true });
await start(page, kind);
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("button", { name: /Close|Schließen/, exact: true }).last()).toBeEnabled();
await expect(dialog.getByText(/The request finished|Die Anfrage ist abgeschlossen/i).first()).toBeVisible();
await dialog.getByRole("button", { name: /Close|Schließen/, exact: true }).last().click();
await expect(page.getByText(kind === "smtp" ? /Send finished\. SMTP accepted 4/ : /IMAP append processed 4 job/)).toBeVisible();
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
});
test("an interrupted send remains uncertain and is never replayed by progress polling", async ({ page }) => {
const fixture = await install(page, { interrupt: true });
await start(page, "smtp");
const dialog = page.getByRole("dialog");
await expect(dialog.getByText("Fixture request interrupted; verify stored outcomes.", { exact: false })).toBeVisible();
await expect.poll(() => fixture.progressReads).toBeGreaterThanOrEqual(2);
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
});
test("IMAP diagnostics use shared SMTP and IMAP list filters", async ({ page }) => {
const fixture = await install(page, { kind: "imap", diagnostics: true });
await page.getByRole("button", { name: /^(Load IMAP diagnostics|IMAP-Diagnose laden)$/ }).click();
const table = page.getByRole("table", { name: "campaign-delivery-campaign-workflow-imap-diagnostics", exact: true });
await expect(table.getByText("recipient-1@example.test", { exact: true })).toBeVisible();
const filterButtons = table.getByRole("button", { name: /^Filter (SMTP|IMAP)$/ });
await expect(filterButtons).toHaveCount(2);
await filterButtons.last().click();
await expect(page.getByRole("checkbox", { name: /Pending|Ausstehend/, exact: true })).toBeVisible();
await expect(page.getByRole("checkbox", { name: /Outcome uncertain|Ergebnis ungewiss/, exact: true })).toBeVisible();
await page.getByRole("button", { name: /^(Deselect all|Alle abwählen)$/ }).click();
await page.getByRole("checkbox", { name: "Pending", exact: true }).check();
await page.keyboard.press("Escape");
await expect(table.getByText("recipient-1@example.test", { exact: true })).toBeVisible();
await expect(table.getByText("recipient-2@example.test", { exact: true })).toHaveCount(0);
await table.getByRole("button", { name: "Filter SMTP", exact: true }).click();
await expect(page.getByRole("checkbox", { name: "SMTP accepted", exact: true })).toBeVisible();
expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
});
test("acknowledged IMAP append refreshes pending diagnostics immediately", async ({ page }) => {
const fixture = await install(page, { kind: "imap" });
await page.getByRole("button", { name: "Load IMAP diagnostics", exact: true }).click();
const table = page.getByRole("table", { name: "campaign-delivery-campaign-workflow-imap-diagnostics", exact: true });
await expect(table.getByText("recipient-1@example.test", { exact: true })).toBeVisible();
await start(page, "imap");
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("button", { name: "Close", exact: true }).last()).toBeEnabled();
await dialog.getByRole("button", { name: "Close", exact: true }).last().click();
await expect(table).toHaveCount(0);
await expect(page.getByText(/IMAP append processed 4 job/)).toBeVisible();
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
});
test("lock waits for attachment preview and rechecks fresh unlinked matches before validation", async ({ page }) => {
let release!: () => void; const held = new Promise<void>(resolve => { release = resolve; });
const fixture = await install(page, { authoring: true, holdPreview: held });
const lock = page.getByRole("button", { name: /^(Lock and validate|Sperren und validieren)$/ });
await expect(lock).toBeDisabled(); expect(fixture.writes).toHaveLength(0);
release(); await expect(lock).toBeEnabled();
await lock.click();
await expect.poll(() => fixture.previews).toBeGreaterThanOrEqual(2);
await expect(page.getByRole("alertdialog")).toBeVisible();
expect(fixture.writes).toHaveLength(0);
await page.getByRole("alertdialog").getByRole("button", { name: /^(Link and lock|Verknüpfen und sperren)$/ }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].path).toMatch(/\/validate$/);
expect(fixture.writes[0].payload.link_unshared_matches).toBe(true);
expect(fixture.errors).toEqual([]);
});
@@ -0,0 +1,196 @@
import { expect, test, type Page } from "@playwright/test";
const zip = { enabled: true, archives: [{
id: "legacy-zip", name: "Existing recipient archive", method: "zip_standard",
password_enabled: true, password_delivery_channel: "separate_mail"
}] };
const defaultCredential = {
id: "credential-default", name: "Default account", is_active: true, is_default: true,
public_data: { username: "sender@example.test" }
};
const alternateCredential = {
id: "credential-alternate", name: "Explicit alternate account", is_active: true, is_default: false,
public_data: { username: "alternate@example.test" }
};
const inactiveCredential = {
id: "credential-inactive", name: "Inactive account", is_active: false, is_default: false,
public_data: { username: "inactive@example.test" }
};
const profile = {
id: "profile-1", name: "Campaign delivery", scope_type: "tenant", scope_id: "tenant-1", is_active: true,
servers: [{
id: "smtp-1", name: "Active SMTP server", protocol: "smtp", is_active: true, is_default: true,
config: { host: "smtp.example.test", port: 587, security: "starttls" },
credentials: [defaultCredential, alternateCredential, inactiveCredential]
}, {
id: "smtp-inactive", name: "Inactive SMTP server", protocol: "smtp", is_active: false, is_default: false,
config: { host: "inactive.example.test", port: 587, security: "starttls" },
credentials: [defaultCredential]
}]
};
async function install(page: Page, options: {
language?: "en" | "de";
selectedServer?: Record<string, string>;
excludeProfile?: boolean;
requireCredential?: boolean;
} = {}) {
const errors: string[] = [];
page.on("pageerror", (error) => { errors.push(error.message); });
let revision = 1;
let migrationRequired = true;
let raw: Record<string, unknown> = {
version: "1.0", campaign: { id: "campaign-mail", name: "Mail repair fixture", mode: "send" },
server: options.selectedServer ?? { mail_profile_id: "profile-1", smtp_server_id: "smtp-1" },
template: { subject: "Fixture subject", text: "Fixture body" },
recipients: { from: [{ email: "sender@example.test" }] }, entries: { inline: [] },
attachments: { zip }, delivery: { imap_append_sent: { enabled: false, folder: "auto" } }
};
const writes: Record<string, any>[] = [];
const reads: URL[] = [];
const unexpectedWrites: string[] = [];
const version = () => ({
id: "version-mail", campaign_id: "campaign-mail", version_number: 2,
schema_version: "1.0", edit_revision: revision, strong_etag: `"version-mail:${revision}"`,
current_flow: "manual", current_step: "mail-settings", workflow_state: "editing",
is_complete: false, editor_state: { created_from: "minimal_campaign" },
user_lock_state: null, locked_at: null, published_at: null,
created_at: "2026-09-07T10:00:00Z", updated_at: "2026-09-07T10:00:00Z",
raw_json: raw, mail_profile_migration_required: migrationRequired
});
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request(); const url = new URL(request.url());
if (request.method() === "GET") {
reads.push(url);
if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: {
campaign: { id: "campaign-mail", name: "Mail repair fixture", current_version_id: "version-mail", status: "draft" },
versions: [version()], current_version: version(), summary: null, deleted: [],
watermark: `mail-watermark-${revision}`, has_more: false, full: true
} });
if (url.pathname === "/api/v1/mail/profiles") return route.fulfill({ json: {
profiles: options.excludeProfile ? [] : [profile]
} });
if (url.pathname.endsWith("/versions/version-mail")) return route.fulfill({ json: version() });
return route.fulfill({ json: {} });
}
if (request.method() === "POST" && url.pathname.endsWith("/versions/version-mail/autosave")) {
const body = request.postDataJSON(); writes.push(body);
if (options.requireCredential && !body.campaign_json.server.smtp_credential_id) {
return route.fulfill({ status: 422, json: {
detail: "Campaign delivery cannot use the selected profile because the effective SMTP credential policy requires an explicit credential selection for this campaign."
} });
}
if (body.base_revision !== revision) return route.fulfill({ status: 409, json: { detail: {
code: "revision_conflict", resource: { type: "campaign_version", id: "version-mail" },
current_revision: revision, submitted_base_revision: body.base_revision
} } });
raw = body.campaign_json;
revision += 1;
if (body.migrate_legacy_mail_settings) migrationRequired = false;
return route.fulfill({ json: version() });
}
unexpectedWrites.push(`${request.method()} ${url.pathname}`);
return route.abort();
});
await page.goto(`/?campaign-mail-settings&language=${options.language ?? "en"}`);
await expect(page.getByRole("combobox", { name: "SMTP credential", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: /Reload profiles|Profile neu laden/ })).toBeEnabled();
return { writes, reads, errors, unexpectedWrites, raw: () => raw, revision: () => revision };
}
for (const language of ["en", "de"] as const) {
test(`real Mail settings never display inherited defaults as explicit saved credentials in ${language}`, async ({ page }) => {
const fixture = await install(page, { language, requireCredential: true });
const credential = page.getByRole("combobox", { name: "SMTP credential", exact: true });
await expect(credential).toHaveValue("");
await expect(credential.locator("option:checked")).toContainText(language === "en" ? "Use profile credentials" : "Profil-Zugangsdaten verwenden");
await expect(credential.locator('option[value="credential-default"]')).toHaveCount(1);
expect(fixture.writes).toHaveLength(0);
await credential.selectOption("credential-default");
await page.getByRole("button", { name: language === "en" ? "Migrate to selected Mail profile" : "Auf ausgewähltes Mail-Profil umstellen", exact: true }).click();
await expect.poll(() => fixture.revision()).toBe(2);
await expect(page.getByRole("button", { name: language === "en" ? "Migrate to selected Mail profile" : "Auf ausgewähltes Mail-Profil umstellen", exact: true })).toHaveCount(0);
expect(fixture.writes).toHaveLength(1);
expect(fixture.writes[0].migrate_legacy_mail_settings).toBe(true);
expect(fixture.writes[0].campaign_json.server).toEqual({
mail_profile_id: "profile-1", smtp_server_id: "smtp-1", smtp_credential_id: "credential-default"
});
expect(fixture.writes[0].campaign_json.attachments.zip).toEqual(zip);
expect(fixture.writes[0].base_revision).toBe(1);
expect(fixture.unexpectedWrites).toEqual([]);
expect(fixture.errors).toEqual([]);
await page.reload();
await expect(credential).toHaveValue("credential-default");
expect(fixture.writes).toHaveLength(1);
});
}
test("explicit-credential 422 preserves the real Mail draft and can be corrected then saved", async ({ page }) => {
const fixture = await install(page, { requireCredential: true });
await page.getByRole("button", { name: "Migrate to selected Mail profile", exact: true }).click();
await expect(page.getByText(/effective SMTP credential policy requires an explicit credential selection/)).toBeVisible();
expect(fixture.revision()).toBe(1);
expect(fixture.writes).toHaveLength(1);
await expect(page.getByRole("combobox", { name: "SMTP server", exact: true })).toHaveValue("smtp-1");
await expect(page.getByRole("combobox", { name: "SMTP credential", exact: true })).toHaveValue("");
await expect(page.getByRole("button", { name: "Migrate to selected Mail profile", exact: true })).toBeEnabled();
await page.getByRole("combobox", { name: "SMTP credential", exact: true }).selectOption("credential-alternate");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect.poll(() => fixture.revision()).toBe(2);
await expect(page.getByRole("button", { name: "Migrate to selected Mail profile", exact: true })).toHaveCount(0);
expect(fixture.writes).toHaveLength(2);
expect(fixture.writes[0].campaign_json.server.smtp_credential_id).toBeUndefined();
expect(fixture.writes[1].campaign_json.server.smtp_credential_id).toBe("credential-alternate");
expect(fixture.writes[1].campaign_json.server.smtp_server_id).toBe("smtp-1");
expect(fixture.writes[1].campaign_json.attachments.zip).toEqual(zip);
expect(fixture.errors).toEqual([]);
expect(fixture.unexpectedWrites).toEqual([]);
});
for (const credentialId of ["credential-missing", "credential-inactive"]) {
test(`a stored ${credentialId} stays visibly unavailable until explicitly replaced`, async ({ page }) => {
const fixture = await install(page, { selectedServer: {
mail_profile_id: "profile-1", smtp_server_id: "smtp-1", smtp_credential_id: credentialId
} });
const credential = page.getByRole("combobox", { name: "SMTP credential", exact: true });
await expect(credential).toHaveValue(credentialId);
await expect(credential.locator("option:checked")).toHaveText("Selected credential is unavailable");
await expect(credential.locator('option[value="credential-inactive"]')).toHaveCount(credentialId === "credential-inactive" ? 1 : 0);
expect(fixture.writes).toHaveLength(0);
await credential.selectOption("credential-default");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect.poll(() => fixture.revision()).toBe(2);
expect(fixture.writes[0].campaign_json.server.smtp_credential_id).toBe("credential-default");
expect(fixture.errors).toEqual([]);
});
}
for (const serverId of ["smtp-missing", "smtp-inactive"]) {
test(`a stored ${serverId} cannot silently fall back to the default SMTP endpoint`, async ({ page }) => {
const fixture = await install(page, { selectedServer: {
mail_profile_id: "profile-1", smtp_server_id: serverId, smtp_credential_id: "credential-default"
} });
const server = page.getByRole("combobox", { name: "SMTP server", exact: true });
await expect(server).toHaveValue(serverId);
await expect(server.locator("option:checked")).toContainText("unavailable");
await expect(page.getByRole("combobox", { name: "SMTP credential", exact: true })).toBeDisabled();
expect(fixture.writes).toHaveLength(0);
await server.selectOption("smtp-1");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect.poll(() => fixture.revision()).toBe(2);
expect(fixture.writes[0].campaign_json.server).toEqual({
mail_profile_id: "profile-1", smtp_server_id: "smtp-1", smtp_credential_id: "credential-default"
});
expect(fixture.errors).toEqual([]);
});
}
test("an unavailable profile remains identified and cannot be silently migrated", async ({ page }) => {
const fixture = await install(page, { excludeProfile: true });
await expect(page.getByRole("combobox", { name: "Profile", exact: true })).toHaveValue("profile-1");
await expect(page.getByRole("button", { name: "Migrate to selected Mail profile", exact: true })).toBeDisabled();
await expect(page.getByRole("combobox", { name: "SMTP server", exact: true })).toBeDisabled();
expect(fixture.writes).toHaveLength(0);
expect(fixture.reads.filter((url) => url.pathname === "/api/v1/mail/profiles").every((url) => url.searchParams.get("campaign_id") === "campaign-mail")).toBe(true);
expect(fixture.unexpectedWrites).toEqual([]);
});
@@ -0,0 +1,119 @@
import { expect, test, type Page } from "@playwright/test";
async function install(page: Page, language = "en", rejectSave = false) {
let revision = 1;
let raw: any = null;
let rejected = false;
const writes: any[] = [];
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
const version = () => ({ id: "order-version", campaign_id: "order-campaign", version_number: 1,
edit_revision: revision, strong_etag: `"order-version:${revision}"`, editor_state: {},
workflow_state: "editing", current_flow: "manual", current_step: "recipients", is_complete: false,
updated_at: "2026-09-07T10:00:00Z", raw_json: raw });
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
if (route.request().method() === "POST" && route.request().url().endsWith("/autosave")) {
const body = route.request().postDataJSON(); writes.push(body);
if (rejectSave && !rejected) {
rejected = true;
return route.fulfill({ status: 422, json: { detail: "Fixture policy rejected this save" } });
}
raw = body.campaign_json; revision += 1;
return route.fulfill({ json: version() });
}
if (route.request().method() === "GET") return route.fulfill({ json: version() });
return route.abort();
});
await page.goto(`/?campaign-recipient-order&language=${language}`);
await expect(page.getByTestId("recipient-order")).toContainText("alpha@example.test");
return { writes, errors, raw: () => raw };
}
async function moveLastToFirst(page: Page) {
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Move address up", exact: true }).nth(2).click();
await dialog.getByRole("button", { name: "Move address up", exact: true }).nth(1).click();
await expect(dialog.locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
}
for (const language of ["en", "de"]) {
test(`individual recipient order survives dialog Save, reopen and actual campaign POST in ${language}`, async ({ page }) => {
const fixture = await install(page, language);
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
await moveLastToFirst(page);
await page.getByRole("dialog").getByRole("button", { name: language === "de" ? "Speichern" : "Save", exact: true }).click();
await expect(page.getByTestId("primary-address")).toHaveText("zulu@example.test");
await expect(page.getByTestId("recipient-dirty")).toHaveText("true");
expect(fixture.writes).toHaveLength(0); // Dialog Save applies the page draft, not a hidden network mutation.
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
await expect(page.getByRole("dialog").locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
await page.getByRole("dialog").getByRole("button", { name: language === "de" ? "Abbrechen" : "Cancel", exact: true }).click();
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
expect(fixture.writes[0].campaign_json.entries.inline[0].to.map((item: any) => item.email)).toEqual([
"zulu@example.test", "alpha@example.test", "beta@example.test"
]);
expect(fixture.raw().entries.inline[0].email).toBe("zulu@example.test");
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
await expect(page.getByRole("dialog").locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
expect(fixture.errors).toEqual([]);
});
}
test("global header addresses preserve their explicitly selected order", async ({ page }) => {
const fixture = await install(page);
await page.getByRole("button", { name: "Edit global addresses", exact: true }).click();
await moveLastToFirst(page);
await page.getByRole("dialog").getByRole("button", { name: "Save", exact: true }).click();
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
expect(fixture.raw().recipients.to.map((item: any) => item.email)).toEqual([
"zulu@example.test", "alpha@example.test", "beta@example.test"
]);
await page.getByRole("button", { name: "Edit global addresses", exact: true }).click();
await expect(page.getByRole("dialog").locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
});
test("paste deduplicates without alphabetically rearranging existing or newly added addresses", async ({ page }) => {
const fixture = await install(page);
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
await moveLastToFirst(page);
await page.getByRole("dialog").locator(".recipient-address-category").evaluate((section) => {
const clipboard = new DataTransfer();
clipboard.setData("text/plain", "Zulu <ZULU@example.test>; Omega <omega@example.test>; Charlie <charlie@example.test>");
section.dispatchEvent(new ClipboardEvent("paste", { clipboardData: clipboard, bubbles: true, cancelable: true }));
});
await expect(page.getByRole("dialog").locator('input[type="email"]')).toHaveCount(5);
await page.getByRole("dialog").getByRole("button", { name: "Save", exact: true }).click();
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
expect(fixture.raw().entries.inline[0].to.map((item: any) => item.email)).toEqual([
"zulu@example.test", "alpha@example.test", "beta@example.test", "omega@example.test", "charlie@example.test"
]);
});
test("failed campaign save retains the reordered dialog result for explicit retry", async ({ page }) => {
const fixture = await install(page, "en", true);
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
await moveLastToFirst(page);
await page.getByRole("dialog").getByRole("button", { name: "Save", exact: true }).click();
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
await expect(page.getByTestId("recipient-error")).toContainText("Fixture policy rejected");
await expect(page.getByTestId("recipient-dirty")).toHaveText("true");
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
await expect(page.getByRole("dialog").locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
expect(fixture.writes).toHaveLength(2);
expect(fixture.writes[1].campaign_json.entries.inline[0].to).toEqual(fixture.writes[0].campaign_json.entries.inline[0].to);
});
test("Cancel deliberately discards only the dialog's unconfirmed reorder", async ({ page }) => {
await install(page);
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
await moveLastToFirst(page);
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
await expect(page.getByTestId("primary-address")).toHaveText("alpha@example.test");
});
@@ -0,0 +1,270 @@
import { expect, test, type Page } from "@playwright/test";
async function install(page: Page, options: { language?: "en" | "de"; readOnly?: boolean; rejectFirst?: boolean; holdWrite?: Promise<void>; switchAccount?: boolean } = {}) {
const errors: string[] = [];
page.on("pageerror", error => errors.push(error.message));
const writes: { path: string; payload: Record<string, any> }[] = [];
let detailReads = 0;
let holdNextRead: Promise<void> | undefined;
let heldReads = 0;
const statuses = ["failed_temporary", "failed_permanent", "outcome_unknown", "sending", "sending", "queued", "smtp_accepted", "skipped"];
const jobs = statuses.map((status, index) => ({
id: `job-${index + 1}`, campaign_version_id: "report-version", entry_id: `entry-${index + 1}`, entry_index: index + 1,
recipient_email: `primary-${index + 1}@example.test`, subject: `Report message ${index + 1}`,
resolved_recipients: { to: [{ email: `primary-${index + 1}@example.test` }, { name: "Additional person", email: `additional-${index + 1}@example.test` }],
cc: [{ email: `copy-${index + 1}@example.test` }], bcc: [{ email: `blind-${index + 1}@example.test` }] },
build_status: "built", validation_status: status === "skipped" ? "excluded" : "ready", queue_status: status === "sending" ? "claimed" : "draft",
send_status: status, imap_status: status === "smtp_accepted" ? "appended" : index === 0 ? "pending" : index === 1 ? "appending" : index === 2 ? "outcome_unknown" : "not_requested", attempt_count: ["not_queued", "queued", "skipped"].includes(status) ? 0 : 1,
postbox_attempt_count: 0, print_attempt_count: 0,
attachments: [], recovery: { smtp: { eligible: index === 3, revision: `claim-revision-${index + 1}`, reason: index === 3 ? "recoverable" : "live_claim" }, imap: { eligible: false, revision: "imap-revision", reason: "not_active" } }
}));
const version = { id: "report-version", campaign_id: "report-campaign", version_number: 1, edit_revision: 1, strong_etag: '"report-version:1"',
workflow_state: "partially_completed", locked_at: "2026-09-07T10:00:00Z", raw_json: {}, editor_state: {} };
const summary = () => ({ cards: { jobs_total: jobs.length, sent: jobs.filter(job => job.send_status === "smtp_accepted").length, failed: jobs.filter(job => job.send_status.startsWith("failed")).length },
delivery: { background_workers_enabled: false, celery_enabled: false }, status_counts: {
send: Object.fromEntries([...new Set(jobs.map(job => job.send_status))].map(status => [status, jobs.filter(job => job.send_status === status).length])),
imap: Object.fromEntries([...new Set(jobs.map(job => job.imap_status))].map(status => [status, jobs.filter(job => job.imap_status === status).length]))
} });
await page.route(url => url.pathname.startsWith("/api/"), async route => {
const request = route.request(); const url = new URL(request.url());
if (request.method() === "GET") {
if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: {
campaign: { id: "report-campaign", name: "Report fixture", current_version_id: "report-version", status: "partially_completed" },
versions: [version], current_version: version, summary: summary(), deleted: [], full: true, has_more: false, watermark: "report-watermark"
} });
if (url.pathname.endsWith("/delivery-progress")) return route.fulfill({ json: {
campaign_id: "report-campaign", version_id: "report-version", generated_at: "2026-09-07T10:00:00Z", total_jobs: jobs.length,
smtp: { total: 7, processed: 4, accepted: summary().cards.sent, active: 2, pending: 1, failed: summary().cards.failed, outcome_unknown: 1, excluded: 1, paused: 0, cancelled: 0 },
imap: { total: 1, processed: 1, appended: 1, active: 0, pending: 0, failed: 0, outcome_unknown: 0, excluded: 7 }, status_counts: {}
} });
if (url.pathname.endsWith("/jobs")) {
const search = (url.searchParams.get("q") ?? url.searchParams.get("filter_recipient") ?? "").toLowerCase();
const matchesStatus = (column: "send" | "imap", status: string) => {
const filter = url.searchParams.get(`filter_${column}`);
return !filter?.startsWith("list:") || JSON.parse(filter.slice(5)).includes(status);
};
const selected = structuredClone(jobs.filter(job => (!search || JSON.stringify([job.resolved_recipients, job.recipient_email, job.entry_id, job.subject]).toLowerCase().includes(search)) &&
matchesStatus("send", job.send_status) && matchesStatus("imap", job.imap_status)));
const hold = holdNextRead;
if (hold) { holdNextRead = undefined; heldReads++; await hold; }
return route.fulfill({ json: { jobs: selected, page: 1, page_size: 50, total: selected.length, total_unfiltered: jobs.length, pages: 1, counts: { send: {}, imap: {} }, filtered_counts: {} } });
}
if (/\/jobs\/job-\d+$/.test(url.pathname)) {
detailReads++;
return route.fulfill({ json: { job: jobs.find(job => url.pathname.endsWith(job.id)), attempts: { smtp: [], imap: [] } } });
}
return route.fulfill({ json: {} });
}
const payload = request.postDataJSON(); writes.push({ path: url.pathname, payload });
if (options.holdWrite) await options.holdWrite;
if (options.rejectFirst && writes.length === 1) return route.fulfill({ status: 409, json: { detail: "Fixture recovery changed. Reload evidence before retrying." } });
if (/\/jobs\/(retry|send-unattempted)$/.test(url.pathname)) {
expect(payload.run_inline).toBe(true); expect(payload.enqueue_celery).toBe(false);
expect(payload.version_id).toBe("report-version");
for (const id of payload.job_ids) {
const job = jobs.find(row => row.id === id)!;
expect(["failed_temporary", "failed_permanent", "not_queued", "queued"]).toContain(job.send_status);
job.send_status = "smtp_accepted";
}
return route.fulfill({ json: { result: { selected_count: payload.job_ids.length, attempted_count: payload.job_ids.length,
sent_count: payload.job_ids.length, failed_count: 0, outcome_unknown_count: 0, remaining_count: 0, run_inline: true } } });
}
const job = jobs.find(row => url.pathname.includes(`/jobs/${row.id}/`));
if (url.pathname.endsWith("/recover-claim") && job) {
expect(payload.expected_revision).toBe(job.recovery.smtp.revision); expect(payload.note.trim()).not.toBe("");
job.send_status = "outcome_unknown"; job.recovery.smtp.eligible = false;
return route.fulfill({ json: { result: { job_id: job.id } } });
}
if (url.pathname.endsWith("/resolve-outcome") && job) {
expect(payload.note.trim()).not.toBe("");
job.send_status = payload.decision === "smtp_accepted" ? "smtp_accepted" : "failed_temporary";
return route.fulfill({ json: { result: { job_id: job.id } } });
}
return route.abort();
});
await page.goto(`/?campaign-report&language=${options.language ?? "en"}${options.readOnly ? "&read-only" : ""}${options.switchAccount ? "&switch-account" : ""}`);
const table = page.getByRole("table", { name: "campaign-report-jobs-v2-report-campaign", exact: true });
await expect(table.getByText("Report message 1", { exact: true })).toBeVisible();
return { table, jobs, writes, errors, holdNextJobsRead(hold: Promise<void>) { holdNextRead = hold; }, get heldReads() { return heldReads; }, get detailReads() { return detailReads; } };
}
for (const language of ["en", "de"] as const) test(`${language}: Report shows every To/Cc/Bcc recipient and searches without per-row details`, async ({ page }) => {
const fixture = await install(page, { language });
await expect(fixture.table.getByText("Additional person <additional-1@example.test>", { exact: false })).toBeVisible();
await expect(fixture.table.getByText("copy-1@example.test", { exact: true })).toBeVisible();
await expect(fixture.table.getByText("blind-1@example.test", { exact: true })).toBeVisible();
await expect(fixture.table.getByText(language === "de" ? "An:" : "To:", { exact: true }).first()).toBeVisible();
expect(fixture.detailReads).toBe(0);
await page.getByRole("textbox").first().fill("blind-2@example.test");
await expect(fixture.table.getByText("Report message 2", { exact: true })).toBeVisible();
await expect(fixture.table.getByText("Report message 1", { exact: true })).toHaveCount(0);
expect(fixture.detailReads).toBe(0); expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
});
test("workerless Report retry executes one canonical request and displays real acceptance", async ({ page }) => {
const fixture = await install(page);
await expect(page.getByRole("button", { name: "Queue temporary failures for workers", exact: true })).toBeDisabled();
await fixture.table.getByRole("button", { name: "Retry now", exact: true }).first().click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].path).toMatch(/\/jobs\/retry$/);
expect(fixture.writes[0].payload.job_ids).toEqual(["job-1"]);
await expect(page.getByRole("dialog").getByRole("button", { name: /Close|Schließen/, exact: true }).last()).toBeEnabled();
await page.getByRole("dialog").getByRole("button", { name: /Close|Schließen/, exact: true }).last().click();
await expect(page.getByText(/Delivery request finished: 1 attempted, 1 accepted, 0 failed/)).toBeVisible();
await page.reload();
await expect(fixture.table.getByText("Report message 1", { exact: true })).toBeVisible();
expect(fixture.jobs[0].send_status).toBe("smtp_accepted"); expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
});
test("page retry and unattempted continuation exclude accepted, active and uncertain messages", async ({ page }) => {
const fixture = await install(page);
expect(fixture.jobs[5].send_status).toBe("queued");
await page.getByRole("button", { name: "Retry failed messages on this page now (2)", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].payload.job_ids).toEqual(["job-1", "job-2"]);
expect(fixture.writes[0].payload.include_permanent).toBe(true);
await page.getByRole("dialog").getByRole("button", { name: /Close|Schließen/, exact: true }).last().click();
await page.getByRole("button", { name: "Send unattempted messages on this page now (1)", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(2);
expect(fixture.writes[1].path).toMatch(/\/jobs\/send-unattempted$/);
expect(fixture.writes[1].payload.job_ids).toEqual(["job-6"]);
expect(fixture.errors).toEqual([]);
});
test("reconciliation requires evidence, retains failed input, and retries only explicitly", async ({ page }) => {
const fixture = await install(page, { rejectFirst: true });
await fixture.table.getByRole("button", { name: /^(Not sent|Nicht gesendet)$/ }).and(page.locator(":enabled")).click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("button", { name: "Record message as not sent", exact: true })).toBeDisabled();
await dialog.getByRole("textbox", { name: /^Evidence note/ }).fill("SMTP logs show no acceptance for this message ID.");
await dialog.getByRole("button", { name: "Record message as not sent", exact: true }).click();
await expect(dialog.getByText("Fixture recovery changed. Reload evidence before retrying.", { exact: false })).toBeVisible();
await expect(dialog.getByRole("textbox")).toHaveValue("SMTP logs show no acceptance for this message ID.");
expect(fixture.writes).toHaveLength(1);
await dialog.getByRole("button", { name: "Record message as not sent", exact: true }).click();
await expect(dialog).toHaveCount(0);
expect(fixture.writes[1].payload.note).toBe("SMTP logs show no acceptance for this message ID.");
expect(fixture.jobs[2].send_status).toBe("failed_temporary");
expect(fixture.writes).toHaveLength(2); expect(fixture.errors).toEqual([]);
});
test("only a server-proven stale claim can be recovered, without implicit send or reconciliation", async ({ page }) => {
const fixture = await install(page);
await expect(fixture.table.getByText(/Processing is still active or its lease has not expired/)).toBeVisible();
await expect(fixture.table.getByRole("button", { name: "Recover interrupted SMTP processing", exact: true }).and(page.locator(":enabled"))).toHaveCount(1);
await fixture.table.getByRole("button", { name: "Recover interrupted SMTP processing", exact: true }).and(page.locator(":enabled")).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("textbox").fill("Previous process stopped; checked its expired lease and server logs.");
await dialog.getByRole("button", { name: "Mark outcome for investigation", exact: true }).click();
await expect(dialog).toHaveCount(0);
expect(fixture.writes).toHaveLength(1); expect(fixture.writes[0].path).toContain("/jobs/job-4/recover-claim");
expect(fixture.writes[0].payload.expected_revision).toBe("claim-revision-4");
expect(fixture.jobs[3].send_status).toBe("outcome_unknown"); expect(fixture.jobs[4].send_status).toBe("sending");
expect(fixture.errors).toEqual([]);
});
test("pending recovery cannot duplicate or dismiss, and read-only Report cannot mutate", async ({ page }) => {
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
const fixture = await install(page, { holdWrite: held });
await fixture.table.getByRole("button", { name: /^(Accepted|Angenommen)$/ }).and(page.locator(":enabled")).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("textbox").fill("Verified SMTP acceptance in the server log.");
await dialog.getByRole("button", { name: "Record SMTP acceptance", exact: true }).dblclick();
await expect.poll(() => fixture.writes.length).toBe(1);
await page.keyboard.press("Escape");
await expect(dialog).toBeVisible(); await expect(dialog.getByRole("textbox")).toBeDisabled();
release(); await expect(dialog).toHaveCount(0);
await page.goto("/?campaign-report&language=en&read-only");
await expect(fixture.table.getByRole("button", { name: "Retry now", exact: true }).first()).toBeDisabled();
await expect(fixture.table.getByRole("button", { name: "Recover interrupted SMTP processing", exact: true }).first()).toBeDisabled();
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
});
test("queued remainder can continue by row, but previous SMTP, Postbox or Print attempts cannot", async ({ page }) => {
const fixture = await install(page);
const continuations = fixture.table.getByRole("button", { name: "Send unattempted message now", exact: true }).and(page.locator(":enabled"));
for (const field of ["attempt_count", "postbox_attempt_count", "print_attempt_count"] as const) {
fixture.jobs[5][field] = 1;
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(continuations).toHaveCount(0);
await expect(page.getByRole("button", { name: "Send unattempted messages on this page now (0)", exact: true })).toBeDisabled();
fixture.jobs[5][field] = 0;
}
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(continuations).toHaveCount(1);
await continuations.click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].payload.job_ids).toEqual(["job-6"]);
expect(fixture.jobs[3].send_status).toBe("sending");
expect(fixture.jobs[2].send_status).toBe("outcome_unknown");
expect(fixture.errors).toEqual([]);
});
test("active, queued and incomplete IMAP counts drill down to the exact current states", async ({ page }) => {
const fixture = await install(page);
for (const [label, count, ids] of [
["Sending", 2, [4, 5]], ["Queued", 1, [6]], ["Pending", 1, [1]],
["Copying to Sent", 1, [2]], ["Outcome uncertain", 1, [3]]
] as const) {
const shortcut = page.locator("dt").filter({ hasText: new RegExp(`^${label}$`) }).locator("..").getByRole("button");
await expect(shortcut).toHaveText(String(count));
await shortcut.click();
await expect(shortcut).toHaveAttribute("aria-pressed", "true");
await expect(fixture.table.getByText(/^Report message \d+$/)).toHaveCount(ids.length);
for (const id of ids) await expect(fixture.table.getByText(`Report message ${id}`, { exact: true })).toBeVisible();
}
expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
});
test("old report reads cannot restore rows after switching account context", async ({ page }) => {
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
const fixture = await install(page, { switchAccount: true });
fixture.holdNextJobsRead(held);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect.poll(() => fixture.heldReads).toBe(1);
fixture.jobs[0].subject = "New account message";
await page.getByRole("button", { name: "Switch fixture account" }).click();
await expect(fixture.table.getByText("New account message", { exact: true })).toBeVisible();
const oldResponse = page.waitForResponse(response => new URL(response.url()).pathname.endsWith("/jobs"));
release();
await oldResponse;
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await expect(fixture.table.getByText("Report message 1", { exact: true })).toHaveCount(0);
await expect(fixture.table.getByText("New account message", { exact: true })).toBeVisible();
expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
});
test("an old recovery acknowledgment cannot reopen progress in another account context", async ({ page }) => {
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
const fixture = await install(page, { switchAccount: true, holdWrite: held });
await fixture.table.getByRole("button", { name: "Retry now", exact: true }).first().click();
await expect.poll(() => fixture.writes.length).toBe(1);
await expect(page.getByRole("dialog")).toBeVisible();
// Simulate an externally changed authentication provider while the request is pending.
await page.getByRole("button", { name: "Switch fixture account" }).evaluate(button => (button as HTMLButtonElement).click());
await expect(page.getByRole("dialog")).toHaveCount(0);
const oldResponse = page.waitForResponse(response => new URL(response.url()).pathname.endsWith("/jobs/retry"));
release();
await oldResponse;
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await expect.poll(() => fixture.jobs[0].send_status).toBe("smtp_accepted");
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByText(/Delivery request finished:/)).toHaveCount(0);
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
});
test("long recipient names and addresses wrap without clipping their contents", async ({ page }) => {
const fixture = await install(page);
const email = `${"long-address-segment-".repeat(5)}@example.test`;
fixture.jobs[0].resolved_recipients.bcc = [{ email }];
await page.getByRole("button", { name: "Reload", exact: true }).click();
const cell = fixture.table.locator(".campaign-report-recipient-cell").filter({ hasText: email });
await expect(cell.getByText(email, { exact: true })).toBeVisible();
await expect.poll(() => cell.evaluate(node => ({ overflow: node.scrollWidth - node.clientWidth, height: node.getBoundingClientRect().height })))
.toMatchObject({ overflow: 0 });
expect(await cell.getByText(email, { exact: true }).evaluate(node => getComputedStyle(node).overflowWrap)).toBe("anywhere");
expect(fixture.errors).toEqual([]);
});
@@ -0,0 +1,55 @@
import { expect, test } from "@playwright/test";
for (const language of ["en", "de"]) {
test(`${language}: validation cause/outcome groups and repeated files are fully paginated`, async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", error => errors.push(error.message));
await page.goto(`/?campaign-review-details&language=${language}`);
const validation = page.getByRole("region", { name: "Validation details fixture" });
const repeated = page.getByRole("region", { name: "Repeated files fixture" });
const next = language === "en" ? "Next page" : "Nächste Seite";
const previous = language === "en" ? "Previous page" : "Vorherige Seite";
const details = language === "en" ? "Technical details" : "Technische Details";
await expect(validation.locator("[data-validation-issue-group]")).toHaveCount(10);
const first = validation.locator("[data-validation-issue-group]").first();
await expect(first.getByText("Missing attachment for recipient 1.", { exact: true })).toBeVisible();
await expect(first.getByText("Policy excludes recipient 1 without attachments.", { exact: true })).toBeVisible();
await first.getByText(details, { exact: true }).click();
await expect(first.getByText(/\/entries\/recipient-1\/attachments\/0/)).toBeVisible();
await expect(first.getByText(/missing_attachment_coverage/)).toBeVisible();
await validation.getByRole("button", { name: next, exact: true }).click();
await expect(validation.locator("[data-validation-issue-group]")).toHaveCount(2);
await expect(validation.getByText("Missing attachment for recipient 12.", { exact: true })).toBeVisible();
await expect(validation.getByRole("button", { name: next, exact: true })).toBeDisabled();
await validation.getByRole("button", { name: previous, exact: true }).click();
await expect(validation.getByText("Missing attachment for recipient 1.", { exact: true })).toBeVisible();
await validation.getByRole("combobox").selectOption("25");
await expect(validation.locator("[data-validation-issue-group]")).toHaveCount(12);
await expect(repeated.getByText(/repeated-file-12\.pdf/)).not.toBeVisible();
await repeated.getByRole("button", { name: next, exact: true }).click();
await expect(repeated.getByText(/repeated-file-12\.pdf/)).toBeVisible();
await repeated.getByRole("button", { name: previous, exact: true }).click();
await expect(repeated.getByText(/repeated-file-1\.pdf/)).toBeVisible();
await expect(repeated.getByText(/repeated-file-12\.pdf/)).not.toBeVisible();
expect(errors).toEqual([]);
});
}
test("detail lists clamp their last page when refreshed results shrink", async ({ page }) => {
await page.goto("/?campaign-review-details&language=en");
const validation = page.getByRole("region", { name: "Validation details fixture" });
const repeated = page.getByRole("region", { name: "Repeated files fixture" });
await validation.getByRole("button", { name: "Next page", exact: true }).click();
await repeated.getByRole("button", { name: "Next page", exact: true }).click();
await expect(validation.getByText("Missing attachment for recipient 12.", { exact: true })).toBeVisible();
await expect(repeated.getByText(/repeated-file-12\.pdf/)).toBeVisible();
await page.getByRole("button", { name: "Reduce fixture details", exact: true }).click();
await expect(validation.locator("[data-validation-issue-group]")).toHaveCount(2);
await expect(validation.getByText("Missing attachment for recipient 1.", { exact: true })).toBeVisible();
await expect(repeated.getByText(/repeated-file-1\.pdf/)).toBeVisible();
await expect(repeated.getByText(/repeated-file-12\.pdf/)).not.toBeVisible();
for (const region of [validation, repeated]) {
await expect(region.getByRole("button", { name: "Previous page", exact: true })).toBeDisabled();
await expect(region.getByRole("button", { name: "Next page", exact: true })).toBeDisabled();
}
});
@@ -0,0 +1,321 @@
import { expect, test, type Page } from "@playwright/test";
async function install(page: Page, options: { rejectFirst?: boolean; conflictFirst?: boolean; stateMatrix?: boolean; holdSave?: Promise<void>; holdFirstJobs?: Promise<void>; rejectFirstJobs?: boolean; language?: "en" | "de"; readOnly?: boolean } = {}) {
const errors: string[] = [];
page.on("pageerror", error => { errors.push(error.message); console.error("Review fixture:", error.message); });
const writes: Record<string, any>[] = [];
const reads: string[] = [];
let revision = 1;
let complete = false;
let buildGeneration = 1;
let selectedVersionId = "review-version";
let jobRequests = 0;
const builds: Record<string, unknown>[] = [];
const publicBuildToken = () => selectedVersionId === "replacement-version" ? "replacement-build-reference"
: buildGeneration === 1 ? "opaque-build-reference" : `rebuilt-reference-${buildGeneration}`;
const subjectFor = (subject: string) => selectedVersionId === "replacement-version" ? `Replacement ${subject}`
: buildGeneration === 1 ? subject : `Rebuilt ${subject}`;
const decisions = new Map<string, Record<string, unknown>>();
const statuses = options.stateMatrix ? ["needs_review", "ready", "excluded", "warning", "blocked", "needs_review"] : ["needs_review", "needs_review", "excluded"];
if (options.stateMatrix) decisions.set("job-6", { job_id: "job-6", decision: "accept", reason: "Already reviewed this specific exception." });
const jobs = statuses.map((status, offset) => {
const index = offset + 1;
return { id: `job-${index}`, entry_id: `entry-${index}`, review_key: `entry-${index}`,
entry_index: index, recipient_email: `recipient-${index}@example.test`, subject: `Message ${index}`,
build_status: "built", validation_status: status, send_status: "not_queued",
resolved_recipients: { to: [{ email: `recipient-${index}@example.test` }] }, attachments: [],
issues: status === "ready" ? [] : [{ code: "missing_optional_attachment", behavior: status === "excluded" ? "drop" : status === "warning" ? "warn" : status === "blocked" ? "block" : "ask", severity: status === "blocked" ? "error" : "warning", source: "attachments", message: "Expected attachment was not found" }],
review_decision: { eligible: status === "needs_review", category_key: "attachment-group", reason_required: true, issue_codes: ["missing_optional_attachment"] }
}; });
const version = () => ({ id: selectedVersionId, campaign_id: "review-campaign", version_number: 1,
edit_revision: revision, strong_etag: `"${selectedVersionId}:${revision}"`, review_build_token: publicBuildToken(),
locked_at: "2026-09-07T09:00:00Z",
current_flow: "manual", current_step: "review", workflow_state: "built", is_complete: false,
updated_at: "2026-09-07T10:00:00Z", build_summary: { built_count: 2, needs_review_count: 2 }, validation_summary: { ok: true, warning_count: 14, issues: [] },
raw_json: { campaign: { name: "Review fixture" }, template: { subject: "Review", text: "Fixture message", html: "<p>Fixture message</p>" },
server: {}, entries: { inline: jobs.map(row => ({ id: row.entry_id, email: row.recipient_email, active: true })) } },
editor_state: { review_send: { review_build_token: publicBuildToken(), inspection_complete: complete,
reviewed_message_keys: [...decisions.keys()].map(id => `entry-${id.split("-")[1]}`), issue_decisions: [...decisions.values()] } }
});
const jobResponse = () => ({ jobs: jobs.map(row => ({ ...row, subject: subjectFor(row.subject), reviewed: decisions.has(row.id) })), page: 1, page_size: 200,
total: jobs.length, total_unfiltered: jobs.length, pages: 1, cursor: null, next_cursor: null, counts: {}, filtered_counts: {},
review: { blocking_count: options.stateMatrix ? 1 : 0, required_count: 2, bulk_acceptable_count: options.stateMatrix ? 1 : 0, reviewed_required_count: decisions.size, inspection_complete: complete } });
await page.route((url) => url.pathname.startsWith("/api/"), async route => {
const request = route.request(); const url = new URL(request.url());
if (request.method() === "GET") {
reads.push(url.pathname);
if (url.pathname.endsWith("/workspace/delta")) {
selectedVersionId = url.searchParams.get("version_id") ?? "review-version";
return route.fulfill({ json: {
campaign: { id: "review-campaign", name: "Review fixture", current_version_id: selectedVersionId, status: "draft" },
versions: [version()], current_version: version(), summary: { cards: { jobs_total: jobs.length, sent: 0, failed: 0 }, status_counts: {}, attachments: { missing_configs: 14 } },
deleted: [], full: true, has_more: false, watermark: `w-${revision}`
} }); }
if (url.pathname.endsWith("/jobs")) {
jobRequests++;
const snapshot = jobResponse();
if (jobRequests === 1 && options.holdFirstJobs) {
// A stale response advertising another page must not start that
// follow-up request after the selected build has changed.
snapshot.pages = 2;
await options.holdFirstJobs;
}
if (jobRequests === 1 && options.rejectFirstJobs) return route.fulfill({ status: 503, json: { detail: "Fixture initial review load failed" } });
return route.fulfill({ json: snapshot });
}
if (/\/jobs\/job-/.test(url.pathname)) return route.fulfill({ json: { job: jobs.find(row => url.pathname.endsWith(row.id)), attempts: [] } });
if (url.pathname.endsWith(`/versions/${selectedVersionId}`)) return route.fulfill({ json: version() });
return route.fulfill({ json: {} });
}
if (url.pathname.endsWith("/attachments/preview")) return route.fulfill({ json: {
campaign_id: "review-campaign", version_id: "review-version", shared_file_count: 0,
rules: [], linkable_files: [], unused_shared_files: []
} });
if (request.method() === "POST" && url.pathname.endsWith("/review-state")) {
const body = request.postDataJSON(); writes.push(body);
if (options.holdSave) await options.holdSave;
if (options.rejectFirst && writes.length === 1) return route.fulfill({ status: 503, json: { detail: "Fixture decision save unavailable" } });
if (options.conflictFirst && writes.length === 1) {
decisions.set("job-2", { job_id: "job-2", decision: "accept", reason: "Another reviewer checked this document.", actor_user_id: "other-reviewer" });
revision++;
return route.fulfill({ status: 409, json: { detail: "Another reviewer saved a decision. Inspect the refreshed progress and retry explicitly." } });
}
expect(body.merge_progress).toBe(true);
expect(body.build_token).toBe(publicBuildToken());
expect(body.base_revision).toBe(revision);
for (const decision of body.issue_decisions) decisions.set(decision.job_id, decision);
revision++; complete = body.inspection_complete;
return route.fulfill({ json: version() });
}
if (request.method() === "POST" && url.pathname.endsWith("/build")) {
builds.push(request.postDataJSON());
buildGeneration++; revision++; complete = false; decisions.clear();
return route.fulfill({ json: { built_count: 2, build_token: publicBuildToken() } });
}
return route.abort();
});
await page.goto(`/?campaign-review&language=${options.language ?? "en"}${options.readOnly ? "&read-only" : ""}${options.holdFirstJobs ? "&race" : ""}`);
if (options.holdFirstJobs || options.rejectFirstJobs) await expect.poll(() => jobRequests).toBe(1);
else await expect(page.getByText("Message 1", { exact: true }).first()).toBeVisible();
return { writes, reads, errors, decisions, builds, get jobRequests() { return jobRequests; } };
}
async function openMessage(page: Page, index = 1) {
const table = page.getByRole("table", { name: "campaign-review-campaign-workflow-built-messages", exact: true });
await expect(table.getByText(`Message ${index}`, { exact: true })).toBeVisible();
await table.getByRole("button", { name: /^(Review|Prüfung)$/ }).nth(index - 1).click();
await expect(page.getByRole("dialog")).toBeVisible();
return page.getByRole("dialog");
}
test("each acceptance persists immediately and advances, surviving reload before whole-review completion", async ({ page }) => {
const fixture = await install(page);
const dialog = await openMessage(page);
const readsBefore = fixture.reads.length;
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Recipient already received the document.");
expect(fixture.reads.length).toBe(readsBefore);
expect(fixture.writes).toHaveLength(0);
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
expect(fixture.writes).toHaveLength(1);
expect(fixture.writes[0].inspection_complete).toBe(false);
expect(fixture.writes[0].issue_decisions[0].reason).toBe("Recipient already received the document.");
expect(fixture.reads.length).toBe(readsBefore);
await page.reload();
await openMessage(page);
await expect(page.getByRole("dialog").getByText("Review decision saved. You can leave and continue later.")).toBeVisible();
expect(fixture.writes).toHaveLength(1);
expect(fixture.errors).toEqual([]);
});
test("failed review save keeps the reason and current message until explicit retry succeeds", async ({ page }) => {
const fixture = await install(page, { rejectFirst: true });
const dialog = await openMessage(page);
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Retain this reason.");
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
await expect(dialog.getByText(/Fixture decision save unavailable/)).toBeVisible();
await expect(dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ })).toHaveValue("Retain this reason.");
expect(fixture.decisions.size).toBe(0);
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
expect(fixture.writes).toHaveLength(2);
expect(fixture.errors).toEqual([]);
});
test("pending individual acceptance cannot duplicate, dismiss, or navigate before acknowledgement", async ({ page }) => {
let acknowledge!: () => void;
const fixture = await install(page, { holdSave: new Promise<void>((resolve) => { acknowledge = resolve; }) });
const dialog = await openMessage(page);
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Confirmed exception, waiting for durable save.");
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).evaluate((button: HTMLButtonElement) => {
button.click(); button.click();
});
await expect.poll(() => fixture.writes.length).toBe(1);
for (const navigation of await dialog.locator(".template-preview-nav button").all()) await expect(navigation).toBeDisabled();
for (const close of await dialog.getByRole("button", { name: /^(Close|Schließen)$/ }).all()) await expect(close).toBeDisabled();
await page.keyboard.press("Escape");
await expect(dialog).toBeVisible();
await expect(dialog.getByText("recipient-1@example.test", { exact: true }).first()).toBeVisible();
acknowledge();
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
expect(fixture.writes).toHaveLength(1);
expect(fixture.errors).toEqual([]);
});
test("German individual review records its reason and skips intentional exclusions", async ({ page }) => {
const fixture = await install(page, { language: "de" });
let dialog = await openMessage(page);
await dialog.getByRole("textbox", { name: /^Warum ist das in Ordnung\?/ }).fill("Das Dokument liegt dem Empfänger bereits vor.");
await dialog.getByRole("button", { name: "Akzeptieren und weiter", exact: true }).click();
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
await dialog.getByRole("textbox", { name: /^Warum ist das in Ordnung\?/ }).fill("Auch dieser Empfänger benötigt keinen weiteren Anhang.");
await dialog.getByRole("button", { name: "Akzeptieren und weiter", exact: true }).click();
await expect(dialog).not.toBeVisible();
expect(fixture.writes).toHaveLength(2);
expect(fixture.decisions.has("job-3")).toBe(false);
await page.reload();
dialog = await openMessage(page);
await expect(dialog.getByText("Prüfentscheidung gespeichert. Sie können die Prüfung später fortsetzen.")).toBeVisible();
expect(fixture.errors).toEqual([]);
});
test("real review grouped acceptance persists only eligible messages with exact category guard", async ({ page }) => {
const fixture = await install(page);
await expect(page.getByText(/14 validation warning.*need review/)).toHaveCount(1);
await page.getByRole("button", { name: "Accept similar review conditions", exact: true }).click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("checkbox")).toHaveCount(2);
await expect(dialog.getByRole("checkbox", { name: "recipient-3@example.test" })).toHaveCount(0);
await dialog.getByRole("textbox").fill("Both recipients have already received these documents.");
await dialog.getByRole("button", { name: "Accept 2 selected messages" }).click();
await expect(dialog).not.toBeVisible();
expect(fixture.writes).toHaveLength(1);
expect(fixture.writes[0]).toMatchObject({ merge_progress: true, build_token: "opaque-build-reference", base_revision: 1,
inspection_complete: false, decision_category_key: "attachment-group", reviewed_message_keys: ["entry-1", "entry-2"] });
expect(fixture.writes[0].issue_decisions).toEqual([
{ job_id: "job-1", decision: "accept", reason: "Both recipients have already received these documents." },
{ job_id: "job-2", decision: "accept", reason: "Both recipients have already received these documents." }
]);
await page.reload();
await expect(page.getByRole("button", { name: "Accept similar review conditions", exact: true })).toBeDisabled();
await openMessage(page, 2);
await expect(page.getByRole("dialog").getByText("Review decision saved. You can leave and continue later.")).toBeVisible();
expect(fixture.decisions.has("job-3")).toBe(false);
await page.getByRole("dialog").getByRole("button", { name: /^(Close|Schließen)$/ }).last().click();
await page.getByRole("button", { name: /^(Complete review|Prüfung abschließen)$/ }).click();
await expect.poll(() => fixture.writes.length).toBe(2);
expect(fixture.writes[1].inspection_complete).toBe(true);
const attachmentsPreflight = page.locator(".deliverability-preflight-item").filter({ has: page.getByText(/^(Attachments|Anhänge)$/) });
await expect(attachmentsPreflight).toHaveAttribute("data-state", "ready");
await expect(page.getByText(/14 validation warning.*need review/)).toHaveCount(0);
await page.reload();
await expect(attachmentsPreflight).toHaveAttribute("data-state", "ready");
await expect(page.getByText(/14 validation warning.*need review/)).toHaveCount(0);
expect(fixture.errors).toEqual([]);
});
test("read-only campaign access permits inspection but no review decisions", async ({ page }) => {
const fixture = await install(page, { readOnly: true });
await expect(page.getByRole("button", { name: "Accept similar review conditions", exact: true })).toBeDisabled();
await expect(page.getByRole("button", { name: /^(Complete review|Prüfung abschließen)$/ })).toBeDisabled();
const dialog = await openMessage(page);
const accept = dialog.getByRole("button", { name: "Accept and continue", exact: true });
if (await accept.count()) await expect(accept).toBeDisabled();
expect(fixture.writes).toHaveLength(0);
expect(fixture.errors).toEqual([]);
});
test("concurrent reviewer conflict refreshes evidence without replaying or overwriting their decision", async ({ page }) => {
const fixture = await install(page, { conflictFirst: true });
const dialog = await openMessage(page);
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Keep my independently inspected exception.");
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
await expect(dialog.getByText(/Another reviewer saved a decision/)).toBeVisible();
await expect(dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ })).toHaveValue("Keep my independently inspected exception.");
await expect(dialog.getByText("recipient-1@example.test", { exact: true }).first()).toBeVisible();
expect(fixture.writes).toHaveLength(1);
expect(fixture.decisions.has("job-1")).toBe(false);
expect(fixture.decisions.get("job-2")?.reason).toBe("Another reviewer checked this document.");
expect(fixture.reads.filter(path => path.endsWith("/versions/review-version"))).toHaveLength(1);
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
await expect(dialog).not.toBeVisible();
expect(fixture.writes).toHaveLength(2);
expect(fixture.writes[1].base_revision).toBe(2);
expect(fixture.writes[1].issue_decisions).toEqual([{ job_id: "job-1", decision: "accept", reason: "Keep my independently inspected exception." }]);
expect(fixture.decisions.get("job-2")?.actor_user_id).toBe("other-reviewer");
expect(fixture.errors).toEqual([]);
});
test("one four-state filter distinguishes automatic, accepted, warning, blocked and excluded messages", async ({ page }) => {
const fixture = await install(page, { stateMatrix: true, language: "de" });
const section = page.locator("#campaign-built-message-details");
const table = section.getByRole("table");
const stateCells = table.locator('.data-grid-body-cell[data-column-id="messageState"]');
await expect(table.locator('[data-column-id="validation"]')).toHaveCount(0);
await expect(table.locator('[data-column-id="reviewed"]')).toHaveCount(0);
await expect(stateCells).toHaveText(["Prüfung erforderlich", "Bereit", "Ausgeschlossen", "Prüfung erforderlich", "Blockiert", "Bereit"]);
await expect(table.locator('.data-grid-body-cell[data-column-id="stateExplanation"]').nth(1)).toContainText("keine manuelle Entscheidung erforderlich");
await expect(table.locator('.data-grid-body-cell[data-column-id="stateExplanation"]').nth(5)).toContainText("gespeicherter Prüfentscheidung");
await table.locator('.data-grid-header-cell[data-column-id="messageState"] .data-grid-filter-trigger').click();
const filter = page.locator(".data-grid-filter-popover");
await expect(filter.getByRole("checkbox")).toHaveCount(4);
for (const label of ["Bereit", "Prüfung erforderlich", "Blockiert", "Ausgeschlossen"]) await expect(filter.getByRole("checkbox", { name: label, exact: true })).toBeVisible();
await filter.getByRole("button", { name: "Alle abwählen", exact: true }).click();
await filter.getByRole("checkbox", { name: "Prüfung erforderlich", exact: true }).check();
await expect(stateCells).toHaveText(["Prüfung erforderlich", "Prüfung erforderlich"]);
await filter.getByRole("button", { name: /^(Close filter|Filter schließen)$/ }).click();
// The convenience action uses the very same derived state, adding blockers
// but never reintroducing accepted rows just because their build said ask.
await section.getByRole("button", { name: /^(Show review candidates only|Nur Prüfkandidaten anzeigen)$/ }).click();
await expect(stateCells).toHaveText(["Prüfung erforderlich", "Prüfung erforderlich", "Blockiert"]);
await section.getByRole("button", { name: /^(Show all messages|Alle Nachrichten anzeigen)$/ }).click();
await expect(stateCells).toHaveCount(6);
expect(fixture.writes).toHaveLength(0);
expect(fixture.errors).toEqual([]);
});
test("rebuilding a version loads its new review rows after the build busy state clears", async ({ page }) => {
const fixture = await install(page);
expect(fixture.jobRequests).toBe(1);
await page.getByRole("button", { name: /^(Build again|Erneut erstellen|Erneut bauen)$/ }).click();
const table = page.getByRole("table", { name: "campaign-review-campaign-workflow-built-messages", exact: true });
await expect(table.getByText("Rebuilt Message 1", { exact: true })).toBeVisible();
await expect(table.getByText("Message 1", { exact: true })).toHaveCount(0);
expect(fixture.builds).toHaveLength(1);
expect(fixture.jobRequests).toBe(2);
await table.getByRole("button", { name: /^(Review|Prüfung)$/ }).first().click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Reviewed the rebuilt evidence.");
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0]).toMatchObject({ build_token: "rebuilt-reference-2", base_revision: 2 });
expect(fixture.errors).toEqual([]);
});
test("a delayed old-version job response cannot replace the newly selected build", async ({ page }) => {
let releaseOld!: () => void;
const fixture = await install(page, { holdFirstJobs: new Promise<void>((resolve) => { releaseOld = resolve; }) });
await page.getByRole("button", { name: "Switch fixture version", exact: true }).click();
await expect(page).toHaveURL(/version=replacement-version/);
await expect.poll(() => fixture.reads.filter(path => path.endsWith("/workspace/delta")).length).toBeGreaterThanOrEqual(2);
releaseOld();
const table = page.getByRole("table", { name: "campaign-review-campaign-workflow-built-messages", exact: true });
await expect(table.getByText("Replacement Message 1", { exact: true })).toBeVisible();
await expect(table.getByText("Message 1", { exact: true })).toHaveCount(0);
expect(fixture.jobRequests).toBe(2);
expect(fixture.writes).toHaveLength(0);
expect(fixture.errors).toEqual([]);
});
test("an initial jobs failure stops automatic retries but a manual retry can recover", async ({ page }) => {
const fixture = await install(page, { rejectFirstJobs: true });
await expect(page.getByText(/Fixture initial review load failed/)).toBeVisible();
const retry = page.getByRole("button", { name: /^(Load review|Prüfung laden)$/ });
await expect(retry).toBeEnabled();
await page.waitForLoadState("networkidle");
expect(fixture.jobRequests).toBe(1);
await retry.click();
await expect(page.getByText("Message 1", { exact: true }).first()).toBeVisible();
expect(fixture.jobRequests).toBe(2);
expect(fixture.errors).toEqual([]);
});
@@ -0,0 +1,145 @@
import { expect, test, type Page } from "@playwright/test";
async function install(page: Page, options: { rejectSave?: boolean; rejectReload?: boolean; conflict?: boolean; hold?: Promise<void>; holdFirstRead?: Promise<void> } = {}) {
page.on("pageerror", (error) => { console.error("Campaign saving fixture:", error.message); });
let revision = 1;
let subject = "Original subject";
let rejected = false;
const writes: Record<string, unknown>[] = [];
let reads = 0;
const version = () => ({ id: "version-a", campaign_id: "campaign-a", version_number: 1,
edit_revision: revision, strong_etag: `"version-a:${revision}"`, editor_state: {},
current_flow: "manual", current_step: "template", workflow_state: "editing", is_complete: false,
updated_at: "2026-09-07T10:00:00Z", raw_json: { campaign: { name: "Original" }, template: { subject, text: "" }, server: {} } });
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
if (route.request().method() === "POST" && route.request().url().endsWith("/autosave")) {
const body = route.request().postDataJSON();
writes.push(body);
if (options.hold) await options.hold;
if (options.rejectSave && !rejected) {
rejected = true;
return route.fulfill({ status: 422, json: { detail: "Fixture policy denied this save" } });
}
if ((options.conflict && !rejected) || body.base_revision !== revision) {
rejected = true;
if (options.conflict) { revision = 2; subject = "Other editor's subject"; }
return route.fulfill({ status: 409, json: { detail: { code: "revision_conflict", resource: { type: "campaign_version", id: "version-a" }, current_revision: revision, submitted_base_revision: body.base_revision, retryable: true } } });
}
subject = body.campaign_json.template.subject;
revision += 1;
return route.fulfill({ json: version() });
}
if (route.request().method() === "GET" && route.request().url().includes("/versions/version-a")) {
reads++;
const snapshot = version();
if (reads === 1 && options.holdFirstRead) await options.holdFirstRead;
return options.rejectReload ? route.fulfill({ status: 503, json: { detail: "Fixture refresh unavailable" } }) : route.fulfill({ json: snapshot });
}
return route.abort();
});
await page.goto("/?campaign-saving&language=en");
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Original subject");
return { writes, reads: () => reads, subject: () => subject };
}
test("a rejected campaign save retains its draft and retries only on explicit request", async ({ page }) => {
const fixture = await install(page, { rejectSave: true });
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Unsaved work");
await page.getByRole("button", { name: "Save draft", exact: true }).click();
await expect(page.getByTestId("save-error")).toContainText("Fixture policy denied");
await expect(page.getByTestId("save-dirty")).toHaveText("true");
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Unsaved work");
expect(fixture.writes).toHaveLength(1);
await page.getByRole("button", { name: "Save draft", exact: true }).click();
await expect(page.getByTestId("save-result")).toHaveText("true");
await expect(page.getByTestId("save-dirty")).toHaveText("false");
expect(fixture.writes).toHaveLength(2);
expect(fixture.subject()).toBe("Unsaved work");
});
test("duplicate save requests coalesce and newer typing survives the older acknowledgement", async ({ page }) => {
let release!: () => void;
const fixture = await install(page, { hold: new Promise<void>((resolve) => { release = resolve; }) });
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Submitted work");
await page.getByRole("button", { name: "Request save twice", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
await expect(page.getByRole("button", { name: "Save draft", exact: true })).toBeDisabled();
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Newer unsaved work");
release();
await expect(page.getByTestId("save-busy")).toHaveText("false");
await expect(page.getByTestId("save-dirty")).toHaveText("true");
await expect(page.getByTestId("save-status")).toContainText("newer changes are still unsaved");
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Newer unsaved work");
expect(fixture.subject()).toBe("Submitted work");
expect(fixture.writes).toHaveLength(1);
await page.getByRole("button", { name: "Save draft", exact: true }).click();
await expect(page.getByRole("alertdialog", { name: "Concurrent changes" })).toBeVisible();
await page.getByRole("tab", { name: "Use my change", exact: true }).click();
await page.getByRole("button", { name: "Apply resolution", exact: true }).click();
await expect(page.getByTestId("save-dirty")).toHaveText("false");
expect(fixture.subject()).toBe("Newer unsaved work");
});
test("failed follow-up refresh never turns an acknowledged save into save failed", async ({ page }) => {
const fixture = await install(page, { rejectReload: true });
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Committed work");
await page.getByRole("button", { name: "Save draft", exact: true }).click();
await expect(page.getByTestId("save-result")).toHaveText("true");
await expect(page.getByTestId("save-status")).toContainText("Saved");
await expect(page.getByTestId("save-error")).toContainText("was saved");
await expect(page.getByTestId("save-dirty")).toHaveText("false");
expect(fixture.writes).toHaveLength(1);
});
test("cancelling a revision conflict ends saving and keeps the local draft", async ({ page }) => {
const fixture = await install(page, { conflict: true });
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("My conflicting work");
await page.getByRole("button", { name: "Save draft", exact: true }).click();
const dialog = page.getByRole("alertdialog", { name: "Concurrent changes" });
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(page.getByTestId("save-busy")).toHaveText("false");
await expect(page.getByTestId("save-status")).toContainText("Save cancelled");
await expect(page.getByTestId("save-dirty")).toHaveText("true");
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("My conflicting work");
expect(fixture.writes).toHaveLength(1);
});
test("background version refresh and failed discard do not silently clear unsaved work", async ({ page }) => {
await install(page, { rejectReload: true });
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Keep this draft");
await page.getByRole("button", { name: "Background refresh", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Keep this draft");
await page.getByRole("button", { name: "Discard draft", exact: true }).click();
await expect(page.getByTestId("save-error")).toContainText("Fixture refresh unavailable");
await expect(page.getByTestId("save-dirty")).toHaveText("true");
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Keep this draft");
});
test("new typing during a discard refresh is retained instead of being replaced by the late response", async ({ page }) => {
let release!: () => void;
const fixture = await install(page, { holdFirstRead: new Promise<void>(resolve => { release = resolve; }) });
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Earlier draft");
await page.getByRole("button", { name: "Discard draft", exact: true }).click();
await expect.poll(fixture.reads).toBe(1);
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Newer draft");
release();
await expect(page.getByTestId("save-error")).toContainText("Discard cancelled");
await expect(page.getByTestId("save-dirty")).toHaveText("true");
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Newer draft");
expect(fixture.writes).toHaveLength(0);
});
test("a late discard response cannot overwrite an acknowledged save", async ({ page }) => {
let release!: () => void;
const fixture = await install(page, { holdFirstRead: new Promise<void>(resolve => { release = resolve; }) });
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Saved work");
await page.getByRole("button", { name: "Discard draft", exact: true }).click();
await expect.poll(fixture.reads).toBe(1);
await page.getByRole("button", { name: "Save draft", exact: true }).click();
await expect(page.getByTestId("save-result")).toHaveText("true");
release();
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Saved work");
await expect(page.getByTestId("save-dirty")).toHaveText("false");
expect(fixture.subject()).toBe("Saved work");
});
@@ -0,0 +1,127 @@
import { expect, test, type Page, type Route } from "@playwright/test";
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => { resolve = done; });
return { promise, resolve };
}
function response(campaignId = "campaign-a", revision = 1, versionId = `${campaignId}-version`, hasMore = false) {
const version = { id: versionId, campaign_id: campaignId, version_number: 1, edit_revision: revision, raw_json: {} };
return {
campaign: { id: campaignId, current_version_id: versionId },
versions: [version], current_version: version, summary: null,
deleted: [], watermark: `watermark-${campaignId}-${revision}`, has_more: hasMore, full: true
};
}
async function fixture(page: Page) {
const requests: URL[] = [];
const state = {
handler: async (route: Route) => {
const url = new URL(route.request().url());
const campaignId = url.pathname.split("/")[4];
await route.fulfill({ json: response(campaignId, 1, url.searchParams.get("version_id") ?? undefined) });
}
};
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith("/workspace/delta")) {
requests.push(url);
await state.handler(route);
} else await route.fulfill({ json: {} });
});
await page.goto("/?campaign-workspace");
await expect(page.getByTestId("workspace-version")).toHaveText("campaign-a-version");
await expect(page.getByTestId("workspace-loading")).toHaveText("false");
requests.length = 0;
return { state, requests };
}
test("failed refresh retains saved workspace and retries without a stale watermark", async ({ page }) => {
const { state, requests } = await fixture(page);
state.handler = async (route) => { await route.fulfill({ status: 503, json: { detail: "Refresh unavailable" } }); };
await page.getByRole("button", { name: "Force reload workspace", exact: true }).click();
await expect(page.getByTestId("workspace-error")).toContainText("Refresh unavailable");
await expect(page.getByTestId("workspace-loading")).toHaveText("false");
await expect(page.getByTestId("workspace-version")).toHaveText("campaign-a-version");
state.handler = async (route) => { await route.fulfill({ json: response("campaign-a", 2) }); };
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
await expect(page.getByTestId("workspace-revision")).toHaveText("2");
expect(requests[requests.length - 1].searchParams.has("since")).toBe(false);
await expect(page.getByTestId("workspace-error")).toBeEmpty();
});
for (const staleFails of [false, true]) {
test(`newer reload wins over an older ${staleFails ? "failed" : "successful"} response`, async ({ page }) => {
const { state } = await fixture(page);
const pending = deferred(); const finished = deferred();
let requestCount = 0;
state.handler = async (route) => {
requestCount += 1;
if (requestCount === 1) {
await pending.promise;
await route.fulfill(staleFails ? { status: 503, json: { detail: "Obsolete error" } } : { json: response("campaign-a", 2) });
finished.resolve();
} else await route.fulfill({ json: response("campaign-a", 3) });
};
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
await expect.poll(() => requestCount).toBe(1);
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
await expect(page.getByTestId("workspace-revision")).toHaveText("3");
pending.resolve(); await finished.promise;
await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
await expect(page.getByTestId("workspace-revision")).toHaveText("3");
await expect(page.getByTestId("workspace-loading")).toHaveText("false");
await expect(page.getByTestId("workspace-error")).toBeEmpty();
});
}
for (const change of ["campaign", "identity", "version"] as const) {
test(`changing ${change} discards old responses and cancels obsolete pagination`, async ({ page }) => {
const { state, requests } = await fixture(page);
const pending = deferred(); const finished = deferred();
let requestCount = 0;
state.handler = async (route) => {
requestCount += 1;
if (requestCount === 1) {
await pending.promise;
await route.fulfill({ json: response("campaign-a", 99, "obsolete-version", true) });
finished.resolve();
} else await route.fulfill({ json: response(change === "campaign" ? "campaign-b" : "campaign-a", 2, "fresh-version") });
};
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
await expect.poll(() => requestCount).toBe(1);
await page.getByRole("button", { name: { campaign: "Switch campaign", identity: "Switch identity", version: "Select version" }[change] }).click();
await expect(page.getByTestId("workspace-version")).toHaveText("fresh-version");
pending.resolve(); await finished.promise;
await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
await expect(page.getByTestId("workspace-version")).toHaveText("fresh-version");
expect(requests).toHaveLength(2);
expect(requests[1].searchParams.has("since")).toBe(false);
});
}
test("failed initial load for a different campaign never reveals the old campaign", async ({ page }) => {
const { state } = await fixture(page);
state.handler = async (route) => { await route.fulfill({ status: 403, json: { detail: "Campaign unavailable" } }); };
await page.getByRole("button", { name: "Switch campaign" }).click();
await expect(page.getByTestId("workspace-error")).toContainText("Campaign unavailable");
await expect(page.getByTestId("workspace-campaign")).toHaveText("none");
await expect(page.getByTestId("workspace-version")).toHaveText("none");
});
test("a failed later page never publishes a partially refreshed workspace", async ({ page }) => {
const { state } = await fixture(page);
let requestCount = 0;
state.handler = async (route) => {
requestCount += 1;
await route.fulfill(requestCount === 1
? { json: response("campaign-a", 4, "partial-version", true) }
: { status: 503, json: { detail: "Second page unavailable" } });
};
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
await expect(page.getByTestId("workspace-error")).toContainText("Second page unavailable");
await expect(page.getByTestId("workspace-version")).toHaveText("campaign-a-version");
await expect(page.getByTestId("workspace-revision")).toHaveText("1");
});
@@ -0,0 +1,177 @@
import { expect, test, type Page } from "@playwright/test";
const credential = {
id: "fixture-credential", name: "Mailbox login", description: "Fixture metadata only",
credential_kind: "username_password", scope_type: "tenant", scope_id: null,
public_data: { username: "fixture@example.test" }, secret_configured: true,
allowed_modules: ["mail"], allowed_server_refs: ["mail:smtp-server", "mail:imap-server", "mail:deleted-server"],
inherit_to_lower_scopes: false, is_active: true,
created_at: "2026-01-01T12:00:00Z", updated_at: "2026-01-01T12:00:00Z"
};
async function installFixtures(page: Page, release: Promise<void>, options: { failFirst?: boolean; paginated?: boolean; denied?: boolean; retrySave?: Promise<void> } = {}) {
let loads = 0;
let savedCredential = credential;
const saves: Record<string, unknown>[] = [];
const forbiddenRequests: string[] = [];
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const url = new URL(request.url());
if (request.method() === "GET" && url.pathname === "/api/v1/credentials") {
await route.fulfill({ json: { credentials: [savedCredential] } });
return;
}
if (request.method() === "PATCH" && url.pathname === `/api/v1/credentials/${credential.id}` && options.retrySave) {
const body = request.postDataJSON() as Record<string, unknown>;
saves.push(body);
await options.retrySave;
if (saves.length === 1) {
await route.fulfill({ status: 503, json: { detail: "Fixture save failed; keep your draft and retry." } });
} else {
savedCredential = { ...credential, name: String(body.name) };
await route.fulfill({ json: savedCredential });
}
return;
}
if (request.method() === "GET" && url.pathname === "/api/v1/platform/modules") {
await route.fulfill({ json: { modules: [{ id: "mail", label: "Mail", version: "1" }] } });
return;
}
if (request.method() === "GET" && url.pathname === "/api/v1/mail/settings/delta") {
loads += 1;
const number = loads;
await release;
if (options.denied || (options.failFirst && number === 1)) {
await route.fulfill({ status: options.denied ? 403 : 503, json: { detail: "Fixture catalogue unavailable" } });
return;
}
expect(url.searchParams.get("scope_type")).toBe("tenant");
expect(url.searchParams.get("include_inactive")).toBe("true");
if (options.paginated && url.searchParams.get("since")) {
await route.fulfill({ json: { full: false, has_more: false, watermark: "fixture-watermark-2", profiles: [],
deleted: [{ id: "deleted-profile", resource_type: "mail_profile" }] } });
return;
}
await route.fulfill({ json: {
full: true, has_more: Boolean(options.paginated), watermark: "fixture-watermark", deleted: [],
profiles: [{ id: "fixture-profile", name: "Town hall", slug: "town-hall", servers: [
{ id: "smtp-server", name: "Outgoing town hall", protocol: "smtp", is_active: true, scope_type: "tenant" },
{ id: "imap-server", name: "Incoming town hall", protocol: "imap", is_active: false, scope_type: "tenant" }
] }, ...(options.paginated ? [{ id: "deleted-profile", name: "Removed profile", slug: "removed", servers: [
{ id: "deleted-server", name: "Removed server", protocol: "smtp", is_active: true, scope_type: "tenant" }
] }] : [])]
} });
return;
}
forbiddenRequests.push(`${request.method()} ${url.pathname}`);
await route.fulfill({ status: 403, json: { detail: "Unexpected fixture request" } });
});
return { get loads() { return loads; }, forbiddenRequests, saves };
}
test("credential server labels resolve on first opening despite StrictMode cleanup", async ({ page }) => {
page.on("pageerror", (error) => { throw error; });
let release!: () => void;
const fixture = await installFixtures(page, new Promise<void>((resolve) => { release = resolve; }));
await page.goto("/?credential-references&language=en");
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Edit reusable credential", exact: true });
await expect(dialog).toBeVisible();
await expect.poll(() => fixture.loads).toBeGreaterThan(0);
release();
const servers = dialog.getByRole("list", { name: "Credential servers selected" });
await expect(servers).toContainText("Outgoing town hall");
await expect(servers).toContainText("Incoming town hall");
await expect(servers.locator(".is-inactive")).toContainText("Incoming town hall");
await expect(servers.locator(".is-unavailable")).toContainText("mail:deleted-server");
await expect(servers.locator(".is-unavailable")).toHaveCount(1);
await dialog.locator('input[data-help-context-id="access.credentials.field.name"]').fill("Renamed metadata only");
await expect(servers).toContainText("Outgoing town hall");
expect(fixture.loads).toBe(1);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("paginated server catalogues remove deleted profiles while retaining their selected references", async ({ page }) => {
const fixture = await installFixtures(page, Promise.resolve(), { paginated: true });
await page.goto("/?credential-references&language=en");
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
const servers = page.getByRole("list", { name: "Credential servers selected" });
await expect(servers).toContainText("Outgoing town hall");
await expect(servers.locator(".is-unavailable")).toHaveCount(1);
await expect(servers.locator(".is-unavailable")).toContainText("mail:deleted-server");
await expect(servers).not.toContainText("Removed server");
expect(fixture.loads).toBe(2);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("denied server metadata remains unavailable without dropping stored references", async ({ page }) => {
const fixture = await installFixtures(page, Promise.resolve(), { denied: true });
await page.goto("/?credential-references&language=en");
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
const servers = page.getByRole("list", { name: "Credential servers selected" });
await expect(servers.locator(".is-unavailable")).toHaveCount(3);
for (const reference of credential.allowed_server_refs) await expect(servers).toContainText(reference);
await expect(servers).not.toContainText("Outgoing town hall");
expect(fixture.forbiddenRequests).toEqual([]);
});
test("temporary server-catalogue failures can recover by reopening the editor", async ({ page }) => {
const fixture = await installFixtures(page, Promise.resolve(), { failFirst: true });
await page.goto("/?credential-references&language=en");
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
await expect(page.getByRole("list", { name: "Credential servers selected" }).locator(".is-unavailable")).toHaveCount(3);
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
await expect(page.getByRole("list", { name: "Credential servers selected" })).toContainText("Outgoing town hall");
expect(fixture.loads).toBe(2);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("closing while servers load does not poison the next credential editor", async ({ page }) => {
let release!: () => void;
const fixture = await installFixtures(page, new Promise<void>((resolve) => { release = resolve; }));
await page.goto("/?credential-references&language=en");
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
await expect.poll(() => fixture.loads).toBeGreaterThan(0);
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
release();
const servers = page.getByRole("list", { name: "Credential servers selected" });
await expect(servers).toContainText("Outgoing town hall");
await expect(servers).toContainText("Incoming town hall");
expect(fixture.forbiddenRequests).toEqual([]);
});
test("failed credential saves show their error inside the editor and retain the draft for explicit retry", async ({ page }) => {
let finishSave!: () => void;
const retrySave = new Promise<void>((resolve) => { finishSave = resolve; });
const fixture = await installFixtures(page, Promise.resolve(), { retrySave });
await page.goto("/?credential-references&language=en");
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Edit reusable credential", exact: true });
const name = dialog.locator('input[data-help-context-id="access.credentials.field.name"]');
const secret = dialog.locator('input[type="password"]');
await name.fill("Renamed mailbox credential");
await secret.fill("synthetic-fixture-replacement");
await dialog.getByRole("button", { name: "Save credential", exact: true }).click();
await expect.poll(() => fixture.saves.length).toBe(1);
await expect(name).toBeDisabled();
await expect(secret).toBeDisabled();
await expect(dialog.getByRole("button", { name: "Cancel", exact: true })).toBeDisabled();
await expect(dialog.locator(".dialog-close")).toBeDisabled();
await page.keyboard.press("Escape");
await expect(dialog).toBeVisible();
finishSave();
await expect(dialog.getByRole("alert")).toContainText("Fixture save failed; keep your draft and retry.");
await expect(page.getByRole("alert")).toHaveCount(1);
await expect(name).toHaveValue("Renamed mailbox credential");
await expect(secret).toHaveValue("synthetic-fixture-replacement");
expect(fixture.saves).toHaveLength(1);
await dialog.getByRole("button", { name: "Save credential", exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByRole("button", { name: "Edit Renamed mailbox credential", exact: true })).toBeVisible();
expect(fixture.saves).toHaveLength(2);
expect(fixture.saves[1]).toEqual(fixture.saves[0]);
expect(fixture.saves[1].allowed_server_refs).toEqual(credential.allowed_server_refs);
expect(fixture.forbiddenRequests).toEqual([]);
});
@@ -0,0 +1,141 @@
import { expect, test, type Page } from "@playwright/test";
const actionCell = (page: Page) => page.locator('.data-grid-body-cell[data-column-id="actions"]');
const header = (page: Page, id: string) => page.locator(`.data-grid-header-cell[data-column-id="${id}"]`);
const width = (page: Page, id: string) => header(page, id).evaluate((element) => element.getBoundingClientRect().width);
async function expectActionsUnclipped(page: Page) {
await expect.poll(async () => actionCell(page).evaluate((cell) => {
const bounds = cell.getBoundingClientRect();
const scroller = cell.closest(".data-grid-scroll-region")!;
const viewport = scroller.getBoundingClientRect();
return Array.from(cell.querySelectorAll("button")).every((button) => {
const rect = button.getBoundingClientRect();
return rect.width >= 35 && rect.left >= bounds.left && rect.right <= bounds.right
&& rect.top >= bounds.top && rect.bottom <= bounds.bottom
&& rect.left >= viewport.left && rect.right <= viewport.right;
});
})).toBe(true);
}
test("undersized action tracks fit real controls and stay visible through narrow horizontal scrolling", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/?data-grid-layout");
await expect.poll(() => width(page, "actions")).toBeGreaterThanOrEqual(181);
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Narrow grid", exact: true }).click();
await expect.poll(() => width(page, "actions")).toBeLessThanOrEqual(160);
const scroller = page.locator(".data-grid-scroll-region");
await expectActionsUnclipped(page);
await scroller.evaluate((element) => { element.scrollLeft = element.scrollWidth / 2; });
await expectActionsUnclipped(page);
await scroller.evaluate((element) => { element.scrollLeft = element.scrollWidth; });
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Inspect Alpha", exact: true }).click();
await expect(page.getByTestId("clicked-action")).toHaveText("Inspect Alpha");
await scroller.focus();
await expect(scroller).toBeFocused();
// The application shell's supported viewport floor is 320px; its padded
// content area is narrower and must still contain the complete action set.
await page.setViewportSize({ width: 320, height: 720 });
await expectActionsUnclipped(page);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
expect(errors).toEqual([]);
});
test("pointer and keyboard resizing persists, cancels safely, and adapts to container resize", async ({ page }) => {
await page.goto("/?data-grid-layout");
await expectActionsUnclipped(page);
const handle = header(page, "name").getByRole("separator");
await expect(handle).toHaveAttribute("aria-orientation", "vertical");
const initial = await width(page, "name");
await handle.focus();
await handle.press("Shift+ArrowRight");
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 70, box.y + box.height / 2, { steps: 5 });
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 110, 0);
await page.keyboard.press("Escape");
await page.mouse.up();
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
await page.getByRole("button", { name: "Narrow grid", exact: true }).click();
await expect.poll(() => width(page, "name")).toBeLessThan(initial + 40);
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Wide grid", exact: true }).click();
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
await handle.press("Enter");
await expect.poll(() => width(page, "name")).toBeCloseTo(initial, 0);
const resetBox = (await handle.boundingBox())!;
await page.mouse.move(resetBox.x + resetBox.width / 2, resetBox.y + resetBox.height / 2);
await page.mouse.down();
await page.mouse.move(resetBox.x + resetBox.width / 2 + 50, resetBox.y + resetBox.height / 2, { steps: 5 });
await page.mouse.up();
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 50, 0);
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 50, 0);
});
test("free content tracks retain explicit user resizing on remount", async ({ page }) => {
await page.goto("/?data-grid-layout&mode=free");
const handle = header(page, "name").getByRole("separator");
await expectActionsUnclipped(page);
const initial = await width(page, "name");
await handle.press("ArrowRight");
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 10, 0);
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 10, 0);
});
test("empty and changing action sets reserve the same complete slots", async ({ page }) => {
await page.goto("/?data-grid-layout");
await expectActionsUnclipped(page);
const initial = await width(page, "actions");
await page.getByRole("button", { name: "Toggle empty rows", exact: true }).click();
await expectActionsUnclipped(page);
await expect.poll(() => width(page, "actions")).toBeCloseTo(initial, 0);
await page.getByRole("button", { name: "Toggle empty rows", exact: true }).click();
await page.getByRole("button", { name: "Toggle extra action", exact: true }).click();
await expect.poll(() => width(page, "actions")).toBeGreaterThan(initial + 35);
await expectActionsUnclipped(page);
});
test("constrained resizing redistributes tracks without introducing overflow", async ({ page }) => {
await page.goto("/?data-grid-layout&mode=constrained");
await expectActionsUnclipped(page);
const initial = await width(page, "name");
await header(page, "name").getByRole("separator").press("Shift+ArrowRight");
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
expect(await page.locator(".data-grid-scroll-region").evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await expectActionsUnclipped(page);
});
test("explicit composite action groups include outer controls in their measured minimum", async ({ page }) => {
await page.goto("/?data-grid-layout&mode=composite");
await expect.poll(() => width(page, "actions")).toBeGreaterThanOrEqual(221);
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Narrow grid", exact: true }).click();
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Extra control", exact: true }).click();
await expect(page.getByTestId("clicked-action")).toHaveText("Extra control");
});
test("oversized explicit sticky minima release stickiness instead of obscuring every data column", async ({ page }) => {
await page.goto("/?data-grid-layout&mode=oversized");
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Narrow grid", exact: true }).click();
await expect(page.locator(".data-grid-shell")).toHaveClass(/data-grid-release-sticky/);
const name = page.locator('.data-grid-body-cell[data-column-id="name"]');
await expect(name).toBeInViewport();
await page.locator(".data-grid-scroll-region").evaluate((element) => { element.scrollLeft = element.scrollWidth; });
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Wide grid", exact: true }).click();
await expect(page.locator(".data-grid-shell")).not.toHaveClass(/data-grid-release-sticky/);
});
@@ -0,0 +1,52 @@
import { expect, test, type Locator } from "@playwright/test";
async function expectDialogFits(dialog: Locator) {
await expect(dialog).toBeVisible();
const sizes = await dialog.evaluate((panel) => {
const body = panel.querySelector<HTMLElement>(".dialog-body")!;
const rect = panel.getBoundingClientRect();
const overflowControls = Array.from(panel.querySelectorAll("input,select,textarea,.dialog-close,.dialog-footer button")).filter((control) => {
const bounds = control.getBoundingClientRect();
return bounds.left < rect.left - 1 || bounds.right > rect.right + 1;
}).map((control) => control.tagName);
return { horizontalOverflow: body.scrollWidth - body.clientWidth, left: rect.left, right: rect.right, viewport: window.innerWidth, overflowControls };
});
expect(sizes.horizontalOverflow).toBeLessThanOrEqual(1);
expect(sizes.left).toBeGreaterThanOrEqual(0);
expect(sizes.right).toBeLessThanOrEqual(sizes.viewport);
expect(sizes.overflowControls).toEqual([]);
}
for (const width of [320, 390, 768, 1280]) {
test(`shared dialog keeps narrow form content and local table scrolling inside ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto("/?dialog-layout");
const dialog = page.getByRole("dialog");
await expectDialogFits(dialog);
const tableScroll = page.getByTestId("dialog-local-scroll");
expect(await tableScroll.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true);
await tableScroll.evaluate((element) => { element.scrollLeft = element.scrollWidth; });
expect(await tableScroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0);
await expectDialogFits(dialog);
});
for (const language of ["en", "de"]) {
test(`actual Templates Add dialog fits ${width}px in ${language}`, async ({ page }) => {
const unexpectedWrites: string[] = [];
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
if (request.method() !== "GET") unexpectedWrites.push(`${request.method()} ${request.url()}`);
await route.fulfill({ json: { items: [] } });
});
await page.setViewportSize({ width, height: 900 });
await page.goto(`/?dialog-layout&fixture=templates&language=${language}`);
await page.getByRole("button", { name: language === "de" ? "Vorlage hinzufügen" : "Add template", exact: true }).click();
const dialog = page.getByRole("dialog");
await expectDialogFits(dialog);
await dialog.locator("input").fill("Eine sehr lange Vorlagenbezeichnung ".repeat(8));
await expectDialogFits(dialog);
await expect(dialog.locator(".dialog-footer button").last()).toBeEnabled();
expect(unexpectedWrites).toEqual([]);
});
}
}
@@ -0,0 +1,62 @@
import { expect, test } from "@playwright/test";
for (const initiallyEmpty of [false, true]) {
test(`Files ignores a late ${initiallyEmpty ? "space discovery" : "folder listing"} Reload after session change`, async ({ page }) => {
let release!: () => void;
const heldReload = new Promise<void>((resolve) => { release = resolve; });
let holdNextRead = false;
let holdingRead = false;
const unexpected: string[] = [];
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (request.method() !== "GET") {
unexpected.push(`${request.method()} ${path}`);
await route.fulfill({ status: 403, json: { detail: "Fixture forbids writes" } });
return;
}
const next = request.headers().authorization === "Bearer fixture-next-session";
const actor = next ? "fixture-next-user" : "fixture-user";
const name = next ? "new-session.zip" : "old-session.zip";
const delayed = holdNextRead && !next && path === (initiallyEmpty ? "/api/v1/files/spaces" : "/api/v1/files");
if (delayed) {
holdNextRead = false;
holdingRead = true;
await heldReload;
}
if (path === "/api/v1/files/spaces") {
await route.fulfill({ json: { spaces: initiallyEmpty && !next && !delayed ? [] : [
{ id: `user:${actor}`, label: next ? "New session files" : "Old session files", owner_type: "user", owner_id: actor, space_type: "managed" }
] } });
} else if (path === "/api/v1/files/folders") {
await route.fulfill({ json: { folders: [], total: 0, next_cursor: null } });
} else if (path === "/api/v1/files") {
await route.fulfill({ json: { files: [{ id: `${actor}-archive`, tenant_id: "fixture-tenant", owner_type: "user", owner_id: actor,
display_path: name, filename: name, size_bytes: 42, content_type: "application/zip", checksum_sha256: "a".repeat(64), version_id: "fixture-version",
created_at: "2026-01-01T12:00:00Z", updated_at: "2026-01-01T12:00:00Z", audit_relevant: false, shares: [], metadata: {}, deleted_at: null
}], total: 1, next_cursor: null } });
} else {
unexpected.push(`${request.method()} ${path}`);
await route.fulfill({ status: 404, json: { detail: "Unconfigured fixture read" } });
}
});
await page.goto("/?files-toolbar&session-switch&language=en");
if (initiallyEmpty) await expect(page.getByText("No file spaces available.", { exact: true })).toBeVisible();
else await expect(page.locator(".file-row").filter({ hasText: "old-session.zip" })).toBeVisible();
const reload = page.locator('[data-interface-id="files.workspace.actions"]').getByRole("button", { name: "Reload", exact: true });
await expect(reload).toBeEnabled();
holdNextRead = true;
await reload.click();
await expect.poll(() => holdingRead).toBe(true);
await page.getByRole("button", { name: "Switch fixture session", exact: true }).click();
await expect(page.locator(".file-row").filter({ hasText: "new-session.zip" })).toBeVisible();
const oldResponse = page.waitForResponse((response) => response.url().includes(initiallyEmpty ? "/api/v1/files/spaces" : "/api/v1/files?") && response.request().headers().authorization !== "Bearer fixture-next-session");
release();
await oldResponse;
await expect(page.locator(".file-tree-root")).toHaveText(["New session files"]);
await expect(page.locator(".file-row")).toHaveCount(1);
await expect(page.locator(".file-row")).toContainText("new-session.zip");
await expect(reload).toBeEnabled();
expect(unexpected).toEqual([]);
});
}
@@ -0,0 +1,199 @@
import { expect, test, type Page } from "@playwright/test";
const archive = {
id: "fixture-archive", tenant_id: "fixture-tenant", owner_type: "user", owner_id: "fixture-user",
display_path: "small-archive.zip", filename: "small-archive.zip", size_bytes: 280,
content_type: "application/zip", checksum_sha256: "a".repeat(64), version_id: "fixture-version-7",
created_at: "2026-01-01T12:00:00Z", updated_at: "2026-01-01T12:00:00Z", audit_relevant: false,
shares: [], metadata: {}, deleted_at: null
};
async function fixtures(page: Page, connector = false) {
const reads: string[] = [];
const forbidden: string[] = [];
const state = { fail: false };
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname;
const readOnlyPattern = request.method() === "POST" && path === "/api/v1/files/resolve-patterns";
if (request.method() !== "GET" && !readOnlyPattern) {
forbidden.push(`${request.method()} ${path}`);
await route.fulfill({ status: 403, json: { detail: "Fixture refuses all writes" } });
return;
}
reads.push(`${request.method()} ${path}${url.search}`);
if (state.fail && (path === "/api/v1/files" || path.endsWith("/browse"))) {
await route.fulfill({ status: 503, json: { detail: "Fixture listing temporarily unavailable" } });
return;
}
if (path === "/api/v1/files/spaces") {
await route.fulfill({ json: { spaces: [
{ id: "user:fixture-user", label: "My files", owner_type: "user", owner_id: "fixture-user", space_type: "managed" },
...(connector ? [{ id: "connector:fixture-remote", label: "Remote archive", owner_type: "user", owner_id: "fixture-user", space_type: "connector", connector_space_id: "fixture-remote", connector_profile_id: "fixture-profile", remote_path: "source", provider: "s3", read_only: true, sync_mode: "manual" }] : [])
] } });
} else if (path === "/api/v1/files/folders") {
await route.fulfill({ json: { folders: [], total: 0, next_cursor: null } });
} else if (path === "/api/v1/files") {
await route.fulfill({ json: { files: [archive], total: 1, next_cursor: null } });
} else if (readOnlyPattern) {
await route.fulfill({ json: { patterns: [{ pattern: "*.zip", matches: [archive] }], unmatched: [] } });
} else if (path === "/api/v1/files/connectors/profiles") {
await route.fulfill({ json: { profiles: [] } });
} else if (path.endsWith("/fixture-profile/browse")) {
const folder = url.searchParams.get("path") ?? "source";
await route.fulfill({ json: { profile_id: "fixture-profile", provider: "s3", path: folder, library_id: null,
read_only: true, has_more: false, decision: { allowed: true }, items: folder.endsWith("/nested")
? [{ kind: "file", name: "remote-letter.pdf", path: "source/nested/remote-letter.pdf", metadata: {}, size_bytes: 42 }]
: [{ kind: "folder", name: "nested", path: "source/nested", metadata: {} }]
} });
} else {
forbidden.push(`${request.method()} ${path}`);
await route.fulfill({ status: 404, json: { detail: "Unconfigured fixture read" } });
}
});
return { reads, forbidden, state };
}
const header = (page: Page) => page.locator('[data-interface-id="files.workspace.actions"]');
const archiveRow = (page: Page) => page.locator(".file-row").filter({ hasText: "small-archive.zip" });
test("Files global Reload, Create folder and primary Upload remain above both workspace panes", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
const fixture = await fixtures(page);
await page.goto("/?files-toolbar&language=en");
await expect(archiveRow(page)).toBeVisible();
const toolbar = header(page);
await expect(toolbar).toHaveAttribute("data-workspace-action-scope", "workspace");
await expect(toolbar.getByRole("link", { name: "Open user documentation", exact: true })).toBeVisible();
await expect(toolbar.locator('[data-page-action-group="trailing"] button')).toHaveText(["Reload", /Create Folder/, /Upload/]);
const [bar, reload, create, upload, tree] = await Promise.all([
toolbar.boundingBox(), toolbar.getByRole("button", { name: "Reload", exact: true }).boundingBox(),
toolbar.getByRole("button", { name: "Create Folder", exact: true }).boundingBox(),
toolbar.getByRole("button", { name: "Upload", exact: true }).boundingBox(), page.locator(".file-tree-panel").boundingBox()
]);
expect(bar && reload && create && upload && tree).toBeTruthy();
expect(reload!.x + reload!.width).toBeLessThanOrEqual(create!.x);
expect(create!.x + create!.width).toBeLessThanOrEqual(upload!.x);
expect(upload!.x + upload!.width).toBeGreaterThan(bar!.x + bar!.width - 24);
expect(bar!.y + bar!.height).toBeLessThanOrEqual(tree!.y + 1);
await expect(toolbar.getByRole("button", { name: "Upload", exact: true })).toHaveClass(/primary/);
await expect(page.locator('.file-list-sticky [data-workspace-action-scope="workspace"]')).toHaveCount(0);
expect(fixture.forbidden).toEqual([]);
});
test("Files selection tools preserve organization, access and confirmed destructive actions", async ({ page }) => {
const fixture = await fixtures(page);
await page.goto("/?files-toolbar&language=en");
await archiveRow(page).click();
await expect(page.getByRole("button", { name: "Unpack archive", exact: true })).toBeEnabled();
await page.getByRole("button", { name: "Manage selection", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Manage selection", exact: true });
await expect(dialog).toContainText("1 file");
for (const name of ["Move", "Copy", "Rename", "Manage shares", "Explain access"]) {
await expect(dialog.getByRole("button", { name, exact: true })).toBeEnabled();
}
const remove = dialog.locator('[data-page-action-separation="destructive"]').getByRole("button", { name: "Delete", exact: true });
await remove.click();
await expect(dialog).toHaveCount(0);
await expect(page.getByRole("alertdialog")).toContainText(/delete|Delete/);
await page.getByRole("alertdialog").getByRole("button", { name: "Cancel", exact: true }).click();
await archiveRow(page).click({ button: "right" });
await expect(page.getByRole("menuitem", { name: "Unpack archive", exact: true })).toBeVisible();
expect(fixture.forbidden).toEqual([]);
});
test("Files Connections and imports exposes explicit tools without starting synchronization", async ({ page }) => {
const fixture = await fixtures(page);
await page.goto("/?files-toolbar&language=en");
await expect(archiveRow(page)).toBeVisible();
const initialReads = fixture.reads.length;
await header(page).getByRole("button", { name: "Connections and imports", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Connections and imports", exact: true });
await expect(dialog).toContainText("never synchronizes or imports");
await expect(dialog.getByRole("button", { name: "Sync", exact: true })).toBeEnabled();
await dialog.getByRole("button", { name: "Add Space", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Add connector space", exact: true })).toBeVisible();
expect(fixture.reads.slice(initialReads).every((read) => read.startsWith("GET "))).toBe(true);
expect(fixture.forbidden).toEqual([]);
});
test("Files Reload preserves active pattern and property filters and retains loaded data after failure", async ({ page }) => {
const fixture = await fixtures(page);
await page.goto("/?files-toolbar&language=en");
await expect(archiveRow(page)).toBeVisible();
await page.locator(".file-search-row input:not([type=checkbox])").fill("*.zip");
await page.locator(".file-search-row").getByRole("button", { name: "Search", exact: true }).click();
await page.getByRole("combobox", { name: "Campaign use", exact: true }).selectOption("linked");
await page.getByRole("button", { name: "Apply filters", exact: true }).click();
await expect(page.getByRole("button", { name: "Clear filters", exact: true })).toBeVisible();
const before = fixture.reads.length;
await header(page).getByRole("button", { name: "Reload", exact: true }).click();
await expect(header(page).getByRole("button", { name: "Reload", exact: true })).toBeEnabled();
await expect.poll(() => fixture.reads.slice(before).some((read) => read.startsWith("POST /api/v1/files/resolve-patterns"))).toBe(true);
await expect.poll(() => fixture.reads.slice(before).some((read) => read.includes("campaign_usage=linked"))).toBe(true);
await expect(header(page).getByRole("button", { name: "Reload", exact: true })).toBeEnabled();
await expect(page.locator(".file-search-row input:not([type=checkbox])")).toHaveValue("*.zip");
await expect(page.getByRole("combobox", { name: "Campaign use", exact: true })).toHaveValue("linked");
fixture.state.fail = true;
await header(page).getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByText(/Fixture listing temporarily unavailable/)).toBeVisible();
await expect(archiveRow(page)).toBeVisible();
await expect(page.getByRole("button", { name: "Clear filters", exact: true })).toBeVisible();
expect(fixture.forbidden).toEqual([]);
});
test("Files reader keeps creation visible with permission reasons and cannot use selected write tools", async ({ page }) => {
const fixture = await fixtures(page);
await page.goto("/?files-toolbar&read-only&language=en");
await expect(archiveRow(page)).toBeVisible();
const upload = header(page).getByRole("button", { name: "Upload", exact: true });
await expect(upload).toBeDisabled();
await header(page).locator(".disabled-action-tooltip").filter({ has: page.getByRole("button", { name: "Upload", exact: true }) }).focus();
await expect(page.getByRole("tooltip")).toContainText(/upload permission/i);
await expect(header(page).getByRole("button", { name: "Create Folder", exact: true })).toBeDisabled();
await archiveRow(page).click();
await expect(page.getByRole("button", { name: "Unpack archive", exact: true })).toBeDisabled();
await page.getByRole("button", { name: "Manage selection", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Manage selection", exact: true });
for (const name of ["Move", "Copy", "Rename", "Manage shares", "Explain access", "Delete"]) {
await expect(dialog.getByRole("button", { name, exact: true })).toBeDisabled();
}
expect(fixture.forbidden).toEqual([]);
});
test("Files connector Reload only re-browses the selected remote folder and retains it on failure", async ({ page }) => {
const fixture = await fixtures(page, true);
await page.goto("/?files-toolbar&language=en");
await expect(archiveRow(page)).toBeVisible();
await page.locator(".file-tree-root").filter({ hasText: "Remote archive" }).click();
await page.locator(".file-list-panel").getByText("nested", { exact: true }).dblclick();
await expect(page.getByText("remote-letter.pdf", { exact: true })).toBeVisible();
const before = fixture.reads.length;
await header(page).getByRole("button", { name: "Reload", exact: true }).click();
await expect(header(page).getByRole("button", { name: "Reload", exact: true })).toBeEnabled();
await expect.poll(() => fixture.reads.slice(before)).toEqual(["GET /api/v1/files/connectors/profiles/fixture-profile/browse?path=source%2Fnested"]);
await expect(header(page).getByRole("button", { name: "Reload", exact: true })).toBeEnabled();
fixture.state.fail = true;
await header(page).getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByText(/Fixture listing temporarily unavailable/)).toBeVisible();
await expect(page.getByText("remote-letter.pdf", { exact: true })).toBeVisible();
expect(fixture.forbidden).toEqual([]);
});
test("Files grouped tools and creation remain reachable without horizontal overflow on narrow German screens", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const fixture = await fixtures(page);
await page.goto("/?files-toolbar&language=de");
await expect(archiveRow(page)).toBeVisible();
await expect(header(page).getByRole("button", { name: "Hochladen", exact: true })).toBeVisible();
await header(page).getByRole("button", { name: "Verbindungen und Importe", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Verbindungen und Importe", exact: true });
await expect(dialog).toBeVisible();
const overflow = await dialog.evaluate((element) => element.scrollWidth - element.clientWidth);
expect(overflow).toBeLessThanOrEqual(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
expect(fixture.forbidden).toEqual([]);
});
@@ -0,0 +1,49 @@
import { expect, test } from "@playwright/test";
for (const width of [1440, 900, 390]) {
test(`mixed form controls and attachment actions remain aligned at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 1200 });
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.route((url) => url.pathname.startsWith("/api/"), (route) => route.abort());
await page.goto("/?form-control-layout&language=en");
const grid = page.getByTestId("mixed-form-grid");
const password = grid.locator('input[type="password"]');
const remove = grid.locator(".toggle-switch-row").filter({ hasText: "Remove configured secret" });
await expect(remove).toBeVisible();
if (width > 1100) {
for (const [input, toggle] of [
[password, remove.locator(".toggle-switch-track")],
[grid.getByRole("textbox", { name: "Wrapped field", exact: true }), grid.locator(".content-grid-item .toggle-switch-track")],
[page.getByRole("textbox", { name: "Other setting", exact: true }), page.getByTestId("mixed-form-layout").locator(".toggle-switch-track")]
]) {
await expect.poll(async () => {
const a = await input.boundingBox(), b = await toggle.boundingBox();
return a && b ? Math.abs(a.y + a.height / 2 - b.y - b.height / 2) : Infinity;
}).toBeLessThan(2);
}
const long = await grid.getByRole("textbox", { name: "Long label field" }).boundingBox();
const short = await grid.getByRole("textbox", { name: "Short label field" }).boundingBox();
expect(Math.abs(long!.y - short!.y)).toBeLessThan(2);
} else {
const field = await password.boundingBox(), toggle = await remove.boundingBox();
expect(toggle!.y).toBeGreaterThan(field!.y + field!.height);
expect(toggle!.height).toBeLessThanOrEqual(64);
}
await remove.getByRole("checkbox").focus();
await page.keyboard.press("Space");
await expect(remove.getByRole("checkbox")).toBeChecked();
await password.fill("fixture-only");
await expect(remove.getByRole("checkbox")).toBeDisabled();
await expect(page.getByTestId("compact-action").getByRole("button")).toHaveCSS("width", "36px");
const attachments = page.getByTestId("global-attachments");
const add = attachments.getByRole("button", { name: "Add first attachment", exact: true });
await expect(add).toHaveCSS("width", "36px");
await expect.poll(() => attachments.locator(".data-grid-empty-action-cell").evaluate((node) => node.getBoundingClientRect().width)).toBeLessThanOrEqual(200);
await add.click();
await expect(attachments.locator(".data-grid-empty-action-cell")).toHaveCount(0);
await expect(attachments.getByRole("button", { name: "Add attachment below", exact: true })).toHaveCSS("width", "36px");
await expect.poll(() => page.locator("main").evaluate((node) => node.scrollWidth <= node.clientWidth + 1)).toBe(true);
expect(errors).toEqual([]);
});
}
@@ -0,0 +1,72 @@
import { expect, test, type Page } from "@playwright/test";
async function installHelp(page: Page, language = "en") {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.route("**/api/v1/docs/context?**", (route) => {
const topic = (id: string, title: string, metadata = {}) => ({
id, title, source_module_id: "forms", kind: "reference", anchor_id: `topic-${id}`, summary: "Application guidance", body: "Public service application instructions", order: 1,
active: true, layer: "configured", target_layer: "configured", links: [], unlocks: [], related_modules: [], area_module_ids: ["forms", "services"],
metadata: { tags: ["Application", "Antrag"], ...metadata }, blockers: { modules: [], capabilities: [], scopes: [], configuration: [] }
});
return route.fulfill({ json: {
actor: { documentation_type: new URL(route.request().url()).searchParams.get("type") ?? "user", available_documentation_types: ["user", "admin"] },
versions: { supported_versions: [], installed_versions: {}, fallback_policy: "Installed versions" },
topic_groups: { system: [], pattern: [], workflow: [], reference: [topic("service", "Service application"), topic("field", "Application field", { parent_topic_id: "service" })] },
layers: { configured: { modules: [{ id: "forms", name: language === "de" ? "Formulare" : "Forms" }, { id: "services", name: language === "de" ? "Leistungen" : "Services" }], routes: [], permissions: [] },
available: { routes: [] }, evidence: { optional_modules: [], sources: [] } }
} });
});
await page.goto(`/?help-center&language=${language}`);
await expect(page.locator(".docs-tree-page").filter({ hasText: "Service application" }).first()).toBeVisible();
return errors;
}
test("help keeps repeated topic selection and expansion local to the clicked occurrence", async ({ page }) => {
const errors = await installHelp(page);
const area = page.locator(".docs-tree-node").filter({ has: page.locator(":scope > .docs-tree-row > .docs-tree-page", { hasText: /^Services$/ }) }).first();
await area.locator(":scope > .docs-tree-row > .docs-tree-toggle").click();
const repeated = area.locator(".docs-tree-node").filter({ has: page.locator(":scope > .docs-tree-row > .docs-tree-page", { hasText: /^Service application$/ }) }).first();
await repeated.locator(":scope > .docs-tree-row > .docs-tree-page").click();
await expect(page.locator(".docs-tree-page.is-active")).toHaveCount(1);
await expect(repeated.locator(":scope > .docs-tree-row > .docs-tree-page")).toHaveClass(/is-active/);
await repeated.locator(":scope > .docs-tree-row > .docs-tree-toggle").click();
await expect(page.locator(".docs-tree-page").filter({ hasText: /^Application field$/ })).toHaveCount(1);
await page.reload();
await expect(area.locator(".docs-tree-page.is-active")).toHaveText("Service application");
await expect(page.locator(".docs-tree-page.is-active")).toHaveCount(1);
expect(errors).toEqual([]);
});
for (const language of ["en", "de"]) test(`help finds topics from tags and localized area names (${language})`, async ({ page }) => {
const errors = await installHelp(page, language);
const search = page.getByRole("searchbox");
await search.fill(language === "de" ? "leistungen antrag" : "services application");
const results = page.getByRole("region", { name: language === "de" ? "Passende Hilfethemen" : "Matching help topics" });
await expect(results.getByRole("option", { name: "Service application", exact: true })).toHaveCount(1);
await expect(results.getByRole("option", { name: "Application field", exact: true })).toHaveCount(1);
await search.fill("not-found-tag");
await expect(results.getByRole("option")).toHaveCount(0);
await search.fill("antrag");
await results.getByRole("option", { name: "Service application", exact: true }).click();
await expect(search).toHaveValue("");
await expect(page.locator(".docs-page-main h2")).toHaveText("Service application");
await expect(page.locator(".docs-tree-page.is-active")).toHaveCount(1);
expect(errors).toEqual([]);
});
test("help tag dropdown shares list filtering and all/none behavior", async ({ page }) => {
const errors = await installHelp(page);
await page.getByRole("button", { name: "Areas and tags", exact: true }).click();
const filter = page.getByRole("dialog", { name: "Areas and tags", exact: true });
await filter.getByRole("button", { name: /clear all|deselect all|select none/i }).click();
const results = page.getByRole("region", { name: "Matching help topics" });
await expect(results.getByRole("option")).toHaveCount(0);
await filter.getByRole("checkbox", { name: "Services", exact: true }).check();
await expect(results.getByRole("option")).toHaveCount(2);
await filter.getByRole("button", { name: "Select all", exact: true }).click();
await expect(results).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(filter).toHaveCount(0);
expect(errors).toEqual([]);
});
@@ -0,0 +1,215 @@
import { expect, test, type Page } from "@playwright/test";
type Policy = {
smtp_credentials?: { inherit?: boolean | null };
imap_credentials?: { inherit?: boolean | null };
allow_lower_level_limits?: Record<string, boolean>;
[key: string]: unknown;
};
async function installPolicy(page: Page, options: { parent?: Policy | null; local?: Policy; failFirst?: boolean; failReadOnce?: boolean; holdSave?: Promise<void> } = {}) {
let current = options.local ?? {};
const parent = options.parent === null ? null : { smtp_credentials: { inherit: false }, imap_credentials: { inherit: true }, ...options.parent };
const writes: Policy[] = [];
const unexpected: string[] = [];
let reads = 0;
function response() {
const effective: Policy = { allow_user_profiles: true, allow_group_profiles: true, allow_campaign_profiles: true, ...parent, ...current };
for (const protocol of ["smtp", "imap"] as const) {
const key = `${protocol}_credentials` as const;
const locked = parent?.allow_lower_level_limits?.[`${key}.inherit`] === false;
effective[key] = { inherit: (!locked ? current[key]?.inherit : null) ?? parent?.[key]?.inherit ?? true };
}
return { policy: current, parent_policy: parent, effective_policy: effective, effective_policy_sources: [
{ scope_type: "system", label: "System", path: "system", applied_fields: [], policy: parent ?? {} },
{ scope_type: "tenant", label: "Tenant", path: "tenant", applied_fields: [], policy: current }
] };
}
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
if (new URL(request.url()).pathname.startsWith("/api/v1/mail/policies/")) {
if (request.method() === "GET") {
reads += 1;
if (options.failReadOnce && reads === 1) {
await route.fulfill({ status: 503, json: { detail: "Synthetic policy load failed" } }); return;
}
await route.fulfill({ json: response() }); return;
}
if (request.method() === "PUT") {
const submitted = request.postDataJSON().policy as Policy;
writes.push(submitted);
if (options.holdSave) await options.holdSave;
if (options.failFirst && writes.length === 1) {
await route.fulfill({ status: 503, json: { detail: "Synthetic policy write failed" } }); return;
}
current = submitted;
await route.fulfill({ json: response() }); return;
}
}
unexpected.push(`${request.method()} ${new URL(request.url()).pathname}`);
await route.fulfill({ status: 403, json: { detail: "Unexpected fixture request" } });
});
return { writes, unexpected, get reads() { return reads; } };
}
function credentialRows(page: Page) {
const section = page.getByTestId("mail-credential-policy");
return {
section,
smtp: section.locator(".policy-row").filter({ has: page.locator('select[aria-label="SMTP credential selection"]') }),
imap: section.locator(".policy-row").filter({ has: page.locator('select[aria-label="IMAP credential selection"]') })
};
}
test("Mail credential policy exposes inherited/effective choices and persists stable override keys", async ({ page }) => {
page.on("pageerror", (error) => { throw error; });
const fixture = await installPolicy(page);
await page.goto("/?mail-credential-policy&language=en");
const { smtp, imap } = credentialRows(page);
await expect(smtp.locator("select")).toHaveValue("inherit");
await expect(smtp.locator(".policy-effective-value")).toContainText("Require explicit Mail credential");
// An inherited false does not itself lock the child: only the separate
// lower-level limit may prevent choosing an inherited default credential.
await smtp.locator("select").selectOption("profile");
await imap.locator("select").selectOption("explicit");
await smtp.getByRole("checkbox", { name: "Allow override" }).focus();
await page.keyboard.press("Space");
const wildcard = page.locator(".mail-policy-pattern-row").filter({ hasText: "SMTP hostnames" });
await wildcard.getByRole("checkbox", { name: "Whitelist", exact: true }).focus();
await page.keyboard.press("Space");
const campaignRow = page.locator(".mail-policy-row").filter({ hasText: "Campaign-scoped profiles" });
await campaignRow.getByRole("checkbox", { name: "Allow override" }).focus();
await page.keyboard.press("Space");
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].smtp_credentials).toEqual({ inherit: true });
expect(fixture.writes[0].imap_credentials).toEqual({ inherit: false });
expect(fixture.writes[0].allow_lower_level_limits).toMatchObject({
"smtp_credentials.inherit": false, "whitelist.smtp_hosts": false, "allow_campaign_profiles": false
});
expect(Object.keys(fixture.writes[0].allow_lower_level_limits ?? {}).some((key) => key.startsWith("i18n:"))).toBe(false);
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
expect(fixture.unexpected).toEqual([]);
});
test("ancestor credential locks cannot be bypassed or re-enabled and stale local overrides are cleared on save", async ({ page }) => {
const fixture = await installPolicy(page, {
parent: { allow_lower_level_limits: { "smtp_credentials.inherit": false } },
local: { smtp_credentials: { inherit: true }, allow_lower_level_limits: { "smtp_credentials.inherit": true } }
});
await page.goto("/?mail-credential-policy&language=en");
const { smtp, imap, section } = credentialRows(page);
await expect(smtp.locator("select")).toHaveValue("inherit");
await expect(smtp.locator("select")).toBeDisabled();
await expect(smtp.getByRole("checkbox", { name: "Allow override" })).toBeDisabled();
await expect(smtp.getByRole("checkbox", { name: "Allow override" })).not.toBeChecked();
await expect(section).toContainText("An ancestor has locked credential selection.");
await imap.locator("select").selectOption("explicit");
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].smtp_credentials).toEqual({ inherit: null });
expect(fixture.writes[0].allow_lower_level_limits?.["smtp_credentials.inherit"]).toBeUndefined();
expect(fixture.unexpected).toEqual([]);
});
test("system defaults are concrete and campaign policy has no lower-level override controls", async ({ page }) => {
const fixture = await installPolicy(page, { parent: null });
await page.goto("/?mail-credential-policy&scope=system&language=en");
let { smtp, imap } = credentialRows(page);
await expect(smtp.locator("select")).toHaveValue("profile");
await expect(smtp.locator('option[value="inherit"]')).toHaveCount(0);
await smtp.locator("select").selectOption("explicit");
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].smtp_credentials).toEqual({ inherit: false });
expect(fixture.writes[0].imap_credentials).toEqual({ inherit: true });
expect(fixture.writes[0].allow_lower_level_limits).toHaveProperty("allow_campaign_profiles", true);
await page.goto("/?mail-credential-policy&scope=campaign&language=en");
({ smtp, imap } = credentialRows(page));
await expect(smtp.getByRole("checkbox")).toHaveCount(0);
await expect(imap.getByRole("checkbox")).toHaveCount(0);
await smtp.locator("select").selectOption("profile");
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(2);
expect(fixture.writes[1].allow_lower_level_limits).toEqual({});
expect(fixture.unexpected).toEqual([]);
});
test("read-only and workflow-locked policy scopes cannot change credential selection", async ({ page }) => {
const fixture = await installPolicy(page);
for (const blocker of ["read-only", "locked"]) {
await page.goto(`/?mail-credential-policy&language=en&${blocker}`);
const { smtp, imap } = credentialRows(page);
await expect(smtp.locator("select")).toBeDisabled();
await expect(imap.locator("select")).toBeDisabled();
await expect(smtp.getByRole("checkbox", { name: "Allow override" })).toBeDisabled();
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
}
expect(fixture.writes).toEqual([]);
expect(fixture.unexpected).toEqual([]);
});
test("successful policy saves remain successful when a dependent refresh fails", async ({ page }) => {
const fixture = await installPolicy(page);
await page.goto("/?mail-credential-policy&language=en&refresh-failure");
await credentialRows(page).smtp.locator("select").selectOption("profile");
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect(page.getByRole("alert")).toContainText("Mail policy was saved, but refreshing dependent data failed");
await expect(page.locator(".alert.danger")).toHaveCount(0);
await expect(page.locator(".alert.success")).toContainText("Mail profile policy saved");
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
expect(fixture.writes).toHaveLength(1);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect.poll(() => fixture.reads).toBeGreaterThan(1);
await expect(credentialRows(page).smtp.locator("select")).toHaveValue("profile");
expect(fixture.writes).toHaveLength(1);
expect(fixture.unexpected).toEqual([]);
});
test("failed policy writes retain the draft and require an explicit retry", async ({ page }) => {
let finish!: () => void;
const fixture = await installPolicy(page, { failFirst: true, holdSave: new Promise<void>((resolve) => { finish = resolve; }) });
await page.goto("/?mail-credential-policy&language=en");
const { smtp } = credentialRows(page);
await smtp.locator("select").selectOption("profile");
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
await expect(smtp.locator("select")).toBeDisabled();
finish();
await expect(page.locator(".alert.danger")).toContainText("Synthetic policy write failed");
await expect(smtp.locator("select")).toHaveValue("profile");
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeEnabled();
expect(fixture.writes).toHaveLength(1);
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
expect(fixture.writes).toHaveLength(2);
expect(fixture.writes[1]).toEqual(fixture.writes[0]);
expect(fixture.unexpected).toEqual([]);
});
test("failed policy loads cannot enable editing an unknown ancestor policy", async ({ page }) => {
const fixture = await installPolicy(page, { failReadOnce: true });
await page.goto("/?mail-credential-policy&language=en");
await expect(page.locator(".alert.danger")).toContainText("Synthetic policy load failed");
await expect(credentialRows(page).smtp.locator("select")).toBeDisabled();
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(credentialRows(page).smtp.locator("select")).toBeEnabled();
expect(fixture.writes).toEqual([]);
expect(fixture.unexpected).toEqual([]);
});
test("credential selection choices and explanations are available in German", async ({ page }) => {
const fixture = await installPolicy(page);
await page.goto("/?mail-credential-policy&language=de");
const section = page.getByTestId("mail-credential-policy");
await expect(section.getByRole("heading", { name: "Auswahl der Zugangsdaten", exact: true })).toBeVisible();
const smtp = section.getByRole("combobox", { name: "SMTP-Zugangsdaten auswählen", exact: true });
await expect(smtp).toBeEnabled();
await expect(smtp.locator('option[value="inherit"]')).toHaveText("Richtlinie vom übergeordneten Bereich erben");
await expect(smtp.locator('option[value="profile"]')).toHaveText("Standard-Zugangsdaten des Profils zulassen");
await expect(smtp.locator('option[value="explicit"]')).toHaveText("Ausdrückliche Mail-Zugangsdaten verlangen");
await expect(section).toContainText("Beide Optionen belassen Geheimnisse in Mail");
expect(fixture.writes).toEqual([]);
expect(fixture.unexpected).toEqual([]);
});
@@ -0,0 +1,48 @@
import { expect, test } from "@playwright/test";
test("Mail synthetic parent labels select without opening nonexistent folders or changing expansion", async ({ page }) => {
const errors: string[] = [];
const providerFolders: string[] = [];
const writes: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.route(url => url.pathname.startsWith("/api/"), async route => {
const request = route.request();
const url = new URL(request.url());
if (request.method() !== "GET") writes.push(url.pathname);
let body: unknown = {};
if (url.pathname === "/api/v1/mail/profiles") body = { profiles: [{
id: "tree-profile", name: "Fixture mailbox", is_active: true, scope_type: "tenant",
imap: { enabled: true, host: "imap.example.test", port: 993, security: "ssl", folder_mappings: { inbox: "INBOX" } }
}] };
else if (url.pathname.endsWith("/mailbox/bootstrap")) body = {
folder: "INBOX", folders: { ok: true, folders: [{ name: "INBOX", flags: [] }, { name: "Archive/2026", flags: [] }] },
messages: { ok: true, folder: "INBOX", messages: [], total_count: 0 }
};
else if (url.pathname.endsWith("/mailbox/messages")) {
providerFolders.push(url.searchParams.get("folder") ?? "");
body = { ok: true, folder: url.searchParams.get("folder"), messages: [], total_count: 0 };
}
return route.fulfill({ json: body });
});
await page.goto("/?mail-folder-explorer&language=en&theme=light");
const archive = page.locator('.explorer-tree-node').filter({ hasText: /^Archive$/ });
const archiveRow = page.locator('.explorer-tree-node-wrap').filter({ has: archive });
const toggle = archiveRow.locator(':scope > .explorer-tree-toggle');
await expect(archive).toBeEnabled();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
const initialReads = providerFolders.length;
await archive.click();
await expect(archive).toHaveAttribute("aria-current", "true");
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(page.getByText("This grouping contains mailbox folders.", { exact: false })).toBeVisible();
expect(providerFolders).toHaveLength(initialReads);
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await archive.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await page.locator('.explorer-tree-node').filter({ hasText: /^2026$/ }).click();
await expect.poll(() => providerFolders.includes("Archive/2026")).toBe(true);
expect(providerFolders).not.toContain("Archive");
expect(errors).toEqual([]);
expect(writes).toEqual([]);
});
@@ -0,0 +1,424 @@
import { expect, test, type Page, type Route } from "@playwright/test";
function deferred() {
let release!: () => void;
const promise = new Promise<void>(resolve => { release = resolve; });
return { promise, release };
}
function profile(id: string, protocol = "imap") {
return {
id, name: `${id} mailbox`, is_active: true, scope_type: "tenant",
...(protocol === "imap" ? { imap: { enabled: true, host: "imap.example.test", port: 993, security: "ssl", folder_mappings: { inbox: "INBOX" } } }
: { servers: [{ id: `${id}-jmap`, protocol: "jmap", is_active: true, is_default: true, config: { session_url: "https://jmap.example.test/session" } }] })
};
}
async function mockMailbox(page: Page) {
const state = {
profiles: [profile("Alpha"), profile("Beta")],
reads: [] as URL[], writes: [] as string[], errors: [] as string[],
failProfiles: false, failBootstrap: false, failDetail: false,
intercept: null as null | ((url: URL, route: Route) => Promise<boolean>),
releasedReads: 0
};
page.on("pageerror", error => state.errors.push(error.message));
await page.route(url => url.pathname.startsWith("/api/"), async route => {
const request = route.request();
const url = new URL(request.url());
if (request.method() !== "GET") {
state.writes.push(url.pathname);
return route.fulfill({ status: 405, json: { detail: "Fixture forbids mailbox writes" } });
}
state.reads.push(url);
if (state.intercept && await state.intercept(url, route)) return;
let body: unknown = {};
if (url.pathname === "/api/v1/mail/profiles") {
if (state.failProfiles) return route.fulfill({ status: 503, json: { detail: "Mailbox profile refresh unavailable" } });
body = { profiles: state.profiles };
} else if (url.pathname.endsWith("/mailbox/bootstrap")) {
if (state.failBootstrap) return route.fulfill({ status: 503, json: { detail: "Mailbox refresh unavailable" } });
body = bootstrap(url);
} else if (url.pathname.endsWith("/mailbox/folders")) body = folderCatalogue();
else if (url.pathname.endsWith("/mailbox/messages")) body = messageIndex(url);
else if (/\/mailbox\/messages\/[^/]+$/.test(url.pathname)) {
if (state.failDetail) return route.fulfill({ status: 503, json: { detail: "Message preview refresh unavailable" } });
body = messageDetail(url);
} else if (url.pathname.endsWith("/address-write-targets")) body = { available: false, targets: [] };
return route.fulfill({ json: body });
});
return state;
}
function folderCatalogue() {
return { ok: true, folders: [{ name: "INBOX", flags: [] }, { name: "Archive/2026", flags: [] }] };
}
function messageIndex(url: URL) {
const id = url.pathname.split("/")[5];
const folder = url.searchParams.get("folder") || "INBOX";
const offset = Number(url.searchParams.get("offset") ?? 0);
const limit = Number(url.searchParams.get("limit") ?? 10);
return {
profile_id: id, folder, total_count: 24, offset, limit,
next_cursor: "next-fixture-cursor", from_cache: false,
messages: Array.from({ length: Math.max(0, Math.min(limit, 24 - offset)) }, (_, index) => ({
uid: String(offset + index + 1), folder, subject: `${id} message ${offset + index + 1}`,
from_header: "Fixture sender <fixture@example.test>", to_header: "Reader <reader@example.test>",
flags: [], size_bytes: 1024, date: "2026-09-01T12:00:00Z"
}))
};
}
function bootstrap(url: URL) {
const messages = messageIndex(url);
return { profile_id: messages.profile_id, folder: messages.folder, folders: folderCatalogue(), messages };
}
function messageDetail(url: URL) {
const parts = url.pathname.split("/");
const uid = parts[parts.length - 1];
const summary = messageIndex(new URL(url.href.replace(/offset=[^&]*/, "offset=0"))).messages[0];
return { message: { ...summary, uid, subject: `${url.pathname.split("/")[5]} message ${uid}`, body_text: `Fixture body ${url.pathname.split("/")[5]} ${uid}`, headers: {}, attachments: [] } };
}
const workspaceBar = (page: Page) => page.locator('[data-workspace-action-scope="workspace"]');
const reload = (page: Page) => workspaceBar(page).locator('[data-page-action-slot="reload"] button');
const mailboxRows = (page: Page) => page.locator(".mailbox-message-row");
const mainMailboxReads = (reads: URL[]) => reads.filter(url => /\/mail\/profiles(?:$|\/[^/]+\/mailbox\/)/.test(url.pathname));
test("Mail has one persistent right-aligned Reload, including empty profiles, and recovers when a profile becomes available", async ({ page }) => {
const state = await mockMailbox(page);
state.profiles = [];
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(reload(page)).toBeEnabled();
await expect(workspaceBar(page)).toHaveCount(1);
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toBeDisabled();
await expect(page.getByRole("button", { name: "Mailbox tools", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: /Refresh (available profiles|folders only|messages only)/ })).toHaveCount(0);
const [barBox, reloadBox] = await Promise.all([workspaceBar(page).boundingBox(), reload(page).boundingBox()]);
expect(reloadBox!.x).toBeGreaterThan(barBox!.x + barBox!.width / 2);
expect(barBox!.x + barBox!.width - reloadBox!.x - reloadBox!.width).toBeLessThanOrEqual(20);
expect(barBox!.y).toBeLessThan(20);
state.profiles = [profile("Alpha")];
await reload(page).click();
await expect(mailboxRows(page)).toHaveCount(10);
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toHaveValue("Alpha");
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail Reload coherently refreshes catalogue, current IMAP page and selected preview without resetting expansion", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page)).toHaveCount(10);
expect((await page.locator(".mailbox-message-scroll").boundingBox())!.height).toBeGreaterThan(90);
await page.getByRole("button", { name: "Next page", exact: true }).click();
await expect(mailboxRows(page).first()).toContainText("Alpha message 11");
await mailboxRows(page).first().click();
await expect(page.getByText("Fixture body Alpha 11", { exact: true })).toBeVisible();
const group = page.locator(".explorer-tree-node-wrap").filter({ has: page.locator(".explorer-tree-node").filter({ hasText: /^Archive$/ }) });
const toggle = group.locator(":scope > .explorer-tree-toggle");
await toggle.click();
const before = state.reads.length;
await reload(page).click();
await expect(reload(page)).toBeEnabled();
await expect(mailboxRows(page).first()).toContainText("Alpha message 11");
await expect(mailboxRows(page).first()).toHaveClass(/is-selected/);
await expect(page.getByText("Fixture body Alpha 11", { exact: true })).toBeVisible();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
const reads = mainMailboxReads(state.reads.slice(before));
expect(reads.map(url => url.pathname)).toEqual(["/api/v1/mail/profiles", "/api/v1/mail/profiles/Alpha/mailbox/bootstrap", "/api/v1/mail/profiles/Alpha/mailbox/messages/11"]);
expect(reads[1].searchParams.get("offset")).toBe("10");
expect(reads[1].searchParams.get("refresh")).toBe("true");
expect(reads[1].searchParams.get("folder")).toBe("INBOX");
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail refresh failures preserve loaded index and preview, disclose failure, and leave recovery available", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page)).toHaveCount(10);
await mailboxRows(page).first().click();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
for (const failure of ["failProfiles", "failBootstrap", "failDetail"] as const) {
state[failure] = true;
await reload(page).click();
await expect(reload(page)).toBeEnabled();
await expect(workspaceBar(page)).toHaveAttribute("data-page-refresh-state", "reload-failed");
await expect(mailboxRows(page)).toHaveCount(10);
await expect(mailboxRows(page).first()).toHaveClass(/is-selected/);
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
state[failure] = false;
await reload(page).click();
await expect(workspaceBar(page)).toHaveAttribute("data-page-refresh-state", "current");
await expect(reload(page)).toBeEnabled();
}
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail grouping Reload refreshes only profiles and folders, preserving synthetic selection and collapsed state", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
const group = page.locator(".explorer-tree-node").filter({ hasText: /^Archive$/ });
await expect(group).toBeEnabled();
await group.click();
const before = state.reads.length;
await reload(page).click();
await expect(reload(page)).toBeEnabled();
await expect(group).toHaveAttribute("aria-current", "true");
await expect(page.locator(".explorer-tree-node-wrap").filter({ has: group }).locator(":scope > .explorer-tree-toggle")).toHaveAttribute("aria-expanded", "false");
await expect(mailboxRows(page)).toHaveCount(0);
expect(mainMailboxReads(state.reads.slice(before)).map(url => url.pathname)).toEqual(["/api/v1/mail/profiles", "/api/v1/mail/profiles/Alpha/mailbox/folders"]);
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail Escape while Reload is pending does not reopen the dismissed preview", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page)).toHaveCount(10);
await mailboxRows(page).first().click();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
const held = deferred();
let started = false;
state.intercept = async (url, route) => {
if (url.pathname.endsWith("/mailbox/bootstrap") && url.searchParams.get("refresh")) {
started = true;
await held.promise;
await route.fulfill({ json: bootstrap(url) });
return true;
}
return false;
};
const before = state.reads.length;
await reload(page).click();
await expect.poll(() => started).toBe(true);
await page.keyboard.press("Escape");
held.release();
await expect(reload(page)).toBeEnabled();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toHaveCount(0);
await expect(page.locator(".mailbox-message-row.is-selected")).toHaveCount(0);
expect(state.reads.slice(before).filter(url => /\/mailbox\/messages\/[^/]+$/.test(url.pathname))).toHaveLength(0);
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail failed pagination keeps retained rows labelled with their committed page and size", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page)).toHaveCount(10);
state.intercept = async (url, route) => {
if (url.pathname.endsWith("/mailbox/messages")) {
await route.fulfill({ status: 503, json: { detail: "Mailbox page unavailable" } });
return true;
}
return false;
};
const before = state.reads.length;
await page.getByRole("button", { name: "Next page", exact: true }).click();
await expect(reload(page)).toBeEnabled();
await expect(workspaceBar(page)).toHaveAttribute("data-page-refresh-state", "reload-failed");
await expect(page.locator(".data-grid-page-controls")).toContainText("Page 1 of 3");
await expect(mailboxRows(page).first()).toContainText("Alpha message 1");
expect(mainMailboxReads(state.reads.slice(before))).toHaveLength(1);
await page.getByRole("combobox", { name: "Rows per page" }).selectOption("25");
await expect(reload(page)).toBeEnabled();
await expect(page.getByRole("combobox", { name: "Rows per page" })).toHaveValue("10");
await expect(page.locator(".data-grid-page-controls")).toContainText("Page 1 of 3");
await expect(mailboxRows(page)).toHaveCount(10);
expect(mainMailboxReads(state.reads.slice(before))).toHaveLength(2);
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail Reload reauthorizes profile availability and never keeps a removed account selected", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page).first()).toContainText("Alpha message 1");
state.profiles = [profile("Beta")];
await reload(page).click();
await expect(mailboxRows(page).first()).toContainText("Beta message 1");
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toHaveValue("Beta");
await expect(page.getByRole("option", { name: "Alpha mailbox" })).toHaveCount(0);
state.profiles = [];
await reload(page).click();
await expect(reload(page)).toBeEnabled();
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toBeDisabled();
await expect(mailboxRows(page)).toHaveCount(0);
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail JMAP Reload restarts its cursor chain but retains the server-side search", async ({ page }) => {
const state = await mockMailbox(page);
state.profiles = [profile("Alpha", "jmap")];
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page)).toHaveCount(10);
await page.getByPlaceholder("Search messages").fill("message");
await expect.poll(() => state.reads.some(url => url.searchParams.get("q") === "message")).toBe(true);
await expect(reload(page)).toBeEnabled();
await page.getByRole("button", { name: "Next page", exact: true }).click();
await expect(mailboxRows(page).first()).toContainText("Alpha message 11");
const before = state.reads.length;
await reload(page).click();
await expect(reload(page)).toBeEnabled();
await expect(page.getByPlaceholder("Search messages")).toHaveValue("message");
await expect(page.locator(".data-grid-page-controls")).toContainText("Page 1 of 3");
await expect(mailboxRows(page).first()).toContainText("Alpha message 1");
const reads = mainMailboxReads(state.reads.slice(before));
expect(reads.map(url => url.pathname)).toEqual(["/api/v1/mail/profiles", "/api/v1/mail/profiles/Alpha/mailbox/bootstrap", "/api/v1/mail/profiles/Alpha/mailbox/messages"]);
expect(reads[2].searchParams.get("protocol")).toBe("jmap");
expect(reads[2].searchParams.get("q")).toBe("message");
expect(reads[2].searchParams.has("cursor")).toBe(false);
expect(reads[2].searchParams.get("offset")).toBe("0");
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail profile switches discard late bootstrap and preview responses instead of showing a previous account", async ({ page }) => {
const state = await mockMailbox(page);
const heldBootstrap = deferred();
let started = false;
state.intercept = async (url, route) => {
if (url.pathname === "/api/v1/mail/profiles/Alpha/mailbox/bootstrap") {
started = true;
await heldBootstrap.promise;
await route.fulfill({ json: bootstrap(url) });
state.releasedReads += 1;
return true;
}
return false;
};
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect.poll(() => started).toBe(true);
await page.getByRole("combobox", { name: "Mailbox profile" }).selectOption("Beta");
await expect(mailboxRows(page).first()).toContainText("Beta message 1");
heldBootstrap.release();
await expect.poll(() => state.releasedReads).toBeGreaterThan(0);
await expect(mailboxRows(page).first()).toContainText("Beta message 1");
const heldPreview = deferred();
let previewStarted = false;
state.intercept = async (url, route) => {
if (url.pathname === "/api/v1/mail/profiles/Beta/mailbox/messages/1") {
previewStarted = true;
await heldPreview.promise;
await route.fulfill({ json: messageDetail(url) });
state.releasedReads += 1;
return true;
}
return false;
};
await mailboxRows(page).first().click();
await expect.poll(() => previewStarted).toBe(true);
await page.getByRole("combobox", { name: "Mailbox profile" }).selectOption("Alpha");
await expect(mailboxRows(page).first()).toContainText("Alpha message 1");
await mailboxRows(page).first().click();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
const released = state.releasedReads;
heldPreview.release();
await expect.poll(() => state.releasedReads).toBeGreaterThan(released);
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
await expect(page.getByText("Fixture body Beta 1", { exact: true })).toHaveCount(0);
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail tools keeps advanced reads scoped, bounce permission explained, and Escape leaves selection intact", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page)).toHaveCount(10);
await mailboxRows(page).first().click();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
const tools = page.getByRole("button", { name: "Mailbox tools", exact: true });
await tools.click();
const dialog = page.getByRole("dialog", { name: "Mailbox tools" });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole("button", { name: "Bounce status", exact: true })).toBeDisabled();
for (let index = 0; index < 10; index += 1) {
await page.keyboard.press("Tab");
expect(await dialog.evaluate(element => element.contains(document.activeElement))).toBe(true);
}
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
await expect(tools).toBeFocused();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
for (const [action, expectedPath] of [
["Refresh available profiles", "/api/v1/mail/profiles"],
["Refresh folders only", "/api/v1/mail/profiles/Alpha/mailbox/folders"],
["Refresh messages only", "/api/v1/mail/profiles/Alpha/mailbox/messages"]
]) {
await tools.click();
const before = state.reads.length;
await dialog.getByRole("button", { name: action, exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(reload(page)).toBeEnabled();
expect(mainMailboxReads(state.reads.slice(before)).map(url => url.pathname)).toEqual([expectedPath]);
await expect(mailboxRows(page).first()).toHaveClass(/is-selected/);
}
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail authority switch invalidates a previous tenant's pending profile catalogue", async ({ page }) => {
const state = await mockMailbox(page);
const held = deferred();
let started = false;
state.intercept = async (url, route) => {
if (url.pathname === "/api/v1/mail/profiles" && !started) {
started = true;
await held.promise;
await route.fulfill({ json: { profiles: [profile("PreviousTenant")] } });
state.releasedReads += 1;
return true;
}
return false;
};
state.profiles = [profile("CurrentTenant")];
await page.goto("/?mail-toolbar&language=en&theme=light&switch-tenant");
await expect.poll(() => started).toBe(true);
await page.getByRole("button", { name: "Switch fixture tenant" }).click();
await expect(mailboxRows(page).first()).toContainText("CurrentTenant message 1");
held.release();
await expect.poll(() => state.releasedReads).toBeGreaterThan(0);
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toHaveValue("CurrentTenant");
await expect(page.getByRole("option", { name: "PreviousTenant mailbox" })).toHaveCount(0);
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail narrow German workspace keeps its sole Reload visible and the grouped tools dialog within the viewport", async ({ page }) => {
const state = await mockMailbox(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/?mail-toolbar&language=de&theme=light&bounce-allowed");
await expect(reload(page)).toBeEnabled();
await expect(reload(page)).toHaveText(/Neu laden/);
await expect(page.locator(".mailbox-message-head")).not.toBeVisible();
expect(await mailboxRows(page).first().evaluate(element => getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(1);
expect((await page.locator(".mailbox-message-scroll").boundingBox())!.height).toBeGreaterThan(90);
await expect(workspaceBar(page).locator('[data-page-action-slot="reload"]')).toHaveCount(1);
const bounds = await reload(page).boundingBox();
expect(bounds!.x).toBeGreaterThanOrEqual(0);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
await mailboxRows(page).first().click();
const preview = page.getByText("Fixture body Alpha 1", { exact: true });
await expect(preview).toBeVisible();
await preview.scrollIntoViewIfNeeded();
await expect(preview).toBeInViewport();
await expect(reload(page)).toBeVisible();
expect((await reload(page).boundingBox())!.y).toBe(bounds!.y);
await page.getByRole("button", { name: "Postfachwerkzeuge", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Postfachwerkzeuge" });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole("button", { name: "Zustellrückläufer", exact: true })).toBeEnabled();
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true);
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
@@ -0,0 +1,296 @@
import { expect, test, type Page } from "@playwright/test";
const sourceFile = {
id: "fixture-archive", tenant_id: "fixture-tenant", owner_type: "user", owner_id: "fixture-user",
display_path: "small-archive.zip", filename: "small-archive.zip", size_bytes: 280,
content_type: "application/zip", checksum_sha256: "a".repeat(64), version_id: "fixture-version-7",
created_at: "2026-01-01T12:00:00Z", updated_at: "2026-01-01T12:00:00Z", audit_relevant: false,
shares: [], metadata: {}, deleted_at: null
};
type RecordedCall = { path: string; body: Record<string, unknown>; contentType: string };
async function installFileFixtures(page: Page, options: { encrypted?: boolean; failPreviewOnce?: boolean; failConfirmation?: boolean; holdPreview?: Promise<void>; holdConfirmation?: Promise<void>; progress?: { current: Record<string, unknown> | null }; staged?: boolean } = {}) {
const calls: RecordedCall[] = [];
const forbiddenRequests: string[] = [];
const importedFiles: Record<string, unknown>[] = [];
const releasedStages: string[] = [];
let previewFailed = false;
// A broad **/api/** glob also matches Vite's /@fs/.../src/api/*.ts.
// Restrict interception to actual API URLs so real module code is rendered.
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname;
if (request.method() === "DELETE" && path.startsWith("/api/v1/files/archive-staging/")) {
releasedStages.push(path.split("/").slice(-1)[0]);
await route.fulfill({ status: 204 });
return;
}
if (request.method() === "GET") {
if (path.startsWith("/api/v1/files/archive-progress/")) {
const operationId = path.split("/").slice(-1)[0];
const confirmation = calls.find((call) => call.path.endsWith("/archive-confirm") && call.body.operation_id === operationId);
if (confirmation && options.progress?.current) await route.fulfill({ json: options.progress.current });
else await route.fulfill({ status: 404, json: { detail: "No measured progress available" } });
return;
}
if (path === "/api/v1/files/spaces") {
await route.fulfill({ json: { spaces: [
{ id: "user:fixture-user", label: "My files", owner_type: "user", owner_id: "fixture-user", space_type: "managed" },
{ id: "group:fixture-team", label: "Team files", owner_type: "group", owner_id: "fixture-team", space_type: "managed" }
] } });
return;
}
const team = url.searchParams.get("owner_id") === "fixture-team";
if (path === "/api/v1/files/folders") {
await route.fulfill({ json: { folders: [{ id: team ? "team-output" : "personal-output", path: "extracted",
owner_type: team ? "group" : "user", owner_id: team ? "fixture-team" : "fixture-user",
name: "extracted", created_at: sourceFile.created_at, updated_at: sourceFile.updated_at }], total: 1, next_cursor: null } });
return;
}
if (path === "/api/v1/files") {
const files = team ? importedFiles : [sourceFile];
await route.fulfill({ json: { files, total: files.length, next_cursor: null } });
return;
}
}
if (request.method() === "POST" && (path.endsWith("/fixture-archive/archive-preview") || path.endsWith("/fixture-archive/archive-confirm") || path === "/api/v1/files/archive-preview" || path === "/api/v1/files/archive-confirm")) {
const contentType = request.headers()["content-type"] ?? "";
let body: Record<string, unknown>;
if (contentType.startsWith("application/json")) body = request.postDataJSON() as Record<string, unknown>;
else {
const form = await new Request(request.url(), { method: "POST", headers: { "content-type": contentType }, body: new Uint8Array(request.postDataBuffer()!) }).formData();
body = Object.fromEntries([...form.entries()].map(([key, value]) => [key, typeof value === "string" ? value : value.name]));
if (typeof body.selected_paths_json === "string") body.selected_paths = JSON.parse(body.selected_paths_json);
}
calls.push({ path, body, contentType: request.headers()["content-type"] ?? "" });
if (path.endsWith("/archive-preview")) {
if (options.holdPreview) await options.holdPreview;
if (options.failPreviewOnce && !previewFailed) {
previewFailed = true;
await route.fulfill({ status: 400, json: { detail: "Managed archive version changed; reload and preview again" } });
return;
}
await route.fulfill({ json: {
preview_token: `fixture-preview-${calls.length}`, archive_format: "zip", file_count: 2, directory_count: 1,
compressed_size_bytes: 280, expanded_size_bytes: 6, expires_at: "2099-01-01T12:00:00Z",
requires_password: Boolean(options.encrypted), password_verified: Boolean(body.password),
...(options.staged ? { staged_upload_id: "fixture-staged-archive" } : {}),
entries: [
{ path: "folder", kind: "directory", size_bytes: 0, encrypted: false },
{ path: "folder/one.txt", kind: "file", size_bytes: 3, compressed_size_bytes: 5, encrypted: Boolean(options.encrypted) },
{ path: "two.txt", kind: "file", size_bytes: 3, compressed_size_bytes: 5, encrypted: Boolean(options.encrypted) }
]
} });
return;
}
if (options.holdConfirmation) await options.holdConfirmation;
if (options.failConfirmation) {
await route.fulfill({ status: 400, json: { detail: "Target file already exists: extracted/folder/one.txt" } });
return;
}
for (const [index, selected] of (body.selected_paths as string[]).entries()) {
importedFiles.push({ ...sourceFile, id: `imported-${index}`, owner_type: body.owner_type, owner_id: body.owner_id,
display_path: `${body.path}/${selected}`, filename: selected.split("/").slice(-1)[0], version_id: `imported-version-${index}` });
}
await route.fulfill({ json: { files: importedFiles } });
return;
}
forbiddenRequests.push(`${request.method()} ${path}`);
await route.fulfill({ status: 403, json: { detail: "Unexpected fixture request; no real API access permitted" } });
});
return { calls, forbiddenRequests, importedFiles, releasedStages };
}
async function openExistingArchive(page: Page, language = "en", contextMenu = false) {
const name = language === "de" ? "Archiv entpacken" : "Unpack archive";
await page.goto(`/?managed-archive&language=${language}`);
const row = page.locator(".file-row").filter({ hasText: "small-archive.zip" });
await expect(row).toBeVisible();
await expect(page.getByRole("button", { name, exact: true })).toHaveCount(0);
await row.click({ button: contextMenu ? "right" : "left" });
if (contextMenu) await page.getByRole("menuitem", { name, exact: true }).click();
else await page.getByRole("button", { name, exact: true }).click();
const dialog = page.getByRole("dialog", { name, exact: true });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText("small-archive.zip");
await expect(dialog.locator('input[type="file"]')).toHaveCount(0);
await dialog.locator("select").selectOption("group:fixture-team");
await dialog.getByRole("button", { name: "extracted", exact: true }).click();
return dialog;
}
for (const language of ["en", "de"]) {
test(`real Files page unpacks a selected managed archive without re-upload in ${language}`, async ({ page }) => {
let finish!: () => void;
const holdConfirmation = new Promise<void>((resolve) => { finish = resolve; });
const fixture = await installFileFixtures(page, { holdConfirmation });
const dialog = await openExistingArchive(page, language);
await dialog.getByRole("button", { name: language === "de" ? "Archivvorschau" : "Preview archive", exact: true }).click();
await expect(dialog.locator(".archive-entry-row")).toHaveCount(3);
await dialog.locator(".archive-entry-row").filter({ hasText: "two.txt" }).locator('input[type="checkbox"]').uncheck();
await dialog.getByRole("button", { name: "Import selected", exact: true }).click();
await expect.poll(() => fixture.calls.filter((call) => call.path.endsWith("/archive-confirm")).length).toBe(1);
await expect(dialog.getByRole("button", { name: "Import selected", exact: true, includeHidden: true })).toBeDisabled();
await expect(dialog.locator(".loading-frame-overlay")).toBeVisible();
await expect(dialog.locator(".loading-frame-overlay")).toHaveCSS("backdrop-filter", /blur/);
await expect(dialog.locator(".loading-envelope")).toHaveCount(0);
await expect(dialog.locator("[inert]")).toHaveCount(1);
await expect(dialog.locator(".dialog-close")).toBeDisabled();
await expect(dialog.getByRole("progressbar")).not.toHaveAttribute("value");
await page.keyboard.press("Escape");
await expect(dialog).toBeVisible();
finish();
await expect(dialog).toHaveCount(0);
await expect(page.locator(".file-row").filter({ hasText: "small-archive.zip" })).toBeVisible();
expect(fixture.calls).toHaveLength(2);
expect(fixture.calls.every((call) => call.contentType.startsWith("application/json"))).toBe(true);
expect(fixture.calls[0].body).toEqual({ source_version_id: "fixture-version-7", owner_type: "group", owner_id: "fixture-team", path: "extracted" });
expect(fixture.calls[1].body).toEqual({ ...fixture.calls[0].body, preview_token: "fixture-preview-1", selected_paths: ["folder/one.txt"], operation_id: expect.any(String) });
expect(fixture.importedFiles.map((file) => file.display_path)).toEqual(["extracted/folder/one.txt"]);
expect(fixture.forbiddenRequests).toEqual([]);
});
}
test("managed context-menu flow verifies a ZIP password in the shared dialog", async ({ page }) => {
const fixture = await installFileFixtures(page, { encrypted: true });
const dialog = await openExistingArchive(page, "en", true);
await dialog.getByRole("button", { name: "Preview archive", exact: true }).click();
await expect(dialog.getByRole("button", { name: "Import selected", exact: true })).toBeDisabled();
await dialog.locator('input[type="password"]').fill("fixture-only-password");
await dialog.getByRole("button", { name: "Verify password", exact: false }).click();
await expect.poll(() => fixture.calls.length).toBe(2);
await expect(dialog.getByRole("button", { name: "Import selected", exact: true })).toBeEnabled();
await dialog.getByRole("button", { name: "Import selected", exact: true }).click();
await expect(dialog).toHaveCount(0);
expect(fixture.calls[1].body.password).toBe("fixture-only-password");
expect(fixture.calls[2].body.password).toBe("fixture-only-password");
expect(fixture.calls[2].body.preview_token).toBe("fixture-preview-2");
expect(fixture.calls.every((call) => call.body.source_version_id === "fixture-version-7")).toBe(true);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("cancelling an uploaded archive releases only its temporary stage without importing", async ({ page }) => {
const fixture = await installFileFixtures(page, { staged: true });
await page.goto("/?managed-archive&language=en");
await expect(page.locator(".file-row").filter({ hasText: "small-archive.zip" })).toBeVisible();
await page.getByRole("button", { name: "Upload", exact: true }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("checkbox", { name: "Preview and unpack archive", exact: true }).focus();
await page.keyboard.press("Space");
await dialog.locator('input[type="file"]').setInputFiles({ name: "cancelled.zip", mimeType: "application/zip", buffer: Buffer.from("fixture archive") });
await expect(dialog.locator(".archive-entry-row")).toHaveCount(3);
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect.poll(() => fixture.releasedStages).toEqual(["fixture-staged-archive"]);
expect(fixture.calls.filter((call) => call.path.endsWith("/archive-confirm"))).toHaveLength(0);
expect(fixture.importedFiles).toEqual([]);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("managed preview and confirmation failures stay visible inside the real dialog", async ({ page }) => {
const fixture = await installFileFixtures(page, { failPreviewOnce: true, failConfirmation: true });
const dialog = await openExistingArchive(page);
await dialog.getByRole("button", { name: "Preview archive", exact: true }).click();
await expect(dialog.getByText(/Managed archive version changed/)).toBeVisible();
await expect(dialog).toContainText("small-archive.zip");
await dialog.getByRole("button", { name: "Preview archive", exact: true }).click();
await expect(dialog.locator(".archive-entry-row")).toHaveCount(3);
await dialog.getByRole("button", { name: "Import selected", exact: true }).click();
await expect(dialog.getByText(/Target file already exists/)).toBeVisible();
await expect(dialog.locator(".loading-frame-overlay")).toHaveCount(0);
await expect(dialog.locator("[inert]")).toHaveCount(0);
await expect(dialog.locator(".dialog-close")).toBeEnabled();
await expect(dialog.getByRole("button", { name: "Import selected", exact: true })).toBeEnabled();
await dialog.getByRole("button", { name: "Change destination", exact: true }).click();
await expect(dialog.getByRole("button", { name: "Preview archive", exact: true })).toBeVisible();
await expect(dialog.locator(".archive-entry-row")).toHaveCount(0);
expect(fixture.importedFiles).toEqual([]);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("managed extraction is unavailable without the download permission", async ({ page }) => {
const fixture = await installFileFixtures(page);
await page.goto("/?managed-archive&language=en&no-download");
await page.locator(".file-row").filter({ hasText: "small-archive.zip" }).click();
await expect(page.getByRole("button", { name: "Unpack archive", exact: true })).toBeDisabled();
expect(fixture.calls).toEqual([]);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("managed preview covers and blurs existing dialog controls without fabricating progress", async ({ page }) => {
let release!: () => void;
const holdPreview = new Promise<void>((resolve) => { release = resolve; });
const fixture = await installFileFixtures(page, { holdPreview });
const dialog = await openExistingArchive(page);
await dialog.getByRole("button", { name: "Preview archive", exact: true }).click();
const overlay = dialog.locator(".loading-frame-overlay");
await expect(overlay).toBeVisible();
await expect(overlay).toContainText("Inspecting the archive");
await expect(dialog.getByRole("progressbar")).not.toHaveAttribute("value");
await expect(dialog.locator(".loading-envelope")).toHaveCount(0);
await expect(dialog.locator(".dialog-close")).toBeDisabled();
await page.keyboard.press("Escape");
await expect(dialog).toBeVisible();
release();
await expect(overlay).toHaveCount(0);
await expect(dialog.locator(".archive-entry-row")).toHaveCount(3);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("actual server counters update the blurred overlay and finalization never claims completion", async ({ page }) => {
let release!: () => void;
const holdConfirmation = new Promise<void>((resolve) => { release = resolve; });
const progress = { current: { phase: "extracting", completed_files: 1, total_files: 2, completed_bytes: 3, total_bytes: 6, status: "running" } as Record<string, unknown> };
const fixture = await installFileFixtures(page, { holdConfirmation, progress });
const dialog = await openExistingArchive(page);
await dialog.getByRole("button", { name: "Preview archive", exact: true }).click();
await expect(dialog.locator(".archive-entry-row")).toHaveCount(3);
await dialog.getByRole("button", { name: "Import selected", exact: true }).click();
await expect(dialog.getByRole("progressbar")).toHaveAttribute("value", "50");
await expect(dialog.locator(".loading-frame-overlay")).toContainText("1 of 2 files");
await expect(dialog.locator(".loading-frame-overlay")).toContainText("3 B of 6 B");
progress.current = { ...progress.current, phase: "finalizing", completed_files: 2, completed_bytes: 6 };
await expect(dialog.locator(".loading-frame-overlay")).toContainText("Finalizing and committing changes");
await expect(dialog.getByRole("progressbar")).not.toHaveAttribute("value");
await expect(dialog).toBeVisible();
release();
await expect(dialog).toHaveCount(0);
expect(fixture.calls.filter((call) => call.path.endsWith("/archive-confirm"))).toHaveLength(1);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("new archive upload preview and confirmation use the same no-envelope progress overlay", async ({ page }) => {
let previewReady!: () => void;
let importReady!: () => void;
const holdPreview = new Promise<void>((resolve) => { previewReady = resolve; });
const holdConfirmation = new Promise<void>((resolve) => { importReady = resolve; });
const progress = { current: { phase: "storing", completed_files: 1, total_files: 2, completed_bytes: 3, total_bytes: 6, status: "running" } as Record<string, unknown> };
const fixture = await installFileFixtures(page, { holdPreview, holdConfirmation, progress, staged: true });
await page.goto("/?managed-archive&language=en");
await expect(page.locator(".file-row").filter({ hasText: "small-archive.zip" })).toBeVisible();
await page.getByRole("button", { name: "Upload", exact: true }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("checkbox", { name: "Preview and unpack archive", exact: true }).focus();
await page.keyboard.press("Space");
await dialog.locator('input[type="file"]').setInputFiles({ name: "new-archive.zip", mimeType: "application/zip", buffer: Buffer.from("fixture ZIP bytes; backend is intercepted") });
await expect(dialog.locator(".loading-frame-overlay")).toBeVisible();
await expect(dialog.locator(".loading-envelope")).toHaveCount(0);
await expect(dialog.locator(".dialog-close")).toBeDisabled();
await expect(dialog.getByRole("progressbar")).not.toHaveAttribute("value", "100");
previewReady();
await expect(dialog.locator(".archive-entry-row")).toHaveCount(3);
await expect(dialog.locator(".loading-frame-overlay")).toHaveCount(0);
await dialog.getByRole("button", { name: "Import selected", exact: true }).click();
await expect(dialog.locator(".loading-frame-overlay")).toContainText("Storing extracted files");
await expect(dialog.getByRole("progressbar")).toHaveAttribute("value", "50");
await expect(dialog.locator(".loading-envelope")).toHaveCount(0);
importReady();
await expect(dialog).toHaveCount(0);
expect(fixture.calls.filter((call) => call.path === "/api/v1/files/archive-confirm")).toHaveLength(1);
const confirmation = fixture.calls.find((call) => call.path === "/api/v1/files/archive-confirm")!;
expect(confirmation.body.staged_upload_id).toBe("fixture-staged-archive");
expect(confirmation.body.file).toBeUndefined();
expect(fixture.forbiddenRequests).toEqual([]);
});
@@ -0,0 +1,116 @@
import { expect, test, type Page } from "@playwright/test";
async function mockModules(page: Page) {
const errors: string[] = [];
const writes: string[] = [];
page.on("pageerror", error => errors.push(error.message));
await page.route(url => url.pathname.startsWith("/api/"), async route => {
const path = new URL(route.request().url()).pathname;
if (route.request().method() !== "GET") writes.push(path);
let body: unknown = {};
if (path.startsWith("/api/v1/committee/workspace/")) body = { records: [], total: 0, offset: 0, limit: 200 };
else if (path === "/api/v1/voting") body = { ballots: [] };
else if (path === "/api/v1/scheduling/requests") body = { requests: [] };
else if (path.endsWith("/source-snapshots")) body = { available: true, snapshots: [] };
else if (path.endsWith("/list-snapshots")) body = { snapshots: [] };
else if (path.endsWith("/review-queue")) body = { candidates: [] };
else if (path.endsWith("/assurance/nodes")) body = { nodes: [] };
else if (path.endsWith("/assurance/summary")) body = { node_count: 0, edge_count: 0, by_kind: {}, by_state: {} };
else if (path === "/api/v1/organizations/model") body = { unit_types: [{ id: "unit-type", name: "Department", slug: "department", is_active: true }], structures: [], relation_types: [], units: [], relations: [], function_types: [], functions: [] };
else if (path === "/api/v1/idm/typed-groups") body = { groups: [{ id: "group", tenant_id: "layout-tenant", key: "reviewers", name: "Reviewers", group_type: "team", status: "active", source_provider: "manual", properties: {}, provenance: {}, revision: 1 }] };
else if (path === "/api/v1/idm/relationships") body = { relationships: [] };
else if (path === "/api/v1/idm/organization-identities") body = { identities: [] };
return route.fulfill({ json: body });
});
return { errors, writes };
}
for (const module of ["committee", "voting", "scheduling"] as const) {
test(`${module} keeps Reload immediately before New at the upper right`, async ({ page }) => {
const fixture = await mockModules(page);
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(`/?module-layouts=${module}&language=en&theme=light`);
const toolbar = page.locator('[data-workspace-action-scope="workspace"]').first();
const create = toolbar.locator('[data-page-action-slot="create"] button');
const reload = toolbar.locator('[data-page-action-slot="reload"] button');
await expect(create).toBeVisible();
await expect(reload).toBeEnabled();
const [barBox, createBox, reloadBox] = await Promise.all([toolbar.boundingBox(), create.boundingBox(), reload.boundingBox()]);
expect(createBox!.x).toBeGreaterThan(barBox!.x + barBox!.width / 2);
expect(Math.abs(barBox!.x + barBox!.width - createBox!.x - createBox!.width)).toBeLessThanOrEqual(20);
expect(Math.abs(createBox!.y - reloadBox!.y)).toBeLessThan(3);
expect(reloadBox!.x + reloadBox!.width).toBeLessThanOrEqual(createBox!.x + 1);
expect(createBox!.x - reloadBox!.x - reloadBox!.width).toBeLessThan(20);
expect(barBox!.y).toBeLessThan(20);
expect(fixture.errors).toEqual([]);
expect(fixture.writes).toEqual([]);
});
}
test("Scheduling retains workspace actions when its editor opens and in read-only mode", async ({ page }) => {
const fixture = await mockModules(page);
await page.goto("/?module-layouts=scheduling&language=en&theme=light");
const toolbar = page.locator('[data-workspace-action-scope="workspace"]');
const create = toolbar.locator('[data-page-action-slot="create"] button');
await expect(create).toBeEnabled();
await create.click();
await expect(page.locator('[data-workspace-action-scope="editor-pane"]')).toBeVisible();
await expect(create).toBeVisible();
await expect(toolbar.locator('[data-page-action-slot="reload"] button')).toBeVisible();
expect(fixture.errors).toEqual([]);
expect(fixture.writes).toEqual([]);
await page.goto("/?module-layouts=scheduling&language=en&read-only&theme=light");
await expect(create).toBeVisible();
await expect(create).toBeDisabled();
});
for (const module of ["organizations", "idm"] as const) {
test(`${module} table cards have no inset or negative overflow`, async ({ page }) => {
const fixture = await mockModules(page);
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(`/?module-layouts=${module}&language=en&theme=light`);
const cards = page.locator('.card:has([data-card-body-layout="table"])');
await expect(cards.first()).toBeVisible();
await expect(page.getByText(module === "idm" ? "Reviewers" : "Department", { exact: true })).toBeVisible();
const geometry = await cards.evaluateAll(elements => elements.map(card => {
const body = card.querySelector('.card-body')!;
const grid = card.querySelector('.data-grid')!;
const cardRect = card.getBoundingClientRect();
const gridRect = grid.getBoundingClientRect();
return { left: gridRect.left - cardRect.left, right: cardRect.right - gridRect.right, padding: getComputedStyle(body).paddingLeft };
}));
expect(geometry.length).toBeGreaterThanOrEqual(2);
for (const item of geometry) {
expect(Math.abs(item.left)).toBeLessThanOrEqual(2);
expect(Math.abs(item.right)).toBeLessThanOrEqual(2);
expect(item.padding).toBe("0px");
}
if (module === "idm") {
const [first, second] = await Promise.all([cards.nth(0).boundingBox(), cards.nth(1).boundingBox()]);
expect(second!.y - first!.y - first!.height).toBeGreaterThanOrEqual(12);
await expect(page.getByText("Business membership is an institutional fact.", { exact: false })).toHaveCount(0);
}
expect(fixture.errors).toEqual([]);
expect(fixture.writes).toEqual([]);
});
}
for (const width of [1280, 390]) {
test(`Risk Compliance uses shared cards and responsive layout at ${width}px`, async ({ page }) => {
const fixture = await mockModules(page);
await page.setViewportSize({ width, height: 900 });
await page.goto("/?module-layouts=risk&language=en&theme=light");
await expect(page.getByRole("heading", { name: "Review queue" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Candidate evidence" })).toBeVisible();
const toolbar = page.locator('[data-workspace-action-scope="workspace"]');
await expect(toolbar.locator('[data-page-action-slot="reload"] button')).toBeEnabled();
await page.getByRole("tab", { name: "Sources", exact: true }).click();
await expect(page.getByRole("heading", { name: "Connector evidence" })).toBeVisible();
await page.getByRole("tab", { name: "Assurance", exact: true }).click();
await expect(page.getByRole("heading", { name: "Assurance objects" })).toBeVisible();
await expect(page.locator('.risk-panel, .risk-toolbar')).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
expect(fixture.errors).toEqual([]);
expect(fixture.writes).toEqual([]);
});
}
@@ -0,0 +1,70 @@
import { expect, test } from "@playwright/test";
test("nested list filter owns keyboard focus and Escape only closes the filter", async ({ page }) => {
await page.goto("/?multi-select-filter");
await page.getByRole("button", { name: "Open filter dialog", exact: true }).click();
const owner = page.locator(".dialog-panel");
const trigger = owner.getByRole("button", { name: "Fixture tags", exact: true });
await trigger.click();
const popup = page.locator(".multi-select-filter-popover");
await expect(popup).toHaveAttribute("data-dialog-stack-state", "topmost");
await expect(owner).toHaveAttribute("inert", "");
await expect(popup.getByRole("button", { name: "Close filter", exact: true })).toBeFocused();
await page.keyboard.press("Tab");
await expect(popup.getByRole("button", { name: "Select all", exact: true })).toBeFocused();
await page.keyboard.press("Tab");
await expect(popup.getByRole("button", { name: "Deselect all", exact: true })).toBeFocused();
await page.keyboard.press("Space");
await expect(popup.getByRole("checkbox").first()).not.toBeChecked();
await page.keyboard.press("Tab");
await expect(popup.getByRole("checkbox").first()).toBeFocused();
await page.keyboard.press("Space");
await expect(popup.getByRole("checkbox").first()).toBeChecked();
await popup.getByRole("checkbox").last().focus();
await page.keyboard.press("Tab");
await expect(popup.getByRole("button", { name: "Close filter", exact: true })).toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect(popup.getByRole("checkbox").last()).toBeFocused();
await page.keyboard.press("Escape");
await expect(popup).toHaveCount(0);
await expect(owner).toBeVisible();
await expect(owner).not.toHaveAttribute("inert");
await expect(trigger).toBeFocused();
await page.keyboard.press("Escape");
await expect(owner).toHaveCount(0);
});
test("outside pointer dismisses only the nested filter, not its parent dialog", async ({ page }) => {
await page.goto("/?multi-select-filter");
await page.getByRole("button", { name: "Open filter dialog", exact: true }).click();
const owner = page.locator(".dialog-panel");
await owner.getByRole("button", { name: "Fixture tags", exact: true }).click();
await expect(page.locator(".multi-select-filter-popover")).toBeVisible();
await page.mouse.click(4, 4);
await expect(page.locator(".multi-select-filter-popover")).toHaveCount(0);
await expect(owner).toBeVisible();
await expect(owner.getByRole("button", { name: "Fixture tags", exact: true })).toBeFocused();
});
for (const modal of [false, true]) test(`80-character labels wrap without horizontal scrolling (${modal ? "modal" : "standalone"})`, async ({ page }) => {
await page.setViewportSize({ width: 320, height: 600 });
await page.goto("/?multi-select-filter");
if (modal) await page.getByRole("button", { name: "Open filter dialog", exact: true }).click();
const root = modal ? page.locator(".dialog-panel") : page.locator("main");
await root.getByRole("button", { name: "Fixture tags", exact: true }).click();
const popup = page.locator(".multi-select-filter-popover");
await expect(popup).toBeVisible();
const sizes = await popup.evaluate((element) => {
const options = element.querySelector(".data-grid-list-filter-options")!;
const rect = element.getBoundingClientRect();
return { left: rect.left, right: rect.right, bottom: rect.bottom, height: window.innerHeight,
viewport: window.innerWidth, popupOverflow: element.scrollWidth - element.clientWidth,
listOverflow: options.scrollWidth - options.clientWidth, parent: element.parentElement?.tagName };
});
expect(sizes.parent).toBe("BODY");
expect(sizes.left).toBeGreaterThanOrEqual(0);
expect(sizes.right).toBeLessThanOrEqual(sizes.viewport);
expect(sizes.bottom).toBeLessThanOrEqual(sizes.height);
expect(sizes.popupOverflow).toBeLessThanOrEqual(1);
expect(sizes.listOverflow).toBeLessThanOrEqual(1);
});
@@ -0,0 +1,86 @@
import { expect, test } from "@playwright/test";
test("collapsed rail keeps visible group separators; opening settings is clean", async ({ page }) => {
await page.goto("/?navigation-layout");
const rail = page.locator(".icon-rail");
await expect(rail).not.toHaveClass(/expanded/);
await expect(rail.getByRole("separator")).toHaveCount(2);
for (const separator of await rail.getByRole("separator").all()) {
await expect(separator).toBeVisible();
expect((await separator.boundingBox())!.width).toBeGreaterThan(20);
}
await expect(page.getByTestId("navigation-draft")).toHaveText("null");
const spacing = await page.locator(".navigation-preference-list > li").first().evaluate((row) => {
const style = getComputedStyle(row);
return { padding: Number.parseFloat(style.paddingLeft), gap: Number.parseFloat(style.columnGap) };
});
expect(spacing.padding).toBeGreaterThanOrEqual(8);
expect(spacing.gap).toBeGreaterThanOrEqual(8);
await page.getByRole("button", { name: "Expand navigation", exact: true }).click();
await expect(rail.getByText("Documents", { exact: true })).toBeVisible();
await expect(rail.getByRole("separator")).toHaveCount(0);
await page.getByRole("button", { name: "Collapse navigation", exact: true }).click();
await expect(rail.getByRole("separator")).toHaveCount(2);
await expect(rail.getByText("Documents", { exact: true })).toBeHidden();
});
for (const scope of ["system", "tenant", "user", "view"]) {
test(`same editor supports add/remove, keyboard reorder and inheritance at ${scope} scope`, async ({ page }) => {
await page.goto(`/?navigation-layout&scope=${scope}`);
const list = page.getByRole("list", { name: "Navigation layout" });
const files = list.locator('[data-navigation-id="files"]');
await files.getByRole("button", { name: "Remove Files", exact: true }).click();
await expect(files).toHaveCount(0);
await page.getByRole("button", { name: "Add module", exact: true }).click();
await expect(list.locator("li").last()).toHaveAttribute("data-navigation-id", "files");
const handle = files.getByRole("button", { name: "Reorder Files", exact: true });
const before = await list.locator("li").evaluateAll((rows) => rows.map((row) => row.getAttribute("data-navigation-id")));
await handle.press("Space");
await handle.press("ArrowUp");
await expect(list.locator("li").last()).not.toHaveAttribute("data-navigation-id", "files");
await handle.press("Escape");
expect(await list.locator("li").evaluateAll((rows) => rows.map((row) => row.getAttribute("data-navigation-id")))).toEqual(before);
await handle.press("Space"); await handle.press("ArrowUp"); await handle.press("Enter");
await page.getByRole("button", { name: "Add separator", exact: true }).click();
const separator = list.locator("li").last();
await expect(separator).toHaveAttribute("data-navigation-kind", "separator");
await separator.getByRole("textbox").fill("Custom group");
const saved = JSON.parse(await page.getByTestId("navigation-draft").textContent() ?? "null");
expect(saved.separators.at(-1).label).toBe("Custom group");
await separator.getByRole("button", { name: "Remove Custom group", exact: true }).click();
if (scope !== "system") await expect(list.getByRole("button", { name: "Remove Dashboard", exact: true })).toBeDisabled();
await page.getByRole("button", { name: "Use inherited layout", exact: true }).click();
await expect(page.getByTestId("navigation-draft")).toHaveText("null");
});
}
test("native pointer drag reorders modules and separators", async ({ page }) => {
await page.goto("/?navigation-layout");
const list = page.getByRole("list", { name: "Navigation layout" });
const files = list.locator('[data-navigation-id="files"]');
await files.getByRole("button", { name: "Reorder Files", exact: true }).dragTo(list.locator("li").first(), { targetPosition: { x: 15, y: 3 } });
await expect(list.locator("li").first()).toHaveAttribute("data-navigation-id", "files");
const separator = list.locator('[data-navigation-kind="separator"]').first();
const separatorId = await separator.getAttribute("data-navigation-id");
await separator.getByRole("button", { name: /^Reorder / }).dragTo(list.locator("li").first(), { targetPosition: { x: 15, y: 3 } });
await expect(list.locator("li").first()).toHaveAttribute("data-navigation-id", separatorId!);
});
test("no-op reordering stays clean and optional module positions survive other edits", async ({ page }) => {
await page.goto("/?navigation-layout");
const handle = page.getByRole("button", { name: "Reorder Files", exact: true });
await handle.press("Space"); await handle.press("Enter");
await expect(page.getByTestId("navigation-draft")).toHaveText("null");
await page.goto("/?navigation-layout&unavailable");
await page.getByRole("button", { name: "Move Mail up", exact: true }).click();
const draft = JSON.parse(await page.getByTestId("navigation-draft").textContent() ?? "null");
expect(draft.order).toContain("optional.navigation.absent");
});
test("German narrow editor does not overflow and read-only controls cannot mutate", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 900 });
await page.goto("/?navigation-layout&language=de&disabled");
await expect(page.getByRole("button", { name: "Trennlinie hinzufügen", exact: true })).toBeDisabled();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await expect(page.getByTestId("navigation-draft")).toHaveText("null");
});
@@ -0,0 +1,112 @@
import { expect, test } from "@playwright/test";
for (const language of ["en", "de"]) test(`notification status uses a shared multi-select dropdown (${language})`, async ({ page }) => {
const errors: string[] = [];
const requests: string[][] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.route("**/api/v1/notifications?**", (route) => {
const states = new URL(route.request().url()).searchParams.getAll("status");
requests.push(states);
const notifications = ["pending", "failed", "sent"].filter((state) => !states.length || states.includes(state)).map((state) => ({
id: state, tenant_id: "filter-tenant", source_module: "test", source_resource_type: "fixture", event_kind: "test", channel: "inbox",
recipient_id: "filter-user", subject: `Message ${state}`, body_text: "Filter-only fixture", status: state,
created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", attempts: [], metadata: {}, payload: {}, priority: 0, attempt_count: 0,
}));
return route.fulfill({ json: { notifications } });
});
await page.goto(`/?notification-filter&language=${language}`);
const list = page.locator(".notifications-selection-list");
await expect(list.locator("button")).toHaveCount(3);
const trigger = page.locator(".multi-select-filter-trigger");
await trigger.click();
const popup = page.locator(".multi-select-filter-popover");
await expect(popup.getByRole("checkbox")).toHaveCount(9);
await popup.locator(".data-grid-list-filter-actions button").nth(1).click();
await expect(list).toHaveCount(0);
const countAfterClear = requests.length;
await popup.getByRole("checkbox").nth(0).check();
await popup.getByRole("checkbox").nth(6).check();
await expect(list.locator("button")).toHaveCount(2);
expect(requests[requests.length - 1]).toEqual(["pending", "failed"]);
expect(requests.length).toBeGreaterThan(countAfterClear);
await popup.locator(".data-grid-list-filter-actions button").first().click();
await expect(list.locator("button")).toHaveCount(3);
await page.keyboard.press("Escape");
await expect(popup).toHaveCount(0);
await expect(trigger).toBeFocused();
expect(errors).toEqual([]);
});
test("standalone list filter fits a narrow viewport and closes outside", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 700 });
await page.route("**/api/v1/notifications?**", (route) => route.fulfill({ json: { notifications: [] } }));
await page.goto("/?notification-filter&language=en");
await page.locator(".multi-select-filter-trigger").click();
const popup = page.locator(".multi-select-filter-popover");
const box = await popup.boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
await page.mouse.click(385, 680);
await expect(popup).toHaveCount(0);
});
test("clearing the filter cancels a slow read and cannot restore old rows", async ({ page }) => {
let releaseRead!: () => void;
const heldRead = new Promise<void>((resolve) => { releaseRead = resolve; });
let readStarted = false;
const failedReads: string[] = [];
page.on("requestfailed", (request) => { if (request.url().includes("/api/v1/notifications?")) failedReads.push(request.url()); });
await page.route("**/api/v1/notifications?**", async (route) => {
readStarted = true;
await heldRead;
await route.fulfill({ json: { notifications: [{ id: "stale", status: "sent", subject: "Stale notice", attempts: [] }] } });
});
await page.goto("/?notification-filter&language=en");
await expect.poll(() => readStarted).toBe(true);
await page.locator(".multi-select-filter-trigger").click();
await page.locator(".multi-select-filter-popover .data-grid-list-filter-actions button").nth(1).click();
await expect(page.locator(".notifications-note")).toHaveText("No notifications in this view.");
releaseRead();
await expect.poll(() => failedReads.length).toBeGreaterThan(0);
await expect(page.getByText("Stale notice", { exact: true })).toHaveCount(0);
});
for (const action of ["read", "dispatch"] as const) test(`late ${action} completion cannot restore another account's inbox`, async ({ page }) => {
let releaseWrite!: () => void;
let writeStarted = false;
let writeFinished = false;
const heldWrite = new Promise<void>((resolve) => { releaseWrite = resolve; });
const reads: string[] = [];
const notice = (owner: string) => ({ id: owner, subject: `Notice for ${owner}`, status: "pending", channel: "inbox",
source_module: "test", source_resource_type: "fixture", recipient_id: owner, body_text: "Fixture", attempts: [], metadata: {}, payload: {},
created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", priority: 0, attempt_count: 0 });
await page.route("**/api/v1/notifications**", async (route) => {
const owner = route.request().headers().authorization?.replace("Bearer ", "") ?? "filter-user";
if (route.request().method() === "GET") {
reads.push(owner);
return route.fulfill({ json: { notifications: [notice(owner)] } });
}
writeStarted = true;
await heldWrite;
await route.fulfill({ json: action === "read" ? { ...notice(owner), read_at: "2026-01-02T00:00:00Z" } : { processed: 1, sent: 1, errors: [] } });
writeFinished = true;
});
await page.goto("/?notification-filter&language=en&write");
await expect(page.locator(".notifications-selection-list button")).toHaveCount(1);
if (action === "read") await page.getByRole("button", { name: "Mark read", exact: true }).click();
else {
await page.getByRole("button", { name: "Dispatch pending", exact: true }).click();
await page.getByRole("alertdialog").getByRole("button", { name: "Dispatch pending", exact: true }).click();
}
await expect.poll(() => writeStarted).toBe(true);
await page.evaluate(() => window.dispatchEvent(new Event("conformance-notification-account")));
await expect(page.locator(".notifications-selection-list")).toContainText("Notice for other-user");
const readCount = reads.length;
const writeResponse = page.waitForResponse((response) => response.request().method() !== "GET" && response.url().includes("/api/v1/notifications"));
releaseWrite();
await writeResponse;
await expect.poll(() => writeFinished).toBe(true);
await expect(page.locator(".notifications-selection-list")).not.toContainText("Notice for filter-user");
await expect(page.getByRole("button", { name: "Mark read", exact: true })).toBeEnabled();
expect(reads.length).toBe(readCount);
});
@@ -0,0 +1,266 @@
import { expect, test, type Page, type Request } from "@playwright/test";
const resources = [
{ provider_id: "files", module_id: "files", resource_type: "file", label: "File", order: 1 },
{ provider_id: "mail", module_id: "mail", resource_type: "message", label: "Message", order: 2 },
{ provider_id: "tickets", module_id: "tickets", resource_type: "ticket", label: "Ticket", order: 3 },
];
function result(module: string, type: string, title: string) {
return { provider_id: module, module_id: module, resource_type: type, resource_id: title, title, url: `/result/${title}`,
highlights: [], breadcrumbs: [], metadata: {}, provenance: {}, score: 1 };
}
async function mockSearch(page: Page) {
const reads: URLSearchParams[] = [];
await page.route("**/api/v1/search/providers", (route) => route.fulfill({ json: { providers: [], resources } }));
await page.route("**/api/v1/search?**", (route) => {
const params = new URL(route.request().url()).searchParams;
reads.push(params);
const modules = params.getAll("module"), types = params.getAll("resource_type");
return route.fulfill({ json: { query: params.get("q"), diagnostics: [], has_more: false,
results: resources.filter((item) => (!modules.length || modules.includes(item.module_id)) && (!types.length || types.includes(item.resource_type)))
.map((item) => result(item.module_id, item.resource_type, `${params.get("q")} ${item.label}`)) } });
});
return reads;
}
const popup = (page: Page) => page.locator(".multi-select-filter-popover");
for (const language of ["en", "de"]) test(`Search page shared filters preserve all, none, OR, and URL history (${language})`, async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
const reads = await mockSearch(page);
await page.goto(`/?search-filters&language=${language}&q=permit&context=current&keep=1`);
await expect(page.locator(".search-result")).toHaveCount(3);
const trigger = page.locator(".multi-select-filter-trigger").first();
await trigger.click();
await expect(popup(page).getByRole("checkbox")).toHaveCount(3);
await expect(popup(page).getByRole("checkbox").first()).toBeChecked();
await popup(page).locator(".data-grid-list-filter-actions button").nth(1).click();
await expect(page.locator(".search-result")).toHaveCount(0);
expect(new URL(page.url()).searchParams.get("module_none")).toBe("1");
const noneReadCount = reads.length;
await popup(page).getByRole("checkbox", { name: "Files", exact: true }).click();
await expect(page.locator(".search-result")).toHaveCount(1);
expect(reads[reads.length - 1]?.getAll("module")).toEqual(["files"]);
await popup(page).getByRole("checkbox", { name: "Mail", exact: true }).click();
await expect(page.locator(".search-result")).toHaveCount(2);
expect(reads[reads.length - 1]?.getAll("module")).toEqual(["files", "mail"]);
expect(reads.length).toBeGreaterThan(noneReadCount);
await page.keyboard.press("Escape");
await expect(trigger).toBeFocused();
await page.getByRole("button", { name: language === "de" ? "Filter zurücksetzen" : "Clear filters", exact: true }).click();
await expect.poll(() => reads[reads.length - 1]?.getAll("module")).toEqual([]);
expect(new URL(page.url()).searchParams.get("context")).toBe("current");
expect(new URL(page.url()).searchParams.get("keep")).toBe("1");
await page.goBack();
await expect.poll(() => reads[reads.length - 1]?.getAll("module")).toEqual(["files", "mail"]);
await page.goBack();
await expect(page.locator(".search-result")).toHaveCount(1);
await page.goBack();
await expect(page.locator(".search-result")).toHaveCount(0);
expect(new URL(page.url()).searchParams.get("module_none")).toBe("1");
expect(errors).toEqual([]);
});
test("legacy Search links retain explicit unknown values, and none takes precedence", async ({ page }) => {
const reads = await mockSearch(page);
await page.goto("/?search-filters&q=permit&module=retired&resource_type=file");
await expect.poll(() => reads.length).toBe(1);
expect(reads[0].getAll("module")).toEqual(["retired"]);
await page.locator(".multi-select-filter-trigger").first().click();
await expect(popup(page).getByRole("checkbox", { name: "Retired", exact: true })).toBeChecked();
await page.goto("/?search-filters&q=permit&module=files&module_none=1");
await expect(page.locator(".search-empty")).toBeVisible();
expect(reads).toHaveLength(1);
});
test("overlay context never broadens incompatible result types; nested filter Escape preserves Search", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
const reads = await mockSearch(page);
await page.goto("/files?search-filters&overlay&language=en");
await expect(page.locator(".titlebar-search-button")).toBeVisible();
await page.keyboard.press("F3");
const overlay = page.locator(".search-overlay-dialog");
await overlay.getByRole("searchbox").fill("permit");
await expect(overlay.locator(".search-result")).toHaveCount(1);
expect(reads[reads.length - 1]?.getAll("module")).toEqual(["files"]);
expect(reads[reads.length - 1]?.getAll("resource_type")).toEqual(["file"]);
await overlay.getByRole("tab", { name: "Everywhere", exact: true }).click();
await expect(overlay.locator(".search-result")).toHaveCount(3);
const types = overlay.locator(".multi-select-filter-trigger").nth(1);
await types.click();
await popup(page).locator(".data-grid-list-filter-actions button").nth(1).click();
await popup(page).getByRole("checkbox", { name: "Message", exact: true }).check();
await expect(overlay.locator(".search-result")).toHaveCount(1);
await page.keyboard.press("Escape");
await expect(popup(page)).toHaveCount(0);
await expect(types).toBeFocused();
await expect(overlay).toBeVisible();
const count = reads.length;
await overlay.getByRole("tab", { name: "Current files", exact: true }).click();
await expect(overlay.locator(".search-empty")).toHaveText("No results for “permit”.");
expect(reads.length).toBe(count);
await overlay.getByRole("button", { name: "Clear filters", exact: true }).click();
await expect(overlay.locator(".search-result")).toHaveCount(1);
expect(reads[reads.length - 1]?.getAll("resource_type")).toEqual(["file"]);
await page.keyboard.press("Escape");
await expect(overlay).toHaveCount(0);
expect(errors).toEqual([]);
});
for (const overlay of [false, true]) test(`late cursor cannot append after filter change (${overlay ? "overlay" : "page"})`, async ({ page }) => {
let release!: () => void;
let cursorStarted = false;
const held = new Promise<void>((resolve) => { release = resolve; });
await mockSearch(page);
await page.route("**/api/v1/search?**", async (route) => {
const params = new URL(route.request().url()).searchParams;
if (params.has("cursor")) { cursorStarted = true; await held; }
return route.fulfill({ json: { query: "permit", diagnostics: [], results: [result("files", "file", params.has("cursor") ? "Old cursor result" : "First result")], next_cursor: params.has("cursor") ? null : "next", has_more: !params.has("cursor") } });
});
await page.goto(`/?search-filters&q=permit&language=en${overlay ? "&overlay" : ""}`);
if (overlay) { await expect(page.locator(".titlebar-search-button")).toBeVisible(); await page.keyboard.press("Control+k"); await page.getByRole("searchbox").fill("permit"); }
await page.getByRole("button", { name: "Load more", exact: true }).click();
await expect.poll(() => cursorStarted).toBe(true);
const failed = page.waitForEvent("requestfailed", { predicate: (request) => request.url().includes("cursor=") });
await page.locator(".multi-select-filter-trigger").first().click();
await popup(page).locator(".data-grid-list-filter-actions button").nth(1).click();
await expect(page.locator(".search-result")).toHaveCount(0);
release();
await failed;
await expect(page.locator(".search-result")).toHaveCount(0);
await expect(page.locator(".search-empty")).toBeVisible();
});
test("shared Search dropdown fits a narrow viewport and typing invalidates keyboard selection immediately", async ({ page }) => {
await mockSearch(page);
await page.setViewportSize({ width: 390, height: 700 });
await page.goto("/?search-filters&overlay&language=de");
await expect(page.locator(".titlebar-search-button")).toBeVisible();
await page.keyboard.press("Control+k");
const input = page.getByRole("searchbox");
await input.fill("permit");
await expect(page.locator(".search-result")).toHaveCount(3);
await input.press("ArrowDown");
await input.fill("changed");
await input.press("Enter");
expect(new URL(page.url()).pathname).toBe("/");
await expect(page.locator(".search-result").first()).toContainText("changed");
await page.locator(".multi-select-filter-trigger").first().click();
const box = await popup(page).boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
await expect(popup(page).getByRole("button", { name: "Alle auswählen", exact: true })).toBeVisible();
});
for (const overlay of [false, true]) test(`partial providers preserve safe results and successful pagination (${overlay ? "overlay" : "page"})`, async ({ page }) => {
const errors: string[] = [];
const reads: URLSearchParams[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.route("**/api/v1/search/providers", (route) => route.fulfill({ json: { providers: [], resources } }));
await page.route("**/api/v1/search?**", (route) => {
const params = new URL(route.request().url()).searchParams;
reads.push(params);
const nextPage = params.has("cursor");
return route.fulfill({ json: {
query: params.get("q"),
results: [result("files", "file", nextPage ? "Second authorized result" : "First authorized result")],
diagnostics: [
{ provider_id: "mail", message: "Mail provider temporarily unavailable." },
...(nextPage ? [{ provider_id: "tickets", message: "Ticket provider returned partial results." }] : []),
],
next_cursor: nextPage ? null : "authorized-page-two", has_more: !nextPage,
} });
});
await page.goto(`/?search-filters&q=permit&language=en${overlay ? "&overlay" : ""}`);
if (overlay) {
await expect(page.locator(".titlebar-search-button")).toBeVisible();
await page.keyboard.press("F3");
await page.getByRole("searchbox").fill("permit");
}
await expect(page.locator(".search-result")).toHaveCount(1);
await expect(page.getByText("Mail provider temporarily unavailable.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Load more", exact: true }).click();
await expect(page.locator(".search-result")).toHaveCount(2);
await expect(page.locator(".search-result").first()).toContainText("First authorized result");
await expect(page.locator(".search-result").nth(1)).toContainText("Second authorized result");
await expect(page.getByText("Mail provider temporarily unavailable.", { exact: true })).toHaveCount(1);
await expect(page.getByText("Ticket provider returned partial results.", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Load more", exact: true })).toHaveCount(0);
expect(reads).toHaveLength(2);
expect(reads[1].get("q")).toBe("permit");
expect(reads[1].get("cursor")).toBe("authorized-page-two");
expect(errors).toEqual([]);
});
for (const sameToken of [false, true]) for (const overlay of [false, true]) test(`late query and catalogue cannot restore another account's Search (${overlay ? "overlay" : "page"}, ${sameToken ? "same token" : "new token"})`, async ({ page }) => {
const errors: string[] = [];
const oldStarted = new Set<string>();
const oldFinished = new Set<string>();
const oldFailed = new Set<string>();
const oldRequests = new Set<Request>();
const currentReads: string[] = [];
const currentTokens: string[] = [];
let accountSwitched = false;
let releaseOld!: () => void;
const oldResponses = new Promise<void>((resolve) => { releaseOld = resolve; });
page.on("pageerror", (error) => errors.push(error.message));
page.on("requestfailed", (request) => {
if (oldRequests.has(request)) {
oldFailed.add(new URL(request.url()).pathname.endsWith("/providers") ? "catalogue" : "query");
}
});
await page.route("**/api/v1/search**", async (route) => {
const oldAccount = !accountSwitched;
const catalogue = new URL(route.request().url()).pathname.endsWith("/providers");
const kind = catalogue ? "catalogue" : "query";
if (oldAccount) { oldRequests.add(route.request()); oldStarted.add(kind); await oldResponses; }
else {
currentReads.push(kind);
currentTokens.push(route.request().headers().authorization ?? "");
}
const json = catalogue ? {
providers: [],
resources: oldAccount
? [{ provider_id: "retired", module_id: "retired_source", resource_type: "retired_record", label: "Retired record", order: 1 }]
: [{ provider_id: "mail", module_id: "mail", resource_type: "message", label: "Current record", order: 1 }],
} : {
query: "permit", diagnostics: [], next_cursor: null, has_more: false,
results: [oldAccount
? result("retired_source", "retired_record", "Previous account result")
: result("mail", "message", "Current account result")],
};
await route.fulfill({ json });
if (oldAccount) oldFinished.add(kind);
});
await page.goto(`/?search-filters&q=permit&language=en${overlay ? "&overlay" : ""}${sameToken ? "&same-token" : ""}`);
if (overlay) {
await expect(page.locator(".titlebar-search-button")).toBeVisible();
await page.keyboard.press("F3");
await page.getByRole("searchbox").fill("permit");
}
await expect.poll(() => [...oldStarted].sort()).toEqual(["catalogue", "query"]);
accountSwitched = true;
await page.evaluate(() => window.dispatchEvent(new Event("conformance-search-account")));
await expect(page.locator(".search-result")).toHaveCount(1);
await expect(page.locator(".search-result")).toContainText("Current account result");
await expect.poll(() => [...currentReads].sort()).toEqual(["catalogue", "query"]);
await page.locator(".multi-select-filter-trigger").first().click();
await expect(popup(page).getByRole("checkbox", { name: "Mail", exact: true })).toBeChecked();
const readsAfterSwitch = currentReads.length;
releaseOld();
await expect.poll(() => [...oldFinished].sort()).toEqual(["catalogue", "query"]);
await expect.poll(() => [...oldFailed].sort()).toEqual(["catalogue", "query"]);
await expect(page.locator(".search-result")).toHaveCount(1);
await expect(page.locator(".search-result")).toContainText("Current account result");
await expect(page.getByText("Previous account result", { exact: true })).toHaveCount(0);
await expect(popup(page).getByRole("checkbox")).toHaveCount(1);
await expect(popup(page).getByRole("checkbox", { name: "Retired Source", exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await page.locator(".multi-select-filter-trigger").nth(1).click();
await expect(popup(page).getByRole("checkbox", { name: "Current record", exact: true })).toBeChecked();
await expect(popup(page).getByRole("checkbox", { name: "Retired record", exact: true })).toHaveCount(0);
expect(currentReads.length).toBe(readsAfterSwitch);
expect(currentTokens).toEqual(Array(2).fill(sameToken ? "Bearer search-user" : "Bearer other-user"));
expect(errors).toEqual([]);
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 228 KiB

@@ -152,22 +152,22 @@ test("shared components remain accessible and keyboard operable", async ({ page
await expect(editorActions.locator("[data-page-action-separation='destructive']")).toHaveCSS("border-left-width", "2px");
const actionSlots = await editorActions.locator("[data-page-action-slot]").evaluateAll((elements) => elements.map((element) => element.getAttribute("data-page-action-slot")));
expect(actionSlots).toEqual([
"reload",
"context",
"reload",
"destructive",
"discard",
"save"
]);
await expect(editorActions.getByRole("button")).toHaveText([
"Neu laden",
"Vorschau öffnen",
"Neu laden",
"Löschen",
"Verwerfen",
"Änderungen speichern"
]);
await editorActions.getByRole("button", { name: "Neu laden" }).focus();
await editorActions.getByRole("button", { name: "Vorschau öffnen" }).focus();
await page.keyboard.press("Tab");
await expect(editorActions.getByRole("button", { name: "Vorschau öffnen" })).toBeFocused();
await expect(editorActions.getByRole("button", { name: "Neu laden" })).toBeFocused();
await page.keyboard.press("Tab");
await expect(editorActions.locator(".disabled-action-tooltip")).toBeFocused();
await expect(page.getByRole("tooltip")).toContainText("Nur die federführende Stelle");
@@ -0,0 +1,56 @@
import { expect, test } from "@playwright/test";
for (const mode of ["visual", "source"] as const) {
test(`rich-text ${mode} editor stays clean until a real content edit`, async ({ page }) => {
const pageErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
await page.goto(`/?wysiwyg-lifecycle&mode=${mode}`);
const changeCount = page.getByTestId("wysiwyg-change-count");
const value = page.getByTestId("wysiwyg-controlled-value");
const editor = mode === "visual"
? page.locator(".wysiwyg-prosemirror")
: page.locator(".wysiwyg-editor-source");
const original = mode === "visual"
? "<p>Legacy <i>template</i></p>"
: '<table style="width: 100%"><tbody><tr><td>Legacy template</td></tr></tbody></table>';
await expect(editor).toBeVisible();
// <i> is supported visually but normalized to <em> inside Tiptap. That
// normalization must not leak into controlled content without a real edit.
if (mode === "visual") await expect(editor.locator("em")).toHaveText("template");
await expect(value).toHaveText(original);
await expect(changeCount).toHaveText("0");
await page.getByRole("button", { name: "Toggle read-only", exact: true }).click();
if (mode === "visual") await expect(editor).toHaveAttribute("contenteditable", "false");
else await expect(editor).toBeDisabled();
await expect(value).toHaveText(original);
await expect(changeCount).toHaveText("0");
await page.getByRole("button", { name: "Toggle read-only", exact: true }).click();
await page.getByRole("button", { name: "Load another value", exact: true }).click();
await expect(value).toHaveText(original.replace("Legacy", "Reloaded"));
await expect(changeCount).toHaveText("0");
await page.getByRole("button", { name: "Toggle editor mount", exact: true }).click();
await expect(editor).toHaveCount(0);
await page.getByRole("button", { name: "Toggle editor mount", exact: true }).click();
await expect(editor).toBeVisible();
await expect(value).toHaveText(original.replace("Legacy", "Reloaded"));
await expect(changeCount).toHaveText("0");
if (mode === "visual") {
await page.getByRole("tab", { name: "Source fixture mode", exact: true }).click();
await expect(page.locator(".wysiwyg-editor-source")).toHaveValue(original.replace("Legacy", "Reloaded"));
await expect(changeCount).toHaveText("0");
await page.getByRole("tab", { name: "Visual fixture mode", exact: true }).click();
await expect(editor).toBeVisible();
await expect(changeCount).toHaveText("0");
}
await editor.fill(mode === "source" ? "<p>Edited content</p>" : "Edited content");
await expect.poll(async () => Number(await changeCount.textContent())).toBeGreaterThan(0);
await expect(value).toContainText("Edited content");
expect(pageErrors).toEqual([]);
});
}