fix(*): 邮箱与系统公告 - 合并 PR #417 并修复迁移冲突和提醒关闭逻辑

保留历史数据库迁移谱系与公告按需加载,复用邮件配置快照校验注册域名,补充白名单及公告回归测试。
This commit is contained in:
ddCat
2026-09-05 16:39:45 +08:00
18 changed files with 560 additions and 79 deletions

View File

@@ -7,7 +7,7 @@
"build:bridge": "bun ../canvas-agent/scripts/build-comfy-bridge.ts",
"dev": "vite --host 0.0.0.0 --port 3000",
"build": "bun run build:bridge && tsc --noEmit && vite build",
"pretest": "bun test test/admin-ui-regressions.test.ts test/password-reset.test.ts test/oss-settings.test.ts",
"pretest": "bun test test/admin-ui-regressions.test.ts test/announcement-prompt-regressions.test.ts test/password-reset.test.ts test/oss-settings.test.ts",
"typecheck": "tsc --noEmit",
"test:canvas": "bun test test/canvas-media-performance.test.ts test/canvas-node-title-interaction.test.ts test/canvas-spatial-index.test.ts",
"test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-pricing.test.ts test/model-protocols.test.ts test/model-capabilities.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-generation-submission.test.ts test/canvas-image-batch-retry.test.ts test/canvas-keyboard.test.ts test/canvas-node-generation-mentions.test.ts test/canvas-node-search.test.ts test/canvas-leafer-viewport.test.ts test/canvas-media-download.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-frame.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",

View File

