Skip to content
Open
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
35 changes: 3 additions & 32 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,37 +44,6 @@ jobs:
working-directory: zeppelin-web-angular/projects/zeppelin-react
run: npm ci --ignore-scripts && npm audit --audit-level=high

run-e2e-tests-in-zeppelin-web:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Tune Runner VM
uses: ./.github/actions/tune-runner-vm
- name: Set up JDK 11
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: 11
- name: Cache local Maven repository
uses: actions/cache@v4
with:
path: |
~/.m2/repository
!~/.m2/repository/org/apache/zeppelin/
~/.spark-dist
~/.cache
key: ${{ runner.os }}-zeppelin-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-zeppelin-
- name: Install application
run: ./mvnw clean install -DskipTests -am -pl zeppelin-web,zeppelin-web-angular -Pweb-classic -Pspark-scala-2.12 -Pspark-3.4 -Pweb-dist ${MAVEN_ARGS}
- name: Run headless test
run: xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" ./mvnw verify -pl zeppelin-web -Pweb-classic -Pspark-scala-2.12 -Pspark-3.4 -Pweb-dist -Pweb-e2e ${MAVEN_ARGS}
- name: Print zeppelin logs
if: always()
run: if [ -d "logs" ]; then cat logs/*; fi

run-playwright-e2e-tests:
runs-on: ubuntu-24.04
env:
Expand Down Expand Up @@ -122,7 +91,7 @@ jobs:
channels: conda-forge,defaults
channel-priority: strict
- name: Install application
run: ./mvnw clean install -DskipTests -am -pl python,zeppelin-jupyter-interpreter,zeppelin-web-angular ${MAVEN_ARGS}
run: ./mvnw clean install -DskipTests -am -pl python,zeppelin-jupyter-interpreter,zeppelin-web,zeppelin-web-angular -Pweb-classic ${MAVEN_ARGS}
- name: Setup Zeppelin Server (Shiro.ini)
run: |
export ZEPPELIN_CONF_DIR=./conf
Expand All @@ -136,6 +105,8 @@ jobs:
mkdir -p $ZEPPELIN_E2E_TEST_NOTEBOOK_DIR
echo "Created test notebook directory: $ZEPPELIN_E2E_TEST_NOTEBOOK_DIR"
- name: Run headless E2E test with Maven
env:
E2E_MODE: ${{ matrix.mode }}
run: xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" ./mvnw verify -pl zeppelin-web-angular -Pweb-e2e ${MAVEN_ARGS}
- name: Upload Playwright Report
uses: actions/upload-artifact@v4
Expand Down
1 change: 0 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -998,7 +998,6 @@
<exclude>**/.bowerrc</exclude>
<exclude>.editorconfig</exclude>
<exclude>**/.eslintrc</exclude>
<exclude>**/protractor.conf.js</exclude>
<exclude>**/.tmp/**</exclude>
<exclude>**/target/**</exclude>
<exclude>**/node/**</exclude>
Expand Down
29 changes: 29 additions & 0 deletions zeppelin-web-angular/e2e/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ exercised transitively and are not counted.
| --- | --- |
| `npm run e2e` | Full suite |
| `npm run e2e:fast` | Chromium only (fast) |
| `npm run e2e:classic` | Classic `/classic` UI suite against `:8080` (needs `-Pweb-classic`) |
| `npm run e2e:ui` | Playwright Test UI |
| `npm run e2e:headed` | Headed run |
| `npm run e2e:debug` | Step-by-step debugger |
Expand Down Expand Up @@ -184,3 +185,31 @@ route being reimplemented, but do not build parity infrastructure ahead of need.
- Keep the composed suite focused on real cross-seam user flows. Behavior that
lives entirely inside one fragment belongs in that fragment's own tests; do not
grow the composed suite into a per-fragment unit suite.

## Classic UI Tests (`e2e/tests/classic/`)

`e2e/tests/classic/` runs Playwright against the legacy AngularJS app served at
`/classic`, ported from the retired `zeppelin-web` Protractor suite. Treat it as
a frozen legacy surface: keep it at parity coverage and test new features only in
the Angular/React suites.

- **Locators (classic exception):** the classic templates predate roles and
`data-testid`, so the role/label/text-first rule cannot apply. Sanctioned here:
element ids (`#findInput`), `ng-click="..."` / `ng-controller="..."` attribute
selectors, and Ace/Select2 internals. Do not add `data-testid` to the frozen
`zeppelin-web` sources.
- **Readiness:** `waitForZeppelinReady` is Angular-specific (`[ng-version]`) and
does not resolve on `/classic`; gate on a classic-visible signal instead (e.g.
the first `ParagraphCtrl` paragraph, or `.ace_text-input` attached).
- **Coverage:** `PAGES` is the Angular coverage denominator; classic pages are
intentionally outside it, so `addPageAnnotationBeforeEach` is not used here.
- **Running:** the `classic` project targets `http://localhost:8080` (Desktop
Chrome only) and needs a Zeppelin server built with `-Pweb-classic` — the
`:4200` dev server does not serve `/classic`. Run it with `npm run e2e:classic`
(sets `E2E_CLASSIC=1`); running a `tests/classic/*` file path directly is also
detected. In CI the classic project runs only in the anonymous matrix leg
(`E2E_MODE`), matching the anonymous-only legacy Protractor suite.
- **POM:** inlining locators/helpers is acceptable while the suite is this small;
if it grows, move them behind `models/classic-*.ts` / `*.util.ts`.
- The React-migration / framework-neutral-spec guidance does not apply to
`tests/classic/`.
8 changes: 5 additions & 3 deletions zeppelin-web-angular/e2e/cleanup-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@

import { BASE_URL, E2E_TEST_FOLDER } from './models/base-page';

const cleanupBaseUrl = process.env.PLAYWRIGHT_BASE_URL || BASE_URL;

export const cleanupTestNotebooks = async () => {
try {
console.log('Cleaning up test folder via API...');

// Get all notebooks and folders
const response = await fetch(`${BASE_URL}/api/notebook`);
const response = await fetch(`${cleanupBaseUrl}/api/notebook`);
const data = await response.json();
if (!data.body || !Array.isArray(data.body)) {
console.log('No notebooks found or invalid response format');
Expand Down Expand Up @@ -47,7 +49,7 @@ export const cleanupTestNotebooks = async () => {
try {
console.log(`Deleting test folder: ${testFolder.id} (${testFolder.path})`);

const deleteResponse = await fetch(`${BASE_URL}/api/notebook/${testFolder.id}`, {
const deleteResponse = await fetch(`${cleanupBaseUrl}/api/notebook/${testFolder.id}`, {
method: 'DELETE'
});

Expand All @@ -70,7 +72,7 @@ export const cleanupTestNotebooks = async () => {
if (error instanceof Error && error.message.includes('ECONNREFUSED')) {
console.error('Failed to connect to local server. Please start the frontend server first:');
console.error(' npm start');
console.error(` or make sure ${BASE_URL} is running`);
console.error(` or make sure ${cleanupBaseUrl} is running`);
} else {
console.warn('Failed to cleanup test folder:', error);
}
Expand Down
84 changes: 84 additions & 0 deletions zeppelin-web-angular/e2e/tests/classic/classic-home.spec.ts
Original file line number Diff line number Diff line change
@@ -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 { expect, Page, test } from '@playwright/test';

const CLASSIC_HOME = '/classic';

const waitForClassicHomeReady = async (page: Page) => {
await page.goto(CLASSIC_HOME, { waitUntil: 'domcontentloaded' });
await expect(page.locator('#welcome')).toHaveText('Welcome to Zeppelin!', { timeout: 30000 });
await expect(page.getByRole('link', { name: /Import note/ })).toBeVisible();
await expect(page.getByRole('link', { name: /Create new note/ })).toBeVisible();
};

test.describe('Classic home', () => {
test.beforeEach(async ({ page }) => {
await waitForClassicHomeReady(page);
});

test('should have a welcome message', async ({ page }) => {
await expect(page.locator('#welcome')).toHaveText('Welcome to Zeppelin!');
});

test('should have the button for importing notebook', async ({ page }) => {
await expect(page.getByRole('link', { name: /Import note/ })).toBeVisible();
});

test('should have the button for creating notebook', async ({ page }) => {
await expect(page.getByRole('link', { name: /Create new note/ })).toBeVisible();
});

test('correct save permission in interpreter', async ({ page }) => {
const ownerName = 'admin';
const interpreterName = `interpreter_e2e_test_${Date.now()}`;

await page.locator('.username').click();
await page.locator('a[href="#/interpreter"]').click();
await expect(page.locator('.interpreterHead')).toBeVisible({ timeout: 30000 });

await page.locator('button[ng-click="showAddNewSetting = !showAddNewSetting"]').click();
const createForm = page.locator('.interpreterSettingAdd');
await expect(createForm).toBeVisible();
await createForm.locator('#newInterpreterSettingName').fill(interpreterName);
await createForm.locator('select[ng-model="newInterpreterSetting.group"]').selectOption({ label: 'angular' });
await createForm.locator('#idShowPermission').check();

const ownerInput = createForm.locator('input.select2-search__field');
await ownerInput.fill(ownerName);
// JUSTIFIED: Select2 can render grouped AJAX/tag candidates; the final visible option is the concrete typed owner.
const ownerOption = page.locator('.select2-results__option').filter({ hasText: ownerName }).last();
await expect(ownerOption).toBeVisible({ timeout: 30000 });
await ownerInput.press('Enter');
await expect(createForm.locator('.select2-selection__choice', { hasText: ownerName })).toBeVisible();

await createForm.locator('span[ng-click="addNewInterpreterSetting()"]').click();

let setting = page.locator(`#${interpreterName}`);
await expect(setting).toBeVisible({ timeout: 30000 });

await setting.locator('span.fa-pencil').click();
await setting.locator('button[type="submit"]').click();
await page.locator('.bootstrap-dialog-footer-buttons button', { hasText: 'OK' }).click();

await page.goto('/classic/#/interpreter', { waitUntil: 'domcontentloaded' });
await expect(page.locator('.interpreterHead')).toBeVisible({ timeout: 30000 });
setting = page.locator(`#${interpreterName}`);
await expect(setting.locator(`select[id="${interpreterName}Owners"] option`)).toHaveText(ownerName, {
timeout: 30000
});

await setting.locator('span.fa-trash').click();
await page.locator('.bootstrap-dialog-footer-buttons button', { hasText: 'OK' }).click();
await expect(setting).toBeHidden({ timeout: 30000 });
});
});
164 changes: 164 additions & 0 deletions zeppelin-web-angular/e2e/tests/classic/classic-search-block.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* 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, Page, test } from '@playwright/test';
import { createTestNotebookWithName } from '../../utils';

const testData = {
textInFirstP: 'text word text',
textInSecondP: 'text tete tt'
};

const countSubstringOccurrence = (text: string, substring: string): number =>
(text.match(new RegExp(substring, 'g')) || []).length;

const expectedMatchCount = (substring: string): number =>
countSubstringOccurrence(testData.textInFirstP, substring) +
countSubstringOccurrence(testData.textInSecondP, substring);

const waitForClassicNotebookReady = async (page: Page, noteId: string) => {
await page.goto(`/classic/#/notebook/${noteId}`, { waitUntil: 'domcontentloaded' });
// JUSTIFIED: Classic note setup creates a single first paragraph; this is the render gate for that paragraph.
await expect(page.locator('div[ng-controller="ParagraphCtrl"]').first()).toBeVisible({ timeout: 30000 });
// JUSTIFIED: The first Ace textarea belongs to the first paragraph created with the note.
await expect(page.locator('.ace_text-input').first()).toBeAttached({ timeout: 30000 });
};

const fillAceEditor = async (page: Page, index: number, text: string) => {
// JUSTIFIED: The tests intentionally fill paragraph editors by creation order: first paragraph, then inserted second paragraph.
const editor = page.locator('.ace_editor').nth(index);
await editor.click();
await page.keyboard.type(text);
await expect(editor.locator('.ace_line', { hasText: text })).toBeVisible({ timeout: 15000 });
};

const makeTestParagraphs = async (page: Page) => {
await fillAceEditor(page, 0, testData.textInFirstP);

await page.locator('.new-paragraph.last-paragraph').click();
await expect(page.locator('.ace_editor')).toHaveCount(2, { timeout: 15000 });
await fillAceEditor(page, 1, testData.textInSecondP);
};

const openSearchBoxByShortcut = async (page: Page) => {
await page.keyboard.press('Control+Alt+F');
await expect(page.locator('.search-dropdown')).toBeVisible({ timeout: 10000 });
};

const findInput = (page: Page) => page.locator('#findInput');
const replaceInput = (page: Page) => page.locator('.search-group', { hasText: 'Replace' }).locator('input');
const matchesElement = (page: Page) => page.locator('.search-group .after-input');
const nextOccurrenceButton = (page: Page) => page.locator('.search-group button[ng-click="nextOccurrence()"]');
const prevOccurrenceButton = (page: Page) => page.locator('.search-group button[ng-click="prevOccurrence()"]');
const replaceButton = (page: Page) => page.locator('.search-group button[ng-click="replace()"]');
const replaceAllButton = (page: Page) => page.locator('.search-group button[ng-click="replaceAll()"]');

const setFindText = async (page: Page, text: string) => {
await findInput(page).fill(text);
};

const waitForMatches = async (page: Page, current: number, amount: number) => {
await expect(matchesElement(page)).toHaveText(`${current} of ${amount}`, { timeout: 15000 });
};

const markerCount = async (page: Page): Promise<number> =>
page.locator('.ace_marker-layer div.ace_selected-word, .ace_marker-layer div.ace_selection').count();

test.describe('Classic search block', () => {
test.beforeEach(async ({ page }) => {
const { noteId } = await createTestNotebookWithName(page, {
namePrefix: 'ClassicSearchBlock'
});
await waitForClassicNotebookReady(page, noteId);
});

test('shortcut works', async ({ page }) => {
// JUSTIFIED: The keyboard shortcut is scoped to the currently focused first paragraph editor.
await page.locator('.ace_editor').first().click();
await openSearchBoxByShortcut(page);
});

test('correct count of selections', async ({ page }) => {
await makeTestParagraphs(page);
await openSearchBoxByShortcut(page);

const textToFind = 'te';
const matchesCount = expectedMatchCount(textToFind);
await setFindText(page, textToFind);

await waitForMatches(page, 1, matchesCount);
await expect.poll(() => markerCount(page), { timeout: 15000 }).toBe(matchesCount + 1);
});

test('correct matches count number', async ({ page }) => {
await makeTestParagraphs(page);
await openSearchBoxByShortcut(page);

let textToFind = 't';
await setFindText(page, textToFind);
await waitForMatches(page, 1, expectedMatchCount(textToFind));

textToFind = 'te';
await setFindText(page, textToFind);
await waitForMatches(page, 1, expectedMatchCount(textToFind));
});

test('counter increase and decrease correctly', async ({ page }) => {
await makeTestParagraphs(page);
await openSearchBoxByShortcut(page);

const textToFind = 'te';
const matchesCount = expectedMatchCount(textToFind);
await setFindText(page, textToFind);
await waitForMatches(page, 1, matchesCount);

await nextOccurrenceButton(page).click();
await waitForMatches(page, matchesCount > 1 ? 2 : 1, matchesCount);

await prevOccurrenceButton(page).click();
await waitForMatches(page, 1, matchesCount);

await prevOccurrenceButton(page).click();
await waitForMatches(page, matchesCount, matchesCount);
});

test('matches count changes correctly after replace', async ({ page }) => {
await makeTestParagraphs(page);
await openSearchBoxByShortcut(page);

const textToFind = 'te';
const matchesCount = expectedMatchCount(textToFind);
await setFindText(page, textToFind);
await waitForMatches(page, 1, matchesCount);
await replaceInput(page).fill('ABC');

await replaceButton(page).click();
await waitForMatches(page, 1, matchesCount - 1);

await prevOccurrenceButton(page).click();
await replaceButton(page).click();
await waitForMatches(page, 1, matchesCount - 2);
});

test('replace all works correctly', async ({ page }) => {
await makeTestParagraphs(page);
await openSearchBoxByShortcut(page);

const textToFind = 'te';
await setFindText(page, textToFind);
await waitForMatches(page, 1, expectedMatchCount(textToFind));
await replaceInput(page).fill('ABC');

await replaceAllButton(page).click();
await waitForMatches(page, 0, 0);
});
});
1 change: 1 addition & 0 deletions zeppelin-web-angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"test:eslint-rules": "node --test eslint-rules/",
"e2e": "playwright test",
"e2e:fast": "playwright test --project=chromium",
"e2e:classic": "cross-env E2E_CLASSIC=1 playwright test --project=classic",
"e2e:ui": "playwright test --ui",
"e2e:headed": "playwright test --headed",
"e2e:debug": "playwright test --debug",
Expand Down
Loading
Loading