This is an automated email from the ASF dual-hosted git repository.
voidmatcha pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git
The following commit(s) were added to refs/heads/master by this push:
new 0d344fb7b1 [ZEPPELIN-6751] Prevent automatic image requests in AI
Assistant answers
0d344fb7b1 is described below
commit 0d344fb7b1318438c98b25fae317543792b962f7
Author: Minho Jang <[email protected]>
AuthorDate: Fri Oct 16 23:29:03 2026 +0900
[ZEPPELIN-6751] Prevent automatic image requests in AI Assistant answers
### What is this PR for?
Addresses the image-loading follow-up raised in
[#5570](https://github.com/apache/zeppelin/pull/5570#issuecomment-6158231443).
Model-generated Markdown can include image URLs containing notebook data.
Loading these images automatically sends a request to the destination without
user interaction.
This PR replaces automatic image loading with a **Load image** button.
Before loading, the reader can review the image description, destination host,
full URL, and a warning that the request may transmit information from the
answer. Only HTTP(S) image URLs are supported.
Approval is specific to each image in an answer. Unchanged images retain
approval during streaming, including when a reference definition gains a title.
Changing the destination or removing and restoring an image requires fresh
approval.
### What type of PR is it?
Bug Fix
### What is the Jira issue?
* https://issues.apache.org/jira/browse/ZEPPELIN-6751
### How should this be tested?
From `zeppelin-web-angular/projects/zeppelin-react`, run:
```bash
npm test -- src/entities/assistant/ui/MarkdownAnswer.spec.tsx
npm run typecheck
npm run build:storybook
npm run test:storybook -- markdown-answer-images.spec.mjs
```
For manual verification, run `npm run storybook` and open **Assistant /
MarkdownAnswer**:
1. In **Image Loading**, inspect the browser’s Network panel. Confirm that
no image request is sent before activation. Use Enter or Space to activate
**Load image** and confirm that only the selected image loads.
2. In **Streaming Images**, load the image and append the next chunk.
Confirm that the image remains loaded while its destination is unchanged. When
a later chunk changes the destination, confirm that the new URL is displayed
and requires another activation before loading.
3. Using the image story’s `content` control, remove an approved image and
restore the same Markdown. Confirm that it requires approval again.
### Questions
- **License changes?** No.
- **Breaking changes?** No.
- **Documentation?** The React README explains image loading and the
destination warning.
Closes #5572 from miinhho/assistant-image-loading-policy.
Signed-off-by: YONGJAE LEE <[email protected]>
---
.../projects/zeppelin-react/README.md | 4 +-
.../entities/assistant/ui/MarkdownAnswer.css.ts | 52 ++++
.../entities/assistant/ui/MarkdownAnswer.spec.tsx | 269 +++++++++++++++++++++
.../assistant/ui/MarkdownAnswer.stories.tsx | 54 +++++
.../src/entities/assistant/ui/MarkdownAnswer.tsx | 91 +++----
.../entities/assistant/ui/MarkdownAnswerImage.tsx | 186 ++++++++++++++
.../assistant/ui/remarkParagraphReferences.ts | 74 ++++++
.../test/fixtures/markdown-answer-image.svg | 36 +++
.../test/storybook/markdown-answer-images.spec.mjs | 139 +++++++++++
9 files changed, 844 insertions(+), 61 deletions(-)
diff --git a/zeppelin-web-angular/projects/zeppelin-react/README.md
b/zeppelin-web-angular/projects/zeppelin-react/README.md
index 4c45e4d7ad..1a81621ec7 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/README.md
+++ b/zeppelin-web-angular/projects/zeppelin-react/README.md
@@ -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.
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.css.ts
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.css.ts
index a39fa0701a..2e54a84c9e 100644
---
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.css.ts
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.css.ts
@@ -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%',
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.spec.tsx
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.spec.tsx
index 629c6ae620..46889eef7d 100644
---
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.spec.tsx
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.spec.tsx
@@ -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';
@@ -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) => ``;
+
+ 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] `;
+ 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] `;
+ 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={``}
/>);
+ fireEvent.click(screen.getByRole('button', { name: /Load image: A/ }));
+ expect(screen.getByRole('img', { name: 'A' })).toBeTruthy();
+
+ rerender(<MarkdownAnswer content={``} />);
+
+ 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`} />);
+ 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})`} />);
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.stories.tsx
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.stories.tsx
index 58aa97396d..3268ed88ae 100644
---
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.stories.tsx
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.stories.tsx
@@ -30,6 +30,36 @@ const streamingChunks = [
'GROUP BY product;\n```\n\nRevenue is grouped by product.'
];
+const imageFixtureUrl = new
URL('../../../../test/fixtures/markdown-answer-image.svg?no-inline',
import.meta.url).href;
+const imageQuerySeparator = imageFixtureUrl.includes('?') ? '&' : '?';
+const streamingImageChunks = [
+ '### Quarterly revenue\n\n![Quarterly revenue
chart][revenue-chart]\n\nRevenue increased across the quarter.',
+ '\n\nThe strongest growth came from recurring sales.',
+ '\n\nThe updated chart includes the final month of the quarter.'
+];
+
+const StreamingImagesExample = (args: MarkdownAnswerProps) => {
+ const [step, setStep] = useState(0);
+ const completed = step === streamingImageChunks.length - 1;
+ const version = completed ? 'B' : 'A';
+ const content = `${streamingImageChunks.slice(0, step +
1).join('')}\n\n[revenue-chart]:
${imageFixtureUrl}${imageQuerySeparator}version=${version}`;
+
+ return (
+ <div>
+ <button type="button" disabled={completed} onClick={() =>
setStep(current => current + 1)}>
+ Append next chunk
+ </button>
+ <button type="button" disabled={step === 0} onClick={() => setStep(0)}
style={{ marginLeft: 8 }}>
+ Restart stream
+ </button>
+ <p role="status">
+ Chunk {step + 1} of {streamingImageChunks.length}
+ </p>
+ <MarkdownAnswer {...args} content={content} />
+ </div>
+ );
+};
+
const ParagraphReferenceExample = ({ onOpenParagraph, ...args }:
MarkdownAnswerProps) => {
const [selectedParagraph, setSelectedParagraph] = useState('No paragraph
selected.');
@@ -237,3 +267,27 @@ export const Streaming: Story = {
}
}
};
+
+export const ImageLoading: Story = {
+ args: {
+ content: [
+ '### Quarterly revenue',
+ '',
+ 'Revenue increased across the quarter, led by recurring sales.',
+ '',
+ ``,
+ '',
+ 'The regional breakdown is shown in the chart accompanying the
documentation:',
+ '',
+ `[Regional comparison: **revenue** ](https://zeppelin.apache.org/docs/latest/)`,
+ '',
+ 'Northern Europe contributed the largest share.'
+ ].join('\n')
+ }
+};
+
+export const StreamingImages: Story = {
+ render: args => <StreamingImagesExample {...args} />,
+ args: { content: streamingImageChunks[0] },
+ argTypes: { content: { control: false } }
+};
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.tsx
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.tsx
index b5c69baf09..56f6d33087 100644
---
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.tsx
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswer.tsx
@@ -18,11 +18,12 @@
import { createContext, useContext, useId, useMemo, type
ComponentPropsWithoutRef, type MouseEvent } from 'react';
import Markdown, { type Components, type ExtraProps } from 'react-markdown';
import remarkGfm from 'remark-gfm';
-import type { Link, Parent, Root, Text } from 'mdast';
-import type { Plugin, PluggableList } from 'unified';
+import type { PluggableList } from 'unified';
import { visuallyHidden } from
'@/shared/ui/assistant-theme/AssistantScope.css';
import * as styles from './MarkdownAnswer.css';
+import { MarkdownAnswerImage, MarkdownAnswerImageScope } from
'./MarkdownAnswerImage';
+import { isParagraphId, remarkParagraphReferences } from
'./remarkParagraphReferences';
export interface MarkdownAnswerProps {
/** Markdown text of the answer. While it is still arriving, it may end
inside a code fence. */
@@ -39,74 +40,26 @@ type AnswerContextValue = Pick<MarkdownAnswerProps,
'describeParagraph' | 'onOpe
};
const AnswerContext = createContext<AnswerContextValue>({ footnotePrefix: ''
});
-const paragraphIdPattern = /^paragraph_\d+_-?\d+$/;
-
-const textReferences = (node: Text): Array<Text | Link> | undefined => {
- // Match a complete ID without splitting Unicode letters, numbers or
combining marks.
- const pattern = new
RegExp('(^|[^\\p{L}\\p{N}\\p{M}_+-])(paragraph_\\d+_-?\\d+)(?![\\p{L}\\p{N}\\p{M}_+-])',
'gu');
- let references: Array<Text | Link> | undefined;
- let offset = 0;
- let match: RegExpExecArray | null;
-
- while ((match = pattern.exec(node.value)) !== null) {
- const id = match[2];
- const start = match.index + match[1].length;
- references ??= [];
- if (start > offset) {
- references.push({ type: 'text', value: node.value.slice(offset, start)
});
- }
- references.push({ type: 'link', url: `#${id}`, children: [{ type: 'text',
value: id }] });
- offset = start + id.length;
- }
-
- if (references && offset < node.value.length) {
- references.push({ type: 'text', value: node.value.slice(offset) });
- }
- return references;
-};
-
-const transformReferences = (parent: Parent): void => {
- let children: Parent['children'] | undefined;
-
- for (let index = 0; index < parent.children.length; index++) {
- const child = parent.children[index];
- let references: Array<Text | Link> | undefined;
- if (child.type === 'text') {
- references = textReferences(child);
- } else if (child.type === 'inlineCode' &&
paragraphIdPattern.test(child.value)) {
- references = [{ type: 'link', url: `#${child.value}`, children: [child]
}];
- } else if (child.type !== 'link' && child.type !== 'linkReference' &&
'children' in child) {
- transformReferences(child);
- }
-
- if (references) {
- children ??= parent.children.slice(0, index);
- children.push(...references);
- } else {
- children?.push(child);
- }
- }
-
- if (children) parent.children = children;
-};
-
-const remarkParagraphReferences: Plugin<[], Root> = () => transformReferences;
const remarkPlugins: PluggableList = [remarkGfm, remarkParagraphReferences];
// remark-rehype gives the footnotes heading this fixed id and points every
footnote reference at it.
const footnoteLabelId = 'footnote-label';
-const AnswerLink = ({ node: _node, href, children, title, ...props }:
ComponentPropsWithoutRef<'a'> & ExtraProps) => {
+const containsImage = (node: NonNullable<ExtraProps['node']>): boolean =>
+ node.tagName === 'img' || node.children.some(child => child.type ===
'element' && containsImage(child));
+
+const AnswerLink = ({ node, href, children, title, ...props }:
ComponentPropsWithoutRef<'a'> & ExtraProps) => {
const { describeParagraph, onOpenParagraph, footnotePrefix } =
useContext(AnswerContext);
if (!href) return <>{children}</>;
+ const imageLink = node ? containsImage(node) : false;
const fragment = href.startsWith('#');
const paragraphId = fragment ? href.slice(1) : undefined;
- if (paragraphId && paragraphIdPattern.test(paragraphId)) {
+ if (paragraphId && isParagraphId(paragraphId)) {
const description = describeParagraph?.(paragraphId);
if (description && onOpenParagraph) {
- return (
+ const reference = (
<button
type="button"
className={styles.paragraphReference}
@@ -116,6 +69,13 @@ const AnswerLink = ({ node: _node, href, children, title,
...props }: ComponentP
{description.text}
</button>
);
+ return imageLink ? (
+ <span className={styles.imageLink}>
+ {children} {reference}
+ </span>
+ ) : (
+ reference
+ );
}
return <>{children}</>;
}
@@ -132,7 +92,7 @@ const AnswerLink = ({ node: _node, href, children, title,
...props }: ComponentP
};
// react-markdown has already applied its default URL safety transform.
- return (
+ const link = (
<a
{...props}
aria-describedby={
@@ -146,9 +106,17 @@ const AnswerLink = ({ node: _node, href, children, title,
...props }: ComponentP
target={fragment ? undefined : '_blank'}
rel={fragment ? undefined : 'noopener noreferrer'}
>
- {children}
+ {imageLink ? 'Open linked page' : children}
</a>
);
+ // Keep every original child outside the anchor so an image's native load
button is never nested in a link.
+ return imageLink ? (
+ <span className={styles.imageLink}>
+ {children} {link}
+ </span>
+ ) : (
+ link
+ );
};
const AnswerCodeBlock = ({ node, children, ...props }:
ComponentPropsWithoutRef<'pre'> & ExtraProps) => {
@@ -185,6 +153,7 @@ const AnswerHeading = ({ node: _node, id, ...props }:
ComponentPropsWithoutRef<'
const components: Components = {
h2: AnswerHeading,
a: AnswerLink,
+ img: MarkdownAnswerImage,
pre: AnswerCodeBlock,
table: AnswerTable
};
@@ -213,7 +182,9 @@ export const MarkdownAnswer = ({ content,
describeParagraph, onOpenParagraph }:
return (
<AnswerContext.Provider value={{ describeParagraph, onOpenParagraph,
footnotePrefix }}>
- <div className={styles.answer}>{markdown}</div>
+ <MarkdownAnswerImageScope>
+ <div className={styles.answer}>{markdown}</div>
+ </MarkdownAnswerImageScope>
</AnswerContext.Provider>
);
};
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswerImage.tsx
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswerImage.tsx
new file mode 100644
index 0000000000..1c4d4ab65e
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/MarkdownAnswerImage.tsx
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ createContext,
+ useContext,
+ useId,
+ useEffect,
+ useMemo,
+ useReducer,
+ type Dispatch,
+ type ComponentPropsWithoutRef,
+ type ReactNode
+} from 'react';
+import type { ExtraProps } from 'react-markdown';
+
+import * as styles from './MarkdownAnswer.css';
+
+type ImageApproval = Readonly<{ url: string; approved: boolean; owner: string
| undefined }>;
+type ImageApprovals = ReadonlyMap<string, ImageApproval>;
+type ImageApprovalAction =
+ | { type: 'mount' | 'approve'; occurrence: string; url: string; owner:
string }
+ | { type: 'unmount'; occurrence: string; owner: string }
+ | { type: 'prune' };
+
+const imageApprovalsReducer = (state: ImageApprovals, action:
ImageApprovalAction): ImageApprovals => {
+ if (action.type === 'prune') {
+ let next: Map<string, ImageApproval> | undefined;
+ state.forEach((approval, occurrence) => {
+ if (approval.owner !== undefined) return;
+ next ??= new Map(state);
+ next.delete(occurrence);
+ });
+ return next ?? state;
+ }
+
+ const previous = state.get(action.occurrence);
+ let approval: ImageApproval;
+ switch (action.type) {
+ case 'mount':
+ if (previous?.url === action.url && previous.owner === action.owner)
return state;
+ approval = {
+ url: action.url,
+ approved: previous?.url === action.url ? previous.approved : false,
+ owner: action.owner
+ };
+ break;
+ case 'approve':
+ if (previous?.url === action.url && previous.owner === action.owner &&
previous.approved) return state;
+ approval = { url: action.url, approved: true, owner: action.owner };
+ break;
+ case 'unmount':
+ if (previous?.owner !== action.owner) return state;
+ approval = { ...previous, owner: undefined };
+ break;
+ }
+ const next = new Map(state);
+ next.set(action.occurrence, approval);
+ return next;
+};
+
+// Committed consent belongs to a Markdown source occurrence, not a renderer's
tag-ordinal key.
+const ImageApprovalsContext = createContext<
+ { imageApprovals: ImageApprovals; dispatch: Dispatch<ImageApprovalAction> }
| undefined
+>(undefined);
+
+export const MarkdownAnswerImageScope = ({ children }: { children: ReactNode
}) => {
+ const [imageApprovals, dispatch] = useReducer(
+ imageApprovalsReducer,
+ undefined,
+ () => new Map<string, ImageApproval>()
+ );
+ let hasUnmountedImage = false;
+ imageApprovals.forEach(approval => {
+ if (approval.owner === undefined) hasUnmountedImage = true;
+ });
+ // Same-batch rekeys reclaim their grant; prune real removals before
children can read stale consent.
+ if (hasUnmountedImage) dispatch({ type: 'prune' });
+ const context = useMemo(() => ({ imageApprovals, dispatch }),
[imageApprovals, dispatch]);
+ return <ImageApprovalsContext.Provider
value={context}>{children}</ImageApprovalsContext.Provider>;
+};
+
+const resolveImageUrl = (src: ComponentPropsWithoutRef<'img'>['src'], base:
string | undefined): URL | undefined => {
+ if (typeof src !== 'string' || !src.trim()) return undefined;
+ try {
+ const url = base === undefined ? new URL(src) : new URL(src, base);
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url :
undefined;
+ } catch {
+ return undefined;
+ }
+};
+
+const useImageApproval = (occurrence: string | undefined, href: string, owner:
string) => {
+ const context = useContext(ImageApprovalsContext);
+ if (context === undefined) throw new Error('MarkdownAnswerImage must be
rendered within MarkdownAnswerImageScope.');
+ const { imageApprovals, dispatch } = context;
+ const occurrenceKey = occurrence ?? owner;
+ const approval = imageApprovals.get(occurrenceKey);
+
+ useEffect(() => {
+ dispatch({ type: 'mount', occurrence: occurrenceKey, url: href, owner });
+ return () => {
+ dispatch({ type: 'unmount', occurrence: occurrenceKey, owner });
+ };
+ }, [dispatch, occurrenceKey, href, owner]);
+
+ return {
+ approved: approval?.url === href && approval.approved,
+ approve: () => dispatch({ type: 'approve', occurrence: occurrenceKey, url:
href, owner })
+ };
+};
+
+type LoadableImageProps = {
+ url: URL;
+ alt: string;
+ title: string | undefined;
+ occurrence: string | undefined;
+};
+
+const LoadableImage = ({ url, alt, title, occurrence }: LoadableImageProps) =>
{
+ const detailsId = useId();
+ const { approved, approve } = useImageApproval(occurrence, url.href,
detailsId);
+
+ return (
+ <span className={styles.imagePlaceholder}>
+ <span className={styles.imageDescription}>Image description: {alt || 'No
description provided.'}</span>
+ <span id={detailsId} className={styles.imageDetails}>
+ <span>Host: {url.hostname}</span>
+ <span>Image URL: {url.href}</span>
+ <span>
+ Loading this image sends a request to this host. The request may
include information from the answer.
+ </span>
+ </span>
+ {approved ? (
+ <img src={url.href} alt={alt} title={title}
className={styles.loadedImage} />
+ ) : (
+ <button
+ type="button"
+ className={styles.loadImage}
+ aria-label={alt ? `Load image: ${alt}` : 'Load image'}
+ aria-describedby={detailsId}
+ onClick={approve}
+ >
+ Load image
+ </button>
+ )}
+ </span>
+ );
+};
+
+export const MarkdownAnswerImage = ({ node, src, alt, title }:
ComponentPropsWithoutRef<'img'> & ExtraProps) => {
+ const base = typeof document === 'undefined' ? undefined : document.baseURI;
+ const url = useMemo(() => resolveImageUrl(src, base), [src, base]);
+ const start = node?.position?.start.offset;
+ const end = node?.position?.end.offset;
+ // Reference definitions can acquire a title later without changing the
image occurrence.
+ const occurrence = useMemo(
+ () => (start === undefined || end === undefined ? undefined :
JSON.stringify([start, end, alt])),
+ [start, end, alt]
+ );
+ if (!url) {
+ return (
+ <span className={styles.imagePlaceholder}>
+ <span className={styles.imageDescription}>Image description: {alt ||
'No description provided.'}</span>
+ <span>Image unavailable: only HTTP(S) image URLs can be loaded.</span>
+ </span>
+ );
+ }
+ return (
+ <LoadableImage key={`${occurrence}:${url.href}`} occurrence={occurrence}
url={url} alt={alt ?? ''} title={title} />
+ );
+};
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/remarkParagraphReferences.ts
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/remarkParagraphReferences.ts
new file mode 100644
index 0000000000..65c96c3152
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/remarkParagraphReferences.ts
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { Link, Parent, Root, Text } from 'mdast';
+import type { Plugin } from 'unified';
+
+const paragraphIdPattern = /^paragraph_\d+_-?\d+$/;
+
+export const isParagraphId = (value: string): boolean =>
paragraphIdPattern.test(value);
+
+const textReferences = (node: Text): Array<Text | Link> | undefined => {
+ // Match a complete ID without splitting Unicode letters, numbers or
combining marks.
+ const pattern = new
RegExp('(^|[^\\p{L}\\p{N}\\p{M}_+-])(paragraph_\\d+_-?\\d+)(?![\\p{L}\\p{N}\\p{M}_+-])',
'gu');
+ let references: Array<Text | Link> | undefined;
+ let offset = 0;
+ let match: RegExpExecArray | null;
+
+ while ((match = pattern.exec(node.value)) !== null) {
+ const id = match[2];
+ const start = match.index + match[1].length;
+ references ??= [];
+ if (start > offset) {
+ references.push({ type: 'text', value: node.value.slice(offset, start)
});
+ }
+ references.push({ type: 'link', url: `#${id}`, children: [{ type: 'text',
value: id }] });
+ offset = start + id.length;
+ }
+
+ if (references && offset < node.value.length) {
+ references.push({ type: 'text', value: node.value.slice(offset) });
+ }
+ return references;
+};
+
+const transformReferences = (parent: Parent): void => {
+ let children: Parent['children'] | undefined;
+
+ for (let index = 0; index < parent.children.length; index++) {
+ const child = parent.children[index];
+ let references: Array<Text | Link> | undefined;
+ if (child.type === 'text') {
+ references = textReferences(child);
+ } else if (child.type === 'inlineCode' && isParagraphId(child.value)) {
+ references = [{ type: 'link', url: `#${child.value}`, children: [child]
}];
+ } else if (child.type !== 'link' && child.type !== 'linkReference' &&
'children' in child) {
+ transformReferences(child);
+ }
+
+ if (references) {
+ children ??= parent.children.slice(0, index);
+ children.push(...references);
+ } else {
+ children?.push(child);
+ }
+ }
+
+ if (children) parent.children = children;
+};
+
+export const remarkParagraphReferences: Plugin<[], Root> = () =>
transformReferences;
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/test/fixtures/markdown-answer-image.svg
b/zeppelin-web-angular/projects/zeppelin-react/test/fixtures/markdown-answer-image.svg
new file mode 100644
index 0000000000..27fa8004ee
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/test/fixtures/markdown-answer-image.svg
@@ -0,0 +1,36 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<svg xmlns="http://www.w3.org/2000/svg" width="480" height="240" viewBox="0 0
480 240" role="img" aria-labelledby="title description">
+ <title id="title">Quarterly revenue</title>
+ <desc id="description">Revenue grows from 120 in January to 180 in February
and 240 in March.</desc>
+ <rect width="480" height="240" rx="8" fill="#f4f7fb" />
+ <g font-family="sans-serif" fill="#233449">
+ <text x="32" y="32" font-size="20">Quarterly revenue</text>
+ <path d="M48 52V194H448" fill="none" stroke="#71839a" stroke-width="2" />
+ <rect x="88" y="124" width="64" height="70" fill="#3071a9" />
+ <rect x="208" y="89" width="64" height="105" fill="#3071a9" />
+ <rect x="328" y="54" width="64" height="140" fill="#3071a9" />
+ <g font-size="16" text-anchor="middle">
+ <text x="120" y="116">120</text>
+ <text x="240" y="81">180</text>
+ <text x="360" y="46">240</text>
+ <text x="120" y="220">January</text>
+ <text x="240" y="220">February</text>
+ <text x="360" y="220">March</text>
+ </g>
+ </g>
+</svg>
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/test/storybook/markdown-answer-images.spec.mjs
b/zeppelin-web-angular/projects/zeppelin-react/test/storybook/markdown-answer-images.spec.mjs
new file mode 100644
index 0000000000..ba258f7d8c
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/test/storybook/markdown-answer-images.spec.mjs
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { readFileSync } from 'node:fs';
+import { test, expect } from '@playwright/test';
+
+const fixture = readFileSync(new URL('../fixtures/markdown-answer-image.svg',
import.meta.url));
+const source =
'https://images.example.test/plot.svg?q=SYNTHETIC_NOTEBOOK_VALUE';
+const storyId = 'assistant-markdownanswer--image-loading';
+const includesText = value => new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g,
'\\$&'));
+
+async function interceptImages(page, baseURL) {
+ const requests = [];
+ const origin = new URL(baseURL).origin;
+ const isFixture = url => url.pathname.includes('markdown-answer-image') &&
url.pathname.endsWith('.svg');
+ const isObserved = url => /^https?:$/.test(url.protocol) && (url.origin !==
origin || isFixture(url));
+ page.on('request', request => {
+ if (isObserved(new URL(request.url()))) requests.push(request.url());
+ });
+ await page.route('**/*', async route => {
+ const url = new URL(route.request().url());
+ if (url.hostname === 'images.example.test' || isFixture(url)) {
+ await route.fulfill({ status: 200, contentType: 'image/svg+xml', body:
fixture });
+ } else if (url.origin !== origin) {
+ // Unexpected external requests fail the assertions without contacting a
real server.
+ await route.abort();
+ } else {
+ await route.continue();
+ }
+ });
+ return requests;
+}
+
+async function openImages(page) {
+ await page.goto(`/iframe.html?id=${storyId}&viewMode=story`);
+ const root = page.locator('#storybook-root');
+ await expect(root.getByRole('button', { name: /load image/i
}).first()).toBeVisible();
+ return root;
+}
+
+async function setContent(page, content) {
+ await page.evaluate(
+ ({ id, content }) => {
+ window.__STORYBOOK_PREVIEW__.channel.emit('updateStoryArgs', { storyId:
id, updatedArgs: { content } });
+ },
+ { id: storyId, content }
+ );
+}
+
+async function expectLoadedImage(image, url) {
+ await expect(image).toHaveAttribute('src', url);
+ await expect.poll(() => image.evaluate(element => element.complete &&
element.naturalWidth > 0)).toBe(true);
+}
+
+test('only keyboard-confirmed destinations are requested, including after a
query change', async ({
+ page,
+ baseURL
+}) => {
+ const requests = await interceptImages(page, baseURL);
+ const root = await openImages(page);
+ await setContent(page, `Revenue improved.\n\n`);
+ const load = root.getByRole('button', { name: /load image/i });
+ await expect(load).toHaveAccessibleDescription(includesText(source));
+ await expect(root.locator('img, [srcset],
link[rel="preload"]')).toHaveCount(0);
+ await page.waitForLoadState('networkidle');
+ expect(requests).toEqual([]);
+
+ await load.focus();
+ await load.press('Enter');
+ await expectLoadedImage(root.getByRole('img', { name: 'Quarterly revenue
chart' }), source);
+ expect(requests).toEqual([source]);
+
+ const revised = source.replace('SYNTHETIC_NOTEBOOK_VALUE',
'UPDATED_NOTEBOOK_VALUE');
+ await setContent(page, `The revised quarter includes final
sales.\n\n`);
+ await expect(load).toHaveAccessibleDescription(includesText(revised));
+ await expect(root.getByRole('img')).toHaveCount(0);
+ await page.waitForLoadState('networkidle');
+ expect(requests).toEqual([source]);
+ await load.focus();
+ await load.press('Space');
+ await expectLoadedImage(root.getByRole('img', { name: 'Quarterly revenue
chart' }), revised);
+ expect(requests).toEqual([source, revised]);
+});
+
+test('streaming reference images preserve approval on append and require
approval for a changed destination', async ({
+ page,
+ baseURL
+}) => {
+ const requests = await interceptImages(page, baseURL);
+ await
page.goto('/iframe.html?id=assistant-markdownanswer--streaming-images&viewMode=story');
+ const root = page.locator('#storybook-root');
+ const load = root.getByRole('button', { name: /load image/i });
+ await expect(load).toBeVisible();
+ const descriptionId = await load.getAttribute('aria-describedby');
+ const disclosed = await
page.locator(`[id="${descriptionId}"]`).textContent();
+ await expect(root.getByRole('img')).toHaveCount(0);
+ await page.waitForLoadState('networkidle');
+ expect(requests).toEqual([]);
+ await load.press('Enter');
+ const image = root.getByRole('img', { name: 'Quarterly revenue chart' });
+ await expect.poll(() => image.evaluate(element => element.complete &&
element.naturalWidth > 0)).toBe(true);
+ const sourceA = await image.getAttribute('src');
+ expect(disclosed).toContain(sourceA);
+ expect(requests).toEqual([sourceA]);
+
+ const initialProgress = await root.getByRole('status').textContent();
+ await root.getByRole('button', { name: 'Append next chunk', exact: true
}).click();
+ await expect(root.getByRole('status')).not.toHaveText(initialProgress);
+ await expectLoadedImage(image, sourceA);
+ await expect(load).toHaveCount(0);
+ await page.waitForLoadState('networkidle');
+ expect(requests).toEqual([sourceA]);
+
+ const revised = new URL(sourceA);
+ revised.searchParams.set('version', 'B');
+ const sourceB = revised.href;
+ await root.getByRole('button', { name: 'Append next chunk', exact: true
}).click();
+ await expect(load).toHaveAccessibleDescription(includesText(sourceB));
+ await expect(image).toHaveCount(0);
+ await page.waitForLoadState('networkidle');
+ expect(requests).toEqual([sourceA]);
+ await load.press('Space');
+ await expectLoadedImage(image, sourceB);
+ expect(requests).toEqual([sourceA, sourceB]);
+});