diff --git a/zeppelin-web-angular/.gitignore b/zeppelin-web-angular/.gitignore index f285d87e9b1..42b640c2fdd 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 00000000000..234d0dbea14 --- /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 aa37f1a1384..403be0f7807 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 98f77c89335..bedea19f575 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 58f9327c6f3..93f49d30beb 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 { 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 { 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 { - await this.renameInput.fill(name); + await this.fillAndVerifyInput(this.renameInput, name); } async clearNewName(): Promise { - await this.renameInput.clear(); - await expect(this.renameInput).toHaveValue(''); + await this.fillAndVerifyInput(this.renameInput, ''); } async clickConfirm(): Promise { @@ -97,4 +86,17 @@ export class FolderRenamePage extends BasePage { async clickCancel(): Promise { 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 f1e32b1ded3..c248e3e376b 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 3222fc3964a..412642df440 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 { diff --git a/zeppelin-web-angular/e2e/models/node-list-page.ts b/zeppelin-web-angular/e2e/models/node-list-page.ts index 9c81bda02f4..88fa2721cd0 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 { - 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 { diff --git a/zeppelin-web-angular/e2e/models/note-create-modal.ts b/zeppelin-web-angular/e2e/models/note-create-modal.ts index a00ef19219f..b39d62d85b2 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 { - await this.noteNameInput.clear(); - await this.noteNameInput.fill(name); + await this.fillAndVerifyInput(this.noteNameInput, name); } async clickCreate(): Promise { diff --git a/zeppelin-web-angular/e2e/models/note-import-modal.ts b/zeppelin-web-angular/e2e/models/note-import-modal.ts index e4634f94bc8..92bfe0e8a01 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 { - await this.importAsInput.fill(name); + await this.fillAndVerifyInput(this.importAsInput, name); } async getImportAsName(): Promise { @@ -60,7 +60,7 @@ export class NoteImportModal extends BasePage { } async setImportUrl(url: string): Promise { - await this.urlInput.fill(url); + await this.fillAndVerifyInput(this.urlInput, url); } async clickImportNote(): Promise { diff --git a/zeppelin-web-angular/e2e/models/note-rename-page.ts b/zeppelin-web-angular/e2e/models/note-rename-page.ts index 932babc4092..9999bfcab9f 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 { await this.ensureEditMode(); - await this.noteTitleInput.fill(title, { timeout: 15000 }); + await this.fillAndVerifyInput(this.noteTitleInput, title); } async clearTitle(): Promise { diff --git a/zeppelin-web-angular/e2e/models/notebook-repos-page.ts b/zeppelin-web-angular/e2e/models/notebook-repos-page.ts index 66234f5b61c..76cbf00f7bc 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 { 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 { await this.saveButton.click({ timeout: 15000 }); + await this.editButton.waitFor({ state: 'visible', timeout: 10000 }); } async clickCancel(): Promise { await this.cancelButton.click({ timeout: 15000 }); + await this.editButton.waitFor({ state: 'visible', timeout: 10000 }); } async fillSettingInput(settingName: string, value: string): Promise { 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 { diff --git a/zeppelin-web-angular/e2e/models/notebook.util.ts b/zeppelin-web-angular/e2e/models/notebook.util.ts index 98ee1d9648d..1070618264f 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 { 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 d637896e0b2..759fde133a2 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): 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 cac761ae85b..66a7e9bd529 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 97a250d6abe..09cf4ea563c 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 5a12f6ea4e0..ef3cc36511f 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 66be0f6f4de..280ab64a341 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 c14a5474e2c..a92326f32e1 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 cd9786d82f8..e7d07c649e5 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; + let testCredentials: Record; - 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 4d012b93c41..358e77c65dc 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 e482464364e..b0818c9f02b 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 d66df7fa5f3..03b956e77d6 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 db259de8340..877a191569a 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 475da43f3ba..099212243d3 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 1cc2b2b2604..996ac0d24ca 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 1f2d6fed08c..c162293d48f 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 a364a20bb50..1bbc8d090d3 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 => { + 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 aae38d544a1..a37d7c41713 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 f6960142a41..17f0c4bfc2d 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 2ef30aa82f1..0bef3a74bb1 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 dbc27205b3e..239f8740733 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 2100d56a394..9fe75cbae34 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 a5a6f1c13f8..34bdac8a178 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 6b6527842e7..355bb287708 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 13b03fbdd37..49bbdf92cb6 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 1796e1578a5..3cdc499f682 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 5fd6af53b94..d941829bc0f 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 e4fc940322f..29cbd419cb2 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 db65b97063d..e8f6f30695c 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 0fd368e4933..f39f2773036 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 39cccf2c581..40c3fa7ae3c 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 55adf20cf30..c4ee882cf17 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 106345fd2e9..86095206d79 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 18a66a0ee6d..b8be01c99a4 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 => + page + .locator('zeppelin-login') + .isVisible() + .catch(() => false); + +const waitForLoginPageReady = async (page: Page): Promise => { + 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 => { + 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 => { const isShiroEnabled = await LoginTestUtil.isShiroEnabled(); if (!isShiroEnabled) { @@ -169,9 +252,7 @@ export const performLoginIfRequired = async (page: Page): Promise => { } 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 => { return false; }; -export const waitForZeppelinReady = async (page: Page): Promise => { +export const waitForZeppelinReady = async (page: Page, options: WaitForZeppelinReadyOptions = {}): Promise => { 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 => { { 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 => { + 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 => { - const url = page.url(); - const match = url.match(NOTEBOOK_PATTERNS.URL_EXTRACT_NOTEBOOK_ID_REGEX); - return match ? match[1] : null; -}; +interface ZeppelinJsonResponse { + status: string; + message?: string; + body: T; +} -const waitForNotebookNavigation = async (page: Page): Promise => { - 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 => { - 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 => { + 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; + 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 = { + 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; + 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 => { - await page.locator('zeppelin-notebook-paragraph').first().waitFor({ state: 'visible', timeout: 20000 }); + let noteJson!: ZeppelinJsonResponse; + 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; + }).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 06e92703854..bc1bd46ebd5 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 a2b378870d4..bb0c706bc4f 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 116d84034f0..faec20b3674 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; + } +}