Files
open-design/apps/web/tests/components/MessageCenter.test.tsx
T
lefarcenandGitHub bdaeead758 fix(web): expand message center rows inline (#6851)
* fix(web): expand message center rows inline

* fix(web): keep paused placeholder suggestions submittable
2026-08-13 14:55:38 +00:00

487 lines
21 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MessageCenter } from '../../src/components/MessageCenter';
import { I18nProvider, useI18n } from '../../src/i18n';
import type { MessageCenterMessage } from '../../src/message-center-client';
const defaultMessages: MessageCenterMessage[] = [
{ id: 'release', audienceType: 'global', typeName: 'Product update', title: 'Open Design 0.14 is available', body: 'The new release is ready.', ctaLabel: 'View update', ctaUrl: 'https://open-design.ai/update', publishedAt: '2026-07-16T12:00:00.000Z', readAt: null },
{ id: 'benefit', audienceType: 'targeted', typeName: 'Benefit', title: 'Credits added', body: 'Your credits are ready.', ctaLabel: null, ctaUrl: null, publishedAt: '2026-07-15T12:00:00.000Z', readAt: '2026-07-16T01:00:00.000Z' },
];
function mockFetch(
options: {
loggedIn?: boolean;
messages?: MessageCenterMessage[];
onStatus?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
onMessages?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
onRead?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
} = {},
) {
const { loggedIn = false, messages = defaultMessages, onStatus, onMessages, onRead } = options;
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes('/status')) return onStatus ? onStatus(input, init) : Response.json({ loggedIn });
if (url.includes('/messages?')) return onMessages ? onMessages(input, init) : Response.json({ messages, nextCursor: null, unreadCount: 1 });
if (url.includes('/read')) return onRead ? onRead(input, init) : Response.json({ read: true, markedCount: 1 });
return new Response(null, { status: 404 });
}));
}
function renderMessageCenter(locale: 'en' | 'zh-CN' = 'en') {
const onOpenNotificationSettings = vi.fn();
const result = render(<I18nProvider initial={locale}><MessageCenter onOpenNotificationSettings={onOpenNotificationSettings}/></I18nProvider>);
return { ...result, onOpenNotificationSettings };
}
function LocaleSwitcher() {
const { setLocale } = useI18n();
return (
<button type="button" onClick={() => setLocale('fr')}>
Switch locale
</button>
);
}
async function openCenter(unreadCount = 1) {
await waitFor(() => expect(screen.getByLabelText(new RegExp(`Open message center \\(${unreadCount} unread\\)`))).toBeTruthy());
fireEvent.click(screen.getByTestId('message-center-trigger'));
return screen.getByTestId('message-center-dialog');
}
beforeEach(() => {
localStorage.clear();
mockFetch();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('MessageCenter', () => {
it('formats published dates using the selected locale', async () => {
const publishedAt = new Date(defaultMessages[0]!.publishedAt);
const zhDate = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium' }).format(publishedAt);
const enDate = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(publishedAt);
renderMessageCenter('zh-CN');
fireEvent.click(screen.getByTestId('message-center-trigger'));
await waitFor(() => {
expect(screen.getByText(zhDate)).toBeTruthy();
});
expect(zhDate).not.toBe(enDate);
expect(screen.queryByText(enDate)).toBeNull();
});
it('renders API messages for anonymous clients without a local window', async () => {
renderMessageCenter();
const dialog = await openCenter();
expect(within(dialog).getByText('Open Design 0.14 is available')).toBeTruthy();
expect(localStorage.getItem('open-design.message-center.anonymous-started-at.v1')).toBeNull();
const anonymousPull = vi.mocked(fetch).mock.calls.find(([url]) => String(url).includes('/api-proxy/') && String(url).includes('/messages?'));
expect(String(anonymousPull?.[0])).not.toContain('startedAt=');
});
it('falls back to the public feed when the AMR runtime is unavailable', async () => {
mockFetch({
onStatus: async () => Response.json({ error: 'amr-runtime-unavailable' }, { status: 503 }),
});
renderMessageCenter();
const dialog = await openCenter();
expect(within(dialog).getByText('Open Design 0.14 is available')).toBeTruthy();
expect(
vi.mocked(fetch).mock.calls.some(([url]) =>
String(url).includes('/api/integrations/vela/message-center-public/messages?'),
),
).toBe(true);
expect(screen.queryByText('Check failed. Please retry.')).toBeNull();
});
it('keeps anonymous read state locally and restores it', async () => {
renderMessageCenter();
await openCenter();
fireEvent.click(screen.getByRole('button', { name: /Open Design 0\.14 is available/ }));
await waitFor(() => expect(screen.queryByLabelText(/unread/)).toBeNull());
expect(localStorage.getItem('open-design.message-center.anonymous-read-ids.v1')).toContain('release');
});
it('uses account read endpoints when logged in', async () => {
mockFetch({ loggedIn: true });
renderMessageCenter();
await openCenter();
fireEvent.click(screen.getByRole('button', { name: /Open Design 0\.14 is available/ }));
await waitFor(() => expect(vi.mocked(fetch).mock.calls.some(([url, init]) => String(url).includes('/release/read') && init?.method === 'POST')).toBe(true));
});
it('filters messages and marks all read', async () => {
renderMessageCenter();
await openCenter();
fireEvent.click(screen.getByRole('button', { name: 'Unread' }));
expect(screen.getByText('Open Design 0.14 is available')).toBeTruthy();
expect(screen.queryByText('Credits added')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Mark all read' }));
await waitFor(() => expect(screen.getByText('All caught up')).toBeTruthy());
});
it('expands the whole message row without rendering view actions', async () => {
renderMessageCenter();
await openCenter();
const row = screen.getByRole('button', { name: /Open Design 0\.14 is available/ });
expect(row).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByText('View')).toBeNull();
expect(screen.queryByRole('button', { name: 'View update' })).toBeNull();
fireEvent.click(row);
expect(row).toHaveAttribute('aria-expanded', 'true');
expect(row.closest('article')?.className).toContain('itemExpanded');
expect(screen.getByText('The new release is ready.')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'View update' })).toBeNull();
});
it('keeps both anonymous reads when two expands resolve out of order', async () => {
const concurrentMessages = [
{ ...defaultMessages[0]!, id: 'release', title: 'Release update', readAt: null, ctaLabel: null, ctaUrl: null },
{ ...defaultMessages[0]!, id: 'security', title: 'Security notice', readAt: null, ctaLabel: null, ctaUrl: null },
] satisfies MessageCenterMessage[];
let resolveFirst: (() => void) | undefined;
let resolveSecond: (() => void) | undefined;
const readRequests: string[] = [];
mockFetch({
loggedIn: true,
messages: concurrentMessages,
onRead: (input) =>
new Promise<Response>((resolve) => {
readRequests.push(String(input));
if (!resolveFirst) {
resolveFirst = () => resolve(Response.json({ read: true, markedCount: 1 }));
return;
}
resolveSecond = () => resolve(Response.json({ read: true, markedCount: 1 }));
}),
});
renderMessageCenter();
await openCenter(2);
fireEvent.click(screen.getByRole('button', { name: /Release update/ }));
fireEvent.click(screen.getByRole('button', { name: /Security notice/ }));
await waitFor(() => expect(readRequests).toEqual(expect.arrayContaining([
expect.stringContaining('/messages/release/read'),
expect.stringContaining('/messages/security/read'),
])));
resolveSecond?.();
await waitFor(() => expect(screen.queryByLabelText(/2 unread/)).toBeNull());
resolveFirst?.();
await waitFor(() => {
expect(screen.queryByLabelText(/unread/)).toBeNull();
expect(localStorage.getItem('open-design.message-center.anonymous-read-ids.v1')).toBeNull();
});
});
it('uses account mark-read before the first delayed message sync resolves', async () => {
const cachedMessages = [
{ ...defaultMessages[0]!, id: 'release', title: 'Release update', readAt: null, ctaLabel: null, ctaUrl: null },
] satisfies MessageCenterMessage[];
let releaseMessages: (() => void) | undefined;
localStorage.setItem('open-design.message-center.anonymous-started-at.v1', '2026-07-16T00:00:00.000Z');
localStorage.setItem('open-design.message-center.anonymous-messages.v1', JSON.stringify(cachedMessages));
localStorage.setItem('open-design.message-center.anonymous-read-ids.v1', JSON.stringify([]));
mockFetch({
loggedIn: true,
onMessages: () =>
new Promise<Response>((resolve) => {
releaseMessages = () => resolve(Response.json({ messages: cachedMessages, nextCursor: null, unreadCount: 1 }));
}),
});
renderMessageCenter();
await openCenter(1);
fireEvent.click(screen.getByRole('button', { name: /Release update/ }));
await waitFor(() =>
expect(
vi.mocked(fetch).mock.calls.some(
([url, init]) => String(url).includes('/messages/release/read') && init?.method === 'POST',
),
).toBe(true),
);
expect(localStorage.getItem('open-design.message-center.anonymous-read-ids.v1')).toBeNull();
releaseMessages?.();
await waitFor(() => expect(screen.queryByLabelText(/unread/)).toBeNull());
});
it('re-checks auth on write after an anonymous mount so mid-session login uses account reads', async () => {
const cachedMessages = [
{ ...defaultMessages[0]!, id: 'release', title: 'Release update', readAt: null, ctaLabel: null, ctaUrl: null },
] satisfies MessageCenterMessage[];
let loggedIn = false;
let statusCalls = 0;
localStorage.setItem('open-design.message-center.anonymous-started-at.v1', '2026-07-16T00:00:00.000Z');
localStorage.setItem('open-design.message-center.anonymous-messages.v1', JSON.stringify(cachedMessages));
localStorage.setItem('open-design.message-center.anonymous-read-ids.v1', JSON.stringify([]));
mockFetch({
onStatus: async () => {
statusCalls += 1;
return Response.json({ loggedIn });
},
onMessages: async () => Response.json({ messages: cachedMessages, nextCursor: null, unreadCount: 1 }),
});
renderMessageCenter();
await openCenter(1);
expect(statusCalls).toBeGreaterThanOrEqual(2);
loggedIn = true;
fireEvent.click(screen.getByRole('button', { name: /Release update/ }));
await waitFor(() =>
expect(
vi.mocked(fetch).mock.calls.some(
([url, init]) => String(url).includes('/messages/release/read') && init?.method === 'POST',
),
).toBe(true),
);
expect(localStorage.getItem('open-design.message-center.anonymous-read-ids.v1')).toBeNull();
});
it('keeps visible account messages when locale changes and the follow-up sync fails', async () => {
let messageRequests = 0;
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes('/status')) return Response.json({ loggedIn: true });
if (url.includes('/messages?')) {
messageRequests += 1;
if (messageRequests === 1) {
return Response.json({ messages: defaultMessages, nextCursor: null, unreadCount: 1 });
}
return new Response(null, { status: 500 });
}
if (url.includes('/read')) return Response.json({ read: true, markedCount: 1 });
return new Response(null, { status: 404 });
}));
render(
<I18nProvider initial="en">
<LocaleSwitcher />
<MessageCenter />
</I18nProvider>,
);
await openCenter();
await waitFor(() => expect(screen.getByText('Open Design 0.14 is available')).toBeTruthy());
fireEvent.click(screen.getByRole('button', { name: 'Switch locale' }));
await waitFor(() => expect(messageRequests).toBeGreaterThanOrEqual(2));
expect(screen.getByText('Open Design 0.14 is available')).toBeTruthy();
expect(screen.getByRole('status')).toBeTruthy();
expect(within(screen.getByRole('status')).getByRole('button')).toBeTruthy();
});
it('shows a loading state instead of the empty copy during the first empty sync', async () => {
let messageRequests = 0;
let releaseFirstRequest: (() => void) | undefined;
mockFetch({
messages: [],
onMessages: () =>
new Promise<Response>((resolve) => {
messageRequests += 1;
if (messageRequests === 1) {
releaseFirstRequest = () => resolve(Response.json({ messages: [], nextCursor: null, unreadCount: 0 }));
return;
}
resolve(Response.json({ messages: [], nextCursor: null, unreadCount: 0 }));
}),
});
renderMessageCenter();
fireEvent.click(screen.getByTestId('message-center-trigger'));
expect(screen.getByRole('status')).toHaveTextContent('Checking for updates...');
expect(screen.queryByText('No messages yet')).toBeNull();
releaseFirstRequest?.();
await waitFor(() => expect(screen.getByText('No messages yet')).toBeTruthy());
});
it('shows retry controls instead of the empty copy when the first empty sync fails', async () => {
let messageRequests = 0;
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes('/status')) return Response.json({ loggedIn: false });
if (url.includes('/messages?')) {
messageRequests += 1;
if (messageRequests <= 2) return new Response(null, { status: 500 });
return Response.json({ messages: [], nextCursor: null, unreadCount: 0 });
}
if (url.includes('/read')) return Response.json({ read: true, markedCount: 1 });
return new Response(null, { status: 404 });
}));
renderMessageCenter();
fireEvent.click(screen.getByTestId('message-center-trigger'));
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Check failed. Please retry.'));
expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy();
expect(screen.queryByText('No messages yet')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
await waitFor(() => expect(screen.getByText('No messages yet')).toBeTruthy());
expect(messageRequests).toBeGreaterThanOrEqual(2);
});
it('renders cached messages when a published date is invalid', async () => {
const cachedMessages = [
{
...defaultMessages[0]!,
id: 'invalid-date',
title: 'Cached update',
body: 'Cached body remains visible.',
publishedAt: 'not-a-date',
readAt: null,
ctaLabel: null,
ctaUrl: null,
},
] satisfies MessageCenterMessage[];
localStorage.setItem('open-design.message-center.anonymous-started-at.v1', '2026-07-16T00:00:00.000Z');
localStorage.setItem('open-design.message-center.anonymous-messages.v1', JSON.stringify(cachedMessages));
localStorage.setItem('open-design.message-center.anonymous-read-ids.v1', JSON.stringify([]));
mockFetch({
onMessages: async () => new Response(null, { status: 500 }),
});
renderMessageCenter();
const dialog = await openCenter(1);
expect(within(dialog).getByText('Cached update')).toBeTruthy();
expect(within(dialog).getByText('Cached body remains visible.')).toBeTruthy();
expect(dialog.querySelector('time')).toBeNull();
});
it('hydrates cached anonymous state through the ref-backed source of truth', async () => {
const cachedMessages = [
{ ...defaultMessages[0]!, id: 'release', title: 'Release update', readAt: null, ctaLabel: null, ctaUrl: null },
{ ...defaultMessages[0]!, id: 'security', title: 'Security notice', readAt: null, ctaLabel: null, ctaUrl: null },
] satisfies MessageCenterMessage[];
localStorage.setItem('open-design.message-center.anonymous-started-at.v1', '2026-07-16T00:00:00.000Z');
localStorage.setItem('open-design.message-center.anonymous-messages.v1', JSON.stringify(cachedMessages));
localStorage.setItem('open-design.message-center.anonymous-read-ids.v1', JSON.stringify([]));
mockFetch({
onMessages: async () => new Response(null, { status: 500 }),
});
renderMessageCenter();
await openCenter(2);
expect(screen.getByText('Release update')).toBeTruthy();
expect(screen.getByRole('status')).toHaveTextContent('Check failed. Please retry.');
fireEvent.click(screen.getByRole('button', { name: /Release update/ }));
await waitFor(() =>
expect(localStorage.getItem('open-design.message-center.anonymous-read-ids.v1')).toContain('release'),
);
fireEvent.click(screen.getByRole('button', { name: 'Mark all read' }));
await waitFor(() =>
expect(localStorage.getItem('open-design.message-center.anonymous-read-ids.v1')).toContain('security'),
);
expect(localStorage.getItem('open-design.message-center.anonymous-read-ids.v1')).toContain('release');
expect(localStorage.getItem('open-design.message-center.anonymous-read-ids.v1')).toContain('security');
expect(localStorage.getItem('open-design.message-center.anonymous-messages.v1')).toContain('Release update');
});
it('drops account read ids when a mounted session falls back to anonymous', async () => {
let loggedIn = true;
mockFetch({
onStatus: async () => Response.json({ loggedIn }),
messages: [{ ...defaultMessages[0]!, id: 'release', title: 'Release update', readAt: null }],
});
renderMessageCenter();
await openCenter(1);
fireEvent.click(screen.getByRole('button', { name: /Release update/ }));
await waitFor(() =>
expect(
vi.mocked(fetch).mock.calls.some(
([url, init]) => String(url).includes('/messages/release/read') && init?.method === 'POST',
),
).toBe(true),
);
await waitFor(() => expect(screen.queryByLabelText(/unread/)).toBeNull());
loggedIn = false;
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
});
fireEvent(document, new Event('visibilitychange'));
await waitFor(() =>
expect(screen.getByLabelText(/Open message center \(1 unread\)/)).toBeTruthy(),
);
expect(localStorage.getItem('open-design.message-center.anonymous-read-ids.v1')).not.toContain('release');
});
it('reports mark-read failures without throwing an unhandled rejection', async () => {
const rejection = new Error('mark-read failed');
mockFetch({
loggedIn: true,
onRead: async () => {
throw rejection;
},
});
const unhandled = vi.fn();
window.addEventListener('unhandledrejection', unhandled);
renderMessageCenter();
await openCenter();
fireEvent.click(screen.getByRole('button', { name: /Open Design 0\.14 is available/ }));
await waitFor(() => expect(screen.getByText('Open Design 0.14 is available')).toBeTruthy());
expect(unhandled).not.toHaveBeenCalled();
window.removeEventListener('unhandledrejection', unhandled);
});
it('shows an inline sync error banner when mark-read fails with visible messages', async () => {
let readAttempts = 0;
mockFetch({
loggedIn: true,
onRead: async () => {
readAttempts += 1;
throw new Error('mark-read failed');
},
});
renderMessageCenter();
await openCenter();
await waitFor(() =>
expect(
vi.mocked(fetch).mock.calls.filter(([url]) => String(url).includes('/messages?')).length,
).toBeGreaterThanOrEqual(2),
);
fireEvent.click(screen.getByRole('button', { name: /Open Design 0\.14 is available/ }));
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Check failed. Please retry.'));
expect(screen.getByText('Open Design 0.14 is available')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
await waitFor(() => expect(screen.queryByRole('status')).toBeNull());
expect(readAttempts).toBe(1);
});
it('closes with Escape and restores trigger focus', async () => {
renderMessageCenter();
const trigger = screen.getByTestId('message-center-trigger');
await openCenter();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByTestId('message-center-dialog')).toBeNull();
expect(document.activeElement).toBe(trigger);
});
});