Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -93,15 +98,29 @@ 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
if [ "${{ matrix.mode }}" != "anonymous" ]; then
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
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions zeppelin-web-angular/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
90 changes: 90 additions & 0 deletions zeppelin-web-angular/e2e/cleanup-util.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
26 changes: 25 additions & 1 deletion zeppelin-web-angular/e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
32 changes: 28 additions & 4 deletions zeppelin-web-angular/e2e/global-teardown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
50 changes: 42 additions & 8 deletions zeppelin-web-angular/e2e/models/base-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) || '';
}
}
Loading
Loading