This is an automated email from the ASF dual-hosted git repository.

chanholee pushed a commit to branch branch-0.12
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/branch-0.12 by this push:
     new c93969f4f2 [ZEPPELIN-6358] Seperate environment config and shared 
utilities from #5101
c93969f4f2 is described below

commit c93969f4f27ff5ac28b3dba070d8206d00ba1000
Author: YONGJAE LEE(์ด์šฉ์žฌ) <[email protected]>
AuthorDate: Sun Dec 28 21:25:38 2025 +0900

    [ZEPPELIN-6358] Seperate environment config and shared utilities from #5101
    
    ### What is this PR for?
    In #5101, the amount of changes became too large, and a committer suggested 
splitting it into smaller parts. Since the updates related to the common test 
environment and shared utility functions have already proven to be stable, I 
separated those pieces into this dedicated PR.
    
    This PR includes only the **safe, standalone changes** that improve the 
shared E2E environment and utilities.
    
    #### [Summary of Changes]
    * **Refined and separated E2E test environment setup**
      * Improved global setup/teardown
        * CI: using ZEPPELIN_E2E_TEST_NOTEBOOK_DIR
        * To use `ZEPPELIN_E2E_TEST_NOTEBOOK_DIR` locally, the server has to be 
restarted at least once, which feels a bit odd to enforce during E2E test 
execution. To make things less messy from a UI perspective, I reorganized the 
structure so that all tests are collected under `E2E_TEST_FOLDER` instead.
    
      * Added folder/notebook initialization and cleanup logic
        * you can run it standalone with this command:`npm run 
e2e:cleanup`(automatically run this once the tests are finished)
    
    * **Extracted shared utilities and constants**
      * Added common E2E constant (e.g., `E2E_TEST_FOLDER`)
      * Introduced cleanup utilities to ensure stable post-test state
    
    * **Updated GitHub Actions (`frontend.yml`)**
      * Added environment variables(for python interpreter), notebook repo 
initialization, and cleanup steps
      * Due to potential storage and cost concerns, I shortened the retention 
period for the `Playwright report` from 30 days to 3 days
    
    * **Updated Playwright / ESLint configurations**
      * ts to cjs
    
    ### What type of PR is it?
    Improvement
    Refactoring
    
    ### Todos
    
    ### What is the Jira issue?
    ZEPPELIN-6358
    
    ### How should this be tested?
    ```sh
    cd zeppelin-web-angular
    nvm use
    npm run start
    npm run e2e
    npm run e2e:cleanup
    ```
    
    ### Screenshots (if appropriate)
    
    ### Questions:
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? No
    
    Closes #5128 from dididy/e2e/notebook-base.
    
    Signed-off-by: ChanHo Lee <[email protected]>
    (cherry picked from commit a041703dd76d6cee15a8c9c4bc6ad6d0d4767ebf)
    Signed-off-by: ChanHo Lee <[email protected]>
---
 .github/workflows/frontend.yml                     |  33 ++-
 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       |  50 +++-
 zeppelin-web-angular/e2e/utils.ts                  | 310 ++++++++++++++++++---
 zeppelin-web-angular/package.json                  |   3 +-
 .../{playwright.config.ts => playwright.config.js} |  24 +-
 zeppelin-web-angular/pom.xml                       |  17 +-
 10 files changed, 530 insertions(+), 66 deletions(-)

diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml
index 587005fb08..2f99846e69 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
diff --git a/zeppelin-web-angular/.eslintrc.json 
b/zeppelin-web-angular/.eslintrc.json
index 683735703c..50793bb947 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 0000000000..a00678dedd
--- /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 d9acad53e2..a4ef2a6f10 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<void> {
+  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 a02aa10418..c25ad66c03 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 2daf3e23e1..c3d9004fde 100644
--- a/zeppelin-web-angular/e2e/models/base-page.ts
+++ b/zeppelin-web-angular/e2e/models/base-page.ts
@@ -12,21 +12,55 @@
 
 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;
+
+  readonly zeppelinNodeList: Locator;
+  readonly zeppelinWorkspace: Locator;
+  readonly zeppelinPageHeader: Locator;
+  readonly zeppelinHeader: 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.zeppelinPageHeader = page.locator('zeppelin-page-header');
+    this.zeppelinHeader = page.locator('zeppelin-header');
   }
 
   async waitForPageLoad(): Promise<void> {
-    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 });
+  }
+
+  async navigateToRoute(
+    route: string,
+    options?: { timeout?: number; waitUntil?: 'load' | 'domcontentloaded' | 
'networkidle' }
+  ): Promise<void> {
+    await this.page.goto(`/#${route}`, {
+      waitUntil: 'domcontentloaded',
+      timeout: 60000,
+      ...options
+    });
+    await this.waitForPageLoad();
+  }
+
+  async navigateToHome(): Promise<void> {
+    await this.navigateToRoute('/');
+  }
+
+  getCurrentPath(): string {
+    const url = new URL(this.page.url());
+    return url.hash || url.pathname;
+  }
+
+  async waitForUrlNotContaining(fragment: string): Promise<void> {
+    await this.page.waitForURL(url => !url.toString().includes(fragment));
+  }
+
+  async getElementText(locator: Locator): Promise<string> {
+    return (await locator.textContent()) || '';
   }
 }
diff --git a/zeppelin-web-angular/e2e/utils.ts 
b/zeppelin-web-angular/e2e/utils.ts
index 0ba6032933..bc57c35352 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<boolean> {
+import { LoginPage } from './models/login-page';
+export const performLoginIfRequired = async (page: Page): Promise<boolean> => {
   const isShiroEnabled = await LoginTestUtil.isShiroEnabled();
   if (!isShiroEnabled) {
     return false;
@@ -173,37 +180,264 @@ export async function performLoginIfRequired(page: 
Page): Promise<boolean> {
 
   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<void> {
+export const waitForZeppelinReady = async (page: Page): Promise<void> => {
   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<void> => {
+  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<string | null> => {
+  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<string | null> 
=> {
+  await page.waitForURL(NOTEBOOK_PATTERNS.URL_REGEX, { timeout: 30000 });
+  return await extractNoteIdFromUrl(page);
+};
+
+const navigateViaHomePageFallback = async (page: Page, baseNotebookName: 
string): Promise<string> => {
+  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<string> => {
+  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 c12b284e41..4333336530 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 8d845d5832..496383e139 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 b488fefaae..1498e85218 100644
--- a/zeppelin-web-angular/pom.xml
+++ b/zeppelin-web-angular/pom.xml
@@ -151,9 +151,19 @@
             <configuration>
               <skip>${web.e2e.disabled}</skip>
               <target unless="skipTests">
-                <exec executable="./zeppelin-daemon.sh" 
dir="${zeppelin.daemon.package.base}" spawn="false">
-                  <arg value="start" />
-                </exec>
+                <!--
+                  NOTE: ZEPPELIN_E2E_TEST_NOTEBOOK_DIR must match 
zeppelin.notebook.dir.
+                  Cleanup logic relies on both pointing to the same directory.
+                -->
+                <condition property="zeppelin.notebook.dir" 
value="${env.ZEPPELIN_E2E_TEST_NOTEBOOK_DIR}">
+                  <isset property="env.ZEPPELIN_E2E_TEST_NOTEBOOK_DIR" />
+                </condition>
+              <property name="zeppelin.notebook.dir" 
value="/tmp/zeppelin-e2e-notebooks" />
+              <exec executable="./zeppelin-daemon.sh" 
dir="${zeppelin.daemon.package.base}" spawn="false">
+                <arg value="start" />
+                <env key="ZEPPELIN_NOTEBOOK_DIR" 
value="${zeppelin.notebook.dir}" />
+                <env key="PATH" value="${env.PATH}" />
+              </exec>
               </target>
             </configuration>
             <goals>
@@ -169,6 +179,7 @@
               <target unless="skipTests">
                 <exec executable="./zeppelin-daemon.sh" 
dir="${zeppelin.daemon.package.base}" spawn="false">
                   <arg value="stop" />
+                  <env key="PATH" value="${env.PATH}" />
                 </exec>
               </target>
             </configuration>


Reply via email to