Skip to content
Draft
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
36 changes: 35 additions & 1 deletion .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,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
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down
6 changes: 6 additions & 0 deletions zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* 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 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', { 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);
});
} finally {
const deleted = await page.request.delete(`/api/notebook/${noteId}`, { timeout: 5000 });
await expect(deleted).toBeOK();
}
});
});
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
}
Expand Down
Loading