Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion zeppelin-web-angular/projects/zeppelin-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,4 +201,6 @@ Pass `describeParagraph` and `onOpenParagraph` to enable paragraph reference but

HTML is displayed as text and dangerous link URLs are blocked.

See **Assistant / MarkdownAnswer** in Storybook for complete answers, streaming, and paragraph reference examples in both themes.
Markdown images load only after you select **Load image**. Review the displayed destination first: loading may transmit information included in the URL. Only HTTP(S) URLs are supported.

See **Assistant / MarkdownAnswer** in Storybook for examples in both themes.
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,58 @@ export const paragraphReference = style({
}
});

export const imagePlaceholder = style({
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: vars.space2,
maxWidth: '100%',
margin: `${vars.space1} 0`,
padding: vars.space3,
border: `1px solid ${vars.border}`,
borderRadius: vars.radius,
background: vars.fill,
verticalAlign: 'middle'
});

export const imageDescription = style({
fontWeight: 600
});

export const imageDetails = style({
display: 'flex',
flexDirection: 'column',
gap: vars.space1,
maxWidth: '100%',
color: vars.textSecondary,
fontSize: vars.fontSizeSm,
overflowWrap: 'anywhere'
});

export const loadImage = style({
padding: `${vars.space1} ${vars.space2}`,
border: `1px solid ${vars.accentBorder}`,
borderRadius: vars.radiusSm,
background: vars.accentBg,
color: vars.accentText,
font: 'inherit',
cursor: 'pointer',
selectors: {
'&:hover': { color: vars.accentTextHover }
}
});

export const loadedImage = style({
display: 'block',
maxWidth: '100%',
height: 'auto'
});

export const imageLink = style({
display: 'inline',
maxWidth: '100%'
});

export const codeBlock = style({
minWidth: 0,
maxWidth: '100%',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
*/

import { fireEvent, render, screen, within } from '@testing-library/react';
import { StrictMode, useLayoutEffect } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { visuallyHidden } from '@/shared/ui/assistant-theme/AssistantScope.css';
import { MarkdownAnswer } from './MarkdownAnswer';
Expand Down Expand Up @@ -416,6 +417,274 @@ describe('MarkdownAnswer', () => {
});
});

describe('image loading', () => {
const sourceA = 'https://images.example.test/plot.svg?q=SYNTHETIC_NOTEBOOK_VALUE';
const sourceB = 'https://images.example.test/plot.svg?q=UPDATED_NOTEBOOK_VALUE';
const imageMarkdown = (source: string) => `![Quarterly revenue](${source} "Revenue plot")`;

it('shows the resolved destination and warning without an image until explicitly activated', () => {
const { container } = render(<MarkdownAnswer content={imageMarkdown(sourceA)} />);

expect(screen.queryByRole('img')).toBeNull();
expect(container.querySelector('img, [src], [srcset], link[rel="preload"]')).toBeNull();
expect(screen.getByText(`Image URL: ${sourceA}`)).toBeTruthy();
expect(screen.getByText('Host: images.example.test')).toBeTruthy();
expect(screen.getByText(/loading.*sends a request.*information from.*answer/i)).toBeTruthy();
expect(container.textContent).toContain('Quarterly revenue');
const button = screen.getByRole('button', { name: /load image/i });

fireEvent.click(button);

const image = screen.getByRole('img', { name: 'Quarterly revenue' });
expect(image.getAttribute('src')).toBe(sourceA);
expect(image.getAttribute('alt')).toBe('Quarterly revenue');
expect(image.getAttribute('title')).toBe('Revenue plot');
expect(image.hasAttribute('srcset')).toBe(false);
});

it('accepts activation before passive image registration has run', () => {
const ActivateOnMount = () => {
useLayoutEffect(() => {
fireEvent.click(screen.getByRole('button', { name: /load image/i }));
}, []);
return <MarkdownAnswer content={imageMarkdown(sourceA)} />;
};

render(<ActivateOnMount />);

expect(screen.getByRole('img', { name: 'Quarterly revenue' }).getAttribute('src')).toBe(sourceA);
});

it.each(['/assistant-plot.svg?q=relative', '//images.example.test/plot.svg?q=protocol-relative'])(
'resolves destination %s against the actual document base URI',
relativeSource => {
const resolvedSource = new URL(relativeSource, document.baseURI);
render(<MarkdownAnswer content={imageMarkdown(relativeSource)} />);

expect(screen.getByText(resolvedSource.href, { exact: false })).toBeTruthy();
expect(screen.getByText(text => text.endsWith(resolvedSource.hostname))).toBeTruthy();
expect(screen.queryByRole('img')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /load image/i }));
expect(screen.getByRole('img').getAttribute('src')).toBe(resolvedSource.href);
}
);

