diff --git a/zeppelin-web-angular/e2e/models/about-zeppelin-modal.ts b/zeppelin-web-angular/e2e/models/about-zeppelin-modal.ts new file mode 100644 index 00000000000..d5a44add770 --- /dev/null +++ b/zeppelin-web-angular/e2e/models/about-zeppelin-modal.ts @@ -0,0 +1,57 @@ +/* + * 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 { Locator, Page } from '@playwright/test'; +import { BasePage } from './base-page'; + +export class AboutZeppelinModal extends BasePage { + readonly modal: Locator; + readonly modalTitle: Locator; + readonly closeButton: Locator; + readonly logo: Locator; + readonly heading: Locator; + readonly versionText: Locator; + readonly getInvolvedLink: Locator; + readonly licenseLink: Locator; + + constructor(page: Page) { + super(page); + this.modal = page.locator('[role="dialog"]').filter({ has: page.getByText('About Zeppelin') }); + this.modalTitle = page.locator('.ant-modal-title', { hasText: 'About Zeppelin' }); + this.closeButton = page.getByRole('button', { name: 'Close' }); + this.logo = page.locator('img[alt="Apache Zeppelin"]'); + this.heading = page.locator('h3', { hasText: 'Apache Zeppelin' }); + this.versionText = page.locator('.about-version'); + this.getInvolvedLink = page.getByRole('link', { name: 'Get involved!' }); + this.licenseLink = page.getByRole('link', { name: 'Licensed under the Apache License, Version 2.0' }); + } + + async close(): Promise { + await this.closeButton.click(); + } + + async getVersionText(): Promise { + return (await this.versionText.textContent()) || ''; + } + + async isLogoVisible(): Promise { + return this.logo.isVisible(); + } + + async getGetInvolvedHref(): Promise { + return this.getInvolvedLink.getAttribute('href'); + } + + async getLicenseHref(): Promise { + return this.licenseLink.getAttribute('href'); + } +} diff --git a/zeppelin-web-angular/e2e/models/header-page.ts b/zeppelin-web-angular/e2e/models/header-page.ts new file mode 100644 index 00000000000..2f5c1c496fc --- /dev/null +++ b/zeppelin-web-angular/e2e/models/header-page.ts @@ -0,0 +1,113 @@ +/* + * 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 { Locator, Page } from '@playwright/test'; +import { BasePage } from './base-page'; + +export class HeaderPage extends BasePage { + readonly header: Locator; + readonly brandLogo: Locator; + readonly brandLink: Locator; + readonly notebookMenuItem: Locator; + readonly notebookDropdownTrigger: Locator; + readonly notebookDropdown: Locator; + readonly jobMenuItem: Locator; + readonly userDropdownTrigger: Locator; + readonly userBadge: Locator; + readonly searchInput: Locator; + readonly themeToggleButton: Locator; + + readonly userMenuItems: { + aboutZeppelin: Locator; + interpreter: Locator; + notebookRepos: Locator; + credential: Locator; + configuration: Locator; + logout: Locator; + switchToClassicUI: Locator; + }; + + constructor(page: Page) { + super(page); + this.header = page.locator('.header'); + this.brandLogo = page.locator('.header .brand .logo'); + this.brandLink = page.locator('.header .brand'); + this.notebookMenuItem = page.locator('[nz-menu-item]').filter({ hasText: 'Notebook' }); + this.notebookDropdownTrigger = page.locator('.node-list-trigger'); + this.notebookDropdown = page.locator('zeppelin-node-list.ant-dropdown-menu'); + this.jobMenuItem = page.getByRole('link', { name: 'Job' }); + this.userDropdownTrigger = page.locator('.header .user .status'); + this.userBadge = page.locator('.header .user nz-badge'); + this.searchInput = page.locator('.header .search input[type="text"]'); + this.themeToggleButton = page.locator('zeppelin-theme-toggle button'); + + this.userMenuItems = { + aboutZeppelin: page.getByText('About Zeppelin', { exact: true }), + interpreter: page.getByRole('link', { name: 'Interpreter' }), + notebookRepos: page.getByRole('link', { name: 'Notebook Repos' }), + credential: page.getByRole('link', { name: 'Credential' }), + configuration: page.getByRole('link', { name: 'Configuration' }), + logout: page.getByText('Logout', { exact: true }), + switchToClassicUI: page.getByRole('link', { name: 'Switch to Classic UI' }) + }; + } + + async clickBrandLogo(): Promise { + await this.brandLink.waitFor({ state: 'visible', timeout: 10000 }); + await this.brandLink.click(); + } + + async clickNotebookMenu(): Promise { + await this.notebookDropdownTrigger.waitFor({ state: 'visible', timeout: 10000 }); + await this.notebookDropdownTrigger.click(); + } + + async clickJobMenu(): Promise { + await this.jobMenuItem.waitFor({ state: 'visible', timeout: 10000 }); + await this.jobMenuItem.click(); + } + + async clickUserDropdown(): Promise { + await this.userDropdownTrigger.waitFor({ state: 'visible', timeout: 10000 }); + await this.userDropdownTrigger.click(); + } + + async clickAboutZeppelin(): Promise { + await this.userMenuItems.aboutZeppelin.click(); + } + + async clickInterpreter(): Promise { + await this.userMenuItems.interpreter.click(); + } + + async clickNotebookRepos(): Promise { + await this.userMenuItems.notebookRepos.click(); + } + + async clickCredential(): Promise { + await this.userMenuItems.credential.click(); + } + + async clickConfiguration(): Promise { + await this.userMenuItems.configuration.click(); + } + + async getUsernameText(): Promise { + return (await this.userBadge.textContent()) || ''; + } + + async searchNote(query: string): Promise { + await this.searchInput.waitFor({ state: 'visible', timeout: 10000 }); + await this.searchInput.fill(query); + await this.page.keyboard.press('Enter'); + } +} diff --git a/zeppelin-web-angular/e2e/models/header-page.util.ts b/zeppelin-web-angular/e2e/models/header-page.util.ts new file mode 100644 index 00000000000..14a369eb0ec --- /dev/null +++ b/zeppelin-web-angular/e2e/models/header-page.util.ts @@ -0,0 +1,109 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, Page } from '@playwright/test'; +import { HeaderPage } from './header-page'; +import { NodeListPage } from './node-list-page'; + +export class HeaderPageUtil { + constructor( + private readonly page: Page, + private readonly headerPage: HeaderPage + ) {} + + async verifyHeaderIsDisplayed(): Promise { + await expect(this.headerPage.header).toBeVisible(); + await expect(this.headerPage.brandLogo).toBeVisible(); + await expect(this.headerPage.notebookMenuItem).toBeVisible(); + await expect(this.headerPage.jobMenuItem).toBeVisible(); + await expect(this.headerPage.userDropdownTrigger).toBeVisible(); + await expect(this.headerPage.searchInput).toBeVisible(); + await expect(this.headerPage.themeToggleButton).toBeVisible(); + } + + async verifyNavigationToHomePage(): Promise { + await this.headerPage.clickBrandLogo(); + await this.page.waitForURL(/\/(#\/)?$/); + const url = this.page.url(); + expect(url).toMatch(/\/(#\/)?$/); + } + + async verifyNavigationToJobManager(): Promise { + await this.headerPage.clickJobMenu(); + await this.page.waitForURL(/jobmanager/); + expect(this.page.url()).toContain('jobmanager'); + } + + async verifyUserDropdownOpens(): Promise { + await this.headerPage.clickUserDropdown(); + await expect(this.headerPage.userMenuItems.aboutZeppelin).toBeVisible(); + } + + async verifyNotebookDropdownOpens(): Promise { + await this.headerPage.clickNotebookMenu(); + await expect(this.headerPage.notebookDropdown).toBeVisible(); + + const nodeList = new NodeListPage(this.page); + await expect(nodeList.createNewNoteButton).toBeVisible(); + } + + async verifySearchNavigation(query: string): Promise { + await this.headerPage.searchNote(query); + await this.page.waitForURL(/search/); + expect(this.page.url()).toContain('search'); + expect(this.page.url()).toContain(query); + } + + async verifyUserMenuItemsVisible(isLoggedIn: boolean): Promise { + await this.headerPage.clickUserDropdown(); + await expect(this.headerPage.userMenuItems.aboutZeppelin).toBeVisible(); + await expect(this.headerPage.userMenuItems.interpreter).toBeVisible(); + await expect(this.headerPage.userMenuItems.notebookRepos).toBeVisible(); + await expect(this.headerPage.userMenuItems.credential).toBeVisible(); + await expect(this.headerPage.userMenuItems.configuration).toBeVisible(); + await expect(this.headerPage.userMenuItems.switchToClassicUI).toBeVisible(); + + if (isLoggedIn) { + const username = await this.headerPage.getUsernameText(); + expect(username).not.toBe('anonymous'); + await expect(this.headerPage.userMenuItems.logout).toBeVisible(); + } + } + + async navigateToInterpreterSettings(): Promise { + await this.headerPage.clickUserDropdown(); + await this.headerPage.clickInterpreter(); + await this.page.waitForURL(/interpreter/); + expect(this.page.url()).toContain('interpreter'); + } + + async navigateToNotebookRepos(): Promise { + await this.headerPage.clickUserDropdown(); + await this.headerPage.clickNotebookRepos(); + await this.page.waitForURL(/notebook-repos/); + expect(this.page.url()).toContain('notebook-repos'); + } + + async navigateToCredential(): Promise { + await this.headerPage.clickUserDropdown(); + await this.headerPage.clickCredential(); + await this.page.waitForURL(/credential/); + expect(this.page.url()).toContain('credential'); + } + + async navigateToConfiguration(): Promise { + await this.headerPage.clickUserDropdown(); + await this.headerPage.clickConfiguration(); + await this.page.waitForURL(/configuration/); + expect(this.page.url()).toContain('configuration'); + } +} diff --git a/zeppelin-web-angular/e2e/models/node-list-page.ts b/zeppelin-web-angular/e2e/models/node-list-page.ts new file mode 100644 index 00000000000..17bd93de33d --- /dev/null +++ b/zeppelin-web-angular/e2e/models/node-list-page.ts @@ -0,0 +1,78 @@ +/* + * 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 { Locator, Page } from '@playwright/test'; +import { BasePage } from './base-page'; + +export class NodeListPage extends BasePage { + readonly nodeListContainer: Locator; + readonly importNoteButton: Locator; + readonly createNewNoteButton: Locator; + readonly filterInput: Locator; + readonly treeView: Locator; + readonly notes: Locator; + readonly trashFolder: Locator; + + constructor(page: Page) { + super(page); + this.nodeListContainer = page.locator('zeppelin-node-list'); + this.importNoteButton = page.getByText('Import Note', { exact: true }).first(); + this.createNewNoteButton = page.getByText('Create new Note', { exact: true }).first(); + this.filterInput = page.locator('zeppelin-node-list input[placeholder*="Filter"]'); + this.treeView = page.locator('zeppelin-node-list nz-tree'); + this.notes = page.locator('nz-tree-node').filter({ has: page.locator('.ant-tree-node-content-wrapper .file') }); + this.trashFolder = page.locator('nz-tree-node').filter({ hasText: '~Trash' }); + } + + async clickImportNote(): Promise { + await this.importNoteButton.click(); + } + + async clickCreateNewNote(): Promise { + await this.createNewNoteButton.click(); + } + + getFolderByName(folderName: string): Locator { + return this.page.locator('nz-tree-node').filter({ hasText: folderName }).first(); + } + + getNoteByName(noteName: string): Locator { + return this.page.locator('nz-tree-node').filter({ hasText: noteName }).first(); + } + + 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(); + } + + async isFilterInputVisible(): Promise { + return this.filterInput.isVisible(); + } + + async isTrashFolderVisible(): Promise { + return this.trashFolder.isVisible(); + } + + async getAllVisibleNoteNames(): Promise { + const noteElements = await this.notes.all(); + const names: string[] = []; + for (const note of noteElements) { + const text = await note.textContent(); + if (text) { + names.push(text.trim()); + } + } + return names; + } +} diff --git a/zeppelin-web-angular/e2e/models/note-create-modal.ts b/zeppelin-web-angular/e2e/models/note-create-modal.ts new file mode 100644 index 00000000000..1e1a0c4808d --- /dev/null +++ b/zeppelin-web-angular/e2e/models/note-create-modal.ts @@ -0,0 +1,54 @@ +/* + * 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 { Locator, Page } from '@playwright/test'; +import { BasePage } from './base-page'; + +export class NoteCreateModal extends BasePage { + readonly modal: Locator; + readonly closeButton: Locator; + readonly noteNameInput: Locator; + readonly interpreterDropdown: Locator; + readonly folderInfoAlert: Locator; + readonly createButton: Locator; + + constructor(page: Page) { + super(page); + this.modal = page.locator('[role="dialog"]').filter({ has: page.locator('input[name="noteName"]') }); + this.closeButton = page.getByRole('button', { name: 'Close' }); + this.noteNameInput = page.locator('input[name="noteName"]'); + this.interpreterDropdown = page.locator('nz-select[name="defaultInterpreter"]'); + this.folderInfoAlert = page.getByText("Use '/' to create folders"); + this.createButton = page.getByRole('button', { name: 'Create' }); + } + + async close(): Promise { + await this.closeButton.click(); + } + + async getNoteName(): Promise { + return (await this.noteNameInput.inputValue()) || ''; + } + + async setNoteName(name: string): Promise { + await this.noteNameInput.clear(); + await this.noteNameInput.fill(name); + } + + async clickCreate(): Promise { + await this.createButton.click(); + } + + async isFolderInfoVisible(): Promise { + return this.folderInfoAlert.isVisible(); + } +} diff --git a/zeppelin-web-angular/e2e/models/note-create-modal.util.ts b/zeppelin-web-angular/e2e/models/note-create-modal.util.ts new file mode 100644 index 00000000000..7553325c1e2 --- /dev/null +++ b/zeppelin-web-angular/e2e/models/note-create-modal.util.ts @@ -0,0 +1,40 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from '@playwright/test'; +import { NoteCreateModal } from './note-create-modal'; + +export class NoteCreateModalUtil { + constructor(private readonly modal: NoteCreateModal) {} + + async verifyModalIsOpen(): Promise { + await expect(this.modal.modal).toBeVisible(); + await expect(this.modal.noteNameInput).toBeVisible(); + await expect(this.modal.createButton).toBeVisible(); + } + + async verifyDefaultNoteName(expectedPattern: RegExp): Promise { + const noteName = await this.modal.getNoteName(); + expect(noteName).toMatch(expectedPattern); + } + + async verifyFolderCreationInfo(): Promise { + await expect(this.modal.folderInfoAlert).toBeVisible(); + const text = await this.modal.folderInfoAlert.textContent(); + expect(text).toContain('/'); + } + + async verifyModalClose(): Promise { + await this.modal.close(); + await expect(this.modal.modal).not.toBeVisible(); + } +} diff --git a/zeppelin-web-angular/e2e/models/note-import-modal.ts b/zeppelin-web-angular/e2e/models/note-import-modal.ts new file mode 100644 index 00000000000..11db6d5da41 --- /dev/null +++ b/zeppelin-web-angular/e2e/models/note-import-modal.ts @@ -0,0 +1,95 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Locator, Page } from '@playwright/test'; +import { BasePage } from './base-page'; + +export class NoteImportModal extends BasePage { + readonly modal: Locator; + readonly modalTitle: Locator; + readonly closeButton: Locator; + readonly importAsInput: Locator; + readonly jsonFileTab: Locator; + readonly urlTab: Locator; + readonly uploadArea: Locator; + readonly uploadText: Locator; + readonly fileSizeLimit: Locator; + readonly urlInput: Locator; + readonly importNoteButton: Locator; + readonly errorAlert: Locator; + + constructor(page: Page) { + super(page); + this.modal = page.locator('[role="dialog"]').filter({ has: page.locator('input[name="noteImportName"]') }); + this.modalTitle = page.locator('.ant-modal-title', { hasText: 'Import New Note' }); + this.closeButton = page.getByRole('button', { name: 'Close' }); + this.importAsInput = page.locator('input[name="noteImportName"]'); + this.jsonFileTab = page.getByRole('tab', { name: 'Import From JSON File' }); + this.urlTab = page.getByRole('tab', { name: 'Import From URL' }); + this.uploadArea = page.locator('nz-upload[nztype="drag"]'); + this.uploadText = page.getByText('Click or drag JSON file to this area to upload'); + this.fileSizeLimit = page.locator('.ant-upload-hint strong'); + this.urlInput = page.locator('input[name="importUrl"]'); + this.importNoteButton = page.getByRole('button', { name: 'Import Note' }); + this.errorAlert = page.locator('nz-alert[nztype="error"]'); + } + + async close(): Promise { + await this.closeButton.click(); + } + + async setImportAsName(name: string): Promise { + await this.importAsInput.fill(name); + } + + async getImportAsName(): Promise { + return (await this.importAsInput.inputValue()) || ''; + } + + async switchToUrlTab(): Promise { + await this.urlTab.click(); + } + + async isJsonFileTabSelected(): Promise { + const ariaSelected = await this.jsonFileTab.getAttribute('aria-selected'); + return ariaSelected === 'true'; + } + + async isUrlTabSelected(): Promise { + const ariaSelected = await this.urlTab.getAttribute('aria-selected'); + return ariaSelected === 'true'; + } + + async setImportUrl(url: string): Promise { + await this.urlInput.fill(url); + } + + async clickImportNote(): Promise { + await this.importNoteButton.click(); + } + + async isImportNoteButtonDisabled(): Promise { + return this.importNoteButton.isDisabled(); + } + + async getFileSizeLimit(): Promise { + return (await this.fileSizeLimit.textContent()) || ''; + } + + async isErrorAlertVisible(): Promise { + return this.errorAlert.isVisible(); + } + + async getErrorMessage(): Promise { + return (await this.errorAlert.textContent()) || ''; + } +} 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 018bfbf40e3..f385de5f585 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 @@ -55,18 +55,18 @@ test.describe('Home Page Note Operations', () => { const firstNote = page.locator('.node .file').first(); await firstNote.hover(); - await expect(homePage.nodeList.noteActions.renameNote).toBeVisible(); - await expect(homePage.nodeList.noteActions.clearOutput).toBeVisible(); - await expect(homePage.nodeList.noteActions.moveToTrash).toBeVisible(); + await expect(homePage.nodeList.noteActions.renameNote.first()).toBeVisible(); + await expect(homePage.nodeList.noteActions.clearOutput.first()).toBeVisible(); + await expect(homePage.nodeList.noteActions.moveToTrash.first()).toBeVisible(); // Test tooltip visibility by hovering over each icon - await homePage.nodeList.noteActions.renameNote.hover(); + await homePage.nodeList.noteActions.renameNote.first().hover(); await expect(page.locator('.ant-tooltip', { hasText: 'Rename note' })).toBeVisible(); - await homePage.nodeList.noteActions.clearOutput.hover(); + await homePage.nodeList.noteActions.clearOutput.first().hover(); await expect(page.locator('.ant-tooltip', { hasText: 'Clear output' })).toBeVisible(); - await homePage.nodeList.noteActions.moveToTrash.hover(); + await homePage.nodeList.noteActions.moveToTrash.first().hover(); await expect(page.locator('.ant-tooltip', { hasText: 'Move note to Trash' })).toBeVisible(); } }); 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 new file mode 100644 index 00000000000..2e8ab234a78 --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/share/about-zeppelin/about-zeppelin-modal.spec.ts @@ -0,0 +1,69 @@ +/* + * 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 { 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'; + +test.describe('About Zeppelin Modal', () => { + let headerPage: HeaderPage; + let aboutModal: AboutZeppelinModal; + + addPageAnnotationBeforeEach(PAGES.SHARE.ABOUT_ZEPPELIN); + + test.beforeEach(async ({ page }) => { + headerPage = new HeaderPage(page); + aboutModal = new AboutZeppelinModal(page); + + await page.goto('/'); + await waitForZeppelinReady(page); + await performLoginIfRequired(page); + + await headerPage.clickUserDropdown(); + await headerPage.clickAboutZeppelin(); + }); + + test('Given user clicks About Zeppelin menu item, When modal opens, Then modal should display all required elements', async () => { + await expect(aboutModal.modal).toBeVisible(); + await expect(aboutModal.modalTitle).toBeVisible(); + await expect(aboutModal.heading).toBeVisible(); + await expect(aboutModal.logo).toBeVisible(); + await expect(aboutModal.versionText).toBeVisible(); + await expect(aboutModal.getInvolvedLink).toBeVisible(); + await expect(aboutModal.licenseLink).toBeVisible(); + }); + + test('Given About Zeppelin modal is open, When viewing version information, Then version should be displayed', async () => { + const version = await aboutModal.getVersionText(); + expect(version).toBeTruthy(); + expect(version.length).toBeGreaterThan(0); + }); + + test('Given About Zeppelin modal is open, When checking external links, Then links should have correct URLs', async () => { + const getInvolvedHref = await aboutModal.getGetInvolvedHref(); + const licenseHref = await aboutModal.getLicenseHref(); + + expect(getInvolvedHref).toContain('zeppelin.apache.org'); + expect(licenseHref).toContain('apache.org/licenses'); + }); + + test('Given About Zeppelin modal is open, When clicking close button, Then modal should close', async () => { + await aboutModal.close(); + await expect(aboutModal.modal).not.toBeVisible(); + }); + + test('Given About Zeppelin modal is open, When checking logo, Then logo should be visible and properly loaded', async () => { + const isLogoVisible = await aboutModal.isLogoVisible(); + expect(isLogoVisible).toBe(true); + }); +}); 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 new file mode 100644 index 00000000000..18ae43faba6 --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/share/header/header-navigation.spec.ts @@ -0,0 +1,73 @@ +/* + * 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 { test } from '@playwright/test'; +import { HeaderPage } from '../../../models/header-page'; +import { HeaderPageUtil } from '../../../models/header-page.util'; +import { addPageAnnotationBeforeEach, PAGES, performLoginIfRequired, waitForZeppelinReady } from '../../../utils'; + +test.describe('Header Navigation', () => { + let headerPage: HeaderPage; + let headerUtil: HeaderPageUtil; + + addPageAnnotationBeforeEach(PAGES.SHARE.HEADER); + + test.beforeEach(async ({ page }) => { + headerPage = new HeaderPage(page); + headerUtil = new HeaderPageUtil(page, headerPage); + + 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 () => { + await headerUtil.verifyHeaderIsDisplayed(); + }); + + test('Given user is on any page, When clicking the Zeppelin logo, Then user should navigate to home page', async () => { + await headerUtil.verifyNavigationToHomePage(); + }); + + test('Given user is on home page, When clicking the Job menu item, Then user should navigate to Job Manager page', async () => { + await headerUtil.verifyNavigationToJobManager(); + }); + + test('Given user is on home page, When clicking the Notebook dropdown, Then dropdown with node list should open', async () => { + await headerUtil.verifyNotebookDropdownOpens(); + }); + + test('Given user is on home page, When clicking the user dropdown, Then user menu should open', async () => { + await headerUtil.verifyUserDropdownOpens(); + }); + + test('Given user opens user dropdown, When all menu items are displayed, Then menu items should include settings and configuration options', async () => { + const isAnonymous = (await headerPage.getUsernameText()).includes('anonymous'); + await headerUtil.verifyUserMenuItemsVisible(!isAnonymous); + }); + + test('Given user opens user dropdown, When clicking Interpreter menu item, Then user should navigate to Interpreter settings page', async () => { + await headerUtil.navigateToInterpreterSettings(); + }); + + test('Given user opens user dropdown, When clicking Notebook Repos menu item, Then user should navigate to Notebook Repos page', async () => { + await headerUtil.navigateToNotebookRepos(); + }); + + test('Given user opens user dropdown, When clicking Credential menu item, Then user should navigate to Credential page', async () => { + await headerUtil.navigateToCredential(); + }); + + test('Given user opens user dropdown, When clicking Configuration menu item, Then user should navigate to Configuration page', async () => { + await headerUtil.navigateToConfiguration(); + }); +}); 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 new file mode 100644 index 00000000000..171f2d52558 --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/share/header/header-search.spec.ts @@ -0,0 +1,42 @@ +/* + * 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 { test, expect } from '@playwright/test'; +import { HeaderPage } from '../../../models/header-page'; +import { HeaderPageUtil } from '../../../models/header-page.util'; +import { addPageAnnotationBeforeEach, PAGES, performLoginIfRequired, waitForZeppelinReady } from '../../../utils'; + +test.describe('Header Search Functionality', () => { + let headerPage: HeaderPage; + let headerUtil: HeaderPageUtil; + + addPageAnnotationBeforeEach(PAGES.SHARE.HEADER); + + test.beforeEach(async ({ page }) => { + headerPage = new HeaderPage(page); + headerUtil = new HeaderPageUtil(page, headerPage); + + 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 () => { + const searchQuery = 'test'; + await headerUtil.verifySearchNavigation(searchQuery); + }); + + test('Given user is on home page, When viewing search input, Then search input should be visible and accessible', async () => { + await expect(headerPage.searchInput).toBeVisible(); + await expect(headerPage.searchInput).toBeEditable(); + }); +}); 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 new file mode 100644 index 00000000000..111d01011f7 --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/share/node-list/node-list-functionality.spec.ts @@ -0,0 +1,113 @@ +/* + * 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 { 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'; + +test.describe('Node List Functionality', () => { + let nodeListPage: NodeListPage; + + addPageAnnotationBeforeEach(PAGES.SHARE.NODE_LIST); + + test.beforeEach(async ({ page }) => { + nodeListPage = new NodeListPage(page); + + 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 () => { + await expect(nodeListPage.nodeListContainer).toBeVisible(); + await expect(nodeListPage.treeView).toBeVisible(); + }); + + test('Given user is on home page, When viewing node list, Then action buttons should be visible', async () => { + await expect(nodeListPage.createNewNoteButton).toBeVisible(); + await expect(nodeListPage.importNoteButton).toBeVisible(); + }); + + test('Given user is on home page, When viewing node list, Then filter input should be visible', async () => { + const isFilterVisible = await nodeListPage.isFilterInputVisible(); + expect(isFilterVisible).toBe(true); + }); + + test('Given a note has been moved to trash, When viewing node list, Then trash folder should be visible', async ({ + page + }) => { + const homePage = new HomePage(page); + + // Create a test note to ensure there is something to trash + await homePage.createNote('_e2e_trash_test'); + + // Navigate back to home + await page.goto('/'); + await waitForZeppelinReady(page); + + // Wait for the created note to appear in the node list, then hover + const testNote = page.locator('.node .file').filter({ hasText: '_e2e_trash_test' }); + await expect(testNote).toBeVisible({ timeout: 15000 }); + await testNote.hover(); + + // Click the delete icon (nz-popconfirm is on the element) + const deleteIcon = testNote.locator('.operation i[nztype="delete"]'); + await deleteIcon.click(); + + // Confirm the popconfirm dialog (ng-zorro en_US default is "OK", not "Yes") + await expect(page.locator('text=This note will be moved to trash.')).toBeVisible(); + const confirmButton = page.locator('.ant-popover button:has-text("OK")'); + await confirmButton.click(); + + // Wait for the trash folder to appear and verify + await expect(nodeListPage.trashFolder).toBeVisible({ timeout: 10000 }); + const isTrashVisible = await nodeListPage.isTrashFolderVisible(); + expect(isTrashVisible).toBe(true); + }); + + test('Given there are notes in node list, When clicking a note, Then user should navigate to that note', async ({ + page + }) => { + await expect(nodeListPage.treeView).toBeVisible(); + const notes = await nodeListPage.getAllVisibleNoteNames(); + + if (notes.length > 0 && notes[0]) { + const noteName = notes[0].trim(); + + await nodeListPage.clickNote(noteName); + await page.waitForURL(/notebook\//); + + expect(page.url()).toContain('notebook/'); + } + }); + + test('Given user clicks Create New Note button, When modal opens, Then note create modal should be displayed', async ({ + page + }) => { + await nodeListPage.clickCreateNewNote(); + await page.waitForSelector('input[name="noteName"]'); + + const noteNameInput = page.locator('input[name="noteName"]'); + await expect(noteNameInput).toBeVisible(); + }); + + test('Given user clicks Import Note button, When modal opens, Then note import modal should be displayed', async ({ + page + }) => { + await nodeListPage.clickImportNote(); + await page.waitForSelector('input[name="noteImportName"]'); + + const importNameInput = page.locator('input[name="noteImportName"]'); + await expect(importNameInput).toBeVisible(); + }); +}); 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 new file mode 100644 index 00000000000..a2674b4c4ae --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/share/note-create/note-create-modal.spec.ts @@ -0,0 +1,108 @@ +/* + * 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 { test, expect } from '@playwright/test'; +import { HomePage } from '../../../models/home-page'; +import { NoteCreateModal } from '../../../models/note-create-modal'; +import { NoteCreateModalUtil } from '../../../models/note-create-modal.util'; +import { addPageAnnotationBeforeEach, PAGES, performLoginIfRequired, waitForZeppelinReady } from '../../../utils'; + +test.describe('Note Create Modal', () => { + let homePage: HomePage; + let noteCreateModal: NoteCreateModal; + let noteCreateUtil: NoteCreateModalUtil; + + addPageAnnotationBeforeEach(PAGES.SHARE.NOTE_CREATE); + + test.beforeEach(async ({ page }) => { + homePage = new HomePage(page); + noteCreateModal = new NoteCreateModal(page); + noteCreateUtil = new NoteCreateModalUtil(noteCreateModal); + + await page.goto('/'); + await waitForZeppelinReady(page); + await performLoginIfRequired(page); + + await homePage.clickCreateNewNote(); + await page.waitForSelector('input[name="noteName"]'); + }); + + test('Given user clicks Create New Note, When modal opens, Then modal should display all required elements', async () => { + await noteCreateUtil.verifyModalIsOpen(); + await expect(noteCreateModal.interpreterDropdown).toBeVisible(); + await noteCreateUtil.verifyFolderCreationInfo(); + }); + + test('Given Create Note modal is open, When checking default note name, Then auto-generated name should follow pattern', async () => { + await noteCreateUtil.verifyDefaultNoteName(/Untitled Note \d+/); + }); + + test('Given Create Note modal is open, When entering custom note name and creating, Then new note should be created successfully', async ({ + page + }) => { + const uniqueName = `Test Note ${Date.now()}`; + await noteCreateModal.setNoteName(uniqueName); + await noteCreateModal.clickCreate(); + + // Wait for modal to disappear + await expect(noteCreateModal.modal).not.toBeVisible(); + + await page.waitForURL(/notebook\//); + expect(page.url()).toContain('notebook/'); + + // Verify the note was created with the correct name + const notebookTitle = page.locator('p, .notebook-title, .note-title, h1, [data-testid="notebook-title"]').first(); + await expect(notebookTitle).toContainText(uniqueName); + + // Verify in the navigation tree if available + await page.goto('/'); + await page.waitForLoadState('networkidle'); + const noteInTree = page.getByRole('link', { name: uniqueName }); + await expect(noteInTree).toBeVisible(); + }); + + test('Given Create Note modal is open, When entering note name with folder path, Then note should be created in folder', async ({ + page + }) => { + const folderPath = `/TestFolder/SubFolder`; + const noteName = `Note ${Date.now()}`; + const fullPath = `${folderPath}/${noteName}`; + + await noteCreateModal.setNoteName(fullPath); + await noteCreateModal.clickCreate(); + + // Wait for modal to disappear + await expect(noteCreateModal.modal).not.toBeVisible(); + + await page.waitForURL(/notebook\//); + expect(page.url()).toContain('notebook/'); + + // Verify the note was created with the correct name (without folder path) + const notebookTitle = page.locator('p, .notebook-title, .note-title, h1, [data-testid="notebook-title"]').first(); + await expect(notebookTitle).toContainText(noteName); + + // Verify the folder structure was created + await page.goto('/'); + await page.waitForLoadState('networkidle'); + const folder = page.locator('nz-tree-node').filter({ hasText: 'TestFolder' }); + await expect(folder).toBeVisible(); + }); + + test('Given Create Note modal is open, When clicking close button, Then modal should close', async () => { + await noteCreateUtil.verifyModalClose(); + }); + + test('Given Create Note modal is open, When viewing folder info alert, Then alert should contain folder creation instructions', async () => { + const isInfoVisible = await noteCreateModal.isFolderInfoVisible(); + expect(isInfoVisible).toBe(true); + }); +}); 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 new file mode 100644 index 00000000000..b20bee0902a --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/share/note-import/note-import-modal.spec.ts @@ -0,0 +1,105 @@ +/* + * 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 { 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'; + +test.describe('Note Import Modal', () => { + let homePage: HomePage; + let noteImportModal: NoteImportModal; + + addPageAnnotationBeforeEach(PAGES.SHARE.NOTE_IMPORT); + + test.beforeEach(async ({ page }) => { + homePage = new HomePage(page); + noteImportModal = new NoteImportModal(page); + + await page.goto('/'); + await waitForZeppelinReady(page); + await performLoginIfRequired(page); + + await homePage.clickImportNote(); + await page.waitForSelector('input[name="noteImportName"]'); + }); + + test('Given user clicks Import Note, When modal opens, Then modal should display all required elements', async () => { + await expect(noteImportModal.modal).toBeVisible(); + await expect(noteImportModal.modalTitle).toBeVisible(); + await expect(noteImportModal.importAsInput).toBeVisible(); + await expect(noteImportModal.jsonFileTab).toBeVisible(); + await expect(noteImportModal.urlTab).toBeVisible(); + }); + + test('Given Import Note modal is open, When viewing default tab, Then JSON File tab should be selected', async () => { + const isJsonTabSelected = await noteImportModal.isJsonFileTabSelected(); + expect(isJsonTabSelected).toBe(true); + + await expect(noteImportModal.uploadArea).toBeVisible(); + await expect(noteImportModal.uploadText).toBeVisible(); + }); + + test('Given Import Note modal is open, When switching to URL tab, Then URL input should be visible', async () => { + await noteImportModal.switchToUrlTab(); + + const isUrlTabSelected = await noteImportModal.isUrlTabSelected(); + expect(isUrlTabSelected).toBe(true); + + await expect(noteImportModal.urlInput).toBeVisible(); + await expect(noteImportModal.importNoteButton).toBeVisible(); + }); + + test('Given URL tab is selected, When URL is empty, Then import button should be disabled', async () => { + await noteImportModal.switchToUrlTab(); + + const isDisabled = await noteImportModal.isImportNoteButtonDisabled(); + expect(isDisabled).toBe(true); + }); + + test('Given URL tab is selected, When entering URL, Then import button should be enabled', async () => { + await noteImportModal.switchToUrlTab(); + await noteImportModal.setImportUrl('https://example.com/note.json'); + + const isDisabled = await noteImportModal.isImportNoteButtonDisabled(); + expect(isDisabled).toBe(false); + }); + + test('Given Import Note modal is open, When entering import name, Then name should be set', async () => { + const importName = `Imported Note ${Date.now()}`; + await noteImportModal.setImportAsName(importName); + + const actualName = await noteImportModal.getImportAsName(); + expect(actualName).toBe(importName); + }); + + test('Given JSON File tab is selected, When viewing file size limit, Then limit should be displayed', async () => { + const fileSizeLimit = await noteImportModal.getFileSizeLimit(); + expect(fileSizeLimit).toBeTruthy(); + expect(fileSizeLimit.length).toBeGreaterThan(0); + }); + + test('Given Import Note modal is open, When clicking close button, Then modal should close', async () => { + await noteImportModal.close(); + await expect(noteImportModal.modal).not.toBeVisible(); + }); + + test('Given URL tab is selected, When entering invalid URL and clicking import, Then error should be displayed', async () => { + await noteImportModal.switchToUrlTab(); + await noteImportModal.setImportUrl('invalid-url'); + await noteImportModal.clickImportNote(); + + await expect(noteImportModal.errorAlert).toBeVisible(); + const errorMessage = await noteImportModal.getErrorMessage(); + expect(errorMessage).toBeTruthy(); + }); +}); diff --git a/zeppelin-web-angular/e2e/utils.ts b/zeppelin-web-angular/e2e/utils.ts index dab04a13256..d4fd455a972 100644 --- a/zeppelin-web-angular/e2e/utils.ts +++ b/zeppelin-web-angular/e2e/utils.ts @@ -215,7 +215,7 @@ export const waitForZeppelinReady = async (page: Page): Promise => { if (isOnLoginPage) { console.log('On login page - checking if authentication is enabled'); - // If we're on login dlpage, this is expected when authentication is required + // 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( () => {