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 e781b021e5 [ZEPPELIN-6336] Enable Conditional Login Test Based on
shiro.ini Presence in Zeppelin
e781b021e5 is described below
commit e781b021e500372d1f016d53445dcc621ffaf66a
Author: YONGJAE LEE(이용재) <[email protected]>
AuthorDate: Sat Oct 4 10:49:52 2025 +0900
[ZEPPELIN-6336] Enable Conditional Login Test Based on shiro.ini Presence
in Zeppelin
### What is this PR for?
Currently, Zeppelin(zeppelin-web-angular)’s E2E authentication tests
require the presence of a `shiro.ini` file to run.
However, in certain build or CI environments, this file may not exist.
In such cases, login tests may fail or behave unpredictably.
To improve flexibility, the test framework should support both scenarios:
- **Auth mode (`shiro.ini` exists)** → Run all tests, including
authentication/login tests
- **Anonymous mode (`shiro.ini` does not exist)** → Skip
authentication/login tests, but run all other tests
#### 1. GitHub Actions Workflow (Matrix Mode)
- Added `strategy.matrix.mode: [anonymous, auth]`
- In `auth` mode, copy `shiro.ini.template → shiro.ini`
- In `anonymous` mode, skip `shiro.ini` setup to simulate a no-auth
environment
#### 2. Playwright Global Setup / Teardown
- **`global-setup.ts`**
- Added `LoginTestUtil.isShiroEnabled()` to detect presence of `shiro.ini`
- If enabled → load credentials & run login tests
- If disabled → skip login tests, log message
- **`global-teardown.ts`**
- Added environment cleanup (e.g., reset cache)
#### 3. Authentication Utility (`login-page.util.ts`)
- `isShiroEnabled()`: Checks if `shiro.ini` is accessible via `fs.access`
- `getTestCredentials()`: Parses credentials only when `shiro.ini` exists
- `resetCache()`: Clears cached values between test runs
#### 4. Test Code Updates
- **`app.spec.ts`**
- Conditionally checks whether login page or workspace should be visible,
based on `isShiroEnabled()`
- **Other Playwright tests**
- Authentication-related tests are skipped when `shiro.ini` is not present
### What type of PR is it?
Improvement
### Todos
### What is the Jira issue?
ZEPPELIN-6336
### How should this be tested?
### Screenshots (if appropriate)
### Questions:
* Does the license files need to update? No
* Is there breaking changes for older versions? No
* Does this needs documentation? No
Closes #5088 from dididy/fix/ZEPPELIN-6336.
Signed-off-by: ChanHo Lee <[email protected]>
(cherry picked from commit 190ecd6a2febd58616e0fdbb2686fbd2b2ea7e7a)
Signed-off-by: ChanHo Lee <[email protected]>
---
.github/workflows/frontend.yml | 13 ++-
zeppelin-web-angular/e2e/global-setup.ts | 36 ++++++
zeppelin-web-angular/e2e/global-teardown.ts | 22 ++++
zeppelin-web-angular/e2e/models/login-page.ts | 55 +++++++++
zeppelin-web-angular/e2e/models/login-page.util.ts | 123 +++++++++++++++++++++
zeppelin-web-angular/e2e/tests/app.spec.ts | 8 +-
zeppelin-web-angular/e2e/tests/login/login.spec.ts | 119 ++++++++++++++++++++
zeppelin-web-angular/playwright.config.ts | 2 +
8 files changed, 375 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml
index 55097ad764..66e5c5baf5 100644
--- a/.github/workflows/frontend.yml
+++ b/.github/workflows/frontend.yml
@@ -62,6 +62,9 @@ jobs:
run-playwright-e2e-tests:
runs-on: ubuntu-24.04
+ strategy:
+ matrix:
+ mode: [anonymous, auth]
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -85,13 +88,19 @@ jobs:
${{ runner.os }}-zeppelin-
- name: Install application
run: ./mvnw clean install -DskipTests -am -pl 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
+ fi
- 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
+ - name: Upload Playwright Report
uses: actions/upload-artifact@v4
if: always()
with:
- name: playwright-report
+ name: playwright-report-${{ matrix.mode }}
path: zeppelin-web-angular/playwright-report/
retention-days: 30
- name: Print Zeppelin logs
diff --git a/zeppelin-web-angular/e2e/global-setup.ts
b/zeppelin-web-angular/e2e/global-setup.ts
new file mode 100644
index 0000000000..d9acad53e2
--- /dev/null
+++ b/zeppelin-web-angular/e2e/global-setup.ts
@@ -0,0 +1,36 @@
+/*
+ * 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 { LoginTestUtil } from './models/login-page.util';
+
+async function globalSetup() {
+ console.log('🔧 Global Setup: Checking Shiro configuration...');
+
+ // Reset cache to ensure fresh check
+ LoginTestUtil.resetCache();
+
+ const isShiroEnabled = await LoginTestUtil.isShiroEnabled();
+
+ if (isShiroEnabled) {
+ console.log('✅ Shiro.ini detected - authentication tests will run');
+
+ // Parse and validate credentials
+ const credentials = await LoginTestUtil.getTestCredentials();
+ const userCount = Object.keys(credentials).length;
+
+ console.log(`📋 Found ${userCount} test credentials in shiro.ini`);
+ } else {
+ console.log('⚠️ Shiro.ini not found - authentication tests will be
skipped');
+ }
+}
+
+export default globalSetup;
diff --git a/zeppelin-web-angular/e2e/global-teardown.ts
b/zeppelin-web-angular/e2e/global-teardown.ts
new file mode 100644
index 0000000000..a02aa10418
--- /dev/null
+++ b/zeppelin-web-angular/e2e/global-teardown.ts
@@ -0,0 +1,22 @@
+/*
+ * 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 { LoginTestUtil } from './models/login-page.util';
+
+async function globalTeardown() {
+ console.log('🧹 Global Teardown: Cleaning up test environment...');
+
+ LoginTestUtil.resetCache();
+ console.log('✅ Test cache cleared');
+}
+
+export default globalTeardown;
diff --git a/zeppelin-web-angular/e2e/models/login-page.ts
b/zeppelin-web-angular/e2e/models/login-page.ts
new file mode 100644
index 0000000000..3745e0df8f
--- /dev/null
+++ b/zeppelin-web-angular/e2e/models/login-page.ts
@@ -0,0 +1,55 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Locator, Page } from '@playwright/test';
+import { BasePage } from './base-page';
+
+export class LoginPage extends BasePage {
+ readonly userNameInput: Locator;
+ readonly passwordInput: Locator;
+ readonly loginButton: Locator;
+ readonly welcomeTitle: Locator;
+ readonly formContainer: Locator;
+
+ constructor(page: Page) {
+ super(page);
+ this.userNameInput = page.getByRole('textbox', { name: 'User Name' });
+ this.passwordInput = page.getByRole('textbox', { name: 'Password' });
+ this.loginButton = page.getByRole('button', { name: 'Login' });
+ this.welcomeTitle = page.getByRole('heading', { name: 'Welcome to
Zeppelin!' });
+ this.formContainer = page.locator('form[nz-form]');
+ }
+
+ async navigate(): Promise<void> {
+ await this.page.goto('/#/login');
+ await this.waitForPageLoad();
+ }
+
+ async login(username: string, password: string): Promise<void> {
+ await this.userNameInput.fill(username);
+ await this.passwordInput.fill(password);
+ await this.loginButton.click();
+ }
+
+ async waitForErrorMessage(): Promise<void> {
+ await this.page.waitForSelector("text=The username and password that you
entered don't match.", { timeout: 5000 });
+ }
+
+ async getErrorMessageText(): Promise<string> {
+ return (
+ (await this.page
+ .locator("text=The username and password that you entered don't
match.")
+ .first()
+ .textContent()) || ''
+ );
+ }
+}
diff --git a/zeppelin-web-angular/e2e/models/login-page.util.ts
b/zeppelin-web-angular/e2e/models/login-page.util.ts
new file mode 100644
index 0000000000..48ce3053d3
--- /dev/null
+++ b/zeppelin-web-angular/e2e/models/login-page.util.ts
@@ -0,0 +1,123 @@
+/*
+ * 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 * as fs from 'fs';
+import * as path from 'path';
+import { promisify } from 'util';
+
+const access = promisify(fs.access);
+const readFile = promisify(fs.readFile);
+
+export interface TestCredentials {
+ username: string;
+ password: string;
+ roles?: string[];
+}
+
+export class LoginTestUtil {
+ private static readonly SHIRO_CONFIG_PATH = path.join(process.cwd(), '..',
'conf', 'shiro.ini');
+
+ private static _testCredentials: Record<string, TestCredentials> | null =
null;
+ private static _isShiroEnabled: boolean | null = null;
+
+ static resetCache(): void {
+ this._testCredentials = null;
+ this._isShiroEnabled = null;
+ }
+
+ static async isShiroEnabled(): Promise<boolean> {
+ if (this._isShiroEnabled !== null) {
+ return this._isShiroEnabled;
+ }
+
+ try {
+ await access(this.SHIRO_CONFIG_PATH);
+ this._isShiroEnabled = true;
+ } catch (error) {
+ this._isShiroEnabled = false;
+ }
+
+ return this._isShiroEnabled;
+ }
+
+ static async getTestCredentials(): Promise<Record<string, TestCredentials>> {
+ if (!(await this.isShiroEnabled())) {
+ return {};
+ }
+
+ if (this._testCredentials !== null) {
+ return this._testCredentials;
+ }
+
+ try {
+ const content = await readFile(this.SHIRO_CONFIG_PATH, 'utf-8');
+ const users: Record<string, TestCredentials> = {};
+
+ this._parseUsersSection(content, users);
+ this._addTestCredentials(users);
+
+ this._testCredentials = users;
+ return users;
+ } catch (error) {
+ console.error('Failed to parse shiro.ini:', error);
+ return {};
+ }
+ }
+
+ private static _parseUsersSection(content: string, users: Record<string,
TestCredentials>): void {
+ const lines = content.split('\n');
+ let inUsersSection = false;
+
+ for (const line of lines) {
+ const trimmedLine = line.trim();
+
+ if (trimmedLine === '[users]') {
+ inUsersSection = true;
+ continue;
+ }
+
+ if (trimmedLine.startsWith('[') && trimmedLine !== '[users]') {
+ inUsersSection = false;
+ continue;
+ }
+
+ if (inUsersSection && trimmedLine && !trimmedLine.startsWith('#')) {
+ this._parseUserLine(trimmedLine, users);
+ }
+ }
+ }
+
+ private static _parseUserLine(line: string, users: Record<string,
TestCredentials>): void {
+ const [userPart, ...roleParts] = line.split('=');
+ if (!userPart || roleParts.length === 0) return;
+
+ const username = userPart.trim();
+ const rightSide = roleParts.join('=').trim();
+ const parts = rightSide.split(',').map(p => p.trim());
+
+ if (parts.length > 0) {
+ const password = parts[0];
+ const roles = parts.slice(1);
+
+ users[username] = {
+ username,
+ password,
+ roles
+ };
+ }
+ }
+
+ private static _addTestCredentials(users: Record<string, TestCredentials>):
void {
+ users.INVALID_USER = { username: 'wronguser', password: 'wrongpass' };
+ users.EMPTY_CREDENTIALS = { username: '', password: '' };
+ }
+}
diff --git a/zeppelin-web-angular/e2e/tests/app.spec.ts
b/zeppelin-web-angular/e2e/tests/app.spec.ts
index 7b87ee3f11..8baa40123a 100644
--- a/zeppelin-web-angular/e2e/tests/app.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/app.spec.ts
@@ -13,6 +13,7 @@
import { expect, test } from '@playwright/test';
import { ZeppelinHelper } from '../helper';
import { BasePage } from '../models/base-page';
+import { LoginTestUtil } from '../models/login-page.util';
import { addPageAnnotationBeforeEach, PAGES } from '../utils';
test.describe('Zeppelin App Component', () => {
@@ -56,7 +57,12 @@ test.describe('Zeppelin App Component', () => {
test('should display workspace after loading', async ({ page }) => {
await zeppelinHelper.waitForZeppelinReady();
- await expect(page.locator('zeppelin-workspace')).toBeVisible();
+ const isShiroEnabled = await LoginTestUtil.isShiroEnabled();
+ if (isShiroEnabled) {
+ await expect(page.locator('zeppelin-login')).toBeVisible();
+ } else {
+ await expect(page.locator('zeppelin-workspace')).toBeVisible();
+ }
});
test('should handle navigation events correctly', async ({ page }) => {
diff --git a/zeppelin-web-angular/e2e/tests/login/login.spec.ts
b/zeppelin-web-angular/e2e/tests/login/login.spec.ts
new file mode 100644
index 0000000000..cd9786d82f
--- /dev/null
+++ b/zeppelin-web-angular/e2e/tests/login/login.spec.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 { expect, test } from '@playwright/test';
+import { LoginPage } from '../../models/login-page';
+import { LoginTestUtil } from '../../models/login-page.util';
+import { addPageAnnotationBeforeEach, PAGES } from '../../utils';
+
+test.describe('Login Page', () => {
+ addPageAnnotationBeforeEach(PAGES.PAGES.LOGIN);
+ let loginPage: LoginPage;
+ let testCredentials: Record<string, any>;
+
+ test.beforeAll(async () => {
+ const isShiroEnabled = await LoginTestUtil.isShiroEnabled();
+ if (!isShiroEnabled) {
+ test.skip(true, 'Skipping all login tests - shiro.ini not found');
+ }
+ testCredentials = await LoginTestUtil.getTestCredentials();
+ });
+
+ test.beforeEach(async ({ page }) => {
+ loginPage = new LoginPage(page);
+ await loginPage.navigate();
+ });
+
+ test('should display login form with required elements', async () => {
+ await expect(loginPage.formContainer).toBeVisible();
+ await expect(loginPage.userNameInput).toBeVisible();
+ await expect(loginPage.passwordInput).toBeVisible();
+ await expect(loginPage.loginButton).toBeVisible();
+ await expect(loginPage.welcomeTitle).toBeVisible();
+ });
+
+ test('should have proper input field attributes', async () => {
+ await expect(loginPage.userNameInput).toHaveAttribute('placeholder', 'User
Name');
+ await expect(loginPage.passwordInput).toHaveAttribute('placeholder',
'Password');
+ await expect(loginPage.passwordInput).toHaveAttribute('type', 'password');
+ await expect(loginPage.userNameInput).toHaveAttribute('name', 'userName');
+ await expect(loginPage.passwordInput).toHaveAttribute('name', 'password');
+ });
+
+ test('should allow text input in form fields', async () => {
+ const testUsername = 'testuser';
+ const testPassword = 'testpass';
+
+ await loginPage.userNameInput.fill(testUsername);
+ await loginPage.passwordInput.fill(testPassword);
+
+ await expect(loginPage.userNameInput).toHaveValue(testUsername);
+ await expect(loginPage.passwordInput).toHaveValue(testPassword);
+ });
+
+ test('should display error message for invalid credentials', async () => {
+ const invalidCreds = testCredentials.INVALID_USER;
+
+ await loginPage.login(invalidCreds.username, invalidCreds.password);
+ await loginPage.waitForErrorMessage();
+
+ const errorText = await loginPage.getErrorMessageText();
+ expect(errorText).toContain("The username and password that you entered
don't match");
+ });
+
+ test('should login successfully with valid credentials', async ({ page }) =>
{
+ const validUserKey = Object.keys(testCredentials).find(
+ key => key !== 'INVALID_USER' && key !== 'EMPTY_CREDENTIALS'
+ );
+ const validCreds = testCredentials[validUserKey || 'user1'];
+
+ // Attempt to login with valid credentials
+ await loginPage.login(validCreds.username, validCreds.password);
+
+ // Wait for navigation and verify the new URL
+ await page.waitForURL('/#/');
+ await expect(page).toHaveURL('/#/');
+
+ // Verify the login form is no longer visible
+ await expect(loginPage.formContainer).not.toBeVisible();
+ });
+
+ test('should maintain form state after failed login', async () => {
+ const invalidCreds = testCredentials.INVALID_USER;
+ await loginPage.login(invalidCreds.username, invalidCreds.password);
+ await loginPage.waitForErrorMessage();
+
+ await expect(loginPage.userNameInput).toHaveValue(invalidCreds.username);
+ await expect(loginPage.passwordInput).toHaveValue(invalidCreds.password);
+ });
+
+ test('should support keyboard navigation', async ({ page }) => {
+ await loginPage.userNameInput.focus();
+ await expect(loginPage.userNameInput).toBeFocused();
+
+ await page.keyboard.press('Tab');
+ await expect(loginPage.passwordInput).toBeFocused();
+
+ await page.keyboard.press('Tab');
+ await expect(loginPage.loginButton).toBeFocused();
+ });
+
+ test('should handle form submission with Enter key', async ({ page }) => {
+ const testCreds = testCredentials.INVALID_USER;
+ await loginPage.login(testCreds.username, testCreds.password);
+
+ await loginPage.passwordInput.focus();
+ await page.keyboard.press('Enter');
+
+ await loginPage.waitForErrorMessage();
+ });
+});
diff --git a/zeppelin-web-angular/playwright.config.ts
b/zeppelin-web-angular/playwright.config.ts
index 85dde088a3..0dd3e2d6dc 100644
--- a/zeppelin-web-angular/playwright.config.ts
+++ b/zeppelin-web-angular/playwright.config.ts
@@ -15,6 +15,8 @@ import { defineConfig, devices } from '@playwright/test';
// https://playwright.dev/docs/test-configuration
export default 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,