From 451297b8d1cbace76e2ec00777bf85646615bf4b Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Mon, 22 Dec 2025 05:45:39 +0900 Subject: [PATCH 1/3] Seperate environment config and shared utilities from apache#5101 --- .github/workflows/frontend.yml | 35 +- package-lock.json | 6 + zeppelin-web-angular/.eslintrc.json | 11 + zeppelin-web-angular/e2e/cleanup-util.ts | 90 +++++ zeppelin-web-angular/e2e/global-setup.ts | 26 +- zeppelin-web-angular/e2e/global-teardown.ts | 32 +- zeppelin-web-angular/e2e/models/base-page.ts | 73 ++++- zeppelin-web-angular/e2e/utils.ts | 310 +++++++++++++++--- zeppelin-web-angular/package.json | 3 +- ...ywright.config.ts => playwright.config.js} | 24 +- zeppelin-web-angular/pom.xml | 17 +- 11 files changed, 560 insertions(+), 67 deletions(-) create mode 100644 package-lock.json create mode 100644 zeppelin-web-angular/e2e/cleanup-util.ts rename zeppelin-web-angular/{playwright.config.ts => playwright.config.js} (72%) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 587005fb08f..ca97c18c6ac 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -24,6 +24,7 @@ env: SPARK_LOCAL_IP: 127.0.0.1 ZEPPELIN_LOCAL_IP: 127.0.0.1 INTERPRETERS: '!hbase,!jdbc,!file,!flink,!cassandra,!elasticsearch,!bigquery,!alluxio,!livy,!groovy,!java,!neo4j,!sparql,!mongodb' + ZEPPELIN_E2E_TEST_NOTEBOOK_DIR: '/tmp/zeppelin-e2e-notebooks' permissions: contents: read # to fetch code (actions/checkout) @@ -62,9 +63,13 @@ jobs: run-playwright-e2e-tests: runs-on: ubuntu-24.04 + env: + # Use VFS storage instead of Git to avoid Git-related issues in CI + ZEPPELIN_NOTEBOOK_STORAGE: org.apache.zeppelin.notebook.repo.VFSNotebookRepo strategy: matrix: mode: [anonymous, auth] + python: [ 3.9 ] steps: - name: Checkout uses: actions/checkout@v4 @@ -93,8 +98,17 @@ jobs: key: ${{ runner.os }}-zeppelin-${{ hashFiles('**/pom.xml') }} restore-keys: | ${{ runner.os }}-zeppelin- + - name: Setup conda environment with python ${{ matrix.python }} + uses: conda-incubator/setup-miniconda@v3 + with: + activate-environment: python_only + python-version: ${{ matrix.python }} + auto-activate-base: false + use-mamba: true + channels: conda-forge,defaults + channel-priority: strict - name: Install application - run: ./mvnw clean install -DskipTests -am -pl zeppelin-web-angular ${MAVEN_ARGS} + run: ./mvnw clean install -DskipTests -am -pl python,rlang,zeppelin-jupyter-interpreter,zeppelin-web-angular ${MAVEN_ARGS} - name: Setup Zeppelin Server (Shiro.ini) run: | export ZEPPELIN_CONF_DIR=./conf @@ -102,6 +116,11 @@ jobs: cp conf/shiro.ini.template conf/shiro.ini sed -i 's/user1 = password2, role1, role2/user1 = password2, role1, role2, admin/' conf/shiro.ini fi + - name: Setup Test Notebook Directory + run: | + # NOTE: Must match zeppelin.notebook.dir defined in pom.xml + mkdir -p $ZEPPELIN_E2E_TEST_NOTEBOOK_DIR + echo "Created test notebook directory: $ZEPPELIN_E2E_TEST_NOTEBOOK_DIR" - name: Run headless E2E test with Maven run: xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" ./mvnw verify -pl zeppelin-web-angular -Pweb-e2e ${MAVEN_ARGS} - name: Upload Playwright Report @@ -110,10 +129,20 @@ jobs: with: name: playwright-report-${{ matrix.mode }} path: zeppelin-web-angular/playwright-report/ - retention-days: 30 + retention-days: 3 - name: Print Zeppelin logs if: always() run: if [ -d "logs" ]; then cat logs/*; fi + - name: Cleanup Test Notebook Directory + if: always() + run: | + if [ -d "$ZEPPELIN_E2E_TEST_NOTEBOOK_DIR" ]; then + echo "Cleaning up test notebook directory: $ZEPPELIN_E2E_TEST_NOTEBOOK_DIR" + rm -rf $ZEPPELIN_E2E_TEST_NOTEBOOK_DIR + echo "Test notebook directory cleaned up" + else + echo "No test notebook directory to clean up" + fi test-selenium-with-spark-module-for-spark-3-5: runs-on: ubuntu-24.04 @@ -162,7 +191,7 @@ jobs: - name: Setup conda environment with python 3.9 and R uses: conda-incubator/setup-miniconda@v3 with: - activate-environment: python_3_with_R + activate-environment: python_only environment-file: testing/env_python_3_with_R.yml python-version: 3.9 channels: conda-forge,defaults diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000000..f5af5198cc5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "zeppelin-new", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/zeppelin-web-angular/.eslintrc.json b/zeppelin-web-angular/.eslintrc.json index 683735703cb..50793bb947c 100644 --- a/zeppelin-web-angular/.eslintrc.json +++ b/zeppelin-web-angular/.eslintrc.json @@ -119,6 +119,17 @@ "yoda": "error" } }, + { + "files": ["*.js"], + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "env": { + "node": true, + "es6": true + } + }, { "files": ["*.html"], "extends": ["plugin:@angular-eslint/template/recommended"], diff --git a/zeppelin-web-angular/e2e/cleanup-util.ts b/zeppelin-web-angular/e2e/cleanup-util.ts new file mode 100644 index 00000000000..a00678dedd8 --- /dev/null +++ b/zeppelin-web-angular/e2e/cleanup-util.ts @@ -0,0 +1,90 @@ +/* + * 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 { BASE_URL, E2E_TEST_FOLDER } from './models/base-page'; + +export const cleanupTestNotebooks = async () => { + try { + console.log('Cleaning up test folder via API...'); + + // Get all notebooks and folders + const response = await fetch(`${BASE_URL}/api/notebook`); + const data = await response.json(); + if (!data.body || !Array.isArray(data.body)) { + console.log('No notebooks found or invalid response format'); + return; + } + + // Find the test folders (E2E_TEST_FOLDER, TestFolder_, and TestFolderRenamed_ patterns) + const testFolders = data.body.filter((item: { path: string }) => { + if (!item.path || item.path.includes(`~Trash`)) { + return false; + } + const folderName = item.path.split('/')[1]; + return ( + folderName === E2E_TEST_FOLDER || + folderName?.startsWith('TestFolder_') || + folderName?.startsWith('TestFolderRenamed_') + ); + }); + + if (testFolders.length === 0) { + console.log('No test folder found to clean up'); + return; + } + + await Promise.all( + testFolders.map(async (testFolder: { id: string; path: string }) => { + try { + console.log(`Deleting test folder: ${testFolder.id} (${testFolder.path})`); + + const deleteResponse = await fetch(`${BASE_URL}/api/notebook/${testFolder.id}`, { + method: 'DELETE' + }); + + // Although a 500 status code is generally not considered a successful response, + // this API returns 500 even when the operation actually succeeds. + // I'll investigate this further and create an issue. + if (deleteResponse.status === 200 || deleteResponse.status === 500) { + console.log(`Deleted test folder: ${testFolder.path}`); + } else { + console.warn(`Failed to delete test folder ${testFolder.path}: ${deleteResponse.status}`); + } + } catch (error) { + console.error(`Error deleting test folder ${testFolder.path}:`, error); + } + }) + ); + + console.log('Test folder cleanup completed'); + } catch (error) { + if (error instanceof Error && error.message.includes('ECONNREFUSED')) { + console.error('Failed to connect to local server. Please start the frontend server first:'); + console.error(' npm start'); + console.error(` or make sure ${BASE_URL} is running`); + } else { + console.warn('Failed to cleanup test folder:', error); + } + } +}; + +if (require.main === module) { + cleanupTestNotebooks() + .then(() => { + console.log('Cleanup completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('Cleanup failed:', error); + process.exit(1); + }); +} diff --git a/zeppelin-web-angular/e2e/global-setup.ts b/zeppelin-web-angular/e2e/global-setup.ts index d9acad53e24..a4ef2a6f106 100644 --- a/zeppelin-web-angular/e2e/global-setup.ts +++ b/zeppelin-web-angular/e2e/global-setup.ts @@ -10,14 +10,19 @@ * limitations under the License. */ +import * as fs from 'fs'; import { LoginTestUtil } from './models/login-page.util'; async function globalSetup() { - console.log('๐Ÿ”ง Global Setup: Checking Shiro configuration...'); + console.log('Global Setup: Preparing test environment...'); // Reset cache to ensure fresh check LoginTestUtil.resetCache(); + // Set up test notebook directory if specified + await setupTestNotebookDirectory(); + + // Check Shiro configuration const isShiroEnabled = await LoginTestUtil.isShiroEnabled(); if (isShiroEnabled) { @@ -33,4 +38,23 @@ async function globalSetup() { } } +async function setupTestNotebookDirectory(): Promise { + const testNotebookDir = process.env.ZEPPELIN_E2E_TEST_NOTEBOOK_DIR; + + if (!testNotebookDir) { + console.log('No custom test notebook directory configured'); + return; + } + + console.log(`Setting up test notebook directory: ${testNotebookDir}`); + + // Remove existing directory if it exists, then create fresh + if (fs.existsSync(testNotebookDir)) { + await fs.promises.rmdir(testNotebookDir, { recursive: true }); + } + + fs.mkdirSync(testNotebookDir, { recursive: true }); + fs.chmodSync(testNotebookDir, 0o777); +} + export default globalSetup; diff --git a/zeppelin-web-angular/e2e/global-teardown.ts b/zeppelin-web-angular/e2e/global-teardown.ts index a02aa104186..c25ad66c030 100644 --- a/zeppelin-web-angular/e2e/global-teardown.ts +++ b/zeppelin-web-angular/e2e/global-teardown.ts @@ -10,13 +10,37 @@ * limitations under the License. */ +import { exec } from 'child_process'; +import { promisify } from 'util'; import { LoginTestUtil } from './models/login-page.util'; -async function globalTeardown() { - console.log('๐Ÿงน Global Teardown: Cleaning up test environment...'); +const execAsync = promisify(exec); + +const globalTeardown = async () => { + console.log('Global Teardown: Cleaning up test environment...'); LoginTestUtil.resetCache(); - console.log('โœ… Test cache cleared'); -} + console.log('Test cache cleared'); + + // CI: Uses ZEPPELIN_E2E_TEST_NOTEBOOK_DIR which gets cleaned up by workflow + // Local: Uses API-based cleanup to avoid server restart required for directory changes + if (!process.env.CI) { + console.log('Running cleanup script: npx tsx e2e/cleanup-util.ts'); + + try { + // The reason for calling it this way instead of using the function directly + // is to maintain compatibility between ESM and CommonJS modules. + const { stdout, stderr } = await execAsync('npx tsx e2e/cleanup-util.ts'); + if (stdout) { + console.log(stdout); + } + if (stderr) { + console.error(stderr); + } + } catch (error) { + console.error('Cleanup script failed:', error); + } + } +}; export default globalTeardown; diff --git a/zeppelin-web-angular/e2e/models/base-page.ts b/zeppelin-web-angular/e2e/models/base-page.ts index 2daf3e23e18..56347a5bcee 100644 --- a/zeppelin-web-angular/e2e/models/base-page.ts +++ b/zeppelin-web-angular/e2e/models/base-page.ts @@ -12,21 +12,78 @@ import { Locator, Page } from '@playwright/test'; +export const E2E_TEST_FOLDER = 'E2E_TEST_FOLDER'; +export const BASE_URL = 'http://localhost:4200'; + export class BasePage { readonly page: Page; - readonly loadingScreen: Locator; + + // Common Zeppelin component locators + readonly zeppelinNodeList: Locator; + readonly zeppelinWorkspace: Locator; + readonly zeppelinHeader: Locator; + readonly zeppelinPageHeader: Locator; constructor(page: Page) { this.page = page; - this.loadingScreen = page.locator('.spin-text'); + this.zeppelinNodeList = page.locator('zeppelin-node-list'); + this.zeppelinWorkspace = page.locator('zeppelin-workspace'); + this.zeppelinHeader = page.locator('zeppelin-header'); + this.zeppelinPageHeader = page.locator('zeppelin-page-header'); } async waitForPageLoad(): Promise { - await this.page.waitForLoadState('domcontentloaded'); - try { - await this.loadingScreen.waitFor({ state: 'hidden', timeout: 5000 }); - } catch { - console.log('Loading screen not found'); - } + await this.page.waitForLoadState('domcontentloaded', { timeout: 15000 }); + } + + // Common navigation patterns + async navigateToRoute( + route: string, + options?: { timeout?: number; waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' } + ): Promise { + await this.page.goto(`/#${route}`, { + waitUntil: 'domcontentloaded', + timeout: 60000, + ...options + }); + await this.waitForPageLoad(); + } + + async navigateToHome(): Promise { + await this.navigateToRoute('/'); + } + + getCurrentPath(): string { + const url = new URL(this.page.url()); + return url.hash || url.pathname; + } + + async waitForUrlNotContaining(fragment: string): Promise { + await this.page.waitForURL(url => !url.toString().includes(fragment)); + } + + // Common form interaction patterns + async fillInput(locator: Locator, value: string, options?: { timeout?: number; force?: boolean }): Promise { + await locator.fill(value, { timeout: 15000, ...options }); + } + + async clickElement(locator: Locator, options?: { timeout?: number; force?: boolean }): Promise { + await locator.click({ timeout: 15000, ...options }); + } + + async getInputValue(locator: Locator): Promise { + return await locator.inputValue(); + } + + async isElementVisible(locator: Locator): Promise { + return await locator.isVisible(); + } + + async isElementEnabled(locator: Locator): Promise { + return await locator.isEnabled(); + } + + async getElementText(locator: Locator): Promise { + return (await locator.textContent()) || ''; } } diff --git a/zeppelin-web-angular/e2e/utils.ts b/zeppelin-web-angular/e2e/utils.ts index 0ba60329336..bc57c353526 100644 --- a/zeppelin-web-angular/e2e/utils.ts +++ b/zeppelin-web-angular/e2e/utils.ts @@ -12,6 +12,14 @@ import { test, Page, TestInfo } from '@playwright/test'; import { LoginTestUtil } from './models/login-page.util'; +import { E2E_TEST_FOLDER } from './models/base-page'; +import { NotebookUtil } from './models/notebook.util'; + +export const NOTEBOOK_PATTERNS = { + URL_REGEX: /\/notebook\/[^\/\?]+/, + URL_EXTRACT_NOTEBOOK_ID_REGEX: /\/notebook\/([^\/\?]+)/, + LINK_SELECTOR: 'a[href*="/notebook/"]' +} as const; export const PAGES = { // Main App @@ -97,27 +105,27 @@ export const PAGES = { } } as const; -export function addPageAnnotation(pageName: string, testInfo: TestInfo) { +export const addPageAnnotation = (pageName: string, testInfo: TestInfo) => { testInfo.annotations.push({ type: 'page', description: pageName }); -} +}; -export function addPageAnnotationBeforeEach(pageName: string) { +export const addPageAnnotationBeforeEach = (pageName: string) => { test.beforeEach(async ({}, testInfo) => { addPageAnnotation(pageName, testInfo); }); -} +}; interface PageStructureType { [key: string]: string | PageStructureType; } -export function flattenPageComponents(pages: PageStructureType): string[] { +export const flattenPageComponents = (pages: PageStructureType): string[] => { const result: string[] = []; - function flatten(obj: PageStructureType) { + const flatten = (obj: PageStructureType) => { for (const value of Object.values(obj)) { if (typeof value === 'string') { result.push(value); @@ -125,36 +133,35 @@ export function flattenPageComponents(pages: PageStructureType): string[] { flatten(value); } } - } + }; flatten(pages); return result.sort(); -} +}; -export function getCoverageTransformPaths(): string[] { - return flattenPageComponents(PAGES); -} +export const getCoverageTransformPaths = (): string[] => flattenPageComponents(PAGES); -export async function waitForUrlNotContaining(page: Page, fragment: string) { +export const waitForUrlNotContaining = async (page: Page, fragment: string) => { await page.waitForURL(url => !url.toString().includes(fragment)); -} +}; -export function getCurrentPath(page: Page): string { +export const getCurrentPath = (page: Page): string => { const url = new URL(page.url()); return url.hash || url.pathname; -} +}; -export async function getBasicPageMetadata(page: Page): Promise<{ +export const getBasicPageMetadata = async ( + page: Page +): Promise<{ title: string; path: string; -}> { - return { - title: await page.title(), - path: getCurrentPath(page) - }; -} +}> => ({ + title: await page.title(), + path: getCurrentPath(page) +}); -export async function performLoginIfRequired(page: Page): Promise { +import { LoginPage } from './models/login-page'; +export const performLoginIfRequired = async (page: Page): Promise => { const isShiroEnabled = await LoginTestUtil.isShiroEnabled(); if (!isShiroEnabled) { return false; @@ -173,37 +180,264 @@ export async function performLoginIfRequired(page: Page): Promise { const isLoginVisible = await page.locator('zeppelin-login').isVisible(); if (isLoginVisible) { - const userNameInput = page.getByRole('textbox', { name: 'User Name' }); - const passwordInput = page.getByRole('textbox', { name: 'Password' }); - const loginButton = page.getByRole('button', { name: 'Login' }); + const loginPage = new LoginPage(page); + await loginPage.login(testUser.username, testUser.password); - await userNameInput.fill(testUser.username); - await passwordInput.fill(testUser.password); - await loginButton.click(); + // for webkit + await page.waitForTimeout(200); + await page.evaluate(() => { + if (window.location.hash.includes('login')) { + window.location.hash = '#/'; + } + }); - await page.waitForSelector('text=Welcome to Zeppelin!', { timeout: 5000 }); - return true; + try { + await page.waitForSelector('zeppelin-login', { state: 'hidden', timeout: 30000 }); + await page.waitForSelector('text=Welcome to Zeppelin!', { timeout: 30000 }); + await page.waitForSelector('zeppelin-node-list', { timeout: 30000 }); + await waitForZeppelinReady(page); + return true; + } catch { + return false; + } } return false; -} +}; -export async function waitForZeppelinReady(page: Page): Promise { +export const waitForZeppelinReady = async (page: Page): Promise => { try { - await page.waitForLoadState('networkidle', { timeout: 30000 }); + // Enhanced wait for network idle with longer timeout for CI environments + await page.waitForLoadState('domcontentloaded', { timeout: 45000 }); + + // Check if we're on login page and authentication is required + const isOnLoginPage = page.url().includes('#/login'); + if (isOnLoginPage) { + console.log('On login page - checking if authentication is enabled'); + + // If we're on login dlpage, this is expected when authentication is required + // Just wait for login elements to be ready instead of waiting for app content + await page.waitForFunction( + () => { + const hasAngular = document.querySelector('[ng-version]') !== null; + const hasLoginElements = + document.querySelector('zeppelin-login') !== null || + document.querySelector('input[placeholder*="User"], input[placeholder*="user"], input[type="text"]') !== + null; + return hasAngular && hasLoginElements; + }, + { timeout: 30000 } + ); + console.log('Login page is ready'); + return; + } + + // Wait for Angular and Zeppelin to be ready with more robust checks await page.waitForFunction( () => { + // Check for Angular framework const hasAngular = document.querySelector('[ng-version]') !== null; + + // Check for Zeppelin-specific content const hasZeppelinContent = document.body.textContent?.includes('Zeppelin') || document.body.textContent?.includes('Notebook') || document.body.textContent?.includes('Welcome'); + + // Check for Zeppelin root element const hasZeppelinRoot = document.querySelector('zeppelin-root') !== null; - return hasAngular && (hasZeppelinContent || hasZeppelinRoot); + + // Check for basic UI elements that indicate the app is ready + const hasBasicUI = + document.querySelector('button, input, .ant-btn') !== null || + document.querySelector('[class*="zeppelin"]') !== null; + + return hasAngular && (hasZeppelinContent || hasZeppelinRoot || hasBasicUI); }, - { timeout: 60 * 1000 } + { timeout: 90000 } ); + + // Additional stability check - wait for DOM to be stable + await page.waitForLoadState('domcontentloaded'); } catch (error) { - throw error instanceof Error ? error : new Error(`Zeppelin loading failed: ${String(error)}`); + throw new Error(`Zeppelin loading failed: ${String(error)}`); } -} +}; + +export const waitForNotebookLinks = async (page: Page, timeout: number = 30000) => { + const locator = page.locator(NOTEBOOK_PATTERNS.LINK_SELECTOR); + + // If there are no notebook links on the page, there's no reason to wait + const count = await locator.count(); + if (count === 0) { + return; + } + + await locator.first().waitFor({ state: 'visible', timeout }); +}; + +export const navigateToNotebookWithFallback = async ( + page: Page, + noteId: string, + notebookName?: string +): Promise => { + let navigationSuccessful = false; + + try { + // Strategy 1: Direct navigation + await page.goto(`/#/notebook/${noteId}`, { waitUntil: 'networkidle', timeout: 30000 }); + navigationSuccessful = true; + } catch (error) { + console.log('Direct navigation failed, trying fallback strategies...'); + + // Strategy 2: Wait for loading completion and check URL + await page.waitForFunction( + () => { + const loadingText = document.body.textContent || ''; + return !loadingText.includes('Getting Ticket Data'); + }, + { timeout: 15000 } + ); + + const currentUrl = page.url(); + if (currentUrl.includes('/notebook/')) { + navigationSuccessful = true; + } + + // Strategy 3: Navigate through home page if notebook name is provided + if (!navigationSuccessful && notebookName) { + await page.goto('/#/'); + await page.waitForLoadState('networkidle', { timeout: 15000 }); + await page.waitForSelector('zeppelin-node-list', { timeout: 15000 }); + + // The link text in the UI is the base name of the note, not the full path. + const baseName = notebookName.split('/').pop(); + const notebookLink = page.locator(NOTEBOOK_PATTERNS.LINK_SELECTOR).filter({ hasText: baseName! }); + // Use the click action's built-in wait. + await notebookLink.click({ timeout: 10000 }); + + await page.waitForURL(NOTEBOOK_PATTERNS.URL_REGEX, { timeout: 20000 }); + navigationSuccessful = true; + } + } + + if (!navigationSuccessful) { + throw new Error(`Failed to navigate to notebook ${noteId}`); + } + + // Wait for notebook to be ready + await waitForZeppelinReady(page); +}; + +const extractNoteIdFromUrl = async (page: Page): Promise => { + const url = page.url(); + const match = url.match(NOTEBOOK_PATTERNS.URL_EXTRACT_NOTEBOOK_ID_REGEX); + return match ? match[1] : null; +}; + +const waitForNotebookNavigation = async (page: Page): Promise => { + await page.waitForURL(NOTEBOOK_PATTERNS.URL_REGEX, { timeout: 30000 }); + return await extractNoteIdFromUrl(page); +}; + +const navigateViaHomePageFallback = async (page: Page, baseNotebookName: string): Promise => { + await page.goto('/#/'); + await page.waitForLoadState('networkidle', { timeout: 15000 }); + await page.waitForSelector('zeppelin-node-list', { timeout: 15000 }); + + await page.waitForFunction(() => document.querySelectorAll(NOTEBOOK_PATTERNS.LINK_SELECTOR).length > 0, { + timeout: 15000 + }); + await page.waitForLoadState('domcontentloaded', { timeout: 15000 }); + + const notebookLink = page.locator(NOTEBOOK_PATTERNS.LINK_SELECTOR).filter({ hasText: baseNotebookName }); + + const browserName = page.context().browser()?.browserType().name(); + if (browserName === 'firefox') { + await page.waitForSelector(`${NOTEBOOK_PATTERNS.LINK_SELECTOR}:has-text("${baseNotebookName}")`, { + state: 'visible', + timeout: 90000 + }); + } else { + await notebookLink.waitFor({ state: 'visible', timeout: 60000 }); + } + + await notebookLink.click({ timeout: 15000 }); + await page.waitForURL(NOTEBOOK_PATTERNS.URL_REGEX, { timeout: 20000 }); + + const noteId = await extractNoteIdFromUrl(page); + if (!noteId) { + throw new Error('Failed to extract notebook ID after home page navigation'); + } + + return noteId; +}; + +const extractFirstParagraphId = async (page: Page): Promise => { + await page.locator('zeppelin-notebook-paragraph').first().waitFor({ state: 'visible', timeout: 10000 }); + + const paragraphContainer = page.locator('zeppelin-notebook-paragraph').first(); + const dropdownTrigger = paragraphContainer.locator('a[nz-dropdown]'); + await dropdownTrigger.click(); + + const paragraphLink = page.locator('li.paragraph-id a').first(); + await paragraphLink.waitFor({ state: 'attached', timeout: 15000 }); + + const paragraphId = await paragraphLink.textContent(); + if (!paragraphId || !paragraphId.startsWith('paragraph_')) { + throw new Error(`Invalid paragraph ID found: ${paragraphId}`); + } + + return paragraphId; +}; + +export const createTestNotebook = async ( + page: Page, + folderPath?: string +): Promise<{ noteId: string; paragraphId: string }> => { + const notebookUtil = new NotebookUtil(page); + const baseNotebookName = `TestNotebook_${Date.now()}`; + const notebookName = folderPath ? `${folderPath}/${baseNotebookName}` : `${E2E_TEST_FOLDER}/${baseNotebookName}`; + + try { + // Create notebook + await notebookUtil.createNotebook(notebookName); + + let noteId: string | null = null; + + // Try direct navigation first + noteId = await waitForNotebookNavigation(page); + + if (!noteId) { + console.log('Direct navigation failed, trying fallback strategies...'); + + // Check if we're already on a notebook page + noteId = await extractNoteIdFromUrl(page); + + if (noteId) { + // Use existing fallback navigation + await navigateToNotebookWithFallback(page, noteId, notebookName); + } else { + // Navigate via home page as last resort + noteId = await navigateViaHomePageFallback(page, baseNotebookName); + } + } + + if (!noteId) { + throw new Error(`Failed to extract notebook ID from URL: ${page.url()}`); + } + + // Extract paragraph ID + const paragraphId = await extractFirstParagraphId(page); + + // Navigate back to home + await page.goto('/#/'); + await waitForZeppelinReady(page); + + return { noteId, paragraphId }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const currentUrl = page.url(); + throw new Error(`Failed to create test notebook: ${errorMessage}. Current URL: ${currentUrl}`); + } +}; diff --git a/zeppelin-web-angular/package.json b/zeppelin-web-angular/package.json index c12b284e41a..43333365308 100644 --- a/zeppelin-web-angular/package.json +++ b/zeppelin-web-angular/package.json @@ -19,7 +19,8 @@ "e2e:debug": "playwright test --debug", "e2e:report": "playwright show-report", "e2e:ci": "export CI=true && playwright test", - "e2e:codegen": "playwright codegen http://localhost:4200" + "e2e:codegen": "playwright codegen http://localhost:4200", + "e2e:cleanup": "npx tsx e2e/cleanup-util.ts" }, "engines": { "node": ">=18.0.0 <19.0.0" diff --git a/zeppelin-web-angular/playwright.config.ts b/zeppelin-web-angular/playwright.config.js similarity index 72% rename from zeppelin-web-angular/playwright.config.ts rename to zeppelin-web-angular/playwright.config.js index 8d845d58320..496383e139e 100644 --- a/zeppelin-web-angular/playwright.config.ts +++ b/zeppelin-web-angular/playwright.config.js @@ -10,18 +10,18 @@ * limitations under the License. */ -import { defineConfig, devices } from '@playwright/test'; +const { defineConfig, devices } = require('@playwright/test'); // https://playwright.dev/docs/test-configuration -export default defineConfig({ +module.exports = defineConfig({ testDir: './e2e', globalSetup: require.resolve('./e2e/global-setup'), globalTeardown: require.resolve('./e2e/global-teardown'), fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: 4, - timeout: 120000, + retries: 1, + workers: 10, + timeout: 300000, expect: { timeout: 60000 }, @@ -34,16 +34,22 @@ export default defineConfig({ baseURL: process.env.CI ? 'http://localhost:8080' : 'http://localhost:4200', trace: 'on-first-retry', // https://playwright.dev/docs/trace-viewer screenshot: process.env.CI ? 'off' : 'only-on-failure', - video: process.env.CI ? 'off' : 'retain-on-failure' + video: process.env.CI ? 'off' : 'retain-on-failure', + launchOptions: { + args: ['--disable-dev-shm-usage'] + }, + headless: true, + actionTimeout: 60000, + navigationTimeout: 180000 }, projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] } + use: { ...devices['Desktop Chrome'], permissions: ['clipboard-read', 'clipboard-write'] } }, { name: 'Google Chrome', - use: { ...devices['Desktop Chrome'], channel: 'chrome' } + use: { ...devices['Desktop Chrome'], channel: 'chrome', permissions: ['clipboard-read', 'clipboard-write'] } }, { name: 'firefox', @@ -60,7 +66,7 @@ export default defineConfig({ }, { name: 'Microsoft Edge', - use: { ...devices['Desktop Edge'], channel: 'msedge' } + use: { ...devices['Desktop Edge'], channel: 'msedge', permissions: ['clipboard-read', 'clipboard-write'] } } ], webServer: process.env.CI diff --git a/zeppelin-web-angular/pom.xml b/zeppelin-web-angular/pom.xml index b488fefaae8..1498e85218f 100644 --- a/zeppelin-web-angular/pom.xml +++ b/zeppelin-web-angular/pom.xml @@ -151,9 +151,19 @@ ${web.e2e.disabled} - - - + + + + + + + + + + @@ -169,6 +179,7 @@ + From 8de0813ab4b1e4d81a9afe1e2062d46ceaa2af7d Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Mon, 22 Dec 2025 19:33:06 +0900 Subject: [PATCH 2/3] update base-page and remove useless file --- package-lock.json | 6 ----- zeppelin-web-angular/e2e/models/base-page.ts | 27 ++------------------ 2 files changed, 2 insertions(+), 31 deletions(-) delete mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index f5af5198cc5..00000000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "zeppelin-new", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/zeppelin-web-angular/e2e/models/base-page.ts b/zeppelin-web-angular/e2e/models/base-page.ts index 56347a5bcee..c3d9004fdec 100644 --- a/zeppelin-web-angular/e2e/models/base-page.ts +++ b/zeppelin-web-angular/e2e/models/base-page.ts @@ -18,25 +18,23 @@ export const BASE_URL = 'http://localhost:4200'; export class BasePage { readonly page: Page; - // Common Zeppelin component locators readonly zeppelinNodeList: Locator; readonly zeppelinWorkspace: Locator; - readonly zeppelinHeader: Locator; readonly zeppelinPageHeader: Locator; + readonly zeppelinHeader: Locator; constructor(page: Page) { this.page = page; this.zeppelinNodeList = page.locator('zeppelin-node-list'); this.zeppelinWorkspace = page.locator('zeppelin-workspace'); - this.zeppelinHeader = page.locator('zeppelin-header'); this.zeppelinPageHeader = page.locator('zeppelin-page-header'); + this.zeppelinHeader = page.locator('zeppelin-header'); } async waitForPageLoad(): Promise { await this.page.waitForLoadState('domcontentloaded', { timeout: 15000 }); } - // Common navigation patterns async navigateToRoute( route: string, options?: { timeout?: number; waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' } @@ -62,27 +60,6 @@ export class BasePage { await this.page.waitForURL(url => !url.toString().includes(fragment)); } - // Common form interaction patterns - async fillInput(locator: Locator, value: string, options?: { timeout?: number; force?: boolean }): Promise { - await locator.fill(value, { timeout: 15000, ...options }); - } - - async clickElement(locator: Locator, options?: { timeout?: number; force?: boolean }): Promise { - await locator.click({ timeout: 15000, ...options }); - } - - async getInputValue(locator: Locator): Promise { - return await locator.inputValue(); - } - - async isElementVisible(locator: Locator): Promise { - return await locator.isVisible(); - } - - async isElementEnabled(locator: Locator): Promise { - return await locator.isEnabled(); - } - async getElementText(locator: Locator): Promise { return (await locator.textContent()) || ''; } From ca2f0fdf0e194b720d9c4f98f6552670eb2152ca Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Sun, 28 Dec 2025 19:18:37 +0900 Subject: [PATCH 3/3] revert unproper change for selenium ci step --- .github/workflows/frontend.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index ca97c18c6ac..2f99846e696 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -191,7 +191,7 @@ jobs: - name: Setup conda environment with python 3.9 and R uses: conda-incubator/setup-miniconda@v3 with: - activate-environment: python_only + activate-environment: python_3_with_R environment-file: testing/env_python_3_with_R.yml python-version: 3.9 channels: conda-forge,defaults