From e33a210df1bd51d54d10651b3f031f1e83c1bc88 Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 10 Mar 2026 18:10:16 +0800 Subject: [PATCH 1/6] chroe: fix ts --- .../components/markdown-with-directive-schema.ts | 4 +++- .../base/markdown-with-directive/index.tsx | 12 +++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts b/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts index 02092407a9..43be150702 100644 --- a/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts +++ b/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts @@ -5,10 +5,12 @@ const commonSchema = { } export const withIconCardListPropsSchema = z.object(commonSchema).strict() +const HTTP_URL_REGEX = /^https?:\/\//i + export const withIconCardItemPropsSchema = z.object({ ...commonSchema, icon: z.string().trim().url().refine( - value => /^https?:\/\//i.test(value), + value => HTTP_URL_REGEX.test(value), 'icon must be a http/https URL', ), }).strict() diff --git a/web/app/components/base/markdown-with-directive/index.tsx b/web/app/components/base/markdown-with-directive/index.tsx index 2662f9356f..8c1a0fa878 100644 --- a/web/app/components/base/markdown-with-directive/index.tsx +++ b/web/app/components/base/markdown-with-directive/index.tsx @@ -34,17 +34,21 @@ function isMdastRoot(node: Parameters[0]): node is MdastRoot { return candidate.type === 'root' && Array.isArray(candidate.children) } +// Move the regex to module scope to avoid recompilation +const DIRECTIVE_ATTRIBUTE_BLOCK_REGEX = /^(\s*:+[a-z][\w-]*(?:\[[^\]\n]*\])?)\s+((?:\{[^}\n]*\}\s*)+)$/i +const ATTRIBUTE_BLOCK_REGEX = /\{([^}\n]*)\}/g + function normalizeDirectiveAttributeBlocks(markdown: string): string { const lines = markdown.split('\n') return lines.map((line) => { - const match = line.match(/^(\s*:+[a-z][\w-]*(?:\[[^\]\n]*\])?)\s+((?:\{[^}\n]*\}\s*)+)$/i) + const match = line.match(DIRECTIVE_ATTRIBUTE_BLOCK_REGEX) if (!match) return line const directivePrefix = match[1] const attributeBlocks = match[2] - const attrMatches = [...attributeBlocks.matchAll(/\{([^}\n]*)\}/g)] + const attrMatches = [...attributeBlocks.matchAll(ATTRIBUTE_BLOCK_REGEX)] if (attrMatches.length === 0) return line @@ -99,6 +103,8 @@ function isValidDirectiveAst(tree: Parameters[0]): boolean { return isValid } +const UNPARSED_DIRECTIVE_LIKE_TEXT_REGEX = /^\s*:{2,}[a-z][\w-]*/im + function hasUnparsedDirectiveLikeText(tree: Parameters[0]): boolean { let hasInvalidText = false @@ -108,7 +114,7 @@ function hasUnparsedDirectiveLikeText(tree: Parameters[0]): boolea const textNode = node as { value?: string } const value = textNode.value || '' - if (/^\s*:{2,}[a-z][\w-]*/im.test(value)) + if (UNPARSED_DIRECTIVE_LIKE_TEXT_REGEX.test(value)) hasInvalidText = true }) From a8fa3ff3cf57a3dda166be89f540c91ec8a4ddc3 Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 10 Mar 2026 18:30:09 +0800 Subject: [PATCH 2/6] chore: add tests --- .../markdown-with-directive/index.spec.tsx | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 web/app/components/base/markdown-with-directive/index.spec.tsx diff --git a/web/app/components/base/markdown-with-directive/index.spec.tsx b/web/app/components/base/markdown-with-directive/index.spec.tsx new file mode 100644 index 0000000000..8725a8080f --- /dev/null +++ b/web/app/components/base/markdown-with-directive/index.spec.tsx @@ -0,0 +1,180 @@ +import { render, screen } from '@testing-library/react' +import DOMPurify from 'dompurify' +import { validateDirectiveProps } from './components/markdown-with-directive-schema' +import WithIconCardItem from './components/with-icon-card-item' +import WithIconCardList from './components/with-icon-card-list' +import { MarkdownWithDirective } from './index' + +vi.mock('next/image', () => ({ + default: (props: React.ImgHTMLAttributes) => , +})) + +describe('markdown-with-directive', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Validate directive prop schemas and error paths. + describe('Directive schema validation', () => { + it('should return true when withiconcardlist props are valid', () => { + expect(validateDirectiveProps('withiconcardlist', { className: 'custom-list' })).toBe(true) + }) + + it('should return true when withiconcarditem props are valid', () => { + expect(validateDirectiveProps('withiconcarditem', { icon: 'https://example.com/icon.png' })).toBe(true) + }) + + it('should return false and log when directive name is unknown', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const isValid = validateDirectiveProps('unknown-directive', { className: 'custom-list' }) + + expect(isValid).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[markdown-with-directive] Unknown directive name.', + expect.objectContaining({ + attributes: { className: 'custom-list' }, + directive: 'unknown-directive', + }), + ) + }) + + it('should return false and log when withiconcarditem icon is not http/https', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const isValid = validateDirectiveProps('withiconcarditem', { icon: 'ftp://example.com/icon.png' }) + + expect(isValid).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[markdown-with-directive] Invalid directive props.', + expect.objectContaining({ + attributes: { icon: 'ftp://example.com/icon.png' }, + directive: 'withiconcarditem', + issues: expect.arrayContaining([ + expect.objectContaining({ + path: 'icon', + }), + ]), + }), + ) + }) + + it('should return false when extra props are provided to strict schema', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const isValid = validateDirectiveProps('withiconcardlist', { + className: 'custom-list', + extra: 'not-allowed', + }) + + expect(isValid).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[markdown-with-directive] Invalid directive props.', + expect.objectContaining({ + directive: 'withiconcardlist', + }), + ) + }) + }) + + // Validate WithIconCardList rendering and class merge behavior. + describe('WithIconCardList component', () => { + it('should render children and merge className with base class', () => { + const { container } = render( + + List child + , + ) + + expect(screen.getByText('List child')).toBeInTheDocument() + expect(container.firstElementChild).toHaveClass('space-y-1') + expect(container.firstElementChild).toHaveClass('custom-list-class') + }) + + it('should render base class when className is not provided', () => { + const { container } = render( + + Only base class + , + ) + + expect(screen.getByText('Only base class')).toBeInTheDocument() + expect(container.firstElementChild).toHaveClass('space-y-1') + }) + }) + + // Validate WithIconCardItem rendering and image prop forwarding. + describe('WithIconCardItem component', () => { + it('should render icon image and child content', () => { + render( + + Card item content + , + ) + + const icon = screen.getByAltText('icon') + expect(icon).toBeInTheDocument() + expect(icon).toHaveAttribute('src', 'https://example.com/icon.png') + expect(screen.getByText('Card item content')).toBeInTheDocument() + }) + }) + + // Validate markdown parsing pipeline, sanitizer usage, and invalid fallback. + describe('MarkdownWithDirective component', () => { + it('should render directives when markdown is valid', () => { + const markdown = [ + '::withiconcardlist {className="custom-list"}', + ':withiconcarditem[Card Title] {icon="https://example.com/icon.png"} {className="custom-item"}', + '::', + ].join('\n') + + const { container } = render() + + const list = container.querySelector('.custom-list') + expect(list).toBeInTheDocument() + expect(list).toHaveClass('space-y-1') + expect(screen.getByText('Card Title')).toBeInTheDocument() + expect(screen.getByAltText('icon')).toHaveAttribute('src', 'https://example.com/icon.png') + }) + + it('should replace output with invalid content when directive is unknown', () => { + const markdown = ':unknown[Bad Content]{foo="bar"}' + + render() + + expect(screen.getByText('invalid content')).toBeInTheDocument() + expect(screen.queryByText('Bad Content')).not.toBeInTheDocument() + }) + + it('should replace output with invalid content when directive props are invalid', () => { + const markdown = ':withiconcarditem[Invalid Icon]{icon="not-a-url"}' + + render() + + expect(screen.getByText('invalid content')).toBeInTheDocument() + expect(screen.queryByText('Invalid Icon')).not.toBeInTheDocument() + }) + + it('should call sanitizer and render based on sanitized markdown', () => { + const sanitizeSpy = vi.spyOn(DOMPurify, 'sanitize') + .mockReturnValue(':withiconcarditem[Sanitized]{icon="https://example.com/safe.png"}') + + render() + + expect(sanitizeSpy).toHaveBeenCalledWith('', { + ALLOWED_ATTR: [], + ALLOWED_TAGS: [], + }) + expect(screen.getByText('Sanitized')).toBeInTheDocument() + expect(screen.getByAltText('icon')).toHaveAttribute('src', 'https://example.com/safe.png') + }) + + it('should render empty output and skip sanitizer when markdown is empty', () => { + const sanitizeSpy = vi.spyOn(DOMPurify, 'sanitize') + const { container } = render() + + expect(sanitizeSpy).not.toHaveBeenCalled() + expect(container).toBeEmptyDOMElement() + }) + }) +}) From 862a907d02eb1907331dcdb2b0fdf91909ba24f6 Mon Sep 17 00:00:00 2001 From: Joel Date: Wed, 11 Mar 2026 10:12:53 +0800 Subject: [PATCH 3/6] chore: tests --- .../markdown-with-directive-schema.spec.ts | 73 +++++++++++++++++++ .../components/with-icon-card-item.spec.tsx | 29 ++++++++ .../components/with-icon-card-list.spec.tsx | 34 +++++++++ 3 files changed, 136 insertions(+) create mode 100644 web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.spec.ts create mode 100644 web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx create mode 100644 web/app/components/base/markdown-with-directive/components/with-icon-card-list.spec.tsx diff --git a/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.spec.ts b/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.spec.ts new file mode 100644 index 0000000000..9e74ed43b4 --- /dev/null +++ b/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.spec.ts @@ -0,0 +1,73 @@ +import { validateDirectiveProps } from './markdown-with-directive-schema' + +describe('markdown-with-directive-schema', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Validate the happy path for known directives. + describe('valid props', () => { + it('should return true for withiconcardlist when className is provided', () => { + expect(validateDirectiveProps('withiconcardlist', { className: 'custom-list' })).toBe(true) + }) + + it('should return true for withiconcarditem when icon is https URL', () => { + expect(validateDirectiveProps('withiconcarditem', { icon: 'https://example.com/icon.png' })).toBe(true) + }) + }) + + // Validate strict schema constraints and error branches. + describe('invalid props', () => { + it('should return false and log error for unknown directive name', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const isValid = validateDirectiveProps('unknown-directive', { className: 'custom-list' }) + + expect(isValid).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[markdown-with-directive] Unknown directive name.', + expect.objectContaining({ + attributes: { className: 'custom-list' }, + directive: 'unknown-directive', + }), + ) + }) + + it('should return false and log error for non-http icon URL', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const isValid = validateDirectiveProps('withiconcarditem', { icon: 'ftp://example.com/icon.png' }) + + expect(isValid).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[markdown-with-directive] Invalid directive props.', + expect.objectContaining({ + attributes: { icon: 'ftp://example.com/icon.png' }, + directive: 'withiconcarditem', + issues: expect.arrayContaining([ + expect.objectContaining({ + path: 'icon', + }), + ]), + }), + ) + }) + + it('should return false when extra field is provided to strict list schema', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const isValid = validateDirectiveProps('withiconcardlist', { + className: 'custom-list', + extra: 'not-allowed', + }) + + expect(isValid).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[markdown-with-directive] Invalid directive props.', + expect.objectContaining({ + directive: 'withiconcardlist', + }), + ) + }) + }) +}) diff --git a/web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx new file mode 100644 index 0000000000..e058947588 --- /dev/null +++ b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react' +import WithIconCardItem from './with-icon-card-item' + +vi.mock('next/image', () => ({ + default: ({ unoptimized: _unoptimized, ...props }: React.ImgHTMLAttributes & { unoptimized?: boolean }) => , +})) + +describe('WithIconCardItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Verify icon image and content rendering. + describe('rendering', () => { + it('should render icon image and children content', () => { + render( + + Card item content + , + ) + + const icon = screen.getByAltText('icon') + expect(icon).toBeInTheDocument() + expect(icon).toHaveAttribute('src', 'https://example.com/icon.png') + expect(icon).toHaveClass('object-contain') + expect(screen.getByText('Card item content')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/markdown-with-directive/components/with-icon-card-list.spec.tsx b/web/app/components/base/markdown-with-directive/components/with-icon-card-list.spec.tsx new file mode 100644 index 0000000000..d5b701b01c --- /dev/null +++ b/web/app/components/base/markdown-with-directive/components/with-icon-card-list.spec.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react' +import WithIconCardList from './with-icon-card-list' + +describe('WithIconCardList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Verify baseline rendering and className merge behavior. + describe('rendering', () => { + it('should render children and merge custom className with base class', () => { + const { container } = render( + + List child + , + ) + + expect(screen.getByText('List child')).toBeInTheDocument() + expect(container.firstElementChild).toHaveClass('space-y-1') + expect(container.firstElementChild).toHaveClass('custom-list-class') + }) + + it('should keep base class when className is not provided', () => { + const { container } = render( + + Only base class + , + ) + + expect(screen.getByText('Only base class')).toBeInTheDocument() + expect(container.firstElementChild).toHaveClass('space-y-1') + }) + }) +}) From bd7ffca12cff6a23d902cc798e0177c008beb61d Mon Sep 17 00:00:00 2001 From: Joel Date: Wed, 11 Mar 2026 10:21:19 +0800 Subject: [PATCH 4/6] chore: tests --- web/app/components/app/in-site-message/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/app/components/app/in-site-message/index.tsx b/web/app/components/app/in-site-message/index.tsx index 728f58fb30..a3f42e7b1b 100644 --- a/web/app/components/app/in-site-message/index.tsx +++ b/web/app/components/app/in-site-message/index.tsx @@ -25,8 +25,10 @@ type InSiteMessageProps = { title: string } +const LINE_BREAK_REGEX = /\\n/g + function normalizeLineBreaks(text: string): string { - return text.replace(/\\n/g, '\n') + return text.replace(LINE_BREAK_REGEX, '\n') } function normalizeLinkData(data: unknown): { href: string, rel?: string, target?: string } | null { From a9a9f412454259e316a26a5cdbbf2d5714d8a87a Mon Sep 17 00:00:00 2001 From: Joel Date: Wed, 11 Mar 2026 10:22:43 +0800 Subject: [PATCH 5/6] chore: tests --- .../app/in-site-message/index.spec.tsx | 133 ++++++++++++ .../app/in-site-message/notification.spec.tsx | 202 ++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 web/app/components/app/in-site-message/index.spec.tsx create mode 100644 web/app/components/app/in-site-message/notification.spec.tsx diff --git a/web/app/components/app/in-site-message/index.spec.tsx b/web/app/components/app/in-site-message/index.spec.tsx new file mode 100644 index 0000000000..4954d6106e --- /dev/null +++ b/web/app/components/app/in-site-message/index.spec.tsx @@ -0,0 +1,133 @@ +/* eslint-disable e18e/prefer-static-regex */ +import type { InSiteMessageActionItem } from './index' +import { fireEvent, render, screen } from '@testing-library/react' +import InSiteMessage from './index' + +describe('InSiteMessage', () => { + const originalLocation = window.location + + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('open', vi.fn()) + }) + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + configurable: true, + }) + vi.unstubAllGlobals() + }) + + const renderComponent = (actions: InSiteMessageActionItem[], props?: Partial>) => { + return render( + , + ) + } + + // Validate baseline rendering and content normalization. + describe('Rendering', () => { + it('should render title, subtitle, markdown content, and action buttons', () => { + const actions: InSiteMessageActionItem[] = [ + { action: 'close', text: 'Close', type: 'default' }, + { action: 'link', text: 'Learn more', type: 'primary', data: 'https://example.com' }, + ] + + renderComponent(actions, { className: 'custom-message' }) + + const closeButton = screen.getByRole('button', { name: 'Close' }) + const learnMoreButton = screen.getByRole('button', { name: 'Learn more' }) + const panel = closeButton.closest('div.fixed') + const titleElement = panel?.querySelector('.title-3xl-bold') + const subtitleElement = panel?.querySelector('.body-md-regular') + expect(panel).toHaveClass('custom-message') + expect(titleElement).toHaveTextContent(/Title.*Line/s) + expect(subtitleElement).toHaveTextContent(/Subtitle.*Line/s) + expect(titleElement?.textContent).not.toContain('\\n') + expect(subtitleElement?.textContent).not.toContain('\\n') + expect(screen.getByText('Main content')).toBeInTheDocument() + expect(closeButton).toBeInTheDocument() + expect(learnMoreButton).toBeInTheDocument() + }) + + it('should fallback to default header background when headerBgUrl is empty string', () => { + const actions: InSiteMessageActionItem[] = [{ action: 'close', text: 'Close', type: 'default' }] + + const { container } = renderComponent(actions, { headerBgUrl: '' }) + const header = container.querySelector('div[style]') + expect(header).toHaveStyle({ backgroundImage: 'url(/in-site-message/header-bg.svg)' }) + }) + }) + + // Validate action handling for close and link actions. + describe('Actions', () => { + it('should call onAction and hide component when close action is clicked', () => { + const onAction = vi.fn() + const closeAction: InSiteMessageActionItem = { action: 'close', text: 'Close', type: 'default' } + + renderComponent([closeAction], { onAction }) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + + expect(onAction).toHaveBeenCalledWith(closeAction) + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument() + }) + + it('should open a new tab when link action data is a string', () => { + const linkAction: InSiteMessageActionItem = { + action: 'link', + text: 'Open link', + type: 'primary', + data: 'https://example.com', + } + + renderComponent([linkAction]) + fireEvent.click(screen.getByRole('button', { name: 'Open link' })) + + expect(window.open).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer') + }) + + it('should navigate with location.assign when link action target is _self', () => { + const assignSpy = vi.fn() + Object.defineProperty(window, 'location', { + value: { + ...originalLocation, + assign: assignSpy, + }, + configurable: true, + }) + + const linkAction: InSiteMessageActionItem = { + action: 'link', + text: 'Open self', + type: 'primary', + data: { href: 'https://example.com/self', target: '_self' }, + } + + renderComponent([linkAction]) + fireEvent.click(screen.getByRole('button', { name: 'Open self' })) + + expect(assignSpy).toHaveBeenCalledWith('https://example.com/self') + expect(window.open).not.toHaveBeenCalled() + }) + + it('should not trigger navigation when link data is invalid', () => { + const linkAction: InSiteMessageActionItem = { + action: 'link', + text: 'Broken link', + type: 'primary', + data: { rel: 'noopener' }, + } + + renderComponent([linkAction]) + fireEvent.click(screen.getByRole('button', { name: 'Broken link' })) + + expect(window.open).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/app/in-site-message/notification.spec.tsx b/web/app/components/app/in-site-message/notification.spec.tsx new file mode 100644 index 0000000000..516e8c7112 --- /dev/null +++ b/web/app/components/app/in-site-message/notification.spec.tsx @@ -0,0 +1,202 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import InSiteMessageNotification from './notification' + +const { + mockConfig, + mockNotification, + mockNotificationDismiss, +} = vi.hoisted(() => ({ + mockConfig: { + isCloudEdition: true, + }, + mockNotification: vi.fn(), + mockNotificationDismiss: vi.fn(), +})) + +vi.mock('@/config', () => ({ + get IS_CLOUD_EDITION() { + return mockConfig.isCloudEdition + }, +})) + +vi.mock('@/service/client', () => ({ + consoleClient: { + notification: (...args: unknown[]) => mockNotification(...args), + notificationDismiss: (...args: unknown[]) => mockNotificationDismiss(...args), + }, + consoleQuery: { + notification: { + queryKey: () => ['console', 'notification'], + }, + notificationDismiss: { + mutationKey: () => ['console', 'notificationDismiss'], + }, + }, +})) + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }) + + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + + return Wrapper +} + +describe('InSiteMessageNotification', () => { + beforeEach(() => { + vi.clearAllMocks() + mockConfig.isCloudEdition = true + vi.stubGlobal('open', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + // Validate query gating and empty state rendering. + describe('Rendering', () => { + it('should render null and skip query when not cloud edition', async () => { + mockConfig.isCloudEdition = false + const Wrapper = createWrapper() + const { container } = render(, { wrapper: Wrapper }) + + await waitFor(() => { + expect(mockNotification).not.toHaveBeenCalled() + }) + expect(container).toBeEmptyDOMElement() + }) + + it('should render null when notification list is empty', async () => { + mockNotification.mockResolvedValue({ notifications: [] }) + const Wrapper = createWrapper() + const { container } = render(, { wrapper: Wrapper }) + + await waitFor(() => { + expect(mockNotification).toHaveBeenCalledTimes(1) + }) + expect(container).toBeEmptyDOMElement() + }) + }) + + // Validate parsed-body behavior and action handling. + describe('Notification body parsing and actions', () => { + it('should render parsed main/actions and dismiss only on close action', async () => { + mockNotification.mockResolvedValue({ + notifications: [ + { + notification_id: 'n-1', + title: 'Update title', + subtitle: 'Update subtitle', + title_pic_url: 'https://example.com/bg.png', + body: JSON.stringify({ + main: 'Parsed body main', + actions: [ + { action: 'link', data: 'https://example.com/docs', text: 'Visit docs', type: 'primary' }, + { action: 'close', text: 'Dismiss now', type: 'default' }, + { action: 'link', data: 'https://example.com/invalid', text: 100, type: 'primary' }, + ], + }), + }, + ], + }) + mockNotificationDismiss.mockResolvedValue({ success: true }) + + const Wrapper = createWrapper() + render(, { wrapper: Wrapper }) + + await waitFor(() => { + expect(screen.getByText('Parsed body main')).toBeInTheDocument() + }) + expect(screen.getByRole('button', { name: 'Visit docs' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Dismiss now' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Invalid' })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Visit docs' })) + expect(mockNotificationDismiss).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss now' })) + await waitFor(() => { + expect(mockNotificationDismiss).toHaveBeenCalledWith({ + body: { + notification_id: 'n-1', + }, + }) + }) + }) + + it('should fallback to raw body and default close action when body is invalid json', async () => { + mockNotification.mockResolvedValue({ + notifications: [ + { + notification_id: 'n-2', + title: 'Fallback title', + subtitle: 'Fallback subtitle', + title_pic_url: 'https://example.com/bg-2.png', + body: 'raw body text', + }, + ], + }) + mockNotificationDismiss.mockResolvedValue({ success: true }) + + const Wrapper = createWrapper() + render(, { wrapper: Wrapper }) + + await waitFor(() => { + expect(screen.getByText('raw body text')).toBeInTheDocument() + }) + + const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) + fireEvent.click(closeButton) + + await waitFor(() => { + expect(mockNotificationDismiss).toHaveBeenCalledWith({ + body: { + notification_id: 'n-2', + }, + }) + }) + }) + + it('should fallback to default close action when parsed actions are all invalid', async () => { + mockNotification.mockResolvedValue({ + notifications: [ + { + notification_id: 'n-3', + title: 'Invalid action title', + subtitle: 'Invalid action subtitle', + title_pic_url: 'https://example.com/bg-3.png', + body: JSON.stringify({ + main: 'Main from parsed body', + actions: [ + { action: 'link', type: 'primary', text: 100, data: 'https://example.com' }, + ], + }), + }, + ], + }) + + const Wrapper = createWrapper() + render(, { wrapper: Wrapper }) + + await waitFor(() => { + expect(screen.getByText('Main from parsed body')).toBeInTheDocument() + }) + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() + }) + }) +}) From 6071d019e1bbdb41408a3730b12c79dd6c562ac5 Mon Sep 17 00:00:00 2001 From: Joel Date: Wed, 11 Mar 2026 10:51:17 +0800 Subject: [PATCH 6/6] fix: stream down render problem and remove useless classnames --- .../components/app/in-site-message/index.tsx | 2 +- .../markdown-with-directive/index.spec.tsx | 16 ++++++++++++++++ .../base/markdown-with-directive/index.tsx | 18 +++++++++++------- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/web/app/components/app/in-site-message/index.tsx b/web/app/components/app/in-site-message/index.tsx index a3f42e7b1b..fdae2827a9 100644 --- a/web/app/components/app/in-site-message/index.tsx +++ b/web/app/components/app/in-site-message/index.tsx @@ -110,7 +110,7 @@ function InSiteMessage({ -
+
diff --git a/web/app/components/base/markdown-with-directive/index.spec.tsx b/web/app/components/base/markdown-with-directive/index.spec.tsx index 8725a8080f..2fab23f7ce 100644 --- a/web/app/components/base/markdown-with-directive/index.spec.tsx +++ b/web/app/components/base/markdown-with-directive/index.spec.tsx @@ -5,6 +5,8 @@ import WithIconCardItem from './components/with-icon-card-item' import WithIconCardList from './components/with-icon-card-list' import { MarkdownWithDirective } from './index' +const FOUR_COLON_RE = /:{4}/ + vi.mock('next/image', () => ({ default: (props: React.ImgHTMLAttributes) => , })) @@ -155,6 +157,20 @@ describe('markdown-with-directive', () => { expect(screen.queryByText('Invalid Icon')).not.toBeInTheDocument() }) + it('should not render trailing fence text for four-colon container directives', () => { + const markdown = [ + '::::withiconcardlist {className="custom-list"}', + ':withiconcarditem[Card Title]{icon="https://example.com/icon.png"}', + '::::', + ].join('\n') + + const { container } = render() + + expect(screen.getByText('Card Title')).toBeInTheDocument() + expect(screen.queryByText(FOUR_COLON_RE)).not.toBeInTheDocument() + expect(container.textContent).not.toContain('::::') + }) + it('should call sanitizer and render based on sanitized markdown', () => { const sanitizeSpy = vi.spyOn(DOMPurify, 'sanitize') .mockReturnValue(':withiconcarditem[Sanitized]{icon="https://example.com/safe.png"}') diff --git a/web/app/components/base/markdown-with-directive/index.tsx b/web/app/components/base/markdown-with-directive/index.tsx index b40aebf97b..c92c49e741 100644 --- a/web/app/components/base/markdown-with-directive/index.tsx +++ b/web/app/components/base/markdown-with-directive/index.tsx @@ -270,12 +270,16 @@ export function MarkdownWithDirective({ markdown }: MarkdownWithDirectiveProps) return null return ( - - {normalizedMarkdown} - +
+ + {normalizedMarkdown} + +
+ ) }