@@ -1,7 +1,7 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Tag } from "antd";
import { Bell } from "lucide-react";
import { lazy, Suspense, useState, type CSSProperties } from "react";
import { lazy, Suspense, useEffect, useState, type CSSProperties } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { aceternityMotion } from "@/lib/aceternity-motion";
@@ -11,6 +11,7 @@ const AnnouncementTimelineModal = lazy(() => import("@/components/ui/aceternity/
const ANNOUNCEMENT_REFRESH_INTERVAL_MS = 5 * 60_000;
const ANNOUNCEMENT_CACHE_TTL_MS = 60_000;
const ANNOUNCEMENT_DISMISS_TODAY_PREFIX = "yingce.announcements.dismiss-today";
type AnnouncementFeed = Awaited<ReturnType<typeof getAnnouncementFeed>>;
@@ -21,12 +22,15 @@ type SystemAnnouncementCenterProps = {
showLabel?: boolean;
labelClassName?: string;
staticMotion?: boolean;
autoOpen?: boolean;
};
export function SystemAnnouncementCenter({ userId, className, style, showLabel = false, labelClassName, staticMotion = false }: SystemAnnouncementCenterProps) {
export function SystemAnnouncementCenter({ userId, className, style, showLabel = false, labelClassName, staticMotion = false, autoOpen = false }: SystemAnnouncementCenterProps) {
const reducedMotion = useReducedMotion();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [automaticPrompt, setAutomaticPrompt] = useState(false);
const [dismissedFingerprint, setDismissedFingerprint] = useState("");
const queryKey = ["system-announcements", userId] as const;
const feedQuery = useQuery({
queryKey,
@@ -41,7 +45,16 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel =
const unreadCount = Math.max(0, feedQuery.data?.unreadCount || 0);
const error = feedQuery.error instanceof Error ? feedQuery.error.message : feedQuery.error ? "读取公告失败" : "";
useEffect(() => {
if (!autoOpen || open || !feedQuery.isSuccess || unreadCount <= 0 || announcements.length === 0) return;
const fingerprint = announcementFeedFingerprint(announcements);
if (!fingerprint || fingerprint === dismissedFingerprint || announcementAutoPromptSuppressed(userId)) return;
setAutomaticPrompt(true);
setOpen(true);
}, [announcements, autoOpen, dismissedFingerprint, feedQuery.isSuccess, open, unreadCount, userId]);
const openAnnouncements = async () => {
setAutomaticPrompt(false);
setOpen(true);
const feed = (await feedQuery.refetch()).data;
if (!feed?.unreadCount) return;
@@ -55,6 +68,14 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel =
}
};
const dismissAutomaticPrompt = (duration: "once" | "today") => {
const fingerprint = announcementFeedFingerprint(announcements);
if (fingerprint) setDismissedFingerprint(fingerprint);
if (duration === "today") rememberAnnouncementDismissalToday(userId);
setAutomaticPrompt(false);
setOpen(false);
};
return (
<>
<motion.button
@@ -93,9 +114,47 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel =
</motion.button>
{open ? (
<Suspense fallback={null}>
<AnnouncementTimelineModal open announcements={announcements} loading={feedQuery.isFetching} error={announcements.length ? "" : error} onClose={() => setOpen(false)} onRetry={() => void feedQuery.refetch()} />
<AnnouncementTimelineModal
open
announcements={announcements}
loading={feedQuery.isFetching}
error={announcements.length ? "" : error}
automaticPrompt={automaticPrompt}
onClose={() => dismissAutomaticPrompt("once")}
onDismissOnce={() => dismissAutomaticPrompt("once")}
onDismissToday={() => dismissAutomaticPrompt("today")}
onRetry={() => void feedQuery.refetch()}
/>
</Suspense>
) : null}
</>
);
}
function announcementFeedFingerprint(announcements: AnnouncementFeed["announcements"]) {
return announcements
.map((announcement) => `${announcement.id}:${announcement.publishedAt}`)
.sort()
.join("|");
}
function announcementAutoPromptSuppressed(userId: string) {
try {
return localStorage.getItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`) === localDateKey();
} catch {
return false;
}
}
function rememberAnnouncementDismissalToday(userId: string) {
try {
localStorage.setItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`, localDateKey());
} catch {
// 浏览器禁用存储时仍允许关闭弹窗,本次组件生命周期内不会重复打开。
}
}
function localDateKey() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}

View File

@@ -47,7 +47,7 @@ export function WorkspaceTopBar({ sidebarOpen, onToggleSidebar }: { sidebarOpen:
</div>
<div className="flex shrink-0 items-center gap-1">
{user ? <SystemAnnouncementCenter userId={user.id} className="app-workspace-topbar-icon-button" /> : null}
{user ? <SystemAnnouncementCenter userId={user.id} className="app-workspace-topbar-icon-button" autoOpen /> : null}
<AnimatedThemeToggler className="app-workspace-topbar-icon-button" theme={theme} onThemeChange={setTheme} aria-label="切换主题" />
<WorkspaceAccountMenu />
</div>

View File

@@ -1,5 +1,5 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Image as AntImage, Modal } from "antd";
import { Button, Image as AntImage, Modal } from "antd";
import { Bell, BellOff, CircleAlert, Info, Pin, ShieldAlert, Wrench } from "lucide-react";
import { aceternityMotion } from "@/lib/aceternity-motion";
@@ -11,7 +11,10 @@ type AnnouncementTimelineModalProps = {
announcements: SystemAnnouncement[];
loading?: boolean;
error?: string;
automaticPrompt?: boolean;
onClose: () => void;
onDismissOnce?: () => void;
onDismissToday?: () => void;
onRetry?: () => void;
};
@@ -22,7 +25,7 @@ const levelMeta: Record<AnnouncementLevel, { label: string; dot: string; icon: t
critical: { label: "重要通知", dot: "bg-red-500", icon: ShieldAlert },
};
export function AnnouncementTimelineModal({ open, announcements, loading = false, error = "", onClose, onRetry }: AnnouncementTimelineModalProps) {
export function AnnouncementTimelineModal({ open, announcements, loading = false, error = "", automaticPrompt = false, onClose, onDismissOnce, onDismissToday, onRetry }: AnnouncementTimelineModalProps) {
const reducedMotion = useReducedMotion();
return (
@@ -30,7 +33,12 @@ export function AnnouncementTimelineModal({ open, announcements, loading = false
open={open}
width={960}
centered
footer={null}
footer={automaticPrompt ? (
<div className="flex flex-wrap justify-end gap-2">
<Button onClick={onDismissToday}>今日不再提醒</Button>
<Button type="primary" onClick={onDismissOnce}>关闭本次</Button>
</div>
) : null}
onCancel={onClose}
title={
<div className="flex min-w-0 items-center gap-3 pr-8">
@@ -111,7 +119,7 @@ function AnnouncementTimelineItem({ announcement, last, reducedMotion }: { annou
{announcement.pinned ? <span className="inline-flex items-center gap-1 text-[var(--fs-label)] font-medium text-amber-500"><Pin className="size-3" />置顶</span> : null}
</div>
{announcement.imageUrl ? <AntImage src={announcementImageUrl(announcement)} alt={`${announcement.title} 配图`} preview className="mt-3 max-h-36 w-56 max-w-full rounded-lg border border-border/70 bg-muted/20 object-contain p-1" /> : null}
<AnnouncementContent content={announcement.content} className="mt-1 text-sm leading-6 text-foreground/75 sm:text-[var(--fs-body-lg)]" />
{announcement.content ? <AnnouncementContent content={announcement.content} className="mt-1 text-sm leading-6 text-foreground/75 sm:text-[var(--fs-body-lg)]" /> : null}
<time dateTime={announcement.publishedAt} className="mt-2 block text-xs tabular-nums text-foreground/40">{relativeTime(announcement.publishedAt)} · {formatDateTime(announcement.publishedAt)}</time>
</div>
</motion.article>

View File

@@ -810,13 +810,10 @@ function AnnouncementEditor({
</Form.Item>
<Form.Item
name="content"
label="公告正文"
rules={[
{ required: true, whitespace: true, message: "请填写公告正文" },
{ max: 4000, message: "正文不能超过 4000 个字符" },
]}
label="公告正文(可选)"
rules={[{ max: 4000, message: "正文不能超过 4000 个字符" }]}
>
<Input.TextArea maxLength={4000} showCount autoSize={{ minRows: 14, maxRows: 24 }} placeholder="填写服务状态、影响范围和用户需要采取的操作" />
<Input.TextArea maxLength={4000} showCount autoSize={{ minRows: 14, maxRows: 24 }} placeholder="可选填写服务状态、影响范围和用户需要采取的操作" />
</Form.Item>
</section>
@@ -835,7 +832,7 @@ function AnnouncementEditor({
</div>
<h3>{watchedTitle?.trim() || "公告标题将在这里显示"}</h3>
{imagePreviewUrl ? <img src={imagePreviewUrl} alt="公告配图预览" className="mt-4 max-h-56 w-full rounded-lg border border-border/70 bg-muted/20 object-contain p-1" /> : null}
{watchedContent?.trim() ? <AnnouncementContent content={watchedContent.trim()} className="admin-announcement-preview-content" /> : <p className="admin-announcement-preview-placeholder">公告正文将在这里显示。</p>}
{watchedContent?.trim() ? <AnnouncementContent content={watchedContent.trim()} className="admin-announcement-preview-content" /> : <p className="admin-announcement-preview-placeholder">此公告仅展示标题。</p>}
</section>
</div>
</Form>
@@ -889,7 +886,7 @@ function AnnouncementEditor({
<div className="is-wide">
<dt>公告正文</dt>
<dd>
<AnnouncementContent content={pending.content} className="admin-announcement-confirm-content" />
{pending.content ? <AnnouncementContent content={pending.content} className="admin-announcement-confirm-content" /> : "无正文"}
</dd>
</div>
</dl>

View File

@@ -8,7 +8,13 @@ import { getAdminEmailSetting, updateAdminEmailSetting, type EmailSetting } from
import { useAppearanceStore } from "@/stores/use-appearance-store";
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "./admin-ui";
type EmailFormValues = Pick<EmailSetting, "enabled" | "host" | "port" | "username" | "password" | "encryption" | "fromEmail" | "fromName">;
type EmailFormValues = Pick<EmailSetting, "enabled" | "host" | "port" | "username" | "password" | "encryption" | "fromEmail" | "fromName"> & {
registrationAllowedDomains: string;
};
type NormalizedEmailFormValues = Omit<EmailFormValues, "registrationAllowedDomains"> & {
registrationAllowedDomains: string[];
};
export default function EmailSettingsPanel() {
const { message, modal } = App.useApp();
@@ -289,8 +295,25 @@ export default function EmailSettingsPanel() {
</SettingsSectionCard>
</div>
{draftEnabled ? (
<div id="admin-email-smtp" className="admin-settings-anchor">
<Form
form={form}
className="admin-email-form-stack"
layout="vertical"
requiredMark={false}
disabled={loading || refreshing || saving}
onValuesChange={() => {
const values = form.getFieldsValue(true);
setDraftEnabled(Boolean(values.enabled));
setDirty(hasEmailChanges(values, setting));
setSaveError("");
}}
>
<Form.Item name="enabled" valuePropName="checked" hidden>
<Switch />
</Form.Item>
{draftEnabled ? (
<div id="admin-email-smtp" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-email-section admin-email-configuration-section"
icon={<Server className="size-4" aria-hidden="true" />}
@@ -310,28 +333,12 @@ export default function EmailSettingsPanel() {
</Button>
) : null}
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || loading || refreshing} onClick={() => void submitSave()}>
保存并启用
{draftEnabled ? "保存并启用" : "保存设置"}
</Button>
</div>
</>
}
>
<Form
form={form}
layout="vertical"
requiredMark={false}
disabled={loading || refreshing || saving}
onValuesChange={() => {
const values = form.getFieldsValue(true);
setDraftEnabled(Boolean(values.enabled));
setDirty(hasEmailChanges(values, setting));
setSaveError("");
}}
>
<Form.Item name="enabled" valuePropName="checked" hidden>
<Switch />
</Form.Item>
<div className="admin-email-form-section">
<FormSectionTitle icon={<Server className="size-4" />} title="服务器连接" description="填写 SMTP 主机、端口和传输加密;STARTTLS 通常使用 587,直接 TLS 通常使用 465。" />
<div className="admin-email-form-grid is-connection">
@@ -402,10 +409,53 @@ export default function EmailSettingsPanel() {
</Form.Item>
</div>
</div>
</Form>
</SettingsSectionCard>
</div>
) : null}
<div id="admin-email-registration-domains" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-email-section"
icon={<MailCheck className="size-4" aria-hidden="true" />}
title="3. 电子邮件域名白名单"
description="每行填写一个允许注册的邮箱域名;不在白名单内的邮箱无法注册。"
status={<AdminStatusBadge label={currentValues.registrationAllowedDomains.length > 0 ? "已限制范围" : "不限制服务商"} tone={currentValues.registrationAllowedDomains.length > 0 ? "info" : "neutral"} />}
footer={
<>
<div className="admin-email-footer-note">
<BadgeCheck className="size-4" aria-hidden="true" />
<span>规则只影响新用户注册,不限制发件邮箱和已有账号找回密码</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{dirty ? (
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={resetDraft}>
撤销
</Button>
) : null}
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || loading || refreshing} onClick={() => void submitSave()}>
保存设置
</Button>
</div>
</>
}
>
<div className="admin-email-form-section">
<Form.Item
name="registrationAllowedDomains"
label="电子邮件域名白名单"
extra="每行一个域名。留空保存表示不限制邮箱域名。"
rules={[{ validator: (_, value: string | undefined) => validateDomainList(value) }]}
>
<Input.TextArea
autoSize={{ minRows: 9, maxRows: 16 }}
placeholder="gmail.com&#10;163.com&#10;126.com&#10;qq.com"
spellCheck={false}
/>
</Form.Item>
</div>
</SettingsSectionCard>
</div>
) : null}
</Form>
</div>
);
}
@@ -432,10 +482,11 @@ function toEmailFormValues(setting: EmailSetting): EmailFormValues {
encryption: setting.encryption,
fromEmail: setting.fromEmail,
fromName: setting.fromNameInherited ? "" : setting.fromName,
registrationAllowedDomains: setting.registrationAllowedDomains.join("\n"),
};
}
function normalizeEmailFormValues(values: Partial<EmailFormValues>): EmailFormValues {
function normalizeEmailFormValues(values: Partial<EmailFormValues>): NormalizedEmailFormValues {
return {
enabled: Boolean(values.enabled),
host: values.host?.trim() || "",
@@ -445,6 +496,7 @@ function normalizeEmailFormValues(values: Partial<EmailFormValues>): EmailFormVa
encryption: values.encryption === "tls" || values.encryption === "none" ? values.encryption : "starttls",
fromEmail: values.fromEmail?.trim().toLowerCase() || "",
fromName: values.fromName?.trim() || "",
registrationAllowedDomains: normalizeDomains(values.registrationAllowedDomains),
};
}
@@ -453,7 +505,8 @@ function hasEmailChanges(values: Partial<EmailFormValues>, setting: EmailSetting
const draft = normalizeEmailFormValues(values);
const saved = normalizeEmailFormValues(toEmailFormValues(setting));
if (draft.password) return true;
return (Object.keys(saved) as Array<keyof EmailFormValues>).some((key) => key !== "password" && draft[key] !== saved[key]);
const scalarFields: Array<keyof EmailFormValues> = ["enabled", "host", "port", "username", "encryption", "fromEmail", "fromName"];
return scalarFields.some((key) => draft[key] !== saved[key]) || !arraysEqual(draft.registrationAllowedDomains, saved.registrationAllowedDomains);
}
function validateEmailDraft(values: EmailFormValues, setting: EmailSetting | null) {
@@ -466,11 +519,11 @@ function validateEmailDraft(values: EmailFormValues, setting: EmailSetting | nul
return "";
}
function emailResponseMatches(setting: EmailSetting, expected: EmailFormValues) {
function emailResponseMatches(setting: EmailSetting, expected: NormalizedEmailFormValues) {
const actual = normalizeEmailFormValues(toEmailFormValues(setting));
const fields: Array<keyof EmailFormValues> = ["enabled", "host", "port", "username", "encryption", "fromEmail", "fromName"];
if (expected.password && !setting.hasPassword) return false;
return fields.every((key) => actual[key] === expected[key]);
return fields.every((key) => actual[key] === expected[key]) && arraysEqual(actual.registrationAllowedDomains, expected.registrationAllowedDomains);
}
function isEmailSetting(value: unknown): value is EmailSetting {
@@ -485,10 +538,36 @@ function isEmailSetting(value: unknown): value is EmailSetting {
typeof setting.fromEmail === "string" &&
typeof setting.fromName === "string" &&
typeof setting.fromNameInherited === "boolean" &&
typeof setting.hasPassword === "boolean"
typeof setting.hasPassword === "boolean" &&
Array.isArray(setting.registrationAllowedDomains) &&
setting.registrationAllowedDomains.every((value) => typeof value === "string")
);
}
function normalizeDomains(value?: string) {
return Array.from(
new Set(
(value || "")
.split(/[\s,,;;]+/)
.map((domain) => domain.trim().toLowerCase().replace(/^@/, "").replace(/\.$/, ""))
.filter(Boolean),
),
);
}
function arraysEqual(left: string[], right: string[]) {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function validateDomainList(value?: string) {
const invalid = invalidDomain(value);
return invalid ? Promise.reject(new Error(`邮箱域名格式不正确:“${invalid}”`)) : Promise.resolve();
}
function invalidDomain(value?: string) {
return normalizeDomains(value).find((domain) => !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(domain));
}
function isValidEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}

View File

@@ -147,6 +147,7 @@ export type EmailSetting = {
fromName: string;
fromNameInherited: boolean;
hasPassword: boolean;
registrationAllowedDomains: string[];
updatedBy?: string;
createdAt?: string;
updatedAt?: string;

View File

@@ -3337,6 +3337,12 @@
padding-bottom: 24px;
}
.admin-email-form-stack {
display: flex;
flex-direction: column;
gap: 16px;
}
.admin-email-settings .admin-settings-section {
border: 1px solid var(--admin-card-border);
}

View File

@@ -0,0 +1,14 @@
import { expect, test } from "bun:test";
test("announcement prompts preserve lazy loading and dismiss even when read synchronization fails", async () => {
const source = await Bun.file(new URL("../src/components/layout/system-announcement-center.tsx", import.meta.url)).text();
expect(source).toContain('lazy(() => import("@/components/ui/aceternity/announcement-timeline-modal")');
expect(source).toContain("{open ? (");
expect(source).toContain("<Suspense fallback={null}>");
expect(source).toContain("automaticPrompt={automaticPrompt}");
expect(source).toContain('onClose={() => dismissAutomaticPrompt("once")}');
expect(source).toContain('onDismissToday={() => dismissAutomaticPrompt("today")}');
expect(source).toContain("setDismissedFingerprint(fingerprint)");
expect(source).toContain("fingerprint === dismissedFingerprint");
expect(source).toContain("`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`");
});