it('requires fresh consent for query changes, including returning to a previously loaded URL', () => {
const { rerender } = render(<MarkdownAnswer content={imageMarkdown(sourceA)} />);
fireEvent.click(screen.getByRole('button', { name: /load image/i }));
expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA);

rerender(<MarkdownAnswer content={imageMarkdown(sourceB)} />);
expect(screen.queryByRole('img')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /load image/i }));
expect(screen.getByRole('img').getAttribute('src')).toBe(sourceB);

rerender(<MarkdownAnswer content={imageMarkdown(sourceA)} />);
expect(screen.queryByRole('img')).toBeNull();
expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy();
});

it('revokes approval when a different destination is shown without being activated', () => {
const { rerender } = render(<MarkdownAnswer content={imageMarkdown(sourceA)} />);
fireEvent.click(screen.getByRole('button', { name: /load image/i }));
expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA);

rerender(<MarkdownAnswer content={imageMarkdown(sourceB)} />);
expect(screen.queryByRole('img')).toBeNull();
expect(screen.getByText(sourceB, { exact: false })).toBeTruthy();
expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy();

rerender(<MarkdownAnswer content={imageMarkdown(sourceA)} />);
expect(screen.queryByRole('img')).toBeNull();
expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy();
});

it('requires fresh consent when the destination path changes', () => {
const { rerender } = render(<MarkdownAnswer content={imageMarkdown(sourceA)} />);
fireEvent.click(screen.getByRole('button', { name: /load image/i }));

rerender(<MarkdownAnswer content={imageMarkdown(sourceA.replace('/plot.svg', '/another.svg'))} />);

expect(screen.queryByRole('img')).toBeNull();
expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy();
});

it('keeps approval for an unchanged URL while more answer text is appended', () => {
const content = `Revenue increased.\n\n${imageMarkdown(sourceA)}`;
const { rerender } = render(<MarkdownAnswer content={content} />);
fireEvent.click(screen.getByRole('button', { name: /load image/i }));

rerender(<MarkdownAnswer content={`${content}\n\nThe next quarter also improved.`} />);

expect(screen.getByRole('img', { name: 'Quarterly revenue' }).getAttribute('src')).toBe(sourceA);
expect(screen.queryByRole('button', { name: /load image/i })).toBeNull();
expect(screen.getByText('The next quarter also improved.')).toBeTruthy();
});

it('keeps approval and updates the title when a reference definition title is appended', () => {
const content = `![Existing][plot]\n\n[plot]: ${sourceA}`;
const { rerender } = render(<MarkdownAnswer content={content} />);
fireEvent.click(screen.getByRole('button', { name: /load image/i }));
expect(screen.getByRole('img', { name: 'Existing' }).getAttribute('src')).toBe(sourceA);

rerender(<MarkdownAnswer content={`${content}\n "Revenue plot"`} />);

const image = screen.getByRole('img', { name: 'Existing' });
expect(image.getAttribute('src')).toBe(sourceA);
expect(image.getAttribute('title')).toBe('Revenue plot');
});

it('keeps consent with its source occurrence when an earlier image reference resolves', () => {
const source = 'https://images.example.test/plot.svg';
const initial = `![New][later] ![Existing](${source})`;
const { rerender } = render(<MarkdownAnswer content={initial} />);
fireEvent.click(screen.getByRole('button', { name: /Existing/ }));
expect(screen.getByRole('img', { name: 'Existing' }).getAttribute('src')).toBe(source);

rerender(<MarkdownAnswer content={`${initial}\n\n[later]: ${source}`} />);

expect(screen.getByRole('button', { name: /New/ })).toBeTruthy();
expect(screen.queryByRole('img', { name: 'New' })).toBeNull();
expect(screen.getByRole('img', { name: 'Existing' }).getAttribute('src')).toBe(source);
expect(screen.queryByRole('button', { name: /Existing/ })).toBeNull();
expect(screen.getAllByRole('img')).toHaveLength(1);
});

it('keeps existing consent through StrictMode replay when an earlier image reference resolves', () => {
const initial = `![New][later] ![Existing](${sourceA})`;
const { rerender } = render(
<StrictMode>
<MarkdownAnswer content={initial} />
</StrictMode>
);
fireEvent.click(screen.getByRole('button', { name: /Existing/ }));
expect(screen.getByRole('img', { name: 'Existing' }).getAttribute('src')).toBe(sourceA);

rerender(
<StrictMode>
<MarkdownAnswer content={`${initial}\n\n[later]: ${sourceB}`} />
</StrictMode>
);

expect(screen.getByRole('img', { name: 'Existing' }).getAttribute('src')).toBe(sourceA);
expect(screen.queryByRole('img', { name: 'New' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /New/ }));

expect(screen.getByRole('img', { name: 'Existing' }).getAttribute('src')).toBe(sourceA);
expect(screen.getByRole('img', { name: 'New' }).getAttribute('src')).toBe(sourceB);
});

it('does not transfer approval to a replacement image at the same source position', () => {
const { rerender } = render(<MarkdownAnswer content={`![A](${sourceA})`} />);
fireEvent.click(screen.getByRole('button', { name: /Load image: A/ }));
expect(screen.getByRole('img', { name: 'A' })).toBeTruthy();

rerender(<MarkdownAnswer content={`![B](${sourceA})`} />);

expect(screen.queryByRole('img')).toBeNull();
expect(screen.getByRole('button', { name: /Load image: B/ })).toBeTruthy();
});

