tbonelee commented on code in PR #5101: URL: https://github.com/apache/zeppelin/pull/5101#discussion_r2498953075
########## zeppelin-web-angular/e2e/models/notebook-action-bar-page.util.ts: ########## @@ -0,0 +1,206 @@ +/* + * 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 { NotebookActionBarPage } from './notebook-action-bar-page'; + +export class NotebookActionBarUtil { + private page: Page; + private actionBarPage: NotebookActionBarPage; + + constructor(page: Page) { + this.page = page; + this.actionBarPage = new NotebookActionBarPage(page); + } + + private async handleOptionalConfirmation(logMessage: string): Promise<void> { + const confirmSelector = this.page + .locator('nz-popconfirm button:has-text("OK"), .ant-popconfirm button:has-text("OK"), button:has-text("OK")') + .first(); + + if (await confirmSelector.isVisible({ timeout: 2000 })) { + await confirmSelector.click(); + await expect(confirmSelector).not.toBeVisible(); + } else { + console.log(logMessage); + } + } + + async verifyTitleEditingFunctionality(newTitle: string): Promise<void> { + await expect(this.actionBarPage.titleEditor).toBeVisible(); + + await this.actionBarPage.titleEditor.click(); + + const titleInputField = this.actionBarPage.titleEditor.locator('input'); + await expect(titleInputField).toBeVisible(); + + await titleInputField.fill(newTitle); + + await this.page.keyboard.press('Enter'); + + await expect(this.actionBarPage.titleEditor).toHaveText(newTitle, { timeout: 10000 }); + } + + async verifyRunAllWorkflow(): Promise<void> { + await expect(this.actionBarPage.runAllButton).toBeVisible(); + await expect(this.actionBarPage.runAllButton).toBeEnabled(); + + await this.actionBarPage.clickRunAll(); + + // Check if confirmation dialog appears (it might not in some configurations) + await this.handleOptionalConfirmation('Run all executed without confirmation dialog'); + } + + async verifyCodeVisibilityToggle(): Promise<void> { + await expect(this.actionBarPage.showHideCodeButton).toBeVisible(); + await expect(this.actionBarPage.showHideCodeButton).toBeEnabled(); + + const initialCodeVisibility = await this.actionBarPage.isCodeVisible(); + await this.actionBarPage.toggleCodeVisibility(); + + // Wait for the icon to change by checking for the expected icon + const expectedIcon = initialCodeVisibility ? 'fullscreen' : 'fullscreen-exit'; + const icon = this.actionBarPage.showHideCodeButton.locator('i[nz-icon] svg'); + await expect(icon).toHaveAttribute('data-icon', expectedIcon, { timeout: 5000 }); + + const newCodeVisibility = await this.actionBarPage.isCodeVisible(); + expect(newCodeVisibility).toBe(!initialCodeVisibility); + + // Verify the button is still functional after click + await expect(this.actionBarPage.showHideCodeButton).toBeEnabled(); + } + + async verifyOutputVisibilityToggle(): Promise<void> { + await expect(this.actionBarPage.showHideOutputButton).toBeVisible(); + await expect(this.actionBarPage.showHideOutputButton).toBeEnabled(); + + const initialOutputVisibility = await this.actionBarPage.isOutputVisible(); + await this.actionBarPage.toggleOutputVisibility(); + + // Wait for the icon to change by checking for the expected icon + const expectedIcon = initialOutputVisibility ? 'book' : 'read'; + const icon = this.actionBarPage.showHideOutputButton.locator('i[nz-icon] svg'); + await expect(icon).toHaveAttribute('data-icon', expectedIcon, { timeout: 5000 }); + + const newOutputVisibility = await this.actionBarPage.isOutputVisible(); + expect(newOutputVisibility).toBe(!initialOutputVisibility); + + // Verify the button is still functional after click + await expect(this.actionBarPage.showHideOutputButton).toBeEnabled(); + } + + async verifyClearOutputWorkflow(): Promise<void> { + await expect(this.actionBarPage.clearOutputButton).toBeVisible(); + await expect(this.actionBarPage.clearOutputButton).toBeEnabled(); + + await this.actionBarPage.clickClearOutput(); + + // Check if confirmation dialog appears (it might not in some configurations) + await this.handleOptionalConfirmation('Clear output executed without confirmation dialog'); Review Comment: Verifying cleared output after `clickClearOutput()` should be here or at least some TODO comments should be added. ########## zeppelin-web-angular/e2e/models/note-toc-page.ts: ########## @@ -0,0 +1,119 @@ +/* + * 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 tocListArea: Locator; + readonly tocEmptyMessage: Locator; + readonly tocItems: Locator; + readonly codeEditor: Locator; + readonly runButton: Locator; + readonly addParagraphButton: Locator; + + constructor(page: Page) { + super(page); + this.tocToggleButton = page.locator('.sidebar-button').first(); + this.tocPanel = page.locator('zeppelin-note-toc').first(); + this.tocTitle = page.getByText('Table of Contents'); + this.tocCloseButton = page + .locator('button') + .filter({ hasText: /close|×/ }) + .or(page.locator('[class*="close"]')) + .first(); + this.tocListArea = page.locator('[class*="toc"]').first(); + this.tocEmptyMessage = page.getByText('Headings in the output show up here'); + this.tocItems = page.locator('[class*="toc"] li, [class*="heading"]'); + this.codeEditor = page.locator('textarea, [contenteditable], .monaco-editor textarea').first(); + this.runButton = page + .locator('button') + .filter({ hasText: /run|실행|▶/ }) Review Comment: The word `실행` may have no effect. ########## zeppelin-web-angular/e2e/models/notebook-action-bar-page.util.ts: ########## @@ -0,0 +1,206 @@ +/* + * 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 { NotebookActionBarPage } from './notebook-action-bar-page'; + +export class NotebookActionBarUtil { + private page: Page; + private actionBarPage: NotebookActionBarPage; + + constructor(page: Page) { + this.page = page; + this.actionBarPage = new NotebookActionBarPage(page); + } + + private async handleOptionalConfirmation(logMessage: string): Promise<void> { + const confirmSelector = this.page + .locator('nz-popconfirm button:has-text("OK"), .ant-popconfirm button:has-text("OK"), button:has-text("OK")') + .first(); + + if (await confirmSelector.isVisible({ timeout: 2000 })) { + await confirmSelector.click(); + await expect(confirmSelector).not.toBeVisible(); + } else { + console.log(logMessage); + } + } + + async verifyTitleEditingFunctionality(newTitle: string): Promise<void> { + await expect(this.actionBarPage.titleEditor).toBeVisible(); + + await this.actionBarPage.titleEditor.click(); + + const titleInputField = this.actionBarPage.titleEditor.locator('input'); + await expect(titleInputField).toBeVisible(); + + await titleInputField.fill(newTitle); + + await this.page.keyboard.press('Enter'); + + await expect(this.actionBarPage.titleEditor).toHaveText(newTitle, { timeout: 10000 }); + } + + async verifyRunAllWorkflow(): Promise<void> { + await expect(this.actionBarPage.runAllButton).toBeVisible(); + await expect(this.actionBarPage.runAllButton).toBeEnabled(); + + await this.actionBarPage.clickRunAll(); + + // Check if confirmation dialog appears (it might not in some configurations) + await this.handleOptionalConfirmation('Run all executed without confirmation dialog'); + } + + async verifyCodeVisibilityToggle(): Promise<void> { + await expect(this.actionBarPage.showHideCodeButton).toBeVisible(); + await expect(this.actionBarPage.showHideCodeButton).toBeEnabled(); + + const initialCodeVisibility = await this.actionBarPage.isCodeVisible(); + await this.actionBarPage.toggleCodeVisibility(); + + // Wait for the icon to change by checking for the expected icon + const expectedIcon = initialCodeVisibility ? 'fullscreen' : 'fullscreen-exit'; + const icon = this.actionBarPage.showHideCodeButton.locator('i[nz-icon] svg'); + await expect(icon).toHaveAttribute('data-icon', expectedIcon, { timeout: 5000 }); + + const newCodeVisibility = await this.actionBarPage.isCodeVisible(); + expect(newCodeVisibility).toBe(!initialCodeVisibility); + + // Verify the button is still functional after click + await expect(this.actionBarPage.showHideCodeButton).toBeEnabled(); + } + + async verifyOutputVisibilityToggle(): Promise<void> { + await expect(this.actionBarPage.showHideOutputButton).toBeVisible(); + await expect(this.actionBarPage.showHideOutputButton).toBeEnabled(); + + const initialOutputVisibility = await this.actionBarPage.isOutputVisible(); + await this.actionBarPage.toggleOutputVisibility(); + + // Wait for the icon to change by checking for the expected icon + const expectedIcon = initialOutputVisibility ? 'book' : 'read'; + const icon = this.actionBarPage.showHideOutputButton.locator('i[nz-icon] svg'); + await expect(icon).toHaveAttribute('data-icon', expectedIcon, { timeout: 5000 }); + + const newOutputVisibility = await this.actionBarPage.isOutputVisible(); + expect(newOutputVisibility).toBe(!initialOutputVisibility); + + // Verify the button is still functional after click + await expect(this.actionBarPage.showHideOutputButton).toBeEnabled(); + } + + async verifyClearOutputWorkflow(): Promise<void> { + await expect(this.actionBarPage.clearOutputButton).toBeVisible(); + await expect(this.actionBarPage.clearOutputButton).toBeEnabled(); + + await this.actionBarPage.clickClearOutput(); + + // Check if confirmation dialog appears (it might not in some configurations) + await this.handleOptionalConfirmation('Clear output executed without confirmation dialog'); + } + + async verifyNoteManagementButtons(): Promise<void> { + await expect(this.actionBarPage.cloneButton).toBeVisible(); + await expect(this.actionBarPage.exportButton).toBeVisible(); + await expect(this.actionBarPage.reloadButton).toBeVisible(); + } + + async verifyCollaborationModeToggle(): Promise<void> { + if (await this.actionBarPage.collaborationModeToggle.isVisible()) { + const personalVisible = await this.actionBarPage.personalModeButton.isVisible(); + const collaborationVisible = await this.actionBarPage.collaborationModeButton.isVisible(); + + expect(personalVisible || collaborationVisible).toBe(true); + + if (personalVisible) { + await this.actionBarPage.switchToPersonalMode(); + } else if (collaborationVisible) { + await this.actionBarPage.switchToCollaborationMode(); + } Review Comment: We should verify the results of the actions. ########## zeppelin-web-angular/e2e/models/notebook-action-bar-page.util.ts: ########## @@ -0,0 +1,206 @@ +/* + * 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 { NotebookActionBarPage } from './notebook-action-bar-page'; + +export class NotebookActionBarUtil { + private page: Page; + private actionBarPage: NotebookActionBarPage; + + constructor(page: Page) { + this.page = page; + this.actionBarPage = new NotebookActionBarPage(page); + } + + private async handleOptionalConfirmation(logMessage: string): Promise<void> { + const confirmSelector = this.page + .locator('nz-popconfirm button:has-text("OK"), .ant-popconfirm button:has-text("OK"), button:has-text("OK")') + .first(); + + if (await confirmSelector.isVisible({ timeout: 2000 })) { + await confirmSelector.click(); + await expect(confirmSelector).not.toBeVisible(); + } else { + console.log(logMessage); + } + } + + async verifyTitleEditingFunctionality(newTitle: string): Promise<void> { + await expect(this.actionBarPage.titleEditor).toBeVisible(); + + await this.actionBarPage.titleEditor.click(); + + const titleInputField = this.actionBarPage.titleEditor.locator('input'); + await expect(titleInputField).toBeVisible(); + + await titleInputField.fill(newTitle); + + await this.page.keyboard.press('Enter'); + + await expect(this.actionBarPage.titleEditor).toHaveText(newTitle, { timeout: 10000 }); + } + + async verifyRunAllWorkflow(): Promise<void> { + await expect(this.actionBarPage.runAllButton).toBeVisible(); + await expect(this.actionBarPage.runAllButton).toBeEnabled(); + + await this.actionBarPage.clickRunAll(); + + // Check if confirmation dialog appears (it might not in some configurations) + await this.handleOptionalConfirmation('Run all executed without confirmation dialog'); + } + + async verifyCodeVisibilityToggle(): Promise<void> { + await expect(this.actionBarPage.showHideCodeButton).toBeVisible(); + await expect(this.actionBarPage.showHideCodeButton).toBeEnabled(); + + const initialCodeVisibility = await this.actionBarPage.isCodeVisible(); + await this.actionBarPage.toggleCodeVisibility(); + + // Wait for the icon to change by checking for the expected icon + const expectedIcon = initialCodeVisibility ? 'fullscreen' : 'fullscreen-exit'; + const icon = this.actionBarPage.showHideCodeButton.locator('i[nz-icon] svg'); + await expect(icon).toHaveAttribute('data-icon', expectedIcon, { timeout: 5000 }); + + const newCodeVisibility = await this.actionBarPage.isCodeVisible(); + expect(newCodeVisibility).toBe(!initialCodeVisibility); + + // Verify the button is still functional after click + await expect(this.actionBarPage.showHideCodeButton).toBeEnabled(); + } + + async verifyOutputVisibilityToggle(): Promise<void> { + await expect(this.actionBarPage.showHideOutputButton).toBeVisible(); + await expect(this.actionBarPage.showHideOutputButton).toBeEnabled(); + + const initialOutputVisibility = await this.actionBarPage.isOutputVisible(); + await this.actionBarPage.toggleOutputVisibility(); + + // Wait for the icon to change by checking for the expected icon + const expectedIcon = initialOutputVisibility ? 'book' : 'read'; + const icon = this.actionBarPage.showHideOutputButton.locator('i[nz-icon] svg'); + await expect(icon).toHaveAttribute('data-icon', expectedIcon, { timeout: 5000 }); + + const newOutputVisibility = await this.actionBarPage.isOutputVisible(); + expect(newOutputVisibility).toBe(!initialOutputVisibility); + + // Verify the button is still functional after click + await expect(this.actionBarPage.showHideOutputButton).toBeEnabled(); + } + + async verifyClearOutputWorkflow(): Promise<void> { + await expect(this.actionBarPage.clearOutputButton).toBeVisible(); + await expect(this.actionBarPage.clearOutputButton).toBeEnabled(); + + await this.actionBarPage.clickClearOutput(); + + // Check if confirmation dialog appears (it might not in some configurations) + await this.handleOptionalConfirmation('Clear output executed without confirmation dialog'); + } + + async verifyNoteManagementButtons(): Promise<void> { + await expect(this.actionBarPage.cloneButton).toBeVisible(); + await expect(this.actionBarPage.exportButton).toBeVisible(); + await expect(this.actionBarPage.reloadButton).toBeVisible(); + } + + async verifyCollaborationModeToggle(): Promise<void> { + if (await this.actionBarPage.collaborationModeToggle.isVisible()) { + const personalVisible = await this.actionBarPage.personalModeButton.isVisible(); + const collaborationVisible = await this.actionBarPage.collaborationModeButton.isVisible(); + + expect(personalVisible || collaborationVisible).toBe(true); + + if (personalVisible) { + await this.actionBarPage.switchToPersonalMode(); + } else if (collaborationVisible) { + await this.actionBarPage.switchToCollaborationMode(); + } + } + } + + async verifyRevisionControlsIfSupported(): Promise<void> { + if (await this.actionBarPage.commitButton.isVisible()) { + await expect(this.actionBarPage.commitButton).toBeEnabled(); + + if (await this.actionBarPage.setRevisionButton.isVisible()) { + await expect(this.actionBarPage.setRevisionButton).toBeEnabled(); + } + + if (await this.actionBarPage.compareRevisionsButton.isVisible()) { + await expect(this.actionBarPage.compareRevisionsButton).toBeEnabled(); + } + + if (await this.actionBarPage.revisionDropdown.isVisible()) { + await this.actionBarPage.openRevisionDropdown(); + await expect(this.actionBarPage.revisionDropdownMenu).toBeVisible(); + } + } + } + + async verifyCommitWorkflow(commitMessage: string): Promise<void> { + if (await this.actionBarPage.commitButton.isVisible()) { + await this.actionBarPage.openCommitPopover(); + await expect(this.actionBarPage.commitPopover).toBeVisible(); + + await this.actionBarPage.enterCommitMessage(commitMessage); + await this.actionBarPage.confirmCommit(); + + await expect(this.actionBarPage.commitPopover).not.toBeVisible(); + } + } + + async verifySchedulerControlsIfEnabled(): Promise<void> { + if (await this.actionBarPage.schedulerButton.isVisible()) { + await this.actionBarPage.openSchedulerDropdown(); + await expect(this.actionBarPage.schedulerDropdown).toBeVisible(); + + if (await this.actionBarPage.cronInput.isVisible()) { + await expect(this.actionBarPage.cronInput).toBeEditable(); + } + + if (await this.actionBarPage.cronPresets.first().isVisible()) { + const presetsCount = await this.actionBarPage.cronPresets.count(); + expect(presetsCount).toBeGreaterThan(0); + } + } + } + + async verifySettingsGroup(): Promise<void> { + if (await this.actionBarPage.shortcutInfoButton.isVisible()) { + await expect(this.actionBarPage.shortcutInfoButton).toBeEnabled(); + } + + if (await this.actionBarPage.interpreterSettingsButton.isVisible()) { + await expect(this.actionBarPage.interpreterSettingsButton).toBeEnabled(); + } + + if (await this.actionBarPage.permissionsButton.isVisible()) { + await expect(this.actionBarPage.permissionsButton).toBeEnabled(); + } + + if (await this.actionBarPage.lookAndFeelDropdown.isVisible()) { + await expect(this.actionBarPage.lookAndFeelDropdown).toBeEnabled(); + } Review Comment: Are there any reasons for conditionally checking those buttons? ########## zeppelin-web-angular/e2e/models/notebook-paragraph-page.util.ts: ########## @@ -0,0 +1,218 @@ +/* + * 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 { NotebookParagraphPage } from './notebook-paragraph-page'; + +export class NotebookParagraphUtil { + private page: Page; + private paragraphPage: NotebookParagraphPage; + + constructor(page: Page) { + this.page = page; + this.paragraphPage = new NotebookParagraphPage(page); + } + + async verifyParagraphContainerStructure(): Promise<void> { + await expect(this.paragraphPage.paragraphContainer).toBeVisible(); + await expect(this.paragraphPage.controlPanel).toBeVisible(); + } + + async verifyDoubleClickEditingFunctionality(): Promise<void> { + await expect(this.paragraphPage.paragraphContainer).toBeVisible(); + + await this.paragraphPage.doubleClickToEdit(); + + await expect(this.paragraphPage.codeEditor).toBeVisible(); + } + + async verifyAddParagraphButtons(): Promise<void> { + await expect(this.paragraphPage.addParagraphAbove).toBeVisible(); + await expect(this.paragraphPage.addParagraphBelow).toBeVisible(); + + const addAboveCount = await this.paragraphPage.addParagraphAbove.count(); + const addBelowCount = await this.paragraphPage.addParagraphBelow.count(); + + expect(addAboveCount).toBeGreaterThan(0); + expect(addBelowCount).toBeGreaterThan(0); + } + + async verifyParagraphControlInterface(): Promise<void> { + await expect(this.paragraphPage.controlPanel).toBeVisible(); + + // Check if run button exists and is visible + try { + const runButtonVisible = await this.paragraphPage.runButton.isVisible(); + if (runButtonVisible) { + await expect(this.paragraphPage.runButton).toBeVisible(); + const isRunEnabled = await this.paragraphPage.isRunButtonEnabled(); + expect(isRunEnabled).toBe(true); + } else { + console.log('Run button not found - paragraph may not support execution'); + } + } catch (error) { + console.log('Run button not accessible - paragraph may not support execution'); + } + } + + async verifyCodeEditorFunctionality(): Promise<void> { + const isCodeEditorVisible = await this.paragraphPage.isCodeEditorVisible(); + if (isCodeEditorVisible) { + await expect(this.paragraphPage.codeEditor).toBeVisible(); + } + } + + async verifyResultDisplaySystem(): Promise<void> { + const hasResult = await this.paragraphPage.hasResult(); + if (hasResult) { + await expect(this.paragraphPage.resultDisplay).toBeVisible(); + } + } + + async verifyTitleEditingIfPresent(): Promise<void> { + const titleVisible = await this.paragraphPage.titleEditor.isVisible(); + if (titleVisible) { + // Check if it's actually editable - some custom components may not be detected as editable + try { + await expect(this.paragraphPage.titleEditor).toBeEditable(); + } catch (error) { + // If it's not detected as editable by default, check if it has contenteditable or can receive focus + const isContentEditable = await this.paragraphPage.titleEditor.getAttribute('contenteditable'); + const hasInputChild = (await this.paragraphPage.titleEditor.locator('input, textarea').count()) > 0; + + if (isContentEditable === 'true' || hasInputChild) { + console.log('Title editor is a custom editable component'); + } else { + console.log('Title editor may not be editable in current state'); + } + } + } + } + + async verifyProgressIndicatorDuringExecution(): Promise<void> { + if (await this.paragraphPage.runButton.isVisible()) { + await this.paragraphPage.runParagraph(); + + const isRunning = await this.paragraphPage.isRunning(); + if (isRunning) { + await expect(this.paragraphPage.progressIndicator).toBeVisible(); + + await this.page.waitForFunction( + () => { + const progressElement = document.querySelector('zeppelin-notebook-paragraph-progress'); + return !progressElement || !progressElement.isConnected; + }, + { timeout: 30000 } + ); + } + } + } + + async verifyDynamicFormsIfPresent(): Promise<void> { + const isDynamicFormsVisible = await this.paragraphPage.isDynamicFormsVisible(); + if (isDynamicFormsVisible) { + await expect(this.paragraphPage.dynamicForms).toBeVisible(); + } + } Review Comment: It seems like an always-passing test. ########## zeppelin-web-angular/e2e/models/folder-rename-page.util.ts: ########## @@ -0,0 +1,231 @@ +/* + * 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 { FolderRenamePage } from './folder-rename-page'; + +export class FolderRenamePageUtil { + constructor( + private readonly page: Page, + private readonly folderRenamePage: FolderRenamePage + ) {} + + async verifyContextMenuAppearsOnHover(folderName: string): Promise<void> { + await this.folderRenamePage.hoverOverFolder(folderName); + + // Find the specific folder node and its rename button + const folderNode = this.page + .locator('.node') + .filter({ + has: this.page.locator('.folder .name', { hasText: folderName }) + }) + .first(); + + const renameButton = folderNode.locator('a[nz-tooltip][nztooltiptitle="Rename folder"]'); + await expect(renameButton).toBeVisible(); + } Review Comment: This looks identical to `verifyRenameMenuItemIsDisplayed()`. Would checking `Rename menu` visiblity be sufficient? ########## zeppelin-web-angular/e2e/models/notebook-paragraph-page.util.ts: ########## @@ -0,0 +1,218 @@ +/* + * 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 { NotebookParagraphPage } from './notebook-paragraph-page'; + +export class NotebookParagraphUtil { + private page: Page; + private paragraphPage: NotebookParagraphPage; + + constructor(page: Page) { + this.page = page; + this.paragraphPage = new NotebookParagraphPage(page); + } + + async verifyParagraphContainerStructure(): Promise<void> { + await expect(this.paragraphPage.paragraphContainer).toBeVisible(); + await expect(this.paragraphPage.controlPanel).toBeVisible(); + } + + async verifyDoubleClickEditingFunctionality(): Promise<void> { + await expect(this.paragraphPage.paragraphContainer).toBeVisible(); + + await this.paragraphPage.doubleClickToEdit(); + + await expect(this.paragraphPage.codeEditor).toBeVisible(); + } + + async verifyAddParagraphButtons(): Promise<void> { + await expect(this.paragraphPage.addParagraphAbove).toBeVisible(); + await expect(this.paragraphPage.addParagraphBelow).toBeVisible(); + + const addAboveCount = await this.paragraphPage.addParagraphAbove.count(); + const addBelowCount = await this.paragraphPage.addParagraphBelow.count(); + + expect(addAboveCount).toBeGreaterThan(0); + expect(addBelowCount).toBeGreaterThan(0); + } + + async verifyParagraphControlInterface(): Promise<void> { + await expect(this.paragraphPage.controlPanel).toBeVisible(); + + // Check if run button exists and is visible + try { + const runButtonVisible = await this.paragraphPage.runButton.isVisible(); + if (runButtonVisible) { + await expect(this.paragraphPage.runButton).toBeVisible(); + const isRunEnabled = await this.paragraphPage.isRunButtonEnabled(); + expect(isRunEnabled).toBe(true); + } else { + console.log('Run button not found - paragraph may not support execution'); + } + } catch (error) { + console.log('Run button not accessible - paragraph may not support execution'); + } + } + + async verifyCodeEditorFunctionality(): Promise<void> { + const isCodeEditorVisible = await this.paragraphPage.isCodeEditorVisible(); + if (isCodeEditorVisible) { + await expect(this.paragraphPage.codeEditor).toBeVisible(); + } + } + + async verifyResultDisplaySystem(): Promise<void> { + const hasResult = await this.paragraphPage.hasResult(); + if (hasResult) { + await expect(this.paragraphPage.resultDisplay).toBeVisible(); + } + } + + async verifyTitleEditingIfPresent(): Promise<void> { + const titleVisible = await this.paragraphPage.titleEditor.isVisible(); + if (titleVisible) { + // Check if it's actually editable - some custom components may not be detected as editable + try { + await expect(this.paragraphPage.titleEditor).toBeEditable(); + } catch (error) { + // If it's not detected as editable by default, check if it has contenteditable or can receive focus + const isContentEditable = await this.paragraphPage.titleEditor.getAttribute('contenteditable'); + const hasInputChild = (await this.paragraphPage.titleEditor.locator('input, textarea').count()) > 0; + + if (isContentEditable === 'true' || hasInputChild) { + console.log('Title editor is a custom editable component'); + } else { + console.log('Title editor may not be editable in current state'); + } + } + } + } + + async verifyProgressIndicatorDuringExecution(): Promise<void> { + if (await this.paragraphPage.runButton.isVisible()) { + await this.paragraphPage.runParagraph(); + + const isRunning = await this.paragraphPage.isRunning(); + if (isRunning) { + await expect(this.paragraphPage.progressIndicator).toBeVisible(); + + await this.page.waitForFunction( + () => { + const progressElement = document.querySelector('zeppelin-notebook-paragraph-progress'); + return !progressElement || !progressElement.isConnected; + }, + { timeout: 30000 } + ); + } + } + } + + async verifyDynamicFormsIfPresent(): Promise<void> { + const isDynamicFormsVisible = await this.paragraphPage.isDynamicFormsVisible(); + if (isDynamicFormsVisible) { + await expect(this.paragraphPage.dynamicForms).toBeVisible(); + } + } + + async verifyFooterInformation(): Promise<void> { + const footerText = await this.paragraphPage.getFooterText(); + expect(footerText).toBeDefined(); + } + + async verifyParagraphControlActions(): Promise<void> { + await this.paragraphPage.openSettingsDropdown(); + + // Wait for dropdown to appear by checking for any menu item + const dropdownMenu = this.page.locator('ul.ant-dropdown-menu, .dropdown-menu'); + await expect(dropdownMenu).toBeVisible({ timeout: 5000 }); + + // Check if dropdown menu items are present (they might use different selectors) + const moveUpVisible = await this.page.locator('li:has-text("Move up")').isVisible(); + const deleteVisible = await this.page.locator('li:has-text("Delete")').isVisible(); + const cloneVisible = await this.page.locator('li:has-text("Clone")').isVisible(); + + if (moveUpVisible) { + await expect(this.page.locator('li:has-text("Move up")')).toBeVisible(); + } + if (deleteVisible) { + await expect(this.page.locator('li:has-text("Delete")')).toBeVisible(); + } + if (cloneVisible) { + await expect(this.page.locator('li:has-text("Clone")')).toBeVisible(); + } + + // Close dropdown if it's open + await this.page.keyboard.press('Escape'); + } + + async verifyParagraphExecutionWorkflow(): Promise<void> { + // Check if run button exists and is accessible + try { + const runButtonVisible = await this.paragraphPage.runButton.isVisible(); + if (runButtonVisible) { + await expect(this.paragraphPage.runButton).toBeVisible(); + await expect(this.paragraphPage.runButton).toBeEnabled(); + + await this.paragraphPage.runParagraph(); + + const isStopVisible = await this.paragraphPage.isStopButtonVisible(); + if (isStopVisible) { + await expect(this.paragraphPage.stopButton).toBeVisible(); + } Review Comment: We need to verify the results of the action, and the isStopVisible check seems to never fail. Also, wouldn’t wrapping the whole test method in a try/catch clause just prevent it from failing? ########## zeppelin-web-angular/e2e/models/notebook-sidebar-page.ts: ########## @@ -0,0 +1,423 @@ +/* + * 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 NotebookSidebarPage extends BasePage { + readonly sidebarContainer: Locator; + readonly tocButton: Locator; + readonly fileTreeButton: Locator; + readonly closeButton: Locator; + readonly nodeList: Locator; + readonly noteToc: Locator; + readonly sidebarContent: Locator; + + constructor(page: Page) { + super(page); + this.sidebarContainer = page.locator('zeppelin-notebook-sidebar'); + // Try multiple possible selectors for TOC button with more specific targeting + this.tocButton = page + .locator( + 'zeppelin-notebook-sidebar button[nzTooltipTitle*="Table"], zeppelin-notebook-sidebar button[title*="Table"], zeppelin-notebook-sidebar i[nz-icon][nzType="unordered-list"], zeppelin-notebook-sidebar button:has(i[nzType="unordered-list"]), zeppelin-notebook-sidebar .sidebar-button:has(i[nzType="unordered-list"])' + ) + .first(); + // Try multiple possible selectors for File Tree button with more specific targeting + this.fileTreeButton = page + .locator( + 'zeppelin-notebook-sidebar button[nzTooltipTitle*="File"], zeppelin-notebook-sidebar button[title*="File"], zeppelin-notebook-sidebar i[nz-icon][nzType="folder"], zeppelin-notebook-sidebar button:has(i[nzType="folder"]), zeppelin-notebook-sidebar .sidebar-button:has(i[nzType="folder"])' + ) + .first(); + // Try multiple selectors for close button with more specific targeting + this.closeButton = page + .locator( + 'zeppelin-notebook-sidebar button.sidebar-close, zeppelin-notebook-sidebar button[nzTooltipTitle*="Close"], zeppelin-notebook-sidebar i[nz-icon][nzType="close"], zeppelin-notebook-sidebar button:has(i[nzType="close"]), zeppelin-notebook-sidebar .close-button, zeppelin-notebook-sidebar [aria-label*="close" i]' + ) + .first(); + this.nodeList = page.locator('zeppelin-node-list'); + this.noteToc = page.locator('zeppelin-note-toc'); + this.sidebarContent = page.locator('.sidebar-content'); + } + + async openToc(): Promise<void> { + // Ensure sidebar is visible first + await expect(this.sidebarContainer).toBeVisible(); + + // Get initial state to check for changes + const initialState = await this.getSidebarState(); + + // Try multiple strategies to find and click the TOC button + const strategies = [ + // Strategy 1: Original button selector + () => this.tocButton.click(), + // Strategy 2: Look for unordered-list icon specifically in sidebar + () => this.page.locator('zeppelin-notebook-sidebar i[nzType="unordered-list"]').first().click(), + // Strategy 3: Look for any button with list-related icons + () => this.page.locator('zeppelin-notebook-sidebar button:has(i[nzType="unordered-list"])').first().click(), + // Strategy 4: Try aria-label or title containing "table" or "content" + () => + this.page + .locator( + 'zeppelin-notebook-sidebar button[aria-label*="Table"], zeppelin-notebook-sidebar button[aria-label*="Contents"]' + ) + .first() + .click(), + // Strategy 5: Look for any clickable element with specific classes + () => + this.page + .locator('zeppelin-notebook-sidebar .sidebar-nav button, zeppelin-notebook-sidebar [role="button"]') + .first() + .click() + ]; + + let success = false; + for (const strategy of strategies) { + try { + await strategy(); + + // Wait for state change after click - check for visible content instead of state + await Promise.race([ + // Option 1: Wait for TOC content to appear + this.page + .locator('zeppelin-note-toc, .sidebar-content .toc') + .waitFor({ state: 'visible', timeout: 3000 }) + .catch(() => {}), + // Option 2: Wait for file tree content to appear + this.page + .locator('zeppelin-node-list, .sidebar-content .file-tree') + .waitFor({ state: 'visible', timeout: 3000 }) + .catch(() => {}), + // Option 3: Wait for any sidebar content change + this.page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => {}) + ]).catch(() => { + // If all fail, continue - this is acceptable + }); + + success = true; + break; + } catch (error) { + console.log(`TOC button strategy failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + if (!success) { + console.log('All TOC button strategies failed - sidebar may not have TOC functionality'); + } + + // Wait for TOC content to be visible if it was successfully opened + const tocContent = this.page.locator('zeppelin-note-toc, .sidebar-content .toc, .outline-content'); + try { + await expect(tocContent).toBeVisible({ timeout: 3000 }); + } catch { + // TOC might not be available or visible, check if file tree opened instead + const fileTreeContent = this.page.locator('zeppelin-node-list, .sidebar-content .file-tree'); + try { + await expect(fileTreeContent).toBeVisible({ timeout: 2000 }); + } catch { + // Neither TOC nor file tree visible + } + } Review Comment: Could we use `waitFor()` method in `Locator` object, instead of wrapping `expect()` method? ########## zeppelin-web-angular/e2e/models/published-paragraph-page.util.ts: ########## @@ -25,6 +26,57 @@ export class PublishedParagraphTestUtil { this.notebookUtil = new NotebookUtil(page); } + async testConfirmationModalForNoResultParagraph({ + noteId, + paragraphId + }: { + noteId: string; + paragraphId: string; + }): Promise<void> { + await this.publishedParagraphPage.navigateToNotebook(noteId); + + const paragraphElement = this.page.locator('zeppelin-notebook-paragraph').first(); + + const settingsButton = paragraphElement.locator('a[nz-dropdown]'); + await settingsButton.click(); + + const clearOutputButton = this.page.locator('li.list-item:has-text("Clear output")'); + await clearOutputButton.click(); + await expect(paragraphElement.locator('[data-testid="paragraph-result"]')).toBeHidden(); + + await this.publishedParagraphPage.navigateToPublishedParagraph(noteId, paragraphId); + + const modal = this.publishedParagraphPage.confirmationModal; + await expect(modal).toBeVisible(); + + // Check for the new enhanced modal content + const modalTitle = this.page.locator('.ant-modal-confirm-title, .ant-modal-title'); + await expect(modalTitle).toContainText('Run Paragraph?'); + + // Check that code preview is shown + const modalContent = this.page.locator('.ant-modal-confirm-content, .ant-modal-body').first(); + await expect(modalContent).toContainText('This paragraph contains the following code:'); + await expect(modalContent).toContainText('Would you like to execute this code?'); + + // Verify that the code preview area exists with proper styling + const codePreview = modalContent.locator('div[style*="background-color: #f5f5f5"]'); + const isCodePreviewVisible = await codePreview.isVisible(); + + if (isCodePreviewVisible) { + await expect(codePreview).toBeVisible(); + } Review Comment: An always-passing test ########## zeppelin-web-angular/e2e/models/notebook-sidebar-page.util.ts: ########## @@ -0,0 +1,501 @@ +/* + * 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 { NotebookSidebarPage } from './notebook-sidebar-page'; +import { NotebookUtil } from './notebook.util'; + +export class NotebookSidebarUtil { + private page: Page; + private sidebarPage: NotebookSidebarPage; + private notebookUtil: NotebookUtil; + + constructor(page: Page) { + this.page = page; + this.sidebarPage = new NotebookSidebarPage(page); + this.notebookUtil = new NotebookUtil(page); + } + + async verifyNavigationButtons(): Promise<void> { + // Check if sidebar container is visible first + await expect(this.sidebarPage.sidebarContainer).toBeVisible(); + + // Try to find any navigation buttons in the sidebar area + const sidebarButtons = this.page.locator('zeppelin-notebook-sidebar button, .sidebar-nav button'); + const buttonCount = await sidebarButtons.count(); + + if (buttonCount > 0) { + // If we find buttons, verify they exist + await expect(sidebarButtons.first()).toBeVisible(); + console.log(`Found ${buttonCount} sidebar navigation buttons`); + } else { + // If no buttons found, try to find the sidebar icons/controls + const sidebarIcons = this.page.locator('zeppelin-notebook-sidebar i[nz-icon], .sidebar-nav i'); + const iconCount = await sidebarIcons.count(); + + if (iconCount > 0) { + await expect(sidebarIcons.first()).toBeVisible(); + console.log(`Found ${iconCount} sidebar navigation icons`); + } else { + // As a fallback, just verify the sidebar container is functional + console.log('Sidebar container is visible, assuming navigation is functional'); + } + } + } + + async verifyStateManagement(): Promise<void> { + const initialState = await this.sidebarPage.getSidebarState(); + expect(['CLOSED', 'TOC', 'FILE_TREE']).toContain(initialState); + + if (initialState === 'CLOSED') { + await this.sidebarPage.openToc(); + const newState = await this.sidebarPage.getSidebarState(); + + // Be flexible about TOC support - accept either TOC or FILE_TREE + if (newState === 'TOC') { + console.log('TOC functionality confirmed'); + } else if (newState === 'FILE_TREE') { + console.log('TOC not available, FILE_TREE functionality confirmed'); + } else { + console.log(`Unexpected state: ${newState}`); + } + expect(['TOC', 'FILE_TREE']).toContain(newState); + } + } + + async verifyToggleBehavior(): Promise<void> { + try { + // Increase timeout for CI stability and add more robust waits + await this.page.waitForLoadState('networkidle', { timeout: 15000 }); + + // Try to open TOC and check if it works - with retries for CI stability + let attempts = 0; + const maxAttempts = 3; + + while (attempts < maxAttempts) { + try { + // Add wait for sidebar to be ready + await expect(this.sidebarPage.sidebarContainer).toBeVisible({ timeout: 10000 }); + + await this.sidebarPage.openToc(); + // Wait for sidebar state to stabilize + await this.page.waitForLoadState('domcontentloaded'); + let currentState = await this.sidebarPage.getSidebarState(); + + // Be flexible about TOC support - if TOC isn't available, just verify sidebar functionality + if (currentState === 'TOC') { + // TOC is working correctly + console.log('TOC functionality confirmed'); + } else if (currentState === 'FILE_TREE') { + // TOC might not be available, but sidebar is functional + console.log('TOC not available or defaulting to FILE_TREE, testing FILE_TREE functionality instead'); + } else { + // Unexpected state + console.log(`Unexpected state after TOC click: ${currentState}`); + } + + // Test file tree functionality + await this.sidebarPage.openFileTree(); + await this.page.waitForLoadState('domcontentloaded'); + currentState = await this.sidebarPage.getSidebarState(); + expect(currentState).toBe('FILE_TREE'); + + // Test close functionality + await this.sidebarPage.closeSidebar(); + await this.page.waitForLoadState('domcontentloaded'); + currentState = await this.sidebarPage.getSidebarState(); + + // Be flexible about close functionality - it might not be available + if (currentState === 'CLOSED') { + console.log('Close functionality working correctly'); + } else { + console.log(`Close functionality not available - sidebar remains in ${currentState} state`); + // This is acceptable for some applications that don't support closing sidebar + } + + // If we get here, the test passed + break; + } catch (error) { + attempts++; + console.warn( + `Sidebar toggle attempt ${attempts} failed:`, + error instanceof Error ? error.message : String(error) + ); + + if (attempts >= maxAttempts) { + console.warn('All sidebar toggle attempts failed - browser may be unstable in CI'); + // Accept failure in CI environment + break; + } + + // Wait before retry + await this.page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}); + } + } + } catch (error) { + console.warn('Sidebar toggle behavior test failed due to browser/page issue:', error); + // If browser closes or connection is lost, just log and continue + } + } + + async verifyTocContentLoading(): Promise<void> { + await this.sidebarPage.openToc(); + + const isTocVisible = await this.sidebarPage.isTocContentVisible(); + if (isTocVisible) { + await expect(this.sidebarPage.noteToc).toBeVisible(); + + const tocItems = await this.sidebarPage.getTocItems(); + expect(tocItems).toBeDefined(); + } + } + + async verifyFileTreeContentLoading(): Promise<void> { + await this.sidebarPage.openFileTree(); + + const isFileTreeVisible = await this.sidebarPage.isFileTreeContentVisible(); + if (isFileTreeVisible) { + await expect(this.sidebarPage.nodeList).toBeVisible(); + + const fileTreeItems = await this.sidebarPage.getFileTreeItems(); + expect(fileTreeItems).toBeDefined(); + } + } + + async verifyTocInteraction(): Promise<void> { + await this.sidebarPage.openToc(); + + const tocItems = await this.sidebarPage.getTocItems(); + if (tocItems.length > 0) { + const firstItem = tocItems[0]; + await this.sidebarPage.clickTocItem(firstItem); + + // Wait for navigation or selection to take effect + await expect(this.page.locator('.paragraph-selected, .active-item')) + .toBeVisible({ timeout: 3000 }) + .catch(() => {}); + } + } + + async verifyFileTreeInteraction(): Promise<void> { + await this.sidebarPage.openFileTree(); + + const fileTreeItems = await this.sidebarPage.getFileTreeItems(); + if (fileTreeItems.length > 0) { + const firstItem = fileTreeItems[0]; + await this.sidebarPage.clickFileTreeItem(firstItem); + + // Wait for file tree item interaction to complete + await expect(this.page.locator('.file-tree-item.selected, .active-file')) + .toBeVisible({ timeout: 3000 }) + .catch(() => {}); + } + } + + async verifyCloseFunctionality(): Promise<void> { + try { + // Add robust waits for CI stability + await this.page.waitForLoadState('networkidle', { timeout: 15000 }); + await expect(this.sidebarPage.sidebarContainer).toBeVisible({ timeout: 10000 }); + + // Try to open TOC, but accept FILE_TREE if TOC isn't available + await this.sidebarPage.openToc(); + await this.page.waitForLoadState('domcontentloaded'); + const state = await this.sidebarPage.getSidebarState(); + expect(['TOC', 'FILE_TREE']).toContain(state); + + await this.sidebarPage.closeSidebar(); + await this.page.waitForLoadState('domcontentloaded'); + const closeState = await this.sidebarPage.getSidebarState(); + + // Be flexible about close functionality + if (closeState === 'CLOSED') { + console.log('Close functionality working correctly'); + } else { + console.log(`Close functionality not available - sidebar remains in ${closeState} state`); + } + } catch (error) { + console.warn('Close functionality test failed due to browser/page issue:', error); + // If browser closes or connection is lost, just log and continue + } + } + + async verifyAllSidebarStates(): Promise<void> { + try { + // Test TOC functionality if available + await this.sidebarPage.openToc(); + const tocState = await this.sidebarPage.getSidebarState(); + + if (tocState === 'TOC') { + console.log('TOC functionality available and working'); + await expect(this.sidebarPage.noteToc).toBeVisible(); + } else { + console.log('TOC functionality not available, testing FILE_TREE instead'); + expect(tocState).toBe('FILE_TREE'); + } + + // Wait for TOC state to stabilize before testing FILE_TREE + await expect(this.sidebarPage.sidebarContainer).toBeVisible(); + + // Test FILE_TREE functionality + await this.sidebarPage.openFileTree(); + const fileTreeState = await this.sidebarPage.getSidebarState(); + expect(fileTreeState).toBe('FILE_TREE'); + await expect(this.sidebarPage.nodeList).toBeVisible(); + + // Wait for file tree state to stabilize before testing close functionality + await expect(this.sidebarPage.nodeList).toBeVisible(); + + // Test close functionality + await this.sidebarPage.closeSidebar(); + const finalState = await this.sidebarPage.getSidebarState(); + + // Be flexible about close functionality + if (finalState === 'CLOSED') { + console.log('Close functionality working correctly'); + } else { + console.log(`Close functionality not available - sidebar remains in ${finalState} state`); + } + } catch (error) { + console.warn('Sidebar states verification failed due to browser/page issue:', error); + // If browser closes or connection is lost, just log and continue + } + } + + async verifyAllSidebarFunctionality(): Promise<void> { + await this.verifyNavigationButtons(); + await this.verifyStateManagement(); + await this.verifyToggleBehavior(); + await this.verifyTocContentLoading(); + await this.verifyFileTreeContentLoading(); + await this.verifyCloseFunctionality(); + await this.verifyAllSidebarStates(); + } + + async createTestNotebook(): Promise<{ noteId: string; paragraphId: string }> { + const notebookName = `Test Notebook ${Date.now()}`; + + try { + // Use existing NotebookUtil to create notebook with increased timeout + await this.notebookUtil.createNotebook(notebookName); + + // Add extra wait for page stabilization + await this.page.waitForLoadState('networkidle', { timeout: 15000 }); + + // Wait for navigation to notebook page or try to navigate + await this.page + .waitForFunction( + () => window.location.href.includes('/notebook/') || document.querySelector('zeppelin-notebook-paragraph'), + { timeout: 10000 } + ) + .catch(() => { + console.log('Notebook navigation timeout, checking current state...'); + }); + + // Extract noteId from URL + let url = this.page.url(); + let noteIdMatch = url.match(/\/notebook\/([^\/\?]+)/); + + // If URL doesn't contain notebook ID, try to find it from the DOM or API + if (!noteIdMatch) { + console.log(`URL ${url} doesn't contain notebook ID, trying alternative methods...`); + + // Try to get notebook ID from the page content or API + const foundNoteId = await this.page.evaluate(async targetName => { + // Check if there's a notebook element with data attributes + const notebookElement = document.querySelector('zeppelin-notebook'); + if (notebookElement) { + const noteIdAttr = notebookElement.getAttribute('data-note-id') || notebookElement.getAttribute('note-id'); + if (noteIdAttr) { + return noteIdAttr; + } + } + + // Try to fetch from API to get the latest created notebook + try { + const response = await fetch('/api/notebook'); + const data = await response.json(); + if (data.body && Array.isArray(data.body)) { + // Find the most recently created notebook with matching name pattern + const testNotebooks = data.body.filter( + (nb: { path?: string }) => nb.path && nb.path.includes(targetName) + ); + if (testNotebooks.length > 0) { + // Sort by creation time and get the latest + testNotebooks.sort( + (a: { dateUpdated?: string }, b: { dateUpdated?: string }) => + new Date(b.dateUpdated || 0).getTime() - new Date(a.dateUpdated || 0).getTime() + ); + return testNotebooks[0].id; + } + } + } catch (apiError) { + console.log('API call failed:', apiError); + } + + return null; + }, notebookName); + + if (foundNoteId) { + console.log(`Found notebook ID via alternative method: ${foundNoteId}`); + // Navigate to the notebook page + await this.page.goto(`/#/notebook/${foundNoteId}`); + await this.page.waitForLoadState('networkidle', { timeout: 15000 }); + url = this.page.url(); + noteIdMatch = url.match(/\/notebook\/([^\/\?]+)/); + } + + if (!noteIdMatch) { + throw new Error(`Failed to extract notebook ID from URL: ${url}. Notebook creation may have failed.`); + } + } + + const noteId = noteIdMatch[1]; + + // Get first paragraph ID with increased timeout + await this.page.locator('zeppelin-notebook-paragraph').first().waitFor({ state: 'visible', timeout: 20000 }); + const paragraphContainer = this.page.locator('zeppelin-notebook-paragraph').first(); + + // Try to get paragraph ID from the paragraph element's data-testid attribute + const paragraphId = await paragraphContainer.getAttribute('data-testid').catch(() => null); + + if (paragraphId && paragraphId.startsWith('paragraph_')) { + console.log(`Found paragraph ID from data-testid attribute: ${paragraphId}`); + return { noteId, paragraphId }; + } + + // Fallback: try dropdown approach with better error handling and proper wait times + const dropdownTrigger = paragraphContainer.locator('a[nz-dropdown]'); + + if ((await dropdownTrigger.count()) > 0) { + await this.page.waitForLoadState('domcontentloaded'); + await dropdownTrigger.click({ timeout: 10000, force: true }); + + // Wait for dropdown menu to be visible before trying to extract content + await this.page.locator('nz-dropdown-menu .setting-menu').waitFor({ state: 'visible', timeout: 5000 }); + + // The paragraph ID is in li.paragraph-id > a element + const paragraphIdLink = this.page.locator('li.paragraph-id a').first(); + + if ((await paragraphIdLink.count()) > 0) { + await paragraphIdLink.waitFor({ state: 'visible', timeout: 3000 }); + const text = await paragraphIdLink.textContent(); + if (text && text.startsWith('paragraph_')) { + console.log(`Found paragraph ID from dropdown: ${text}`); + // Close dropdown before returning + await this.page.keyboard.press('Escape'); + return { noteId, paragraphId: text }; + } + } + + // Close dropdown if still open + await this.page.keyboard.press('Escape'); + } + + // Final fallback: generate a paragraph ID + const fallbackParagraphId = `paragraph_${Date.now()}_000001`; + console.warn(`Could not find paragraph ID via data-testid or dropdown, using fallback: ${fallbackParagraphId}`); + + // Navigate back to home with increased timeout + await this.page.goto('/'); + await this.page.waitForLoadState('networkidle', { timeout: 15000 }); + await this.page.waitForSelector('text=Welcome to Zeppelin!', { timeout: 10000 }); + + return { noteId, paragraphId: fallbackParagraphId }; + } catch (error) { + console.error('Failed to create test notebook:', error); + throw error; + } Review Comment: Can we just not catch this to reduce code branches in this method? ########## zeppelin-web-angular/e2e/tests/notebook/sidebar/sidebar-functionality.spec.ts: ########## @@ -0,0 +1,247 @@ +/* + * 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 { NotebookSidebarUtil } from '../../../models/notebook-sidebar-page.util'; +import { addPageAnnotationBeforeEach, performLoginIfRequired, waitForZeppelinReady, PAGES } from '../../../utils'; + +test.describe('Notebook Sidebar Functionality', () => { + addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_SIDEBAR); + + test.beforeEach(async ({ page }) => { + await page.goto('/', { + waitUntil: 'load', + timeout: 60000 + }); + await waitForZeppelinReady(page); + await performLoginIfRequired(page); + }); + + test('should display navigation buttons', async ({ page }) => { + // Given: User is on the home page + await page.goto('/'); + await waitForZeppelinReady(page); + + // Create a test notebook since none may exist in CI + const sidebarUtil = new NotebookSidebarUtil(page); + const testNotebook = await sidebarUtil.createTestNotebook(); + + try { Review Comment: This try/catch may silence the failure. ########## zeppelin-web-angular/e2e/tests/notebook/published/published-paragraph.spec.ts: ########## @@ -87,55 +94,139 @@ test.describe('Published Paragraph', () => { }); }); - test.describe('Valid Paragraph Display', () => { - test('should enter published paragraph by clicking', async () => { + test.describe('Navigation and URL Patterns', () => { + test('should enter published paragraph by clicking link', async () => { await testUtil.verifyClickLinkThisParagraphBehavior(testNotebook.noteId, testNotebook.paragraphId); }); - test('should enter published paragraph by URL', async ({ page }) => { + test('should enter published paragraph by direct URL navigation', async ({ page }) => { await page.goto(`/#/notebook/${testNotebook.noteId}/paragraph/${testNotebook.paragraphId}`); await page.waitForLoadState('networkidle'); await expect(page).toHaveURL(`/#/notebook/${testNotebook.noteId}/paragraph/${testNotebook.paragraphId}`, { timeout: 10000 }); }); + + test('should maintain paragraph context in published mode', async ({ page }) => { + const { noteId, paragraphId } = testNotebook; + + await page.goto(`/#/notebook/${noteId}/paragraph/${paragraphId}`); + await page.waitForLoadState('networkidle'); + + expect(page.url()).toContain(noteId); + expect(page.url()).toContain(paragraphId); + + const publishedContainer = page.locator('zeppelin-publish-paragraph'); + if (await publishedContainer.isVisible()) { + await expect(publishedContainer).toBeVisible(); + } Review Comment: Always-passing test. ########## zeppelin-web-angular/e2e/global-teardown.ts: ########## @@ -17,6 +17,63 @@ async function globalTeardown() { LoginTestUtil.resetCache(); console.log('✅ Test cache cleared'); + + // Clean up test notebooks that may have been left behind due to test failures + await cleanupTestNotebooks(); +} + +async function cleanupTestNotebooks() { Review Comment: What I meant was exporting a temporary directory for tests as an environment variable in `frontend.yml`, and then deleting that directory all at once from somewhere. ########## zeppelin-web-angular/e2e/models/note-toc-page.ts: ########## @@ -0,0 +1,119 @@ +/* + * 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 tocListArea: Locator; + readonly tocEmptyMessage: Locator; + readonly tocItems: Locator; + readonly codeEditor: Locator; + readonly runButton: Locator; + readonly addParagraphButton: Locator; + + constructor(page: Page) { + super(page); + this.tocToggleButton = page.locator('.sidebar-button').first(); Review Comment: How about adding more specific label(e.g., `aria-label`) to the element and using that to select the element? ########## zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts: ########## @@ -0,0 +1,1110 @@ +/* + * 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 { BasePage } from './base-page'; + +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; + + 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('[data-testid="paragraph-result"]'); + 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'); + } + + async navigateToNotebook(noteId: string): Promise<void> { + if (!noteId) { + throw new Error('noteId is undefined or null. Cannot navigate to notebook.'); + } + + // Use the reusable navigation function with fallback strategies + await navigateToNotebookWithFallback(this.page, noteId); + + // Ensure paragraphs are visible after navigation + try { + await expect(this.paragraphContainer.first()).toBeVisible({ timeout: 15000 }); + } catch (error) { + // If no paragraphs found, log but don't throw - let tests handle gracefully + const paragraphCount = await this.page.locator('zeppelin-notebook-paragraph').count(); + console.log(`Found ${paragraphCount} paragraphs after navigation`); + } + } + + async focusCodeEditor(paragraphIndex: number = 0): Promise<void> { + if (this.page.isClosed()) { + console.warn('Cannot focus code editor: page is closed'); + return; + } + try { + // First check if paragraphs exist at all + const paragraphCount = await this.page.locator('zeppelin-notebook-paragraph').count(); + 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 }); + + const editor = paragraph.locator('.monaco-editor, .CodeMirror, .ace_editor, textarea').first(); + await editor.waitFor({ state: 'visible', timeout: 5000 }); + + await editor.click({ force: true }); + + const textArea = editor.locator('textarea'); + if (await textArea.count()) { + await textArea.press('ArrowRight'); + await expect(textArea).toBeFocused({ timeout: 2000 }); + return; + } + + // Wait for editor to be focused instead of fixed timeout + await expect(editor).toHaveClass(/focused|focus/, { timeout: 5000 }); + } catch (error) { + console.warn(`Focus code editor for paragraph ${paragraphIndex} failed:`, error); + } + } + + async typeInEditor(text: string): Promise<void> { + await this.page.keyboard.type(text); + } + + async pressKey(key: string, modifiers?: string[]): Promise<void> { + if (modifiers && modifiers.length > 0) { + await this.page.keyboard.press(`${modifiers.join('+')}+${key}`); + } else { + await this.page.keyboard.press(key); + } + } + + async pressControlEnter(): Promise<void> { + await this.page.keyboard.press('Control+Enter'); + } + + async pressControlSpace(): Promise<void> { + await this.page.keyboard.press('Control+Space'); + } + + async pressArrowDown(): Promise<void> { + await this.page.keyboard.press('ArrowDown'); + } + + async pressArrowUp(): Promise<void> { + await this.page.keyboard.press('ArrowUp'); + } + + async pressTab(): Promise<void> { + await this.page.keyboard.press('Tab'); + } + + async pressEscape(): Promise<void> { + await this.page.keyboard.press('Escape'); + } + + // Platform detection utility + private getPlatform(): string { + return process.platform || 'unknown'; + } + + private isMacOS(): boolean { + return this.getPlatform() === 'darwin'; + } + + private async executeWebkitShortcut(formattedShortcut: string): Promise<void> { + const parts = formattedShortcut.split('+'); + const mainKey = parts[parts.length - 1]; + const hasControl = formattedShortcut.includes('control'); + const hasShift = formattedShortcut.includes('shift'); + const hasAlt = formattedShortcut.includes('alt'); + const keyMap: Record<string, string> = { + arrowup: 'ArrowUp', + arrowdown: 'ArrowDown', + enter: 'Enter' + }; + const resolvedKey = keyMap[mainKey] || mainKey.toUpperCase(); + + if (hasAlt) { + await this.page.keyboard.down('Alt'); + } + if (hasShift) { + await this.page.keyboard.down('Shift'); + } + if (hasControl) { + await this.page.keyboard.down('Control'); + } + + await this.page.keyboard.press(resolvedKey, { delay: 50 }); + + if (hasControl) { + await this.page.keyboard.up('Control'); + } + if (hasShift) { + await this.page.keyboard.up('Shift'); + } + if (hasAlt) { + await this.page.keyboard.up('Alt'); + } + } + + private async executeStandardShortcut(formattedShortcut: string): Promise<void> { + const isMac = this.isMacOS(); + const formattedKey = formattedShortcut + .replace(/alt/g, 'Alt') + .replace(/shift/g, 'Shift') + .replace(/arrowup/g, 'ArrowUp') + .replace(/arrowdown/g, 'ArrowDown') + .replace(/enter/g, 'Enter') + .replace(/control/g, isMac ? 'Meta' : 'Control') + .replace(/\+([a-z0-9-=])$/, (_, c) => `+${c.toUpperCase()}`); + + console.log('Final key combination:', formattedKey); + await this.page.keyboard.press(formattedKey, { delay: 50 }); + } + + // Platform-aware keyboard shortcut execution + private async executePlatformShortcut(shortcuts: string | string[]): Promise<void> { + const shortcutArray = Array.isArray(shortcuts) ? shortcuts : [shortcuts]; + const browserName = test.info().project.name; + + for (const shortcut of shortcutArray) { + try { + const formatted = shortcut.toLowerCase().replace(/\./g, '+'); + console.log('Shortcut:', shortcut, '->', formatted, 'on', browserName); + + await this.page.evaluate(() => { + const el = document.activeElement || document.querySelector('body'); + if (el && 'focus' in el && typeof (el as HTMLElement).focus === 'function') { + (el as HTMLElement).focus(); + } + }); + + if (browserName === 'webkit') { + await this.executeWebkitShortcut(formatted); + } else { + await this.executeStandardShortcut(formatted); + } + + return; + } catch (error) { + console.log(`Shortcut ${shortcut} failed:`, error); + // Continue to next shortcut variant + } + } + } + + // All ShortcutsMap keyboard shortcuts + + // Run paragraph - shift.enter + async pressRunParagraph(): Promise<void> { + const browserName = test.info().project.name; + + if (browserName === 'chromium' || browserName === 'Google Chrome' || browserName === 'Microsoft Edge') { + try { + const paragraph = this.getParagraphByIndex(0); + const textarea = paragraph.locator('textarea').first(); + await textarea.focus(); + await expect(textarea).toBeFocused({ timeout: 1000 }); + await textarea.press('Shift+Enter'); + console.log(`${browserName}: Used textarea.press for Shift+Enter`); + return; + } catch (error) { + console.log(`${browserName}: textarea.press failed:`, error); + } + } + + try { + const paragraph = this.getParagraphByIndex(0); + + // Try multiple selectors for the run button - ordered by specificity + const runButtonSelectors = [ + 'i.run-para[nz-tooltip][nzTooltipTitle="Run paragraph"]', + 'i.run-para[nzType="play-circle"]', + 'i.run-para', + 'i[nzType="play-circle"]', + 'button[title="Run this paragraph"]', + 'button:has-text("Run")', + 'i.ant-icon-caret-right', + '.paragraph-control i[nz-tooltip]', + '.run-control i', + 'i.fa-play' + ]; + + let clickSuccess = false; + + for (const selector of runButtonSelectors) { + try { + const runElement = paragraph.locator(selector).first(); + const count = await runElement.count(); + + if (count > 0) { + await runElement.waitFor({ state: 'visible', timeout: 3000 }); + await runElement.click({ force: true }); + + // Wait for paragraph to start running instead of fixed timeout + const runningIndicator = paragraph.locator( + '.paragraph-control .fa-spin, .running-indicator, .paragraph-status-running' + ); + await runningIndicator.waitFor({ state: 'visible', timeout: 2000 }).catch(() => { + console.log('No running indicator found, execution may have completed quickly'); + }); + + console.log(`${browserName}: Used selector "${selector}" for run button`); + clickSuccess = true; + break; + } + } catch (selectorError) { + console.log(`${browserName}: Selector "${selector}" failed:`, selectorError); + continue; + } + } + + if (clickSuccess) { + // Wait for execution to start or complete instead of fixed timeout + const targetParagraph = this.getParagraphByIndex(0); + const runningIndicator = targetParagraph.locator( + '.paragraph-control .fa-spin, .running-indicator, .paragraph-status-running' + ); + const resultIndicator = targetParagraph.locator('[data-testid="paragraph-result"]'); + + // Wait for either execution to start (running indicator) or complete (result appears) + await Promise.race([ + runningIndicator.waitFor({ state: 'visible', timeout: browserName === 'webkit' ? 3000 : 2000 }), + resultIndicator.waitFor({ state: 'visible', timeout: browserName === 'webkit' ? 3000 : 2000 }) + ]).catch(() => { + console.log(`${browserName}: No execution indicators found, continuing...`); + }); + + console.log(`${browserName}: Used Run button click as fallback`); + return; + } + + throw new Error('No run button found with any selector'); Review Comment: I haven't figured out a fundamental way to reduce this method's complexity yet. However, instead of throwing an error at the end when the click fails and then handling the final fallback in the catch block, wouldn't it be better to ignore the error inside the catch block and move the final fallback logic outside the catch? That way, you don't need to throw and immediately catch the error again, and the depth of the final fallback logic becomes flatter, making the flow a bit more natural to read. ########## zeppelin-web-angular/e2e/models/notebook-page.util.ts: ########## @@ -0,0 +1,159 @@ +/* + * 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 { HomePage } from './home-page'; +import { NotebookPage } from './notebook-page'; + +export class NotebookPageUtil extends BasePage { + private homePage: HomePage; + private notebookPage: NotebookPage; + + constructor(page: Page) { + super(page); + this.homePage = new HomePage(page); + this.notebookPage = new NotebookPage(page); + } + + // ===== NOTEBOOK CREATION METHODS ===== + + async createNotebook(notebookName: string): Promise<void> { + await this.homePage.navigateToHome(); + await this.homePage.createNewNoteButton.click(); + + // Wait for the modal to appear and fill the notebook name + const notebookNameInput = this.page.locator('input[name="noteName"]'); + await expect(notebookNameInput).toBeVisible({ timeout: 10000 }); + + // Fill notebook name + await notebookNameInput.fill(notebookName); + + // Click the 'Create' button in the modal + const createButton = this.page.locator('button', { hasText: 'Create' }); + await createButton.click(); + + // Wait for the notebook to be created and navigate to it + await expect(this.page).toHaveURL(/#\/notebook\//, { timeout: 60000 }); + await this.waitForPageLoad(); + await this.page.waitForSelector('zeppelin-notebook-paragraph', { timeout: 15000 }); + await this.page.waitForSelector('.spin-text', { state: 'hidden', timeout: 10000 }).catch(() => {}); + } + + // ===== NOTEBOOK VERIFICATION METHODS ===== + + async verifyNotebookContainerStructure(): Promise<void> { + await expect(this.notebookPage.notebookContainer).toBeVisible(); + + const containerClass = await this.notebookPage.getNotebookContainerClass(); + expect(containerClass).toContain('notebook-container'); + } + + async verifyActionBarPresence(): Promise<void> { + // Wait for the notebook container to be fully loaded first + await expect(this.notebookPage.notebookContainer).toBeVisible(); + + // Wait for the action bar to be visible with a longer timeout + await expect(this.notebookPage.actionBar).toBeVisible({ timeout: 15000 }); + } + + async verifySidebarFunctionality(): Promise<void> { + // Wait for the notebook container to be fully loaded first + await expect(this.notebookPage.notebookContainer).toBeVisible(); + + // Wait for the sidebar area to be visible with a longer timeout + await expect(this.notebookPage.sidebarArea).toBeVisible({ timeout: 15000 }); + + const width = await this.notebookPage.getSidebarWidth(); + expect(width).toBeGreaterThanOrEqual(40); + expect(width).toBeLessThanOrEqual(800); + } + + async verifyParagraphContainerStructure(): Promise<void> { + // Wait for the notebook container to be fully loaded first + await expect(this.notebookPage.notebookContainer).toBeVisible(); + + // Wait for the paragraph inner area to be visible + await expect(this.notebookPage.paragraphInner).toBeVisible({ timeout: 15000 }); + + const paragraphCount = await this.notebookPage.getParagraphCount(); + expect(paragraphCount).toBeGreaterThanOrEqual(0); + } + + async verifyExtensionAreaIfVisible(): Promise<void> { + const isExtensionVisible = await this.notebookPage.isExtensionAreaVisible(); + if (isExtensionVisible) { + await expect(this.notebookPage.extensionArea).toBeVisible(); + } + } + + async verifyNoteFormBlockIfVisible(): Promise<void> { + const isFormBlockVisible = await this.notebookPage.isNoteFormBlockVisible(); + if (isFormBlockVisible) { + await expect(this.notebookPage.noteFormBlock).toBeVisible(); + } + } + Review Comment: It seems almost like testing `if A is true, then A is true` ########## zeppelin-web-angular/e2e/models/notebook-paragraph-page.util.ts: ########## @@ -0,0 +1,218 @@ +/* + * 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 { NotebookParagraphPage } from './notebook-paragraph-page'; + +export class NotebookParagraphUtil { + private page: Page; + private paragraphPage: NotebookParagraphPage; + + constructor(page: Page) { + this.page = page; + this.paragraphPage = new NotebookParagraphPage(page); + } + + async verifyParagraphContainerStructure(): Promise<void> { + await expect(this.paragraphPage.paragraphContainer).toBeVisible(); + await expect(this.paragraphPage.controlPanel).toBeVisible(); + } + + async verifyDoubleClickEditingFunctionality(): Promise<void> { + await expect(this.paragraphPage.paragraphContainer).toBeVisible(); + + await this.paragraphPage.doubleClickToEdit(); + + await expect(this.paragraphPage.codeEditor).toBeVisible(); + } + + async verifyAddParagraphButtons(): Promise<void> { + await expect(this.paragraphPage.addParagraphAbove).toBeVisible(); + await expect(this.paragraphPage.addParagraphBelow).toBeVisible(); + + const addAboveCount = await this.paragraphPage.addParagraphAbove.count(); + const addBelowCount = await this.paragraphPage.addParagraphBelow.count(); + + expect(addAboveCount).toBeGreaterThan(0); + expect(addBelowCount).toBeGreaterThan(0); + } + + async verifyParagraphControlInterface(): Promise<void> { + await expect(this.paragraphPage.controlPanel).toBeVisible(); + + // Check if run button exists and is visible + try { + const runButtonVisible = await this.paragraphPage.runButton.isVisible(); + if (runButtonVisible) { + await expect(this.paragraphPage.runButton).toBeVisible(); + const isRunEnabled = await this.paragraphPage.isRunButtonEnabled(); + expect(isRunEnabled).toBe(true); + } else { + console.log('Run button not found - paragraph may not support execution'); + } + } catch (error) { + console.log('Run button not accessible - paragraph may not support execution'); + } + } + + async verifyCodeEditorFunctionality(): Promise<void> { + const isCodeEditorVisible = await this.paragraphPage.isCodeEditorVisible(); + if (isCodeEditorVisible) { + await expect(this.paragraphPage.codeEditor).toBeVisible(); + } + } + + async verifyResultDisplaySystem(): Promise<void> { + const hasResult = await this.paragraphPage.hasResult(); + if (hasResult) { + await expect(this.paragraphPage.resultDisplay).toBeVisible(); + } + } Review Comment: It seems like always-passing tests. ########## zeppelin-web-angular/e2e/models/notebook-paragraph-page.util.ts: ########## @@ -0,0 +1,218 @@ +/* + * 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 { NotebookParagraphPage } from './notebook-paragraph-page'; + +export class NotebookParagraphUtil { + private page: Page; + private paragraphPage: NotebookParagraphPage; + + constructor(page: Page) { + this.page = page; + this.paragraphPage = new NotebookParagraphPage(page); + } + + async verifyParagraphContainerStructure(): Promise<void> { + await expect(this.paragraphPage.paragraphContainer).toBeVisible(); + await expect(this.paragraphPage.controlPanel).toBeVisible(); + } + + async verifyDoubleClickEditingFunctionality(): Promise<void> { + await expect(this.paragraphPage.paragraphContainer).toBeVisible(); + + await this.paragraphPage.doubleClickToEdit(); + + await expect(this.paragraphPage.codeEditor).toBeVisible(); + } + + async verifyAddParagraphButtons(): Promise<void> { + await expect(this.paragraphPage.addParagraphAbove).toBeVisible(); + await expect(this.paragraphPage.addParagraphBelow).toBeVisible(); + + const addAboveCount = await this.paragraphPage.addParagraphAbove.count(); + const addBelowCount = await this.paragraphPage.addParagraphBelow.count(); + + expect(addAboveCount).toBeGreaterThan(0); + expect(addBelowCount).toBeGreaterThan(0); + } + + async verifyParagraphControlInterface(): Promise<void> { + await expect(this.paragraphPage.controlPanel).toBeVisible(); + + // Check if run button exists and is visible + try { + const runButtonVisible = await this.paragraphPage.runButton.isVisible(); + if (runButtonVisible) { + await expect(this.paragraphPage.runButton).toBeVisible(); + const isRunEnabled = await this.paragraphPage.isRunButtonEnabled(); + expect(isRunEnabled).toBe(true); + } else { + console.log('Run button not found - paragraph may not support execution'); + } + } catch (error) { + console.log('Run button not accessible - paragraph may not support execution'); + } + } + + async verifyCodeEditorFunctionality(): Promise<void> { + const isCodeEditorVisible = await this.paragraphPage.isCodeEditorVisible(); + if (isCodeEditorVisible) { + await expect(this.paragraphPage.codeEditor).toBeVisible(); + } + } + + async verifyResultDisplaySystem(): Promise<void> { + const hasResult = await this.paragraphPage.hasResult(); + if (hasResult) { + await expect(this.paragraphPage.resultDisplay).toBeVisible(); + } + } + + async verifyTitleEditingIfPresent(): Promise<void> { + const titleVisible = await this.paragraphPage.titleEditor.isVisible(); + if (titleVisible) { + // Check if it's actually editable - some custom components may not be detected as editable + try { + await expect(this.paragraphPage.titleEditor).toBeEditable(); + } catch (error) { + // If it's not detected as editable by default, check if it has contenteditable or can receive focus + const isContentEditable = await this.paragraphPage.titleEditor.getAttribute('contenteditable'); + const hasInputChild = (await this.paragraphPage.titleEditor.locator('input, textarea').count()) > 0; + + if (isContentEditable === 'true' || hasInputChild) { + console.log('Title editor is a custom editable component'); + } else { + console.log('Title editor may not be editable in current state'); + } Review Comment: I think this catch caluse may lead to false postive. ########## zeppelin-web-angular/e2e/models/notebook-paragraph-page.util.ts: ########## @@ -0,0 +1,218 @@ +/* + * 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 { NotebookParagraphPage } from './notebook-paragraph-page'; + +export class NotebookParagraphUtil { + private page: Page; + private paragraphPage: NotebookParagraphPage; + + constructor(page: Page) { + this.page = page; + this.paragraphPage = new NotebookParagraphPage(page); + } + + async verifyParagraphContainerStructure(): Promise<void> { + await expect(this.paragraphPage.paragraphContainer).toBeVisible(); + await expect(this.paragraphPage.controlPanel).toBeVisible(); + } + + async verifyDoubleClickEditingFunctionality(): Promise<void> { + await expect(this.paragraphPage.paragraphContainer).toBeVisible(); + + await this.paragraphPage.doubleClickToEdit(); + + await expect(this.paragraphPage.codeEditor).toBeVisible(); + } + + async verifyAddParagraphButtons(): Promise<void> { + await expect(this.paragraphPage.addParagraphAbove).toBeVisible(); + await expect(this.paragraphPage.addParagraphBelow).toBeVisible(); + + const addAboveCount = await this.paragraphPage.addParagraphAbove.count(); + const addBelowCount = await this.paragraphPage.addParagraphBelow.count(); + + expect(addAboveCount).toBeGreaterThan(0); + expect(addBelowCount).toBeGreaterThan(0); + } + + async verifyParagraphControlInterface(): Promise<void> { + await expect(this.paragraphPage.controlPanel).toBeVisible(); + + // Check if run button exists and is visible + try { + const runButtonVisible = await this.paragraphPage.runButton.isVisible(); + if (runButtonVisible) { + await expect(this.paragraphPage.runButton).toBeVisible(); + const isRunEnabled = await this.paragraphPage.isRunButtonEnabled(); + expect(isRunEnabled).toBe(true); + } else { + console.log('Run button not found - paragraph may not support execution'); + } + } catch (error) { + console.log('Run button not accessible - paragraph may not support execution'); + } + } + + async verifyCodeEditorFunctionality(): Promise<void> { + const isCodeEditorVisible = await this.paragraphPage.isCodeEditorVisible(); + if (isCodeEditorVisible) { + await expect(this.paragraphPage.codeEditor).toBeVisible(); + } + } + + async verifyResultDisplaySystem(): Promise<void> { + const hasResult = await this.paragraphPage.hasResult(); + if (hasResult) { + await expect(this.paragraphPage.resultDisplay).toBeVisible(); + } + } + + async verifyTitleEditingIfPresent(): Promise<void> { + const titleVisible = await this.paragraphPage.titleEditor.isVisible(); + if (titleVisible) { + // Check if it's actually editable - some custom components may not be detected as editable + try { + await expect(this.paragraphPage.titleEditor).toBeEditable(); + } catch (error) { + // If it's not detected as editable by default, check if it has contenteditable or can receive focus + const isContentEditable = await this.paragraphPage.titleEditor.getAttribute('contenteditable'); + const hasInputChild = (await this.paragraphPage.titleEditor.locator('input, textarea').count()) > 0; + + if (isContentEditable === 'true' || hasInputChild) { + console.log('Title editor is a custom editable component'); + } else { + console.log('Title editor may not be editable in current state'); + } + } + } + } + + async verifyProgressIndicatorDuringExecution(): Promise<void> { + if (await this.paragraphPage.runButton.isVisible()) { + await this.paragraphPage.runParagraph(); + + const isRunning = await this.paragraphPage.isRunning(); + if (isRunning) { + await expect(this.paragraphPage.progressIndicator).toBeVisible(); + + await this.page.waitForFunction( + () => { + const progressElement = document.querySelector('zeppelin-notebook-paragraph-progress'); + return !progressElement || !progressElement.isConnected; + }, + { timeout: 30000 } + ); + } + } + } + + async verifyDynamicFormsIfPresent(): Promise<void> { + const isDynamicFormsVisible = await this.paragraphPage.isDynamicFormsVisible(); + if (isDynamicFormsVisible) { + await expect(this.paragraphPage.dynamicForms).toBeVisible(); + } + } + + async verifyFooterInformation(): Promise<void> { + const footerText = await this.paragraphPage.getFooterText(); + expect(footerText).toBeDefined(); + } + + async verifyParagraphControlActions(): Promise<void> { + await this.paragraphPage.openSettingsDropdown(); + + // Wait for dropdown to appear by checking for any menu item + const dropdownMenu = this.page.locator('ul.ant-dropdown-menu, .dropdown-menu'); + await expect(dropdownMenu).toBeVisible({ timeout: 5000 }); + + // Check if dropdown menu items are present (they might use different selectors) + const moveUpVisible = await this.page.locator('li:has-text("Move up")').isVisible(); + const deleteVisible = await this.page.locator('li:has-text("Delete")').isVisible(); + const cloneVisible = await this.page.locator('li:has-text("Clone")').isVisible(); + + if (moveUpVisible) { + await expect(this.page.locator('li:has-text("Move up")')).toBeVisible(); + } + if (deleteVisible) { + await expect(this.page.locator('li:has-text("Delete")')).toBeVisible(); + } + if (cloneVisible) { + await expect(this.page.locator('li:has-text("Clone")')).toBeVisible(); + } Review Comment: The same always-passing tests. ########## zeppelin-web-angular/e2e/models/notebook-paragraph-page.util.ts: ########## @@ -0,0 +1,218 @@ +/* + * 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 { NotebookParagraphPage } from './notebook-paragraph-page'; + +export class NotebookParagraphUtil { + private page: Page; + private paragraphPage: NotebookParagraphPage; + + constructor(page: Page) { + this.page = page; + this.paragraphPage = new NotebookParagraphPage(page); + } + + async verifyParagraphContainerStructure(): Promise<void> { + await expect(this.paragraphPage.paragraphContainer).toBeVisible(); + await expect(this.paragraphPage.controlPanel).toBeVisible(); + } + + async verifyDoubleClickEditingFunctionality(): Promise<void> { + await expect(this.paragraphPage.paragraphContainer).toBeVisible(); + + await this.paragraphPage.doubleClickToEdit(); + + await expect(this.paragraphPage.codeEditor).toBeVisible(); + } + + async verifyAddParagraphButtons(): Promise<void> { + await expect(this.paragraphPage.addParagraphAbove).toBeVisible(); + await expect(this.paragraphPage.addParagraphBelow).toBeVisible(); + + const addAboveCount = await this.paragraphPage.addParagraphAbove.count(); + const addBelowCount = await this.paragraphPage.addParagraphBelow.count(); + + expect(addAboveCount).toBeGreaterThan(0); + expect(addBelowCount).toBeGreaterThan(0); + } + + async verifyParagraphControlInterface(): Promise<void> { + await expect(this.paragraphPage.controlPanel).toBeVisible(); + + // Check if run button exists and is visible + try { + const runButtonVisible = await this.paragraphPage.runButton.isVisible(); + if (runButtonVisible) { + await expect(this.paragraphPage.runButton).toBeVisible(); + const isRunEnabled = await this.paragraphPage.isRunButtonEnabled(); + expect(isRunEnabled).toBe(true); + } else { + console.log('Run button not found - paragraph may not support execution'); + } + } catch (error) { + console.log('Run button not accessible - paragraph may not support execution'); + } + } + + async verifyCodeEditorFunctionality(): Promise<void> { + const isCodeEditorVisible = await this.paragraphPage.isCodeEditorVisible(); + if (isCodeEditorVisible) { + await expect(this.paragraphPage.codeEditor).toBeVisible(); + } + } + + async verifyResultDisplaySystem(): Promise<void> { + const hasResult = await this.paragraphPage.hasResult(); + if (hasResult) { + await expect(this.paragraphPage.resultDisplay).toBeVisible(); + } + } + + async verifyTitleEditingIfPresent(): Promise<void> { + const titleVisible = await this.paragraphPage.titleEditor.isVisible(); + if (titleVisible) { + // Check if it's actually editable - some custom components may not be detected as editable + try { + await expect(this.paragraphPage.titleEditor).toBeEditable(); + } catch (error) { + // If it's not detected as editable by default, check if it has contenteditable or can receive focus + const isContentEditable = await this.paragraphPage.titleEditor.getAttribute('contenteditable'); + const hasInputChild = (await this.paragraphPage.titleEditor.locator('input, textarea').count()) > 0; + + if (isContentEditable === 'true' || hasInputChild) { + console.log('Title editor is a custom editable component'); + } else { + console.log('Title editor may not be editable in current state'); + } + } + } + } + + async verifyProgressIndicatorDuringExecution(): Promise<void> { + if (await this.paragraphPage.runButton.isVisible()) { + await this.paragraphPage.runParagraph(); + + const isRunning = await this.paragraphPage.isRunning(); + if (isRunning) { + await expect(this.paragraphPage.progressIndicator).toBeVisible(); + + await this.page.waitForFunction( + () => { + const progressElement = document.querySelector('zeppelin-notebook-paragraph-progress'); + return !progressElement || !progressElement.isConnected; + }, + { timeout: 30000 } + ); + } + } + } + + async verifyDynamicFormsIfPresent(): Promise<void> { + const isDynamicFormsVisible = await this.paragraphPage.isDynamicFormsVisible(); + if (isDynamicFormsVisible) { + await expect(this.paragraphPage.dynamicForms).toBeVisible(); + } + } + + async verifyFooterInformation(): Promise<void> { + const footerText = await this.paragraphPage.getFooterText(); + expect(footerText).toBeDefined(); + } + + async verifyParagraphControlActions(): Promise<void> { + await this.paragraphPage.openSettingsDropdown(); + + // Wait for dropdown to appear by checking for any menu item + const dropdownMenu = this.page.locator('ul.ant-dropdown-menu, .dropdown-menu'); + await expect(dropdownMenu).toBeVisible({ timeout: 5000 }); + + // Check if dropdown menu items are present (they might use different selectors) + const moveUpVisible = await this.page.locator('li:has-text("Move up")').isVisible(); + const deleteVisible = await this.page.locator('li:has-text("Delete")').isVisible(); + const cloneVisible = await this.page.locator('li:has-text("Clone")').isVisible(); + + if (moveUpVisible) { + await expect(this.page.locator('li:has-text("Move up")')).toBeVisible(); + } + if (deleteVisible) { + await expect(this.page.locator('li:has-text("Delete")')).toBeVisible(); + } + if (cloneVisible) { + await expect(this.page.locator('li:has-text("Clone")')).toBeVisible(); + } + + // Close dropdown if it's open + await this.page.keyboard.press('Escape'); + } + + async verifyParagraphExecutionWorkflow(): Promise<void> { + // Check if run button exists and is accessible + try { + const runButtonVisible = await this.paragraphPage.runButton.isVisible(); + if (runButtonVisible) { + await expect(this.paragraphPage.runButton).toBeVisible(); + await expect(this.paragraphPage.runButton).toBeEnabled(); + + await this.paragraphPage.runParagraph(); + + const isStopVisible = await this.paragraphPage.isStopButtonVisible(); + if (isStopVisible) { + await expect(this.paragraphPage.stopButton).toBeVisible(); + } + } else { + console.log('Run button not found - paragraph execution not available'); + } + } catch (error) { + console.log('Run button not accessible - paragraph execution not supported'); + } + } + + async verifyAdvancedParagraphOperations(): Promise<void> { + await this.paragraphPage.openSettingsDropdown(); + + // Wait for dropdown to appear by checking for any menu item + const dropdownMenu = this.page.locator('ul.ant-dropdown-menu, .dropdown-menu'); + await expect(dropdownMenu).toBeVisible({ timeout: 5000 }); + + const clearOutputItem = this.page.locator('li:has-text("Clear output")'); + const toggleEditorItem = this.page.locator('li:has-text("Toggle editor")'); + const insertBelowItem = this.page.locator('li:has-text("Insert below")'); + + if (await clearOutputItem.isVisible()) { + await expect(clearOutputItem).toBeVisible(); + } + + if (await toggleEditorItem.isVisible()) { + await expect(toggleEditorItem).toBeVisible(); + } + + if (await insertBelowItem.isVisible()) { + await expect(insertBelowItem).toBeVisible(); + } Review Comment: These also seems like always-passing tests. ########## zeppelin-web-angular/e2e/models/notebook-sidebar-page.ts: ########## @@ -0,0 +1,423 @@ +/* + * 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 NotebookSidebarPage extends BasePage { + readonly sidebarContainer: Locator; + readonly tocButton: Locator; + readonly fileTreeButton: Locator; + readonly closeButton: Locator; + readonly nodeList: Locator; + readonly noteToc: Locator; + readonly sidebarContent: Locator; + + constructor(page: Page) { + super(page); + this.sidebarContainer = page.locator('zeppelin-notebook-sidebar'); + // Try multiple possible selectors for TOC button with more specific targeting + this.tocButton = page + .locator( + 'zeppelin-notebook-sidebar button[nzTooltipTitle*="Table"], zeppelin-notebook-sidebar button[title*="Table"], zeppelin-notebook-sidebar i[nz-icon][nzType="unordered-list"], zeppelin-notebook-sidebar button:has(i[nzType="unordered-list"]), zeppelin-notebook-sidebar .sidebar-button:has(i[nzType="unordered-list"])' + ) + .first(); + // Try multiple possible selectors for File Tree button with more specific targeting + this.fileTreeButton = page + .locator( + 'zeppelin-notebook-sidebar button[nzTooltipTitle*="File"], zeppelin-notebook-sidebar button[title*="File"], zeppelin-notebook-sidebar i[nz-icon][nzType="folder"], zeppelin-notebook-sidebar button:has(i[nzType="folder"]), zeppelin-notebook-sidebar .sidebar-button:has(i[nzType="folder"])' + ) + .first(); + // Try multiple selectors for close button with more specific targeting + this.closeButton = page + .locator( + 'zeppelin-notebook-sidebar button.sidebar-close, zeppelin-notebook-sidebar button[nzTooltipTitle*="Close"], zeppelin-notebook-sidebar i[nz-icon][nzType="close"], zeppelin-notebook-sidebar button:has(i[nzType="close"]), zeppelin-notebook-sidebar .close-button, zeppelin-notebook-sidebar [aria-label*="close" i]' + ) + .first(); + this.nodeList = page.locator('zeppelin-node-list'); + this.noteToc = page.locator('zeppelin-note-toc'); + this.sidebarContent = page.locator('.sidebar-content'); + } + + async openToc(): Promise<void> { + // Ensure sidebar is visible first + await expect(this.sidebarContainer).toBeVisible(); + + // Get initial state to check for changes + const initialState = await this.getSidebarState(); + + // Try multiple strategies to find and click the TOC button + const strategies = [ + // Strategy 1: Original button selector + () => this.tocButton.click(), + // Strategy 2: Look for unordered-list icon specifically in sidebar + () => this.page.locator('zeppelin-notebook-sidebar i[nzType="unordered-list"]').first().click(), + // Strategy 3: Look for any button with list-related icons + () => this.page.locator('zeppelin-notebook-sidebar button:has(i[nzType="unordered-list"])').first().click(), + // Strategy 4: Try aria-label or title containing "table" or "content" + () => + this.page + .locator( + 'zeppelin-notebook-sidebar button[aria-label*="Table"], zeppelin-notebook-sidebar button[aria-label*="Contents"]' + ) + .first() + .click(), + // Strategy 5: Look for any clickable element with specific classes + () => + this.page + .locator('zeppelin-notebook-sidebar .sidebar-nav button, zeppelin-notebook-sidebar [role="button"]') + .first() + .click() + ]; + + let success = false; + for (const strategy of strategies) { + try { + await strategy(); + + // Wait for state change after click - check for visible content instead of state + await Promise.race([ + // Option 1: Wait for TOC content to appear + this.page + .locator('zeppelin-note-toc, .sidebar-content .toc') + .waitFor({ state: 'visible', timeout: 3000 }) + .catch(() => {}), + // Option 2: Wait for file tree content to appear + this.page + .locator('zeppelin-node-list, .sidebar-content .file-tree') + .waitFor({ state: 'visible', timeout: 3000 }) + .catch(() => {}), + // Option 3: Wait for any sidebar content change + this.page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => {}) + ]).catch(() => { + // If all fail, continue - this is acceptable + }); + + success = true; + break; + } catch (error) { + console.log(`TOC button strategy failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + if (!success) { + console.log('All TOC button strategies failed - sidebar may not have TOC functionality'); + } + + // Wait for TOC content to be visible if it was successfully opened + const tocContent = this.page.locator('zeppelin-note-toc, .sidebar-content .toc, .outline-content'); + try { + await expect(tocContent).toBeVisible({ timeout: 3000 }); + } catch { + // TOC might not be available or visible, check if file tree opened instead + const fileTreeContent = this.page.locator('zeppelin-node-list, .sidebar-content .file-tree'); + try { + await expect(fileTreeContent).toBeVisible({ timeout: 2000 }); + } catch { + // Neither TOC nor file tree visible + } + } + } + + async openFileTree(): Promise<void> { + // Ensure sidebar is visible first + await expect(this.sidebarContainer).toBeVisible(); + + // Try multiple ways to find and click the File Tree button + try { + await this.fileTreeButton.click(); + } catch (error) { + // Fallback: try clicking any folder icon in the sidebar + const fallbackFileTreeButton = this.page.locator('zeppelin-notebook-sidebar i[nzType="folder"]').first(); + await fallbackFileTreeButton.click(); + } + + // Wait for file tree content to appear after click + await Promise.race([ + // Wait for file tree content to appear + this.page.locator('zeppelin-node-list, .sidebar-content .file-tree').waitFor({ state: 'visible', timeout: 3000 }), + // Wait for network to stabilize + this.page.waitForLoadState('networkidle', { timeout: 3000 }) + ]).catch(() => { + // If both fail, continue - this is acceptable + }); + + // Wait for file tree content to be visible + const fileTreeContent = this.page.locator('zeppelin-node-list, .sidebar-content .file-tree, .file-browser'); + try { + await expect(fileTreeContent).toBeVisible({ timeout: 3000 }); + } catch { + // File tree might not be available or visible + } Review Comment: The same suggestion for here. ########## zeppelin-web-angular/e2e/models/notebook-sidebar-page.ts: ########## @@ -0,0 +1,423 @@ +/* + * 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 NotebookSidebarPage extends BasePage { + readonly sidebarContainer: Locator; + readonly tocButton: Locator; + readonly fileTreeButton: Locator; + readonly closeButton: Locator; + readonly nodeList: Locator; + readonly noteToc: Locator; + readonly sidebarContent: Locator; + + constructor(page: Page) { + super(page); + this.sidebarContainer = page.locator('zeppelin-notebook-sidebar'); + // Try multiple possible selectors for TOC button with more specific targeting + this.tocButton = page + .locator( + 'zeppelin-notebook-sidebar button[nzTooltipTitle*="Table"], zeppelin-notebook-sidebar button[title*="Table"], zeppelin-notebook-sidebar i[nz-icon][nzType="unordered-list"], zeppelin-notebook-sidebar button:has(i[nzType="unordered-list"]), zeppelin-notebook-sidebar .sidebar-button:has(i[nzType="unordered-list"])' + ) + .first(); + // Try multiple possible selectors for File Tree button with more specific targeting + this.fileTreeButton = page + .locator( + 'zeppelin-notebook-sidebar button[nzTooltipTitle*="File"], zeppelin-notebook-sidebar button[title*="File"], zeppelin-notebook-sidebar i[nz-icon][nzType="folder"], zeppelin-notebook-sidebar button:has(i[nzType="folder"]), zeppelin-notebook-sidebar .sidebar-button:has(i[nzType="folder"])' + ) + .first(); + // Try multiple selectors for close button with more specific targeting + this.closeButton = page + .locator( + 'zeppelin-notebook-sidebar button.sidebar-close, zeppelin-notebook-sidebar button[nzTooltipTitle*="Close"], zeppelin-notebook-sidebar i[nz-icon][nzType="close"], zeppelin-notebook-sidebar button:has(i[nzType="close"]), zeppelin-notebook-sidebar .close-button, zeppelin-notebook-sidebar [aria-label*="close" i]' + ) + .first(); + this.nodeList = page.locator('zeppelin-node-list'); + this.noteToc = page.locator('zeppelin-note-toc'); + this.sidebarContent = page.locator('.sidebar-content'); + } + + async openToc(): Promise<void> { + // Ensure sidebar is visible first + await expect(this.sidebarContainer).toBeVisible(); + + // Get initial state to check for changes + const initialState = await this.getSidebarState(); + + // Try multiple strategies to find and click the TOC button + const strategies = [ + // Strategy 1: Original button selector + () => this.tocButton.click(), + // Strategy 2: Look for unordered-list icon specifically in sidebar + () => this.page.locator('zeppelin-notebook-sidebar i[nzType="unordered-list"]').first().click(), + // Strategy 3: Look for any button with list-related icons + () => this.page.locator('zeppelin-notebook-sidebar button:has(i[nzType="unordered-list"])').first().click(), + // Strategy 4: Try aria-label or title containing "table" or "content" + () => + this.page + .locator( + 'zeppelin-notebook-sidebar button[aria-label*="Table"], zeppelin-notebook-sidebar button[aria-label*="Contents"]' + ) + .first() + .click(), + // Strategy 5: Look for any clickable element with specific classes + () => + this.page + .locator('zeppelin-notebook-sidebar .sidebar-nav button, zeppelin-notebook-sidebar [role="button"]') + .first() + .click() + ]; + + let success = false; + for (const strategy of strategies) { + try { + await strategy(); + + // Wait for state change after click - check for visible content instead of state + await Promise.race([ + // Option 1: Wait for TOC content to appear + this.page + .locator('zeppelin-note-toc, .sidebar-content .toc') + .waitFor({ state: 'visible', timeout: 3000 }) + .catch(() => {}), + // Option 2: Wait for file tree content to appear + this.page + .locator('zeppelin-node-list, .sidebar-content .file-tree') + .waitFor({ state: 'visible', timeout: 3000 }) + .catch(() => {}), + // Option 3: Wait for any sidebar content change + this.page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => {}) + ]).catch(() => { + // If all fail, continue - this is acceptable + }); + + success = true; + break; + } catch (error) { + console.log(`TOC button strategy failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + if (!success) { + console.log('All TOC button strategies failed - sidebar may not have TOC functionality'); + } + + // Wait for TOC content to be visible if it was successfully opened + const tocContent = this.page.locator('zeppelin-note-toc, .sidebar-content .toc, .outline-content'); + try { + await expect(tocContent).toBeVisible({ timeout: 3000 }); + } catch { + // TOC might not be available or visible, check if file tree opened instead + const fileTreeContent = this.page.locator('zeppelin-node-list, .sidebar-content .file-tree'); + try { + await expect(fileTreeContent).toBeVisible({ timeout: 2000 }); + } catch { + // Neither TOC nor file tree visible + } + } + } + + async openFileTree(): Promise<void> { + // Ensure sidebar is visible first + await expect(this.sidebarContainer).toBeVisible(); + + // Try multiple ways to find and click the File Tree button + try { + await this.fileTreeButton.click(); + } catch (error) { + // Fallback: try clicking any folder icon in the sidebar + const fallbackFileTreeButton = this.page.locator('zeppelin-notebook-sidebar i[nzType="folder"]').first(); + await fallbackFileTreeButton.click(); + } + + // Wait for file tree content to appear after click + await Promise.race([ + // Wait for file tree content to appear + this.page.locator('zeppelin-node-list, .sidebar-content .file-tree').waitFor({ state: 'visible', timeout: 3000 }), + // Wait for network to stabilize + this.page.waitForLoadState('networkidle', { timeout: 3000 }) + ]).catch(() => { + // If both fail, continue - this is acceptable + }); + + // Wait for file tree content to be visible + const fileTreeContent = this.page.locator('zeppelin-node-list, .sidebar-content .file-tree, .file-browser'); + try { + await expect(fileTreeContent).toBeVisible({ timeout: 3000 }); + } catch { + // File tree might not be available or visible + } + } + + async closeSidebar(): Promise<void> { + // Ensure sidebar is visible first + await expect(this.sidebarContainer).toBeVisible(); + + // Try multiple strategies to find and click the close button + const strategies = [ + // Strategy 1: Original close button selector + () => this.closeButton.click(), + // Strategy 2: Look for close icon specifically in sidebar + () => this.page.locator('zeppelin-notebook-sidebar i[nzType="close"]').first().click(), + // Strategy 3: Look for any button with close-related icons + () => this.page.locator('zeppelin-notebook-sidebar button:has(i[nzType="close"])').first().click(), + // Strategy 4: Try any close-related elements + () => + this.page.locator('zeppelin-notebook-sidebar .close, zeppelin-notebook-sidebar .sidebar-close').first().click(), + // Strategy 5: Try keyboard shortcut (Escape key) + () => this.page.keyboard.press('Escape'), + // Strategy 6: Click on the sidebar toggle button again (might close it) + () => this.page.locator('zeppelin-notebook-sidebar button').first().click() + ]; + + let success = false; + for (const strategy of strategies) { + try { + await strategy(); + + // Wait for sidebar to close or become hidden + await Promise.race([ + // Wait for sidebar to be hidden + this.sidebarContainer.waitFor({ state: 'hidden', timeout: 3000 }), + // Wait for sidebar content to disappear + this.page + .locator('zeppelin-notebook-sidebar zeppelin-note-toc, zeppelin-notebook-sidebar zeppelin-node-list') + .waitFor({ state: 'hidden', timeout: 3000 }), + // Wait for network to stabilize + this.page.waitForLoadState('networkidle', { timeout: 3000 }) + ]).catch(() => { + // If all fail, continue - close functionality may not be available + }); + + success = true; + break; + } catch (error) { + console.log(`Close button strategy failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + if (!success) { + console.log('All close button strategies failed - sidebar may not have close functionality'); + } + + // Final check - wait for sidebar to be hidden if it was successfully closed + try { + await expect(this.sidebarContainer).toBeHidden({ timeout: 3000 }); + } catch { + // Sidebar might still be visible or close functionality not available + // This is acceptable as some applications don't support closing sidebar + } Review Comment: Same as the above. ########## zeppelin-web-angular/e2e/tests/notebook/sidebar/sidebar-functionality.spec.ts: ########## @@ -0,0 +1,247 @@ +/* + * 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 { NotebookSidebarUtil } from '../../../models/notebook-sidebar-page.util'; +import { addPageAnnotationBeforeEach, performLoginIfRequired, waitForZeppelinReady, PAGES } from '../../../utils'; + +test.describe('Notebook Sidebar Functionality', () => { + addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_SIDEBAR); + + test.beforeEach(async ({ page }) => { + await page.goto('/', { + waitUntil: 'load', + timeout: 60000 + }); + await waitForZeppelinReady(page); + await performLoginIfRequired(page); + }); + + test('should display navigation buttons', async ({ page }) => { + // Given: User is on the home page + await page.goto('/'); + await waitForZeppelinReady(page); + + // Create a test notebook since none may exist in CI + const sidebarUtil = new NotebookSidebarUtil(page); + const testNotebook = await sidebarUtil.createTestNotebook(); + + try { + // When: User opens the test notebook + await page.goto(`/#/notebook/${testNotebook.noteId}`); + await page.waitForLoadState('networkidle'); + + // Then: Navigation buttons should be visible + await sidebarUtil.verifyNavigationButtons(); + } finally { + // Clean up + await sidebarUtil.deleteTestNotebook(testNotebook.noteId); + } + }); + + test('should manage three sidebar states correctly', async ({ page }) => { + // Given: User is on the home page + await page.goto('/'); + await waitForZeppelinReady(page); + + // Create a test notebook since none may exist in CI + const sidebarUtil = new NotebookSidebarUtil(page); + const testNotebook = await sidebarUtil.createTestNotebook(); + + try { + // When: User opens the test notebook and interacts with sidebar state management + await page.goto(`/#/notebook/${testNotebook.noteId}`); + await page.waitForLoadState('networkidle'); + + // Then: State management should work properly + await sidebarUtil.verifyStateManagement(); + } finally { + // Clean up + await sidebarUtil.deleteTestNotebook(testNotebook.noteId); + } + }); + + test('should toggle between states correctly', async ({ page }) => { + // Given: User is on the home page + await page.goto('/'); + await waitForZeppelinReady(page); + + // Create a test notebook since none may exist in CI + const sidebarUtil = new NotebookSidebarUtil(page); + let testNotebook; + + try { + testNotebook = await sidebarUtil.createTestNotebook(); + + // When: User opens the test notebook and toggles between different sidebar states + await page.goto(`/#/notebook/${testNotebook.noteId}`); + await page.waitForLoadState('networkidle', { timeout: 10000 }); + + // Then: Toggle behavior should work correctly + await sidebarUtil.verifyToggleBehavior(); + } catch (error) { + console.warn('Sidebar toggle test failed:', error instanceof Error ? error.message : String(error)); + // Test may fail due to browser stability issues in CI + } finally { + // Clean up + if (testNotebook) { + await sidebarUtil.deleteTestNotebook(testNotebook.noteId); + } + } Review Comment: Same as the above. ########## zeppelin-web-angular/e2e/tests/notebook/sidebar/sidebar-functionality.spec.ts: ########## @@ -0,0 +1,247 @@ +/* + * 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 { NotebookSidebarUtil } from '../../../models/notebook-sidebar-page.util'; +import { addPageAnnotationBeforeEach, performLoginIfRequired, waitForZeppelinReady, PAGES } from '../../../utils'; + +test.describe('Notebook Sidebar Functionality', () => { + addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_SIDEBAR); + + test.beforeEach(async ({ page }) => { + await page.goto('/', { + waitUntil: 'load', + timeout: 60000 + }); + await waitForZeppelinReady(page); + await performLoginIfRequired(page); + }); + + test('should display navigation buttons', async ({ page }) => { + // Given: User is on the home page + await page.goto('/'); + await waitForZeppelinReady(page); + + // Create a test notebook since none may exist in CI + const sidebarUtil = new NotebookSidebarUtil(page); + const testNotebook = await sidebarUtil.createTestNotebook(); + + try { + // When: User opens the test notebook + await page.goto(`/#/notebook/${testNotebook.noteId}`); + await page.waitForLoadState('networkidle'); + + // Then: Navigation buttons should be visible + await sidebarUtil.verifyNavigationButtons(); + } finally { + // Clean up + await sidebarUtil.deleteTestNotebook(testNotebook.noteId); + } + }); + + test('should manage three sidebar states correctly', async ({ page }) => { + // Given: User is on the home page + await page.goto('/'); + await waitForZeppelinReady(page); + + // Create a test notebook since none may exist in CI + const sidebarUtil = new NotebookSidebarUtil(page); + const testNotebook = await sidebarUtil.createTestNotebook(); + + try { + // When: User opens the test notebook and interacts with sidebar state management + await page.goto(`/#/notebook/${testNotebook.noteId}`); + await page.waitForLoadState('networkidle'); + + // Then: State management should work properly + await sidebarUtil.verifyStateManagement(); + } finally { + // Clean up + await sidebarUtil.deleteTestNotebook(testNotebook.noteId); + } Review Comment: Possible false positive. ########## zeppelin-web-angular/e2e/tests/notebook/published/published-paragraph.spec.ts: ########## @@ -87,55 +94,139 @@ test.describe('Published Paragraph', () => { }); }); - test.describe('Valid Paragraph Display', () => { - test('should enter published paragraph by clicking', async () => { + test.describe('Navigation and URL Patterns', () => { + test('should enter published paragraph by clicking link', async () => { await testUtil.verifyClickLinkThisParagraphBehavior(testNotebook.noteId, testNotebook.paragraphId); }); - test('should enter published paragraph by URL', async ({ page }) => { + test('should enter published paragraph by direct URL navigation', async ({ page }) => { await page.goto(`/#/notebook/${testNotebook.noteId}/paragraph/${testNotebook.paragraphId}`); await page.waitForLoadState('networkidle'); await expect(page).toHaveURL(`/#/notebook/${testNotebook.noteId}/paragraph/${testNotebook.paragraphId}`, { timeout: 10000 }); }); + + test('should maintain paragraph context in published mode', async ({ page }) => { + const { noteId, paragraphId } = testNotebook; + + await page.goto(`/#/notebook/${noteId}/paragraph/${paragraphId}`); + await page.waitForLoadState('networkidle'); + + expect(page.url()).toContain(noteId); + expect(page.url()).toContain(paragraphId); + + const publishedContainer = page.locator('zeppelin-publish-paragraph'); + if (await publishedContainer.isVisible()) { + await expect(publishedContainer).toBeVisible(); + } + }); }); - test('should show confirmation modal and allow running the paragraph', async ({ page }) => { - const { noteId, paragraphId } = testNotebook; + test.describe('Published Mode Functionality', () => { + test('should display result in read-only mode with published flag', async ({ page }) => { + const { noteId, paragraphId } = testNotebook; - await publishedParagraphPage.navigateToNotebook(noteId); + await page.goto(`/#/notebook/${noteId}/paragraph/${paragraphId}`); + await page.waitForLoadState('networkidle'); - const paragraphElement = page.locator('zeppelin-notebook-paragraph').first(); - const paragraphResult = paragraphElement.locator('zeppelin-notebook-paragraph-result'); + // Verify that we're in published mode by checking the URL pattern + expect(page.url()).toContain(`/paragraph/${paragraphId}`); - // Only clear output if result exists - if (await paragraphResult.isVisible()) { - const settingsButton = paragraphElement.locator('a[nz-dropdown]'); - await settingsButton.click(); + const publishedContainer = page.locator('zeppelin-publish-paragraph'); + const isPublishedContainerVisible = await publishedContainer.isVisible(); - const clearOutputButton = page.locator('li.list-item:has-text("Clear output")'); - await clearOutputButton.click(); - await expect(paragraphResult).toBeHidden(); - } + if (isPublishedContainerVisible) { + await expect(publishedContainer).toBeVisible(); + } + + const isResultVisible = await page.locator('zeppelin-notebook-paragraph-result').isVisible(); + if (isResultVisible) { + await expect(page.locator('zeppelin-notebook-paragraph-result')).toBeVisible(); + } Review Comment: Always-passing test. ########## zeppelin-web-angular/e2e/tests/notebook/sidebar/sidebar-functionality.spec.ts: ########## Review Comment: Catching the verifying methods may silence the failure. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
