From c1700fa0a0f977a1a61deb5e7ad977bb0b070c3f Mon Sep 17 00:00:00 2001 From: miinhho Date: Thu, 12 Nov 2026 21:08:31 +0900 Subject: [PATCH 1/4] [ZEPPELIN-6383] Restore Spark dynamic-form E2E coverage Verify form defaults and edited values through real Spark execution. Report undeclared Spark support as skipped and propagate interpreter and assertion failures. --- .../e2e/models/notebook-paragraph-page.ts | 6 ++ .../notebook/paragraph/dynamic-forms.spec.ts | 80 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 zeppelin-web-angular/e2e/tests/notebook/paragraph/dynamic-forms.spec.ts diff --git a/zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts b/zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts index f0083edb266..ccdc69a647a 100644 --- a/zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts +++ b/zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts @@ -41,6 +41,8 @@ export class NotebookParagraphPage extends BasePage { readonly editorViewLines: Locator; readonly editorLines: Locator; readonly dynamicForms: Locator; + readonly dynamicFormTextInput: Locator; + readonly dynamicFormSelect: Locator; readonly resultDisplay: Locator; readonly executionTime: Locator; readonly elapsedTime: Locator; @@ -87,6 +89,10 @@ export class NotebookParagraphPage extends BasePage { this.editorViewLinesAll = codeEditors.locator('.view-lines'); this.editorLines = this.codeEditor.locator('.view-line'); this.dynamicForms = this.paragraphContainer.locator('zeppelin-notebook-paragraph-dynamic-forms'); + // Both controls expose textbox roles; exclude the select's internal search input. + this.dynamicFormTextInput = this.dynamicForms.locator('input.ant-input'); + // The dynamic-form template has no associated labels; nz-select owns the dropdown trigger. + this.dynamicFormSelect = this.dynamicForms.locator('nz-select'); this.resultDisplay = this.paragraphContainer.locator('zeppelin-notebook-paragraph-result'); const footerInfo = this.paragraphContainer.getByTestId('angular-paragraph-footer'); this.executionTime = footerInfo.locator('.execution-time'); diff --git a/zeppelin-web-angular/e2e/tests/notebook/paragraph/dynamic-forms.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/paragraph/dynamic-forms.spec.ts new file mode 100644 index 00000000000..4b092175e6c --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/notebook/paragraph/dynamic-forms.spec.ts @@ -0,0 +1,80 @@ +/* + * 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 { expect, test } from '@playwright/test'; +import { NotebookParagraphPage } from 'e2e/models/notebook-paragraph-page'; +import { addPageAnnotationBeforeEach, createNotebookViaRest, PAGES, setParagraphText } from '../../../utils'; + +test.describe('Notebook Paragraph Dynamic Forms', () => { + test.setTimeout(60000); + test.use({ actionTimeout: 10000, navigationTimeout: 10000 }); + test.skip( + process.env.ZEPPELIN_E2E_REQUIRE_SPARK !== 'true', + 'Not run: requires a server with Spark; set ZEPPELIN_E2E_REQUIRE_SPARK=true to require execution' + ); + addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH); + + test('should display dynamic forms and execute with edited values', async ({ page }, testInfo) => { + const { noteId, paragraphId } = await createNotebookViaRest( + page, + `E2E_TEST_FOLDER/DynamicForms_${Date.now()}_${testInfo.workerIndex}` + ); + try { + await setParagraphText( + page, + noteId, + paragraphId, + `%spark +println("Name: " + z.input("name", "World")) +println("Age: " + z.select("age", "1", Seq(("1", "Under 18"), ("2", "18-65"), ("3", "Over 65"))))` + ); + // Keep edits local until Run is clicked, so each output belongs to one explicit execution. + const configure = await page.request.put(`/api/notebook/${noteId}/paragraph/${paragraphId}/config`, { + data: { runOnSelectionChange: false } + }); + await expect(configure).toBeOK(); + await page.goto(`/#/notebook/${noteId}`); + const paragraph = new NotebookParagraphPage(page); + await expect(paragraph.paragraphContainer).toBeVisible({ timeout: 10000 }); + const runAndExpectOutput = async (output: string, timeout: number) => { + await paragraph.runParagraph(); + // Stop waiting on interpreter errors, not only when the expected output arrives. + await paragraph.resultDisplay + .filter({ hasText: output }) + .or(paragraph.status.filter({ hasText: /^(ERROR|ABORT)$/ })) + .waitFor({ state: 'visible', timeout }); + await expect(paragraph.status).toHaveText('FINISHED', { timeout: 1000 }); + // Spark may prepend compiler warnings; the form values and successful execution are the contract. + await expect(paragraph.resultDisplay).toContainText(output, { timeout: 1000 }); + }; + + await test.step('Spark creates a text input and a select with their default values', async () => { + await runAndExpectOutput('Name: World\nAge: 1\n', 30000); + await expect(paragraph.dynamicFormTextInput).toHaveValue('World', { timeout: 5000 }); + await expect(paragraph.dynamicFormSelect).toHaveText('Under 18', { timeout: 5000 }); + }); + + await test.step('Editing both forms changes the next Spark execution output', async () => { + await paragraph.dynamicFormTextInput.fill('Zeppelin'); + await paragraph.dynamicFormSelect.click(); + await page.getByText('18-65', { exact: true }).click(); + await expect(paragraph.dynamicFormTextInput).toHaveValue('Zeppelin'); + await expect(paragraph.dynamicFormSelect).toHaveText('18-65'); + // Distinct output cannot be satisfied by the previous FINISHED execution. + await runAndExpectOutput('Name: Zeppelin\nAge: 2\n', 10000); + }); + } finally { + const deleted = await page.request.delete(`/api/notebook/${noteId}`, { timeout: 5000 }); + await expect(deleted).toBeOK(); + } + }); +}); From bf63b99751e92eddd526531b71acc92c23d01664 Mon Sep 17 00:00:00 2001 From: miinhho Date: Thu, 12 Nov 2026 21:08:39 +0900 Subject: [PATCH 2/4] [ZEPPELIN-6383] Run Spark dynamic-form E2E in an isolated CI step Build the Spark interpreter and Scala 2.12 support. Run the dynamic-form scenario with Spark 3.5.8 in both authentication modes, using isolated notebook storage and a separate report. Exclude Spark from ordinary E2E interpreter discovery. --- .github/workflows/frontend.yml | 36 +++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index a64f8190f61..3ba85374665 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -54,6 +54,10 @@ jobs: env: # Use VFS storage instead of Git to avoid Git-related issues in CI ZEPPELIN_NOTEBOOK_STORAGE: org.apache.zeppelin.notebook.repo.VFSNotebookRepo + # Preserve the ordinary suite's interpreter inventory even though Spark is built below. + ZEPPELIN_INTERPRETER_EXCLUDES: spark + SPARK_VERSION: '3.5.8' + HADOOP_VERSION: '3' strategy: # Keep both legs running so a Maven-owned failure on the auth leg cannot cancel the anonymous leg # before its named unit test and typecheck steps report. @@ -100,8 +104,13 @@ jobs: use-mamba: true channels: conda-forge,defaults channel-priority: strict + - name: Cache Spark distribution + uses: actions/cache@v5 + with: + path: .spark-dist + key: ${{ runner.os }}-spark-e2e-${{ env.SPARK_VERSION }}-hadoop${{ env.HADOOP_VERSION }} - name: Install application - run: ./mvnw clean install -DskipTests -am -pl python,zeppelin-jupyter-interpreter,zeppelin-web,zeppelin-web-angular -Pweb-classic ${MAVEN_ARGS} + run: ./mvnw clean install -DskipTests -am -pl spark/interpreter,spark/scala-2.12,python,zeppelin-jupyter-interpreter,zeppelin-web,zeppelin-web-angular -Pweb-classic,spark-scala-2.12,spark-3.5 ${MAVEN_ARGS} - name: Run Angular shell unit tests if: matrix.mode == 'anonymous' working-directory: zeppelin-web-angular @@ -161,6 +170,30 @@ jobs: "$zeppelin_daemon" start cd zeppelin-web-angular xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" ./node/npm run e2e -- tests/notebook/revision/revision-isolation.spec.ts --output=test-results-revision --reporter=github,html + - name: Run Spark dynamic-form E2E + env: + CI: 'true' + ZEPPELIN_INTERPRETER_EXCLUDES: '' + ZEPPELIN_E2E_REQUIRE_SPARK: 'true' + SPARK_HOME: ${{ github.workspace }}/spark-${{ env.SPARK_VERSION }}-bin-hadoop${{ env.HADOOP_VERSION }} + SPARK_MASTER: 'local[2]' + PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report-spark + run: | + ./testing/downloadSpark.sh "$SPARK_VERSION" "$HADOOP_VERSION" + test -x "$SPARK_HOME/bin/spark-submit" + spark_notebook_dir="$(mktemp -d "${RUNNER_TEMP}/zeppelin-spark-notebooks.XXXXXX")" + zeppelin_daemon="${GITHUB_WORKSPACE}/bin/zeppelin-daemon.sh" + cleanup() { + "$zeppelin_daemon" stop || true + rm -rf -- "$spark_notebook_dir" + } + trap cleanup EXIT + export ZEPPELIN_NOTEBOOK_DIR="$spark_notebook_dir" + # The step owns cleanup; globalSetup must not reset the ordinary suite's notebook directory. + unset ZEPPELIN_E2E_TEST_NOTEBOOK_DIR + "$zeppelin_daemon" start + cd zeppelin-web-angular + xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" ./node/npm run e2e -- tests/notebook/paragraph/dynamic-forms.spec.ts --workers=1 --retries=0 --output=test-results-spark --reporter=github,html - name: Upload Playwright Report uses: actions/upload-artifact@v6 if: always() @@ -170,6 +203,7 @@ jobs: zeppelin-web-angular/playwright-report/ zeppelin-web-angular/playwright-report-classic/ zeppelin-web-angular/playwright-report-revision/ + zeppelin-web-angular/playwright-report-spark/ retention-days: 3 - name: Print Zeppelin logs if: always() From 914194478e53184ac96890c7d9997b16da5d3927 Mon Sep 17 00:00:00 2001 From: miinhho Date: Thu, 12 Nov 2026 21:44:17 +0900 Subject: [PATCH 3/4] [ZEPPELIN-6383] Explicitly enable classic UI distribution build Keep web-dist active when spark-scala-2.12 is explicitly selected so the classic UI build generates dist/WEB-INF/web.xml before WAR packaging. --- .github/workflows/frontend.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 3ba85374665..f9476cedf3b 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -110,7 +110,7 @@ jobs: path: .spark-dist key: ${{ runner.os }}-spark-e2e-${{ env.SPARK_VERSION }}-hadoop${{ env.HADOOP_VERSION }} - name: Install application - run: ./mvnw clean install -DskipTests -am -pl spark/interpreter,spark/scala-2.12,python,zeppelin-jupyter-interpreter,zeppelin-web,zeppelin-web-angular -Pweb-classic,spark-scala-2.12,spark-3.5 ${MAVEN_ARGS} + run: ./mvnw clean install -DskipTests -am -pl spark/interpreter,spark/scala-2.12,python,zeppelin-jupyter-interpreter,zeppelin-web,zeppelin-web-angular -Pweb-classic,web-dist,spark-scala-2.12,spark-3.5 ${MAVEN_ARGS} - name: Run Angular shell unit tests if: matrix.mode == 'anonymous' working-directory: zeppelin-web-angular From 37d902d77a790366018103c365087de674755e41 Mon Sep 17 00:00:00 2001 From: miinhho Date: Sat, 14 Nov 2026 03:30:53 +0900 Subject: [PATCH 4/4] [ZEPPELIN-6383] Preserve paragraph input focus during selection --- .../notebook/paragraph/dynamic-forms.spec.ts | 5 +- .../paragraph/paragraph.component.spec.ts | 95 +++++++++++++++++++ .../notebook/paragraph/paragraph.component.ts | 6 +- 3 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.spec.ts diff --git a/zeppelin-web-angular/e2e/tests/notebook/paragraph/dynamic-forms.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/paragraph/dynamic-forms.spec.ts index 4b092175e6c..54e291a728a 100644 --- a/zeppelin-web-angular/e2e/tests/notebook/paragraph/dynamic-forms.spec.ts +++ b/zeppelin-web-angular/e2e/tests/notebook/paragraph/dynamic-forms.spec.ts @@ -65,10 +65,11 @@ println("Age: " + z.select("age", "1", Seq(("1", "Under 18"), ("2", "18-65"), (" await test.step('Editing both forms changes the next Spark execution output', async () => { await paragraph.dynamicFormTextInput.fill('Zeppelin'); + await expect(paragraph.dynamicFormTextInput).toHaveValue('Zeppelin', { timeout: 1000 }); await paragraph.dynamicFormSelect.click(); await page.getByText('18-65', { exact: true }).click(); - await expect(paragraph.dynamicFormTextInput).toHaveValue('Zeppelin'); - await expect(paragraph.dynamicFormSelect).toHaveText('18-65'); + await expect(paragraph.dynamicFormTextInput).toHaveValue('Zeppelin', { timeout: 1000 }); + await expect(paragraph.dynamicFormSelect).toHaveText('18-65', { timeout: 1000 }); // Distinct output cannot be satisfied by the previous FINISHED execution. await runAndExpectOutput('Name: Zeppelin\nAge: 2\n', 10000); }); diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.spec.ts b/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.spec.ts new file mode 100644 index 00000000000..11f8cfa21f1 --- /dev/null +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.spec.ts @@ -0,0 +1,95 @@ +/* + * 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 { ChangeDetectorRef, ElementRef, SimpleChange } from '@angular/core'; +import { EMPTY } from 'rxjs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { NzModalService } from 'ng-zorro-antd/modal'; +import type { + MessageService, + NgZService, + NoteStatusService, + NoteVarShareService, + ShortcutService +} from '@zeppelin/services'; + +vi.mock('../../share/result/result.component', () => ({ NotebookParagraphResultComponent: class {} })); +vi.mock('./code-editor/code-editor.component', () => ({ NotebookParagraphCodeEditorComponent: class {} })); + +import { NotebookParagraphComponent } from './paragraph.component'; + +describe('NotebookParagraphComponent', () => { + describe.each(['selection', 'reorder'])('Paragraph focus after %s', reason => { + let component: NotebookParagraphComponent; + let host: HTMLElement; + let input: HTMLInputElement; + let outside: HTMLButtonElement; + + beforeEach(() => { + vi.useFakeTimers(); + host = document.createElement('div'); + host.tabIndex = -1; + input = document.createElement('input'); + host.append(input); + outside = document.createElement('button'); + document.body.append(host, outside); + component = new NotebookParagraphComponent( + { receive: () => EMPTY, receiveEnvelope: () => EMPTY } as unknown as MessageService, + new ElementRef(host), + {} as NoteVarShareService, + {} as NzModalService, + {} as ShortcutService, + {} as NoteStatusService, + { markForCheck: vi.fn() } as unknown as ChangeDetectorRef, + {} as NgZService + ); + component.select = true; + }); + + afterEach(() => { + component.ngOnDestroy(); + host.remove(); + outside.remove(); + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + const scheduleFocus = () => { + component.ngOnChanges( + reason === 'selection' + ? { select: new SimpleChange(false, true, false) } + : { index: new SimpleChange(1, 0, false) } + ); + }; + + it('preserves input focus acquired after scheduling', () => { + outside.focus(); + scheduleFocus(); + input.focus(); + + vi.runOnlyPendingTimers(); + + expect(document.activeElement).toBe(input); + }); + + it('focuses the selected paragraph when focus is outside it', () => { + outside.focus(); + scheduleFocus(); + + vi.runOnlyPendingTimers(); + + expect(document.activeElement).toBe(host); + }); + }); +}); diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.ts b/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.ts index bd788f81890..4b2fb7ce767 100644 --- a/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.ts +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.ts @@ -770,8 +770,10 @@ export class NotebookParagraphComponent (select && select.currentValue === true && select.previousValue !== true) ) { setTimeout(() => { - if (this.mode === 'command' && this.host.nativeElement) { - (this.host.nativeElement as HTMLElement).focus(); + const host = this.host.nativeElement as HTMLElement; + // A descendant may acquire focus after this callback was scheduled. + if (this.mode === 'command' && host && !host.contains(host.ownerDocument.activeElement)) { + host.focus(); } }); }