it('does not share approval with another image in the same answer', () => {
render(<MarkdownAnswer content={`${imageMarkdown(sourceA)}\n\n![Comparison](${sourceA})`} />);
const buttons = screen.getAllByRole('button', { name: /load image/i });

fireEvent.click(buttons[0]);

expect(screen.getAllByRole('img')).toHaveLength(1);
expect(screen.getByRole('img', { name: 'Quarterly revenue' })).toBeTruthy();
expect(screen.queryByRole('img', { name: 'Comparison' })).toBeNull();
expect(screen.getAllByRole('button', { name: /load image/i })).toHaveLength(1);
});

it('does not share approval between independently rendered answers', () => {
const first = render(<MarkdownAnswer content={imageMarkdown(sourceA)} />);
fireEvent.click(within(first.container).getByRole('button', { name: /load image/i }));
const second = render(<MarkdownAnswer content={imageMarkdown(sourceA)} />);

expect(within(first.container).getByRole('img')).toBeTruthy();
expect(within(second.container).queryByRole('img')).toBeNull();
expect(within(second.container).getByRole('button', { name: /load image/i })).toBeTruthy();
});

it('requires fresh consent when removed image content is restored in the same answer', () => {
const content = imageMarkdown(sourceA);
const { rerender } = render(<MarkdownAnswer content={content} />);
fireEvent.click(screen.getByRole('button', { name: /load image/i }));
expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA);

rerender(<MarkdownAnswer content="No answer selected." />);
expect(screen.queryByRole('img')).toBeNull();
expect(screen.queryByRole('button', { name: /load image/i })).toBeNull();
expect(screen.getByText('No answer selected.')).toBeTruthy();

rerender(<MarkdownAnswer content={content} />);
expect(screen.queryByRole('img')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /load image/i }));
expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA);
});

it('requires fresh consent when a previously displayed answer is restored after unmounting', () => {
const content = imageMarkdown(sourceA);
const first = render(<MarkdownAnswer content={content} />);
fireEvent.click(screen.getByRole('button', { name: /load image/i }));
expect(screen.getByRole('img').getAttribute('src')).toBe(sourceA);

first.unmount();
render(<MarkdownAnswer content={content} />);

expect(screen.queryByRole('img')).toBeNull();
expect(screen.getByRole('button', { name: /load image/i })).toBeTruthy();
});

it.each(['javascript:alert%281%29', 'ftp://images.example.test/plot.svg', ''])(
'does not offer loading for the unsupported or empty image destination %s',
source => {
const { container } = render(<MarkdownAnswer content={imageMarkdown(source)} />);

expect(screen.queryByRole('img')).toBeNull();
expect(screen.queryByRole('button', { name: /load image/i })).toBeNull();
expect(container.querySelector('[src], [srcset]')).toBeNull();
expect(container.textContent).toContain('Quarterly revenue');
}
);

it('keeps the original link separate from the image loading button and preserves mixed content', () => {
const href = 'https://zeppelin.apache.org/docs/latest/';
const { container } = render(
<MarkdownAnswer content={`[Before **bold** ${imageMarkdown(sourceA)} after](${href})`} />
);
const button = screen.getByRole('button', { name: /load image/i });
const links = screen.getAllByRole('link');

expect(button.closest('a')).toBeNull();
expect(button.closest('p')).not.toBeNull();
expect(container.querySelector('p p, p div')).toBeNull();
expect(container.textContent).toContain('Before');
expect(screen.getByText('bold').tagName).toBe('STRONG');
expect(container.textContent).toContain('after');
for (const link of links) {
expect(link.getAttribute('href')).toBe(href);
expect(link.getAttribute('target')).toBe('_blank');
expect(link.getAttribute('rel')?.split(/\s+/)).toEqual(expect.arrayContaining(['noopener', 'noreferrer']));
}

fireEvent.click(button);

expect(screen.getByRole('img', { name: 'Quarterly revenue' }).getAttribute('src')).toBe(sourceA);
expect(screen.getAllByRole('link').map(link => link.getAttribute('href'))).toEqual(links.map(() => href));
});

it('retains a navigation link for an image-only Markdown link without nesting its load button', () => {
const href = 'https://zeppelin.apache.org/docs/latest/';
render(<MarkdownAnswer content={`[${imageMarkdown(sourceA)}](${href})`} />);

const button = screen.getByRole('button', { name: /load image/i });
expect(button.closest('a')).toBeNull();
expect(screen.getByRole('link').getAttribute('href')).toBe(href);
});
});

it.each(dangerousUrls)('renders the rejected URL %s as non-navigable text', url => {
const { container } = render(<MarkdownAnswer content={`[Unsafe link](${url})`} />);

Expand Down
Loading
Loading