Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions zeppelin-web-angular/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Thumbs.db
/playwright-coverage/
/test-results/
/playwright/.cache/
/playwright/.auth/

#
.env
Expand Down
45 changes: 45 additions & 0 deletions zeppelin-web-angular/e2e/global.setup.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
Comment on lines +21 to +45
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This setup project is the core change in the PR. The 29 specs that previously called login in their own beforeEach raced on the shared session cookie under parallel workers. Consolidating to a single login + storageState removes that race.

22 changes: 17 additions & 5 deletions zeppelin-web-angular/e2e/models/base-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] });
Comment thread
tbonelee marked this conversation as resolved.
}
}
5 changes: 5 additions & 0 deletions zeppelin-web-angular/e2e/models/dark-mode-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand Down
40 changes: 21 additions & 19 deletions zeppelin-web-angular/e2e/models/folder-rename-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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"]');
Expand All @@ -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);
Comment thread
tbonelee marked this conversation as resolved.
}

async clearNewName(): Promise<void> {
await this.renameInput.clear();
await expect(this.renameInput).toHaveValue('');
await this.fillAndVerifyInput(this.renameInput, '');
}

async clickConfirm(): Promise<void> {
Expand All @@ -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();
}
}
2 changes: 1 addition & 1 deletion zeppelin-web-angular/e2e/models/folder-rename-page.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
5 changes: 3 additions & 2 deletions zeppelin-web-angular/e2e/models/home-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
9 changes: 3 additions & 6 deletions zeppelin-web-angular/e2e/models/node-list-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Comment thread
tbonelee marked this conversation as resolved.
}

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[]> {
Expand Down
3 changes: 1 addition & 2 deletions zeppelin-web-angular/e2e/models/note-create-modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
4 changes: 2 additions & 2 deletions zeppelin-web-angular/e2e/models/note-import-modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand All @@ -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> {
Expand Down
2 changes: 1 addition & 1 deletion zeppelin-web-angular/e2e/models/note-rename-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
9 changes: 7 additions & 2 deletions zeppelin-web-angular/e2e/models/notebook-repos-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
6 changes: 2 additions & 4 deletions zeppelin-web-angular/e2e/models/notebook.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);

Expand Down
71 changes: 51 additions & 20 deletions zeppelin-web-angular/e2e/tests/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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
Expand All @@ -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'];
Expand All @@ -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'
);
Loading
Loading