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 50b0f4071a [ZEPPELIN-6650] Add unit tests for the React result
renderers
50b0f4071a is described below
commit 50b0f4071af527b4ebd840fa13431b520fe451a5
Author: κΉμλ <[email protected]>
AuthorDate: Fri Sep 4 21:51:58 2026 +0900
[ZEPPELIN-6650] Add unit tests for the React result renderers
### What is this PR for?
Unit tests for the two files that decide what a result looks like before it
is drawn, following the conventions ZEPPELIN-6648 added in
`projects/zeppelin-react/AGENTS.md`.
**`SingleResultRenderer`** gets one spec per `DatasetType` arm, asserting
what the user ends up seeing rather than which component was chosen: markup for
HTML, a base64 png for IMG, the unsupported notice for ANGULAR, and nothing at
all for a type with no renderer, since drawing something misleading would be
worse than drawing nothing. The TABLE arm asserts only that it was taken. What
the visualization does with the data is its own concern, and its display-mode
state is the defect `A [...]
**`HTMLRenderer`** is the reason that file assigns `innerHTML` by hand. A
script parsed out of `innerHTML` is inert for good, so the component builds a
fresh element and puts it in the old one's place. jsdom never runs scripts, and
neither `runScripts` nor the environment options change that here, so the
execution itself is out of reach and belongs in e2e. What is observable is the
swap, and that is what these specs pin: attributes and body carried over,
`async` forced off so a librar [...]
Two specs were written and then dropped because they could not fail, which
`AGENTS.md` names as the thing lint is there to catch:
* A carriage-return spec at the `SingleResultRenderer` level.
`ansi-to-react` already collapses `\r` the same way
`checkAndReplaceCarriageReturn` does, so removing the call leaves the rendered
text identical. `textUtils.spec.ts` covers the helper itself.
* An unmount spec for `HTMLRenderer`. React removes the container it owns
whether or not the effect cleans up, so the assertion holds either way.
The `matchMedia` stub in `test-setup.ts` is what lets the TABLE arm render
at all: antd's responsive observer calls it and jsdom implements none. The
identical block is in the ZEPPELIN-6630 branch (#5436), so whichever of the two
lands second drops it in rebase. Flagging it rather than hiding it in a spec
file, since it is package infrastructure and the next spec touching an antd
component needs it too.
Two things noticed while writing these, both out of scope here and neither
touched: `HTMLRenderer` imports `highlight.js`, which this package does not
declare (it resolves through the shell's `node_modules` and webpack's
`resolve.modules` fallback), and that dynamic import has no `.catch()`, so a
failed chunk becomes an unhandled rejection.
### What type of PR is it?
Improvement
### Todos
None
### What is the Jira issue?
https://issues.apache.org/jira/browse/ZEPPELIN-6650
### How should this be tested?
`npm test` from `projects/zeppelin-react`, green at 59. `npm test --
--coverage` for the figures above; note that a file at 100% on every metric is
omitted from the printed table, so `SingleResultRenderer` shows up only in
`coverage/lcov.info`.
Every assertion was checked by breaking what it covers, twelve in all.
Removing the script-replacement block, the attribute copy, `async = false`, the
highlight block, or changing the effect's dependency array or `innerHTML` to
`textContent` each fail the HTMLRenderer specs. Misrouting the HTML or TABLE
arm, returning `null` for ANGULAR, returning an element from the default arm,
reading `config?.[0]` instead of `config?.[index]`, and changing the image mime
type each fail exactly one [...]
Also run: `npm run test:shell` (29, unaffected), `npm run build` for the
remote, `tsc --noEmit`, lint, prettier, and RAT.
This package's suite still does not run in pull-request CI (ZEPPELIN-6566),
so the assertions here are checked locally only. The lint rules do run.
### Screenshots (if appropriate)
No, this adds tests only.
### Questions:
* Does the license files need to update? No
* Is there breaking changes for older versions? No
* Does this needs documentation? No
Closes #5440 from kimyenac/ZEPPELIN-6650.
Signed-off-by: YONGJAE LEE <[email protected]>
---
.../projects/zeppelin-react/package-lock.json | 20 +++++
.../projects/zeppelin-react/package.json | 2 +
.../HTMLRenderer.failedHighlight.spec.tsx | 33 +++++++++
.../src/components/renderers/HTMLRenderer.spec.tsx | 84 +++++++++++++++++++++
.../src/components/renderers/HTMLRenderer.tsx | 9 ++-
.../src/templates/SingleResultRenderer.spec.tsx | 85 ++++++++++++++++++++++
6 files changed, 230 insertions(+), 3 deletions(-)
diff --git a/zeppelin-web-angular/projects/zeppelin-react/package-lock.json
b/zeppelin-web-angular/projects/zeppelin-react/package-lock.json
index 0cc55535ae..c988c10777 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/package-lock.json
+++ b/zeppelin-web-angular/projects/zeppelin-react/package-lock.json
@@ -15,6 +15,7 @@
"chart.js": "^4.5.1",
"date-fns": "^3.6.0",
"file-saver": "2.0.5",
+ "highlight.js": "^9.15.8",
"react": "18.3.1",
"react-dom": "18.3.1",
"rxjs": "^7.8.0",
@@ -25,6 +26,7 @@
"@testing-library/dom": "10.4.1",
"@testing-library/react": "16.3.2",
"@types/file-saver": "2.0.7",
+ "@types/highlight.js": "^9.12.3",
"@types/node": "22.19.19",
"@types/react": "18.3.26",
"@types/react-dom": "18.3.7",
@@ -1980,6 +1982,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/highlight.js": {
+ "version": "9.12.4",
+ "resolved":
"https://registry.npmjs.org/@types/highlight.js/-/highlight.js-9.12.4.tgz",
+ "integrity":
"sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/html-minifier-terser": {
"version": "6.1.0",
"resolved":
"https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
@@ -5519,6 +5528,17 @@
"he": "bin/he"
}
},
+ "node_modules/highlight.js": {
+ "version": "9.18.5",
+ "resolved":
"https://registry.npmjs.org/highlight.js/-/highlight.js-9.18.5.tgz",
+ "integrity":
"sha512-a5bFyofd/BHCX52/8i8uJkjr9DYwXIPnM/plwI6W7ezItLGqzt7X2G2nXuYSfsIJdkwwj/g9DG1LkcGJI/dDoA==",
+ "deprecated": "Support has ended for 9.x series. Upgrade to @latest",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved":
"https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
diff --git a/zeppelin-web-angular/projects/zeppelin-react/package.json
b/zeppelin-web-angular/projects/zeppelin-react/package.json
index 1d99fda253..c6441af70b 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/package.json
+++ b/zeppelin-web-angular/projects/zeppelin-react/package.json
@@ -17,6 +17,7 @@
"@ant-design/icons": "5.4.0",
"@zeppelin/sdk": "file:../zeppelin-sdk",
"ansi-to-react": "6.2.6",
+ "highlight.js": "^9.15.8",
"antd": "5.21.0",
"chart.js": "^4.5.1",
"date-fns": "^3.6.0",
@@ -31,6 +32,7 @@
"@testing-library/dom": "10.4.1",
"@testing-library/react": "16.3.2",
"@types/file-saver": "2.0.7",
+ "@types/highlight.js": "^9.12.3",
"@types/node": "22.19.19",
"@types/react": "18.3.26",
"@types/react-dom": "18.3.7",
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/components/renderers/HTMLRenderer.failedHighlight.spec.tsx
b/zeppelin-web-angular/projects/zeppelin-react/src/components/renderers/HTMLRenderer.failedHighlight.spec.tsx
new file mode 100644
index 0000000000..77984620d6
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/components/renderers/HTMLRenderer.failedHighlight.spec.tsx
@@ -0,0 +1,33 @@
+/*
+ * Licensed 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 { render } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { HTMLRenderer } from './HTMLRenderer';
+
+// A file of its own: the mock is hoisted over the whole module, so it cannot
+// share one with the specs that need highlighting to work.
+vi.mock('highlight.js', () => {
+ throw new Error('chunk load failed');
+});
+
+describe('HTMLRenderer when the highlight chunk fails', () => {
+ it('leaves the code block unhighlighted instead of raising an unhandled
rejection', async () => {
+ const { container } = render(<HTMLRenderer html="<pre><code>const x =
1;</code></pre>" />);
+
+ // Give the rejected import a turn to settle; nothing must escape it.
+ await vi.waitFor(() => expect(container.querySelector('pre
code')).not.toBeNull());
+
+ expect(container.querySelector('pre
code')!.classList.contains('hljs')).toBe(false);
+ expect(container.textContent).toContain('const x = 1;');
+ });
+});
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/components/renderers/HTMLRenderer.spec.tsx
b/zeppelin-web-angular/projects/zeppelin-react/src/components/renderers/HTMLRenderer.spec.tsx
new file mode 100644
index 0000000000..0911a9f15b
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/components/renderers/HTMLRenderer.spec.tsx
@@ -0,0 +1,84 @@
+/*
+ * Licensed 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 { render, screen, waitFor } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { HTMLRenderer } from './HTMLRenderer';
+
+// jsdom never runs scripts, so the execution this component exists for is out
of reach here and belongs in e2e.
+// What these specs pin is the fidelity of the swap:
+// attributes, body and position survive it. They do not prove the swap
happened,
+// because a script parsed out of innerHTML already carries all three.
+// Only the async assertion below distinguishes a rebuilt script from an inert
one.
+describe('HTMLRenderer', () => {
+ it('renders the markup it is given', () => {
+ render(<HTMLRenderer html="<p>rendered output</p>" />);
+
+ expect(screen.getByText('rendered output')).toBeTruthy();
+ });
+
+ it('carries the original attributes onto the replacement script', () => {
+ const { container } = render(
+ <HTMLRenderer html='<script type="text/javascript" data-mark="kept"
src="lib.js"></script>' />
+ );
+
+ const script = container.querySelector('script')!;
+ expect(script.getAttribute('type')).toBe('text/javascript');
+ expect(script.getAttribute('data-mark')).toBe('kept');
+ expect(script.getAttribute('src')).toBe('lib.js');
+ });
+
+ it('keeps the script body so the replacement has something to run', () => {
+ const { container } = render(<HTMLRenderer html="<script>window.answer =
42;</script>" />);
+
+ expect(container.querySelector('script')!.textContent).toBe('window.answer
= 42;');
+ });
+
+ it('forces async off even when the source markup asked for it', () => {
+ // A library and the code using it must not arrive out of order.
+ const { container } = render(<HTMLRenderer html='<script async
src="lib.js"></script>' />);
+
+ expect(container.querySelector('script')!.async).toBe(false);
+ });
+
+ it('leaves each script where it was among the surrounding markup', () => {
+ const { container } = render(
+ <HTMLRenderer html='<p>before</p><script
id="one"></script><p>between</p><script id="two"></script>' />
+ );
+
+ const ids = Array.from(container.querySelectorAll('.inner-html >
*')).map(node => node.id || node.tagName);
+ expect(ids).toEqual(['P', 'one', 'P', 'two']);
+ });
+
+ it('highlights a code block, matching what the Angular renderer does', async
() => {
+ const { container } = render(<HTMLRenderer html="<pre><code>const x =
1;</code></pre>" />);
+
+ // highlight.js arrives through a dynamic import, so the class lands a tick
+ // later. Which language it guesses is its own business and not pinned
here.
+ await waitFor(() => expect(container.querySelector('pre
code')!.classList.contains('hljs')).toBe(true));
+ });
+
+ it('replaces the previous output when the html changes', () => {
+ const { rerender } = render(<HTMLRenderer html="<p>first</p>" />);
+
+ rerender(<HTMLRenderer html="<p>second</p>" />);
+
+ expect(screen.queryByText('first')).toBeNull();
+ expect(screen.getByText('second')).toBeTruthy();
+ });
+
+ it('renders nothing visible for empty html', () => {
+ const { container } = render(<HTMLRenderer html="" />);
+
+ expect(container.textContent).toBe('');
+ });
+});
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/components/renderers/HTMLRenderer.tsx
b/zeppelin-web-angular/projects/zeppelin-react/src/components/renderers/HTMLRenderer.tsx
index 45fbeec9d4..93ba5b5223 100644
---
a/zeppelin-web-angular/projects/zeppelin-react/src/components/renderers/HTMLRenderer.tsx
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/components/renderers/HTMLRenderer.tsx
@@ -31,9 +31,12 @@ export const HTMLRenderer = ({ html }: HTMLRendererProps) =>
{
// Highlight code blocks (matches Angular: result.component.ts
renderHTML)
const codeEle = container.querySelector('pre code');
if (codeEle) {
- import('highlight.js').then(({ default: hljs }) => {
- hljs.highlightBlock(codeEle as HTMLElement);
- });
+ import('highlight.js')
+ .then(({ default: hljs }) => {
+ hljs.highlightBlock(codeEle as HTMLElement);
+ })
+ // Without this a failed chunk is an unhandled rejection; the cost
is an unhighlighted block.
+ .catch(() => undefined);
}
const scripts = Array.from(container.querySelectorAll('script'));
diff --git
a/zeppelin-web-angular/projects/zeppelin-react/src/templates/SingleResultRenderer.spec.tsx
b/zeppelin-web-angular/projects/zeppelin-react/src/templates/SingleResultRenderer.spec.tsx
new file mode 100644
index 0000000000..3240b42f11
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-react/src/templates/SingleResultRenderer.spec.tsx
@@ -0,0 +1,85 @@
+/*
+ * Licensed 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 { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { DatasetType, ParagraphConfigResults, ParagraphIResultsMsgItem } from
'@zeppelin/sdk';
+import { SingleResultRenderer } from './SingleResultRenderer';
+
+const result = (type: DatasetType, data: string): ParagraphIResultsMsgItem =>
({ type, data });
+
+const TABLE_DATA = 'name\tage\nalice\t30';
+
+// Index 0 stays a table, index 1 draws a chart, so reading the wrong entry
shows.
+const configs = {
+ 0: { graph: { mode: 'table' } },
+ 1: { graph: { mode: 'multiBarChart' } }
+} as unknown as ParagraphConfigResults;
+
+describe('SingleResultRenderer', () => {
+ it('renders TEXT as text, leaving markup in it literal', () => {
+ // Markup in the payload is what separates this arm from HTML: routing
TEXT to
+ // HTMLRenderer would parse the tag away instead of showing it.
+ render(<SingleResultRenderer index={0} result={result(DatasetType.TEXT,
'line one <b>not bold</b>')} />);
+
+ expect(screen.getByText(/line one <b>not bold<\/b>/)).toBeTruthy();
+ });
+
+ it('renders TABLE through the visualization', () => {
+ render(<SingleResultRenderer index={0} result={result(DatasetType.TABLE,
TABLE_DATA)} />);
+
+ // Only that the arm was taken. The visualization's display-mode state is a
+ // known defect (projects/zeppelin-react/AGENTS.md), so nothing here pins
it.
+ expect(screen.getByText('alice')).toBeTruthy();
+ expect(screen.getByRole('button', { name: /Bar Chart/ })).toBeTruthy();
+ });
+
+ it('hands the visualization the display config for its own result index', ()
=> {
+ // Both indices are rendered: index 1 alone would pass against a
hard-coded [1],
+ // and the positive assertion keeps an absent chart from reading as
success.
+ const table = render(
+ <SingleResultRenderer index={0} config={configs}
result={result(DatasetType.TABLE, TABLE_DATA)} />
+ );
+ expect(screen.getByText('alice')).toBeTruthy();
+ table.unmount();
+
+ render(<SingleResultRenderer index={1} config={configs}
result={result(DatasetType.TABLE, TABLE_DATA)} />);
+ expect(screen.getByRole('button', { name: /Table/ })).toBeTruthy();
+ expect(screen.queryByText('alice')).toBeNull();
+ });
+
+ it('renders IMG as a base64 png', () => {
+ render(<SingleResultRenderer index={0} result={result(DatasetType.IMG,
'QUJD')} />);
+
+
expect(screen.getByRole('img').getAttribute('src')).toBe('data:image/png;base64,QUJD');
+ });
+
+ it('renders HTML as markup rather than as text', () => {
+ render(<SingleResultRenderer index={0} result={result(DatasetType.HTML,
'<p>markup output</p>')} />);
+
+ expect(screen.getByText('markup output').tagName).toBe('P');
+ });
+
+ it('tells the user that ANGULAR results are unsupported here', () => {
+ render(<SingleResultRenderer index={0} result={result(DatasetType.ANGULAR,
'anything')} />);
+
+ expect(screen.getByText('Angular Component')).toBeTruthy();
+ expect(screen.getByText(/not supported in React
environment/)).toBeTruthy();
+ });
+
+ it('renders nothing for a type it has no renderer for', () => {
+ // NETWORK is declared by the SDK and reaches the default arm.
+ const { container } = render(<SingleResultRenderer index={0}
result={result(DatasetType.NETWORK, 'graph')} />);
+
+ expect(container.innerHTML).toBe('');
+ });
+});