From 076676aa87f92b052619691c2788bd53771a266e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?YONGJAE=20LEE=28=EC=9D=B4=EC=9A=A9=EC=9E=AC=29?= Date: Sat, 4 Apr 2026 21:04:45 +0900 Subject: [PATCH 1/2] [ZEPPELIN-6358] Remove anti-patterns of E2E and tidy test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What is this PR for? Applied the [`e2e-reviewer`](https://github.com/dididy/e2e-skills) skill on the existing E2E suite. The skill does static analysis — it catches tests that can never actually fail, silent skips, swallowed errors in POM methods, that kind of thing. Findings and fixes: - `home-page-enhanced-functionality.spec.ts` was mostly duplicating `home-page-elements` and `home-page-note-operations` → deleted and merged - `toBeGreaterThanOrEqual(0)` and `toBeAttached()` on static elements were always passing → replaced with assertions that can fail - `if (isVisible) { expect() }` patterns silently skip when something breaks → removed or converted to `test.skip` - Several POM methods had `.catch(() => {})` with no comment → removed; kept the intentional ones and marked with `// JUSTIFIED:` - `document.querySelector` in `page.evaluate()` → swapped for Playwright locator API - Added `aria-label` / `data-testid` to action bar HTML; a few tests were breaking on DOM structure changes - Renamed a handful of tests whose names didn't match what they actually tested; dropped the ones that only called `toBeVisible()` ### What type of PR is it? Improvement Refactoring ### Todos ### What is the Jira issue? ZEPPELIN-6358 ### How should this be tested? ### Screenshots (if appropriate) ### Questions: * Does the license files need to update? No * Is there breaking changes for older versions? No * Does this needs documentation? No Closes #5180 from dididy/tidy-e2e. Signed-off-by: Jongyoul Lee --- .../e2e/models/about-zeppelin-modal.ts | 4 - zeppelin-web-angular/e2e/models/base-page.ts | 34 +- zeppelin-web-angular/e2e/models/home-page.ts | 70 ++-- .../e2e/models/node-list-page.ts | 14 +- .../e2e/models/note-create-modal.ts | 4 - .../e2e/models/note-import-modal.ts | 22 -- .../e2e/models/notebook-repo-item.util.ts | 6 +- .../e2e/models/notebook-repos-page.ts | 36 +- .../e2e/models/notebook.util.ts | 6 +- .../e2e/models/workspace-page.ts | 23 -- .../e2e/models/workspace-page.util.ts | 60 --- zeppelin-web-angular/e2e/tests/app.spec.ts | 128 ++----- .../anonymous-login-redirect.spec.ts | 117 +++--- .../e2e/tests/home/home-page-elements.spec.ts | 55 +-- .../home-page-enhanced-functionality.spec.ts | 92 ----- .../home/home-page-external-links.spec.ts | 46 +-- .../e2e/tests/home/home-page-layout.spec.ts | 22 +- .../home/home-page-note-operations.spec.ts | 357 +++++++++--------- .../home/home-page-notebook-actions.spec.ts | 57 +-- .../published/published-paragraph.spec.ts | 105 +++--- .../about-zeppelin-modal.spec.ts | 18 +- .../node-list/node-list-functionality.spec.ts | 36 +- .../note-create/note-create-modal.spec.ts | 21 +- .../note-import/note-import-modal.spec.ts | 18 +- .../e2e/tests/theme/dark-mode.spec.ts | 12 +- .../notebook-repo-item-display.spec.ts | 13 +- .../notebook-repo-item-edit.spec.ts | 36 +- ...notebook-repo-item-form-validation.spec.ts | 34 +- .../notebook-repo-item-settings.spec.ts | 84 ++--- .../notebook-repo-item-workflow.spec.ts | 22 +- .../notebook-repos-page-structure.spec.ts | 16 +- .../tests/workspace/workspace-main.spec.ts | 48 +-- zeppelin-web-angular/e2e/utils.ts | 12 +- zeppelin-web-angular/playwright.config.js | 2 +- .../projects/zeppelin-react/package-lock.json | 6 +- .../action-bar/action-bar.component.html | 2 +- 36 files changed, 549 insertions(+), 1089 deletions(-) delete mode 100644 zeppelin-web-angular/e2e/models/workspace-page.ts delete mode 100644 zeppelin-web-angular/e2e/models/workspace-page.util.ts delete mode 100644 zeppelin-web-angular/e2e/tests/home/home-page-enhanced-functionality.spec.ts diff --git a/zeppelin-web-angular/e2e/models/about-zeppelin-modal.ts b/zeppelin-web-angular/e2e/models/about-zeppelin-modal.ts index d5a44add770..a61632d3e6f 100644 --- a/zeppelin-web-angular/e2e/models/about-zeppelin-modal.ts +++ b/zeppelin-web-angular/e2e/models/about-zeppelin-modal.ts @@ -43,10 +43,6 @@ export class AboutZeppelinModal extends BasePage { return (await this.versionText.textContent()) || ''; } - async isLogoVisible(): Promise { - return this.logo.isVisible(); - } - async getGetInvolvedHref(): Promise { return this.getInvolvedLink.getAttribute('href'); } diff --git a/zeppelin-web-angular/e2e/models/base-page.ts b/zeppelin-web-angular/e2e/models/base-page.ts index 539f096cbb8..aa37f1a1384 100644 --- a/zeppelin-web-angular/e2e/models/base-page.ts +++ b/zeppelin-web-angular/e2e/models/base-page.ts @@ -24,8 +24,6 @@ export class BasePage { readonly zeppelinHeader: Locator; readonly modalTitle: Locator; - readonly modalBody: Locator; - readonly modalContent: Locator; readonly okButton: Locator; readonly cancelButton: Locator; @@ -41,8 +39,6 @@ export class BasePage { this.zeppelinHeader = page.locator('zeppelin-header'); this.modalTitle = page.locator('.ant-modal-confirm-title, .ant-modal-title'); - this.modalBody = page.locator('.ant-modal-confirm-content, .ant-modal-body'); - this.modalContent = page.locator('.ant-modal-body'); this.okButton = page.locator('button:has-text("OK")'); this.cancelButton = page.locator('button:has-text("Cancel")'); @@ -71,11 +67,6 @@ export class BasePage { await this.navigateToRoute('/'); } - getCurrentPath(): string { - const url = new URL(this.page.url()); - return url.hash || url.pathname; - } - async waitForUrlNotContaining(fragment: string): Promise { await this.page.waitForURL(url => !url.toString().includes(fragment)); } @@ -85,14 +76,8 @@ export class BasePage { } async waitForFormLabels(labelTexts: string[], timeout = 10000): Promise { - await this.page.waitForFunction( - texts => { - const labels = Array.from(document.querySelectorAll('nz-form-label')); - return texts.some(text => labels.some(l => l.textContent?.includes(text))); - }, - labelTexts, - { timeout } - ); + const locators = labelTexts.map(text => this.page.locator('nz-form-label', { hasText: text })); + await Promise.race(locators.map(l => l.waitFor({ state: 'attached', timeout }))); } async waitForElementAttribute( @@ -109,25 +94,20 @@ export class BasePage { } } - async waitForRouterOutletChild(timeout = 10000): Promise { - await expect(this.page.locator('zeppelin-workspace router-outlet + *')).toHaveCount(1, { timeout }); - } - async fillAndVerifyInput( locator: Locator, value: string, options?: { timeout?: number; clearFirst?: boolean } ): Promise { - const { timeout = 10000, clearFirst = true } = options || {}; + const { timeout = 10000 } = options || {}; await expect(locator).toBeVisible({ timeout }); await expect(locator).toBeEnabled({ timeout: 5000 }); - if (clearFirst) { - await locator.clear(); - } - + // 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); + await expect(locator).toHaveValue(value, { timeout: 10000 }); } } diff --git a/zeppelin-web-angular/e2e/models/home-page.ts b/zeppelin-web-angular/e2e/models/home-page.ts index 81f5085790e..3222fc3964a 100644 --- a/zeppelin-web-angular/e2e/models/home-page.ts +++ b/zeppelin-web-angular/e2e/models/home-page.ts @@ -17,8 +17,6 @@ export class HomePage extends BasePage { readonly notebookSection: Locator; readonly helpSection: Locator; readonly communitySection: Locator; - readonly zeppelinLogo: Locator; - readonly anonymousUserIndicator: Locator; readonly welcomeSection: Locator; readonly moreInfoGrid: Locator; readonly notebookColumn: Locator; @@ -28,9 +26,6 @@ export class HomePage extends BasePage { readonly notebookHeading: Locator; readonly helpHeading: Locator; readonly communityHeading: Locator; - readonly createNoteModal: Locator; - readonly createNoteButton: Locator; - readonly notebookNameInput: Locator; readonly externalLinks: { documentation: Locator; mailingList: Locator; @@ -41,13 +36,12 @@ export class HomePage extends BasePage { createNewNoteLink: Locator; importNoteLink: Locator; filterInput: Locator; - tree: Locator; - noteActions: { - renameNote: Locator; - clearOutput: Locator; - moveToTrash: Locator; - }; }; + readonly anonymousUserIndicator: Locator; + private readonly zeppelinLogo: Locator; + private readonly createNoteModal: Locator; + private readonly createNoteButton: Locator; + private readonly notebookNameInput: Locator; constructor(page: Page) { super(page); @@ -58,8 +52,8 @@ export class HomePage extends BasePage { this.anonymousUserIndicator = page.locator('text=anonymous'); this.welcomeSection = page.locator('.welcome'); this.moreInfoGrid = page.locator('.more-info'); - this.notebookColumn = page.locator('[nz-col]').first(); - this.helpCommunityColumn = page.locator('[nz-col]').last(); + this.notebookColumn = page.locator('[nz-col]').first(); // first() — left column contains the Notebook section + this.helpCommunityColumn = page.locator('[nz-col]').last(); // last() — right column contains Help and Community sections this.welcomeDescription = page.locator('.welcome').getByText('Zeppelin is web-based notebook'); this.refreshNoteButton = page.locator('a.refresh-note'); this.notebookHeading = this.notebookColumn.locator('h3'); @@ -79,13 +73,7 @@ export class HomePage extends BasePage { this.nodeList = { createNewNoteLink: page.locator('zeppelin-node-list a').filter({ hasText: 'Create new Note' }), importNoteLink: page.locator('zeppelin-node-list a').filter({ hasText: 'Import Note' }), - filterInput: page.locator('zeppelin-node-list input[placeholder*="Filter"]'), - tree: page.locator('zeppelin-node-list nz-tree'), - noteActions: { - renameNote: page.locator('.file .operation a[nztooltiptitle*="Rename note"]'), - clearOutput: page.locator('.file .operation a[nztooltiptitle*="Clear output"]'), - moveToTrash: page.locator('.file .operation a[nztooltiptitle*="Move note to Trash"]') - } + filterInput: page.locator('zeppelin-node-list input[placeholder*="Filter"]') }; } @@ -100,14 +88,6 @@ export class HomePage extends BasePage { await this.waitForUrlNotContaining('#/login'); } - async isHomeContentDisplayed(): Promise { - return this.welcomeTitle.isVisible(); - } - - async isAnonymousUser(): Promise { - return this.anonymousUserIndicator.isVisible(); - } - async clickZeppelinLogo(): Promise { await this.zeppelinLogo.click({ timeout: 15000 }); } @@ -117,19 +97,11 @@ export class HomePage extends BasePage { return text || ''; } - async getWelcomeDescriptionText(): Promise { - const text = await this.welcomeDescription.textContent(); - return text || ''; - } - async clickRefreshNotes(): Promise { + await this.refreshNoteButton.waitFor({ state: 'visible', timeout: 10000 }); await this.refreshNoteButton.click({ timeout: 15000 }); } - async isNotebookListVisible(): Promise { - return this.zeppelinNodeList.isVisible(); - } - async clickCreateNewNote(): Promise { await this.nodeList.createNewNoteLink.click({ timeout: 15000 }); await this.createNoteModal.waitFor({ state: 'visible' }); @@ -141,7 +113,7 @@ export class HomePage extends BasePage { // Wait for the modal form to be fully rendered with proper labels await this.page.waitForSelector('nz-form-label', { timeout: 10000 }); - await this.waitForFormLabels(['Note Name', 'Clone Note']); + await this.waitForFormLabels(['Note Name']); // Fill and verify the notebook name input await this.fillAndVerifyInput(this.notebookNameInput, notebookName); @@ -149,7 +121,9 @@ export class HomePage extends BasePage { // Click the 'Create' button in the modal await expect(this.createNoteButton).toBeEnabled({ timeout: 5000 }); await this.createNoteButton.click({ timeout: 15000 }); - await this.waitForPageLoad(); + // 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 }); } async clickImportNote(): Promise { @@ -159,19 +133,17 @@ export class HomePage extends BasePage { async filterNotes(searchTerm: string): Promise { await this.page.waitForLoadState('domcontentloaded', { timeout: 10000 }); await this.nodeList.filterInput.waitFor({ state: 'visible', timeout: 5000 }); - await this.nodeList.filterInput.fill(searchTerm, { timeout: 15000 }); - } - - async isRefreshIconSpinning(): Promise { - const spinAttribute = await this.refreshIcon.getAttribute('nzSpin'); - return spinAttribute === 'true' || spinAttribute === ''; + // pressSequentially fires real key events so Angular's ngModel detects the change (fill() does not). + // Triple-click to select all, then type to replace or Backspace to clear. + await this.nodeList.filterInput.click({ clickCount: 3 }); + if (searchTerm) { + await this.nodeList.filterInput.pressSequentially(searchTerm); + } else { + await this.nodeList.filterInput.press('Backspace'); + } } async waitForRefreshToComplete(): Promise { await this.waitForElementAttribute('a.refresh-note i[nz-icon]', 'nzSpin', false); } - - async getDocumentationLinkHref(): Promise { - return this.externalLinks.documentation.getAttribute('href'); - } } diff --git a/zeppelin-web-angular/e2e/models/node-list-page.ts b/zeppelin-web-angular/e2e/models/node-list-page.ts index 17bd93de33d..9c81bda02f4 100644 --- a/zeppelin-web-angular/e2e/models/node-list-page.ts +++ b/zeppelin-web-angular/e2e/models/node-list-page.ts @@ -41,11 +41,7 @@ export class NodeListPage extends BasePage { await this.createNewNoteButton.click(); } - getFolderByName(folderName: string): Locator { - return this.page.locator('nz-tree-node').filter({ hasText: folderName }).first(); - } - - getNoteByName(noteName: string): Locator { + private getNoteByName(noteName: string): Locator { return this.page.locator('nz-tree-node').filter({ hasText: noteName }).first(); } @@ -56,14 +52,6 @@ export class NodeListPage extends BasePage { 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[] = []; diff --git a/zeppelin-web-angular/e2e/models/note-create-modal.ts b/zeppelin-web-angular/e2e/models/note-create-modal.ts index 1e1a0c4808d..a00ef19219f 100644 --- a/zeppelin-web-angular/e2e/models/note-create-modal.ts +++ b/zeppelin-web-angular/e2e/models/note-create-modal.ts @@ -47,8 +47,4 @@ export class NoteCreateModal extends BasePage { async clickCreate(): Promise { await this.createButton.click(); } - - async isFolderInfoVisible(): Promise { - return this.folderInfoAlert.isVisible(); - } } diff --git a/zeppelin-web-angular/e2e/models/note-import-modal.ts b/zeppelin-web-angular/e2e/models/note-import-modal.ts index 11db6d5da41..e4634f94bc8 100644 --- a/zeppelin-web-angular/e2e/models/note-import-modal.ts +++ b/zeppelin-web-angular/e2e/models/note-import-modal.ts @@ -59,16 +59,6 @@ export class NoteImportModal extends BasePage { 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); } @@ -77,19 +67,7 @@ export class NoteImportModal extends BasePage { 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/models/notebook-repo-item.util.ts b/zeppelin-web-angular/e2e/models/notebook-repo-item.util.ts index 06cdab7ed2c..333af7c4171 100644 --- a/zeppelin-web-angular/e2e/models/notebook-repo-item.util.ts +++ b/zeppelin-web-angular/e2e/models/notebook-repo-item.util.ts @@ -24,14 +24,12 @@ export class NotebookRepoItemUtil extends BasePage { async verifyDisplayMode(): Promise { await expect(this.repoItemPage.editButton).toBeVisible(); - const isEditMode = await this.repoItemPage.isEditMode(); - expect(isEditMode).toBe(false); + await expect(this.repoItemPage.repositoryCard).not.toHaveClass(/\bedit\b/); } async verifyEditMode(): Promise { await expect(this.repoItemPage.saveButton).toBeVisible(); await expect(this.repoItemPage.cancelButton).toBeVisible(); - const isEditMode = await this.repoItemPage.isEditMode(); - expect(isEditMode).toBe(true); + await expect(this.repoItemPage.repositoryCard).toHaveClass(/\bedit\b/); } } diff --git a/zeppelin-web-angular/e2e/models/notebook-repos-page.ts b/zeppelin-web-angular/e2e/models/notebook-repos-page.ts index 3272df477de..66234f5b61c 100644 --- a/zeppelin-web-angular/e2e/models/notebook-repos-page.ts +++ b/zeppelin-web-angular/e2e/models/notebook-repos-page.ts @@ -34,10 +34,6 @@ export class NotebookReposPage extends BasePage { this.page.waitForSelector('zeppelin-notebook-repo-item', { state: 'visible' }) ]); } - - async getRepositoryItemCount(): Promise { - return await this.repositoryItems.count(); - } } export class NotebookRepoItemPage extends BasePage { @@ -72,20 +68,6 @@ export class NotebookRepoItemPage extends BasePage { await this.cancelButton.click({ timeout: 15000 }); } - async isEditMode(): Promise { - return await this.repositoryCard.evaluate(el => el.classList.contains('edit')); - } - - async isSaveButtonEnabled(): Promise { - return await this.saveButton.isEnabled(); - } - - async getSettingValue(settingName: string): Promise { - const row = this.repositoryCard.locator('tbody tr').filter({ hasText: settingName }); - const valueCell = row.locator('td').nth(1); - return (await valueCell.textContent()) || ''; - } - async fillSettingInput(settingName: string, value: string): Promise { const row = this.repositoryCard.locator('tbody tr').filter({ hasText: settingName }); const input = row.locator('input[nz-input]'); @@ -93,29 +75,15 @@ export class NotebookRepoItemPage extends BasePage { await input.fill(value); } - async selectSettingDropdown(settingName: string, optionValue: string): Promise { - const row = this.repositoryCard.locator('tbody tr').filter({ hasText: settingName }); - const select = row.locator('nz-select'); - await select.click({ timeout: 15000 }); - await this.page.locator(`nz-option[nzvalue="${optionValue}"]`).click({ timeout: 15000 }); - } - async getSettingInputValue(settingName: string): Promise { const row = this.repositoryCard.locator('tbody tr').filter({ hasText: settingName }); const input = row.locator('input[nz-input]'); return await input.inputValue(); } - async isInputVisible(settingName: string): Promise { - const row = this.repositoryCard.locator('tbody tr').filter({ hasText: settingName }); - const input = row.locator('input[nz-input]'); - return await input.isVisible(); - } - - async isDropdownVisible(settingName: string): Promise { + async getSettingValue(settingName: string): Promise { const row = this.repositoryCard.locator('tbody tr').filter({ hasText: settingName }); - const select = row.locator('nz-select'); - return await select.isVisible(); + return (await row.locator('td').nth(1).textContent()) || ''; } async getSettingCount(): Promise { diff --git a/zeppelin-web-angular/e2e/models/notebook.util.ts b/zeppelin-web-angular/e2e/models/notebook.util.ts index 00e8dbc1831..98ee1d9648d 100644 --- a/zeppelin-web-angular/e2e/models/notebook.util.ts +++ b/zeppelin-web-angular/e2e/models/notebook.util.ts @@ -33,10 +33,8 @@ export class NotebookUtil extends BasePage { await waitForZeppelinReady(this.page); // Wait for URL to not contain 'login' and for the notebook list to appear - await this.page.waitForFunction( - () => !window.location.href.includes('#/login') && document.querySelector('zeppelin-node-list') !== null, - { timeout: 30000 } - ); + await this.page.waitForURL(url => !url.toString().includes('#/login'), { timeout: 30000 }); + await this.page.locator('zeppelin-node-list').waitFor({ state: 'attached', timeout: 30000 }); await expect(this.homePage.zeppelinNodeList).toBeVisible({ timeout: 90000 }); await this.homePage.createNote(notebookName); diff --git a/zeppelin-web-angular/e2e/models/workspace-page.ts b/zeppelin-web-angular/e2e/models/workspace-page.ts deleted file mode 100644 index 1fdcf9e5a78..00000000000 --- a/zeppelin-web-angular/e2e/models/workspace-page.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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 WorkspacePage extends BasePage { - readonly routerOutlet: Locator; - - constructor(page: Page) { - super(page); - this.routerOutlet = page.locator('zeppelin-workspace router-outlet'); - } -} diff --git a/zeppelin-web-angular/e2e/models/workspace-page.util.ts b/zeppelin-web-angular/e2e/models/workspace-page.util.ts deleted file mode 100644 index 8ed557b66de..00000000000 --- a/zeppelin-web-angular/e2e/models/workspace-page.util.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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 { BasePage } from './base-page'; -import { WorkspacePage } from './workspace-page'; -import { performLoginIfRequired, waitForZeppelinReady } from '../utils'; - -export class WorkspaceUtil extends BasePage { - private workspacePage: WorkspacePage; - - constructor(page: Page) { - super(page); - this.workspacePage = new WorkspacePage(page); - } - - async navigateAndWaitForLoad(): Promise { - await this.workspacePage.navigateToWorkspace(); - await performLoginIfRequired(this.page); - await waitForZeppelinReady(this.page); - } - - async verifyWorkspaceLayout(): Promise { - await expect(this.workspacePage.workspaceComponent).toBeVisible(); - await expect(this.workspacePage.routerOutlet).toBeAttached(); - } - - async verifyHeaderVisibility(shouldBeVisible: boolean): Promise { - if (shouldBeVisible) { - await expect(this.workspacePage.zeppelinHeader).toBeVisible(); - } else { - await expect(this.workspacePage.zeppelinHeader).toBeHidden(); - } - } - - async verifyRouterOutletActivation(): Promise { - await expect(this.workspacePage.routerOutlet).toBeAttached(); - await this.waitForRouterOutletChild(); - } - - async waitForComponentActivation(): Promise { - await this.page.waitForFunction( - () => { - const workspace = document.querySelector('zeppelin-workspace'); - const content = workspace?.querySelector('.content'); - return content && content.children.length > 1; - }, - { timeout: 15000 } - ); - } -} diff --git a/zeppelin-web-angular/e2e/tests/app.spec.ts b/zeppelin-web-angular/e2e/tests/app.spec.ts index 5d956c747f2..d637896e0b2 100644 --- a/zeppelin-web-angular/e2e/tests/app.spec.ts +++ b/zeppelin-web-angular/e2e/tests/app.spec.ts @@ -29,64 +29,47 @@ test.describe('Zeppelin App Component', () => { test('should have correct component selector and structure', async ({ page }) => { await basePage.waitForPageLoad(); - // Test zeppelin-root selector - const zeppelinRoot = page.locator('zeppelin-root'); - await expect(zeppelinRoot).toBeAttached(); - await waitForZeppelinReady(page); // Verify router-outlet is inside zeppelin-root (use first to avoid multiple elements) - const routerOutlet = zeppelinRoot.locator('router-outlet').first(); - await expect(routerOutlet).toBeAttached(); + const zeppelinRoot = page.locator('zeppelin-root'); + + // Verify routing has activated by checking that actual content is rendered inside the workspace + await expect(zeppelinRoot.locator('zeppelin-workspace')).toBeVisible(); // Check for loading spinner const loadingSpinner = zeppelinRoot.locator('zeppelin-spin').filter({ hasText: 'Getting Ticket Data' }); const logoutSpinner = zeppelinRoot.locator('zeppelin-spin').filter({ hasText: 'Logging out' }); - // Loading spinner should exist, logout spinner may or may not exist depending on conditions - const loadingSpinnerCount = await loadingSpinner.count(); - const logoutSpinnerCount = await logoutSpinner.count(); - - expect(loadingSpinnerCount).toBeGreaterThanOrEqual(0); - expect(logoutSpinnerCount).toBeGreaterThanOrEqual(0); + // After waitForZeppelinReady, both spinners must be gone + await expect(loadingSpinner).toHaveCount(0); + await expect(logoutSpinner).toHaveCount(0); }); test('should have proper page title', async ({ page }) => { await expect(page).toHaveTitle(/Zeppelin/); }); - test('should display workspace after loading', async ({ page }) => { + test('should display home content after loading', async ({ page }) => { await waitForZeppelinReady(page); // After the `beforeEach` hook, which handles login, the workspace should be visible. await expect(basePage.zeppelinWorkspace).toBeVisible(); + // Verify the home page content is rendered (not just a blank shell) + await expect(basePage.zeppelinWorkspace.locator('zeppelin-home')).toBeVisible(); }); - test('should handle navigation events correctly', async ({ page }) => { + test('should hide loading spinner after navigation', async ({ page }) => { await waitForZeppelinReady(page); - // Test navigation back to root path - try { - await page.goto('/', { waitUntil: 'load', timeout: 10000 }); - - // Check if loading spinner appears during navigation - const loadingSpinner = page.locator('zeppelin-spin').filter({ hasText: 'Getting Ticket Data' }); - - // Loading might be very fast, so we check if it exists - const spinnerCount = await loadingSpinner.count(); - expect(spinnerCount).toBeGreaterThanOrEqual(0); - - await waitForZeppelinReady(page); + await page.goto('/', { waitUntil: 'load', timeout: 10000 }); + await waitForZeppelinReady(page); - // After ready, loading should be hidden if it was visible - if (await loadingSpinner.isVisible()) { - await expect(loadingSpinner).toBeHidden(); - } - } catch (error) { - console.log('Navigation test skipped due to timeout:', error); - } + // After the app is ready, the loading spinner must be hidden + const loadingSpinner = page.locator('zeppelin-spin').filter({ hasText: 'Getting Ticket Data' }); + await expect(loadingSpinner).toBeHidden(); }); - test('should properly manage loading state observable', async ({ page }) => { + test('should hide loading spinner after page reload', async ({ page }) => { await basePage.waitForPageLoad(); // Test that loading$ observable works correctly @@ -95,55 +78,44 @@ test.describe('Zeppelin App Component', () => { // Reload page to trigger loading state await page.reload({ waitUntil: 'load' }); - // Check loading state during page load - const initialLoadingVisible = await loadingSpinner.isVisible(); - - if (initialLoadingVisible) { - await expect(loadingSpinner).toBeVisible(); - await expect(loadingSpinner).toContainText('Getting Ticket Data ...'); - } + // If the spinner is briefly visible during reload, it will resolve; just wait for ready // Wait for loading to complete await waitForZeppelinReady(page); await expect(loadingSpinner).toBeHidden(); }); - test('should handle logout observable correctly', async ({ page }) => { + test('should show logout spinner when logging out', async ({ page }) => { await waitForZeppelinReady(page); + // Only test logout flow for authenticated (non-anonymous) users — skip before any assertions + const statusElement = page.locator('.status'); + await expect(statusElement).toBeVisible(); + 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(); - // Check if we have a logout mechanism available - const statusElement = page.locator('.status'); - if (await statusElement.isVisible()) { - const statusText = await statusElement.textContent(); - - if (statusText && !statusText.includes('anonymous')) { - // If not anonymous user, test logout spinner - await statusElement.click(); - const logoutButton = page.getByRole('link', { name: 'Logout' }); - - if (await logoutButton.isVisible()) { - await logoutButton.click(); - - // Logout spinner should appear - await expect(logoutSpinner).toBeVisible(); - await expect(logoutSpinner).toContainText('Logging out ...'); - } - } - } + 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 ...'); }); test('should maintain component integrity during navigation', async ({ page }) => { await waitForZeppelinReady(page); await performLoginIfRequired(page); - const zeppelinRoot = page.locator('zeppelin-root'); - const routerOutlet = zeppelinRoot.locator('router-outlet').first(); - // Navigate to different pages and ensure component remains intact const testPaths = ['/#/notebook', '/#/jobmanager', '/#/configuration']; @@ -151,36 +123,12 @@ test.describe('Zeppelin App Component', () => { await page.goto(path, { waitUntil: 'load', timeout: 10000 }); await waitForZeppelinReady(page); - // Component should still be attached - await expect(zeppelinRoot).toBeAttached(); - - // Router outlet should still be present - await expect(routerOutlet).toBeAttached(); + // Workspace must render visible content after each navigation (confirms Angular didn't unmount the root component) + await expect(page.locator('zeppelin-workspace')).toBeVisible(); } // Return to home await page.goto('/', { waitUntil: 'load' }); await waitForZeppelinReady(page); - await expect(zeppelinRoot).toBeAttached(); - }); - - test('should verify spinner text content and visibility', async ({ page }) => { - await basePage.waitForPageLoad(); - - // Check exact text content of spinners - const loadingSpinner = page.locator('zeppelin-spin').filter({ hasText: 'Getting Ticket Data' }); - const logoutSpinner = page.locator('zeppelin-spin').filter({ hasText: 'Logging out' }); - - // Verify spinner elements exist - expect(await loadingSpinner.count()).toBeGreaterThanOrEqual(0); - expect(await logoutSpinner.count()).toBeGreaterThanOrEqual(0); - - // If loading spinner is visible, check its exact text - if (await loadingSpinner.isVisible()) { - await expect(loadingSpinner).toHaveText('Getting Ticket Data ...'); - } - - // Logout spinner should not be visible initially - await expect(logoutSpinner).toBeHidden(); }); }); diff --git a/zeppelin-web-angular/e2e/tests/authentication/anonymous-login-redirect.spec.ts b/zeppelin-web-angular/e2e/tests/authentication/anonymous-login-redirect.spec.ts index 5e73dc036d4..3189863e138 100644 --- a/zeppelin-web-angular/e2e/tests/authentication/anonymous-login-redirect.spec.ts +++ b/zeppelin-web-angular/e2e/tests/authentication/anonymous-login-redirect.spec.ts @@ -54,27 +54,35 @@ test.describe('Anonymous User Login Redirect', () => { const currentPath = getCurrentPath(page); const isLoginUrlMaintained = currentPath.includes('#/login'); - const isHomeContentDisplayed = await homePage.isHomeContentDisplayed(); - const isAnonymousUser = await homePage.isAnonymousUser(); expect(isLoginUrlMaintained).toBe(false); - expect(isHomeContentDisplayed).toBe(true); - expect(isAnonymousUser).toBe(true); + await expect(homePage.welcomeTitle).toBeVisible(); + await expect(homePage.anonymousUserIndicator).toBeVisible(); expect(currentPath).toContain('#/'); expect(currentPath).not.toContain('#/login'); }); - test('When accessing login page directly, Then should display all home page elements correctly', async ({ + test('When accessing login page directly, Then should display full home page with all sections and links', async ({ page }) => { await page.goto('/#/login'); await waitForZeppelinReady(page); await page.waitForURL(url => !url.toString().includes('#/login')); + // Sections await expect(homePage.welcomeTitle).toBeVisible(); await expect(homePage.notebookSection).toBeVisible(); await expect(homePage.helpSection).toBeVisible(); await expect(homePage.communitySection).toBeVisible(); + // Notebook actions + await expect(homePage.nodeList.createNewNoteLink).toBeVisible(); + await expect(homePage.nodeList.importNoteLink).toBeVisible(); + await expect(homePage.nodeList.filterInput).toBeVisible(); + // External links + await expect(homePage.externalLinks.documentation).toBeVisible(); + await expect(homePage.externalLinks.mailingList).toBeVisible(); + await expect(homePage.externalLinks.issuesTracking).toBeVisible(); + await expect(homePage.externalLinks.github).toBeVisible(); }); test('When clicking Zeppelin logo after redirect, Then should maintain home URL and content', async ({ page }) => { @@ -86,12 +94,11 @@ test.describe('Anonymous User Login Redirect', () => { await homePage.clickZeppelinLogo(); await basePage.waitForPageLoad(); const pathAfterClick = getCurrentPath(page); - const homeContentMaintained = await homePage.isHomeContentDisplayed(); expect(pathBeforeClick).toContain('#/'); expect(pathBeforeClick).not.toContain('#/login'); expect(pathAfterClick).toContain('#/'); - expect(homeContentMaintained).toBe(true); + await expect(homePage.welcomeTitle).toBeVisible(); }); test('When accessing login page, Then should redirect and maintain anonymous user state', async ({ page }) => { @@ -100,89 +107,75 @@ test.describe('Anonymous User Login Redirect', () => { await page.waitForURL(url => !url.toString().includes('#/login')); const basicMetadata = await getBasicPageMetadata(page); - const isAnonymous = await homePage.isAnonymousUser(); expect(basicMetadata.title).toContain('Zeppelin'); expect(basicMetadata.path).toContain('#/'); expect(basicMetadata.path).not.toContain('#/login'); - expect(isAnonymous).toBe(true); + await expect(homePage.anonymousUserIndicator).toBeVisible(); }); - test('When accessing login page, Then should display welcome heading and main sections', async ({ page }) => { - await page.goto('/#/login'); + test('When navigating between home and login URLs, Then should maintain consistent user experience', async ({ + page + }) => { + await page.goto('/#/'); await waitForZeppelinReady(page); - await page.waitForURL(url => !url.toString().includes('#/login')); - await expect(basePage.welcomeTitle).toBeVisible(); - await expect(page.locator('text=Notebook').first()).toBeVisible(); - await expect(page.locator('text=Help').first()).toBeVisible(); - await expect(page.locator('text=Community').first()).toBeVisible(); - }); + const homeMetadata = await getBasicPageMetadata(page); + expect(homeMetadata.path).toContain('#/'); + await expect(homePage.anonymousUserIndicator).toBeVisible(); - test('When accessing login page, Then should display notebook functionalities', async ({ page }) => { await page.goto('/#/login'); await waitForZeppelinReady(page); await page.waitForURL(url => !url.toString().includes('#/login')); - await expect(page.locator('text=Create new Note')).toBeVisible(); - await expect(page.locator('text=Import Note')).toBeVisible(); + const loginMetadata = await getBasicPageMetadata(page); + expect(loginMetadata.path).toContain('#/'); + expect(loginMetadata.path).not.toContain('#/login'); + await expect(homePage.anonymousUserIndicator).toBeVisible(); - const filterInput = page.locator('input[placeholder*="Filter"]'); - if ((await filterInput.count()) > 0) { - await expect(filterInput).toBeVisible(); - } + await homePage.navigateToLogin(); + await expect(homePage.welcomeTitle).toBeVisible(); }); - test('When accessing login page, Then should display external links in help and community sections', async ({ + test('When accessing protected route directly, Then should load home content for anonymous user', async ({ page }) => { - await page.goto('/#/login'); + // Notebook-repos is a management route; anonymous users should either access it or be redirected home + await page.goto('/#/notebook-repos'); await waitForZeppelinReady(page); - await page.waitForURL(url => !url.toString().includes('#/login')); - const docLinks = page.locator('a[href*="zeppelin.apache.org/docs"]'); - const communityLinks = page.locator('a[href*="community.html"]'); - const issuesLinks = page.locator('a[href*="issues.apache.org"]'); - const githubLinks = page.locator('a[href*="github.com/apache/zeppelin"]'); + // Then: Either the notebook-repos page loads (anonymous mode allows it) OR + // the user is redirected back to home — both are valid; the app must not crash or show an empty shell + const currentPath = getCurrentPath(page); - if ((await docLinks.count()) > 0) { - await expect(docLinks).toBeVisible(); - } - if ((await communityLinks.count()) > 0) { - await expect(communityLinks).toBeVisible(); - } - if ((await issuesLinks.count()) > 0) { - await expect(issuesLinks).toBeVisible(); - } - if ((await githubLinks.count()) > 0) { - await expect(githubLinks).toBeVisible(); + await expect(homePage.anonymousUserIndicator).toBeVisible(); + // The app root must still be rendering — not a blank white page + await expect(basePage.zeppelinWorkspace).toBeVisible(); + // If redirected, must land on home (not an error page) + if (!currentPath.includes('#/notebook-repos')) { + // JUSTIFIED: both states are valid — notebook-repos accessible OR redirect to home; only assert welcomeTitle on redirect path + await expect(basePage.welcomeTitle).toBeVisible(); } }); - test('When navigating between home and login URLs, Then should maintain consistent user experience', async ({ + test('When accessing configuration route directly, Then should handle navigation for anonymous user', async ({ page }) => { - await page.goto('/#/'); + // Configuration is a management route; anonymous users should either access it or be redirected home + await page.goto('/#/configuration'); await waitForZeppelinReady(page); - const homeMetadata = await getBasicPageMetadata(page); - const isHomeAnonymous = await homePage.isAnonymousUser(); - expect(homeMetadata.path).toContain('#/'); - expect(isHomeAnonymous).toBe(true); - - await page.goto('/#/login'); - await waitForZeppelinReady(page); - await page.waitForURL(url => !url.toString().includes('#/login')); - - const loginMetadata = await getBasicPageMetadata(page); - const isLoginAnonymous = await homePage.isAnonymousUser(); - expect(loginMetadata.path).toContain('#/'); - expect(loginMetadata.path).not.toContain('#/login'); - expect(isLoginAnonymous).toBe(true); + // Then: Either the configuration page loads (anonymous mode allows it) OR + // the user is redirected back to home — both are valid; the app must not crash + const currentPath = getCurrentPath(page); - await homePage.navigateToLogin(); - const isHomeContentDisplayed = await homePage.isHomeContentDisplayed(); - expect(isHomeContentDisplayed).toBe(true); + await expect(homePage.anonymousUserIndicator).toBeVisible(); + await expect(basePage.zeppelinWorkspace).toBeVisible(); + if (!currentPath.includes('#/configuration')) { + // JUSTIFIED: both states are valid — in anonymous mode (no shiro.ini) all routes including + // /configuration are accessible; shiro.ini url rules control whether this route is restricted + await expect(basePage.welcomeTitle).toBeVisible({ timeout: 15000 }); + } }); test('When multiple page loads occur on login URL, Then should consistently redirect to home', async ({ page }) => { @@ -192,7 +185,7 @@ test.describe('Anonymous User Login Redirect', () => { await waitForUrlNotContaining(page, '#/login'); await expect(basePage.welcomeTitle).toBeVisible(); - await expect(page.locator('text=anonymous')).toBeVisible(); + await expect(page.getByText('anonymous', { exact: true })).toBeVisible(); const path = getCurrentPath(page); expect(path).toContain('#/'); 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 f41c00c544e..cac761ae85b 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 @@ -47,29 +47,6 @@ test.describe('Home Page - Core Elements', () => { expect(welcomeText).toContain('interactive data analytics'); }); }); - - test('should have proper welcome message structure', async () => { - await test.step('Given I am on the home page', async () => { - await homePage.navigateToHome(); - }); - - await test.step('When I examine the welcome section', async () => { - await expect(homePage.welcomeSection).toBeVisible(); - }); - - await test.step('Then I should see the welcome heading', async () => { - await expect(homePage.welcomeTitle).toBeVisible(); - const headingText = await homePage.getWelcomeHeadingText(); - expect(headingText.trim()).toBe('Welcome to Zeppelin!'); - }); - - await test.step('And I should see the welcome description', async () => { - await expect(homePage.welcomeDescription).toBeVisible(); - const descriptionText = await homePage.getWelcomeDescriptionText(); - expect(descriptionText).toContain('web-based notebook'); - expect(descriptionText).toContain('interactive data analytics'); - }); - }); }); test.describe('Notebook Section', () => { @@ -85,13 +62,13 @@ test.describe('Home Page - Core Elements', () => { await test.step('Then I should see all notebook section components', async () => { await expect(homePage.notebookSection).toBeVisible(); await expect(homePage.notebookHeading).toBeVisible(); + await expect(homePage.notebookHeading).toContainText('Notebook'); await expect(homePage.refreshNoteButton).toBeVisible(); - await page.waitForSelector('zeppelin-node-list', { timeout: 10000 }); await expect(homePage.zeppelinNodeList).toBeVisible(); }); }); - test('should have functional refresh notes button', async () => { + test('should keep notebook list visible after refresh', async () => { await test.step('Given I am on the home page with notebook section visible', async () => { await homePage.navigateToHome(); await expect(homePage.refreshNoteButton).toBeVisible(); @@ -104,24 +81,6 @@ test.describe('Home Page - Core Elements', () => { await test.step('Then the notebook list should still be visible', async () => { await homePage.waitForRefreshToComplete(); await expect(homePage.zeppelinNodeList).toBeVisible(); - const isStillVisible = await homePage.zeppelinNodeList.isVisible(); - expect(isStillVisible).toBe(true); - }); - }); - - test('should display notebook list component', async ({ page }) => { - await test.step('Given I am on the home page', async () => { - await homePage.navigateToHome(); - }); - - await test.step('When I look for the notebook list', async () => { - await waitForZeppelinReady(page); - }); - - await test.step('Then I should see the notebook list component', async () => { - await expect(homePage.zeppelinNodeList).toBeVisible(); - const isVisible = await homePage.isNotebookListVisible(); - expect(isVisible).toBe(true); }); }); }); @@ -148,7 +107,7 @@ test.describe('Home Page - Core Elements', () => { }); test.describe('Community Section', () => { - test('should display community section with all links', async ({ page }) => { + test('should display community section heading', async ({ page }) => { await test.step('Given I am on the home page', async () => { await homePage.navigateToHome(); }); @@ -160,14 +119,10 @@ test.describe('Home Page - Core Elements', () => { await test.step('Then I should see the community section', async () => { await expect(homePage.communitySection).toBeVisible(); await expect(homePage.communityHeading).toBeVisible(); + await expect(homePage.communityHeading).toContainText('Community'); }); - await test.step('And I should see all community links', async () => { - await expect(homePage.externalLinks.documentation).toBeVisible(); - await expect(homePage.externalLinks.mailingList).toBeVisible(); - await expect(homePage.externalLinks.issuesTracking).toBeVisible(); - await expect(homePage.externalLinks.github).toBeVisible(); - }); + // External link href/target/icon assertions are covered by home-page-external-links.spec.ts }); }); }); diff --git a/zeppelin-web-angular/e2e/tests/home/home-page-enhanced-functionality.spec.ts b/zeppelin-web-angular/e2e/tests/home/home-page-enhanced-functionality.spec.ts deleted file mode 100644 index fb3a56cbc20..00000000000 --- a/zeppelin-web-angular/e2e/tests/home/home-page-enhanced-functionality.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * 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, test } from '@playwright/test'; -import { HomePage } from '../../models/home-page'; -import { addPageAnnotationBeforeEach, performLoginIfRequired, waitForZeppelinReady, PAGES } from '../../utils'; - -addPageAnnotationBeforeEach(PAGES.WORKSPACE.HOME); - -test.describe('Home Page Enhanced Functionality', () => { - let homePage: HomePage; - - test.beforeEach(async ({ page }) => { - homePage = new HomePage(page); - await page.goto('/#/'); - await waitForZeppelinReady(page); - await performLoginIfRequired(page); - }); - - test.describe('Given documentation links are displayed', () => { - test('When documentation link is checked Then should have correct version in URL', async () => { - const href = await homePage.getDocumentationLinkHref(); - expect(href).toContain('zeppelin.apache.org/docs'); - expect(href).toMatch(/\/docs\/\d+\.\d+\.\d+(-SNAPSHOT)?\//); - }); - - test('When external links are checked Then should all open in new tab', async () => { - const links = [ - homePage.externalLinks.documentation, - homePage.externalLinks.mailingList, - homePage.externalLinks.issuesTracking, - homePage.externalLinks.github - ]; - - for (const link of links) { - const target = await link.getAttribute('target'); - expect(target).toBe('_blank'); - } - }); - }); - - test.describe('Given welcome section display', () => { - test('When page loads Then should show welcome content with proper text', async () => { - await expect(homePage.welcomeSection).toBeVisible(); - await expect(homePage.welcomeTitle).toBeVisible(); - const headingText = await homePage.getWelcomeHeadingText(); - expect(headingText.trim()).toBe('Welcome to Zeppelin!'); - await expect(homePage.welcomeDescription).toBeVisible(); - const welcomeText = await homePage.welcomeDescription.textContent(); - expect(welcomeText).toContain('web-based notebook'); - expect(welcomeText).toContain('interactive data analytics'); - }); - - test('When welcome section is displayed Then should contain interactive elements', async ({ page }) => { - await expect(homePage.notebookSection).toBeVisible(); - await expect(homePage.notebookHeading).toBeVisible(); - await expect(homePage.refreshNoteButton).toBeVisible(); - await page.waitForSelector('zeppelin-node-list', { timeout: 10000 }); - await expect(homePage.zeppelinNodeList).toBeVisible(); - }); - }); - - test.describe('Given community section content', () => { - test('When community section loads Then should display help and community headings', async () => { - await expect(homePage.helpSection).toBeVisible(); - await expect(homePage.helpHeading).toBeVisible(); - await expect(homePage.communitySection).toBeVisible(); - await expect(homePage.communityHeading).toBeVisible(); - }); - - test('When external links are displayed Then should show correct targets', async () => { - const docHref = await homePage.externalLinks.documentation.getAttribute('href'); - const mailHref = await homePage.externalLinks.mailingList.getAttribute('href'); - const issuesHref = await homePage.externalLinks.issuesTracking.getAttribute('href'); - const githubHref = await homePage.externalLinks.github.getAttribute('href'); - - expect(docHref).toContain('zeppelin.apache.org/docs'); - expect(mailHref).toContain('community.html'); - expect(issuesHref).toContain('issues.apache.org'); - expect(githubHref).toContain('github.com/apache/zeppelin'); - }); - }); -}); 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 ce44eb967bf..97a250d6abe 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 @@ -28,18 +28,13 @@ test.describe('Home Page - External Links', () => { test.describe('Documentation Link', () => { test('should have correct documentation link with dynamic version', async () => { - await test.step('Given I am on the home page', async () => { - await homePage.navigateToHome(); - }); - await test.step('When I examine the documentation link', async () => { await expect(homePage.externalLinks.documentation).toBeVisible(); }); await test.step('Then it should have the correct href pattern', async () => { const href = await homePage.externalLinks.documentation.getAttribute('href'); - expect(href).toContain('zeppelin.apache.org/docs'); - expect(href).toContain('index.html'); + expect(href).toMatch(/\/docs\/\d+\.\d+\.\d+(-SNAPSHOT)?\/index\.html/); }); await test.step('And it should open in a new tab', async () => { @@ -51,10 +46,6 @@ test.describe('Home Page - External Links', () => { test.describe('Community Links', () => { test('should have correct mailing list link', async () => { - await test.step('Given I am on the home page', async () => { - await homePage.navigateToHome(); - }); - await test.step('When I examine the mailing list link', async () => { await expect(homePage.externalLinks.mailingList).toBeVisible(); }); @@ -76,10 +67,6 @@ test.describe('Home Page - External Links', () => { }); test('should have correct issues tracking link', async () => { - await test.step('Given I am on the home page', async () => { - await homePage.navigateToHome(); - }); - await test.step('When I examine the issues tracking link', async () => { await expect(homePage.externalLinks.issuesTracking).toBeVisible(); }); @@ -101,10 +88,6 @@ test.describe('Home Page - External Links', () => { }); test('should have correct GitHub link', async () => { - await test.step('Given I am on the home page', async () => { - await homePage.navigateToHome(); - }); - await test.step('When I examine the GitHub link', async () => { await expect(homePage.externalLinks.github).toBeVisible(); }); @@ -125,31 +108,4 @@ test.describe('Home Page - External Links', () => { }); }); }); - - test.describe('Link Verification', () => { - test('should have all external links with proper attributes', async () => { - await test.step('Given I am on the home page', async () => { - await homePage.navigateToHome(); - }); - - await test.step('When I examine all external links', async () => { - await expect(homePage.externalLinks.documentation).toBeVisible(); - await expect(homePage.externalLinks.mailingList).toBeVisible(); - await expect(homePage.externalLinks.issuesTracking).toBeVisible(); - await expect(homePage.externalLinks.github).toBeVisible(); - }); - - await test.step('Then all links should open in new tabs', async () => { - const docTarget = await homePage.externalLinks.documentation.getAttribute('target'); - const mailTarget = await homePage.externalLinks.mailingList.getAttribute('target'); - const issuesTarget = await homePage.externalLinks.issuesTracking.getAttribute('target'); - const githubTarget = await homePage.externalLinks.github.getAttribute('target'); - - expect(docTarget).toBe('_blank'); - expect(mailTarget).toBe('_blank'); - expect(issuesTarget).toBe('_blank'); - expect(githubTarget).toBe('_blank'); - }); - }); - }); }); 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 e960c3c6cb5..5a12f6ea4e0 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 @@ -27,16 +27,6 @@ test.describe('Home Page - Layout and Grid', () => { }); test.describe('Responsive Grid Layout', () => { - test('should display responsive grid structure', async ({ page }) => { - await test.step('Given I am on the home page', async () => { - await homePage.navigateToHome(); - }); - - await test.step('When the page loads', async () => { - await waitForZeppelinReady(page); - }); - }); - test('should have proper column distribution', async () => { await test.step('Given I am on the home page', async () => { await homePage.navigateToHome(); @@ -58,6 +48,7 @@ test.describe('Home Page - Layout and Grid', () => { await test.step('And I should see the help/community column with proper sizing', async () => { await expect(homePage.helpCommunityColumn).toBeVisible(); // Check that the column contains help and community content + // JUSTIFIED: Help heading comes before Community heading in the right-column DOM order const helpHeading = homePage.helpCommunityColumn.locator('h3').first(); await expect(helpHeading).toBeVisible(); const helpText = await helpHeading.textContent(); @@ -78,6 +69,12 @@ test.describe('Home Page - Layout and Grid', () => { await expect(homePage.moreInfoGrid).toBeVisible(); await expect(homePage.notebookColumn).toBeVisible(); await expect(homePage.helpCommunityColumn).toBeVisible(); + // Verify headings are readable and contain expected text at tablet width + const notebookHeading = homePage.notebookColumn.locator('h3'); + // JUSTIFIED: Help heading comes before Community heading in the right-column DOM order + const helpHeading = homePage.helpCommunityColumn.locator('h3').first(); + await expect(notebookHeading).toContainText('Notebook'); + await expect(helpHeading).toContainText('Help'); }); await test.step('When I resize to mobile view', async () => { @@ -89,11 +86,14 @@ test.describe('Home Page - Layout and Grid', () => { await expect(homePage.notebookColumn).toBeVisible(); await expect(homePage.helpCommunityColumn).toBeVisible(); - // Verify content is still accessible in mobile view + // Verify headings are readable and contain expected text in mobile view const notebookHeading = homePage.notebookColumn.locator('h3'); + // JUSTIFIED: Help heading comes before Community heading in the right-column DOM order const helpHeading = homePage.helpCommunityColumn.locator('h3').first(); await expect(notebookHeading).toBeVisible(); + await expect(notebookHeading).toContainText('Notebook'); await expect(helpHeading).toBeVisible(); + await expect(helpHeading).toContainText('Help'); }); }); }); 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 f385de5f585..66be0f6f4de 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 @@ -18,235 +18,220 @@ addPageAnnotationBeforeEach(PAGES.WORKSPACE.HOME); test.describe('Home Page Note Operations', () => { 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); - const noteListLocator = page.locator('zeppelin-node-list'); - await expect(noteListLocator).toBeVisible({ timeout: 15000 }); - }); - test.describe('Given note operations are available', () => { - test('When note list loads Then should show note action buttons on hover', async ({ page }) => { - const notesExist = await page.locator('.node .file').count(); + // Create a test note so all operation tests have a real target + await homePage.createNote(testNoteName); + await page.goto('/#/'); + await waitForZeppelinReady(page); - if (notesExist > 0) { - const firstNote = page.locator('.node .file').first(); - await firstNote.hover(); + await expect(page.locator('zeppelin-node-list')).toBeVisible({ timeout: 15000 }); - await expect(homePage.nodeList.noteActions.renameNote.first()).toBeVisible(); - await expect(homePage.nodeList.noteActions.clearOutput.first()).toBeVisible(); - await expect(homePage.nodeList.noteActions.moveToTrash.first()).toBeVisible(); - } else { - console.log('No notes available for testing operations'); - } + // Force a note list refresh so the newly created note is guaranteed to appear + await homePage.clickRefreshNotes(); + await expect(page.locator('.node .file').filter({ hasText: testNoteName })).toBeVisible({ timeout: 15000 }); + }); + + test.describe('Given note operations are available', () => { + test('When hovering over note Then should show rename, clear, and delete action buttons', async ({ page }) => { + const testNote = page.locator('.node .file').filter({ hasText: testNoteName }); + await testNote.hover(); + + // Scoped to testNote: CSS .node:hover reveals .operation icons only for the hovered node + await expect(testNote.locator('.operation a[nztooltiptitle="Rename note"]')).toBeVisible(); + await expect(testNote.locator('.operation a[nztooltiptitle="Clear output"]')).toBeVisible(); + await expect(testNote.locator('.operation a[nztooltiptitle="Move note to Trash"]')).toBeVisible(); }); test('When hovering over note actions Then should show tooltip descriptions', async ({ page }) => { - const noteExists = await page - .locator('.node .file') - .first() - .isVisible() - .catch(() => false); + const testNote = page.locator('.node .file').filter({ hasText: testNoteName }); + await testNote.hover(); + // Wait for the buttons to become visible — CSS .node:hover makes display:inline-block + const renameBtn = testNote.locator('.operation a[nztooltiptitle="Rename note"]'); + const clearBtn = testNote.locator('.operation a[nztooltiptitle="Clear output"]'); + const trashBtn = testNote.locator('.operation a[nztooltiptitle="Move note to Trash"]'); + await renameBtn.waitFor({ state: 'visible' }); + + // dispatchEvent mouseenter — justified: nz-tooltip listens to mouseenter; direct hover() on a child + // causes a CSS :hover race where the mouse leaves the parent .node .file mid-movement, hiding the button. + await renameBtn.dispatchEvent('mouseenter'); + await expect(page.locator('.ant-tooltip', { hasText: 'Rename note' })).toBeVisible(); + await renameBtn.dispatchEvent('mouseleave'); + + await clearBtn.dispatchEvent('mouseenter'); + await expect(page.locator('.ant-tooltip', { hasText: 'Clear output' })).toBeVisible(); + await clearBtn.dispatchEvent('mouseleave'); + + await trashBtn.dispatchEvent('mouseenter'); + await expect(page.locator('.ant-tooltip', { hasText: 'Move note to Trash' })).toBeVisible(); + await trashBtn.dispatchEvent('mouseleave'); + }); + }); - if (noteExists) { - const firstNote = page.locator('.node .file').first(); - await firstNote.hover(); + test.describe('Given rename note functionality', () => { + test('When rename button is clicked Then should open rename dialog', async ({ page }) => { + const testNote = page.locator('.node .file').filter({ hasText: testNoteName }); + await testNote.hover(); + + const renameButton = testNote.locator('.operation a[nztooltiptitle="Rename note"]'); + await expect(renameButton).toBeVisible(); + await renameButton.click(); + + // JUSTIFIED: compound selector targets rename dialog; first() picks the visible modal instance + await expect(page.locator('zeppelin-note-rename, [role="dialog"].ant-modal').first()).toBeVisible({ + timeout: 5000 + }); + }); + }); - 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.describe('Given clear output functionality', () => { + test('When clear output button is clicked Then should show and dismiss confirmation dialog', async ({ page }) => { + const testNote = page.locator('.node .file').filter({ hasText: testNoteName }); + await testNote.hover(); - // Test tooltip visibility by hovering over each icon - await homePage.nodeList.noteActions.renameNote.first().hover(); - await expect(page.locator('.ant-tooltip', { hasText: 'Rename note' })).toBeVisible(); + const clearButton = testNote.locator('.operation a[nztooltiptitle="Clear output"]'); + await expect(clearButton).toBeVisible(); + await clearButton.click(); - await homePage.nodeList.noteActions.clearOutput.first().hover(); - await expect(page.locator('.ant-tooltip', { hasText: 'Clear output' })).toBeVisible(); + await expect(page.locator('text=Do you want to clear all output?')).toBeVisible(); + await page.locator('.ant-popover button:has-text("OK")').click(); - await homePage.nodeList.noteActions.moveToTrash.first().hover(); - await expect(page.locator('.ant-tooltip', { hasText: 'Move note to Trash' })).toBeVisible(); - } + // Popover should close after confirming the operation + await expect(page.locator('text=Do you want to clear all output?')).not.toBeVisible(); }); }); - test.describe('Given rename note functionality', () => { - test('When rename button is clicked Then should trigger rename workflow', async ({ page }) => { - const noteExists = await page - .locator('.node .file') - .first() - .isVisible() - .catch(() => false); - - if (noteExists) { - const noteItem = page.locator('.node .file').first(); - await noteItem.hover(); - - const renameButton = homePage.nodeList.noteActions.renameNote.first(); - await expect(renameButton).toBeVisible(); - await renameButton.click(); - - await page - .waitForFunction( - () => - document.querySelector('zeppelin-note-rename') !== null || - document.querySelector('[role="dialog"]') !== null || - document.querySelector('.ant-modal') !== null, - { timeout: 5000 } - ) - .catch(() => { - console.log('Rename modal did not appear - might need different trigger'); - }); - } + test.describe('Given move to trash functionality', () => { + test('When move to trash is confirmed Then should move note to trash folder', async ({ page }) => { + const testNote = page.locator('.node .file').filter({ hasText: testNoteName }); + await testNote.hover(); + + const deleteButton = testNote.locator('.operation a[nztooltiptitle="Move note to Trash"]'); + await expect(deleteButton).toBeVisible(); + await deleteButton.click(); + + await expect(page.locator('text=This note will be moved to trash.')).toBeVisible(); + await page.locator('.ant-popover button:has-text("OK")').click(); + + // Source note must disappear from the list — not just that Trash folder appears + await expect(testNote).not.toBeVisible({ timeout: 10000 }); + await expect(page.locator('.node .folder').filter({ hasText: 'Trash' })).toBeVisible({ timeout: 10000 }); }); }); - test.describe('Given clear output functionality', () => { - test('When clear output button is clicked Then should show confirmation dialog', async ({ page }) => { - const noteExists = await page - .locator('.node .file') - .first() - .isVisible() - .catch(() => false); - - if (noteExists) { - const noteItem = page.locator('.node .file').first(); - await noteItem.hover(); - - const clearButton = homePage.nodeList.noteActions.clearOutput.first(); - await expect(clearButton).toBeVisible(); - await clearButton.click(); - - await expect(page.locator('text=Do you want to clear all output?')).toBeVisible(); + test.describe('Given note filter with special characters', () => { + test('When filtering with special characters Then should not crash and should dim non-matching results', async ({ + page + }) => { + for (const char of ['#', '%', '"']) { + await homePage.filterNotes(char); + + // App must not crash — node list container remains present + await expect(page.locator('zeppelin-node-list')).toBeVisible(); + // Test note name contains none of these chars, so it must be dimmed (.not-matched class) + await expect(page.locator('.node').filter({ hasText: testNoteName })).toHaveClass(/not-matched/, { + timeout: 10000 + }); } - }); - test('When clear output is confirmed Then should execute clear operation', async ({ page }) => { - const noteExists = await page - .locator('.node .file') - .first() - .isVisible() - .catch(() => false); - - if (noteExists) { - const noteItem = page.locator('.node .file').first(); - await noteItem.hover(); - - const clearButton = homePage.nodeList.noteActions.clearOutput.first(); - await expect(clearButton).toBeVisible(); - await clearButton.click(); - - const confirmButton = page.locator('button:has-text("Yes")'); - if (await confirmButton.isVisible()) { - await confirmButton.click(); - } - } + // Clearing filter restores the note (removes .not-matched) + await homePage.filterNotes(''); + await expect(page.locator('.node').filter({ hasText: testNoteName })).not.toHaveClass(/not-matched/, { + timeout: 5000 + }); }); }); - test.describe('Given move to trash functionality', () => { - test('When delete button is clicked Then should show trash confirmation', async ({ page }) => { - const noteExists = await page - .locator('.node .file') - .first() - .isVisible() - .catch(() => false); - - if (noteExists) { - const noteItem = page.locator('.node .file').first(); - await noteItem.hover(); - - const deleteButton = homePage.nodeList.noteActions.moveToTrash.first(); - await expect(deleteButton).toBeVisible(); - await deleteButton.click(); - - await expect(page.locator('text=This note will be moved to trash.')).toBeVisible(); - } - }); + test.describe('Given max length note name input', () => { + test('When note name input is filled with a very long string Then input should cap or accept gracefully', async ({ + page + }) => { + await homePage.clickCreateNewNote(); + await page.waitForSelector('nz-form-label', { timeout: 10000 }); - test('When move to trash is confirmed Then should move note to trash folder', async ({ page }) => { - const noteExists = await page - .locator('.node .file') - .first() - .isVisible() - .catch(() => false); - - if (noteExists) { - const noteItem = page.locator('.node .file').first(); - await noteItem.hover(); - - const deleteButton = homePage.nodeList.noteActions.moveToTrash.first(); - await expect(deleteButton).toBeVisible(); - await deleteButton.click(); - - const confirmButton = page.locator('button:has-text("Yes")'); - if (await confirmButton.isVisible()) { - await confirmButton.click(); - - const trashFolder = page.locator('.node .folder').filter({ hasText: 'Trash' }); - await expect(trashFolder).toBeVisible(); - } + const notebookNameInput = page.locator('div.ant-modal-content input[name="noteName"]'); + const maxLengthAttr = await notebookNameInput.getAttribute('maxlength'); + const longName = `_e2e_ml_${'a'.repeat(300)}`; + + await notebookNameInput.fill(longName); + const actualValue = await notebookNameInput.inputValue(); + + // Must have content — input did not silently reject the fill + expect(actualValue.length).toBeGreaterThan(0); + + if (maxLengthAttr !== null) { + // If the element enforces maxlength, the value must be capped at that limit + expect(actualValue.length).toBeLessThanOrEqual(parseInt(maxLengthAttr, 10)); + } else { + // No client-side cap — the full value passes through + expect(actualValue).toBe(longName); } + + // Dismiss the modal without creating + await page.keyboard.press('Escape'); + await page.locator('div.ant-modal-content').waitFor({ state: 'detached', timeout: 5000 }); }); }); test.describe('Given trash folder operations', () => { + test.beforeEach(async ({ page }) => { + // Move the test note to trash to put the trash folder into a known state + const testNote = page.locator('.node .file').filter({ hasText: testNoteName }); + await testNote.hover(); + await testNote.locator('.operation').waitFor({ state: 'visible' }); + + const deleteButton = testNote.locator('.operation a[nztooltiptitle="Move note to Trash"]'); + await deleteButton.click(); + + await expect(page.locator('text=This note will be moved to trash.')).toBeVisible(); + await page.locator('.ant-popover button:has-text("OK")').click(); + await expect(page.locator('.node .folder').filter({ hasText: 'Trash' })).toBeVisible({ timeout: 10000 }); + }); + test('When trash folder exists Then should show restore and empty options', async ({ page }) => { - const trashExists = await page - .locator('.node .folder') - .filter({ hasText: 'Trash' }) - .isVisible() - .catch(() => false); - - if (trashExists) { - const trashFolder = page.locator('.node .folder').filter({ hasText: 'Trash' }); - await trashFolder.hover(); - - await expect(page.locator('.folder .operation a[nztooltiptitle*="Restore all"]')).toBeVisible(); - await expect(page.locator('.folder .operation a[nztooltiptitle*="Empty all"]')).toBeVisible(); - } + const trashFolder = page.locator('.node .folder').filter({ hasText: 'Trash' }); + await trashFolder.hover(); + await trashFolder.locator('.operation').waitFor({ state: 'visible' }); + + // Scoped to trashFolder: same CSS hover rule applies to .node:hover for folder nodes + await expect(trashFolder.locator('.operation a[nztooltiptitle*="Restore all"]')).toBeVisible(); + await expect(trashFolder.locator('.operation a[nztooltiptitle*="Empty all"]')).toBeVisible(); }); test('When restore all is clicked Then should show confirmation dialog', async ({ page }) => { - const trashExists = await page - .locator('.node .folder') - .filter({ hasText: 'Trash' }) - .isVisible() - .catch(() => false); - - if (trashExists) { - const trashFolder = page.locator('.node .folder').filter({ hasText: 'Trash' }); - await trashFolder.hover(); - - const restoreButton = page.locator('.folder .operation a[nztooltiptitle*="Restore all"]').first(); - await expect(restoreButton).toBeVisible(); - await restoreButton.click(); - - await expect( - page.locator('text=Folders and notes in the trash will be merged into their original position.') - ).toBeVisible(); - } + const trashFolder = page.locator('.node .folder').filter({ hasText: 'Trash' }); + await trashFolder.hover(); + await trashFolder.locator('.operation').waitFor({ state: 'visible' }); + + const restoreButton = trashFolder.locator('.operation a[nztooltiptitle*="Restore all"]'); + await expect(restoreButton).toBeVisible(); + // JUSTIFIED: hovering restoreButton directly can briefly exit .folder:hover and hide it + await restoreButton.click({ force: true }); + + await expect( + page.locator('text=Folders and notes in the trash will be merged into their original position.') + ).toBeVisible(); }); test('When empty trash is clicked Then should show permanent deletion warning', async ({ page }) => { - const trashExists = await page - .locator('.node .folder') - .filter({ hasText: 'Trash' }) - .isVisible() - .catch(() => false); + const trashFolder = page.locator('.node .folder').filter({ hasText: 'Trash' }); + await trashFolder.hover(); + await trashFolder.locator('.operation').waitFor({ state: 'visible' }); - if (trashExists) { - const trashFolder = page.locator('.node .folder').filter({ hasText: 'Trash' }); - await trashFolder.hover(); + const emptyButton = trashFolder.locator('.operation a[nztooltiptitle*="Empty all"]'); + await expect(emptyButton).toBeVisible(); + await emptyButton.hover(); + await emptyButton.click(); - const emptyButton = page.locator('.folder .operation a[nztooltiptitle*="Empty all"]').first(); - await expect(emptyButton).toBeVisible(); - await emptyButton.click(); - - await expect(page.locator('text=This cannot be undone. Are you sure?')).toBeVisible(); - } + await expect(page.locator('text=This cannot be undone. Are you sure?')).toBeVisible(); }); }); }); 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 3cb9725dcb4..c14a5474e2c 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 @@ -27,14 +27,7 @@ test.describe('Home Page Notebook Actions', () => { }); test.describe('Given notebook list is displayed', () => { - test('When page loads Then should show notebook actions', async () => { - await expect(homePage.nodeList.createNewNoteLink).toBeVisible(); - await expect(homePage.nodeList.importNoteLink).toBeVisible(); - await expect(homePage.nodeList.filterInput).toBeVisible(); - await expect(homePage.nodeList.tree).toBeVisible(); - }); - - test('When refresh button is clicked Then should trigger reload with loading state', async ({ page }) => { + test('When refresh button is clicked Then should keep refresh icon visible', async ({ page }) => { const refreshButton = page.locator('a.refresh-note'); const refreshIcon = page.locator('a.refresh-note i[nz-icon]'); @@ -42,48 +35,36 @@ test.describe('Home Page Notebook Actions', () => { await expect(refreshIcon).toBeVisible(); await homePage.clickRefreshNotes(); - - await page.waitForTimeout(500); + await homePage.waitForRefreshToComplete(); await expect(refreshIcon).toBeVisible(); }); test('When filter is used Then should filter notebook list', async ({ page }) => { - // Note (ZEPPELIN-6386): - // The Notebook search filter in the New UI is currently too slow, - // so this test is temporarily skipped. The skip will be removed - // once the performance issue is resolved. - test.skip(); + test.skip(true, 'ZEPPELIN-6386: Notebook search filter in the New UI is too slow — re-enable when fixed'); await homePage.filterNotes('test'); await page.waitForLoadState('networkidle', { timeout: 15000 }); const filteredResults = await page.locator('nz-tree .node').count(); - expect(filteredResults).toBeGreaterThanOrEqual(0); + expect(filteredResults).toBeGreaterThan(0); }); - }); - test.describe('Given create new note action', () => { - test('When create new note is clicked Then should open note creation modal', async ({ page }) => { - await homePage.clickCreateNewNote(); - await page.waitForSelector('zeppelin-note-create', { timeout: 10000 }); - await expect(page.locator('zeppelin-note-create')).toBeVisible(); - }); - }); + test('When filter input receives special characters Then page should not crash', async ({ page }) => { + // Given: The filter input is visible + await expect(homePage.nodeList.filterInput).toBeVisible(); - test.describe('Given import note action', () => { - test('When import note is clicked Then should open import modal', async ({ page }) => { - await homePage.clickImportNote(); - await page.waitForSelector('zeppelin-note-import', { timeout: 10000 }); - await expect(page.locator('zeppelin-note-import')).toBeVisible(); - }); - }); + // 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); + // 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(); + await expect(homePage.nodeList.filterInput).toBeEditable(); + await expect(homePage.nodeList.filterInput).toHaveValue(specialInput); + await expect(page.locator('zeppelin-header')).toBeVisible(); + } - test.describe('Given notebook refresh functionality', () => { - test('When refresh is triggered Then should maintain notebook list visibility', async () => { - await homePage.clickRefreshNotes(); - await homePage.waitForRefreshToComplete(); - await expect(homePage.zeppelinNodeList).toBeVisible(); - const isStillVisible = await homePage.zeppelinNodeList.isVisible(); - expect(isStillVisible).toBe(true); + // Clean up: clear the filter so other tests start fresh + await homePage.nodeList.filterInput.fill(''); }); }); }); 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 b15facedc77..475da43f3ba 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 @@ -51,6 +51,7 @@ test.describe('Published Paragraph', () => { await publishedParagraphPage.navigateToPublishedParagraph(nonExistentIds.noteId, nonExistentIds.paragraphId); + // JUSTIFIED: last() handles stacked modals where the most recent error modal appears on top const modal = page.locator('.ant-modal', { hasText: /not found/i }).last(); await expect(modal).toBeVisible({ timeout: 10000 }); await expect(modal).toContainText(/not found/i); @@ -77,7 +78,7 @@ test.describe('Published Paragraph', () => { await publishedParagraphPage.navigateToPublishedParagraph(nonExistentIds.noteId, nonExistentIds.paragraphId); // Modal must appear — we navigated to non-existent IDs - const modal = page.locator('.ant-modal').last(); + const modal = page.locator('.ant-modal').filter({ hasText: /not found/i }); await expect(modal).toBeVisible({ timeout: 10000 }); await publishedParagraphPage.okButton.click(); @@ -93,7 +94,7 @@ test.describe('Published Paragraph', () => { await page.goto(`/#/notebook/${noteId}`); await page.waitForLoadState('networkidle'); - // createTestNotebook creates a single paragraph, so .first() is the target + // JUSTIFIED: createTestNotebook creates a single paragraph; first() is deterministic const paragraphElement = page.locator('zeppelin-notebook-paragraph').first(); await expect(paragraphElement).toBeVisible({ timeout: 10000 }); @@ -115,11 +116,19 @@ test.describe('Published Paragraph', () => { test('should load published paragraph component by direct URL navigation', async ({ page }) => { await page.goto(`/#/notebook/${testNotebook.noteId}/paragraph/${testNotebook.paragraphId}`); - await page.waitForLoadState('networkidle'); + + // Wait for the confirmation modal — it signals NOTE was received and the component is fully rendered. + // networkidle fires before the NOTE WebSocket response, so the modal is the reliable ready signal. + const confirmModal = page.locator('.ant-modal-confirm'); + await expect(confirmModal).toBeVisible({ timeout: 15000 }); + await publishedParagraphPage.cancelButton.click(); + await expect(confirmModal).toBeHidden({ timeout: 5000 }); await expect(page).toHaveURL( new RegExp(`/notebook/${testNotebook.noteId}/paragraph/${testNotebook.paragraphId}`) ); + // JUSTIFIED: paragraph has no results yet so the component renders 0×0 — toBeAttached confirms + // the route is active without requiring visible content. await expect(page.locator('zeppelin-publish-paragraph')).toBeAttached({ timeout: 10000 }); }); @@ -127,19 +136,17 @@ test.describe('Published Paragraph', () => { const { noteId, paragraphId } = testNotebook; await page.goto(`/#/notebook/${noteId}/paragraph/${paragraphId}`); - await page.waitForLoadState('networkidle'); - const publishedContainer = page.locator('zeppelin-publish-paragraph'); - await expect(publishedContainer).toBeAttached({ timeout: 10000 }); - - // Confirmation modal should appear for paragraph execution + // Confirmation modal signals NOTE was received and component is fully rendered. const modal = page.locator('.ant-modal'); await expect(modal).toBeVisible({ timeout: 20000 }); await publishedParagraphPage.runButton.click(); await expect(modal).not.toBeVisible({ timeout: 10000 }); - // Published container should remain attached after modal dismissal + const publishedContainer = page.locator('zeppelin-publish-paragraph'); + // JUSTIFIED: paragraph has no results yet so the component renders 0×0 — toBeAttached confirms + // the route is still active (not navigated away) without requiring visible content. await expect(publishedContainer).toBeAttached({ timeout: 10000 }); }); @@ -156,6 +163,7 @@ test.describe('Published Paragraph', () => { await test.step('And React widget should be mounted in the container', async () => { // React mount() renders
or (Alert) const reactContent = page.locator('[data-testid="react-published-paragraph"], .ant-alert'); + // JUSTIFIED: compound selector covers React success + error fallback (.ant-alert); either may render await expect(reactContent).toBeAttached({ timeout: 15000 }); }); }); @@ -166,8 +174,15 @@ test.describe('Published Paragraph', () => { const { noteId, paragraphId } = testNotebook; await page.goto(`/#/notebook/${noteId}/paragraph/${paragraphId}`); - await page.waitForLoadState('networkidle'); + // Wait for modal then dismiss — component visibility is unreliable while modal is animating open. + const confirmModal = page.locator('.ant-modal-confirm'); + await expect(confirmModal).toBeVisible({ timeout: 15000 }); + await publishedParagraphPage.cancelButton.click(); + await expect(confirmModal).toBeHidden({ timeout: 5000 }); + + // JUSTIFIED: paragraph has no results yet so the component renders 0×0 — toBeAttached confirms + // the route is active without requiring visible content. await expect(page.locator('zeppelin-publish-paragraph')).toBeAttached({ timeout: 10000 }); await expect(page.locator('zeppelin-notebook-paragraph-code-editor')).toBeHidden(); await expect(page.locator('zeppelin-notebook-paragraph-control')).toBeHidden(); @@ -175,59 +190,26 @@ test.describe('Published Paragraph', () => { }); test.describe('Confirmation Modal and Execution', () => { - test('should show confirmation modal with code preview and allow running', async ({ page }) => { - const { noteId, paragraphId } = testNotebook; - - await publishedParagraphPage.navigateToNotebook(noteId); - - // Verify paragraph has no results yet - const paragraphElement = page.locator('zeppelin-notebook-paragraph').first(); - await expect(paragraphElement.locator('zeppelin-notebook-paragraph-result')).toBeHidden(); - - await publishedParagraphPage.navigateToPublishedParagraph(noteId, paragraphId); - - await expect(page).toHaveURL(new RegExp(`/paragraph/${paragraphId}`)); + for (const reactMode of [false, true]) { + test(`should show confirmation modal with code preview and allow running${reactMode ? ' (React mode)' : ''}`, async ({ + page + }) => { + const { noteId, paragraphId } = testNotebook; - const modal = publishedParagraphPage.confirmationModal; - await expect(modal).toBeVisible(); - - // Modal title - await expect(publishedParagraphPage.modalTitle).toHaveText('Run Paragraph?'); - - // Code preview content - const modalContent = modal.locator('.ant-modal-confirm-content'); - await expect(modalContent).toContainText('This paragraph contains the following code:'); - await expect(modalContent).toContainText('Would you like to execute this code?'); - - // Code preview element - const codePreview = modalContent.locator('pre, code, .code-preview, [class*="code"]').first(); - await expect(codePreview).toBeVisible(); - - // Run and Cancel buttons - await expect(publishedParagraphPage.runButton).toBeVisible(); - await expect(publishedParagraphPage.cancelButton).toBeVisible(); - - // Execute and verify modal dismissal - await publishedParagraphPage.runButton.click(); - await expect(modal).toBeHidden(); - }); - - test('should show confirmation modal in React mode and allow running', async ({ page }) => { - const { noteId, paragraphId } = testNotebook; - - await test.step('Given paragraph has no results in normal notebook view', async () => { await publishedParagraphPage.navigateToNotebook(noteId); + // JUSTIFIED: createTestNotebook creates a single paragraph; first() is deterministic const paragraphElement = page.locator('zeppelin-notebook-paragraph').first(); await expect(paragraphElement.locator('zeppelin-notebook-paragraph-result')).toBeHidden(); - }); - await test.step('When I navigate to React mode published paragraph URL', async () => { - await page.goto(`/#/notebook/${noteId}/paragraph/${paragraphId}?react=true`); + const urlSuffix = reactMode ? '?react=true' : ''; + await page.goto(`/#/notebook/${noteId}/paragraph/${paragraphId}${urlSuffix}`); await waitForZeppelinReady(page); - }); - await test.step('Then confirmation modal should appear and allow execution', async () => { + if (!reactMode) { + await expect(page).toHaveURL(new RegExp(`/paragraph/${paragraphId}`)); + } + const modal = publishedParagraphPage.confirmationModal; await expect(modal).toBeVisible({ timeout: 30000 }); @@ -237,9 +219,20 @@ test.describe('Published Paragraph', () => { await expect(modalContent).toContainText('This paragraph contains the following code:'); await expect(modalContent).toContainText('Would you like to execute this code?'); + if (!reactMode) { + // Code preview element only checked in Angular mode + // JUSTIFIED: compound fallback selector; first() picks any element that confirms code preview is rendered + const codePreview = modalContent.locator('pre, code, .code-preview, [class*="code"]').first(); + await expect(codePreview).toBeVisible(); + await expect(codePreview).not.toBeEmpty(); // code must have content, not just an empty container + + await expect(publishedParagraphPage.runButton).toBeVisible(); + await expect(publishedParagraphPage.cancelButton).toBeVisible(); + } + await publishedParagraphPage.runButton.click(); await expect(modal).toBeHidden(); }); - }); + } }); }); 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 2e8ab234a78..1f2d6fed08c 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 @@ -35,18 +35,17 @@ test.describe('About Zeppelin Modal', () => { 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.modalTitle).toContainText('About Zeppelin'); + await expect(aboutModal.heading).toContainText('Apache Zeppelin'); await expect(aboutModal.logo).toBeVisible(); - await expect(aboutModal.versionText).toBeVisible(); + await expect(aboutModal.versionText).not.toBeEmpty(); 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); + expect(version).toMatch(/\d+\.\d+/); }); test('Given About Zeppelin modal is open, When checking external links, Then links should have correct URLs', async () => { @@ -62,8 +61,11 @@ test.describe('About Zeppelin Modal', () => { 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); + test('Given About Zeppelin modal is open, When checking logo, Then logo should be visible and its image loaded', async () => { + await expect(aboutModal.logo).toBeVisible(); + // JUSTIFIED: naturalWidth is the only reliable way to verify image has loaded; + // Playwright has no built-in assertion for image load status + const naturalWidth = await aboutModal.logo.evaluate((img: HTMLImageElement) => img.naturalWidth); + expect(naturalWidth).toBeGreaterThan(0); }); }); 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 111d01011f7..2ef30aa82f1 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 @@ -31,16 +31,20 @@ test.describe('Node List Functionality', () => { 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(); + // JUSTIFIED: first() confirms at least one tree node is rendered in the list + await expect(nodeListPage.treeView.locator('nz-tree-node').first()).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.createNewNoteButton).toContainText('Create new Note'); await expect(nodeListPage.importNoteButton).toBeVisible(); + await expect(nodeListPage.importNoteButton).toContainText('Import Note'); }); - 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 user is on home page, When viewing node list, Then filter input should be visible with placeholder', async () => { + await expect(nodeListPage.filterInput).toBeVisible(); + await expect(nodeListPage.filterInput).toHaveAttribute('placeholder', /[Ff]ilter/); }); test('Given a note has been moved to trash, When viewing node list, Then trash folder should be visible', async ({ @@ -71,24 +75,28 @@ test.describe('Node List Functionality', () => { // 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\//); + const homePage = new HomePage(page); - expect(page.url()).toContain('notebook/'); + 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(); } + + 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 ({ 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 a2674b4c4ae..640e60899cc 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 @@ -60,12 +60,13 @@ test.describe('Note Create Modal', () => { 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(); + const notebookTitle = page.locator('[data-testid="notebook-title"]'); await expect(notebookTitle).toContainText(uniqueName); // Verify in the navigation tree if available - await page.goto('/'); - await page.waitForLoadState('networkidle'); + await page.goto('/#/'); + await waitForZeppelinReady(page); + await page.locator('zeppelin-node-list').waitFor({ state: 'visible', timeout: 15000 }); const noteInTree = page.getByRole('link', { name: uniqueName }); await expect(noteInTree).toBeVisible(); }); @@ -87,14 +88,14 @@ test.describe('Note Create Modal', () => { 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(); + const notebookTitle = page.locator('[data-testid="notebook-title"]'); 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(); + await page.goto('/#/'); + await waitForZeppelinReady(page); + await page.locator('zeppelin-node-list').waitFor({ state: 'visible', timeout: 15000 }); + await expect(page.locator('a.name[data-testid="folder-TestFolder"]')).toBeVisible(); }); test('Given Create Note modal is open, When clicking close button, Then modal should close', async () => { @@ -102,7 +103,7 @@ test.describe('Note Create Modal', () => { }); 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); + await expect(noteCreateModal.folderInfoAlert).toBeVisible(); + await expect(noteCreateModal.folderInfoAlert).toContainText("Use '/' to create folders"); }); }); 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 b20bee0902a..2100d56a394 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 @@ -42,8 +42,7 @@ test.describe('Note Import Modal', () => { }); 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.jsonFileTab).toHaveAttribute('aria-selected', 'true'); await expect(noteImportModal.uploadArea).toBeVisible(); await expect(noteImportModal.uploadText).toBeVisible(); @@ -52,8 +51,7 @@ test.describe('Note Import Modal', () => { 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.urlTab).toHaveAttribute('aria-selected', 'true'); await expect(noteImportModal.urlInput).toBeVisible(); await expect(noteImportModal.importNoteButton).toBeVisible(); @@ -62,16 +60,14 @@ test.describe('Note Import Modal', () => { 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); + await expect(noteImportModal.importNoteButton).toBeDisabled(); }); 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); + await expect(noteImportModal.importNoteButton).toBeEnabled(); }); test('Given Import Note modal is open, When entering import name, Then name should be set', async () => { @@ -84,8 +80,7 @@ test.describe('Note Import Modal', () => { 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); + expect(fileSizeLimit).toMatch(/\d+\s*(MB|KB|GB)/i); }); test('Given Import Note modal is open, When clicking close button, Then modal should close', async () => { @@ -99,7 +94,6 @@ test.describe('Note Import Modal', () => { await noteImportModal.clickImportNote(); await expect(noteImportModal.errorAlert).toBeVisible(); - const errorMessage = await noteImportModal.getErrorMessage(); - expect(errorMessage).toBeTruthy(); + await expect(noteImportModal.errorAlert).not.toBeEmpty(); }); }); 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 61a15e26d1b..13b03fbdd37 100644 --- a/zeppelin-web-angular/e2e/tests/theme/dark-mode.spec.ts +++ b/zeppelin-web-angular/e2e/tests/theme/dark-mode.spec.ts @@ -34,16 +34,15 @@ test.describe('Dark Mode Theme Switching', () => { await darkModePage.clearLocalStorage(); }); - test('Scenario: User can switch to dark mode and persistence is maintained', async ({ page, browserName }) => { + test('Scenario: Dark mode persists across page reload when set via localStorage', async ({ page, browserName }) => { // GIVEN: User is on the main page, which starts in 'system' mode by default (localStorage cleared). await test.step('GIVEN the page starts in system mode', async () => { await darkModePage.assertSystemTheme(); // Robot icon for system theme }); - // WHEN: Explicitly set theme to light mode for the rest of the test. - await test.step('WHEN the user explicitly sets theme to light mode', async () => { + // WHEN: Set theme to light via localStorage and reload (bypasses UI toggle for test setup). + await test.step('WHEN localStorage theme is set to light and page reloads', async () => { await darkModePage.setThemeInLocalStorage('light'); - await page.waitForTimeout(500); // Reload the page to apply localStorage theme changes if (browserName === 'webkit') { const currentUrl = page.url(); @@ -55,10 +54,9 @@ test.describe('Dark Mode Theme Switching', () => { await darkModePage.assertLightTheme(); // Now it should be light mode with sun icon }); - // WHEN: User switches to dark mode by setting localStorage and reloading. - await test.step('WHEN the user explicitly sets theme to dark mode', async () => { + // WHEN: Set theme to dark via localStorage and reload. + await test.step('WHEN localStorage theme is set to dark and page reloads', async () => { await darkModePage.setThemeInLocalStorage('dark'); - await page.waitForTimeout(500); // Reload the page to apply localStorage theme changes if (browserName === 'webkit') { const currentUrl = page.url(); 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 6e887b1924e..1796e1578a5 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 @@ -28,8 +28,10 @@ test.describe('Notebook Repository Item - Display Mode', () => { notebookReposPage = new NotebookReposPage(page); await notebookReposPage.navigate(); + // JUSTIFIED: .first() picks the first configured repo; tests require at least one repo to be present const firstCard = notebookReposPage.repositoryItems.first(); firstRepoName = (await firstCard.locator('.ant-card-head-title').textContent()) || ''; + expect(firstRepoName, 'No repository found — ensure at least one repo is configured').not.toBe(''); repoItemPage = new NotebookRepoItemPage(page, firstRepoName); }); @@ -40,14 +42,7 @@ test.describe('Notebook Repository Item - Display Mode', () => { test('should show edit button in display mode', async () => { await expect(repoItemPage.editButton).toBeVisible(); - }); - - test('should display settings table', async () => { - await expect(repoItemPage.settingTable).toBeVisible(); - }); - - test('should show all settings in display mode', async () => { - const settingCount = await repoItemPage.getSettingCount(); - expect(settingCount).toBeGreaterThan(0); + await expect(repoItemPage.editButton).toBeEnabled(); + await expect(repoItemPage.editButton).toContainText('Edit'); }); }); 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 1ee350c21dc..5fd6af53b94 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 @@ -30,8 +30,10 @@ test.describe('Notebook Repository Item - Edit Mode', () => { notebookReposPage = new NotebookReposPage(page); await notebookReposPage.navigate(); + // JUSTIFIED: .first() picks the first configured repo; tests require at least one repo to be present const firstCard = notebookReposPage.repositoryItems.first(); firstRepoName = (await firstCard.locator('.ant-card-head-title').textContent()) || ''; + expect(firstRepoName, 'No repository found — ensure at least one repo is configured').not.toBe(''); repoItemPage = new NotebookRepoItemPage(page, firstRepoName); repoItemUtil = new NotebookRepoItemUtil(page, firstRepoName); }); @@ -41,42 +43,8 @@ test.describe('Notebook Repository Item - Edit Mode', () => { await repoItemUtil.verifyEditMode(); }); - test('should show save and cancel buttons in edit mode', async () => { - await repoItemPage.clickEdit(); - await expect(repoItemPage.saveButton).toBeVisible(); - await expect(repoItemPage.cancelButton).toBeVisible(); - }); - test('should hide edit button in edit mode', async () => { await repoItemPage.clickEdit(); await expect(repoItemPage.editButton).toBeHidden(); }); - - test('should apply edit CSS class to card in edit mode', async () => { - await repoItemPage.clickEdit(); - const isEditMode = await repoItemPage.isEditMode(); - expect(isEditMode).toBe(true); - }); - - test('should exit edit mode when cancel button is clicked', async () => { - await repoItemPage.clickEdit(); - await repoItemUtil.verifyEditMode(); - await repoItemPage.clickCancel(); - await repoItemUtil.verifyDisplayMode(); - }); - - test('should reset form when cancel is clicked', async () => { - const firstRow = repoItemPage.settingRows.first(); - const settingName = (await firstRow.locator('td').first().textContent()) || ''; - const originalValue = await repoItemPage.getSettingValue(settingName); - - await repoItemPage.clickEdit(); - - await repoItemPage.fillSettingInput(settingName, 'temp-value'); - - await repoItemPage.clickCancel(); - - const currentValue = await repoItemPage.getSettingValue(settingName); - expect(currentValue.trim()).toBe(originalValue.trim()); - }); }); 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 aedf7e16751..e4fc940322f 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 @@ -28,55 +28,55 @@ test.describe('Notebook Repository Item - Form Validation', () => { notebookReposPage = new NotebookReposPage(page); await notebookReposPage.navigate(); + // JUSTIFIED: .first() picks the first configured repo; tests require at least one repo to be present const firstCard = notebookReposPage.repositoryItems.first(); firstRepoName = (await firstCard.locator('.ant-card-head-title').textContent()) || ''; + expect(firstRepoName, 'No repository found — ensure at least one repo is configured').not.toBe(''); repoItemPage = new NotebookRepoItemPage(page, firstRepoName); }); test('should disable save button when form is invalid', async () => { await repoItemPage.clickEdit(); + // JUSTIFIED: any row is sufficient — all rows share the same save-disable-on-empty behavior const firstRow = repoItemPage.settingRows.first(); + // JUSTIFIED: td.first() is the Name column in the fixed 2-column settings table const settingName = (await firstRow.locator('td').first().textContent()) || ''; await repoItemPage.fillSettingInput(settingName, ''); - const isSaveEnabled = await repoItemPage.isSaveButtonEnabled(); - expect(isSaveEnabled).toBe(false); + await expect(repoItemPage.saveButton).not.toBeEnabled(); }); test('should enable save button when form is valid', async () => { await repoItemPage.clickEdit(); + // JUSTIFIED: any row is sufficient — all rows share the same save-enable-on-valid behavior const firstRow = repoItemPage.settingRows.first(); + // JUSTIFIED: td.first() is the Name column in the fixed 2-column settings table const settingName = (await firstRow.locator('td').first().textContent()) || ''; const originalValue = await repoItemPage.getSettingInputValue(settingName); await repoItemPage.fillSettingInput(settingName, originalValue || 'valid-value'); - const isSaveEnabled = await repoItemPage.isSaveButtonEnabled(); - expect(isSaveEnabled).toBe(true); + await expect(repoItemPage.saveButton).toBeEnabled(); }); - test('should validate required fields on form controls', async () => { + test('should have editable controls for every setting row in edit mode', async () => { const settingRows = await repoItemPage.settingRows.count(); await repoItemPage.clickEdit(); for (let i = 0; i < settingRows; i++) { + // JUSTIFIED: nth(i) iterates all rows deterministically; order matches server-defined settings const row = repoItemPage.settingRows.nth(i); - const settingName = (await row.locator('td').first().textContent()) || ''; - - const isInputVisible = await repoItemPage.isInputVisible(settingName); - if (isInputVisible) { - const input = row.locator('input[nz-input]'); - await expect(input).toBeVisible(); - } - - const isDropdownVisible = await repoItemPage.isDropdownVisible(settingName); - if (isDropdownVisible) { - const select = row.locator('nz-select'); - await expect(select).toBeVisible(); + const input = row.locator('input[nz-input]'); + const select = row.locator('nz-select'); + await expect(input.or(select), `Row ${i} must have an editable control in edit mode`).toBeVisible(); + const isInputRow = await input.isVisible(); + if (isInputRow) { + // JUSTIFIED: attribute check only applies to INPUT-type rows; DROPDOWN-type rows use nz-select and have no nz-input + await expect(input).toHaveAttribute('nz-input'); } } }); 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 68cc608bb3c..db65b97063d 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 @@ -28,8 +28,10 @@ test.describe('Notebook Repository Item - Settings', () => { notebookReposPage = new NotebookReposPage(page); await notebookReposPage.navigate(); + // JUSTIFIED: .first() picks the first configured repo; tests require at least one repo to be present const firstCard = notebookReposPage.repositoryItems.first(); firstRepoName = (await firstCard.locator('.ant-card-head-title').textContent()) || ''; + expect(firstRepoName, 'No repository found — ensure at least one repo is configured').not.toBe(''); repoItemPage = new NotebookRepoItemPage(page, firstRepoName); }); @@ -37,79 +39,67 @@ test.describe('Notebook Repository Item - Settings', () => { await expect(repoItemPage.settingTable).toBeVisible(); const headers = repoItemPage.settingTable.locator('thead th'); - await expect(headers.nth(0)).toContainText('Name'); - await expect(headers.nth(1)).toContainText('Value'); + await expect(headers.filter({ hasText: 'Name' })).toBeVisible(); + await expect(headers.filter({ hasText: 'Value' })).toBeVisible(); }); - test('should display all setting rows', async () => { - const settingCount = await repoItemPage.getSettingCount(); - expect(settingCount).toBeGreaterThan(0); - }); - - test('should show input controls for INPUT type settings in edit mode', async () => { - const settingRows = await repoItemPage.settingRows.count(); - + test('should show input controls for INPUT type settings in edit mode', async ({ page }) => { await repoItemPage.clickEdit(); - for (let i = 0; i < settingRows; i++) { - const row = repoItemPage.settingRows.nth(i); - const settingName = (await row.locator('td').first().textContent()) || ''; + const inputRows = repoItemPage.settingRows.filter({ has: page.locator('input[nz-input]') }); + await expect(inputRows).not.toHaveCount(0); // repo must have at least one INPUT-type setting - const isInputVisible = await repoItemPage.isInputVisible(settingName); - if (isInputVisible) { - const input = row.locator('input[nz-input]'); - await expect(input).toBeVisible(); - await expect(input).toHaveAttribute('nz-input'); - } + const count = await inputRows.count(); + for (let i = 0; i < count; i++) { + // JUSTIFIED: nth(i) iterates all INPUT-type rows deterministically; order matches server-defined settings + const input = inputRows.nth(i).locator('input[nz-input]'); + await expect(input).toBeVisible(); + await expect(input).toHaveAttribute('nz-input'); } }); - test('should show dropdown controls for DROPDOWN type settings in edit mode', async () => { - const settingRows = await repoItemPage.settingRows.count(); - + test('should show dropdown controls for DROPDOWN type settings in edit mode', async ({ page }) => { await repoItemPage.clickEdit(); - for (let i = 0; i < settingRows; i++) { - const row = repoItemPage.settingRows.nth(i); - const settingName = (await row.locator('td').first().textContent()) || ''; + const dropdownRows = repoItemPage.settingRows.filter({ has: page.locator('nz-select') }); + const count = await dropdownRows.count(); + test.skip(count === 0, 'VFSNotebookRepo has no DROPDOWN-type settings in this environment'); - const isDropdownVisible = await repoItemPage.isDropdownVisible(settingName); - if (isDropdownVisible) { - const select = row.locator('nz-select'); - await expect(select).toBeVisible(); - } + for (let i = 0; i < count; i++) { + // JUSTIFIED: nth(i) iterates all DROPDOWN-type rows deterministically; order matches server-defined settings + await expect(dropdownRows.nth(i).locator('nz-select')).toBeVisible(); } }); - test('should update input value in edit mode', async () => { - const settingRows = await repoItemPage.settingRows.count(); - + test('should update input value in edit mode', async ({ page }) => { await repoItemPage.clickEdit(); - for (let i = 0; i < settingRows; i++) { - const row = repoItemPage.settingRows.nth(i); - const settingName = (await row.locator('td').first().textContent()) || ''; - - const isInputVisible = await repoItemPage.isInputVisible(settingName); - if (isInputVisible) { - const testValue = 'test-value'; - await repoItemPage.fillSettingInput(settingName, testValue); - const inputValue = await repoItemPage.getSettingInputValue(settingName); - expect(inputValue).toBe(testValue); - break; - } - } + const inputRows = repoItemPage.settingRows.filter({ has: page.locator('input[nz-input]') }); + await expect(inputRows).not.toHaveCount(0); // repo must have at least one INPUT-type setting + + // JUSTIFIED: any INPUT-type row works — all share the same input control structure + const firstRow = inputRows.first(); + // JUSTIFIED: td.first() is the Name column in the fixed 2-column settings table + const settingName = (await firstRow.locator('td').first().textContent()) || ''; + const testValue = 'test-value'; + await repoItemPage.fillSettingInput(settingName, testValue); + expect(await repoItemPage.getSettingInputValue(settingName)).toBe(testValue); }); test('should display setting name and value in display mode', async () => { + // JUSTIFIED: any row is sufficient — testing Name/Value column structure shared by all rows const firstRow = repoItemPage.settingRows.first(); + // JUSTIFIED: td.first() = Name column in the fixed 2-column settings table const nameCell = firstRow.locator('td').first(); + // JUSTIFIED: td.nth(1) = Value column in the fixed 2-column settings table const valueCell = firstRow.locator('td').nth(1); await expect(nameCell).toBeVisible(); await expect(valueCell).toBeVisible(); const nameText = await nameCell.textContent(); - expect(nameText).toBeTruthy(); + expect(nameText).not.toBe(''); + const valueText = await valueCell.textContent(); + expect(valueText).not.toBe(''); }); }); 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 52f3e429096..0fd368e4933 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 @@ -30,6 +30,7 @@ test.describe('Notebook Repository Item - Edit Workflow', () => { notebookReposPage = new NotebookReposPage(page); await notebookReposPage.navigate(); + // JUSTIFIED: .first() picks the first configured repo; tests require at least one repo to be present const firstCard = notebookReposPage.repositoryItems.first(); firstRepoName = (await firstCard.locator('.ant-card-head-title').textContent()) || ''; repoItemPage = new NotebookRepoItemPage(page, firstRepoName); @@ -44,30 +45,41 @@ test.describe('Notebook Repository Item - Edit Workflow', () => { await repoItemPage.clickEdit(); await repoItemUtil.verifyEditMode(); + let savedSettingName = ''; + let savedValue = ''; for (let i = 0; i < settingRows; i++) { + // JUSTIFIED: nth(i) iterates all rows deterministically to find the first INPUT-type row const row = repoItemPage.settingRows.nth(i); + // JUSTIFIED: td.first() is the Name column in the fixed 2-column settings table const settingName = (await row.locator('td').first().textContent()) || ''; - const isInputVisible = await repoItemPage.isInputVisible(settingName); + const isInputVisible = await row.locator('input[nz-input]').isVisible(); if (isInputVisible) { - const originalValue = await repoItemPage.getSettingInputValue(settingName); - await repoItemPage.fillSettingInput(settingName, originalValue || 'test-value'); + savedValue = (await repoItemPage.getSettingInputValue(settingName)) || 'test-value'; + await repoItemPage.fillSettingInput(settingName, savedValue); + savedSettingName = settingName; break; } } - const isSaveEnabled = await repoItemPage.isSaveButtonEnabled(); - expect(isSaveEnabled).toBe(true); + expect(savedSettingName, 'No INPUT-type setting found — cannot verify save result').not.toBe(''); + await expect(repoItemPage.saveButton).toBeEnabled(); await repoItemPage.clickSave(); await repoItemUtil.verifyDisplayMode(); + + // Verify the saved value is shown in display mode — not just that mode switched + const displayValue = await repoItemPage.getSettingValue(savedSettingName); + expect(displayValue.trim()).toBe(savedValue.trim()); }); test('should complete full edit workflow with cancel', async () => { await repoItemUtil.verifyDisplayMode(); + // JUSTIFIED: any row is representative — testing that cancel reverts all changes const firstRow = repoItemPage.settingRows.first(); + // JUSTIFIED: td.first() is the Name column in the fixed 2-column settings table const settingName = (await firstRow.locator('td').first().textContent()) || ''; const originalValue = await repoItemPage.getSettingValue(settingName); 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 747037ef47f..39cccf2c581 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 @@ -29,16 +29,14 @@ test.describe('Notebook Repository Page - Structure', () => { test('should display page header with correct title and description', async () => { await expect(notebookReposPage.zeppelinPageHeader).toBeVisible(); - await expect(notebookReposPage.pageDescription).toBeVisible(); + await expect(notebookReposPage.zeppelinPageHeader).toContainText('Notebook Repository'); + await expect(notebookReposPage.pageDescription).toContainText("Manage your Notebook Repositories' settings."); }); - test('should render repository list container', async () => { - const count = await notebookReposPage.getRepositoryItemCount(); - expect(count).toBeGreaterThanOrEqual(0); - }); - - test('should display all repository items', async () => { - const count = await notebookReposPage.getRepositoryItemCount(); - expect(count).toBeGreaterThan(0); + test('should display all repository items with names', async () => { + await expect(notebookReposPage.repositoryItems).not.toHaveCount(0); + // JUSTIFIED: .first() samples the first repo card; all cards share the same title structure + const firstTitle = notebookReposPage.repositoryItems.first().locator('.ant-card-head-title'); + await expect(firstTitle).not.toBeEmpty(); }); }); 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 a3e42474c02..106345fd2e9 100644 --- a/zeppelin-web-angular/e2e/tests/workspace/workspace-main.spec.ts +++ b/zeppelin-web-angular/e2e/tests/workspace/workspace-main.spec.ts @@ -11,58 +11,40 @@ */ import { expect, test } from '@playwright/test'; -import { WorkspacePage } from 'e2e/models/workspace-page'; -import { WorkspaceUtil } from '../../models/workspace-page.util'; +import { BasePage } from 'e2e/models/base-page'; import { addPageAnnotationBeforeEach, PAGES, performLoginIfRequired, waitForZeppelinReady } from '../../utils'; addPageAnnotationBeforeEach(PAGES.WORKSPACE.MAIN); test.describe('Workspace Main Component', () => { - let workspaceUtil: WorkspaceUtil; - let workspacePage: WorkspacePage; + let basePage: BasePage; test.beforeEach(async ({ page }) => { await page.goto('/#/'); await waitForZeppelinReady(page); await performLoginIfRequired(page); - workspacePage = new WorkspacePage(page); - workspaceUtil = new WorkspaceUtil(page); + basePage = new BasePage(page); }); test.describe('Given user accesses workspace container', () => { - test('When workspace loads Then should display main container structure', async ({ page }) => { - await expect(workspacePage.zeppelinWorkspace).toBeVisible(); - await expect(workspacePage.routerOutlet).toBeAttached(); - - await expect(workspacePage.zeppelinWorkspace).toBeVisible(); - const contentElements = await page.locator('.content').count(); - expect(contentElements).toBeGreaterThan(0); + test('When workspace loads Then should display main container structure', async () => { + await expect(basePage.zeppelinWorkspace).toBeVisible(); + // Verify workspace contains the header — not just that the elements exist in isolation + await expect(basePage.zeppelinWorkspace.locator('zeppelin-header')).toBeVisible(); }); test('When workspace loads Then should display header component', async () => { - await workspaceUtil.verifyHeaderVisibility(true); - }); - - test('When workspace loads Then should activate router outlet', async () => { - await workspaceUtil.verifyRouterOutletActivation(); - }); - - test('When component activates Then should trigger onActivate event', async () => { - await workspaceUtil.waitForComponentActivation(); + await expect(basePage.zeppelinHeader).toBeVisible(); + // Header must contain navigable content, not just be an empty shell + await expect(basePage.zeppelinHeader).toContainText('Zeppelin'); }); - }); - - test.describe('Given workspace header visibility', () => { - test('When not in publish mode Then should show header', async () => { - await workspaceUtil.verifyHeaderVisibility(true); - }); - }); - test.describe('Given router outlet functionality', () => { - test('When navigating to workspace Then should load child components', async () => { - await workspaceUtil.verifyRouterOutletActivation(); - await workspaceUtil.waitForComponentActivation(); + test('When workspace loads Then should have router outlet attached with home component', async ({ page }) => { + // Router outlet must have an activated child, not just exist as an empty outlet + await expect(page.locator('zeppelin-workspace router-outlet + *')).toHaveCount(1); + // Activated route must have rendered the home component + await expect(basePage.zeppelinWorkspace.locator('zeppelin-home')).toBeVisible(); }); }); }); diff --git a/zeppelin-web-angular/e2e/utils.ts b/zeppelin-web-angular/e2e/utils.ts index efd60965320..18a66a0ee6d 100644 --- a/zeppelin-web-angular/e2e/utils.ts +++ b/zeppelin-web-angular/e2e/utils.ts @@ -184,8 +184,6 @@ export const performLoginIfRequired = async (page: Page): Promise => { const loginPage = new LoginPage(page); await loginPage.login(testUser.username, testUser.password); - // for webkit - await page.waitForTimeout(200); await page.evaluate(() => { if (window.location.hash.includes('login')) { window.location.hash = '#/'; @@ -220,6 +218,7 @@ export const waitForZeppelinReady = async (page: Page): Promise => { // 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 = @@ -236,6 +235,7 @@ export const waitForZeppelinReady = async (page: Page): Promise => { // Wait for Angular and Zeppelin to be ready with more robust checks await page.waitForFunction( + // JUSTIFIED: multi-condition OR across DOM + textContent checks; textContent not expressible via Playwright locator API () => { // Check for Angular framework const hasAngular = document.querySelector('[ng-version]') !== null; @@ -347,9 +347,7 @@ const navigateViaHomePageFallback = async (page: Page, baseNotebookName: string) await page.waitForLoadState('networkidle', { timeout: 15000 }); await page.waitForSelector('zeppelin-node-list', { timeout: 15000 }); - await page.waitForFunction(() => document.querySelectorAll(NOTEBOOK_PATTERNS.LINK_SELECTOR).length > 0, { - timeout: 15000 - }); + await page.locator(NOTEBOOK_PATTERNS.LINK_SELECTOR).first().waitFor({ state: 'attached', timeout: 15000 }); await page.waitForLoadState('domcontentloaded', { timeout: 15000 }); const notebookLink = page.locator(NOTEBOOK_PATTERNS.LINK_SELECTOR).filter({ hasText: baseNotebookName }); @@ -386,6 +384,10 @@ const extractFirstParagraphId = async (page: Page): Promise => { await paragraphLink.waitFor({ state: 'attached', timeout: 15000 }); const paragraphId = await paragraphLink.textContent(); + + // Close the dropdown before returning — leaving it open leaks state into subsequent tests + await page.keyboard.press('Escape'); + if (!paragraphId || !paragraphId.startsWith('paragraph_')) { throw new Error(`Invalid paragraph ID found: ${paragraphId}`); } diff --git a/zeppelin-web-angular/playwright.config.js b/zeppelin-web-angular/playwright.config.js index 6e3e664bf07..06e92703854 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 : 10, + workers: process.env.CI ? 2 : 5, timeout: 300000, expect: { timeout: 60000 diff --git a/zeppelin-web-angular/projects/zeppelin-react/package-lock.json b/zeppelin-web-angular/projects/zeppelin-react/package-lock.json index ff9ba13cd01..2fa8a69a4bf 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/package-lock.json +++ b/zeppelin-web-angular/projects/zeppelin-react/package-lock.json @@ -4824,9 +4824,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, diff --git a/zeppelin-web-angular/src/app/pages/workspace/notebook/action-bar/action-bar.component.html b/zeppelin-web-angular/src/app/pages/workspace/notebook/action-bar/action-bar.component.html index 8e3bdea00b8..54f4e46853b 100644 --- a/zeppelin-web-angular/src/app/pages/workspace/notebook/action-bar/action-bar.component.html +++ b/zeppelin-web-angular/src/app/pages/workspace/notebook/action-bar/action-bar.component.html @@ -11,7 +11,7 @@ -->
-
+
Date: Sat, 4 Apr 2026 21:05:47 +0900 Subject: [PATCH 2/2] [ZEPPELIN-6358] Add E2E test coverage for notebook components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What is this PR for? This is the final PR in the series derived from #5101. Notebook features had zero E2E coverage. This adds 20 spec files (~3500 lines). **Notebook core** - `notebook-container` — structure, action bar presence, sidebar width constraints, paragraph grid layout, extension area - `action-bar-functionality` — run all, code/output toggle, clear output, clone/export/reload, collaboration mode, revision controls, scheduler, settings group - `notebook-keyboard-shortcuts` — full ShortcutsMap coverage (Monaco editor; serial because Monaco holds focus state between tests — isolating via `beforeEach` wasn't viable) - `sidebar-functionality` — TOC panel, file tree panel, open/close state transitions - `paragraph-functionality` — edit mode, run/cancel, dynamic forms, footer DOM presence **Share features** - `folder-rename` — hover context menu, rename modal, validation, delete confirmation, folder merge on name collision - `note-rename` — inline title editing, enter/blur/escape flows, empty name rejection, special characters - `note-toc` — panel open/close, empty state message, toggle button attributes, repeated toggle #### Pulled in test failure fixes from #5180 - Cleaned up `about-zeppelin-modal` and `note-create-modal` specs and models - Added missing aria attributes and `data-testid` selectors to `action-bar.component.html` - Bumped `flatted` 3.3.3 → 3.4.1 (npm audit) ### What type of PR is it? Improvement Feature Documentation ### Todos ### What is the Jira issue? ZEPPELIN-6358 ### How should this be tested? ### Screenshots (if appropriate) ### Questions: * Does the license files need to update? No * Is there breaking changes for older versions? No * Does this needs documentation? No Closes #5181 from dididy/e2e/notebook-final. Signed-off-by: Jongyoul Lee --- .../e2e/models/folder-rename-page.ts | 100 ++ .../e2e/models/folder-rename-page.util.ts | 38 + .../e2e/models/header-page.util.ts | 109 -- .../e2e/models/note-create-modal.util.ts | 40 - .../e2e/models/note-rename-page.ts | 68 ++ .../e2e/models/note-rename-page.util.ts | 34 + .../e2e/models/note-toc-page.ts | 44 + .../e2e/models/note-toc-page.util.ts | 32 + .../e2e/models/notebook-action-bar-page.ts | 113 ++ .../e2e/models/notebook-keyboard-page.ts | 696 +++++++++++ .../e2e/models/notebook-page.ts | 42 + .../e2e/models/notebook-paragraph-page.ts | 63 + .../e2e/models/notebook-sidebar-page.ts | 185 +++ .../action-bar-functionality.spec.ts | 220 ++++ .../notebook-keyboard-shortcuts.spec.ts | 1044 +++++++++++++++++ .../notebook/main/notebook-container.spec.ts | 74 ++ .../paragraph/paragraph-functionality.spec.ts | 186 +++ .../sidebar/sidebar-functionality.spec.ts | 86 ++ .../share/folder-rename/folder-rename.spec.ts | 146 +++ .../share/header/header-navigation.spec.ts | 91 +- .../tests/share/header/header-search.spec.ts | 12 +- .../note-create/note-create-modal.spec.ts | 16 +- .../share/note-rename/note-rename.spec.ts | 111 ++ .../e2e/tests/share/note-toc/note-toc.spec.ts | 84 ++ 24 files changed, 3452 insertions(+), 182 deletions(-) create mode 100644 zeppelin-web-angular/e2e/models/folder-rename-page.ts create mode 100644 zeppelin-web-angular/e2e/models/folder-rename-page.util.ts delete mode 100644 zeppelin-web-angular/e2e/models/header-page.util.ts delete mode 100644 zeppelin-web-angular/e2e/models/note-create-modal.util.ts create mode 100644 zeppelin-web-angular/e2e/models/note-rename-page.ts create mode 100644 zeppelin-web-angular/e2e/models/note-rename-page.util.ts create mode 100644 zeppelin-web-angular/e2e/models/note-toc-page.ts create mode 100644 zeppelin-web-angular/e2e/models/note-toc-page.util.ts create mode 100644 zeppelin-web-angular/e2e/models/notebook-action-bar-page.ts create mode 100644 zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts create mode 100644 zeppelin-web-angular/e2e/models/notebook-page.ts create mode 100644 zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts create mode 100644 zeppelin-web-angular/e2e/models/notebook-sidebar-page.ts create mode 100644 zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts create mode 100644 zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts create mode 100644 zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts create mode 100644 zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts create mode 100644 zeppelin-web-angular/e2e/tests/notebook/sidebar/sidebar-functionality.spec.ts create mode 100644 zeppelin-web-angular/e2e/tests/share/folder-rename/folder-rename.spec.ts create mode 100644 zeppelin-web-angular/e2e/tests/share/note-rename/note-rename.spec.ts create mode 100644 zeppelin-web-angular/e2e/tests/share/note-toc/note-toc.spec.ts diff --git a/zeppelin-web-angular/e2e/models/folder-rename-page.ts b/zeppelin-web-angular/e2e/models/folder-rename-page.ts new file mode 100644 index 00000000000..58f9327c6f3 --- /dev/null +++ b/zeppelin-web-angular/e2e/models/folder-rename-page.ts @@ -0,0 +1,100 @@ +/* + * 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, Locator, Page } from '@playwright/test'; +import { BasePage } from './base-page'; + +export class FolderRenamePage extends BasePage { + readonly folderList: Locator; + readonly renameModal: Locator; + readonly renameInput: Locator; + readonly confirmButton: Locator; + readonly cancelButton: Locator; + readonly deleteConfirmation: Locator; + + constructor(page: Page) { + super(page); + this.folderList = page.locator('zeppelin-node-list'); + this.renameModal = page.locator('.ant-modal'); + this.renameInput = page.locator('input[placeholder="Insert New Name"]'); + this.confirmButton = page.getByRole('button', { name: 'Rename' }); + this.cancelButton = page.locator('.ant-modal-close-x'); // Modal close button + 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(); + 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 + } + + async clickDeleteIcon(folderName: string): Promise { + await this.hoverOverFolder(folderName); + const folderNode = this.getFolderNode(folderName); + const deleteIcon = folderNode.locator( + '.operation a[nztooltiptitle*="Move folder to Trash"], .operation a[nztooltiptitle*="Trash"]' + ); + await expect(deleteIcon).toBeVisible({ timeout: 5000 }); + await deleteIcon.click({ force: true }); // JUSTIFIED: icon is only actionable after CSS-:hover; force bypasses the actionability check that fails before hover state propagates + } + + async clickRenameMenuItem(folderName: string): Promise { + const folderNode = this.getFolderNode(folderName); + const nameLink = folderNode.locator('a.name'); + + await nameLink.scrollIntoViewIfNeeded(); + 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"]'); + + await expect(renameIcon).toBeVisible({ timeout: 3000 }); + await renameIcon.click({ force: true }); // JUSTIFIED: icon is only actionable after CSS-:hover; force bypasses the actionability check that fails before hover state propagates + + await this.renameModal.waitFor({ state: 'visible', timeout: 3000 }); + } + + async enterNewName(name: string): Promise { + await this.renameInput.fill(name); + } + + async clearNewName(): Promise { + await this.renameInput.clear(); + await expect(this.renameInput).toHaveValue(''); + } + + async clickConfirm(): Promise { + // Wait for button to be enabled before clicking + await expect(this.confirmButton).toBeEnabled({ timeout: 5000 }); + await this.confirmButton.click(); + + // Wait for modal to close; if it stays open validation errors prevented submission (caller re-checks) + await this.renameModal.waitFor({ state: 'detached', timeout: 3000 }).catch(() => {}); // JUSTIFIED: modal stays open on validation failure; caller asserts final state + } + + async clickCancel(): Promise { + await this.cancelButton.click(); + } +} diff --git a/zeppelin-web-angular/e2e/models/folder-rename-page.util.ts b/zeppelin-web-angular/e2e/models/folder-rename-page.util.ts new file mode 100644 index 00000000000..f1e32b1ded3 --- /dev/null +++ b/zeppelin-web-angular/e2e/models/folder-rename-page.util.ts @@ -0,0 +1,38 @@ +/* + * 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 { FolderRenamePage } from './folder-rename-page'; + +export class FolderRenamePageUtil { + private folderRenamePage: FolderRenamePage; + + constructor(folderRenamePage: FolderRenamePage) { + this.folderRenamePage = folderRenamePage; + } + + async openContextMenuOnHoverAndVerifyOptions(folderName: string): Promise { + await this.folderRenamePage.hoverOverFolder(folderName); + const folderNode = this.getFolderNode(folderName); + await expect(folderNode.locator('.folder .operation a[nz-tooltip][nztooltiptitle="Rename folder"]')).toHaveCount(1); + await expect(folderNode.locator('.folder .operation a[nztooltiptitle*="Move folder to Trash"]')).toBeVisible(); + } + + private getFolderNode(folderName: string) { + return this.folderRenamePage.page + .locator('.node') + .filter({ + has: this.folderRenamePage.page.locator('.folder .name', { hasText: folderName }) + }) + .first(); + } +} diff --git a/zeppelin-web-angular/e2e/models/header-page.util.ts b/zeppelin-web-angular/e2e/models/header-page.util.ts deleted file mode 100644 index 14a369eb0ec..00000000000 --- a/zeppelin-web-angular/e2e/models/header-page.util.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* - * 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/note-create-modal.util.ts b/zeppelin-web-angular/e2e/models/note-create-modal.util.ts deleted file mode 100644 index 7553325c1e2..00000000000 --- a/zeppelin-web-angular/e2e/models/note-create-modal.util.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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-rename-page.ts b/zeppelin-web-angular/e2e/models/note-rename-page.ts new file mode 100644 index 00000000000..932babc4092 --- /dev/null +++ b/zeppelin-web-angular/e2e/models/note-rename-page.ts @@ -0,0 +1,68 @@ +/* + * 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 NoteRenamePage extends BasePage { + readonly noteTitle: Locator; + readonly noteTitleInput: Locator; + + constructor(page: Page) { + super(page); + // Note title in elastic input component inside the heading element + this.noteTitle = page.getByRole('heading').locator('p'); + this.noteTitleInput = page.getByRole('heading').locator('input'); + } + + async clickTitle(): Promise { + await this.noteTitle.click({ timeout: 15000 }); + await this.noteTitleInput.waitFor({ state: 'visible', timeout: 5000 }); + } + + async enterTitle(title: string): Promise { + await this.ensureEditMode(); + await this.noteTitleInput.fill(title, { timeout: 15000 }); + } + + async clearTitle(): Promise { + await this.ensureEditMode(); + await this.noteTitleInput.clear(); + } + + async pressEnter(): Promise { + await this.noteTitleInput.waitFor({ state: 'visible', timeout: 5000 }); + await this.noteTitleInput.press('Enter'); + await this.noteTitleInput.waitFor({ state: 'hidden', timeout: 5000 }); + } + + async pressEscape(): Promise { + await this.noteTitleInput.waitFor({ state: 'visible', timeout: 5000 }); + await this.noteTitleInput.press('Escape'); + await this.noteTitleInput.waitFor({ state: 'hidden', timeout: 5000 }); + } + + async blur(): Promise { + await this.noteTitleInput.blur(); + } + + async getTitle(): Promise { + return this.getElementText(this.noteTitle); + } + + private async ensureEditMode(): Promise { + if (!(await this.noteTitleInput.isVisible())) { + await this.clickTitle(); + } + await this.noteTitleInput.waitFor({ state: 'visible' }); + } +} diff --git a/zeppelin-web-angular/e2e/models/note-rename-page.util.ts b/zeppelin-web-angular/e2e/models/note-rename-page.util.ts new file mode 100644 index 00000000000..e1208aa6eb4 --- /dev/null +++ b/zeppelin-web-angular/e2e/models/note-rename-page.util.ts @@ -0,0 +1,34 @@ +/* + * 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 { NoteRenamePage } from './note-rename-page'; + +export class NoteRenamePageUtil { + private noteRenamePage: NoteRenamePage; + + constructor(noteRenamePage: NoteRenamePage) { + this.noteRenamePage = noteRenamePage; + } + + async verifyTitleText(expectedTitle: string): Promise { + await expect(this.noteRenamePage.noteTitle).toContainText(expectedTitle); + } + + async verifyTitleCanBeChanged(newTitle: string): Promise { + await this.noteRenamePage.clickTitle(); + await this.noteRenamePage.clearTitle(); + await this.noteRenamePage.enterTitle(newTitle); + await this.noteRenamePage.pressEnter(); + await this.verifyTitleText(newTitle); + } +} diff --git a/zeppelin-web-angular/e2e/models/note-toc-page.ts b/zeppelin-web-angular/e2e/models/note-toc-page.ts new file mode 100644 index 00000000000..b7ea4644879 --- /dev/null +++ b/zeppelin-web-angular/e2e/models/note-toc-page.ts @@ -0,0 +1,44 @@ +/* + * 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 { NotebookKeyboardPage } from './notebook-keyboard-page'; + +export class NoteTocPage extends NotebookKeyboardPage { + readonly tocToggleButton: Locator; + readonly tocPanel: Locator; + readonly tocTitle: Locator; + readonly tocCloseButton: Locator; + readonly tocEmptyMessage: Locator; + + constructor(page: Page) { + super(page); + this.tocToggleButton = page.getByRole('button', { name: 'Toggle Table of Contents' }); + this.tocPanel = page.locator('zeppelin-note-toc').first(); + this.tocTitle = page.getByText('Table of Contents'); + this.tocCloseButton = page.getByRole('button', { name: 'Close Sidebar' }); + this.tocEmptyMessage = page.getByText('Headings in the output show up here'); + } + + async clickTocToggle(): Promise { + await this.tocToggleButton.click(); + } + + async clickTocClose(): Promise { + try { + await this.tocCloseButton.click({ timeout: 5000 }); + } catch { + // Fallback: try to click the TOC toggle again to close + await this.tocToggleButton.click(); + } + } +} diff --git a/zeppelin-web-angular/e2e/models/note-toc-page.util.ts b/zeppelin-web-angular/e2e/models/note-toc-page.util.ts new file mode 100644 index 00000000000..5a40bf1b118 --- /dev/null +++ b/zeppelin-web-angular/e2e/models/note-toc-page.util.ts @@ -0,0 +1,32 @@ +/* + * 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 { NoteTocPage } from './note-toc-page'; + +export class NoteTocPageUtil { + private noteTocPage: NoteTocPage; + + constructor(noteTocPage: NoteTocPage) { + this.noteTocPage = noteTocPage; + } + + async verifyTocPanelOpens(): Promise { + await this.noteTocPage.clickTocToggle(); + await expect(this.noteTocPage.tocPanel).toBeVisible(); + } + + async verifyTocPanelCloses(): Promise { + await this.noteTocPage.clickTocClose(); + await expect(this.noteTocPage.tocPanel).not.toBeVisible(); + } +} diff --git a/zeppelin-web-angular/e2e/models/notebook-action-bar-page.ts b/zeppelin-web-angular/e2e/models/notebook-action-bar-page.ts new file mode 100644 index 00000000000..d73b268c2dd --- /dev/null +++ b/zeppelin-web-angular/e2e/models/notebook-action-bar-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 NotebookActionBarPage extends BasePage { + readonly titleEditor: Locator; + readonly runAllButton: Locator; + readonly showHideCodeButton: Locator; + readonly showHideOutputButton: Locator; + readonly clearOutputButton: Locator; + readonly cloneButton: Locator; + readonly exportButton: Locator; + readonly reloadButton: Locator; + readonly collaborationModeToggle: Locator; + readonly personalModeButton: Locator; + readonly collaborationModeButton: Locator; + readonly commitButton: Locator; + readonly setRevisionButton: Locator; + readonly compareRevisionsButton: Locator; + readonly revisionDropdown: Locator; + readonly revisionDropdownMenu: Locator; + readonly schedulerButton: Locator; + readonly schedulerDropdown: Locator; + readonly cronInput: Locator; + readonly cronPresets: Locator; + readonly shortcutInfoButton: Locator; + readonly interpreterSettingsButton: Locator; + readonly permissionsButton: Locator; + readonly lookAndFeelDropdown: Locator; + + constructor(page: Page) { + super(page); + this.titleEditor = page.locator('zeppelin-elastic-input'); + this.runAllButton = page.locator('button[nzTooltipTitle="Run all paragraphs"]'); + this.showHideCodeButton = page.locator('button[nzTooltipTitle="Show/hide the code"]'); + this.showHideOutputButton = page.locator('button[nzTooltipTitle="Show/hide the output"]'); + this.clearOutputButton = page.locator('button[nzTooltipTitle="Clear all output"]'); + this.cloneButton = page.locator('button[nzTooltipTitle="Clone this note"]'); + this.exportButton = page.locator('button[nzTooltipTitle="Export this note"]'); + this.reloadButton = page.locator('button[nzTooltipTitle="Reload from note file"]'); + this.collaborationModeToggle = page.locator('ng-container[ngSwitch="note.config.personalizedMode"]'); + this.personalModeButton = page.getByRole('button', { name: 'Personal' }); + this.collaborationModeButton = page.getByRole('button', { name: 'Collaboration' }); + this.commitButton = page.getByRole('button', { name: 'Commit' }); + this.setRevisionButton = page.getByRole('button', { name: 'Set as default revision' }); + this.compareRevisionsButton = page.getByRole('button', { name: 'Compare with current revision' }); + this.revisionDropdown = page.locator('button[nz-dropdown]').filter({ hasText: 'Revision' }); + this.revisionDropdownMenu = page.locator('nz-dropdown-menu'); + this.schedulerButton = page.locator('button[nz-dropdown]').filter({ hasText: 'Scheduler' }); + this.schedulerDropdown = page.locator('.scheduler-dropdown'); + this.cronInput = page.locator('input[placeholder*="cron"]'); + this.cronPresets = page.locator('.cron-preset'); + this.shortcutInfoButton = page.locator('.setting button:has(i[nzType="info-circle"])'); + this.interpreterSettingsButton = page.locator('.setting button:has(i[nzType="setting"])'); + this.permissionsButton = page.locator('.setting button:has(i[nzType="lock"])'); + this.lookAndFeelDropdown = page.locator('.setting button[nz-dropdown]:has(i[nzType="down"])'); + } + + async clickRunAll(): Promise { + await this.runAllButton.click(); + } + + async toggleCodeVisibility(): Promise { + await this.showHideCodeButton.click(); + } + + async toggleOutputVisibility(): Promise { + await this.showHideOutputButton.click(); + } + + async clickClearOutput(): Promise { + await this.clearOutputButton.click(); + } + + async switchToPersonalMode(): Promise { + await this.personalModeButton.click(); + } + + async switchToCollaborationMode(): Promise { + await this.collaborationModeButton.click(); + } + + async openRevisionDropdown(): Promise { + await this.revisionDropdown.click(); + } + + async openSchedulerDropdown(): Promise { + await this.schedulerButton.click(); + } + + async isCodeVisible(): Promise { + const icon = this.showHideCodeButton.locator('i[nz-icon] svg'); + const iconType = await icon.getAttribute('data-icon'); + return iconType === 'fullscreen-exit'; + } + + async isOutputVisible(): Promise { + const icon = this.showHideOutputButton.locator('i[nz-icon] svg'); + const iconType = await icon.getAttribute('data-icon'); + return iconType === 'read'; + } +} diff --git a/zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts b/zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts new file mode 100644 index 00000000000..7c2d8b875c7 --- /dev/null +++ b/zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts @@ -0,0 +1,696 @@ +/* + * 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, Locator, Page } from '@playwright/test'; +import { navigateToNotebookWithFallback } from '../utils'; +import { ShortcutsMap } from '../../src/app/key-binding/shortcuts-map'; +import { ParagraphActions } from '../../src/app/key-binding/paragraph-actions'; +import { BasePage } from './base-page'; + +const PARAGRAPH_RESULT_SELECTOR = '[data-testid="paragraph-result"]'; + +export class NotebookKeyboardPage extends BasePage { + readonly codeEditor: Locator; + readonly paragraphContainer: Locator; + readonly firstParagraph: Locator; + readonly runButton: Locator; + readonly paragraphResult: Locator; + readonly newParagraphButton: Locator; + readonly interpreterSelector: Locator; + readonly interpreterDropdown: Locator; + readonly autocompletePopup: Locator; + readonly autocompleteItems: Locator; + readonly paragraphTitle: Locator; + readonly editorLines: Locator; + readonly cursorLine: Locator; + readonly settingsButton: Locator; + readonly clearOutputOption: Locator; + readonly deleteButton: Locator; + readonly addParagraphComponent: Locator; + readonly searchDialog: Locator; + readonly modal: Locator; + readonly okButtons: Locator; + + constructor(page: Page) { + super(page); + this.codeEditor = page.locator('.monaco-editor .monaco-mouse-cursor-text'); + this.paragraphContainer = page.locator('zeppelin-notebook-paragraph'); + this.firstParagraph = this.paragraphContainer.first(); + this.runButton = page.locator('button[title="Run this paragraph"], button:has-text("Run")'); + this.paragraphResult = page.locator(PARAGRAPH_RESULT_SELECTOR); + this.newParagraphButton = page.locator('button:has-text("Add Paragraph"), .new-paragraph-button'); + this.interpreterSelector = page.locator('.interpreter-selector'); + this.interpreterDropdown = page.locator('nz-select[ng-reflect-nz-placeholder="Interpreter"]'); + this.autocompletePopup = page.locator('.monaco-editor .suggest-widget'); + this.autocompleteItems = page.locator('.monaco-editor .suggest-widget .monaco-list-row'); + this.paragraphTitle = page.locator('.paragraph-title'); + this.editorLines = page.locator('.monaco-editor .view-lines'); + this.cursorLine = page.locator('.monaco-editor .current-line'); + this.settingsButton = page.locator('a[nz-dropdown]'); + this.clearOutputOption = page.locator('li.list-item:has-text("Clear output")'); + this.deleteButton = page.locator('button:has-text("Delete"), .delete-paragraph-button'); + this.addParagraphComponent = page.locator('zeppelin-notebook-add-paragraph').last(); // last() — the add-paragraph strip at the bottom of the notebook; the first() is the top strip and is less reliable for insertions + this.searchDialog = page.locator( + '.dropdown-menu.search-code, .search-widget, .find-widget, [role="dialog"]:has-text("Find")' + ); + this.modal = page.locator('.ant-modal, .modal-dialog, .ant-modal-confirm'); + this.okButtons = page.locator( + 'button:has-text("OK"), button:has-text("Ok"), button:has-text("Okay"), button:has-text("Confirm")' + ); + } + + async navigateToNotebook(noteId: string): Promise { + if (!noteId) { + throw new Error('noteId is undefined or null. Cannot navigate to notebook.'); + } + + await navigateToNotebookWithFallback(this.page, noteId); + + // Verify we're actually on a notebook page before checking for paragraphs + await expect(this.page).toHaveURL(new RegExp(`/notebook/${noteId}`), { timeout: 15000 }); + + // Ensure paragraphs are visible after navigation with longer timeout + await expect(this.paragraphContainer.first()).toBeVisible({ timeout: 30000 }); + } + + async tryFocusCodeEditor(paragraphIndex: number = 0): Promise { + if (this.page.isClosed()) { + console.warn('Cannot focus code editor: page is closed'); + return; + } + + const paragraphCount = await this.getParagraphCount(); + if (paragraphCount === 0) { + console.warn('No paragraphs found on page, cannot focus editor'); + return; + } + + const paragraph = this.getParagraphByIndex(paragraphIndex); + await paragraph.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {}); // JUSTIFIED: paragraph may not exist yet; caller guards with paragraphCount check + + // Wait for any loading/rendering to complete + await this.page.waitForLoadState('domcontentloaded'); + + const browserName = this.page.context().browser()?.browserType().name(); + if (browserName === 'firefox' || browserName === 'chromium') { + // Additional wait for Firefox to ensure editor is fully ready + await this.page.waitForTimeout(200); // JUSTIFIED: Monaco editor requires extra settle time in Firefox before focus dispatch + } + + await this.focusEditorElement(paragraph, paragraphIndex); + } + + async typeInEditor(text: string): Promise { + await this.page.keyboard.type(text); + } + + async pressKey(key: string): Promise { + await this.page.keyboard.press(key); + } + + async pressControlEnter(): Promise { + await this.page.keyboard.press('Control+Enter'); + } + + async pressControlSpace(): Promise { + await this.page.keyboard.press('Control+Space'); + } + + async pressArrowDown(): Promise { + await this.page.keyboard.press('ArrowDown'); + } + + async pressArrowUp(): Promise { + await this.page.keyboard.press('ArrowUp'); + } + + async pressArrowRight(): Promise { + await this.page.keyboard.press('ArrowRight'); + } + + async pressTab(): Promise { + await this.page.keyboard.press('Tab'); + } + + async pressEscape(): Promise { + await this.page.keyboard.press('Escape'); + } + + async pressSelectAll(): Promise { + const isWebkit = this.page.context().browser()?.browserType().name() === 'webkit'; + if (isWebkit) { + await this.page.keyboard.press('Meta+A'); + } else { + await this.page.keyboard.press('ControlOrMeta+A'); + } + } + + // Run paragraph - shift.enter + async pressRunParagraph(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.Run]); + } + + // Run all above paragraphs - control.shift.arrowup + async pressRunAbove(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.RunAbove]); + } + + // Run all below paragraphs - control.shift.arrowdown + async pressRunBelow(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.RunBelow]); + } + + // Cancel - control.alt.c (or control.alt.ç for macOS) + async pressCancel(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.Cancel]); + } + + // Move cursor up - control.p + async pressMoveCursorUp(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.MoveCursorUp]); + } + + // Move cursor down - control.n + async pressMoveCursorDown(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.MoveCursorDown]); + } + + // Delete paragraph - control.alt.d (or control.alt.∂ for macOS) + async pressDeleteParagraph(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.Delete]); + } + + // Insert paragraph above - control.alt.a (or control.alt.å for macOS) + async pressInsertAbove(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.InsertAbove]); + } + + // Insert paragraph below - control.alt.b (or control.alt.∫ for macOS) + async pressInsertBelow(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.InsertBelow]); + } + + async addParagraph(): Promise { + const currentCount = await this.getParagraphCount(); + console.log(`[addParagraph] Paragraph count before: ${currentCount}`); + + await this.addParagraphComponent.hover(); + await this.addParagraphComponent.locator('a.inner').click(); + console.log(`[addParagraph] "Add Paragraph" button clicked`); + + // Wait for paragraph count to increase + await this.page.waitForFunction( + expectedCount => document.querySelectorAll('zeppelin-notebook-paragraph').length > expectedCount, // JUSTIFIED: waitForFunction polls DOM count — Playwright toHaveCount() requires exact match, not minimum + currentCount, + { timeout: 10000 } + ); + + const newCount = await this.getParagraphCount(); + console.log(`[addParagraph] Success! Paragraph count increased from ${currentCount} to ${newCount}`); + } + + // Insert copy of paragraph below - control.shift.c + async pressInsertCopy(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.InsertCopyOfParagraphBelow]); + } + + // Move paragraph up - control.alt.k (or control.alt.˚ for macOS) + async pressMoveParagraphUp(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.MoveParagraphUp]); + } + + // Move paragraph down - control.alt.j (or control.alt.∆ for macOS) + async pressMoveParagraphDown(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.MoveParagraphDown]); + } + + // Switch editor - control.alt.e + async pressSwitchEditor(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.SwitchEditor]); + } + + // Switch enable/disable paragraph - control.alt.r (or control.alt.® for macOS) + async pressSwitchEnable(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.SwitchEnable]); + } + + // Switch output show/hide - control.alt.o (or control.alt.ø for macOS) + async pressSwitchOutputShow(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.SwitchOutputShow]); + } + + // Switch line numbers - control.alt.m (or control.alt.µ for macOS) + async pressSwitchLineNumber(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.SwitchLineNumber]); + } + + // Switch title show/hide - control.alt.t (or control.alt.† for macOS) + async pressSwitchTitleShow(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.SwitchTitleShow]); + } + + // Clear output - control.alt.l (or control.alt.¬ for macOS) + async pressClearOutput(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.Clear]); + } + + // Link this paragraph - control.alt.w (or control.alt.∑ for macOS) + async pressLinkParagraph(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.Link]); + } + + // Reduce paragraph width - control.shift.- + async pressReduceWidth(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.ReduceWidth]); + } + + // Increase paragraph width - control.shift.= + async pressIncreaseWidth(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.IncreaseWidth]); + } + + // Cut line - control.k + async pressCutLine(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.CutLine]); + } + + // Paste line - control.y + async pressPasteLine(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.PasteLine]); + } + + // Search inside code - control.s + async pressSearchInsideCode(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.SearchInsideCode]); + } + + // Find in code - control.alt.f (or control.alt.ƒ for macOS) + async pressFindInCode(): Promise { + await this.executePlatformShortcut(ShortcutsMap[ParagraphActions.FindInCode]); + } + + async getParagraphCount(): Promise { + if (this.page.isClosed()) { + return 0; + } + return await this.paragraphContainer.count(); + } + + getParagraphByIndex(index: number): Locator { + return this.paragraphContainer.nth(index); + } + + async isAutocompleteVisible(): Promise { + return await this.autocompletePopup.isVisible(); + } + + async getAutocompleteItemCount(): Promise { + if (await this.isAutocompleteVisible()) { + return await this.autocompleteItems.count(); + } + return 0; + } + + async isParagraphResultSettled(paragraphIndex: number = 0): Promise { + if (this.page.isClosed()) { + return false; + } + + const paragraph = this.getParagraphByIndex(paragraphIndex); + + // Check status from DOM directly + const statusElement = paragraph.locator('.status'); + if (await statusElement.isVisible()) { + const status = await statusElement.textContent(); + console.log(`Paragraph ${paragraphIndex} status: ${status}`); + + // NOTE: accept PENDING/RUNNING states as "settled" because + // these browsers may maintain execution in these states longer than Chromium, + // but the paragraph execution has been triggered successfully and will complete. + // The key is that execution started, not necessarily that it finished. + + if (status === 'FINISHED' || status === 'ERROR' || status === 'PENDING' || status === 'RUNNING') { + return true; + } + } + + return false; + } + + async getCodeEditorContent(): Promise { + // Fallback to Angular scope + const angularContent = await this.page.evaluate(() => { + const paragraphElement = document.querySelector('zeppelin-notebook-paragraph'); // JUSTIFIED: accesses AngularJS $scope via window.angular — not accessible via Playwright locator API + if (paragraphElement) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const angular = (window as any).angular; + if (angular) { + const scope = angular.element(paragraphElement).scope(); + if (scope && scope.$ctrl && scope.$ctrl.paragraph) { + return scope.$ctrl.paragraph.text || ''; + } + } + } + return null; + }); + + if (angularContent !== null) { + return angularContent; + } + + // Fallback to DOM-based approaches + const selectors = ['.monaco-editor .view-lines', '.CodeMirror-line', '.ace_line', 'textarea']; + + for (const selector of selectors) { + const element = this.page.locator(selector).first(); + if (await element.isVisible({ timeout: 1000 })) { + if (selector === 'textarea') { + return await element.inputValue(); + } else { + return (await element.textContent()) || ''; + } + } + } + + return ''; + } + + async setCodeEditorContent(content: string, paragraphIndex: number = 0): Promise { + if (this.page.isClosed()) { + console.warn('Cannot set code editor content: page is closed'); + return; + } + + await this.tryFocusCodeEditor(paragraphIndex); + if (this.page.isClosed()) { + console.warn('Cannot set code editor content: page closed after focusing'); + return; + } + + const paragraph = this.getParagraphByIndex(paragraphIndex); + const editorInput = paragraph.locator('.monaco-editor .inputarea, .monaco-editor textarea').first(); + + const browserName = test.info().project.name; + if (browserName !== 'firefox') { + await editorInput.waitFor({ state: 'visible', timeout: 30000 }); + await editorInput.click(); + await editorInput.clear(); + } + + // Clear existing content with keyboard shortcuts for better reliability + await editorInput.focus(); + + if (browserName === 'firefox') { + // Clear by backspacing existing content length + const currentContent = await editorInput.inputValue(); + const contentLength = currentContent.length; + + // Position cursor at end and backspace all content + await this.page.keyboard.press('End'); + for (let i = 0; i < contentLength; i++) { + await this.page.keyboard.press('Backspace'); + } + await this.page.waitForTimeout(100); // JUSTIFIED: Monaco content state settle between backspaces and new input + + await this.page.keyboard.type(content); + + await this.page.waitForTimeout(300); // JUSTIFIED: Monaco content state settle after keystroke sequence + } else { + // Standard clearing for other browsers + await this.pressSelectAll(); + await this.page.keyboard.press('Delete'); + await editorInput.fill(content, { force: true }); // JUSTIFIED: Monaco textarea may be overlaid by editor decorations after select+delete; force required for programmatic fill + } + + await this.page.waitForTimeout(200); // JUSTIFIED: Monaco content state settle after fill completes + } + + // Helper methods for verifying shortcut effects + async waitForParagraphExecution(paragraphIndex: number = 0, timeout: number = 30000): Promise { + if (this.page.isClosed()) { + console.warn('Cannot wait for paragraph execution: page is closed'); + return; + } + + const paragraph = this.getParagraphByIndex(paragraphIndex); + + // Step 1: Wait for execution to start + await this.waitForExecutionStart(paragraphIndex); + + // Step 2: Wait for execution to complete + const runningIndicator = paragraph.locator( + '.paragraph-control .fa-spin, .running-indicator, .paragraph-status-running' + ); + await this.waitForExecutionComplete(runningIndicator, paragraphIndex, timeout); + + // Step 3: Wait for result to be visible + await this.waitForResultVisible(paragraphIndex, timeout); + } + + async isParagraphEnabled(paragraphIndex: number = 0): Promise { + const paragraph = this.getParagraphByIndex(paragraphIndex); + const runButton = paragraph.locator('i[nztooltiptitle="Run paragraph"]'); + return await runButton.isVisible(); + } + + async isEditorVisible(paragraphIndex: number = 0): Promise { + const paragraph = this.getParagraphByIndex(paragraphIndex); + const editor = paragraph.locator('zeppelin-notebook-paragraph-code-editor'); + return await editor.isVisible(); + } + + async isOutputVisible(paragraphIndex: number = 0): Promise { + const paragraph = this.getParagraphByIndex(paragraphIndex); + const output = paragraph.locator(PARAGRAPH_RESULT_SELECTOR); + return await output.isVisible(); + } + + async areLineNumbersVisible(paragraphIndex: number = 0): Promise { + if (this.page.isClosed()) { + return false; + } + const paragraph = this.getParagraphByIndex(paragraphIndex); + const lineNumbers = paragraph.locator('.monaco-editor .margin .line-numbers').first(); + return await lineNumbers.isVisible(); + } + + async isTitleVisible(paragraphIndex: number = 0): Promise { + const paragraph = this.getParagraphByIndex(paragraphIndex); + const title = paragraph.locator('.paragraph-title, zeppelin-elastic-input'); + return await title.isVisible(); + } + + async getParagraphWidth(paragraphIndex: number = 0): Promise { + const paragraph = this.getParagraphByIndex(paragraphIndex); + const boundingBox = await paragraph.boundingBox(); + return boundingBox?.width || 0; + } + + async getCodeEditorContentByIndex(paragraphIndex: number): Promise { + const paragraph = this.getParagraphByIndex(paragraphIndex); + + const editorTextarea = paragraph.locator('.monaco-editor textarea'); + if (await editorTextarea.isVisible()) { + const textContent = await editorTextarea.inputValue(); + if (textContent) { + return textContent; + } + } + + const viewLines = paragraph.locator('.monaco-editor .view-lines'); + if (await viewLines.isVisible()) { + const text = await viewLines.evaluate((el: Element) => (el as HTMLElement).innerText || ''); + if (text && text.trim().length > 0) { + return text; + } + } + + const scopeContent = await paragraph.evaluate(el => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const angular = (window as any).angular; + if (angular) { + const scope = angular.element(el).scope(); + if (scope && scope.$ctrl && scope.$ctrl.paragraph) { + return scope.$ctrl.paragraph.text || ''; + } + } + return ''; + }); + + if (scopeContent) { + return scopeContent; + } + + return ''; + } + + async waitForParagraphCountChange(expectedCount: number, timeout: number = 30000): Promise { + if (this.page.isClosed()) { + return; + } + + await expect(this.paragraphContainer).toHaveCount(expectedCount, { timeout }); + } + + async isSearchDialogVisible(): Promise { + return await this.searchDialog.isVisible(); + } + + async tryClickModalOkButton(timeout: number = 30000): Promise { + await this.modal.waitFor({ state: 'visible', timeout }); + + const count = await this.okButtons.count(); + if (count === 0) { + console.log('⚠️ No OK buttons found.'); + return; + } + + for (let i = 0; i < count; i++) { + const button = this.okButtons.nth(i); + await button.waitFor({ state: 'visible', timeout }); + await button.click({ delay: 100 }); + await this.modal.waitFor({ state: 'hidden', timeout: 2000 }).catch(() => {}); // JUSTIFIED: UI stabilization — next iteration or detach check handles remaining modals + } + + await this.modal.waitFor({ state: 'detached', timeout: 2000 }).catch(() => {}); // JUSTIFIED: UI stabilization — some modals may legitimately remain open + } + + private async waitForExecutionStart(paragraphIndex: number): Promise { + const started = await this.page + .waitForFunction( + // waitForFunction executes in browser context, not Node.js context. + // Browser cannot access Node.js variables like PARAGRAPH_RESULT_SELECTOR. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ([index, selector]: any[]) => { + const paragraphs = document.querySelectorAll('zeppelin-notebook-paragraph'); // JUSTIFIED: index-based paragraph lookup with sub-element checks not expressible via Playwright locator API + const targetParagraph = paragraphs[index]; + if (!targetParagraph) { + return false; + } + + const hasRunning = targetParagraph.querySelector('.fa-spin, .running-indicator, .paragraph-status-running'); + const hasResult = targetParagraph.querySelector(selector); + + return hasRunning || hasResult; + }, + [paragraphIndex, PARAGRAPH_RESULT_SELECTOR], + { timeout: 8000 } + ) + .catch(() => false); // JUSTIFIED: execution may not start within timeout; caller falls back to checking existing result + + if (!started) { + const paragraph = this.getParagraphByIndex(paragraphIndex); + const existingResult = await paragraph.locator(PARAGRAPH_RESULT_SELECTOR).isVisible(); + if (!existingResult) { + console.log(`Warning: Could not detect execution start for paragraph ${paragraphIndex}`); + } + } + } + + private async waitForExecutionComplete( + runningIndicator: Locator, + paragraphIndex: number, + timeout: number + ): Promise { + if (this.page.isClosed()) { + return; + } + + await runningIndicator.waitFor({ state: 'detached', timeout: timeout / 2 }).catch(() => {}); // JUSTIFIED: UI stabilization — paragraph may have completed before indicator appeared + } + + private async waitForResultVisible(paragraphIndex: number, timeout: number): Promise { + if (this.page.isClosed()) { + return; + } + + const resultVisible = await this.page + .waitForFunction( + // waitForFunction executes in browser context, not Node.js context. + // Browser cannot access Node.js variables like PARAGRAPH_RESULT_SELECTOR. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ([index, selector]: any[]) => { + const paragraphs = document.querySelectorAll('zeppelin-notebook-paragraph'); // JUSTIFIED: index-based paragraph lookup with sub-element checks not expressible via Playwright locator API + const targetParagraph = paragraphs[index]; + if (!targetParagraph) { + return false; + } + + const result = targetParagraph.querySelector(selector); + return result && getComputedStyle(result).display !== 'none'; + }, + [paragraphIndex, PARAGRAPH_RESULT_SELECTOR], + { timeout: Math.min(timeout / 2, 15000) } + ) + .catch(() => false); // JUSTIFIED: result may not appear within timeout; caller checks existence separately + + if (!resultVisible) { + const paragraph = this.getParagraphByIndex(paragraphIndex); + const resultExists = await paragraph.locator(PARAGRAPH_RESULT_SELECTOR).isVisible(); + if (!resultExists) { + console.log(`Warning: No result found for paragraph ${paragraphIndex} after execution`); + } + } + } + + private async focusEditorElement(paragraph: Locator, paragraphIndex: number): Promise { + if (this.page.isClosed()) { + console.warn(`Attempted to focus editor in paragraph ${paragraphIndex} but page is closed.`); + return; + } + + const editor = paragraph.locator('.monaco-editor, .CodeMirror, .ace_editor, textarea').first(); + + await editor.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {}); // JUSTIFIED: UI stabilization — editor may not be visible yet; click attempt follows + await editor.click({ force: true, trial: true }).catch(async () => { + // JUSTIFIED: UI stabilization — falls back to textarea focus if click fails + const textArea = editor.locator('textarea').first(); + if ((await textArea.count()) > 0) { + await textArea.focus({ timeout: 1000 }); + } + }); + + await this.ensureEditorFocused(editor); + } + + private async ensureEditorFocused(editor: Locator): Promise { + const textArea = editor.locator('textarea'); + const hasTextArea = (await textArea.count()) > 0; + + if (hasTextArea) { + await textArea.focus(); + await expect(textArea).toBeFocused({ timeout: 3000 }); + } else { + await expect(editor).toHaveClass(/focused|focus|active/, { timeout: 30000 }); + } + } + + private async executePlatformShortcut(shortcut: string | string[]): Promise { + const shortcuts = Array.isArray(shortcut) ? shortcut : [shortcut]; + const isMac = process.platform === 'darwin'; + const selected = isMac && shortcuts.length > 1 ? shortcuts[1] : shortcuts[0]; + await this.page.keyboard.press(this.formatKey(selected)); + } + + private formatKey(shortcut: string): string { + return shortcut + .toLowerCase() + .replace(/\./g, '+') + .replace(/control/g, 'Control') + .replace(/shift/g, 'Shift') + .replace(/alt/g, 'Alt') + .replace(/arrowup/g, 'ArrowUp') + .replace(/arrowdown/g, 'ArrowDown') + .replace(/enter/g, 'Enter') + .replace(/\+([a-z])$/, (_, c) => `+${c.toUpperCase()}`); + } +} diff --git a/zeppelin-web-angular/e2e/models/notebook-page.ts b/zeppelin-web-angular/e2e/models/notebook-page.ts new file mode 100644 index 00000000000..8e5bcf9bf7e --- /dev/null +++ b/zeppelin-web-angular/e2e/models/notebook-page.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 { Locator, Page } from '@playwright/test'; +import { BasePage } from './base-page'; + +export class NotebookPage extends BasePage { + readonly notebookContainer: Locator; + readonly actionBar: Locator; + readonly sidebarArea: Locator; + readonly extensionArea: Locator; + readonly paragraphInner: Locator; + readonly settingsButton: Locator; + + constructor(page: Page) { + super(page); + this.notebookContainer = page.locator('.notebook-container'); + this.actionBar = page.locator('zeppelin-notebook-action-bar'); + this.sidebarArea = page.locator('.sidebar-area[nz-resizable]'); + this.extensionArea = page.locator('.extension-area'); + this.paragraphInner = page.locator('.paragraph-inner[nz-row]'); + this.settingsButton = page.locator('button i[nztype="setting"]').first(); + } + + async getSidebarWidth(): Promise { + const sidebarElement = await this.sidebarArea.boundingBox(); + return sidebarElement?.width || 0; + } + + async getNotebookContainerClass(): Promise { + return await this.notebookContainer.getAttribute('class'); + } +} diff --git a/zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts b/zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts new file mode 100644 index 00000000000..674dcffd142 --- /dev/null +++ b/zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts @@ -0,0 +1,63 @@ +/* + * 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 NotebookParagraphPage extends BasePage { + readonly paragraphContainer: Locator; + readonly addParagraphAbove: Locator; + readonly addParagraphBelow: Locator; + readonly controlPanel: Locator; + readonly codeEditor: Locator; + readonly dynamicForms: Locator; + readonly resultDisplay: Locator; + readonly footerInfo: Locator; + readonly runButton: Locator; + readonly settingsDropdown: Locator; + + constructor(page: Page) { + super(page); + this.paragraphContainer = page.locator('.paragraph-container').first(); + this.addParagraphAbove = page.locator('zeppelin-notebook-add-paragraph').first(); + this.addParagraphBelow = page.locator('zeppelin-notebook-add-paragraph').last(); + this.controlPanel = page.locator('zeppelin-notebook-paragraph-control').first(); + this.codeEditor = page.locator('zeppelin-notebook-paragraph-code-editor').first(); + this.dynamicForms = page.locator('zeppelin-notebook-paragraph-dynamic-forms').first(); + this.resultDisplay = page.locator('zeppelin-notebook-paragraph-result').first(); + this.footerInfo = page.locator('zeppelin-notebook-paragraph-footer').first(); + this.runButton = page + .locator('.paragraph-container') + .first() + .locator( + 'button[nzTooltipTitle*="Run"], button[title*="Run"], button:has-text("Run"), .run-button, [aria-label*="Run"], i[nzType="play-circle"]:visible, button:has(i[nzType="play-circle"])' + ) + .first(); + this.settingsDropdown = page + .locator('.paragraph-container') + .first() + .locator('zeppelin-notebook-paragraph-control a[nz-dropdown]') + .first(); + } + + async doubleClickToEdit(): Promise { + await this.paragraphContainer.dblclick(); + } + + async runParagraph(): Promise { + await this.runButton.click(); + } + + async openSettingsDropdown(): Promise { + await this.settingsDropdown.click(); + } +} diff --git a/zeppelin-web-angular/e2e/models/notebook-sidebar-page.ts b/zeppelin-web-angular/e2e/models/notebook-sidebar-page.ts new file mode 100644 index 00000000000..5226f0e437e --- /dev/null +++ b/zeppelin-web-angular/e2e/models/notebook-sidebar-page.ts @@ -0,0 +1,185 @@ +/* + * 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 NotebookSidebarPage extends BasePage { + // Selector constants for state detection + private static readonly TOC_ALTERNATIVE_SELECTORS = [ + 'zeppelin-notebook-sidebar .toc-content', + 'zeppelin-notebook-sidebar .note-toc', + 'zeppelin-notebook-sidebar [class*="toc"]', + 'zeppelin-notebook-sidebar zeppelin-note-toc', + 'zeppelin-notebook-sidebar .sidebar-content zeppelin-note-toc' + ]; + + private static readonly FILE_TREE_ALTERNATIVE_SELECTORS = [ + 'zeppelin-notebook-sidebar .file-tree', + 'zeppelin-notebook-sidebar .node-list', + 'zeppelin-notebook-sidebar [class*="file"]', + 'zeppelin-notebook-sidebar [class*="tree"]', + 'zeppelin-notebook-sidebar zeppelin-node-list', + 'zeppelin-notebook-sidebar .sidebar-content zeppelin-node-list' + ]; + readonly sidebarContainer: Locator; + readonly tocButton: Locator; + readonly fileTreeButton: Locator; + readonly closeButton: Locator; + readonly nodeList: Locator; + readonly noteToc: Locator; + + constructor(page: Page) { + super(page); + this.sidebarContainer = page.locator('zeppelin-notebook-sidebar'); + this.tocButton = page.getByRole('button', { name: 'Toggle Table of Contents' }); + this.fileTreeButton = page.getByRole('button', { name: 'Toggle File Tree' }); + this.closeButton = page.getByRole('button', { name: 'Close Sidebar' }); + this.nodeList = page.locator('zeppelin-node-list'); + this.noteToc = page.locator('zeppelin-note-toc'); + } + + async openToc(): Promise { + await this.tocButton.click(); + await this.noteToc.waitFor({ state: 'visible' }); + } + + async openFileTree(): Promise { + await this.fileTreeButton.click(); + await this.nodeList.waitFor({ state: 'visible' }); + } + + async closeSidebar(): Promise { + const sidebarMain = this.page.locator('zeppelin-notebook-sidebar .sidebar-main'); + if (await sidebarMain.isVisible()) { + await this.closeButton.click(); + await sidebarMain.waitFor({ state: 'hidden' }); + } + } + + async getSidebarState(): Promise<'CLOSED' | 'TOC' | 'FILE_TREE' | 'UNKNOWN'> { + const sidebarMain = this.page.locator('zeppelin-notebook-sidebar .sidebar-main'); + if (!(await sidebarMain.isVisible())) { + return 'CLOSED'; + } + + // Method 1: Check primary content elements + const primaryState = await this.checkByPrimaryContent(); + if (primaryState) { + return primaryState; + } + + // Method 2: Check alternative TOC selectors + if (await this.checkTocByAlternativeSelectors()) { + return 'TOC'; + } + + // Method 3: Check alternative FileTree selectors + if (await this.checkFileTreeByAlternativeSelectors()) { + return 'FILE_TREE'; + } + + // Method 4: Check active button states + const buttonState = await this.checkByButtonState(); + if (buttonState) { + return buttonState; + } + + // Method 5: Check content text patterns + const contentState = await this.checkByContentText(); + if (contentState) { + return contentState; + } + + console.log('Could not determine sidebar state'); + return 'UNKNOWN'; + } + + // ===== PRIVATE HELPER METHODS FOR STATE DETECTION ===== + + private async checkByPrimaryContent(): Promise<'TOC' | 'FILE_TREE' | null> { + const isTocVisible = await this.noteToc.isVisible(); + const isFileTreeVisible = await this.nodeList.isVisible(); + + console.log(`State detection - TOC visible: ${isTocVisible}, FileTree visible: ${isFileTreeVisible}`); + + if (isTocVisible) { + return 'TOC'; + } + if (isFileTreeVisible) { + return 'FILE_TREE'; + } + return null; + } + + private async checkTocByAlternativeSelectors(): Promise { + for (const selector of NotebookSidebarPage.TOC_ALTERNATIVE_SELECTORS) { + if (await this.page.locator(selector).isVisible()) { + console.log(`Found TOC using selector: ${selector}`); + return true; + } + } + return false; + } + + private async checkFileTreeByAlternativeSelectors(): Promise { + for (const selector of NotebookSidebarPage.FILE_TREE_ALTERNATIVE_SELECTORS) { + if (await this.page.locator(selector).isVisible()) { + console.log(`Found FileTree using selector: ${selector}`); + return true; + } + } + return false; + } + + private async checkByButtonState(): Promise<'TOC' | 'FILE_TREE' | null> { + const tocButtonActive = await this.page + .locator( + 'zeppelin-notebook-sidebar button.active:has(i[nzType="unordered-list"]), zeppelin-notebook-sidebar .active:has(i[nzType="unordered-list"])' + ) + .isVisible(); + + if (tocButtonActive) { + console.log('Found active TOC button'); + return 'TOC'; + } + + const fileTreeButtonActive = await this.page + .locator( + 'zeppelin-notebook-sidebar button.active:has(i[nzType="folder"]), zeppelin-notebook-sidebar .active:has(i[nzType="folder"])' + ) + .isVisible(); + + if (fileTreeButtonActive) { + console.log('Found active FileTree button'); + return 'FILE_TREE'; + } + + return null; + } + + private async checkByContentText(): Promise<'TOC' | 'FILE_TREE' | null> { + const hasAnyContent = (await this.page.locator('zeppelin-notebook-sidebar *').count()) > 1; + if (!hasAnyContent) { + return null; + } + + const sidebarText = (await this.page.locator('zeppelin-notebook-sidebar').textContent()) || ''; + if (sidebarText.toLowerCase().includes('heading') || sidebarText.toLowerCase().includes('title')) { + console.log('Guessing TOC based on content text'); + return 'TOC'; + } + + console.log('Defaulting to FILE_TREE as fallback'); + return 'FILE_TREE'; + } +} 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 new file mode 100644 index 00000000000..4d012b93c41 --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts @@ -0,0 +1,220 @@ +/* + * 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, test } from '@playwright/test'; +import { NotebookActionBarPage } from '../../../models/notebook-action-bar-page'; +import { + addPageAnnotationBeforeEach, + performLoginIfRequired, + waitForZeppelinReady, + PAGES, + createTestNotebook +} from '../../../utils'; + +test.describe('Notebook Action Bar Functionality', () => { + addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_ACTION_BAR); + + let actionBarPage: NotebookActionBarPage; + let testNotebook: { noteId: string; paragraphId: string }; + + 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'); + }); + + test('should display and allow title editing with tooltip', async ({ page }) => { + const notebookName = `TestNotebook_${Date.now()}`; + + await expect(actionBarPage.titleEditor).toBeVisible(); + await actionBarPage.titleEditor.click(); + + const titleInputField = actionBarPage.titleEditor.locator('input'); + await expect(titleInputField).toBeVisible(); + await titleInputField.fill(notebookName); + await page.keyboard.press('Enter'); + + await expect(actionBarPage.titleEditor).toHaveText(notebookName, { timeout: 10000 }); + }); + + test('should execute run all paragraphs workflow', async ({ page }) => { + await expect(actionBarPage.runAllButton).toBeVisible(); + await expect(actionBarPage.runAllButton).toBeEnabled(); + + await actionBarPage.clickRunAll(); + + // Confirmation dialog must appear when running all paragraphs + const confirmButton = page + .locator('nz-popconfirm button:has-text("OK"), .ant-popconfirm button:has-text("OK"), button:has-text("OK")') + // JUSTIFIED: compound locator targets unique OK button in popconfirm + .first(); + await expect(confirmButton).toBeVisible({ timeout: 5000 }); + await confirmButton.click(); + await expect(confirmButton).not.toBeVisible(); + }); + + test('should toggle code visibility', async () => { + await expect(actionBarPage.showHideCodeButton).toBeVisible(); + await expect(actionBarPage.showHideCodeButton).toBeEnabled(); + + const initialCodeVisibility = await actionBarPage.isCodeVisible(); + await actionBarPage.toggleCodeVisibility(); + + const expectedIcon = initialCodeVisibility ? 'fullscreen' : 'fullscreen-exit'; + const icon = actionBarPage.showHideCodeButton.locator('i[nz-icon] svg'); + await expect(icon).toHaveAttribute('data-icon', expectedIcon, { timeout: 5000 }); + + expect(await actionBarPage.isCodeVisible()).toBe(!initialCodeVisibility); + await expect(actionBarPage.showHideCodeButton).toBeEnabled(); + }); + + test('should toggle output visibility', async () => { + await expect(actionBarPage.showHideOutputButton).toBeVisible(); + await expect(actionBarPage.showHideOutputButton).toBeEnabled(); + + const initialOutputVisibility = await actionBarPage.isOutputVisible(); + await actionBarPage.toggleOutputVisibility(); + + const expectedIcon = initialOutputVisibility ? 'book' : 'read'; + const icon = actionBarPage.showHideOutputButton.locator('i[nz-icon] svg'); + await expect(icon).toHaveAttribute('data-icon', expectedIcon, { timeout: 5000 }); + + expect(await actionBarPage.isOutputVisible()).toBe(!initialOutputVisibility); + await expect(actionBarPage.showHideOutputButton).toBeEnabled(); + }); + + test('should execute clear output workflow', async ({ page }) => { + await expect(actionBarPage.clearOutputButton).toBeVisible(); + await expect(actionBarPage.clearOutputButton).toBeEnabled(); + + await actionBarPage.clickClearOutput(); + + const confirmSelector = page + .locator('nz-popconfirm button:has-text("OK"), .ant-popconfirm button:has-text("OK"), button:has-text("OK")') + // JUSTIFIED: compound locator targets unique OK button in popconfirm + .first(); + // JUSTIFIED: confirmation dialog is optional — some configurations don't require it + const isVisible = await confirmSelector.isVisible({ timeout: 2000 }).catch(() => false); + // JUSTIFIED: dialog is optional — absent when notebook has no output + if (isVisible) { + await confirmSelector.click(); + await expect(confirmSelector).not.toBeVisible(); + } + + await expect(actionBarPage.clearOutputButton).toBeEnabled(); + await page.waitForLoadState('networkidle'); + + const paragraphResults = page.locator('zeppelin-notebook-paragraph-result'); + const resultCount = await paragraphResults.count(); + for (let i = 0; i < resultCount; i++) { + // JUSTIFIED: iterating all paragraph results by index + const result = paragraphResults.nth(i); + // JUSTIFIED: only visible results need empty check + if (await result.isVisible()) { + await expect(result).toBeEmpty(); + } + } + }); + + test('should display note management buttons', async () => { + await expect(actionBarPage.cloneButton).toBeVisible(); + await expect(actionBarPage.cloneButton).toBeEnabled(); + await expect(actionBarPage.exportButton).toBeVisible(); + await expect(actionBarPage.exportButton).toBeEnabled(); + await expect(actionBarPage.reloadButton).toBeVisible(); + await expect(actionBarPage.reloadButton).toBeEnabled(); + }); + + test('should handle collaboration mode toggle when available', async () => { + test.skip( + !(await actionBarPage.collaborationModeToggle.isVisible()), + 'Collaboration mode not available in this environment' + ); + + const personalVisible = await actionBarPage.personalModeButton.isVisible(); + const collaborationVisible = await actionBarPage.collaborationModeButton.isVisible(); + expect(personalVisible || collaborationVisible).toBe(true); + + if (personalVisible) { + await actionBarPage.switchToPersonalMode(); + await expect(actionBarPage.collaborationModeButton).toBeVisible({ timeout: 5000 }); + } else if (collaborationVisible) { + await actionBarPage.switchToCollaborationMode(); + await expect(actionBarPage.personalModeButton).toBeVisible({ timeout: 5000 }); + } + }); + + test('should handle revision controls when supported', async () => { + test.skip(!(await actionBarPage.commitButton.isVisible()), 'Revision controls not supported in this environment'); + + await expect(actionBarPage.commitButton).toBeVisible(); + await expect(actionBarPage.commitButton).toBeEnabled(); + + // JUSTIFIED: optional when revision system disabled + if (await actionBarPage.setRevisionButton.isVisible()) { + await expect(actionBarPage.setRevisionButton).toBeEnabled(); + } + // JUSTIFIED: optional when revision system disabled + if (await actionBarPage.compareRevisionsButton.isVisible()) { + await expect(actionBarPage.compareRevisionsButton).toBeEnabled(); + } + // JUSTIFIED: optional when revision system disabled + if (await actionBarPage.revisionDropdown.isVisible()) { + await actionBarPage.openRevisionDropdown(); + await expect(actionBarPage.revisionDropdownMenu).toBeVisible(); + } + }); + + test('should handle scheduler controls when enabled', async () => { + test.skip(!(await actionBarPage.schedulerButton.isVisible()), 'Scheduler not enabled in this environment'); + + await expect(actionBarPage.schedulerButton).toBeVisible(); + await actionBarPage.openSchedulerDropdown(); + await expect(actionBarPage.schedulerDropdown).toBeVisible(); + + // JUSTIFIED: optional when scheduler disabled + if (await actionBarPage.cronInput.isVisible()) { + await expect(actionBarPage.cronInput).toBeEditable(); + } + // JUSTIFIED: optional when scheduler disabled + if (await actionBarPage.cronPresets.first().isVisible()) { + await expect(actionBarPage.cronPresets).not.toHaveCount(0); + } + }); + + test('should display settings group properly', async ({ page }) => { + const actionBar = page.locator('zeppelin-notebook-action-bar'); + await expect(actionBar).toBeVisible({ timeout: 15000 }); + + // Required control: shortcut info button must always be present + await expect(actionBarPage.shortcutInfoButton).toBeVisible({ timeout: 5000 }); + await expect(actionBarPage.shortcutInfoButton).toBeEnabled(); + + // Optional controls: visible+enabled when present, absent when permissions/config hide them + for (const control of [ + actionBarPage.interpreterSettingsButton, + actionBarPage.permissionsButton, + actionBarPage.lookAndFeelDropdown + ]) { + // JUSTIFIED: optional per user permissions + if (await control.isVisible()) { + await expect(control).toBeEnabled(); + } + } + }); +}); 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 new file mode 100644 index 00000000000..e482464364e --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts @@ -0,0 +1,1044 @@ +/* + * 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, test } from '@playwright/test'; +import { NotebookKeyboardPage } from 'e2e/models/notebook-keyboard-page'; +import { + addPageAnnotationBeforeEach, + performLoginIfRequired, + waitForNotebookLinks, + waitForZeppelinReady, + PAGES, + createTestNotebook +} from '../../../utils'; + +/** + * Comprehensive keyboard shortcuts test suite based on ShortcutsMap + * Tests all keyboard shortcuts defined in src/app/key-binding/shortcuts-map.ts + * + * Note: This spec uses waitForTimeout in several places because Monaco editor cursor + * state and editor focus are not observable via DOM events that Playwright can detect. + * These are justified timing gaps to allow Monaco's internal state to settle between + * keystroke sequences. See: https://github.com/microsoft/monaco-editor/issues/2688 + */ +// JUSTIFIED: Monaco editor focus state is not observable via DOM events; serial ordering prevents cross-test editor state corruption +test.describe.serial('Comprehensive Keyboard Shortcuts (ShortcutsMap)', () => { + addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK); + addPageAnnotationBeforeEach(PAGES.SHARE.SHORTCUT); + + let keyboardPage: NotebookKeyboardPage; + let testNotebook: { noteId: string; paragraphId: string }; + + test.beforeEach(async ({ page }) => { + keyboardPage = new NotebookKeyboardPage(page); + + await page.goto('/#/'); + await waitForZeppelinReady(page); + await performLoginIfRequired(page); + await waitForNotebookLinks(page); + + // Handle the welcome modal if it appears + const welcomeModal = page.locator('.ant-modal-root', { hasText: 'Welcome to Zeppelin!' }); + if ((await welcomeModal.count()) > 0) { + const cancelButton = welcomeModal.locator('button', { hasText: 'Cancel' }); + await cancelButton.click(); + await welcomeModal.waitFor({ state: 'hidden', timeout: 5000 }); + } + + testNotebook = await createTestNotebook(page); + await keyboardPage.navigateToNotebook(testNotebook.noteId); + const currentUrl = page.url(); + if (!currentUrl.includes(`/notebook/${testNotebook.noteId}`)) { + throw new Error(`Navigation to notebook ${testNotebook.noteId} failed. Got: ${currentUrl}`); + } + // JUSTIFIED: single-paragraph test notebook; first() is deterministic + await expect(keyboardPage.paragraphContainer.first()).toBeVisible({ timeout: 30000 }); + await keyboardPage.setCodeEditorContent('%python\nprint("Hello World")'); + }); + + test.afterEach(async ({ page }) => { + // Clean up any open dialogs or modals + await page.keyboard.press('Escape'); + }); + + // ===== CORE EXECUTION SHORTCUTS ===== + + test.describe('ParagraphActions.Run: Shift+Enter', () => { + test('should execute markdown paragraph with Shift+Enter', async () => { + // Given: A paragraph with markdown content + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%md\n# Test Heading\n\nThis is **bold** text.'); + + // Verify content was set + const content = await keyboardPage.getCodeEditorContent(); + expect(content.replace(/\s+/g, '')).toContain('#TestHeading'); + + // When: User presses Shift+Enter + await keyboardPage.pressRunParagraph(); + + // Then: Paragraph should execute (reach a terminal state — interpreter availability varies by env) + await keyboardPage.waitForParagraphExecution(0); + // JUSTIFIED: single-paragraph test notebook; first() is deterministic + const statusEl = keyboardPage.paragraphContainer.first().locator('.status'); + const statusText = (await statusEl.textContent({ timeout: 30000 }))?.trim(); + expect(statusText === 'FINISHED' || statusText === 'ERROR' || statusText === 'ABORT').toBe(true); + }); + }); + + // TODO: Fix the previously skipped tests - ZEPPELIN-6379 + test.describe('ParagraphActions.RunAbove: Control+Shift+ArrowUp', () => { + test.skip(); + test('should run all paragraphs above current with Control+Shift+ArrowUp', async () => { + // Given: Multiple paragraphs + await keyboardPage.tryFocusCodeEditor(0); + await keyboardPage.setCodeEditorContent('%md\n# First Paragraph\nTest content for run above', 0); + await keyboardPage.addParagraph(); + await keyboardPage.waitForParagraphCountChange(2); + + // Focus on second paragraph + await keyboardPage.tryFocusCodeEditor(1); + await keyboardPage.setCodeEditorContent('%md\n# Second Paragraph\nTest content for second paragraph', 1); + await keyboardPage.tryFocusCodeEditor(1); // Ensure focus on the second paragraph + + // Add an explicit wait for the page to be completely stable and the notebook UI to be interactive + await keyboardPage.page.waitForLoadState('networkidle', { timeout: 30000 }); // Wait for network to be idle + // JUSTIFIED: single-paragraph test notebook; first() is deterministic + await expect(keyboardPage.paragraphContainer.first()).toBeVisible({ timeout: 15000 }); // Ensure a paragraph is visible + + // When: User presses Control+Shift+ArrowUp from second paragraph + await keyboardPage.pressRunAbove(); + + await keyboardPage.tryClickModalOkButton(); + + // Then: First paragraph should execute + await keyboardPage.waitForParagraphExecution(0); + const hasResult = await keyboardPage.isParagraphResultSettled(0); + expect(hasResult).toBe(true); + }); + }); + + // TODO: Fix the previously skipped tests - ZEPPELIN-6379 + test.describe('ParagraphActions.RunBelow: Control+Shift+ArrowDown', () => { + test.skip(); + test('should run current and all paragraphs below with Control+Shift+ArrowDown', async () => { + // Given: Multiple paragraphs with content + await keyboardPage.tryFocusCodeEditor(0); + await keyboardPage.setCodeEditorContent('%md\n# First Paragraph\nContent for run below test', 0); + await keyboardPage.addParagraph(); + await keyboardPage.waitForParagraphCountChange(2); + + // Add content to second paragraph + await keyboardPage.tryFocusCodeEditor(1); + await keyboardPage.setCodeEditorContent('%md\n# Second Paragraph\nContent for run below test', 1); + + // Focus first paragraph + await keyboardPage.tryFocusCodeEditor(0); + + // When: User presses Control+Shift+ArrowDown + await keyboardPage.pressRunBelow(); + + // Confirmation modal must appear when running paragraphs + await keyboardPage.tryClickModalOkButton(); + + // Then: Both paragraphs should execute + await keyboardPage.waitForParagraphExecution(0); + await keyboardPage.waitForParagraphExecution(1); + + const firstHasResult = await keyboardPage.isParagraphResultSettled(0); + const secondHasResult = await keyboardPage.isParagraphResultSettled(1); + + expect(firstHasResult).toBe(true); + expect(secondHasResult).toBe(true); + }); + }); + + test.describe('ParagraphActions.Cancel: Control+Alt+C', () => { + test('should cancel running paragraph with Control+Alt+C', async () => { + test.skip(!!process.env.CI, 'Requires Python interpreter with running indicator — not available in CI'); + // Given: A long-running paragraph + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%python\nimport time;time.sleep(3)\nprint("Should be cancelled")'); + + // Start execution + await keyboardPage.pressRunParagraph(); + + // Wait for execution to start by checking if paragraph is running + // JUSTIFIED: compound selector; first() picks any visible running indicator + const runningIndicator = keyboardPage.page + .locator('zeppelin-notebook-paragraph .fa-spin, .running-indicator') + .first(); + await expect(runningIndicator).toBeVisible({ timeout: 30000 }); + + // When: User presses Control+Alt+C quickly + await keyboardPage.pressCancel(); + + // Then: The execution should be cancelled or completed + await expect( + keyboardPage.getParagraphByIndex(0).locator('.paragraph-control .fa-spin, .running-indicator') + ).not.toBeVisible(); + }); + }); + + // ===== CURSOR MOVEMENT SHORTCUTS ===== + + test.describe('ParagraphActions.MoveCursorUp: Control+P', () => { + test('should move cursor up with Control+P', async () => { + // Given: A paragraph with multiple lines + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%python\nline1\nline2\nline3'); + + // Position cursor at end of last line using more reliable cross-browser method + await keyboardPage.pressSelectAll(); // Select all content + await keyboardPage.pressKey('ArrowRight'); // Move to end + await keyboardPage.page.waitForTimeout(500); // Wait for cursor to position // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + + // When: User presses Control+P (should move cursor up one line) + await keyboardPage.pressMoveCursorUp(); + await keyboardPage.page.waitForTimeout(500); // Wait for cursor movement // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + + // Then: Verify cursor movement by checking if we can type at the current position + // Type a marker and check where it appears in the content + await keyboardPage.pressKey('End'); // Move to end of current line + await keyboardPage.page.keyboard.type('MARKER'); + + const content = await keyboardPage.getCodeEditorContent(); + // If cursor moved up correctly, marker should be on line2 + expect(content).toContain('line2MARKER'); + expect(content).not.toContain('line3MARKER'); + }); + }); + + test.describe('ParagraphActions.MoveCursorDown: Control+N', () => { + test('should move cursor down with Control+N', async () => { + // Given: A paragraph with multiple lines + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%python\nline1\nline2\nline3'); + + // Position cursor at beginning of first content line (after %python) using more reliable method + await keyboardPage.pressSelectAll(); // Select all content + await keyboardPage.pressKey('ArrowLeft'); // Move to beginning + await keyboardPage.pressKey('ArrowDown'); // Move to line1 + await keyboardPage.page.waitForTimeout(500); // Wait for cursor to position // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + + // When: User presses Control+N (should move cursor down one line) + await keyboardPage.pressMoveCursorDown(); + await keyboardPage.page.waitForTimeout(500); // Wait for cursor movement // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + + // Then: Verify cursor movement by checking if we can type at the current position + // Type a marker and check where it appears in the content + await keyboardPage.page.keyboard.type('MARKER'); + + const content = await keyboardPage.getCodeEditorContent(); + // If cursor moved down correctly, marker should be on line2 + expect(content).toContain('MARKERline2'); + expect(content).not.toContain('MARKERline1'); + }); + }); + + // ===== PARAGRAPH MANIPULATION SHORTCUTS ===== + + test.describe('ParagraphActions.Delete: Control+Alt+D', () => { + test('should delete current paragraph with Control+Alt+D', async () => { + // Wait for notebook to fully load + await keyboardPage.tryFocusCodeEditor(0); + await keyboardPage.setCodeEditorContent('%python\nprint("First paragraph")', 0); + const firstParagraph = keyboardPage.getParagraphByIndex(0); + await firstParagraph.click(); + await keyboardPage.addParagraph(); + + // Use more flexible waiting strategy + await keyboardPage.waitForParagraphCountChange(2); + + const currentCount = await keyboardPage.getParagraphCount(); + + // Add content to second paragraph + const secondParagraph = keyboardPage.getParagraphByIndex(1); + await secondParagraph.click(); + await keyboardPage.setCodeEditorContent('%python\nprint("Second paragraph")', 1); + // Focus first paragraph + await firstParagraph.click(); + await keyboardPage.tryFocusCodeEditor(0); + await keyboardPage.page.waitForTimeout(1000); // JUSTIFIED: Monaco editor requires time to register focus before keyboard shortcut dispatch + + // When: User presses Control+Alt+D + await keyboardPage.pressDeleteParagraph(); + + // Handle confirmation modal — removeParagraph() always shows nzModalService.confirm() + await keyboardPage.tryClickModalOkButton(); + + // Then: Paragraph count should decrease + await keyboardPage.waitForParagraphCountChange(currentCount - 1); + const finalCount = await keyboardPage.getParagraphCount(); + expect(finalCount).toEqual(currentCount - 1); + }); + + test('should not delete last remaining paragraph with Control+Alt+D', async () => { + // Given: A notebook with exactly one paragraph (beforeEach creates one) + const initialCount = await keyboardPage.getParagraphCount(); + expect(initialCount).toBe(1); + + await keyboardPage.tryFocusCodeEditor(0); + await keyboardPage.page.waitForTimeout(500); // JUSTIFIED: Monaco editor requires time to register focus before keyboard shortcut dispatch + + // When: User presses Control+Alt+D on the only paragraph + await keyboardPage.pressDeleteParagraph(); + + // JUSTIFIED: compound locator; first() picks any visible cancel/no button in confirmation dialog + const cancelButton = keyboardPage.page.locator('button:has-text("Cancel"), button:has-text("No")').first(); + const isCancelVisible = await cancelButton.isVisible({ timeout: 2000 }); + if (isCancelVisible) { + // JUSTIFIED: cancel dialog is optional; paragraph count assertion below covers both paths + await cancelButton.click(); + await cancelButton.waitFor({ state: 'hidden', timeout: 3000 }); + } + + // Then: The notebook must still have at least one paragraph + const finalCount = await keyboardPage.getParagraphCount(); + expect(finalCount).toBe(1); + }); + }); + + test.describe('ParagraphActions.InsertAbove: Control+Alt+A', () => { + test('should insert paragraph above with Control+Alt+A', async () => { + // Given: A single paragraph with content + await keyboardPage.tryFocusCodeEditor(); + const originalContent = '%python\n# Original Paragraph\nprint("Content for insert above test")'; + await keyboardPage.setCodeEditorContent(originalContent); + + const initialCount = await keyboardPage.getParagraphCount(); + + await keyboardPage.tryFocusCodeEditor(0); + + // When: User presses Control+Alt+A + await keyboardPage.pressInsertAbove(); + + // Then: A new paragraph should be inserted above + await keyboardPage.waitForParagraphCountChange(initialCount + 1); + const finalCount = await keyboardPage.getParagraphCount(); + expect(finalCount).toBe(initialCount + 1); + + // And: The new paragraph should be at index 0 (above the original) + const newParagraphContent = await keyboardPage.getCodeEditorContentByIndex(0); + const originalParagraphContent = await keyboardPage.getCodeEditorContentByIndex(1); + + // New paragraph may have default interpreter (%python) or be empty + expect(newParagraphContent === '' || newParagraphContent === '%python').toBe(true); + + // Normalize whitespace for comparison since Monaco editor may format differently + const normalizedOriginalContent = originalContent.replace(/\s+/g, ' ').trim(); + const normalizedReceivedContent = originalParagraphContent.replace(/\s+/g, ' ').trim(); + expect(normalizedReceivedContent).toContain(normalizedOriginalContent); // Original content should be at index 1 + }); + }); + + test.describe('ParagraphActions.InsertBelow: Control+Alt+B', () => { + test('should insert paragraph below with Control+Alt+B', async () => { + // Given: A single paragraph with content + await keyboardPage.tryFocusCodeEditor(); + const originalContent = '%md\n# Original Paragraph\nContent for insert below test'; + await keyboardPage.setCodeEditorContent(originalContent); + + const initialCount = await keyboardPage.getParagraphCount(); + + // When: User presses Control+Alt+B + await keyboardPage.pressInsertBelow(); + + // Then: A new paragraph should be inserted below + await keyboardPage.waitForParagraphCountChange(initialCount + 1); + const finalCount = await keyboardPage.getParagraphCount(); + expect(finalCount).toBe(initialCount + 1); + + // And: The new paragraph should be at index 1 (below the original) + const originalParagraphContent = await keyboardPage.getCodeEditorContentByIndex(0); + const newParagraphContent = await keyboardPage.getCodeEditorContentByIndex(1); + + // Compare content - use regex to handle potential encoding issues + expect(originalParagraphContent).toMatch(/Original\s+Paragraph/); + expect(originalParagraphContent).toMatch(/Content\s+for\s+insert\s+below\s+test/); + expect(newParagraphContent).toBeDefined(); // New paragraph just needs to exist + }); + }); + + // Note (ZEPPELIN-6294): + // This test appears to be related to ZEPPELIN-6294. + // A proper fix or verification should be added based on the issue details. + // In the New UI, the cloned paragraph’s text is empty on PARAGRAPH_ADDED, + // while the Classic UI receives the correct text. This discrepancy should be addressed + // when applying the proper fix for the issue. + test.describe('ParagraphActions.InsertCopyOfParagraphBelow: Control+Shift+C', () => { + test('should insert copy of paragraph below with Control+Shift+C', async () => { + test.skip(); + // Given: A paragraph with content + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%md\n# Copy Test\nContent to be copied below'); + + const initialCount = await keyboardPage.getParagraphCount(); + + // Capture the original paragraph content to verify the copy + const originalContent = await keyboardPage.getCodeEditorContentByIndex(0); + + // When: User presses Control+Shift+C + await keyboardPage.pressInsertCopy(); + + // Then: A copy of the paragraph should be inserted below + await keyboardPage.waitForParagraphCountChange(initialCount + 1); + const finalCount = await keyboardPage.getParagraphCount(); + expect(finalCount).toBe(initialCount + 1); + + // And: The copied content should be identical to the original + const originalParagraphContent = await keyboardPage.getCodeEditorContentByIndex(0); + const copiedParagraphContent = await keyboardPage.getCodeEditorContentByIndex(1); + + expect(originalParagraphContent).toBe(originalContent); // Original should remain unchanged + expect(copiedParagraphContent).toBe(originalContent); // Copied content should match original exactly + }); + }); + + test.describe('ParagraphActions.MoveParagraphUp: Control+Alt+K', () => { + test('should move paragraph up with Control+Alt+K', async () => { + // Given: Create two paragraphs using keyboard shortcut + const firstContent = '%python\nprint("First Paragraph - Content for move up test")'; + const secondContent = '%python\nprint("Second Paragraph - This should move up")'; + + // Set first paragraph content + await keyboardPage.tryFocusCodeEditor(0); + await keyboardPage.setCodeEditorContent(firstContent, 0); + await keyboardPage.page.waitForTimeout(300); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + + // Create second paragraph using InsertBelow shortcut (Control+Alt+B) + await keyboardPage.pressInsertBelow(); + await keyboardPage.waitForParagraphCountChange(2); + + // Set second paragraph content + await keyboardPage.tryFocusCodeEditor(1); + await keyboardPage.setCodeEditorContent(secondContent, 1); + await keyboardPage.page.waitForTimeout(300); // JUSTIFIED: Monaco content state settle before read + + // Verify we have 2 paragraphs + const paragraphCount = await keyboardPage.getParagraphCount(); + expect(paragraphCount).toBe(2); + + // Verify initial content before move + const initialFirst = await keyboardPage.getCodeEditorContentByIndex(0); + const initialSecond = await keyboardPage.getCodeEditorContentByIndex(1); + + // Focus on second paragraph for move operation + await keyboardPage.tryFocusCodeEditor(1); + await keyboardPage.page.waitForTimeout(200); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + + // When: User presses Control+Alt+K from second paragraph + await keyboardPage.pressMoveParagraphUp(); + + // Wait for move operation to complete + await keyboardPage.page.waitForTimeout(1000); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + + // Then: Paragraph count should remain the same + const finalParagraphCount = await keyboardPage.getParagraphCount(); + expect(finalParagraphCount).toBe(2); + + // And: Paragraph positions should be swapped + const newFirstParagraph = await keyboardPage.getCodeEditorContentByIndex(0); + const newSecondParagraph = await keyboardPage.getCodeEditorContentByIndex(1); + + expect(newFirstParagraph).toBe(initialSecond); // Second paragraph moved to first position + expect(newSecondParagraph).toBe(initialFirst); // First paragraph moved to second position + }); + }); + + test.describe('ParagraphActions.MoveParagraphDown: Control+Alt+J', () => { + test('should move paragraph down with Control+Alt+J', async () => { + // Given: Create two paragraphs using keyboard shortcut instead of addParagraph() + const firstContent = '%python\nprint("First Paragraph - This should move down")'; + const secondContent = '%python\nprint("Second Paragraph - Content for second paragraph")'; + + // Set first paragraph content + await keyboardPage.tryFocusCodeEditor(0); + await keyboardPage.setCodeEditorContent(firstContent, 0); + await keyboardPage.page.waitForTimeout(300); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + + // Create second paragraph using InsertBelow shortcut (Control+Alt+B) + await keyboardPage.pressInsertBelow(); + await keyboardPage.waitForParagraphCountChange(2); + + // Set second paragraph content + await keyboardPage.tryFocusCodeEditor(1); + await keyboardPage.setCodeEditorContent(secondContent, 1); + await keyboardPage.page.waitForTimeout(300); // JUSTIFIED: Monaco content state settle before read + + // Verify we have 2 paragraphs + const paragraphCount = await keyboardPage.getParagraphCount(); + expect(paragraphCount).toBe(2); + + // Verify initial content before move + const initialFirst = await keyboardPage.getCodeEditorContentByIndex(0); + const initialSecond = await keyboardPage.getCodeEditorContentByIndex(1); + + // Focus first paragraph for move operation + await keyboardPage.tryFocusCodeEditor(0); + await keyboardPage.page.waitForTimeout(200); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + + // When: User presses Control+Alt+J from first paragraph + await keyboardPage.pressMoveParagraphDown(); + + // Wait for move operation to complete + await keyboardPage.page.waitForTimeout(1000); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + + // Then: Paragraph count should remain the same + const finalParagraphCount = await keyboardPage.getParagraphCount(); + expect(finalParagraphCount).toBe(2); + + // And: Paragraph positions should be swapped + const newFirstParagraph = await keyboardPage.getCodeEditorContentByIndex(0); + const newSecondParagraph = await keyboardPage.getCodeEditorContentByIndex(1); + + expect(newFirstParagraph).toBe(initialSecond); // Second paragraph moved to first position + expect(newSecondParagraph).toBe(initialFirst); // First paragraph moved to second position + }); + }); + + // ===== UI TOGGLE SHORTCUTS ===== + + test.describe('ParagraphActions.SwitchEditor: Control+Alt+E', () => { + test('should toggle editor visibility with Control+Alt+E', async () => { + // Given: A paragraph with visible editor + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%python\nprint("Test editor toggle")'); + + const initialEditorVisibility = await keyboardPage.isEditorVisible(0); + + // When: User presses Control+Alt+E + await keyboardPage.pressSwitchEditor(); + + // Then: Editor visibility should toggle + await keyboardPage.page.waitForTimeout(500); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + const finalEditorVisibility = await keyboardPage.isEditorVisible(0); + expect(finalEditorVisibility).not.toBe(initialEditorVisibility); + }); + }); + + test.describe('ParagraphActions.SwitchEnable: Control+Alt+R', () => { + test('should toggle paragraph enable/disable with Control+Alt+R', async () => { + // Given: An enabled paragraph + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%python\nprint("Test enable toggle")'); + + const initialEnabledState = await keyboardPage.isParagraphEnabled(0); + + // When: User presses Control+Alt+R + await keyboardPage.pressSwitchEnable(); + + // Then: Paragraph enabled state should toggle + await keyboardPage.page.waitForTimeout(1000); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + const finalEnabledState = await keyboardPage.isParagraphEnabled(0); + expect(finalEnabledState).not.toBe(initialEnabledState); + }); + }); + + test.describe('ParagraphActions.SwitchOutputShow: Control+Alt+O', () => { + test('should toggle output visibility with Control+Alt+O', async () => { + // Given: A paragraph with output + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%md\n# Test Output Toggle\nThis creates immediate output'); + await keyboardPage.pressRunParagraph(); + await keyboardPage.waitForParagraphExecution(0); + + const resultLocator = keyboardPage.getParagraphByIndex(0).locator('[data-testid="paragraph-result"]'); + await expect(resultLocator).toBeVisible(); + + const initialOutputVisibility = await keyboardPage.isOutputVisible(0); + + // When: User presses Control+Alt+O + await keyboardPage.tryFocusCodeEditor(0); + await keyboardPage.pressSwitchOutputShow(); + + const finalOutputVisibility = await keyboardPage.isOutputVisible(0); + expect(finalOutputVisibility).not.toBe(initialOutputVisibility); + }); + }); + + test.describe('ParagraphActions.SwitchLineNumber: Control+Alt+M', () => { + test('should toggle line numbers with Control+Alt+M', async () => { + // Given: A paragraph with code + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%python\nprint("Test line numbers")'); + + const initialLineNumbersVisibility = await keyboardPage.areLineNumbersVisible(0); + + // When: User presses Control+Alt+M + await keyboardPage.pressSwitchLineNumber(); + + // Then: Line numbers visibility should toggle + await keyboardPage.page.waitForTimeout(500); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + const finalLineNumbersVisibility = await keyboardPage.areLineNumbersVisible(0); + expect(finalLineNumbersVisibility).not.toBe(initialLineNumbersVisibility); + }); + }); + + test.describe('ParagraphActions.SwitchTitleShow: Control+Alt+T', () => { + test('should toggle title visibility with Control+Alt+T', async () => { + // Given: A paragraph + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%python\nprint("Test title toggle")'); + + const initialTitleVisibility = await keyboardPage.isTitleVisible(0); + + // When: User presses Control+Alt+T + await keyboardPage.pressSwitchTitleShow(); + + // Then: Title visibility should toggle + const finalTitleVisibility = await keyboardPage.isTitleVisible(0); + expect(finalTitleVisibility).not.toBe(initialTitleVisibility); + }); + }); + + test.describe('ParagraphActions.Clear: Control+Alt+L', () => { + test('should clear output with Control+Alt+L', async () => { + // Given: A paragraph with executed content that has output + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%md\n# Test Content\nFor clear output test'); + await keyboardPage.pressRunParagraph(); + await keyboardPage.waitForParagraphExecution(0); + + // Verify there is output to clear + // JUSTIFIED: single-paragraph test notebook; first() is deterministic + const statusElBefore = keyboardPage.paragraphContainer.first().locator('.status'); + await expect(statusElBefore).toHaveText(/FINISHED|ERROR|PENDING|RUNNING/); + + // When: User presses Control+Alt+L + await keyboardPage.tryFocusCodeEditor(0); + await keyboardPage.pressClearOutput(); + + // Then: Output should be cleared + const resultLocator = keyboardPage.getParagraphByIndex(0).locator('[data-testid="paragraph-result"]'); + await expect(resultLocator).not.toBeVisible(); + }); + }); + + test.describe('ParagraphActions.Link: Control+Alt+W', () => { + test('should trigger link paragraph with Control+Alt+W', async () => { + // Given: A paragraph with content + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%python\nprint("Link Test")'); + + // Get the current URL to extract notebook ID + const currentUrl = keyboardPage.page.url(); + const notebookMatch = currentUrl.match(/\/notebook\/([^\/]+)/); + expect(notebookMatch).not.toBeNull(); + const notebookId = notebookMatch![1]; + + // Listen for new tabs being opened + const newPagePromise = keyboardPage.page.context().waitForEvent('page'); + + // When: User presses Control+Alt+W + await keyboardPage.pressLinkParagraph(); + + // Then: A new tab should be opened with paragraph link + const newPage = await newPagePromise; + await newPage.waitForLoadState('networkidle'); + + // Verify the new tab URL contains the notebook ID and paragraph reference + const newUrl = newPage.url(); + expect(newUrl).toContain(`/notebook/${notebookId}/paragraph/`); + expect(newUrl).toMatch(/\/paragraph\/paragraph_\d+_\d+/); + + // Clean up: Close the new tab + await newPage.close(); + }); + }); + + // ===== PARAGRAPH WIDTH SHORTCUTS ===== + + test.describe('ParagraphActions.ReduceWidth: Control+Shift+-', () => { + test('should reduce paragraph width with Control+Shift+-', async () => { + // Given: A paragraph + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%python\nprint("Test width reduction")'); + + const initialWidth = await keyboardPage.getParagraphWidth(0); + + // When: User presses Control+Shift+- + await keyboardPage.pressReduceWidth(); + + // Then: Paragraph width should be reduced + const finalWidth = await keyboardPage.getParagraphWidth(0); + expect(finalWidth).toBeLessThan(initialWidth); + }); + }); + + test.describe('ParagraphActions.IncreaseWidth: Control+Shift+=', () => { + test('should increase paragraph width with Control+Shift+=', async () => { + // Given: A paragraph + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%python\nprint("Test width increase")'); + + // First, reduce width to ensure there's room to increase + await keyboardPage.pressReduceWidth(); + await keyboardPage.page.waitForTimeout(500); // Give UI a moment to update after reduction // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + + const initialWidth = await keyboardPage.getParagraphWidth(0); + + // When: User presses Control+Shift+= + await keyboardPage.pressIncreaseWidth(); + + // Then: Paragraph width should be increased + const finalWidth = await keyboardPage.getParagraphWidth(0); + expect(finalWidth).toBeGreaterThan(initialWidth); + }); + }); + + // ===== EDITOR LINE OPERATIONS ===== + + // TODO: Fix the previously skipped tests - ZEPPELIN-6379 + test.describe('ParagraphActions.CutLine: Control+K', () => { + test.skip(); + test('should cut line with Control+K', async () => { + // Given: Code editor with content + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('first line\nsecond line\nthird line'); + + const initialContent = await keyboardPage.getCodeEditorContent(); + expect(initialContent).toContain('first line'); + + // Additional wait and focus for Firefox compatibility + const browserName = test.info().project.name; + if (browserName === 'firefox') { + await keyboardPage.page.waitForTimeout(200); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + // Ensure Monaco editor is properly focused + // JUSTIFIED: single Monaco editor per paragraph; first() picks the active textarea + const editorTextarea = keyboardPage.page.locator('.monaco-editor textarea').first(); + await editorTextarea.click(); + await editorTextarea.focus(); + await keyboardPage.page.waitForTimeout(200); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + } + + // When: User presses Control+K (cut to end of line) + await keyboardPage.pressCutLine(); + + // Then: First line content should be cut (cut from cursor position to end of line) + await keyboardPage.page.waitForTimeout(500); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + const finalContent = await keyboardPage.getCodeEditorContent(); + expect(finalContent).toBeDefined(); + expect(typeof finalContent).toBe('string'); + + // Verify the first line was actually cut + expect(finalContent).toContain('first line'); + expect(finalContent).toContain('second line'); + expect(finalContent).not.toContain('third line'); + }); + }); + + // TODO: Fix the previously skipped tests - ZEPPELIN-6379 + test.describe('ParagraphActions.PasteLine: Control+Y', () => { + test.skip(); + test('should paste line with Control+Y', async () => { + // Given: Content in the editor + await keyboardPage.tryFocusCodeEditor(); + const originalContent = 'line to cut and paste'; + await keyboardPage.setCodeEditorContent(originalContent); + + // Wait for content to be properly set and verify it + await keyboardPage.page.waitForTimeout(500); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + const initialContent = await keyboardPage.getCodeEditorContent(); + expect(initialContent.replace(/\s+/g, ' ').trim()).toContain(originalContent); + + // When: User presses Control+K to cut the line + await keyboardPage.pressCutLine(); + await keyboardPage.page.waitForTimeout(500); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + + // Then: Content should be reduced (line was cut) + const afterCutContent = await keyboardPage.getCodeEditorContent(); + expect(afterCutContent.length).toBeLessThan(initialContent.length); + + // Clear the editor to verify paste works from clipboard + await keyboardPage.setCodeEditorContent(''); + await keyboardPage.page.waitForTimeout(200); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + const emptyContent = await keyboardPage.getCodeEditorContent(); + expect(emptyContent.trim()).toBe(''); + + // When: User presses Control+Y to paste + await keyboardPage.pressPasteLine(); + await keyboardPage.page.waitForTimeout(500); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + + // Then: Original content should be restored from clipboard + const finalContent = await keyboardPage.getCodeEditorContent(); + expect(finalContent.replace(/\s+/g, ' ').trim()).toContain(originalContent); + }); + }); + + // ===== SEARCH SHORTCUTS ===== + + test.describe('ParagraphActions.SearchInsideCode: Control+S', () => { + test('should open search with Control+S', async () => { + // Given: A paragraph with content + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%python\nprint("Search test content")'); + + // When: User presses Control+S + await keyboardPage.pressSearchInsideCode(); + + // Then: Search functionality should be triggered + await expect(keyboardPage.searchDialog).toBeVisible(); + }); + }); + + test.describe('ParagraphActions.FindInCode: Control+Alt+F', () => { + test('should open find in code with Control+Alt+F', async () => { + // Given: A paragraph with content + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%python\nprint("Find test content")'); + + // When: User presses Control+Alt+F + await keyboardPage.pressFindInCode(); + + // Then: Find functionality should be triggered + await keyboardPage.page.waitForTimeout(1000); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + await expect(keyboardPage.searchDialog).toBeVisible(); + + // Close search dialog + await keyboardPage.pressEscape(); + }); + }); + + // ===== AUTOCOMPLETION AND NAVIGATION ===== + + test.describe('Control+Space: Code Autocompletion', () => { + test('should trigger autocomplete for Python code', async () => { + // Given: Code editor with partial Python function + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%python\npr'); + await keyboardPage.pressKey('End'); // Position cursor at end + + // When: User presses Control+Space to trigger autocomplete + await keyboardPage.pressControlSpace(); + await keyboardPage.page.waitForTimeout(1000); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + + // Then: Editor must remain functional after shortcut (baseline — always asserts) + // JUSTIFIED: single-paragraph test notebook; first() is deterministic + await expect(keyboardPage.codeEditor.first()).toBeVisible(); + + const isAutocompleteVisible = await keyboardPage.isAutocompleteVisible(); + if (isAutocompleteVisible) { + // If autocomplete appeared, verify we can interact with it and close it cleanly + const autocompletePopup = keyboardPage.page + .locator('.monaco-editor .suggest-widget, .autocomplete-popup, [role="listbox"]') + // JUSTIFIED: compound selector; first() picks any visible autocomplete popup + .first(); + await expect(autocompletePopup).toBeVisible(); + await keyboardPage.pressEscape(); + } + // If no autocomplete (e.g., no Python kernel): editor-visible assertion above is the baseline + }); + + test('should complete autocomplete selection when available', async () => { + // Given: Code editor with content likely to have autocomplete suggestions + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%python\nimport os\nos.'); + await keyboardPage.pressKey('End'); + + // When: User triggers autocomplete and selects an option + await keyboardPage.pressControlSpace(); + await keyboardPage.page.waitForTimeout(1000); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + + const isAutocompleteVisible = await keyboardPage.isAutocompleteVisible(); + if (isAutocompleteVisible) { + // Navigate and select first suggestion + await keyboardPage.pressArrowDown(); + await keyboardPage.pressKey('Enter'); + + // Then: Content should be modified with autocomplete suggestion + const finalContent = await keyboardPage.getCodeEditorContent(); + expect(finalContent.length).toBeGreaterThan('os.'.length); + expect(finalContent).toContain('os.'); + } else { + // If autocomplete not available, verify typing still works + await keyboardPage.pressKey('p'); + await keyboardPage.pressKey('a'); + await keyboardPage.pressKey('t'); + await keyboardPage.pressKey('h'); + + const finalContent = await keyboardPage.getCodeEditorContent(); + expect(finalContent).toContain('os.path'); + } + }); + }); + + test.describe('Tab: Code Indentation', () => { + test('should indent code properly when Tab is pressed', async () => { + // Given: Code editor with a function definition and cursor on new line + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%python\ndef function():'); + await keyboardPage.pressKey('End'); + await keyboardPage.pressKey('Enter'); + + const contentBeforeTab = await keyboardPage.getCodeEditorContent(); + + // When: User presses Tab for indentation + await keyboardPage.pressTab(); + + // Then: Content should be longer (indentation added) — poll until CodeMirror processes the Tab asynchronously + let contentAfterTab = ''; + await expect(async () => { + contentAfterTab = await keyboardPage.getCodeEditorContent(); + expect(contentAfterTab.length).toBeGreaterThan(contentBeforeTab.length); + }).toPass({ timeout: 5000 }); + + // And: The difference should be the addition of indentation characters + const addedContent = contentAfterTab.substring(contentBeforeTab.length); + + // Check that indentation was added and is either tabs (1-2 chars) or spaces (2-8 chars) + expect(addedContent.length).toBeLessThanOrEqual(8); // Reasonable indentation limit + + // Should be only whitespace characters + expect(addedContent).toMatch(/^\s+$/); + }); + }); + + test.describe('Arrow Keys: Cursor Navigation', () => { + test('should move cursor position with arrow keys', async () => { + // Given: Code editor with multi-line content + await keyboardPage.tryFocusCodeEditor(); + const testContent = '%python\nfirst line\nsecond line\nthird line'; + await keyboardPage.setCodeEditorContent(testContent); + + // Position cursor at the beginning + await keyboardPage.pressKey('Control+Home'); + + // When: User navigates with arrow keys + await keyboardPage.pressArrowDown(); // Move down one line + await keyboardPage.pressArrowRight(); // Move right one character + + // Type a character to verify cursor position + await keyboardPage.pressKey('X'); + + // Then: Character should be inserted at the correct position + const finalContent = await keyboardPage.getCodeEditorContent(); + expect(finalContent).toContain('X'); + expect(finalContent).not.toBe(testContent); // Content should have changed + }); + }); + + test.describe('Interpreter Selection', () => { + test('should recognize and highlight interpreter directives', async () => { + // Given: Empty code editor + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent(''); + + // When: User types various interpreter directives + await keyboardPage.typeInEditor('%python\nprint("Hello")\n'); + + // Then: Content should contain the interpreter directive + const pythonContent = await keyboardPage.getCodeEditorContent(); + expect(pythonContent).toContain('%python'); + expect(pythonContent).toContain('print("Hello")'); + + // When: User changes to different interpreter + await keyboardPage.setCodeEditorContent('%scala\nval x = 1'); + + // Then: New interpreter directive should be recognized + const scalaContent = await keyboardPage.getCodeEditorContent(); + expect(scalaContent).toContain('%scala'); + + // Monaco editor removes line breaks, check individual parts + expect(scalaContent).toContain('val'); + expect(scalaContent).toContain('x'); + expect(scalaContent).toContain('='); + expect(scalaContent).toContain('1'); + + // When: User types markdown directive + await keyboardPage.setCodeEditorContent('%md\n# Header\nMarkdown content'); + + // Then: Markdown directive should be recognized + const markdownContent = await keyboardPage.getCodeEditorContent(); + expect(markdownContent).toContain('%md'); + + // Monaco editor removes line breaks, check individual parts + expect(markdownContent).toContain('#'); + expect(markdownContent).toContain('Header'); + }); + }); + + test.describe('Comprehensive Shortcuts Integration', () => { + test('should maintain shortcut functionality after errors', async () => { + // Given: An error has occurred + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('invalid python syntax here'); + await keyboardPage.pressRunParagraph(); + await keyboardPage.waitForParagraphExecution(0); + + // Verify error result exists (invalid syntax produces a final ERROR or FINISHED with error output) + // JUSTIFIED: single-paragraph test notebook; first() is deterministic + const statusElError = keyboardPage.paragraphContainer.first().locator('.status'); + await expect(statusElError).toHaveText(/FINISHED|ERROR/, { timeout: 30000 }); + + // When: User continues with shortcuts (insert new paragraph) + const initialCount = await keyboardPage.getParagraphCount(); + await keyboardPage.addParagraph(); + await keyboardPage.waitForParagraphCountChange(initialCount + 1, 10000); + + // Set valid content in new paragraph and run + const newParagraphIndex = (await keyboardPage.getParagraphCount()) - 1; + await keyboardPage.tryFocusCodeEditor(newParagraphIndex); + await keyboardPage.setCodeEditorContent('%md\n# Recovery Test\nShortcuts work after error', newParagraphIndex); + await keyboardPage.pressRunParagraph(); + + // Then: New paragraph should execute (FINISHED or ERROR is acceptable — the key assertion is + // that execution completed, proving shortcuts are functional after an error occurred) + await keyboardPage.waitForParagraphExecution(newParagraphIndex); + // JUSTIFIED: newParagraphIndex is dynamically computed from getParagraphCount(); nth() is the only way to address this specific paragraph + const statusElNew = keyboardPage.paragraphContainer.nth(newParagraphIndex).locator('.status'); + await expect(statusElNew).toHaveText(/FINISHED|ERROR/, { timeout: 30000 }); + }); + + test('should gracefully handle shortcuts when no paragraph is focused', async () => { + // Given: A notebook with at least one paragraph but no focus + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%md\n# Test paragraph'); + + // Remove focus by clicking on empty area + await keyboardPage.page.click('body'); + await keyboardPage.page.waitForTimeout(500); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + + const initialCount = await keyboardPage.getParagraphCount(); + + // When: User tries keyboard shortcuts that require paragraph focus + // These should either not work or gracefully handle the lack of focus + await keyboardPage.pressInsertBelow(); // This may not work without focus + await keyboardPage.page.waitForTimeout(1000); // JUSTIFIED: Monaco editor internal state settle — cursor/focus state not observable via DOM + + const afterShortcut = await keyboardPage.getParagraphCount(); + + // Then: Either the shortcut works (creates new paragraph) or is gracefully ignored + expect(afterShortcut === initialCount || afterShortcut === initialCount + 1).toBe(true); + + // System must remain stable — editor still accessible + // JUSTIFIED: single-paragraph test notebook; first() is deterministic + await expect(keyboardPage.codeEditor.first()).toBeVisible(); + }); + + test('should handle rapid keyboard operations without instability', async () => { + await keyboardPage.tryFocusCodeEditor(); + await keyboardPage.setCodeEditorContent('%python\nprint("test")'); + + // Rapid Shift+Enter operations + for (let i = 0; i < 3; i++) { + await keyboardPage.pressRunParagraph(); + // JUSTIFIED: single-paragraph test notebook; first() is deterministic + await expect(keyboardPage.paragraphResult.first()).toBeVisible({ timeout: 15000 }); + await keyboardPage.page.waitForTimeout(500); // JUSTIFIED: brief gap between rapid sequential runs to prevent WebSocket message overlap + } + + // Then: System should remain stable + // JUSTIFIED: single-paragraph test notebook; first() is deterministic + await expect(keyboardPage.codeEditor.first()).toBeVisible(); + }); + }); +}); 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 new file mode 100644 index 00000000000..d66df7fa5f3 --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts @@ -0,0 +1,74 @@ +/* + * 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, test } from '@playwright/test'; +import { NotebookPage } from '../../../models/notebook-page'; +import { + addPageAnnotationBeforeEach, + performLoginIfRequired, + waitForZeppelinReady, + PAGES, + createTestNotebook +} from '../../../utils'; + +test.describe('Notebook Container Component', () => { + addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK); + + let notebookPage: NotebookPage; + let testNotebook: { noteId: string; paragraphId: string }; + + 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'); + }); + + test('should display notebook container with proper structure', async () => { + await expect(notebookPage.notebookContainer).toBeVisible(); + expect(await notebookPage.getNotebookContainerClass()).toContain('notebook-container'); + }); + + test('should display action bar component', async () => { + await expect(notebookPage.notebookContainer).toBeVisible(); + await expect(notebookPage.actionBar).toBeVisible({ timeout: 15000 }); + }); + + test('should display resizable sidebar with width constraints', async () => { + await expect(notebookPage.notebookContainer).toBeVisible(); + await expect(notebookPage.sidebarArea).toBeVisible({ timeout: 15000 }); + + const width = await notebookPage.getSidebarWidth(); + expect(width).toBeGreaterThanOrEqual(40); + expect(width).toBeLessThanOrEqual(800); + }); + + test('should display paragraph container with grid layout', async () => { + await expect(notebookPage.paragraphInner).toBeVisible(); + expect(await notebookPage.paragraphInner.getAttribute('class')).toContain('paragraph-inner'); + await expect(notebookPage.paragraphInner).toHaveAttribute('nz-row'); + }); + + test('should display extension area when activated', async () => { + await expect(notebookPage.notebookContainer).toBeVisible(); + await expect(notebookPage.actionBar).toBeVisible({ timeout: 15000 }); + + await notebookPage.settingsButton.click(); + + await expect(notebookPage.extensionArea).toBeVisible(); + }); +}); diff --git a/zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts new file mode 100644 index 00000000000..c1d50203091 --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts @@ -0,0 +1,186 @@ +/* + * 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, test } from '@playwright/test'; +import { NotebookParagraphPage } from 'e2e/models/notebook-paragraph-page'; +import { NotebookKeyboardPage } from 'e2e/models/notebook-keyboard-page'; +import { + addPageAnnotationBeforeEach, + performLoginIfRequired, + waitForZeppelinReady, + PAGES, + createTestNotebook +} from '../../../utils'; + +test.describe('Notebook Paragraph Functionality', () => { + addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH); + addPageAnnotationBeforeEach(PAGES.SHARE.CODE_EDITOR); + + let paragraphPage: NotebookParagraphPage; + let testNotebook: { noteId: string; paragraphId: string }; + + test.beforeEach(async ({ page }) => { + await page.goto('/#/'); + await waitForZeppelinReady(page); + await performLoginIfRequired(page); + + testNotebook = await createTestNotebook(page); + paragraphPage = new NotebookParagraphPage(page); + + await page.goto(`/#/notebook/${testNotebook.noteId}`); + await page.waitForLoadState('networkidle'); + }); + + test('should display paragraph container with proper structure', async () => { + await expect(paragraphPage.paragraphContainer).toBeVisible(); + await expect(paragraphPage.controlPanel).toBeVisible(); + }); + + test('should support double-click editing functionality', async () => { + await expect(paragraphPage.paragraphContainer).toBeVisible(); + await paragraphPage.doubleClickToEdit(); + await expect(paragraphPage.codeEditor).toBeVisible(); + }); + + test('should display add paragraph buttons', async () => { + await expect(paragraphPage.addParagraphAbove).toBeVisible(); + await expect(paragraphPage.addParagraphAbove).toHaveCount(1); + await expect(paragraphPage.addParagraphBelow).toBeVisible(); + await expect(paragraphPage.addParagraphBelow).toHaveCount(1); + }); + + test('should display comprehensive control interface', async () => { + await expect(paragraphPage.controlPanel).toBeVisible(); + await expect(paragraphPage.runButton).toBeVisible(); + await expect(paragraphPage.runButton).toBeEnabled(); + }); + + test('should display result system properly', async ({ page }) => { + await expect(page).toHaveURL(/\/notebook\/[^\/]+/, { timeout: 10000 }); + await page.waitForLoadState('domcontentloaded'); + await expect(paragraphPage.paragraphContainer).toBeVisible({ timeout: 15000 }); + // JUSTIFIED: codeEditor is visibility:hidden before double-click; toBeAttached confirms it's in the DOM + await expect(paragraphPage.codeEditor).toBeAttached({ timeout: 10000 }); + + await paragraphPage.doubleClickToEdit(); + await expect(paragraphPage.codeEditor).toBeVisible(); + + // JUSTIFIED: compound selector; first() picks primary Monaco input + const codeEditor = paragraphPage.codeEditor.locator('textarea, .monaco-editor .input-area').first(); + // JUSTIFIED: Monaco textarea may be visibility:hidden before focus; toBeAttached confirms DOM presence + await expect(codeEditor).toBeAttached({ timeout: 10000 }); + await expect(codeEditor).toBeEnabled({ timeout: 10000 }); + + await codeEditor.focus(); + await expect(codeEditor).toBeFocused({ timeout: 5000 }); + + const notebookKeyboardPage = new NotebookKeyboardPage(page); + await notebookKeyboardPage.pressSelectAll(); + await page.keyboard.type('%python\nprint("Hello World")'); + + await paragraphPage.runParagraph(); + await expect(paragraphPage.resultDisplay).toBeVisible({ timeout: 15000 }); + await expect(paragraphPage.resultDisplay).not.toBeEmpty(); + }); + + test('should display dynamic forms', async ({ page }) => { + test.skip(!!process.env.CI, 'Dynamic form tests require a Spark interpreter — skipped on CI'); + + await paragraphPage.doubleClickToEdit(); + await expect(paragraphPage.codeEditor).toBeVisible(); + + // JUSTIFIED: compound selector; first() picks primary Monaco input + const codeEditor = paragraphPage.codeEditor.locator('textarea, .monaco-editor .input-area').first(); + await expect(codeEditor).toBeAttached({ timeout: 10000 }); + await expect(codeEditor).toBeEnabled({ timeout: 10000 }); + + await codeEditor.focus(); + await expect(codeEditor).toBeFocused({ timeout: 5000 }); + + const notebookKeyboardPage = new NotebookKeyboardPage(page); + await notebookKeyboardPage.pressSelectAll(); + await page.keyboard.type(`%spark +println("Name: " + z.input("name", "World")) +println("Age: " + z.select("age", Seq(("1","Under 18"), ("2","18-65"), ("3","Over 65")))) +`); + + await paragraphPage.runParagraph(); + await expect(paragraphPage.resultDisplay).toBeVisible({ timeout: 15000 }); + + // Handles error cases gracefully — Spark may not be available + // JUSTIFIED: result display may not exist when interpreter is unavailable; null triggers graceful fallback below + const resultText = await paragraphPage.resultDisplay.textContent().catch(() => null); + const hasInterpreterError = + resultText && + ((resultText.toLowerCase().includes('interpreter') && resultText.toLowerCase().includes('not found')) || + resultText.toLowerCase().includes('error')); + + if (hasInterpreterError) { + await expect(paragraphPage.resultDisplay).toBeVisible(); + } else { + await expect(paragraphPage.dynamicForms).toBeVisible(); + } + }); + + test('should render footer element in paragraph DOM', async () => { + // JUSTIFIED: footer is visibility:hidden by default (hover-only); toBeAttached confirms it's rendered in DOM + await expect(paragraphPage.footerInfo).toBeAttached(); + }); + + test('should provide paragraph control actions', async ({ page }) => { + await expect(page).toHaveURL(/\/notebook\/[^\/]+/, { timeout: 10000 }); + await expect(paragraphPage.paragraphContainer).toBeVisible({ timeout: 15000 }); + + await paragraphPage.openSettingsDropdown(); + + const dropdownMenu = page.locator('ul.ant-dropdown-menu, .dropdown-menu'); + await expect(dropdownMenu).toBeVisible({ timeout: 5000 }); + await expect(page.locator('li:has-text("Insert")')).toBeVisible(); + await expect(page.locator('li:has-text("Clone")')).toBeVisible(); + + await page.keyboard.press('Escape'); + }); + + test('should show cancel button during execution', async ({ page }) => { + await expect(page).toHaveURL(/\/notebook\/[^\/]+/, { timeout: 10000 }); + await expect(paragraphPage.paragraphContainer).toBeVisible({ timeout: 15000 }); + await expect(paragraphPage.runButton).toBeVisible(); + await expect(paragraphPage.runButton).toBeEnabled(); + + await paragraphPage.doubleClickToEdit(); + await expect(paragraphPage.codeEditor).toBeVisible(); + + // JUSTIFIED: compound selector; first() picks primary Monaco input + const codeEditor = paragraphPage.codeEditor.locator('textarea, .monaco-editor .input-area').first(); + await expect(codeEditor).toBeAttached({ timeout: 10000 }); + await expect(codeEditor).toBeEnabled({ timeout: 10000 }); + + await codeEditor.focus(); + await expect(codeEditor).toBeFocused({ timeout: 5000 }); + + const notebookKeyboardPage = new NotebookKeyboardPage(page); + await notebookKeyboardPage.pressSelectAll(); + await page.keyboard.type('%python\nimport time;time.sleep(10)\nprint("Done")'); + + await paragraphPage.runParagraph(); + + const cancelButton = page.locator( + '.cancel-para, [nz-tooltip*="Cancel"], [title*="Cancel"], button:has-text("Cancel"), i[nz-icon="pause-circle"], .anticon-pause-circle' + ); + await expect(cancelButton).toBeVisible({ timeout: 5000 }); + + await cancelButton.click(); + + // Then: Execution should stop — running spinner disappears + await expect(page.locator('.paragraph-control .fa-spin')).not.toBeVisible({ timeout: 15000 }); + }); +}); 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 new file mode 100644 index 00000000000..1cc2b2b2604 --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/notebook/sidebar/sidebar-functionality.spec.ts @@ -0,0 +1,86 @@ +/* + * 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, test } from '@playwright/test'; +import { NotebookSidebarPage } from '../../../models/notebook-sidebar-page'; +import { + addPageAnnotationBeforeEach, + performLoginIfRequired, + waitForZeppelinReady, + PAGES, + createTestNotebook +} from '../../../utils'; + +test.describe('Notebook Sidebar Functionality', () => { + addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_SIDEBAR); + + let sidebar: NotebookSidebarPage; + let testNotebook: { noteId: string; paragraphId: string }; + + 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'); + }); + + test('should display navigation buttons', async ({ page }) => { + await expect(sidebar.sidebarContainer).toBeVisible(); + + const navigationControls = page.locator( + 'zeppelin-notebook-sidebar button, .sidebar-nav button, zeppelin-notebook-sidebar i[nz-icon], .sidebar-nav i' + ); + // JUSTIFIED: compound selector; first() picks any visible nav control + await expect(navigationControls.first()).toBeVisible(); + }); + + test('should manage three sidebar states correctly', async () => { + await sidebar.closeSidebar(); + expect(await sidebar.getSidebarState()).toBe('CLOSED'); + + await sidebar.openToc(); + const newState = await sidebar.getSidebarState(); + // TOC or FILE_TREE both valid — TOC may not be available in all environments + expect(newState === 'TOC' || newState === 'FILE_TREE').toBe(true); + }); + + test('should open and display TOC panel', async () => { + await sidebar.openToc(); + await expect(sidebar.noteToc).toBeVisible(); + await expect(sidebar.sidebarContainer).toBeVisible(); + }); + + test('should open and display file tree panel', async () => { + await sidebar.openFileTree(); + await expect(sidebar.nodeList).toBeVisible(); + }); + + test('should close sidebar functionality work properly', async ({ page }) => { + await page.waitForLoadState('networkidle', { timeout: 15000 }); + await expect(sidebar.sidebarContainer).toBeVisible({ timeout: 10000 }); + + // Try to open TOC, but accept FILE_TREE if TOC isn't available + await sidebar.openToc(); + await page.waitForLoadState('domcontentloaded'); + const state = await sidebar.getSidebarState(); + expect(state === 'TOC' || state === 'FILE_TREE').toBe(true); + + await sidebar.closeSidebar(); + await page.waitForLoadState('domcontentloaded'); + expect(await sidebar.getSidebarState()).toBe('CLOSED'); + }); +}); 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 new file mode 100644 index 00000000000..a364a20bb50 --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/share/folder-rename/folder-rename.spec.ts @@ -0,0 +1,146 @@ +/* + * 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 { FolderRenamePage } from '../../../models/folder-rename-page'; +import { FolderRenamePageUtil } from '../../../models/folder-rename-page.util'; +import { + addPageAnnotationBeforeEach, + PAGES, + performLoginIfRequired, + waitForZeppelinReady, + createTestNotebook +} from '../../../utils'; + +// JUSTIFIED: rename/delete ops mutate shared state; parallel runs cause folder-not-found races +test.describe.serial('Folder Rename', () => { + let folderRenamePage: FolderRenamePage; + let folderRenameUtil: FolderRenamePageUtil; + let testFolderName: string; + + addPageAnnotationBeforeEach(PAGES.SHARE.FOLDER_RENAME); + + test.beforeEach(async ({ page }) => { + folderRenamePage = new FolderRenamePage(page); + folderRenameUtil = new FolderRenamePageUtil(folderRenamePage); + + 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('/#/'); + }); + + 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 }) }) + // 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"]'); + await expect(renameButton).toHaveCount(1); + }); + + test('Given context menu is open, When clicking Rename, Then rename modal should open', async () => { + await folderRenamePage.clickRenameMenuItem(testFolderName); + await expect(folderRenamePage.renameModal).toBeVisible({ timeout: 10000 }); + }); + + test('Given rename modal is open, When checking modal content, Then input field should be displayed', async () => { + await folderRenamePage.hoverOverFolder(testFolderName); + await folderRenamePage.clickRenameMenuItem(testFolderName); + await expect(folderRenamePage.renameInput).toBeVisible(); + }); + + test('Given rename modal is open, When entering new name and confirming, Then folder should be renamed', async ({ + page + }) => { + const browserName = page.context().browser()?.browserType().name(); + const renamedFolderName = `TestFolderRenamed_${`${Date.now()}_${browserName}`}`; + + await folderRenamePage.hoverOverFolder(testFolderName); + await folderRenamePage.clickRenameMenuItem(testFolderName); + await folderRenamePage.renameInput.waitFor({ state: 'visible', timeout: 5000 }); + await folderRenamePage.clearNewName(); + await folderRenamePage.enterNewName(renamedFolderName); + await folderRenamePage.clickConfirm(); + + await expect(folderRenamePage.renameModal).not.toBeVisible({ timeout: 10000 }); + await expect(page.locator('.folder .name', { hasText: 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 }); + }); + + test('Given rename modal is open, When submitting empty name, Then empty name should not be allowed', async () => { + await folderRenamePage.hoverOverFolder(testFolderName); + await folderRenamePage.clickRenameMenuItem(testFolderName); + await folderRenamePage.clearNewName(); + + await expect(folderRenamePage.confirmButton).toBeDisabled({ timeout: 5000 }); + + await folderRenamePage.clickCancel(); + await expect(folderRenamePage.renameModal).not.toBeVisible({ timeout: 5000 }); + await expect(folderRenamePage.page.locator('.folder .name', { hasText: testFolderName })).toBeVisible({ + timeout: 5000 + }); + }); + + test('Given folder is hovered, When checking available options, Then Delete icon should be visible', async () => { + await folderRenamePage.hoverOverFolder(testFolderName); + const folderNode = folderRenamePage.page + .locator('.node') + .filter({ has: folderRenamePage.page.locator('.folder .name', { hasText: 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(); + }); + + test('Given folder exists, When clicking delete icon, Then delete confirmation should appear', async () => { + await folderRenamePage.clickDeleteIcon(testFolderName); + await expect(folderRenamePage.deleteConfirmation).toBeVisible(); + }); + + test('Given folder can be renamed, When opening context menu multiple times, Then menu should consistently appear', async ({ + page + }) => { + await folderRenameUtil.openContextMenuOnHoverAndVerifyOptions(testFolderName); + await page.locator('h1', { hasText: 'Welcome to Zeppelin!' }).hover(); + await folderRenameUtil.openContextMenuOnHoverAndVerifyOptions(testFolderName); + }); + + test('should remove source folder when renamed to an existing folder name', async ({ page }) => { + // 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 + + // Attempt to rename the first folder to the name of the second folder + await folderRenamePage.hoverOverFolder(testFolderName); + await folderRenamePage.clickRenameMenuItem(testFolderName); + await folderRenamePage.clearNewName(); + await folderRenamePage.enterNewName(existingFolderName); + 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 }); + // Wait for the target folder to remain visible + await expect(page.locator('.folder .name', { hasText: 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 18ae43faba6..aae38d544a1 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 @@ -10,20 +10,18 @@ * limitations under the License. */ -import { test } from '@playwright/test'; +import { test, expect } from '@playwright/test'; import { HeaderPage } from '../../../models/header-page'; -import { HeaderPageUtil } from '../../../models/header-page.util'; +import { NodeListPage } from '../../../models/node-list-page'; 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); @@ -31,43 +29,94 @@ test.describe('Header Navigation', () => { }); test('Given user is on any page, When viewing the header, Then all header elements should be visible', async () => { - await headerUtil.verifyHeaderIsDisplayed(); + await expect(headerPage.header).toBeVisible(); + await expect(headerPage.brandLogo).toBeVisible(); + await expect(headerPage.notebookMenuItem).toBeVisible(); + await expect(headerPage.jobMenuItem).toBeVisible(); + await expect(headerPage.userDropdownTrigger).toBeVisible(); + await expect(headerPage.searchInput).toBeVisible(); + await expect(headerPage.themeToggleButton).toBeVisible(); }); - 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 any page, When clicking the Zeppelin logo, Then user should navigate to home page', async ({ + page + }) => { + await headerPage.clickBrandLogo(); + await page.waitForURL(/\/(#\/)?$/); + expect(page.url()).toMatch(/\/(#\/)?$/); }); - 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 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'); }); - 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 Notebook dropdown, Then dropdown with node list should open', async ({ + page + }) => { + await headerPage.clickNotebookMenu(); + await expect(headerPage.notebookDropdown).toBeVisible(); + + const nodeList = new NodeListPage(page); + await expect(nodeList.createNewNoteButton).toBeVisible(); }); test('Given user is on home page, When clicking the user dropdown, Then user menu should open', async () => { - await headerUtil.verifyUserDropdownOpens(); + await headerPage.clickUserDropdown(); + await expect(headerPage.userMenuItems.aboutZeppelin).toBeVisible(); }); 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); + await headerPage.clickUserDropdown(); + await expect(headerPage.userMenuItems.aboutZeppelin).toBeVisible(); + await expect(headerPage.userMenuItems.interpreter).toBeVisible(); + await expect(headerPage.userMenuItems.notebookRepos).toBeVisible(); + await expect(headerPage.userMenuItems.credential).toBeVisible(); + await expect(headerPage.userMenuItems.configuration).toBeVisible(); + await expect(headerPage.userMenuItems.switchToClassicUI).toBeVisible(); + if (!isAnonymous) { + expect(await headerPage.getUsernameText()).not.toBe('anonymous'); + await expect(headerPage.userMenuItems.logout).toBeVisible(); + } }); - 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 Interpreter menu item, Then user should navigate to Interpreter settings page', async ({ + page + }) => { + await headerPage.clickUserDropdown(); + await headerPage.clickInterpreter(); + await page.waitForURL(/interpreter/); + expect(page.url()).toContain('interpreter'); }); - 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 Notebook Repos menu item, Then user should navigate to Notebook Repos page', async ({ + page + }) => { + await headerPage.clickUserDropdown(); + await headerPage.clickNotebookRepos(); + await page.waitForURL(/notebook-repos/); + expect(page.url()).toContain('notebook-repos'); }); - 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 Credential menu item, Then user should navigate to Credential page', async ({ + page + }) => { + await headerPage.clickUserDropdown(); + await headerPage.clickCredential(); + await page.waitForURL(/credential/); + expect(page.url()).toContain('credential'); }); - test('Given user opens user dropdown, When clicking Configuration menu item, Then user should navigate to Configuration page', async () => { - await headerUtil.navigateToConfiguration(); + test('Given user opens user dropdown, When clicking Configuration menu item, Then user should navigate to Configuration page', async ({ + page + }) => { + await headerPage.clickUserDropdown(); + await headerPage.clickConfiguration(); + await page.waitForURL(/configuration/); + expect(page.url()).toContain('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 171f2d52558..f6960142a41 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,27 +12,29 @@ 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 () => { + test('Given user is on home page, When entering search query and pressing Enter, Then user should navigate to search results page', async ({ + page + }) => { const searchQuery = 'test'; - await headerUtil.verifySearchNavigation(searchQuery); + await headerPage.searchNote(searchQuery); + await page.waitForURL(/search/); + expect(page.url()).toContain('search'); + expect(page.url()).toContain(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/note-create/note-create-modal.spec.ts b/zeppelin-web-angular/e2e/tests/share/note-create/note-create-modal.spec.ts index 640e60899cc..dbc27205b3e 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,20 +13,17 @@ 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); @@ -37,13 +34,17 @@ test.describe('Note Create Modal', () => { }); test('Given user clicks Create New Note, When modal opens, Then modal should display all required elements', async () => { - await noteCreateUtil.verifyModalIsOpen(); + await expect(noteCreateModal.modal).toBeVisible(); + await expect(noteCreateModal.noteNameInput).toBeVisible(); + await expect(noteCreateModal.createButton).toBeVisible(); await expect(noteCreateModal.interpreterDropdown).toBeVisible(); - await noteCreateUtil.verifyFolderCreationInfo(); + await expect(noteCreateModal.folderInfoAlert).toBeVisible(); + expect(await noteCreateModal.folderInfoAlert.textContent()).toContain('/'); }); 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+/); + const noteName = await noteCreateModal.getNoteName(); + expect(noteName).toMatch(/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 ({ @@ -99,7 +100,8 @@ test.describe('Note Create Modal', () => { }); test('Given Create Note modal is open, When clicking close button, Then modal should close', async () => { - await noteCreateUtil.verifyModalClose(); + await noteCreateModal.close(); + await expect(noteCreateModal.modal).not.toBeVisible(); }); test('Given Create Note modal is open, When viewing folder info alert, Then alert should contain folder creation instructions', async () => { 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 new file mode 100644 index 00000000000..a5a6f1c13f8 --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/share/note-rename/note-rename.spec.ts @@ -0,0 +1,111 @@ +/* + * 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 { NoteRenamePage } from '../../../models/note-rename-page'; +import { NoteRenamePageUtil } from '../../../models/note-rename-page.util'; +import { + addPageAnnotationBeforeEach, + PAGES, + performLoginIfRequired, + waitForZeppelinReady, + createTestNotebook +} from '../../../utils'; + +test.describe('Note Rename', () => { + let noteRenamePage: NoteRenamePage; + let noteRenameUtil: NoteRenamePageUtil; + let testNotebook: { noteId: string; paragraphId: string }; + + addPageAnnotationBeforeEach(PAGES.SHARE.NOTE_RENAME); + + test.beforeEach(async ({ page }) => { + noteRenamePage = new NoteRenamePage(page); + noteRenameUtil = new NoteRenamePageUtil(noteRenamePage); + + 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'); + }); + + test('Given notebook page is loaded, When checking note title, Then title should be displayed', async () => { + await expect(noteRenamePage.noteTitle).toBeVisible(); + }); + + test('Given note title is displayed, When checking default title, Then title should match pattern', async () => { + await noteRenameUtil.verifyTitleText('TestNotebook'); + }); + + test('Given note title is displayed, When clicking title, Then title input should appear', async () => { + await noteRenamePage.clickTitle(); + await expect(noteRenamePage.noteTitleInput).toBeVisible(); + }); + + test('Given title input is displayed, When entering new title and pressing Enter, Then title should be updated', async () => { + await noteRenameUtil.verifyTitleCanBeChanged(`Test Note 1-${Date.now()}`); + }); + + test('Given title input is displayed, When entering new title and blurring, Then title should be updated', async () => { + const newTitle = `Test Note 2-${Date.now()}`; + await noteRenamePage.clickTitle(); + await noteRenamePage.clearTitle(); + await noteRenamePage.enterTitle(newTitle); + await noteRenamePage.blur(); + await noteRenameUtil.verifyTitleText(newTitle); + }); + + test('Given title input is displayed, When entering text and pressing Escape, Then changes should be cancelled', async () => { + const originalTitle = await noteRenamePage.getTitle(); + await noteRenamePage.clickTitle(); + await noteRenamePage.clearTitle(); + await noteRenamePage.enterTitle('Temporary Title'); + await noteRenamePage.pressEscape(); + await noteRenameUtil.verifyTitleText(originalTitle); + await expect(noteRenamePage.noteTitleInput).not.toBeVisible(); + }); + + test('Given title input is displayed, When clearing title and pressing Enter, Then empty title should not be allowed', async () => { + const originalTitle = await noteRenamePage.getTitle(); + await noteRenamePage.clickTitle(); + await noteRenamePage.clearTitle(); + await noteRenamePage.pressEnter(); + await noteRenameUtil.verifyTitleText(originalTitle); + }); + + test('Given note title exists, When changing title multiple times, Then each change should persist', async () => { + await noteRenameUtil.verifyTitleCanBeChanged(`First Change-${Date.now()}`); + await noteRenameUtil.verifyTitleCanBeChanged(`Second Change-${Date.now()}`); + await noteRenameUtil.verifyTitleCanBeChanged(`Third Change-${Date.now()}`); + }); + + test('Given title is in edit mode, When checking input visibility, Then input should be visible and title should be hidden', async () => { + await noteRenamePage.clickTitle(); + await expect(noteRenamePage.noteTitleInput).toBeVisible(); + await expect(noteRenamePage.noteTitle.locator('span.text')).toBeHidden(); + }); + + test('Given title has special characters, When renaming with special characters, Then special characters should be preserved', async () => { + const title = `Test-Note_123 (v2)-${Date.now()}`; + await noteRenamePage.clickTitle(); + await noteRenamePage.clearTitle(); + await noteRenamePage.enterTitle(title); + await noteRenamePage.pressEnter(); + await noteRenameUtil.verifyTitleText(title); + }); +}); 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 new file mode 100644 index 00000000000..6b6527842e7 --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/share/note-toc/note-toc.spec.ts @@ -0,0 +1,84 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { 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'; + +test.describe('Note Table of Contents', () => { + let noteTocPage: NoteTocPage; + let noteTocUtil: NoteTocPageUtil; + let testNotebook: { noteId: string; paragraphId: string }; + + addPageAnnotationBeforeEach(PAGES.SHARE.NOTE_TOC); + + test.beforeEach(async ({ page }) => { + noteTocPage = new NoteTocPage(page); + noteTocUtil = new NoteTocPageUtil(noteTocPage); + + await page.goto('/#/'); + await waitForZeppelinReady(page); + await performLoginIfRequired(page); + + testNotebook = await createTestNotebook(page); + + // Use the more robust navigation method from parent class + await noteTocPage.navigateToNotebook(testNotebook.noteId); + + // Wait for notebook to fully load + await page.waitForLoadState('networkidle'); + + // Verify we're actually in a notebook with more specific checks + await expect(page).toHaveURL(new RegExp(`#/notebook/${testNotebook.noteId}`)); + // JUSTIFIED: test notebook always has exactly one paragraph + await expect(page.locator('zeppelin-notebook-paragraph').first()).toBeVisible({ timeout: 15000 }); + + // Only proceed if TOC button exists (confirms notebook context) + await expect(noteTocPage.tocToggleButton).toBeVisible({ timeout: 10000 }); + }); + + 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'); + }); + + test('Given TOC panel is open with no headings, When checking content, Then empty message should be displayed', async () => { + await noteTocUtil.verifyTocPanelOpens(); + await expect(noteTocPage.tocEmptyMessage).toBeVisible(); + }); + + test('Given TOC panel is open, When clicking close button, Then TOC panel should close', async () => { + await noteTocUtil.verifyTocPanelOpens(); + await noteTocUtil.verifyTocPanelCloses(); + }); + + test('Given TOC toggle button exists, When checking button visibility, Then button should be visible, enabled, and labeled', async () => { + await expect(noteTocPage.tocToggleButton).toBeVisible(); + await expect(noteTocPage.tocToggleButton).toBeEnabled(); + await expect(noteTocPage.tocToggleButton).toHaveAttribute('aria-label', 'Toggle Table of Contents'); + }); + + test('Given TOC panel can be toggled, When opening and closing multiple times, Then panel should respond consistently', async () => { + await noteTocUtil.verifyTocPanelOpens(); + await noteTocUtil.verifyTocPanelCloses(); + await noteTocUtil.verifyTocPanelOpens(); + await noteTocUtil.verifyTocPanelCloses(); + }); +});