This is an automated email from the ASF dual-hosted git repository.
tbonelee 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 0539c8a386 [ZEPPELIN-6422] Stabilize flaky Playwright E2E tests
0539c8a386 is described below
commit 0539c8a386c198985bd69acefbe8208bcfa12412
Author: YONGJAE LEE (이용재) <[email protected]>
AuthorDate: Wed Jun 3 19:55:46 2026 +0900
[ZEPPELIN-6422] Stabilize flaky Playwright E2E tests
### What is this PR for?
Three races caused flaky `frontend / run-playwright-e2e-tests`:
1. Per-test login raced on the shared session cookie under parallel workers
-> moved to a single `setup` project + `storageState`.
2. `locator.fill` on Ant modal inputs landed before Angular bound the
form-control -> new `BasePage.fillAndVerifyInput()` retries via `expect.toPass`
until the input value sticks.
3. Modal/dropdown/theme/logout transitions had no explicit wait -> targeted
waits added at each boundary.
Inline comments on the diff for the non-obvious bits.
### What type of PR is it?
Bug Fix
### Todos
### What is the Jira issue?
ZEPPELIN-6422
### How should this be tested?
### Screenshots (if appropriate)
### Questions:
* Does the license files need to update? No
* Is there breaking changes for older versions? No
* Does this needs documentation? No
Closes #5262 from voidmatcha/fix/e2e-flaky-final.
Signed-off-by: ChanHo Lee <[email protected]>
---
zeppelin-web-angular/.gitignore | 1 +
zeppelin-web-angular/e2e/global.setup.ts | 45 +++
zeppelin-web-angular/e2e/models/base-page.ts | 22 +-
zeppelin-web-angular/e2e/models/dark-mode-page.ts | 5 +
.../e2e/models/folder-rename-page.ts | 40 +--
.../e2e/models/folder-rename-page.util.ts | 2 +-
zeppelin-web-angular/e2e/models/home-page.ts | 5 +-
zeppelin-web-angular/e2e/models/node-list-page.ts | 9 +-
.../e2e/models/note-create-modal.ts | 3 +-
.../e2e/models/note-import-modal.ts | 4 +-
.../e2e/models/note-rename-page.ts | 2 +-
.../e2e/models/notebook-repos-page.ts | 9 +-
zeppelin-web-angular/e2e/models/notebook.util.ts | 6 +-
zeppelin-web-angular/e2e/tests/app.spec.ts | 71 +++--
.../e2e/tests/home/home-page-elements.spec.ts | 3 +-
.../tests/home/home-page-external-links.spec.ts | 3 +-
.../e2e/tests/home/home-page-layout.spec.ts | 3 +-
.../tests/home/home-page-note-operations.spec.ts | 31 +-
.../tests/home/home-page-notebook-actions.spec.ts | 7 +-
zeppelin-web-angular/e2e/tests/login/login.spec.ts | 17 +-
.../action-bar/action-bar-functionality.spec.ts | 14 +-
.../keyboard/notebook-keyboard-shortcuts.spec.ts | 9 +-
.../tests/notebook/main/notebook-container.spec.ts | 11 +-
.../notebook/main/notebook-navigation.spec.ts | 3 +-
.../notebook/published/published-paragraph.spec.ts | 8 +-
.../notebook/sidebar/sidebar-functionality.spec.ts | 11 +-
.../about-zeppelin/about-zeppelin-modal.spec.ts | 3 +-
.../share/folder-rename/folder-rename.spec.ts | 35 ++-
.../tests/share/header/header-navigation.spec.ts | 21 +-
.../e2e/tests/share/header/header-search.spec.ts | 9 +-
.../node-list/node-list-functionality.spec.ts | 27 +-
.../share/note-create/note-create-modal.spec.ts | 11 +-
.../share/note-import/note-import-modal.spec.ts | 3 +-
.../tests/share/note-rename/note-rename.spec.ts | 17 +-
.../e2e/tests/share/note-toc/note-toc.spec.ts | 14 +-
.../e2e/tests/theme/dark-mode.spec.ts | 15 +-
.../notebook-repo-item-display.spec.ts | 3 +-
.../notebook-repos/notebook-repo-item-edit.spec.ts | 3 +-
.../notebook-repo-item-form-validation.spec.ts | 3 +-
.../notebook-repo-item-settings.spec.ts | 3 +-
.../notebook-repo-item-workflow.spec.ts | 3 +-
.../notebook-repos-page-structure.spec.ts | 3 +-
.../tests/workspace/user-menu-navigation.spec.ts | 3 +-
.../e2e/tests/workspace/workspace-main.spec.ts | 3 +-
zeppelin-web-angular/e2e/utils.ts | 325 +++++++++++++--------
zeppelin-web-angular/playwright.config.js | 44 ++-
.../src/app/share/header/header.component.html | 1 +
.../src/app/share/header/header.component.less | 11 +
48 files changed, 560 insertions(+), 344 deletions(-)
diff --git a/zeppelin-web-angular/.gitignore b/zeppelin-web-angular/.gitignore
index f285d87e9b..42b640c2fd 100644
--- a/zeppelin-web-angular/.gitignore
+++ b/zeppelin-web-angular/.gitignore
@@ -48,6 +48,7 @@ Thumbs.db
/playwright-coverage/
/test-results/
/playwright/.cache/
+/playwright/.auth/
#
.env
diff --git a/zeppelin-web-angular/e2e/global.setup.ts
b/zeppelin-web-angular/e2e/global.setup.ts
new file mode 100644
index 0000000000..234d0dbea1
--- /dev/null
+++ b/zeppelin-web-angular/e2e/global.setup.ts
@@ -0,0 +1,45 @@
+/*
+ * 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 * as fs from 'fs';
+import * as path from 'path';
+import { test as setup, expect } from '@playwright/test';
+import { LoginTestUtil } from './models/login-page.util';
+import { performLoginIfRequired, waitForZeppelinReady } from './utils';
+
+// Resolved against the Playwright project rootDir (zeppelin-web-angular/).
+// Must match the `storageState` value declared for browser projects in
playwright.config.js.
+export const STORAGE_STATE = path.join('playwright', '.auth', 'user.json');
+
+setup('authenticate', async ({ page }) => {
+ fs.mkdirSync(path.dirname(STORAGE_STATE), { recursive: true });
+
+ const isShiroEnabled = await LoginTestUtil.isShiroEnabled();
+ if (!isShiroEnabled) {
+ // Auth variant disabled — write an empty storage state so dependent
projects load,
+ // then exit. This keeps the setup-project pattern uniform across CI
matrix variants.
+ await page.context().storageState({ path: STORAGE_STATE });
+ return;
+ }
+
+ await page.goto('/');
+ await waitForZeppelinReady(page);
+
+ await performLoginIfRequired(page);
+
+ // Verify we are authenticated. Don't rely on performLoginIfRequired's
return value —
+ // it returns false both for "no work to do" and "login attempt failed".
+ await expect(page.locator('zeppelin-login')).toBeHidden({ timeout: 30000 });
+ await expect(page.getByRole('heading', { name: 'Welcome to Zeppelin!'
})).toBeVisible({ timeout: 30000 });
+
+ await page.context().storageState({ path: STORAGE_STATE });
+});
diff --git a/zeppelin-web-angular/e2e/models/base-page.ts
b/zeppelin-web-angular/e2e/models/base-page.ts
index aa37f1a138..403be0f780 100644
--- a/zeppelin-web-angular/e2e/models/base-page.ts
+++ b/zeppelin-web-angular/e2e/models/base-page.ts
@@ -104,10 +104,22 @@ export class BasePage {
await expect(locator).toBeVisible({ timeout });
await expect(locator).toBeEnabled({ timeout: 5000 });
- // Click first so Angular's form control is focused and its initial
setValue cycle
- // has completed before we overwrite it. Then fill() atomically sets the
value.
- await locator.click();
- await locator.fill(value);
- await expect(locator).toHaveValue(value, { timeout: 10000 });
+ // Ant-modal autofocus + Angular form initialization race: any of fill /
type /
+ // pressSequentially can land BEFORE the form-control's initial value sync,
+ // after which Angular silently resets the input back to the model's
initial
+ // value (placeholder). ng-dirty is set but the visible value is wrong.
+ await expect(async () => {
+ await locator.click();
+ await locator.fill(value);
+ await locator.evaluate((el: HTMLInputElement) => {
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ el.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ // Verify the value stuck — if Angular reset it, this throws and toPass
retries.
+ const actual = await locator.inputValue();
+ if (actual !== value) {
+ throw new Error(`fillAndVerifyInput retry: expected "${value}" got
"${actual}"`);
+ }
+ }).toPass({ timeout: 15000, intervals: [200, 500, 1000, 2000] });
}
}
diff --git a/zeppelin-web-angular/e2e/models/dark-mode-page.ts
b/zeppelin-web-angular/e2e/models/dark-mode-page.ts
index 98f77c8933..bedea19f57 100644
--- a/zeppelin-web-angular/e2e/models/dark-mode-page.ts
+++ b/zeppelin-web-angular/e2e/models/dark-mode-page.ts
@@ -40,6 +40,11 @@ export class DarkModePage extends BasePage {
}
async assertSystemTheme() {
+ // After page reload, Angular re-bootstraps and reads theme from
localStorage. The
+ // toggle-button icon refresh races against that bootstrap window. Wait
for the
+ // root element's data-theme attribute to be set first — that guarantees
Angular's
+ // theme-init has completed — then assert the icon.
+ await expect(this.rootElement).toHaveAttribute('data-theme', /light|dark/,
{ timeout: 15000 });
await expect(this.themeToggleButton).toHaveText('smart_toy', { timeout:
60000 });
}
diff --git a/zeppelin-web-angular/e2e/models/folder-rename-page.ts
b/zeppelin-web-angular/e2e/models/folder-rename-page.ts
index 58f9327c6f..93f49d30be 100644
--- a/zeppelin-web-angular/e2e/models/folder-rename-page.ts
+++ b/zeppelin-web-angular/e2e/models/folder-rename-page.ts
@@ -31,23 +31,12 @@ export class FolderRenamePage extends BasePage {
this.deleteConfirmation = page.locator('.ant-popover').filter({ hasText:
'This folder will be moved to trash.' });
}
- private getFolderNode(folderName: string): Locator {
- return this.page
- .locator('.folder')
- .filter({
- has: this.page.locator('a.name', {
- hasText: new RegExp(`^\\s*${folderName}\\s*$`, 'i')
- })
- })
- .first();
- }
-
async hoverOverFolder(folderName: string): Promise<void> {
await this.page.waitForSelector('zeppelin-node-list', { state: 'visible'
});
- const folderNode = this.getFolderNode(folderName);
// Hover a.name (not .folder) — CSS :hover on .operation is triggered by
the text link, same as clickRenameMenuItem()
- const nameLink = folderNode.locator('a.name');
- await nameLink.scrollIntoViewIfNeeded();
+ const nameLink = this.getFolderNameLink(folderName);
+ await expect(nameLink).toBeVisible({ timeout: 60000 });
+ await nameLink.scrollIntoViewIfNeeded({ timeout: 10000 });
await nameLink.hover({ force: true }); // JUSTIFIED: .operation buttons
are CSS-:hover-revealed; force required to trigger the hover event on the text
link that activates the context menu
}
@@ -63,9 +52,10 @@ export class FolderRenamePage extends BasePage {
async clickRenameMenuItem(folderName: string): Promise<void> {
const folderNode = this.getFolderNode(folderName);
- const nameLink = folderNode.locator('a.name');
+ const nameLink = this.getFolderNameLink(folderName);
- await nameLink.scrollIntoViewIfNeeded();
+ await expect(nameLink).toBeVisible({ timeout: 60000 });
+ await nameLink.scrollIntoViewIfNeeded({ timeout: 10000 });
await nameLink.hover({ force: true }); // JUSTIFIED: .operation buttons
are CSS-:hover-revealed; force required to trigger the hover event on the text
link that activates the context menu
const renameIcon = folderNode.locator('.operation a[nztooltiptitle="Rename
folder"]');
@@ -77,12 +67,11 @@ export class FolderRenamePage extends BasePage {
}
async enterNewName(name: string): Promise<void> {
- await this.renameInput.fill(name);
+ await this.fillAndVerifyInput(this.renameInput, name);
}
async clearNewName(): Promise<void> {
- await this.renameInput.clear();
- await expect(this.renameInput).toHaveValue('');
+ await this.fillAndVerifyInput(this.renameInput, '');
}
async clickConfirm(): Promise<void> {
@@ -97,4 +86,17 @@ export class FolderRenamePage extends BasePage {
async clickCancel(): Promise<void> {
await this.cancelButton.click();
}
+
+ private getFolderNameLink(folderName: string): Locator {
+ return this.page.getByTestId(`folder-${folderName}`).first();
+ }
+
+ private getFolderNode(folderName: string): Locator {
+ return this.page
+ .locator('.node')
+ .filter({
+ has: this.getFolderNameLink(folderName)
+ })
+ .first();
+ }
}
diff --git a/zeppelin-web-angular/e2e/models/folder-rename-page.util.ts
b/zeppelin-web-angular/e2e/models/folder-rename-page.util.ts
index f1e32b1ded..c248e3e376 100644
--- a/zeppelin-web-angular/e2e/models/folder-rename-page.util.ts
+++ b/zeppelin-web-angular/e2e/models/folder-rename-page.util.ts
@@ -31,7 +31,7 @@ export class FolderRenamePageUtil {
return this.folderRenamePage.page
.locator('.node')
.filter({
- has: this.folderRenamePage.page.locator('.folder .name', { hasText:
folderName })
+ has: this.folderRenamePage.page.getByTestId(`folder-${folderName}`)
})
.first();
}
diff --git a/zeppelin-web-angular/e2e/models/home-page.ts
b/zeppelin-web-angular/e2e/models/home-page.ts
index 3222fc3964..412642df44 100644
--- a/zeppelin-web-angular/e2e/models/home-page.ts
+++ b/zeppelin-web-angular/e2e/models/home-page.ts
@@ -122,8 +122,9 @@ export class HomePage extends BasePage {
await expect(this.createNoteButton).toBeEnabled({ timeout: 5000 });
await this.createNoteButton.click({ timeout: 15000 });
// Wait for navigation to the notebook page — confirms the note was
created server-side.
- // waitForPageLoad() (domcontentloaded) fires instantly on SPA routing and
does not guarantee this.
- await this.page.waitForURL(/\/notebook\//, { timeout: 45000 });
+ // This is an Angular hash-route transition, so polling the URL is more
reliable than
+ // waitForURL()'s default "load" wait, which can hang on same-document SPA
navigation.
+ await expect(this.page).toHaveURL(/\/notebook\//, { timeout: 45000 });
}
async clickImportNote(): Promise<void> {
diff --git a/zeppelin-web-angular/e2e/models/node-list-page.ts
b/zeppelin-web-angular/e2e/models/node-list-page.ts
index 9c81bda02f..88fa2721cd 100644
--- a/zeppelin-web-angular/e2e/models/node-list-page.ts
+++ b/zeppelin-web-angular/e2e/models/node-list-page.ts
@@ -41,15 +41,12 @@ export class NodeListPage extends BasePage {
await this.createNewNoteButton.click();
}
- private getNoteByName(noteName: string): Locator {
- return this.page.locator('nz-tree-node').filter({ hasText: noteName
}).first();
+ noteLinkByName(noteName: string): Locator {
+ return this.nodeListContainer.getByRole('link', { name: noteName, exact:
true });
}
async clickNote(noteName: string): Promise<void> {
- const note = this.getNoteByName(noteName);
- // Target the specific link that navigates to the notebook (has href with
"#/notebook/")
- const noteLink = note.locator('a[href*="#/notebook/"]');
- await noteLink.click();
+ await this.noteLinkByName(noteName).click();
}
async getAllVisibleNoteNames(): Promise<string[]> {
diff --git a/zeppelin-web-angular/e2e/models/note-create-modal.ts
b/zeppelin-web-angular/e2e/models/note-create-modal.ts
index a00ef19219..b39d62d85b 100644
--- a/zeppelin-web-angular/e2e/models/note-create-modal.ts
+++ b/zeppelin-web-angular/e2e/models/note-create-modal.ts
@@ -40,8 +40,7 @@ export class NoteCreateModal extends BasePage {
}
async setNoteName(name: string): Promise<void> {
- await this.noteNameInput.clear();
- await this.noteNameInput.fill(name);
+ await this.fillAndVerifyInput(this.noteNameInput, name);
}
async clickCreate(): Promise<void> {
diff --git a/zeppelin-web-angular/e2e/models/note-import-modal.ts
b/zeppelin-web-angular/e2e/models/note-import-modal.ts
index e4634f94bc..92bfe0e8a0 100644
--- a/zeppelin-web-angular/e2e/models/note-import-modal.ts
+++ b/zeppelin-web-angular/e2e/models/note-import-modal.ts
@@ -48,7 +48,7 @@ export class NoteImportModal extends BasePage {
}
async setImportAsName(name: string): Promise<void> {
- await this.importAsInput.fill(name);
+ await this.fillAndVerifyInput(this.importAsInput, name);
}
async getImportAsName(): Promise<string> {
@@ -60,7 +60,7 @@ export class NoteImportModal extends BasePage {
}
async setImportUrl(url: string): Promise<void> {
- await this.urlInput.fill(url);
+ await this.fillAndVerifyInput(this.urlInput, url);
}
async clickImportNote(): Promise<void> {
diff --git a/zeppelin-web-angular/e2e/models/note-rename-page.ts
b/zeppelin-web-angular/e2e/models/note-rename-page.ts
index 932babc409..9999bfcab9 100644
--- a/zeppelin-web-angular/e2e/models/note-rename-page.ts
+++ b/zeppelin-web-angular/e2e/models/note-rename-page.ts
@@ -31,7 +31,7 @@ export class NoteRenamePage extends BasePage {
async enterTitle(title: string): Promise<void> {
await this.ensureEditMode();
- await this.noteTitleInput.fill(title, { timeout: 15000 });
+ await this.fillAndVerifyInput(this.noteTitleInput, title);
}
async clearTitle(): Promise<void> {
diff --git a/zeppelin-web-angular/e2e/models/notebook-repos-page.ts
b/zeppelin-web-angular/e2e/models/notebook-repos-page.ts
index 66234f5b61..76cbf00f7b 100644
--- a/zeppelin-web-angular/e2e/models/notebook-repos-page.ts
+++ b/zeppelin-web-angular/e2e/models/notebook-repos-page.ts
@@ -58,21 +58,26 @@ export class NotebookRepoItemPage extends BasePage {
async clickEdit(): Promise<void> {
await this.editButton.click({ timeout: 15000 });
+ // Wait for Angular to swap to edit mode before returning. Without this,
+ // a follow-up assertion like `expect(editButton).not.toBeVisible()` races
+ // against the re-render and intermittently sees the button still present.
+ await this.saveButton.waitFor({ state: 'visible', timeout: 10000 });
}
async clickSave(): Promise<void> {
await this.saveButton.click({ timeout: 15000 });
+ await this.editButton.waitFor({ state: 'visible', timeout: 10000 });
}
async clickCancel(): Promise<void> {
await this.cancelButton.click({ timeout: 15000 });
+ await this.editButton.waitFor({ state: 'visible', timeout: 10000 });
}
async fillSettingInput(settingName: string, value: string): Promise<void> {
const row = this.repositoryCard.locator('tbody tr').filter({ hasText:
settingName });
const input = row.locator('input[nz-input]');
- await input.clear();
- await input.fill(value);
+ await this.fillAndVerifyInput(input, value);
}
async getSettingInputValue(settingName: string): Promise<string> {
diff --git a/zeppelin-web-angular/e2e/models/notebook.util.ts
b/zeppelin-web-angular/e2e/models/notebook.util.ts
index 98ee1d9648..1070618264 100644
--- a/zeppelin-web-angular/e2e/models/notebook.util.ts
+++ b/zeppelin-web-angular/e2e/models/notebook.util.ts
@@ -11,7 +11,7 @@
*/
import { expect, Page } from '@playwright/test';
-import { performLoginIfRequired, waitForZeppelinReady } from '../utils';
+import { waitForZeppelinReady } from '../utils';
import { BasePage } from './base-page';
import { HomePage } from './home-page';
@@ -26,9 +26,7 @@ export class NotebookUtil extends BasePage {
async createNotebook(notebookName: string): Promise<void> {
await this.homePage.navigateToHome();
- // Perform login if required
- await performLoginIfRequired(this.page);
-
+ // Auth is handled by the `setup` Playwright project + storageState; no
per-call login here.
// Wait for Zeppelin to be fully ready
await waitForZeppelinReady(this.page);
diff --git a/zeppelin-web-angular/e2e/tests/app.spec.ts
b/zeppelin-web-angular/e2e/tests/app.spec.ts
index d637896e0b..759fde133a 100644
--- a/zeppelin-web-angular/e2e/tests/app.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/app.spec.ts
@@ -12,7 +12,9 @@
import { expect, test } from '@playwright/test';
import { BasePage } from '../models/base-page';
-import { addPageAnnotationBeforeEach, waitForZeppelinReady, PAGES,
performLoginIfRequired } from '../utils';
+import { LoginPage } from '../models/login-page';
+import { LoginTestUtil, TestCredentials } from '../models/login-page.util';
+import { addPageAnnotationBeforeEach, waitForZeppelinReady, PAGES } from
'../utils';
test.describe('Zeppelin App Component', () => {
addPageAnnotationBeforeEach(PAGES.APP);
@@ -23,7 +25,6 @@ test.describe('Zeppelin App Component', () => {
await page.goto('/', { waitUntil: 'load' });
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
});
test('should have correct component selector and structure', async ({ page
}) => {
@@ -85,7 +86,7 @@ test.describe('Zeppelin App Component', () => {
await expect(loadingSpinner).toBeHidden();
});
- test('should show logout spinner when logging out', async ({ page }) => {
+ test('should show logout spinner when logging out', async ({ page, browser,
baseURL }) => {
await waitForZeppelinReady(page);
// Only test logout flow for authenticated (non-anonymous) users — skip
before any assertions
@@ -94,27 +95,52 @@ test.describe('Zeppelin App Component', () => {
const statusText = await statusElement.textContent();
test.skip(statusText?.includes('anonymous') ?? false, 'Logout spinner only
applies to authenticated users');
- const logoutSpinner = page.locator('zeppelin-spin').filter({ hasText:
'Logging out' });
-
- // Initially logout spinner should be hidden
- await expect(logoutSpinner).toBeHidden();
-
- await statusElement.click();
- const logoutButton = page.getByRole('link', { name: 'Logout' });
-
- // If the dropdown has no Logout link, auth is not configured — skip
gracefully
- const logoutCount = await logoutButton.count();
- test.skip(logoutCount === 0, 'Logout option not available — auth not
configured in this environment');
-
- await logoutButton.click();
-
- await expect(logoutSpinner).toBeVisible();
- await expect(logoutSpinner).toContainText('Logging out ...');
+ const credentials = await LoginTestUtil.getTestCredentials();
+ const logoutUser = getIsolatedLogoutUser(credentials);
+ test.skip(!logoutUser, 'No non-shared logout test user available');
+
+ // The default auth storage state is shared by the whole parallel suite.
Logging
+ // out from that shared user invalidates the server-side Shiro session for
many
+ // still-running tests, so exercise logout from a throwaway user/session
instead.
+ const context = await browser.newContext({
+ baseURL: baseURL ?? 'http://localhost:4200',
+ storageState: { cookies: [], origins: [] }
+ });
+
+ try {
+ const logoutPage = await context.newPage();
+ const loginPage = new LoginPage(logoutPage);
+ await loginPage.navigate();
+ await loginPage.login(logoutUser!.username, logoutUser!.password);
+ await logoutPage.waitForURL('/#/', { timeout: 30000 });
+ await waitForZeppelinReady(logoutPage);
+
+ const isolatedStatusElement = logoutPage.locator('.status');
+ const logoutSpinner = logoutPage.locator('zeppelin-spin').filter({
hasText: 'Logging out' });
+
+ await expect(logoutSpinner).toBeHidden();
+
+ await isolatedStatusElement.click();
+ const logoutButton = logoutPage.getByRole('link', { name: 'Logout' });
+
+ // If the dropdown has no Logout link, auth is not configured — skip
gracefully
+ const logoutCount = await logoutButton.count();
+ test.skip(logoutCount === 0, 'Logout option not available — auth not
configured in this environment');
+
+ await logoutButton.click();
+
+ // `toBeVisible` can resolve briefly before the spinner mounts then
misses the
+ // narrow visibility window. `toHaveCount(1)` polls the DOM for the
spinner's
+ // presence which is more tolerant of the transient mount.
+ await expect(logoutSpinner).toHaveCount(1, { timeout: 10000 });
+ await expect(logoutSpinner).toContainText('Logging out ...');
+ } finally {
+ await context.close();
+ }
});
test('should maintain component integrity during navigation', async ({ page
}) => {
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
// Navigate to different pages and ensure component remains intact
const testPaths = ['/#/notebook', '/#/jobmanager', '/#/configuration'];
@@ -132,3 +158,8 @@ test.describe('Zeppelin App Component', () => {
await waitForZeppelinReady(page);
});
});
+
+const getIsolatedLogoutUser = (credentials: Record<string, TestCredentials>):
TestCredentials | undefined =>
+ Object.values(credentials).find(
+ credential => credential.username && credential.password &&
credential.username !== 'user1'
+ );
diff --git a/zeppelin-web-angular/e2e/tests/home/home-page-elements.spec.ts
b/zeppelin-web-angular/e2e/tests/home/home-page-elements.spec.ts
index cac761ae85..66a7e9bd52 100644
--- a/zeppelin-web-angular/e2e/tests/home/home-page-elements.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/home/home-page-elements.spec.ts
@@ -12,7 +12,7 @@
import { expect, test } from '@playwright/test';
import { HomePage } from '../../models/home-page';
-import { addPageAnnotationBeforeEach, performLoginIfRequired,
waitForZeppelinReady, PAGES } from '../../utils';
+import { addPageAnnotationBeforeEach, waitForZeppelinReady, PAGES } from
'../../utils';
test.describe('Home Page - Core Elements', () => {
addPageAnnotationBeforeEach(PAGES.WORKSPACE.HOME);
@@ -23,7 +23,6 @@ test.describe('Home Page - Core Elements', () => {
homePage = new HomePage(page);
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
});
test.describe('Welcome Section', () => {
diff --git
a/zeppelin-web-angular/e2e/tests/home/home-page-external-links.spec.ts
b/zeppelin-web-angular/e2e/tests/home/home-page-external-links.spec.ts
index 97a250d6ab..09cf4ea563 100644
--- a/zeppelin-web-angular/e2e/tests/home/home-page-external-links.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/home/home-page-external-links.spec.ts
@@ -12,7 +12,7 @@
import { expect, test } from '@playwright/test';
import { HomePage } from '../../models/home-page';
-import { addPageAnnotationBeforeEach, performLoginIfRequired,
waitForZeppelinReady, PAGES } from '../../utils';
+import { addPageAnnotationBeforeEach, waitForZeppelinReady, PAGES } from
'../../utils';
test.describe('Home Page - External Links', () => {
addPageAnnotationBeforeEach(PAGES.WORKSPACE.HOME);
@@ -23,7 +23,6 @@ test.describe('Home Page - External Links', () => {
homePage = new HomePage(page);
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
});
test.describe('Documentation Link', () => {
diff --git a/zeppelin-web-angular/e2e/tests/home/home-page-layout.spec.ts
b/zeppelin-web-angular/e2e/tests/home/home-page-layout.spec.ts
index 5a12f6ea4e..ef3cc36511 100644
--- a/zeppelin-web-angular/e2e/tests/home/home-page-layout.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/home/home-page-layout.spec.ts
@@ -12,7 +12,7 @@
import { expect, test } from '@playwright/test';
import { HomePage } from '../../models/home-page';
-import { addPageAnnotationBeforeEach, performLoginIfRequired,
waitForZeppelinReady, PAGES } from '../../utils';
+import { addPageAnnotationBeforeEach, waitForZeppelinReady, PAGES } from
'../../utils';
test.describe('Home Page - Layout and Grid', () => {
addPageAnnotationBeforeEach(PAGES.WORKSPACE.HOME);
@@ -23,7 +23,6 @@ test.describe('Home Page - Layout and Grid', () => {
homePage = new HomePage(page);
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
});
test.describe('Responsive Grid Layout', () => {
diff --git
a/zeppelin-web-angular/e2e/tests/home/home-page-note-operations.spec.ts
b/zeppelin-web-angular/e2e/tests/home/home-page-note-operations.spec.ts
index 66be0f6f4d..280ab64a34 100644
--- a/zeppelin-web-angular/e2e/tests/home/home-page-note-operations.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/home/home-page-note-operations.spec.ts
@@ -12,24 +12,30 @@
import { expect, test } from '@playwright/test';
import { HomePage } from '../../models/home-page';
-import { addPageAnnotationBeforeEach, performLoginIfRequired,
waitForZeppelinReady, PAGES } from '../../utils';
+import { addPageAnnotationBeforeEach, createTestNotebookWithName,
waitForZeppelinReady, PAGES } from '../../utils';
addPageAnnotationBeforeEach(PAGES.WORKSPACE.HOME);
test.describe('Home Page Note Operations', () => {
+ // JUSTIFIED: homePage and testNoteName are describe-scoped; fullyParallel
can overwrite them.
+ test.describe.configure({ mode: 'default' });
+
let homePage: HomePage;
let testNoteName: string;
test.beforeEach(async ({ page }) => {
homePage = new HomePage(page);
- testNoteName = `_e2e_ops_test_${Date.now()}`;
-
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
- // Create a test note so all operation tests have a real target
- await homePage.createNote(testNoteName);
+ // Create the operation target through the REST API so setup is not
coupled to
+ // the UI create-note modal, which this suite exercises separately below.
+ const testNote = await createTestNotebookWithName(page, {
+ folderPath: null,
+ namePrefix: '_e2e_ops_test'
+ });
+ testNoteName = testNote.notebookName;
+
await page.goto('/#/');
await waitForZeppelinReady(page);
@@ -161,7 +167,18 @@ test.describe('Home Page Note Operations', () => {
const maxLengthAttr = await notebookNameInput.getAttribute('maxlength');
const longName = `_e2e_ml_${'a'.repeat(300)}`;
- await notebookNameInput.fill(longName);
+ await expect(async () => {
+ await notebookNameInput.click();
+ await notebookNameInput.fill(longName);
+ await notebookNameInput.evaluate((el: HTMLInputElement) => {
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ el.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ const value = await notebookNameInput.inputValue();
+ if (value.length === 0 || value === 'Untitled Note 1') {
+ throw new Error(`note name fill retry: got "${value}"`);
+ }
+ }).toPass({ timeout: 15000, intervals: [200, 500, 1000, 2000] });
const actualValue = await notebookNameInput.inputValue();
// Must have content — input did not silently reject the fill
diff --git
a/zeppelin-web-angular/e2e/tests/home/home-page-notebook-actions.spec.ts
b/zeppelin-web-angular/e2e/tests/home/home-page-notebook-actions.spec.ts
index c14a5474e2..a92326f32e 100644
--- a/zeppelin-web-angular/e2e/tests/home/home-page-notebook-actions.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/home/home-page-notebook-actions.spec.ts
@@ -12,7 +12,7 @@
import { expect, test } from '@playwright/test';
import { HomePage } from '../../models/home-page';
-import { addPageAnnotationBeforeEach, performLoginIfRequired,
waitForZeppelinReady, PAGES } from '../../utils';
+import { addPageAnnotationBeforeEach, waitForZeppelinReady, PAGES } from
'../../utils';
addPageAnnotationBeforeEach(PAGES.WORKSPACE.HOME);
@@ -23,7 +23,6 @@ test.describe('Home Page Notebook Actions', () => {
homePage = new HomePage(page);
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
});
test.describe('Given notebook list is displayed', () => {
@@ -54,7 +53,7 @@ test.describe('Home Page Notebook Actions', () => {
// When: User types special characters that could break regex or URL
encoding
for (const specialInput of ['[test]', '*.note', '/folder/sub', 'a?b=c'])
{
- await homePage.nodeList.filterInput.fill(specialInput);
+ await homePage.fillAndVerifyInput(homePage.nodeList.filterInput,
specialInput);
// Then: The page must still render without crashing — no blank
screen, input remains editable.
// Note: nz-tree may be hidden when the filter returns 0 results; that
is valid behavior.
await expect(page.locator('zeppelin-node-list')).toBeVisible();
@@ -64,7 +63,7 @@ test.describe('Home Page Notebook Actions', () => {
}
// Clean up: clear the filter so other tests start fresh
- await homePage.nodeList.filterInput.fill('');
+ await homePage.nodeList.filterInput.clear();
});
});
});
diff --git a/zeppelin-web-angular/e2e/tests/login/login.spec.ts
b/zeppelin-web-angular/e2e/tests/login/login.spec.ts
index cd9786d82f..e7d07c649e 100644
--- a/zeppelin-web-angular/e2e/tests/login/login.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/login/login.spec.ts
@@ -12,19 +12,30 @@
import { expect, test } from '@playwright/test';
import { LoginPage } from '../../models/login-page';
-import { LoginTestUtil } from '../../models/login-page.util';
+import { LoginTestUtil, TestCredentials } from '../../models/login-page.util';
import { addPageAnnotationBeforeEach, PAGES } from '../../utils';
test.describe('Login Page', () => {
+ test.use({ storageState: { cookies: [], origins: [] } });
+
addPageAnnotationBeforeEach(PAGES.PAGES.LOGIN);
let loginPage: LoginPage;
- let testCredentials: Record<string, any>;
+ let testCredentials: Record<string, TestCredentials>;
- test.beforeAll(async () => {
+ test.beforeAll(async ({ request }) => {
const isShiroEnabled = await LoginTestUtil.isShiroEnabled();
if (!isShiroEnabled) {
test.skip(true, 'Skipping all login tests - shiro.ini not found');
}
+
+ const ticketResponse = await request.get('/api/security/ticket', {
failOnStatusCode: false });
+ if (ticketResponse.ok()) {
+ const ticket = await ticketResponse.json();
+ if (ticket?.body?.principal === 'anonymous') {
+ test.skip(true, 'Skipping all login tests - Zeppelin server is running
in anonymous mode');
+ }
+ }
+
testCredentials = await LoginTestUtil.getTestCredentials();
});
diff --git
a/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts
b/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts
index 4d012b93c4..358e77c65d 100644
---
a/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts
+++
b/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts
@@ -14,13 +14,16 @@ import { expect, test } from '@playwright/test';
import { NotebookActionBarPage } from
'../../../models/notebook-action-bar-page';
import {
addPageAnnotationBeforeEach,
- performLoginIfRequired,
waitForZeppelinReady,
PAGES,
- createTestNotebook
+ createTestNotebook,
+ navigateToNotebookWithFallback
} from '../../../utils';
test.describe('Notebook Action Bar Functionality', () => {
+ // JUSTIFIED: page objects and notebook ids are stored in describe scope;
fullyParallel can overwrite them.
+ test.describe.configure({ mode: 'default' });
+
addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_ACTION_BAR);
let actionBarPage: NotebookActionBarPage;
@@ -29,13 +32,11 @@ test.describe('Notebook Action Bar Functionality', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
testNotebook = await createTestNotebook(page);
actionBarPage = new NotebookActionBarPage(page);
- await page.goto(`/#/notebook/${testNotebook.noteId}`);
- await page.waitForLoadState('networkidle');
+ await navigateToNotebookWithFallback(page, testNotebook.noteId);
});
test('should display and allow title editing with tooltip', async ({ page })
=> {
@@ -45,8 +46,7 @@ test.describe('Notebook Action Bar Functionality', () => {
await actionBarPage.titleEditor.click();
const titleInputField = actionBarPage.titleEditor.locator('input');
- await expect(titleInputField).toBeVisible();
- await titleInputField.fill(notebookName);
+ await actionBarPage.fillAndVerifyInput(titleInputField, notebookName);
await page.keyboard.press('Enter');
await expect(actionBarPage.titleEditor).toHaveText(notebookName, {
timeout: 10000 });
diff --git
a/zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts
b/zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts
index e482464364..b0818c9f02 100644
---
a/zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts
+++
b/zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts
@@ -14,7 +14,6 @@ import { expect, test } from '@playwright/test';
import { NotebookKeyboardPage } from 'e2e/models/notebook-keyboard-page';
import {
addPageAnnotationBeforeEach,
- performLoginIfRequired,
waitForNotebookLinks,
waitForZeppelinReady,
PAGES,
@@ -43,7 +42,6 @@ test.describe.serial('Comprehensive Keyboard Shortcuts
(ShortcutsMap)', () => {
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
await waitForNotebookLinks(page);
// Handle the welcome modal if it appears
@@ -1004,7 +1002,7 @@ test.describe.serial('Comprehensive Keyboard Shortcuts
(ShortcutsMap)', () => {
await keyboardPage.setCodeEditorContent('%md\n# Test paragraph');
// Remove focus by clicking on empty area
- await keyboardPage.page.click('body');
+ await keyboardPage.page.locator('body').click();
await keyboardPage.page.waitForTimeout(500); // JUSTIFIED: Monaco editor
internal state settle — cursor/focus state not observable via DOM
const initialCount = await keyboardPage.getParagraphCount();
@@ -1026,13 +1024,14 @@ test.describe.serial('Comprehensive Keyboard Shortcuts
(ShortcutsMap)', () => {
test('should handle rapid keyboard operations without instability', async
() => {
await keyboardPage.tryFocusCodeEditor();
- await keyboardPage.setCodeEditorContent('%python\nprint("test")');
+ await keyboardPage.setCodeEditorContent('%md\nrapid keyboard test');
// Rapid Shift+Enter operations
for (let i = 0; i < 3; i++) {
await keyboardPage.pressRunParagraph();
+ await keyboardPage.waitForParagraphExecution(0, 60000);
// JUSTIFIED: single-paragraph test notebook; first() is deterministic
- await expect(keyboardPage.paragraphResult.first()).toBeVisible({
timeout: 15000 });
+ await expect(keyboardPage.paragraphResult.first()).toBeVisible({
timeout: 60000 });
await keyboardPage.page.waitForTimeout(500); // JUSTIFIED: brief gap
between rapid sequential runs to prevent WebSocket message overlap
}
diff --git
a/zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts
b/zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts
index d66df7fa5f..03b956e77d 100644
--- a/zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts
@@ -14,13 +14,16 @@ import { expect, test } from '@playwright/test';
import { NotebookPage } from '../../../models/notebook-page';
import {
addPageAnnotationBeforeEach,
- performLoginIfRequired,
waitForZeppelinReady,
PAGES,
- createTestNotebook
+ createTestNotebook,
+ navigateToNotebookWithFallback
} from '../../../utils';
test.describe('Notebook Container Component', () => {
+ // JUSTIFIED: page objects and notebook ids are stored in describe scope;
fullyParallel can overwrite them.
+ test.describe.configure({ mode: 'default' });
+
addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK);
let notebookPage: NotebookPage;
@@ -29,13 +32,11 @@ test.describe('Notebook Container Component', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
testNotebook = await createTestNotebook(page);
notebookPage = new NotebookPage(page);
- await page.goto(`/#/notebook/${testNotebook.noteId}`);
- await page.waitForLoadState('networkidle');
+ await navigateToNotebookWithFallback(page, testNotebook.noteId);
});
test('should display notebook container with proper structure', async () => {
diff --git
a/zeppelin-web-angular/e2e/tests/notebook/main/notebook-navigation.spec.ts
b/zeppelin-web-angular/e2e/tests/notebook/main/notebook-navigation.spec.ts
index db259de834..877a191569 100644
--- a/zeppelin-web-angular/e2e/tests/notebook/main/notebook-navigation.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/notebook/main/notebook-navigation.spec.ts
@@ -13,7 +13,7 @@
import { expect, Page, test } from '@playwright/test';
import { HeaderPage } from '../../../models/header-page';
import { HomePage } from '../../../models/home-page';
-import { addPageAnnotationBeforeEach, PAGES, performLoginIfRequired,
waitForZeppelinReady } from '../../../utils';
+import { addPageAnnotationBeforeEach, PAGES, waitForZeppelinReady } from
'../../../utils';
const noteIdFromUrl = (url: string): string => {
const match = url.match(/\/notebook\/([^/?]+)/);
@@ -37,7 +37,6 @@ test.describe('Notebook Navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
});
// Regression: ZEPPELIN-6387 moved the note fetch onto the WebSocket
connectedStatus$
diff --git
a/zeppelin-web-angular/e2e/tests/notebook/published/published-paragraph.spec.ts
b/zeppelin-web-angular/e2e/tests/notebook/published/published-paragraph.spec.ts
index 475da43f3b..099212243d 100644
---
a/zeppelin-web-angular/e2e/tests/notebook/published/published-paragraph.spec.ts
+++
b/zeppelin-web-angular/e2e/tests/notebook/published/published-paragraph.spec.ts
@@ -15,7 +15,6 @@ import { PublishedParagraphPage } from
'e2e/models/published-paragraph-page';
import { PublishedParagraphTestUtil } from
'../../../models/published-paragraph-page.util';
import {
addPageAnnotationBeforeEach,
- performLoginIfRequired,
waitForNotebookLinks,
waitForZeppelinReady,
PAGES,
@@ -23,6 +22,9 @@ import {
} from '../../../utils';
test.describe('Published Paragraph', () => {
+ // JUSTIFIED: page objects and notebook ids are stored in describe scope;
fullyParallel can overwrite them.
+ test.describe.configure({ mode: 'default' });
+
addPageAnnotationBeforeEach(PAGES.WORKSPACE.PUBLISHED_PARAGRAPH);
let publishedParagraphPage: PublishedParagraphPage;
@@ -33,7 +35,6 @@ test.describe('Published Paragraph', () => {
publishedParagraphPage = new PublishedParagraphPage(page);
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
await waitForNotebookLinks(page);
if ((await publishedParagraphPage.cancelButton.count()) > 0) {
@@ -91,8 +92,7 @@ test.describe('Published Paragraph', () => {
test('should enter published paragraph by clicking link', async ({ page })
=> {
const { noteId, paragraphId } = testNotebook;
- await page.goto(`/#/notebook/${noteId}`);
- await page.waitForLoadState('networkidle');
+ await publishedParagraphPage.navigateToNotebook(noteId);
// JUSTIFIED: createTestNotebook creates a single paragraph; first() is
deterministic
const paragraphElement =
page.locator('zeppelin-notebook-paragraph').first();
diff --git
a/zeppelin-web-angular/e2e/tests/notebook/sidebar/sidebar-functionality.spec.ts
b/zeppelin-web-angular/e2e/tests/notebook/sidebar/sidebar-functionality.spec.ts
index 1cc2b2b260..996ac0d24c 100644
---
a/zeppelin-web-angular/e2e/tests/notebook/sidebar/sidebar-functionality.spec.ts
+++
b/zeppelin-web-angular/e2e/tests/notebook/sidebar/sidebar-functionality.spec.ts
@@ -14,13 +14,16 @@ import { expect, test } from '@playwright/test';
import { NotebookSidebarPage } from '../../../models/notebook-sidebar-page';
import {
addPageAnnotationBeforeEach,
- performLoginIfRequired,
waitForZeppelinReady,
PAGES,
- createTestNotebook
+ createTestNotebook,
+ navigateToNotebookWithFallback
} from '../../../utils';
test.describe('Notebook Sidebar Functionality', () => {
+ // JUSTIFIED: page objects and notebook ids are stored in describe scope;
fullyParallel can overwrite them.
+ test.describe.configure({ mode: 'default' });
+
addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_SIDEBAR);
let sidebar: NotebookSidebarPage;
@@ -29,13 +32,11 @@ test.describe('Notebook Sidebar Functionality', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/', { waitUntil: 'load', timeout: 60000 });
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
sidebar = new NotebookSidebarPage(page);
testNotebook = await createTestNotebook(page);
- await page.goto(`/#/notebook/${testNotebook.noteId}`);
- await page.waitForLoadState('networkidle');
+ await navigateToNotebookWithFallback(page, testNotebook.noteId);
});
test('should display navigation buttons', async ({ page }) => {
diff --git
a/zeppelin-web-angular/e2e/tests/share/about-zeppelin/about-zeppelin-modal.spec.ts
b/zeppelin-web-angular/e2e/tests/share/about-zeppelin/about-zeppelin-modal.spec.ts
index 1f2d6fed08..c162293d48 100644
---
a/zeppelin-web-angular/e2e/tests/share/about-zeppelin/about-zeppelin-modal.spec.ts
+++
b/zeppelin-web-angular/e2e/tests/share/about-zeppelin/about-zeppelin-modal.spec.ts
@@ -13,7 +13,7 @@
import { test, expect } from '@playwright/test';
import { HeaderPage } from '../../../models/header-page';
import { AboutZeppelinModal } from '../../../models/about-zeppelin-modal';
-import { addPageAnnotationBeforeEach, PAGES, performLoginIfRequired,
waitForZeppelinReady } from '../../../utils';
+import { addPageAnnotationBeforeEach, PAGES, waitForZeppelinReady } from
'../../../utils';
test.describe('About Zeppelin Modal', () => {
let headerPage: HeaderPage;
@@ -27,7 +27,6 @@ test.describe('About Zeppelin Modal', () => {
await page.goto('/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
await headerPage.clickUserDropdown();
await headerPage.clickAboutZeppelin();
diff --git
a/zeppelin-web-angular/e2e/tests/share/folder-rename/folder-rename.spec.ts
b/zeppelin-web-angular/e2e/tests/share/folder-rename/folder-rename.spec.ts
index a364a20bb5..1bbc8d090d 100644
--- a/zeppelin-web-angular/e2e/tests/share/folder-rename/folder-rename.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/share/folder-rename/folder-rename.spec.ts
@@ -10,16 +10,16 @@
* limitations under the License.
*/
-import { test, expect } from '@playwright/test';
+import { test, expect, Page } from '@playwright/test';
import { FolderRenamePage } from '../../../models/folder-rename-page';
import { FolderRenamePageUtil } from '../../../models/folder-rename-page.util';
-import {
- addPageAnnotationBeforeEach,
- PAGES,
- performLoginIfRequired,
- waitForZeppelinReady,
- createTestNotebook
-} from '../../../utils';
+import { addPageAnnotationBeforeEach, PAGES, waitForZeppelinReady,
createTestNotebook } from '../../../utils';
+
+const refreshHomeAndWaitForFolder = async (page: Page, folderName: string):
Promise<void> => {
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitForZeppelinReady(page);
+ await expect(page.getByTestId(`folder-${folderName}`)).toBeVisible({
timeout: 60000 });
+};
// JUSTIFIED: rename/delete ops mutate shared state; parallel runs cause
folder-not-found races
test.describe.serial('Folder Rename', () => {
@@ -35,19 +35,18 @@ test.describe.serial('Folder Rename', () => {
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
// Create a test notebook with folder structure
testFolderName = `TestFolder_${Date.now()}`;
await createTestNotebook(page, testFolderName);
- await page.goto('/#/');
+ await refreshHomeAndWaitForFolder(page, testFolderName);
});
test('Given folder exists in notebook list, When hovering over folder, Then
context menu should appear with Rename option', async () => {
await folderRenamePage.hoverOverFolder(testFolderName);
const folderNode = folderRenamePage.page
.locator('.node')
- .filter({ has: folderRenamePage.page.locator('.folder .name', { hasText:
testFolderName }) })
+ .filter({ has:
folderRenamePage.page.getByTestId(`folder-${testFolderName}`) })
// JUSTIFIED: filter already narrows to target folder; first() handles
nested .node structure
.first();
const renameButton = folderNode.locator('.folder .operation
a[nz-tooltip][nztooltiptitle="Rename folder"]');
@@ -79,13 +78,13 @@ test.describe.serial('Folder Rename', () => {
await folderRenamePage.clickConfirm();
await expect(folderRenamePage.renameModal).not.toBeVisible({ timeout:
10000 });
- await expect(page.locator('.folder .name', { hasText: testFolderName
})).not.toBeVisible({ timeout: 10000 });
+ await
expect(page.getByTestId(`folder-${testFolderName}`)).not.toBeVisible({ timeout:
10000 });
await page.reload();
await page.waitForLoadState('domcontentloaded', { timeout: 15000 });
const baseNewName = renamedFolderName.split('/').pop() ??
renamedFolderName;
- await expect(page.locator('.folder .name', { hasText: baseNewName
})).toBeVisible({ timeout: 30000 });
+ await expect(page.getByTestId(`folder-${baseNewName}`)).toBeVisible({
timeout: 30000 });
});
test('Given rename modal is open, When submitting empty name, Then empty
name should not be allowed', async () => {
@@ -97,7 +96,7 @@ test.describe.serial('Folder Rename', () => {
await folderRenamePage.clickCancel();
await expect(folderRenamePage.renameModal).not.toBeVisible({ timeout: 5000
});
- await expect(folderRenamePage.page.locator('.folder .name', { hasText:
testFolderName })).toBeVisible({
+ await
expect(folderRenamePage.page.getByTestId(`folder-${testFolderName}`)).toBeVisible({
timeout: 5000
});
});
@@ -106,7 +105,7 @@ test.describe.serial('Folder Rename', () => {
await folderRenamePage.hoverOverFolder(testFolderName);
const folderNode = folderRenamePage.page
.locator('.node')
- .filter({ has: folderRenamePage.page.locator('.folder .name', { hasText:
testFolderName }) })
+ .filter({ has:
folderRenamePage.page.getByTestId(`folder-${testFolderName}`) })
// JUSTIFIED: filter already narrows to target folder; first() handles
nested .node structure
.first();
await expect(folderNode.locator('.folder .operation
a[nztooltiptitle*="Move folder to Trash"]')).toBeVisible();
@@ -129,7 +128,7 @@ test.describe.serial('Folder Rename', () => {
// Create a second folder to use as a name collision target
const existingFolderName = `ExistingFolder_${Date.now()}`;
await createTestNotebook(page, existingFolderName);
- await page.goto('/#/'); // Refresh to see the new folder
+ await refreshHomeAndWaitForFolder(page, existingFolderName);
// Attempt to rename the first folder to the name of the second folder
await folderRenamePage.hoverOverFolder(testFolderName);
@@ -139,8 +138,8 @@ test.describe.serial('Folder Rename', () => {
await folderRenamePage.clickConfirm();
// Wait for the source folder to disappear (as it's merged into target)
- await expect(page.locator('.folder .name', { hasText: testFolderName
})).toHaveCount(0, { timeout: 10000 });
+ await expect(page.getByTestId(`folder-${testFolderName}`)).toHaveCount(0,
{ timeout: 10000 });
// Wait for the target folder to remain visible
- await expect(page.locator('.folder .name', { hasText: existingFolderName
})).toBeVisible({ timeout: 10000 });
+ await
expect(page.getByTestId(`folder-${existingFolderName}`)).toBeVisible({ timeout:
10000 });
});
});
diff --git
a/zeppelin-web-angular/e2e/tests/share/header/header-navigation.spec.ts
b/zeppelin-web-angular/e2e/tests/share/header/header-navigation.spec.ts
index aae38d544a..a37d7c4171 100644
--- a/zeppelin-web-angular/e2e/tests/share/header/header-navigation.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/share/header/header-navigation.spec.ts
@@ -13,7 +13,7 @@
import { test, expect } from '@playwright/test';
import { HeaderPage } from '../../../models/header-page';
import { NodeListPage } from '../../../models/node-list-page';
-import { addPageAnnotationBeforeEach, PAGES, performLoginIfRequired,
waitForZeppelinReady } from '../../../utils';
+import { addPageAnnotationBeforeEach, PAGES, waitForZeppelinReady } from
'../../../utils';
test.describe('Header Navigation', () => {
let headerPage: HeaderPage;
@@ -25,7 +25,6 @@ test.describe('Header Navigation', () => {
await page.goto('/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
});
test('Given user is on any page, When viewing the header, Then all header
elements should be visible', async () => {
@@ -42,16 +41,14 @@ test.describe('Header Navigation', () => {
page
}) => {
await headerPage.clickBrandLogo();
- await page.waitForURL(/\/(#\/)?$/);
- expect(page.url()).toMatch(/\/(#\/)?$/);
+ await expect(page).toHaveURL(/\/(#\/)?$/);
});
test('Given user is on home page, When clicking the Job menu item, Then user
should navigate to Job Manager page', async ({
page
}) => {
await headerPage.clickJobMenu();
- await page.waitForURL(/jobmanager/);
- expect(page.url()).toContain('jobmanager');
+ await expect(page).toHaveURL(/jobmanager/);
});
test('Given user is on home page, When clicking the Notebook dropdown, Then
dropdown with node list should open', async ({
@@ -89,8 +86,7 @@ test.describe('Header Navigation', () => {
}) => {
await headerPage.clickUserDropdown();
await headerPage.clickInterpreter();
- await page.waitForURL(/interpreter/);
- expect(page.url()).toContain('interpreter');
+ await expect(page).toHaveURL(/interpreter/);
});
test('Given user opens user dropdown, When clicking Notebook Repos menu
item, Then user should navigate to Notebook Repos page', async ({
@@ -98,8 +94,7 @@ test.describe('Header Navigation', () => {
}) => {
await headerPage.clickUserDropdown();
await headerPage.clickNotebookRepos();
- await page.waitForURL(/notebook-repos/);
- expect(page.url()).toContain('notebook-repos');
+ await expect(page).toHaveURL(/notebook-repos/);
});
test('Given user opens user dropdown, When clicking Credential menu item,
Then user should navigate to Credential page', async ({
@@ -107,8 +102,7 @@ test.describe('Header Navigation', () => {
}) => {
await headerPage.clickUserDropdown();
await headerPage.clickCredential();
- await page.waitForURL(/credential/);
- expect(page.url()).toContain('credential');
+ await expect(page).toHaveURL(/credential/);
});
test('Given user opens user dropdown, When clicking Configuration menu item,
Then user should navigate to Configuration page', async ({
@@ -116,7 +110,6 @@ test.describe('Header Navigation', () => {
}) => {
await headerPage.clickUserDropdown();
await headerPage.clickConfiguration();
- await page.waitForURL(/configuration/);
- expect(page.url()).toContain('configuration');
+ await expect(page).toHaveURL(/configuration/);
});
});
diff --git a/zeppelin-web-angular/e2e/tests/share/header/header-search.spec.ts
b/zeppelin-web-angular/e2e/tests/share/header/header-search.spec.ts
index f6960142a4..17f0c4bfc2 100644
--- a/zeppelin-web-angular/e2e/tests/share/header/header-search.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/share/header/header-search.spec.ts
@@ -12,7 +12,7 @@
import { test, expect } from '@playwright/test';
import { HeaderPage } from '../../../models/header-page';
-import { addPageAnnotationBeforeEach, PAGES, performLoginIfRequired,
waitForZeppelinReady } from '../../../utils';
+import { addPageAnnotationBeforeEach, PAGES, waitForZeppelinReady } from
'../../../utils';
test.describe('Header Search Functionality', () => {
let headerPage: HeaderPage;
@@ -24,7 +24,6 @@ test.describe('Header Search Functionality', () => {
await page.goto('/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
});
test('Given user is on home page, When entering search query and pressing
Enter, Then user should navigate to search results page', async ({
@@ -32,9 +31,9 @@ test.describe('Header Search Functionality', () => {
}) => {
const searchQuery = 'test';
await headerPage.searchNote(searchQuery);
- await page.waitForURL(/search/);
- expect(page.url()).toContain('search');
- expect(page.url()).toContain(searchQuery);
+ await expect(page).toHaveURL(/search/);
+ // searchQuery is alphanumeric test data ('test'); safe for new RegExp
without escaping.
+ await expect(page).toHaveURL(new RegExp(searchQuery));
});
test('Given user is on home page, When viewing search input, Then search
input should be visible and accessible', async () => {
diff --git
a/zeppelin-web-angular/e2e/tests/share/node-list/node-list-functionality.spec.ts
b/zeppelin-web-angular/e2e/tests/share/node-list/node-list-functionality.spec.ts
index 2ef30aa82f..0bef3a74bb 100644
---
a/zeppelin-web-angular/e2e/tests/share/node-list/node-list-functionality.spec.ts
+++
b/zeppelin-web-angular/e2e/tests/share/node-list/node-list-functionality.spec.ts
@@ -13,9 +13,12 @@
import { test, expect } from '@playwright/test';
import { HomePage } from '../../../models/home-page';
import { NodeListPage } from '../../../models/node-list-page';
-import { addPageAnnotationBeforeEach, PAGES, performLoginIfRequired,
waitForZeppelinReady } from '../../../utils';
+import { addPageAnnotationBeforeEach, PAGES, waitForZeppelinReady } from
'../../../utils';
test.describe('Node List Functionality', () => {
+ // JUSTIFIED: page objects are stored in describe scope; fullyParallel can
overwrite them.
+ test.describe.configure({ mode: 'default' });
+
let nodeListPage: NodeListPage;
addPageAnnotationBeforeEach(PAGES.SHARE.NODE_LIST);
@@ -25,7 +28,6 @@ test.describe('Node List Functionality', () => {
await page.goto('/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
});
test('Given user is on home page, When viewing node list, Then node list
should display tree structure', async () => {
@@ -81,22 +83,17 @@ test.describe('Node List Functionality', () => {
page
}) => {
const homePage = new HomePage(page);
+ const noteName =
`_e2e_nav_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
- await expect(nodeListPage.treeView).toBeVisible();
- let notes = await nodeListPage.getAllVisibleNoteNames();
-
- if (notes.length === 0) {
- // Seed a note so the test always runs — critical navigation path must
not be skipped
- await homePage.createNote(`_e2e_nav_${Date.now()}`);
- await page.goto('/');
- await waitForZeppelinReady(page);
- notes = await nodeListPage.getAllVisibleNoteNames();
- }
+ // Seed a unique note so the click target is deterministic even when other
+ // parallel specs leave many notes/folders in the shared test workspace.
+ await homePage.createNote(noteName);
+ await page.goto('/');
+ await waitForZeppelinReady(page);
- const noteName = notes[0].trim();
+ await expect(nodeListPage.noteLinkByName(noteName)).toBeVisible({ timeout:
15000 });
await nodeListPage.clickNote(noteName);
- await page.waitForURL(/notebook\//);
- expect(page.url()).toContain('notebook/');
+ await expect(page).toHaveURL(/notebook\//, { timeout: 45000 });
});
test('Given user clicks Create New Note button, When modal opens, Then note
create modal should be displayed', async ({
diff --git
a/zeppelin-web-angular/e2e/tests/share/note-create/note-create-modal.spec.ts
b/zeppelin-web-angular/e2e/tests/share/note-create/note-create-modal.spec.ts
index dbc27205b3..239f874073 100644
--- a/zeppelin-web-angular/e2e/tests/share/note-create/note-create-modal.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/share/note-create/note-create-modal.spec.ts
@@ -13,7 +13,7 @@
import { test, expect } from '@playwright/test';
import { HomePage } from '../../../models/home-page';
import { NoteCreateModal } from '../../../models/note-create-modal';
-import { addPageAnnotationBeforeEach, PAGES, performLoginIfRequired,
waitForZeppelinReady } from '../../../utils';
+import { addPageAnnotationBeforeEach, PAGES, waitForZeppelinReady } from
'../../../utils';
test.describe('Note Create Modal', () => {
let homePage: HomePage;
@@ -27,7 +27,6 @@ test.describe('Note Create Modal', () => {
await page.goto('/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
await homePage.clickCreateNewNote();
await page.waitForSelector('input[name="noteName"]');
@@ -39,7 +38,7 @@ test.describe('Note Create Modal', () => {
await expect(noteCreateModal.createButton).toBeVisible();
await expect(noteCreateModal.interpreterDropdown).toBeVisible();
await expect(noteCreateModal.folderInfoAlert).toBeVisible();
- expect(await noteCreateModal.folderInfoAlert.textContent()).toContain('/');
+ await expect(noteCreateModal.folderInfoAlert).toContainText('/');
});
test('Given Create Note modal is open, When checking default note name, Then
auto-generated name should follow pattern', async () => {
@@ -57,8 +56,7 @@ test.describe('Note Create Modal', () => {
// Wait for modal to disappear
await expect(noteCreateModal.modal).not.toBeVisible();
- await page.waitForURL(/notebook\//);
- expect(page.url()).toContain('notebook/');
+ await expect(page).toHaveURL(/notebook\//);
// Verify the note was created with the correct name
const notebookTitle = page.locator('[data-testid="notebook-title"]');
@@ -85,8 +83,7 @@ test.describe('Note Create Modal', () => {
// Wait for modal to disappear
await expect(noteCreateModal.modal).not.toBeVisible();
- await page.waitForURL(/notebook\//);
- expect(page.url()).toContain('notebook/');
+ await expect(page).toHaveURL(/notebook\//);
// Verify the note was created with the correct name (without folder path)
const notebookTitle = page.locator('[data-testid="notebook-title"]');
diff --git
a/zeppelin-web-angular/e2e/tests/share/note-import/note-import-modal.spec.ts
b/zeppelin-web-angular/e2e/tests/share/note-import/note-import-modal.spec.ts
index 2100d56a39..9fe75cbae3 100644
--- a/zeppelin-web-angular/e2e/tests/share/note-import/note-import-modal.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/share/note-import/note-import-modal.spec.ts
@@ -13,7 +13,7 @@
import { test, expect } from '@playwright/test';
import { HomePage } from '../../../models/home-page';
import { NoteImportModal } from '../../../models/note-import-modal';
-import { addPageAnnotationBeforeEach, PAGES, performLoginIfRequired,
waitForZeppelinReady } from '../../../utils';
+import { addPageAnnotationBeforeEach, PAGES, waitForZeppelinReady } from
'../../../utils';
test.describe('Note Import Modal', () => {
let homePage: HomePage;
@@ -27,7 +27,6 @@ test.describe('Note Import Modal', () => {
await page.goto('/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
await homePage.clickImportNote();
await page.waitForSelector('input[name="noteImportName"]');
diff --git
a/zeppelin-web-angular/e2e/tests/share/note-rename/note-rename.spec.ts
b/zeppelin-web-angular/e2e/tests/share/note-rename/note-rename.spec.ts
index a5a6f1c13f..34bdac8a17 100644
--- a/zeppelin-web-angular/e2e/tests/share/note-rename/note-rename.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/share/note-rename/note-rename.spec.ts
@@ -15,13 +15,16 @@ import { NoteRenamePage } from
'../../../models/note-rename-page';
import { NoteRenamePageUtil } from '../../../models/note-rename-page.util';
import {
addPageAnnotationBeforeEach,
+ createTestNotebook,
+ navigateToNotebookWithFallback,
PAGES,
- performLoginIfRequired,
- waitForZeppelinReady,
- createTestNotebook
+ waitForZeppelinReady
} from '../../../utils';
test.describe('Note Rename', () => {
+ // JUSTIFIED: page objects and notebook ids are stored in describe scope;
fullyParallel can overwrite them.
+ test.describe.configure({ mode: 'default' });
+
let noteRenamePage: NoteRenamePage;
let noteRenameUtil: NoteRenamePageUtil;
let testNotebook: { noteId: string; paragraphId: string };
@@ -34,14 +37,14 @@ test.describe('Note Rename', () => {
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
// Create a test notebook for each test
testNotebook = await createTestNotebook(page);
- // Navigate to the test notebook
- await page.goto(`/#/notebook/${testNotebook.noteId}`);
- await page.waitForLoadState('networkidle');
+ // Navigate to the test notebook and wait for the notebook component to
bind
+ // to backend data. Hash-route navigation can leave the home shell visible
+ // for a short time in auth mode, which makes the title locator race.
+ await navigateToNotebookWithFallback(page, testNotebook.noteId);
});
test('Given notebook page is loaded, When checking note title, Then title
should be displayed', async () => {
diff --git a/zeppelin-web-angular/e2e/tests/share/note-toc/note-toc.spec.ts
b/zeppelin-web-angular/e2e/tests/share/note-toc/note-toc.spec.ts
index 6b6527842e..355bb28770 100644
--- a/zeppelin-web-angular/e2e/tests/share/note-toc/note-toc.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/share/note-toc/note-toc.spec.ts
@@ -13,15 +13,12 @@
import { test, expect } from '@playwright/test';
import { NoteTocPage } from '../../../models/note-toc-page';
import { NoteTocPageUtil } from '../../../models/note-toc-page.util';
-import {
- addPageAnnotationBeforeEach,
- PAGES,
- performLoginIfRequired,
- waitForZeppelinReady,
- createTestNotebook
-} from '../../../utils';
+import { addPageAnnotationBeforeEach, PAGES, waitForZeppelinReady,
createTestNotebook } from '../../../utils';
test.describe('Note Table of Contents', () => {
+ // JUSTIFIED: page objects and notebook ids are stored in describe scope;
fullyParallel can overwrite them.
+ test.describe.configure({ mode: 'default' });
+
let noteTocPage: NoteTocPage;
let noteTocUtil: NoteTocPageUtil;
let testNotebook: { noteId: string; paragraphId: string };
@@ -34,7 +31,6 @@ test.describe('Note Table of Contents', () => {
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
testNotebook = await createTestNotebook(page);
@@ -56,7 +52,7 @@ test.describe('Note Table of Contents', () => {
test('Given TOC panel is open, When checking panel title, Then title should
display "Table of Contents"', async () => {
await noteTocUtil.verifyTocPanelOpens();
await expect(noteTocPage.tocTitle).toBeVisible();
- expect(await noteTocPage.tocTitle.textContent()).toBe('Table of Contents');
+ await expect(noteTocPage.tocTitle).toHaveText('Table of Contents');
});
test('Given TOC panel is open with no headings, When checking content, Then
empty message should be displayed', async () => {
diff --git a/zeppelin-web-angular/e2e/tests/theme/dark-mode.spec.ts
b/zeppelin-web-angular/e2e/tests/theme/dark-mode.spec.ts
index 13b03fbdd3..49bbdf92cb 100644
--- a/zeppelin-web-angular/e2e/tests/theme/dark-mode.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/theme/dark-mode.spec.ts
@@ -12,7 +12,7 @@
import { expect, test } from '@playwright/test';
import { DarkModePage } from '../../models/dark-mode-page';
-import { addPageAnnotationBeforeEach, performLoginIfRequired,
waitForZeppelinReady, PAGES } from '../../utils';
+import { addPageAnnotationBeforeEach, waitForZeppelinReady, PAGES } from
'../../utils';
test.describe('Dark Mode Theme Switching', () => {
addPageAnnotationBeforeEach(PAGES.SHARE.THEME_TOGGLE);
@@ -28,7 +28,6 @@ test.describe('Dark Mode Theme Switching', () => {
await waitForZeppelinReady(page);
// Handle authentication if shiro.ini exists
- await performLoginIfRequired(page);
// Ensure a clean localStorage for each test
await darkModePage.clearLocalStorage();
@@ -100,8 +99,8 @@ test.describe('Dark Mode Theme Switching', () => {
await waitForZeppelinReady(page);
// When no explicit theme is set, it defaults to 'system' mode
// Even in system mode with light preference, the icon should be robot
- await expect(darkModePage.rootElement).toHaveClass(/light/);
- await expect(darkModePage.rootElement).toHaveAttribute('data-theme',
'light');
+ await expect(darkModePage.rootElement).toHaveClass(/light/, { timeout:
15000 });
+ await expect(darkModePage.rootElement).toHaveAttribute('data-theme',
'light', { timeout: 15000 });
await darkModePage.assertSystemTheme(); // Should show robot icon
});
@@ -125,8 +124,8 @@ test.describe('Dark Mode Theme Switching', () => {
await page.emulateMedia({ colorScheme: 'light' });
await page.goto('/');
await waitForZeppelinReady(page);
- await expect(darkModePage.rootElement).toHaveClass(/light/);
- await expect(darkModePage.rootElement).toHaveAttribute('data-theme',
'light');
+ await expect(darkModePage.rootElement).toHaveClass(/light/, { timeout:
15000 });
+ await expect(darkModePage.rootElement).toHaveAttribute('data-theme',
'light', { timeout: 15000 });
await darkModePage.assertSystemTheme(); // Robot icon for system theme
});
@@ -135,8 +134,8 @@ test.describe('Dark Mode Theme Switching', () => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto('/');
await waitForZeppelinReady(page);
- await expect(darkModePage.rootElement).toHaveClass(/dark/);
- await expect(darkModePage.rootElement).toHaveAttribute('data-theme',
'dark');
+ await expect(darkModePage.rootElement).toHaveClass(/dark/, { timeout:
15000 });
+ await expect(darkModePage.rootElement).toHaveAttribute('data-theme',
'dark', { timeout: 15000 });
await darkModePage.assertSystemTheme(); // Robot icon for system theme
});
});
diff --git
a/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-display.spec.ts
b/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-display.spec.ts
index 1796e1578a..3cdc499f68 100644
---
a/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-display.spec.ts
+++
b/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-display.spec.ts
@@ -12,7 +12,7 @@
import { expect, test } from '@playwright/test';
import { NotebookReposPage, NotebookRepoItemPage } from
'../../../models/notebook-repos-page';
-import { addPageAnnotationBeforeEach, performLoginIfRequired,
waitForZeppelinReady, PAGES } from '../../../utils';
+import { addPageAnnotationBeforeEach, waitForZeppelinReady, PAGES } from
'../../../utils';
test.describe('Notebook Repository Item - Display Mode', () => {
addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_REPOS_ITEM);
@@ -24,7 +24,6 @@ test.describe('Notebook Repository Item - Display Mode', ()
=> {
test.beforeEach(async ({ page }) => {
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
notebookReposPage = new NotebookReposPage(page);
await notebookReposPage.navigate();
diff --git
a/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-edit.spec.ts
b/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-edit.spec.ts
index 5fd6af53b9..d941829bc0 100644
---
a/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-edit.spec.ts
+++
b/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-edit.spec.ts
@@ -13,7 +13,7 @@
import { expect, test } from '@playwright/test';
import { NotebookReposPage, NotebookRepoItemPage } from
'../../../models/notebook-repos-page';
import { NotebookRepoItemUtil } from '../../../models/notebook-repo-item.util';
-import { addPageAnnotationBeforeEach, performLoginIfRequired,
waitForZeppelinReady, PAGES } from '../../../utils';
+import { addPageAnnotationBeforeEach, waitForZeppelinReady, PAGES } from
'../../../utils';
test.describe('Notebook Repository Item - Edit Mode', () => {
addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_REPOS_ITEM);
@@ -26,7 +26,6 @@ test.describe('Notebook Repository Item - Edit Mode', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
notebookReposPage = new NotebookReposPage(page);
await notebookReposPage.navigate();
diff --git
a/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-form-validation.spec.ts
b/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-form-validation.spec.ts
index e4fc940322..29cbd419cb 100644
---
a/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-form-validation.spec.ts
+++
b/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-form-validation.spec.ts
@@ -12,7 +12,7 @@
import { expect, test } from '@playwright/test';
import { NotebookReposPage, NotebookRepoItemPage } from
'../../../models/notebook-repos-page';
-import { addPageAnnotationBeforeEach, performLoginIfRequired,
waitForZeppelinReady, PAGES } from '../../../utils';
+import { addPageAnnotationBeforeEach, waitForZeppelinReady, PAGES } from
'../../../utils';
test.describe('Notebook Repository Item - Form Validation', () => {
addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_REPOS_ITEM);
@@ -24,7 +24,6 @@ test.describe('Notebook Repository Item - Form Validation',
() => {
test.beforeEach(async ({ page }) => {
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
notebookReposPage = new NotebookReposPage(page);
await notebookReposPage.navigate();
diff --git
a/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-settings.spec.ts
b/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-settings.spec.ts
index db65b97063..e8f6f30695 100644
---
a/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-settings.spec.ts
+++
b/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-settings.spec.ts
@@ -12,7 +12,7 @@
import { expect, test } from '@playwright/test';
import { NotebookReposPage, NotebookRepoItemPage } from
'../../../models/notebook-repos-page';
-import { addPageAnnotationBeforeEach, performLoginIfRequired,
waitForZeppelinReady, PAGES } from '../../../utils';
+import { addPageAnnotationBeforeEach, waitForZeppelinReady, PAGES } from
'../../../utils';
test.describe('Notebook Repository Item - Settings', () => {
addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_REPOS_ITEM);
@@ -24,7 +24,6 @@ test.describe('Notebook Repository Item - Settings', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
notebookReposPage = new NotebookReposPage(page);
await notebookReposPage.navigate();
diff --git
a/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-workflow.spec.ts
b/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-workflow.spec.ts
index 0fd368e493..f39f277303 100644
---
a/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-workflow.spec.ts
+++
b/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repo-item-workflow.spec.ts
@@ -13,7 +13,7 @@
import { expect, test } from '@playwright/test';
import { NotebookReposPage, NotebookRepoItemPage } from
'../../../models/notebook-repos-page';
import { NotebookRepoItemUtil } from '../../../models/notebook-repo-item.util';
-import { addPageAnnotationBeforeEach, performLoginIfRequired,
waitForZeppelinReady, PAGES } from '../../../utils';
+import { addPageAnnotationBeforeEach, waitForZeppelinReady, PAGES } from
'../../../utils';
test.describe('Notebook Repository Item - Edit Workflow', () => {
addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_REPOS_ITEM);
@@ -26,7 +26,6 @@ test.describe('Notebook Repository Item - Edit Workflow', ()
=> {
test.beforeEach(async ({ page }) => {
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
notebookReposPage = new NotebookReposPage(page);
await notebookReposPage.navigate();
diff --git
a/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repos-page-structure.spec.ts
b/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repos-page-structure.spec.ts
index 39cccf2c58..40c3fa7ae3 100644
---
a/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repos-page-structure.spec.ts
+++
b/zeppelin-web-angular/e2e/tests/workspace/notebook-repos/notebook-repos-page-structure.spec.ts
@@ -12,7 +12,7 @@
import { expect, test } from '@playwright/test';
import { NotebookReposPage } from '../../../models/notebook-repos-page';
-import { addPageAnnotationBeforeEach, performLoginIfRequired,
waitForZeppelinReady, PAGES } from '../../../utils';
+import { addPageAnnotationBeforeEach, waitForZeppelinReady, PAGES } from
'../../../utils';
test.describe('Notebook Repository Page - Structure', () => {
addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_REPOS);
@@ -22,7 +22,6 @@ test.describe('Notebook Repository Page - Structure', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
notebookReposPage = new NotebookReposPage(page);
await notebookReposPage.navigate();
});
diff --git
a/zeppelin-web-angular/e2e/tests/workspace/user-menu-navigation.spec.ts
b/zeppelin-web-angular/e2e/tests/workspace/user-menu-navigation.spec.ts
index 55adf20cf3..c4ee882cf1 100644
--- a/zeppelin-web-angular/e2e/tests/workspace/user-menu-navigation.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/workspace/user-menu-navigation.spec.ts
@@ -12,7 +12,7 @@
import { expect, test } from '@playwright/test';
import { HeaderPage } from '../../models/header-page';
-import { performLoginIfRequired, waitForZeppelinReady } from '../../utils';
+import { waitForZeppelinReady } from '../../utils';
/**
* Regression guard for the header user-menu navigation.
@@ -41,7 +41,6 @@ test.describe('Header user menu - full-row navigation', () =>
{
header = new HeaderPage(page);
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
});
for (const item of MENU_ITEMS) {
diff --git a/zeppelin-web-angular/e2e/tests/workspace/workspace-main.spec.ts
b/zeppelin-web-angular/e2e/tests/workspace/workspace-main.spec.ts
index 106345fd2e..86095206d7 100644
--- a/zeppelin-web-angular/e2e/tests/workspace/workspace-main.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/workspace/workspace-main.spec.ts
@@ -12,7 +12,7 @@
import { expect, test } from '@playwright/test';
import { BasePage } from 'e2e/models/base-page';
-import { addPageAnnotationBeforeEach, PAGES, performLoginIfRequired,
waitForZeppelinReady } from '../../utils';
+import { addPageAnnotationBeforeEach, PAGES, waitForZeppelinReady } from
'../../utils';
addPageAnnotationBeforeEach(PAGES.WORKSPACE.MAIN);
@@ -22,7 +22,6 @@ test.describe('Workspace Main Component', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/#/');
await waitForZeppelinReady(page);
- await performLoginIfRequired(page);
basePage = new BasePage(page);
});
diff --git a/zeppelin-web-angular/e2e/utils.ts
b/zeppelin-web-angular/e2e/utils.ts
index 18a66a0ee6..b8be01c99a 100644
--- a/zeppelin-web-angular/e2e/utils.ts
+++ b/zeppelin-web-angular/e2e/utils.ts
@@ -10,14 +10,13 @@
* limitations under the License.
*/
-import { test, Page, TestInfo } from '@playwright/test';
+import { test, expect, Page, TestInfo } from '@playwright/test';
import { LoginTestUtil } from './models/login-page.util';
import { E2E_TEST_FOLDER } from './models/base-page';
-import { NotebookUtil } from './models/notebook.util';
+import { LoginPage } from './models/login-page';
export const NOTEBOOK_PATTERNS = {
URL_REGEX: /\/notebook\/[^\/\?]+/,
- URL_EXTRACT_NOTEBOOK_ID_REGEX: /\/notebook\/([^\/\?]+)/,
LINK_SELECTOR: 'a[href*="/notebook/"]'
} as const;
@@ -161,7 +160,91 @@ export const getBasicPageMetadata = async (
path: getCurrentPath(page)
});
-import { LoginPage } from './models/login-page';
+interface WaitForZeppelinReadyOptions {
+ allowLoginPage?: boolean;
+}
+
+const isLoginPageVisible = async (page: Page): Promise<boolean> =>
+ page
+ .locator('zeppelin-login')
+ .isVisible()
+ .catch(() => false);
+
+const waitForLoginPageReady = async (page: Page): Promise<void> => {
+ await page.waitForFunction(
+ // JUSTIFIED: multi-condition AND — Angular presence + login element OR
across three selectors; can't express as single locator wait
+ () => {
+ const hasAngular = document.querySelector('[ng-version]') !== null;
+ const hasLoginElements =
+ document.querySelector('zeppelin-login') !== null ||
+ document.querySelector('input[placeholder*="User"],
input[placeholder*="user"], input[type="text"]') !== null;
+ return hasAngular && hasLoginElements;
+ },
+ { timeout: 30000 }
+ );
+};
+
+const waitForWorkspaceOrLogin = async (page: Page): Promise<'workspace' |
'login' | undefined> =>
+ new Promise(resolve => {
+ let pending = 3;
+ let resolved = false;
+
+ const finish = (state?: 'workspace' | 'login') => {
+ if (resolved) {
+ return;
+ }
+ if (state) {
+ resolved = true;
+ resolve(state);
+ return;
+ }
+ pending -= 1;
+ if (pending === 0) {
+ resolved = true;
+ resolve(undefined);
+ }
+ };
+
+ page
+ .locator('zeppelin-workspace')
+ .waitFor({ state: 'attached', timeout: 45000 })
+ .then(() => finish('workspace'))
+ .catch(() => finish());
+ page
+ .locator('zeppelin-login')
+ .waitFor({ state: 'visible', timeout: 45000 })
+ .then(() => finish('login'))
+ .catch(() => finish());
+ page
+ .waitForURL(url => url.toString().includes('#/login'), { timeout: 45000
})
+ .then(() => finish('login'))
+ .catch(() => finish());
+ });
+
+const handleLoginPageIfNeeded = async (page: Page, options:
WaitForZeppelinReadyOptions): Promise<boolean> => {
+ const isOnLoginPage = page.url().includes('#/login') || (await
isLoginPageVisible(page));
+ if (!isOnLoginPage) {
+ return false;
+ }
+
+ await waitForLoginPageReady(page);
+
+ if (options.allowLoginPage) {
+ return true;
+ }
+
+ if (await LoginTestUtil.isShiroEnabled()) {
+ const loggedIn = await performLoginIfRequired(page);
+ if (loggedIn) {
+ return true;
+ }
+
+ throw new Error('Authentication is required, but the test page remained on
the login screen');
+ }
+
+ return true;
+};
+
export const performLoginIfRequired = async (page: Page): Promise<boolean> => {
const isShiroEnabled = await LoginTestUtil.isShiroEnabled();
if (!isShiroEnabled) {
@@ -169,9 +252,7 @@ export const performLoginIfRequired = async (page: Page):
Promise<boolean> => {
}
const credentials = await LoginTestUtil.getTestCredentials();
- const validUsers = Object.values(credentials).filter(
- cred => cred.username && cred.password && cred.username !== 'INVALID_USER'
&& cred.username !== 'EMPTY_CREDENTIALS'
- );
+ const validUsers = Object.values(credentials).filter(cred => cred.username
&& cred.password);
if (validUsers.length === 0) {
return false;
@@ -205,31 +286,12 @@ export const performLoginIfRequired = async (page: Page):
Promise<boolean> => {
return false;
};
-export const waitForZeppelinReady = async (page: Page): Promise<void> => {
+export const waitForZeppelinReady = async (page: Page, options:
WaitForZeppelinReadyOptions = {}): Promise<void> => {
try {
// Enhanced wait for network idle with longer timeout for CI environments
await page.waitForLoadState('domcontentloaded', { timeout: 45000 });
- // Check if we're on login page and authentication is required
- const isOnLoginPage = page.url().includes('#/login');
- if (isOnLoginPage) {
- console.log('On login page - checking if authentication is enabled');
-
- // If we're on login page, this is expected when authentication is
required
- // Just wait for login elements to be ready instead of waiting for app
content
- await page.waitForFunction(
- // JUSTIFIED: multi-condition AND — Angular presence + login element
OR across three selectors; can't express as single locator wait
- () => {
- const hasAngular = document.querySelector('[ng-version]') !== null;
- const hasLoginElements =
- document.querySelector('zeppelin-login') !== null ||
- document.querySelector('input[placeholder*="User"],
input[placeholder*="user"], input[type="text"]') !==
- null;
- return hasAngular && hasLoginElements;
- },
- { timeout: 30000 }
- );
- console.log('Login page is ready');
+ if (await handleLoginPageIfNeeded(page, options)) {
return;
}
@@ -259,8 +321,10 @@ export const waitForZeppelinReady = async (page: Page):
Promise<void> => {
{ timeout: 90000 }
);
- // Additional stability check - wait for DOM to be stable
- await page.waitForLoadState('domcontentloaded');
+ const settledState = await waitForWorkspaceOrLogin(page);
+ if (settledState === 'login' || (await handleLoginPageIfNeeded(page,
options))) {
+ return;
+ }
} catch (error) {
throw new Error(`Zeppelin loading failed: ${String(error)}`);
}
@@ -278,6 +342,21 @@ export const waitForNotebookLinks = async (page: Page,
timeout: number = 30000)
await locator.first().waitFor({ state: 'visible', timeout });
};
+const waitForNotebookParagraphVisible = async (page: Page, noteId: string):
Promise<void> => {
+ const waitOnce = async () => {
+ await page.waitForURL(new RegExp(`/notebook/${noteId}`), { timeout: 15000
});
+ await page.locator('zeppelin-notebook-paragraph').first().waitFor({ state:
'visible', timeout: 30000 });
+ };
+
+ try {
+ await waitOnce();
+ } catch {
+ await page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 });
+ await waitForZeppelinReady(page);
+ await waitOnce();
+ }
+};
+
export const navigateToNotebookWithFallback = async (
page: Page,
noteId: string,
@@ -290,8 +369,6 @@ export const navigateToNotebookWithFallback = async (
await page.goto(`/#/notebook/${noteId}`, { waitUntil: 'networkidle',
timeout: 30000 });
navigationSuccessful = true;
} catch (error) {
- console.log('Direct navigation failed, trying fallback strategies...');
-
// Strategy 2: Wait for loading completion and check URL
await page.waitForFunction(
() => {
@@ -327,121 +404,125 @@ export const navigateToNotebookWithFallback = async (
throw new Error(`Failed to navigate to notebook ${noteId}`);
}
- // Wait for notebook to be ready
+ // Wait for notebook to be ready. Hash navigation can occasionally reach the
+ // target URL before the notebook component has subscribed to the backend
data;
+ // a single reload keeps the same route while forcing Angular to fetch the
note.
await waitForZeppelinReady(page);
+ await waitForNotebookParagraphVisible(page, noteId);
};
-const extractNoteIdFromUrl = async (page: Page): Promise<string | null> => {
- const url = page.url();
- const match = url.match(NOTEBOOK_PATTERNS.URL_EXTRACT_NOTEBOOK_ID_REGEX);
- return match ? match[1] : null;
-};
+interface ZeppelinJsonResponse<T> {
+ status: string;
+ message?: string;
+ body: T;
+}
-const waitForNotebookNavigation = async (page: Page): Promise<string | null>
=> {
- await page.waitForURL(NOTEBOOK_PATTERNS.URL_REGEX, { timeout: 30000 });
- return await extractNoteIdFromUrl(page);
-};
+interface InterpreterSettingSummary {
+ name?: string;
+}
-const navigateViaHomePageFallback = async (page: Page, baseNotebookName:
string): Promise<string> => {
- await page.goto('/#/');
- await page.waitForLoadState('networkidle', { timeout: 15000 });
- await page.waitForSelector('zeppelin-node-list', { timeout: 15000 });
+interface NoteSummary {
+ paragraphs?: Array<{ id?: string }>;
+}
- await page.locator(NOTEBOOK_PATTERNS.LINK_SELECTOR).first().waitFor({ state:
'attached', timeout: 15000 });
- await page.waitForLoadState('domcontentloaded', { timeout: 15000 });
+const getDefaultInterpreterGroup = async (page: Page): Promise<string |
undefined> => {
+ const response = await page.request.get('/api/interpreter/setting', {
failOnStatusCode: false });
+ if (!response.ok()) {
+ return undefined;
+ }
- const notebookLink = page.locator(NOTEBOOK_PATTERNS.LINK_SELECTOR).filter({
hasText: baseNotebookName });
+ const json = (await response.json()) as
ZeppelinJsonResponse<InterpreterSettingSummary[]>;
+ return json.body?.find(setting => !!setting.name)?.name;
+};
- const browserName = page.context().browser()?.browserType().name();
- if (browserName === 'firefox') {
- await
page.waitForSelector(`${NOTEBOOK_PATTERNS.LINK_SELECTOR}:has-text("${baseNotebookName}")`,
{
- state: 'visible',
- timeout: 90000
- });
- } else {
- await notebookLink.waitFor({ state: 'visible', timeout: 60000 });
+const createNotebookViaRest = async (
+ page: Page,
+ notebookName: string
+): Promise<{ noteId: string; paragraphId: string }> => {
+ const defaultInterpreterGroup = await getDefaultInterpreterGroup(page);
+ const payload: Record<string, unknown> = {
+ notePath: notebookName,
+ addingEmptyParagraph: true
+ };
+
+ if (defaultInterpreterGroup) {
+ payload.defaultInterpreterGroup = defaultInterpreterGroup;
}
- await notebookLink.click({ timeout: 15000 });
- await page.waitForURL(NOTEBOOK_PATTERNS.URL_REGEX, { timeout: 20000 });
+ const createResponse = await page.request.post('/api/notebook', {
+ data: payload,
+ failOnStatusCode: false
+ });
+ if (!createResponse.ok()) {
+ throw new Error(`Create notebook REST request failed:
${createResponse.status()} ${await createResponse.text()}`);
+ }
- const noteId = await extractNoteIdFromUrl(page);
+ const createJson = (await createResponse.json()) as
ZeppelinJsonResponse<string>;
+ const noteId = createJson.body;
if (!noteId) {
- throw new Error('Failed to extract notebook ID after home page
navigation');
+ throw new Error(`Create notebook REST response did not include note id:
${JSON.stringify(createJson)}`);
}
- return noteId;
-};
-
-const extractFirstParagraphId = async (page: Page): Promise<string> => {
- await page.locator('zeppelin-notebook-paragraph').first().waitFor({ state:
'visible', timeout: 20000 });
+ let noteJson!: ZeppelinJsonResponse<NoteSummary>;
+ await expect(async () => {
+ const response = await page.request.get(`/api/notebook/${noteId}`, {
failOnStatusCode: false });
+ if (!response.ok()) {
+ throw new Error(`Fetch notebook REST request failed:
${response.status()} ${await response.text()}`);
+ }
+ noteJson = (await response.json()) as ZeppelinJsonResponse<NoteSummary>;
+ }).toPass({ timeout: 7500, intervals: [500, 1000, 1500, 2000, 2500] });
- const paragraphContainer =
page.locator('zeppelin-notebook-paragraph').first();
- const dropdownTrigger = paragraphContainer.locator('a[nz-dropdown]');
- await dropdownTrigger.click();
+ const paragraphId = noteJson.body?.paragraphs?.[0]?.id;
+ if (!paragraphId || !paragraphId.startsWith('paragraph_')) {
+ throw new Error(`Create notebook REST response did not include paragraph
id: ${JSON.stringify(noteJson.body)}`);
+ }
- const paragraphLink = page.locator('li.paragraph-id a').first();
- await paragraphLink.waitFor({ state: 'attached', timeout: 15000 });
+ return { noteId, paragraphId };
+};
- const paragraphId = await paragraphLink.textContent();
+interface CreateTestNotebookWithNameOptions {
+ folderPath?: string | null;
+ namePrefix?: string;
+}
- // Close the dropdown before returning — leaving it open leaks state into
subsequent tests
- await page.keyboard.press('Escape');
+export const createTestNotebookWithName = async (
+ page: Page,
+ options: CreateTestNotebookWithNameOptions = {}
+): Promise<{ noteId: string; paragraphId: string; notebookName: string;
notebookPath: string }> => {
+ const isRetryableError = (message: string): boolean =>
+ /REST request failed: (404|409|500)\b/.test(message) ||
message.includes('Fetch notebook REST request failed');
+
+ const tryCreate = async () => {
+ const prefix = options.namePrefix ?? 'TestNotebook';
+ const notebookName =
`${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+ const notebookPath =
+ options.folderPath === null ? notebookName : `${options.folderPath ||
E2E_TEST_FOLDER}/${notebookName}`;
+ const { noteId, paragraphId } = await createNotebookViaRest(page,
notebookPath);
+ await page.goto('/#/');
+ await waitForZeppelinReady(page);
+ return { noteId, paragraphId, notebookName, notebookPath };
+ };
- if (!paragraphId || !paragraphId.startsWith('paragraph_')) {
- throw new Error(`Invalid paragraph ID found: ${paragraphId}`);
+ for (let attempt = 1; attempt <= 3; attempt++) {
+ try {
+ return await tryCreate();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ if (attempt === 3 || !isRetryableError(message)) {
+ throw new Error(`Failed to create test notebook: ${message}. Current
URL: ${page.url()}`);
+ }
+ await page.waitForTimeout(1000 * attempt);
+ }
}
- return paragraphId;
+ // Unreachable: loop returns on success or throws on final attempt.
+ throw new Error('createTestNotebookWithName: exhausted retries without
resolution');
};
export const createTestNotebook = async (
page: Page,
folderPath?: string
): Promise<{ noteId: string; paragraphId: string }> => {
- const notebookUtil = new NotebookUtil(page);
- const baseNotebookName = `TestNotebook_${Date.now()}`;
- const notebookName = folderPath ? `${folderPath}/${baseNotebookName}` :
`${E2E_TEST_FOLDER}/${baseNotebookName}`;
-
- try {
- // Create notebook
- await notebookUtil.createNotebook(notebookName);
-
- let noteId: string | null = null;
-
- // Try direct navigation first
- noteId = await waitForNotebookNavigation(page);
-
- if (!noteId) {
- console.log('Direct navigation failed, trying fallback strategies...');
-
- // Check if we're already on a notebook page
- noteId = await extractNoteIdFromUrl(page);
-
- if (noteId) {
- // Use existing fallback navigation
- await navigateToNotebookWithFallback(page, noteId, notebookName);
- } else {
- // Navigate via home page as last resort
- noteId = await navigateViaHomePageFallback(page, baseNotebookName);
- }
- }
-
- if (!noteId) {
- throw new Error(`Failed to extract notebook ID from URL: ${page.url()}`);
- }
-
- // Extract paragraph ID
- const paragraphId = await extractFirstParagraphId(page);
-
- // Navigate back to home
- await page.goto('/#/');
- await waitForZeppelinReady(page);
-
- return { noteId, paragraphId };
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message :
String(error);
- const currentUrl = page.url();
- throw new Error(`Failed to create test notebook: ${errorMessage}. Current
URL: ${currentUrl}`);
- }
+ const { noteId, paragraphId } = await createTestNotebookWithName(page, {
folderPath });
+ return { noteId, paragraphId };
};
diff --git a/zeppelin-web-angular/playwright.config.js
b/zeppelin-web-angular/playwright.config.js
index 06e9270385..bc1bd46ebd 100644
--- a/zeppelin-web-angular/playwright.config.js
+++ b/zeppelin-web-angular/playwright.config.js
@@ -20,7 +20,7 @@ module.exports = defineConfig({
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 1,
- workers: process.env.CI ? 2 : 5,
+ workers: 5,
timeout: 300000,
expect: {
timeout: 60000
@@ -43,17 +43,39 @@ module.exports = defineConfig({
navigationTimeout: 180000
},
projects: [
+ // Auth setup runs once and writes playwright/.auth/user.json, which the
browser
+ // projects consume via storageState — replaces the per-test login that
raced
+ // under parallel workers.
+ {
+ name: 'setup',
+ testMatch: /global\.setup\.ts/
+ },
{
name: 'chromium',
- use: { ...devices['Desktop Chrome'], permissions: ['clipboard-read',
'clipboard-write'] }
+ use: {
+ ...devices['Desktop Chrome'],
+ permissions: ['clipboard-read', 'clipboard-write'],
+ storageState: 'playwright/.auth/user.json'
+ },
+ dependencies: ['setup']
},
{
name: 'Google Chrome',
- use: { ...devices['Desktop Chrome'], channel: 'chrome', permissions:
['clipboard-read', 'clipboard-write'] }
+ use: {
+ ...devices['Desktop Chrome'],
+ channel: 'chrome',
+ permissions: ['clipboard-read', 'clipboard-write'],
+ storageState: 'playwright/.auth/user.json'
+ },
+ dependencies: ['setup']
},
{
name: 'firefox',
- use: { ...devices['Desktop Firefox'] }
+ use: {
+ ...devices['Desktop Firefox'],
+ storageState: 'playwright/.auth/user.json'
+ },
+ dependencies: ['setup']
},
{
name: 'webkit',
@@ -61,12 +83,20 @@ module.exports = defineConfig({
...devices['Desktop Safari'],
launchOptions: {
slowMo: 200
- }
- }
+ },
+ storageState: 'playwright/.auth/user.json'
+ },
+ dependencies: ['setup']
},
{
name: 'Microsoft Edge',
- use: { ...devices['Desktop Edge'], channel: 'msedge', permissions:
['clipboard-read', 'clipboard-write'] }
+ use: {
+ ...devices['Desktop Edge'],
+ channel: 'msedge',
+ permissions: ['clipboard-read', 'clipboard-write'],
+ storageState: 'playwright/.auth/user.json'
+ },
+ dependencies: ['setup']
}
],
webServer: process.env.CI
diff --git a/zeppelin-web-angular/src/app/share/header/header.component.html
b/zeppelin-web-angular/src/app/share/header/header.component.html
index a2b378870d..bb0c706bc4 100644
--- a/zeppelin-web-angular/src/app/share/header/header.component.html
+++ b/zeppelin-web-angular/src/app/share/header/header.component.html
@@ -25,6 +25,7 @@
class="node-list-trigger"
[nzDropdownMenu]="list"
[nzTrigger]="'click'"
+ nzOverlayClassName="zeppelin-notebook-dropdown"
[(nzVisible)]="noteListVisible"
>
Notebook
diff --git a/zeppelin-web-angular/src/app/share/header/header.component.less
b/zeppelin-web-angular/src/app/share/header/header.component.less
index 116d84034f..faec20b367 100644
--- a/zeppelin-web-angular/src/app/share/header/header.component.less
+++ b/zeppelin-web-angular/src/app/share/header/header.component.less
@@ -140,3 +140,14 @@
}
}
}
+
+// Cap the dropdown so workspaces with many notes don't overflow the viewport.
+// ::ng-deep escapes view encapsulation since the overlay renders in a
body-level CDK overlay.
+// Scoped via nzOverlayClassName so no other dropdown is affected.
+::ng-deep .zeppelin-notebook-dropdown {
+ zeppelin-node-list {
+ display: block;
+ max-height: calc(100vh - 100px);
+ overflow-y: auto;
+ }
+}