This is an automated email from the ASF dual-hosted git repository.
voidmatcha pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git
The following commit(s) were added to refs/heads/master by this push:
new 3ce7b63e35 [ZEPPELIN-6362] Add E2E coverage for interpreter
configuration
3ce7b63e35 is described below
commit 3ce7b63e3593aceb21dc0aa9b1a886c553d99dbf
Author: Minho Jang <[email protected]>
AuthorDate: Sat Nov 14 18:56:41 2026 +0900
[ZEPPELIN-6362] Add E2E coverage for interpreter configuration
### What is this PR for?
Add Playwright E2E coverage for the existing Angular interpreter route
before future React migration work.
Cover:
- Direct navigation, reload, settings display and case-insensitive search.
- Interpreter creation, required fields, duplicate names and group defaults.
- Typed properties, dependency editing and cancellation.
- Binding options, impersonation and conditional process and permission
fields.
- Restart and removal confirmations, including cancellation.
- Repository display, protected defaults, tooltips and add/remove workflows.
- Repository form validation, URL protocols, credentials and proxy settings.
Tests use real server reads by default. Endpoint-specific stubs provide
controlled states that are not guaranteed in the default environment. Mutation
requests are intercepted to avoid persisting configuration changes or
restarting interpreters.
### What type of PR is it?
Improvement
### What is the Jira issue?
* https://issues.apache.org/jira/browse/ZEPPELIN-6362
### How should this be tested?
With the Zeppelin server running at `http://localhost:8080`, run from
`zeppelin-web-angular`:
```bash
npm run e2e:fast -- tests/workspace/interpreter --retries=0
```
### Questions:
- Does the license files need to update? No.
- Is there breaking changes for older versions? No.
- Does this needs documentation? No.
Closes #5612 from miinhho/ZEPPELIN-6362-interpreter-e2e.
Signed-off-by: YONGJAE LEE <[email protected]>
---
.../e2e/models/interpreter-page.ts | 145 ++++++
.../e2e/models/interpreter-repository-modal.ts | 48 +-
.../e2e/models/interpreter-repository-page.ts | 51 ++
.../e2e/models/interpreter-test.util.ts | 117 +++++
.../create-repository-proxy-credentials.spec.ts | 32 +-
.../interpreter/interpreter-page-structure.spec.ts | 82 +++
.../interpreter/interpreter-repositories.spec.ts | 397 +++++++++++++++
.../interpreter/interpreter-settings.spec.ts | 563 +++++++++++++++++++++
.../create-repository-modal.component.html | 7 +-
.../interpreter/interpreter.component.html | 7 +-
.../workspace/interpreter/item/item.component.html | 19 +-
11 files changed, 1424 insertions(+), 44 deletions(-)
diff --git a/zeppelin-web-angular/e2e/models/interpreter-page.ts
b/zeppelin-web-angular/e2e/models/interpreter-page.ts
new file mode 100644
index 0000000000..47836486cf
--- /dev/null
+++ b/zeppelin-web-angular/e2e/models/interpreter-page.ts
@@ -0,0 +1,145 @@
+/*
+ * 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';
+
+export class InterpreterPage {
+ readonly title: Locator;
+ readonly description: Locator;
+ readonly search: Locator;
+ readonly repositoryButton: Locator;
+ readonly createCard: Locator;
+ readonly settings: Locator;
+ readonly settingTitles: Locator;
+
+ constructor(readonly page: Page) {
+ this.title = page.getByRole('heading', { level: 2, name:
/^Interpreters(?:\s|$)/ });
+ this.description = page.getByText('Manage interpreters settings.', {
exact: false });
+ this.search = page.getByPlaceholder('Search interpreters...', { exact:
true });
+ this.repositoryButton = page.getByRole('button', { name: 'Repository',
exact: true });
+ this.createCard = page.getByText('Create', { exact: true });
+ this.settings = page.getByTestId('interpreter-setting');
+ this.settingTitles = this.settings
+ .getByRole('heading', { level: 3 })
+ .filter({ has: page.getByTestId('interpreter-status') });
+ }
+
+ setting(name: string): InterpreterItemPage {
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ return new InterpreterItemPage(
+ this.page,
+ this.settings.filter({
+ has: this.page.getByRole('heading', { level: 3, name: new
RegExp(`^${escaped}\\s+%`) })
+ })
+ );
+ }
+
+ createForm(): InterpreterItemPage {
+ return new InterpreterItemPage(this.page,
this.page.getByTestId('interpreter-create-form'));
+ }
+}
+
+export class InterpreterItemPage {
+ readonly edit: Locator;
+ readonly restart: Locator;
+ readonly remove: Locator;
+ readonly save: Locator;
+ readonly cancel: Locator;
+ readonly name: Locator;
+ readonly group: Locator;
+ readonly status: Locator;
+ readonly properties: Locator;
+ readonly dependencies: Locator;
+ readonly newProperty: Locator;
+ readonly propertyKey: Locator;
+ readonly propertyValue: Locator;
+ readonly propertyType: Locator;
+ readonly artifact: Locator;
+ readonly exclusions: Locator;
+ readonly existingProcess: Locator;
+ readonly host: Locator;
+ readonly port: Locator;
+ readonly setPermission: Locator;
+ readonly owners: Locator;
+ readonly tooltip: Locator;
+ readonly bindingMenu: Locator;
+ readonly bindingMode: Locator;
+
+ constructor(
+ readonly page: Page,
+ readonly card: Locator
+ ) {
+ this.edit = card.getByRole('button', { name: 'Edit', exact: true });
+ this.restart = card.getByRole('button', { name: 'Restart', exact: true });
+ this.remove = card.getByRole('button', { name: 'Remove', exact: true });
+ this.save = card.getByRole('button', { name: 'Save', exact: true });
+ this.cancel = card.getByRole('button', { name: 'Cancel', exact: true });
+ this.name = card.getByPlaceholder('interpreter name', { exact: true });
+ this.group = card.getByTestId('interpreter-group');
+ this.status = card.getByTestId('interpreter-status');
+ this.properties = card.getByTestId('interpreter-properties');
+ this.dependencies = card.getByTestId('interpreter-dependencies');
+ this.newProperty = this.properties
+ .getByRole('row')
+ .filter({ has: page.getByRole('button', { name: 'Add property', exact:
true }) });
+ this.propertyKey =
this.newProperty.getByRole('cell').nth(0).getByRole('textbox');
+ this.propertyValue =
this.newProperty.getByRole('cell').nth(1).locator('input:not([readonly]),
textarea');
+ this.propertyType = this.newProperty.locator('.type-selector');
+ this.artifact = card.getByPlaceholder('groupId:artifactId:version or local
file path', { exact: true });
+ this.exclusions = card.getByPlaceholder('(Optional) comma separated
groupId:artifactId list', { exact: true });
+ this.existingProcess = card.getByRole('checkbox', { name: 'Connect to
existing process', exact: true });
+ this.host = card.getByRole('textbox', { name: 'Host', exact: true });
+ this.port = card.getByRole('spinbutton', { name: 'Port', exact: true });
+ this.setPermission = card.getByRole('checkbox', { name: 'Set permission',
exact: true });
+ this.owners = card.getByTestId('interpreter-owners');
+ this.tooltip = page.locator('.ant-tooltip-inner:visible');
+ this.bindingMenu = page.locator('.ant-dropdown:visible');
+ this.bindingMode = card.locator('.ant-dropdown-trigger').first();
+ }
+
+ propertyRow(key: string): Locator {
+ return this.properties.getByRole('row').filter({ has:
this.page.getByRole('cell', { name: key, exact: true }) });
+ }
+
+ propertyCell(key: string): Locator {
+ return this.propertyRow(key).getByRole('cell').nth(1);
+ }
+
+ passwordValue(key: string): Locator {
+ return this.propertyCell(key).locator('input[type="password"]');
+ }
+
+ editableDependencyRow(index: number): Locator {
+ return this.dependencies
+ .getByRole('row')
+ .filter({
+ has: this.page.getByRole('button', { name: 'Remove dependency', exact:
true })
+ })
+ .nth(index);
+ }
+
+ dependencyRow(artifact: string): Locator {
+ return this.dependencies
+ .getByRole('row')
+ .filter({ has: this.page.getByRole('cell', { name: artifact, exact: true
}) });
+ }
+
+ async selectGroup(name: string): Promise<void> {
+ await this.group.click();
+ await this.page.locator('.ant-select-dropdown:visible').getByText(name, {
exact: true }).click();
+ }
+
+ async selectPropertyType(name: string): Promise<void> {
+ await this.propertyType.click();
+ await this.page.locator('.ant-select-dropdown:visible').getByText(name, {
exact: true }).click();
+ }
+}
diff --git a/zeppelin-web-angular/e2e/models/interpreter-repository-modal.ts
b/zeppelin-web-angular/e2e/models/interpreter-repository-modal.ts
index c4fc0d1782..9c635fd628 100644
--- a/zeppelin-web-angular/e2e/models/interpreter-repository-modal.ts
+++ b/zeppelin-web-angular/e2e/models/interpreter-repository-modal.ts
@@ -23,23 +23,44 @@ export interface RepositoryProxyCredentials {
export class InterpreterRepositoryModal extends BasePage {
readonly repositoryTrigger: Locator;
readonly createRepositoryTag: Locator;
+ readonly dialog: Locator;
readonly idInput: Locator;
readonly urlInput: Locator;
+ readonly urlProtocol: Locator;
+ readonly snapshotSwitch: Locator;
+ readonly usernameInput: Locator;
+ readonly passwordInput: Locator;
+ readonly proxyHttpRadio: Locator;
+ readonly proxyHttpsRadio: Locator;
+ readonly proxyHostInput: Locator;
+ readonly proxyPortInput: Locator;
readonly proxyLoginInput: Locator;
readonly proxyPasswordInput: Locator;
readonly addButton: Locator;
+ readonly dismissButton: Locator;
constructor(page: Page) {
super(page);
- this.repositoryTrigger = page.locator('button.repository-trigger');
- this.createRepositoryTag = page.locator('nz-tag.editable-tag');
- this.idInput = page.locator('input[placeholder="Repository id"]');
- this.urlInput = page.locator('input[placeholder="Repository url"]');
- // Locate the proxy inputs by placeholder, not formControlName: the
binding is
- // what the regression test exercises, so the locator must not depend on
it.
- this.proxyLoginInput = page.locator('input[placeholder="proxy login"]');
- this.proxyPasswordInput = page.locator('input[placeholder="proxy
password"]');
- this.addButton = page.getByRole('button', { name: 'Add', exact: true });
+ this.repositoryTrigger = page.getByRole('button', { name: 'Repository',
exact: true });
+ this.createRepositoryTag =
page.getByTestId('create-interpreter-repository');
+ this.dialog = page.getByRole('dialog').filter({
+ has: page.getByText('Add New Repository', { exact: true })
+ });
+ this.idInput = this.dialog.getByPlaceholder('Repository id', { exact: true
});
+ this.urlInput = this.dialog.getByPlaceholder('Repository url', { exact:
true });
+ this.urlProtocol = this.dialog.getByTestId('repository-url-protocol');
+ this.snapshotSwitch = this.dialog.locator('.ant-switch');
+ this.usernameInput = this.dialog.getByPlaceholder('username', { exact:
true });
+ this.passwordInput = this.dialog.getByPlaceholder('password', { exact:
true });
+ this.proxyHttpRadio = this.dialog.getByRole('radio', { name: 'HTTP',
exact: true });
+ this.proxyHttpsRadio = this.dialog.getByRole('radio', { name: 'HTTPS',
exact: true });
+ this.proxyHostInput = this.dialog.getByPlaceholder('proxy host', { exact:
true });
+ this.proxyPortInput = this.dialog.getByPlaceholder('proxy port', { exact:
true });
+ // The proxy binding is under test, so locate it by its user-facing
placeholder.
+ this.proxyLoginInput = this.dialog.getByPlaceholder('proxy login', {
exact: true });
+ this.proxyPasswordInput = this.dialog.getByPlaceholder('proxy password', {
exact: true });
+ this.addButton = this.dialog.getByRole('button', { name: 'Add', exact:
true });
+ this.dismissButton = this.dialog.getByRole('button', { name: 'Cancel',
exact: true });
}
async navigate(): Promise<void> {
@@ -52,6 +73,15 @@ export class InterpreterRepositoryModal extends BasePage {
await this.idInput.waitFor({ state: 'visible' });
}
+ async selectUrlProtocol(protocol: 'http://' | 'https://' | 'file://'):
Promise<void> {
+ await this.urlProtocol.click();
+ await
this.page.locator('.ant-select-dropdown:visible').getByText(protocol, { exact:
true }).click();
+ }
+
+ async selectProxyProtocol(protocol: 'HTTP' | 'HTTPS'): Promise<void> {
+ await this.dialog.getByText(protocol, { exact: true }).click();
+ }
+
async fillProxyRepository(form: RepositoryProxyCredentials): Promise<void> {
await this.fillAndVerifyInput(this.idInput, form.id);
await this.fillAndVerifyInput(this.urlInput, form.url);
diff --git a/zeppelin-web-angular/e2e/models/interpreter-repository-page.ts
b/zeppelin-web-angular/e2e/models/interpreter-repository-page.ts
new file mode 100644
index 0000000000..d89c71af17
--- /dev/null
+++ b/zeppelin-web-angular/e2e/models/interpreter-repository-page.ts
@@ -0,0 +1,51 @@
+/*
+ * 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';
+
+export class InterpreterRepositoryPage {
+ readonly repositoryTrigger: Locator;
+ readonly panel: Locator;
+ readonly heading: Locator;
+ readonly repositoryTags: Locator;
+ readonly createRepositoryTag: Locator;
+ readonly closeIcons: Locator;
+ readonly tooltip: Locator;
+ readonly deleteDialog: Locator;
+ readonly deleteMessage: Locator;
+ readonly confirmDeleteButton: Locator;
+ readonly cancelDeleteButton: Locator;
+
+ constructor(private readonly page: Page) {
+ this.repositoryTrigger = page.getByRole('button', { name: 'Repository',
exact: true });
+ this.panel = page.getByTestId('interpreter-repositories');
+ this.heading = this.panel.getByRole('heading', { name: 'Repositories',
exact: true });
+ this.repositoryTags = this.panel.getByTestId('interpreter-repository');
+ this.createRepositoryTag =
this.panel.getByTestId('create-interpreter-repository');
+ this.closeIcons = page.locator('.anticon-close');
+ this.tooltip = page.locator('.ant-tooltip-inner:visible');
+ this.deleteDialog = page.getByRole('dialog').filter({
+ has: page.getByText('Do you want to delete this repository?', { exact:
true })
+ });
+ this.deleteMessage = this.deleteDialog.getByText('Do you want to delete
this repository?', { exact: true });
+ this.confirmDeleteButton = this.deleteDialog.getByRole('button', { name:
'OK', exact: true });
+ this.cancelDeleteButton = this.deleteDialog.getByRole('button', { name:
'Cancel', exact: true });
+ }
+
+ repositoryTag(id: string): Locator {
+ return this.repositoryTags.and(this.page.getByText(id, { exact: true }));
+ }
+
+ repositoryCloseIcon(id: string): Locator {
+ return this.repositoryTag(id).locator(this.closeIcons);
+ }
+}
diff --git a/zeppelin-web-angular/e2e/models/interpreter-test.util.ts
b/zeppelin-web-angular/e2e/models/interpreter-test.util.ts
new file mode 100644
index 0000000000..0442fada52
--- /dev/null
+++ b/zeppelin-web-angular/e2e/models/interpreter-test.util.ts
@@ -0,0 +1,117 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { expect, Page, Response, test as base } from '@playwright/test';
+import type { Interpreter, InterpreterMap, InterpreterRepository } from
'../../src/app/interfaces/interpreter';
+import { waitForZeppelinReady } from '../utils';
+
+export interface InterpreterPageData {
+ settings: Interpreter[];
+ availableInterpreters: InterpreterMap;
+ repositories: Array<Pick<InterpreterRepository, 'id' | 'url'>>;
+}
+
+interface InterpreterWrites {
+ requests: Array<{ method: string; path: string; body: unknown }>;
+ stub(method: 'POST' | 'PUT' | 'DELETE', path: string, responseBody:
unknown): Promise<void>;
+}
+
+const readBody = async <T>(response: Response): Promise<T> => {
+ expect(response.ok(), `${response.request().method()} ${response.url()}
returned ${response.status()}`).toBe(true);
+ const payload: { status: string; body: T } = await response.json();
+ expect(payload.status).toBe('OK');
+ return payload.body;
+};
+
+export const openInterpreterPage = async (
+ page: Page,
+ navigation: 'direct' | 'reload' = 'direct'
+): Promise<InterpreterPageData> => {
+ const responseFor = (path: string): Promise<Response> =>
+ page.waitForResponse(
+ response => response.request().method() === 'GET' && new
URL(response.url()).pathname === path
+ );
+ const settingsResponse = responseFor('/api/interpreter/setting');
+ const availableResponse = responseFor('/api/interpreter');
+ const repositoriesResponse = responseFor('/api/interpreter/repository');
+ if (navigation === 'reload') {
+ await page.reload();
+ } else {
+ await page.goto('/#/interpreter');
+ }
+ await waitForZeppelinReady(page);
+ const [settings, availableInterpreters, repositories] = await Promise.all([
+ settingsResponse.then(response => readBody<Interpreter[]>(response)),
+ availableResponse.then(response => readBody<InterpreterMap>(response)),
+ repositoriesResponse.then(response =>
readBody<InterpreterPageData['repositories']>(response))
+ ]);
+ expect(settings, 'The E2E server must provide at least one interpreter
setting').not.toEqual([]);
+ return { settings, availableInterpreters, repositories };
+};
+
+export const interpreterTitlePattern = (setting: Interpreter): RegExp => {
+ const escape = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g,
'\\$&');
+ const aliases = setting.interpreterGroup.map((group, index) =>
+ escape(index === 0 ? `%${setting.name}` : `%${setting.name}.${group.name}`)
+ );
+ return new
RegExp(`^\\s*${escape(setting.name)}\\s+${aliases.join('\\s*,\\s*')}\\s*$`);
+};
+
+export const stubInterpreterRead = async (page: Page, path: string, body:
unknown): Promise<void> => {
+ await page.route(`**${path}`, async route => {
+ if (route.request().method() === 'GET') {
+ await route.fulfill({ json: { status: 'OK', message: '', body } });
+ } else {
+ await route.fallback();
+ }
+ });
+};
+
+export const test = base.extend<{ interpreterWrites: InterpreterWrites }>({
+ interpreterWrites: [
+ async ({ page }, use) => {
+ const requests: InterpreterWrites['requests'] = [];
+ const unexpectedWrites: string[] = [];
+ await page.route(/\/api\/interpreter(?:\/|$)/, async route => {
+ const request = route.request();
+ if (request.method() === 'GET') {
+ await route.fallback();
+ } else {
+ unexpectedWrites.push(`${request.method()} ${new
URL(request.url()).pathname}`);
+ await route.abort();
+ }
+ });
+ await use({
+ requests,
+ stub: async (method, path, responseBody) => {
+ await page.route(`**${path}`, async route => {
+ const request = route.request();
+ if (request.method() !== method) {
+ await route.fallback();
+ return;
+ }
+ requests.push({
+ method: request.method(),
+ path: new URL(request.url()).pathname,
+ body: request.postDataJSON()
+ });
+ await route.fulfill({ json: { status: 'OK', message: '', body:
responseBody } });
+ });
+ }
+ });
+ expect(unexpectedWrites, 'Only explicitly stubbed interpreter writes are
allowed').toEqual([]);
+ },
+ { auto: true }
+ ]
+});
+
+export { expect };
diff --git
a/zeppelin-web-angular/e2e/tests/workspace/interpreter/create-repository-proxy-credentials.spec.ts
b/zeppelin-web-angular/e2e/tests/workspace/interpreter/create-repository-proxy-credentials.spec.ts
index ec156dac60..a63c7fe064 100644
---
a/zeppelin-web-angular/e2e/tests/workspace/interpreter/create-repository-proxy-credentials.spec.ts
+++
b/zeppelin-web-angular/e2e/tests/workspace/interpreter/create-repository-proxy-credentials.spec.ts
@@ -10,9 +10,9 @@
* limitations under the License.
*/
-import { expect, test } from '@playwright/test';
+import { expect, openInterpreterPage, test } from
'../../../models/interpreter-test.util';
import { InterpreterRepositoryModal } from
'../../../models/interpreter-repository-modal';
-import { addPageAnnotationBeforeEach, waitForZeppelinReady, PAGES } from
'../../../utils';
+import { addPageAnnotationBeforeEach, PAGES } from '../../../utils';
interface AddRepositoryRequestBody {
id: string;
@@ -23,28 +23,13 @@ interface AddRepositoryRequestBody {
test.describe('Interpreter Repository - Proxy Credentials', () => {
addPageAnnotationBeforeEach(PAGES.WORKSPACE.INTERPRETER_CREATE_REPO);
- test('submits the proxy login and proxy password to their own fields', async
({ page }) => {
+ test('submits the proxy login and proxy password to their own fields', async
({ page, interpreterWrites }) => {
const proxyLogin = 'proxy-user';
const proxyPassword = 'proxy-secret';
const repoId = `e2e-proxy-repo-${Date.now()}`;
- await page.goto('/#/interpreter');
- await waitForZeppelinReady(page);
-
- // Intercept the POST so the payload can be checked without persisting a
repo.
- let requestBody: AddRepositoryRequestBody | null = null;
- await page.route('**/api/interpreter/repository', async route => {
- if (route.request().method() === 'POST') {
- requestBody = route.request().postDataJSON() as
AddRepositoryRequestBody;
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({ status: 'OK', message: '', body: '' })
- });
- return;
- }
- await route.continue();
- });
+ await openInterpreterPage(page);
+ await interpreterWrites.stub('POST', '/api/interpreter/repository', '');
const modal = new InterpreterRepositoryModal(page);
await modal.openCreateModal();
@@ -56,11 +41,12 @@ test.describe('Interpreter Repository - Proxy Credentials',
() => {
});
await modal.submit();
- await expect.poll(() => requestBody, { timeout: 15000 }).not.toBeNull();
+ await expect.poll(() => interpreterWrites.requests.length).toBe(1);
+ const requestBody = interpreterWrites.requests[0].body as
AddRepositoryRequestBody;
// Regression guard for ZEPPELIN-6520: Password was bound to proxyLogin,
so the
// password overwrote the login and proxyPassword was always empty.
- expect(requestBody!.proxyLogin).toBe(proxyLogin);
- expect(requestBody!.proxyPassword).toBe(proxyPassword);
+ expect(requestBody.proxyLogin).toBe(proxyLogin);
+ expect(requestBody.proxyPassword).toBe(proxyPassword);
});
});
diff --git
a/zeppelin-web-angular/e2e/tests/workspace/interpreter/interpreter-page-structure.spec.ts
b/zeppelin-web-angular/e2e/tests/workspace/interpreter/interpreter-page-structure.spec.ts
new file mode 100644
index 0000000000..db50f87aab
--- /dev/null
+++
b/zeppelin-web-angular/e2e/tests/workspace/interpreter/interpreter-page-structure.spec.ts
@@ -0,0 +1,82 @@
+/*
+ * 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, interpreterTitlePattern, openInterpreterPage, test } from
'../../../models/interpreter-test.util';
+import { InterpreterPage } from '../../../models/interpreter-page';
+import { addPageAnnotationBeforeEach, PAGES } from '../../../utils';
+
+test.describe('Interpreter Page - Real Route Baseline', () => {
+ addPageAnnotationBeforeEach(PAGES.WORKSPACE.INTERPRETER);
+
+ let interpreterPage: InterpreterPage;
+
+ test.beforeEach(async ({ page }) => {
+ interpreterPage = new InterpreterPage(page);
+ });
+
+ test('loads server settings and primary controls on direct navigation and
reload', async ({ page }) => {
+ for (const navigation of ['direct', 'reload'] as const) {
+ await test.step(`When opening the interpreter route via ${navigation}`,
async () => {
+ const data = await openInterpreterPage(page, navigation);
+ await expect(page).toHaveURL(/\/#\/interpreter$/);
+ await expect(interpreterPage.title).toBeVisible();
+ await expect(interpreterPage.description).toBeVisible();
+ await expect(interpreterPage.search).toBeVisible();
+ await expect(interpreterPage.repositoryButton).toBeEnabled();
+ await expect(interpreterPage.createCard).toBeVisible();
+ await
expect(interpreterPage.settings).toHaveCount(data.settings.length);
+ await
expect(interpreterPage.settingTitles).toHaveText(data.settings.map(interpreterTitlePattern));
+ for (const setting of data.settings) {
+ const item = interpreterPage.setting(setting.name);
+ await expect(item.edit).toBeEnabled();
+ await expect(item.restart).toBeEnabled();
+ await expect(item.remove).toBeEnabled();
+ await expect(item.existingProcess).toBeDisabled();
+ await expect(item.setPermission).toBeDisabled();
+ await expect(item.bindingMode).toHaveAttribute('disabled', '');
+ await expect(item.save).toHaveCount(0);
+ }
+ });
+ }
+ });
+
+ test('filters actual server settings case-insensitively and restores them on
clear', async ({ page }) => {
+ const { settings } = await openInterpreterPage(page);
+ const searchable = settings.find(setting => /[a-z]/i.test(setting.name));
+ if (!searchable) {
+ throw new Error('The E2E server must provide a setting name with letters
to verify case-insensitive search');
+ }
+ const character = searchable.name.match(/[a-z]/i)![0];
+ const query = character === character.toUpperCase() ?
character.toLowerCase() : character.toUpperCase();
+ const matches = settings.filter(setting =>
setting.name.toLowerCase().includes(query.toLowerCase()));
+ let missingName = 'e2e-no-matching-interpreter';
+ while (settings.some(setting =>
setting.name.toLowerCase().includes(missingName))) {
+ missingName += '-';
+ }
+
+ await test.step('When part of a returned name is searched with different
casing', async () => {
+ await interpreterPage.search.fill(query);
+ await
expect(interpreterPage.settingTitles).toHaveText(matches.map(interpreterTitlePattern));
+ });
+
+ await test.step('Then an absent name hides settings without hiding
Create', async () => {
+ await interpreterPage.search.fill(missingName);
+ await expect(interpreterPage.settings).toHaveCount(0);
+ await expect(interpreterPage.createCard).toBeVisible();
+ });
+
+ await test.step('Then clearing the search restores all server settings',
async () => {
+ await interpreterPage.search.clear();
+ await
expect(interpreterPage.settingTitles).toHaveText(settings.map(interpreterTitlePattern));
+ });
+ });
+});
diff --git
a/zeppelin-web-angular/e2e/tests/workspace/interpreter/interpreter-repositories.spec.ts
b/zeppelin-web-angular/e2e/tests/workspace/interpreter/interpreter-repositories.spec.ts
new file mode 100644
index 0000000000..7a895342ad
--- /dev/null
+++
b/zeppelin-web-angular/e2e/tests/workspace/interpreter/interpreter-repositories.spec.ts
@@ -0,0 +1,397 @@
+/*
+ * 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, openInterpreterPage, stubInterpreterRead, test } from
'../../../models/interpreter-test.util';
+import { InterpreterRepositoryModal } from
'../../../models/interpreter-repository-modal';
+import { InterpreterRepositoryPage } from
'../../../models/interpreter-repository-page';
+import { addPageAnnotationBeforeEach, PAGES } from '../../../utils';
+
+type Repository = { id: string; url: string };
+
+const unusedRepositoryId = (repositories: Repository[], name: string): string
=> {
+ let id = name;
+ while (repositories.some(repository => repository.id === id)) {
+ id += '-unused';
+ }
+ return id;
+};
+
+const removableRepositories = (repositories: Repository[]) => {
+ const target = {
+ id: unusedRepositoryId(repositories, 'e2e-delete-target'),
+ url: 'https://delete-target.example.test/maven/'
+ };
+ const retained = {
+ id: unusedRepositoryId([...repositories, target], 'e2e-delete-retained'),
+ url: 'https://delete-retained.example.test/maven/'
+ };
+ return { target, repositories: [...repositories, target, retained] };
+};
+
+const submissionCases = [
+ {
+ name: 'HTTP with snapshots disabled',
+ protocol: 'http://' as const,
+ id: 'http-repository',
+ url: 'http.example.test/maven/',
+ expectedUrl: 'http://http.example.test/maven/',
+ snapshot: false,
+ username: '',
+ password: '',
+ proxyProtocol: 'HTTP',
+ proxyHost: '',
+ proxyPort: null
+ },
+ {
+ name: 'HTTPS with snapshots, repository credentials and a proxy',
+ protocol: 'https://' as const,
+ id: 'https-repository',
+ url: 'secure.example.test/maven/',
+ expectedUrl: 'https://secure.example.test/maven/',
+ snapshot: true,
+ username: 'repository-user',
+ password: 'repository-secret',
+ proxyProtocol: 'HTTPS',
+ proxyHost: 'proxy.example.test',
+ proxyPort: '8080'
+ },
+ {
+ name: 'a file URL',
+ protocol: 'file://' as const,
+ id: 'file-repository',
+ url: '/tmp/zeppelin-file-repository/',
+ expectedUrl: 'file:///tmp/zeppelin-file-repository/',
+ snapshot: false,
+ username: '',
+ password: '',
+ proxyProtocol: 'HTTP',
+ proxyHost: '',
+ proxyPort: null
+ }
+];
+
+test.describe('Interpreter repositories', () => {
+ addPageAnnotationBeforeEach(PAGES.WORKSPACE.INTERPRETER);
+ addPageAnnotationBeforeEach(PAGES.WORKSPACE.INTERPRETER_CREATE_REPO);
+
+ let repositoryPage: InterpreterRepositoryPage;
+ let modal: InterpreterRepositoryModal;
+ let repositories: Repository[];
+
+ test.beforeEach(async ({ page }) => {
+ repositoryPage = new InterpreterRepositoryPage(page);
+ modal = new InterpreterRepositoryModal(page);
+
+ await test.step('Given the interpreter page loads the real server
repository list', async () => {
+ ({ repositories } = await openInterpreterPage(page));
+ await expect(repositoryPage.repositoryTrigger).toBeVisible();
+ });
+ });
+
+ test('opens and closes the repository panel with protected defaults and
server repository tooltips', async ({
+ interpreterWrites
+ }) => {
+ await test.step('Given the repository panel is initially closed', async ()
=> {
+ await expect(repositoryPage.panel).toBeHidden();
+ });
+
+ await test.step('When the Repository button is selected', async () => {
+ await repositoryPage.repositoryTrigger.click();
+ });
+
+ await test.step('Then all server repositories are shown and defaults have
no remove control', async () => {
+ await expect(repositoryPage.heading).toBeVisible();
+ await
expect(repositoryPage.repositoryTags).toHaveText(repositories.map(repository =>
repository.id));
+ await expect(repositoryPage.repositoryTag('central')).toBeVisible();
+ await expect(repositoryPage.repositoryTag('local')).toBeVisible();
+ await
expect(repositoryPage.repositoryCloseIcon('central')).toHaveCount(0);
+ await expect(repositoryPage.repositoryCloseIcon('local')).toHaveCount(0);
+ await expect(repositoryPage.createRepositoryTag).toBeVisible();
+ });
+
+ await test.step('Then each repository tooltip shows its URL', async () => {
+ await expect(repositoryPage.panel).toHaveCSS('opacity', '1');
+ for (const repository of repositories) {
+ await repositoryPage.repositoryTag(repository.id).hover();
+ await expect(repositoryPage.tooltip).toHaveText(repository.url);
+ }
+ });
+
+ await test.step('When the Repository button is selected again', async ()
=> {
+ await repositoryPage.repositoryTrigger.click();
+ });
+
+ await test.step('Then the panel closes without changing repositories',
async () => {
+ await expect(repositoryPage.panel).toBeHidden();
+ expect(interpreterWrites.requests).toEqual([]);
+ });
+ });
+
+ test('cancels deleting a custom repository without sending a mutation',
async ({ page, interpreterWrites }) => {
+ const deletion = removableRepositories(repositories);
+ await stubInterpreterRead(page, '/api/interpreter/repository',
deletion.repositories);
+ await openInterpreterPage(page, 'reload');
+
+ await test.step('Given the repository panel is open', async () => {
+ await repositoryPage.repositoryTrigger.click();
+ await
expect(repositoryPage.repositoryTags).toHaveText(deletion.repositories.map(repository
=> repository.id));
+ await expect(repositoryPage.panel).toHaveCSS('opacity', '1');
+ });
+
+ await test.step('When the custom repository remove control is selected',
async () => {
+ await repositoryPage.repositoryCloseIcon(deletion.target.id).click();
+ });
+
+ await test.step('Then confirmation identifies the repository before any
mutation', async () => {
+ await expect(repositoryPage.deleteDialog).toBeVisible();
+ await
expect(repositoryPage.deleteDialog).toContainText(deletion.target.id);
+ await expect(repositoryPage.deleteMessage).toBeVisible();
+ expect(interpreterWrites.requests).toEqual([]);
+ });
+
+ await test.step('When deletion is cancelled', async () => {
+ await repositoryPage.cancelDeleteButton.click();
+ });
+
+ await test.step('Then every repository remains and no mutation was sent',
async () => {
+ await expect(repositoryPage.deleteDialog).toBeHidden();
+ await
expect(repositoryPage.repositoryTags).toHaveText(deletion.repositories.map(repository
=> repository.id));
+ expect(interpreterWrites.requests).toEqual([]);
+ });
+ });
+
+ test('confirms deletion with the repository ID and removes only the selected
tag', async ({
+ page,
+ interpreterWrites
+ }) => {
+ const deletion = removableRepositories(repositories);
+ await stubInterpreterRead(page, '/api/interpreter/repository',
deletion.repositories);
+ await openInterpreterPage(page, 'reload');
+
+ await test.step('Given deletion is permitted only for the selected
repository', async () => {
+ await interpreterWrites.stub('DELETE',
`/api/interpreter/repository/${deletion.target.id}`, '');
+ await repositoryPage.repositoryTrigger.click();
+ await
expect(repositoryPage.repositoryTags).toHaveText(deletion.repositories.map(repository
=> repository.id));
+ await expect(repositoryPage.panel).toHaveCSS('opacity', '1');
+ });
+
+ await test.step('When deletion of the custom repository is confirmed',
async () => {
+ await repositoryPage.repositoryCloseIcon(deletion.target.id).click();
+ await expect(repositoryPage.deleteMessage).toBeVisible();
+ await
expect(repositoryPage.deleteDialog).toContainText(deletion.target.id);
+ expect(interpreterWrites.requests).toEqual([]);
+ await repositoryPage.confirmDeleteButton.click();
+ });
+
+ await test.step('Then exactly that repository is deleted and the other
tags remain', async () => {
+ await expect(repositoryPage.deleteDialog).toBeHidden();
+ await
expect(repositoryPage.repositoryTag(deletion.target.id)).toHaveCount(0);
+ await expect(repositoryPage.repositoryTags).toHaveText(
+ deletion.repositories
+ .filter(repository => repository.id !== deletion.target.id)
+ .map(repository => repository.id)
+ );
+ expect(interpreterWrites.requests).toHaveLength(1);
+ expect(interpreterWrites.requests[0]).toMatchObject({
+ method: 'DELETE',
+ path: `/api/interpreter/repository/${deletion.target.id}`
+ });
+ });
+ });
+
+ test('requires both repository ID and URL before enabling Add', async ({
interpreterWrites }) => {
+ await test.step('When the add repository modal is opened', async () => {
+ await modal.openCreateModal();
+ });
+
+ await test.step('Then the empty required fields disable Add and defaults
are visible', async () => {
+ await expect(modal.dialog).toBeVisible();
+ await expect(modal.idInput).toHaveValue('');
+ await expect(modal.urlInput).toHaveValue('');
+ await expect(modal.urlProtocol).toHaveText('http://');
+ await expect(modal.snapshotSwitch).not.toHaveClass(/ant-switch-checked/);
+ await expect(modal.proxyHttpRadio).toBeChecked();
+ await expect(modal.proxyLoginInput).toBeVisible();
+ await expect(modal.proxyPasswordInput).toHaveAttribute('type',
'password');
+ await expect(modal.addButton).toBeDisabled();
+ });
+
+ await test.step('When only the ID is supplied', async () => {
+ await modal.idInput.fill(unusedRepositoryId(repositories,
'required-fields-repository'));
+ });
+
+ await test.step('Then Add remains disabled until a URL is also supplied',
async () => {
+ await expect(modal.addButton).toBeDisabled();
+ await modal.urlInput.fill('required.example.test/maven/');
+ await expect(modal.addButton).toBeEnabled();
+ });
+
+ await test.step('When the ID is removed while the URL remains', async ()
=> {
+ await modal.idInput.fill('');
+ });
+
+ await test.step('Then a URL alone cannot enable Add or send a mutation',
async () => {
+ await expect(modal.addButton).toBeDisabled();
+ expect(interpreterWrites.requests).toEqual([]);
+ });
+ });
+
+ test('rejects an out-of-range proxy port and accepts port 8080', async ({
interpreterWrites }) => {
+ await test.step('Given the required repository fields are complete', async
() => {
+ await modal.openCreateModal();
+ await modal.idInput.fill(unusedRepositoryId(repositories,
'proxy-port-repository'));
+ await modal.urlInput.fill('port.example.test/maven/');
+ await expect(modal.addButton).toBeEnabled();
+ });
+
+ await test.step('When proxy port 99999 is entered', async () => {
+ await modal.proxyPortInput.fill('99999');
+ });
+
+ await test.step('Then Add is disabled without submitting', async () => {
+ await expect(modal.addButton).toBeDisabled();
+ expect(interpreterWrites.requests).toEqual([]);
+ });
+
+ await test.step('When the proxy port is corrected to 8080', async () => {
+ await modal.proxyPortInput.fill('8080');
+ });
+
+ await test.step('Then Add is enabled without sending a mutation', async ()
=> {
+ await expect(modal.addButton).toBeEnabled();
+ expect(interpreterWrites.requests).toEqual([]);
+ });
+ });
+
+ for (const submission of submissionCases) {
+ test(`submits ${submission.name} and renders the refreshed server
repository list`, async ({
+ page,
+ interpreterWrites
+ }) => {
+ const submittedId = unusedRepositoryId(repositories, submission.id);
+ const serverRepository = {
+ id: unusedRepositoryId(
+ [...repositories, { id: submittedId, url: submission.expectedUrl }],
+ 'server-only-result'
+ ),
+ url: `https://server.example.test/${submission.id}/`
+ };
+
+ await test.step('Given the add repository modal is open with the initial
repository list', async () => {
+ await modal.openCreateModal();
+ await
expect(repositoryPage.repositoryTags).toHaveText(repositories.map(repository =>
repository.id));
+ await interpreterWrites.stub('POST', '/api/interpreter/repository',
'');
+ });
+
+ await test.step('When the repository URL and optional settings are
entered', async () => {
+ await modal.idInput.fill(submittedId);
+ await expect(modal.idInput).toHaveValue(submittedId);
+ await modal.urlInput.fill(submission.url);
+ await expect(modal.idInput).toHaveValue(submittedId);
+ await modal.selectUrlProtocol(submission.protocol);
+ await expect(modal.urlProtocol).toHaveText(submission.protocol);
+ await
expect(modal.snapshotSwitch).not.toHaveClass(/ant-switch-checked/);
+ if (submission.snapshot) {
+ await modal.snapshotSwitch.click();
+ }
+ await expect(modal.snapshotSwitch).toHaveClass(
+ submission.snapshot ? /ant-switch-checked/ :
/^(?!.*ant-switch-checked)/
+ );
+ await modal.usernameInput.fill(submission.username);
+ await modal.passwordInput.fill(submission.password);
+ await expect(modal.passwordInput).toHaveAttribute('type', 'password');
+ if (submission.proxyProtocol === 'HTTPS') {
+ await modal.selectProxyProtocol('HTTPS');
+ }
+ await expect(modal.proxyHttpsRadio).toBeChecked({ checked:
submission.proxyProtocol === 'HTTPS' });
+ await expect(modal.proxyHttpRadio).toBeChecked({ checked:
submission.proxyProtocol === 'HTTP' });
+ await modal.proxyHostInput.fill(submission.proxyHost);
+ if (submission.proxyPort !== null) {
+ await modal.proxyPortInput.fill(submission.proxyPort);
+ }
+ await expect(modal.addButton).toBeEnabled();
+ });
+
+ await test.step('When Add succeeds and the next repository GET returns a
new server-only tag', async () => {
+ await stubInterpreterRead(page, '/api/interpreter/repository',
[...repositories, serverRepository]);
+ const refreshedRepositories = page.waitForResponse(
+ response =>
+ response.request().method() === 'GET' && new
URL(response.url()).pathname === '/api/interpreter/repository'
+ );
+ await modal.submit();
+ await refreshedRepositories;
+ });
+
+ await test.step('Then the independently expected payload is sent and the
refreshed response is rendered', async () => {
+ await expect(modal.dialog).toBeHidden();
+ expect(interpreterWrites.requests).toEqual([
+ {
+ method: 'POST',
+ path: '/api/interpreter/repository',
+ body: {
+ id: submittedId,
+ url: submission.expectedUrl,
+ snapshot: submission.snapshot,
+ username: submission.username,
+ password: submission.password,
+ proxyProtocol: submission.proxyProtocol,
+ proxyHost: submission.proxyHost,
+ proxyPort: submission.proxyPort,
+ proxyLogin: '',
+ proxyPassword: ''
+ }
+ }
+ ]);
+ await expect(repositoryPage.repositoryTags).toHaveText([
+ ...repositories.map(repository => repository.id),
+ serverRepository.id
+ ]);
+ await expect(repositoryPage.repositoryTag(submittedId)).toHaveCount(0);
+ await expect(repositoryPage.panel).toHaveCSS('opacity', '1');
+ await repositoryPage.repositoryTag(serverRepository.id).hover();
+ await expect(repositoryPage.tooltip).toHaveText(serverRepository.url);
+ });
+ });
+ }
+
+ test('cancels a completed add form without changing repositories or sending
a mutation', async ({
+ interpreterWrites
+ }) => {
+ const cancelledId = unusedRepositoryId(repositories,
'cancelled-repository');
+
+ await test.step('Given a valid add repository form has unsaved changes',
async () => {
+ await modal.openCreateModal();
+ await
expect(repositoryPage.repositoryTags).toHaveText(repositories.map(repository =>
repository.id));
+ await modal.idInput.fill(cancelledId);
+ await modal.urlInput.fill('cancelled.example.test/maven/');
+ await modal.selectUrlProtocol('https://');
+ await modal.snapshotSwitch.click();
+ await modal.usernameInput.fill('unsaved-user');
+ await modal.passwordInput.fill('unsaved-password');
+ await modal.selectProxyProtocol('HTTPS');
+ await modal.proxyHostInput.fill('unsaved-proxy.example.test');
+ await modal.proxyPortInput.fill('8080');
+ await expect(modal.addButton).toBeEnabled();
+ });
+
+ await test.step('When Cancel is selected', async () => {
+ await modal.dismissButton.click();
+ });
+
+ await test.step('Then the modal closes and the original repositories
remain without a mutation', async () => {
+ await expect(modal.dialog).toBeHidden();
+ await
expect(repositoryPage.repositoryTags).toHaveText(repositories.map(repository =>
repository.id));
+ await expect(repositoryPage.repositoryTag(cancelledId)).toHaveCount(0);
+ expect(interpreterWrites.requests).toEqual([]);
+ });
+ });
+});
diff --git
a/zeppelin-web-angular/e2e/tests/workspace/interpreter/interpreter-settings.spec.ts
b/zeppelin-web-angular/e2e/tests/workspace/interpreter/interpreter-settings.spec.ts
new file mode 100644
index 0000000000..a9aa285e38
--- /dev/null
+++
b/zeppelin-web-angular/e2e/tests/workspace/interpreter/interpreter-settings.spec.ts
@@ -0,0 +1,563 @@
+/*
+ * 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 type { Interpreter, InterpreterPropertyValue } from
'../../../../src/app/interfaces/interpreter';
+import {
+ expect,
+ InterpreterPageData,
+ interpreterTitlePattern,
+ openInterpreterPage,
+ stubInterpreterRead,
+ test
+} from '../../../models/interpreter-test.util';
+import { InterpreterItemPage, InterpreterPage } from
'../../../models/interpreter-page';
+import { addPageAnnotationBeforeEach, PAGES } from '../../../utils';
+
+const unusedName = (names: string[], prefix: string): string => {
+ let name = prefix;
+ while (names.includes(name)) {
+ name += '-new';
+ }
+ return name;
+};
+
+const typedSetting = (source: Interpreter): Interpreter => ({
+ ...structuredClone(source),
+ id: `${source.name}-different-id`,
+ status: 'READY',
+ option: {
+ remote: true,
+ port: -1,
+ host: '',
+ owners: [],
+ perNote: 'shared',
+ perUser: 'shared',
+ isExistingProcess: false,
+ setPermission: false,
+ isUserImpersonate: false
+ },
+ properties: {
+ greeting: { name: 'greeting', value: 'hello from server', type: 'string' },
+ timeout: { name: 'timeout', value: '1000', type: 'number' },
+ enabled: { name: 'enabled', value: false, type: 'checkbox' },
+ secret: { name: 'secret', value: 'fixture-secret', type: 'password' },
+ website: { name: 'website', value: 'https://example.org/', type: 'url' },
+ script: { name: 'script', value: 'line one\nline two', type: 'textarea' }
+ },
+ dependencies: [
+ { groupArtifactVersion: 'org.example:original:1.0', exclusions:
['org.example:excluded'], local: false }
+ ]
+});
+
+const creationDefaults = (available: Interpreter[], group: string):
Record<string, InterpreterPropertyValue> => {
+ const properties: Record<string, InterpreterPropertyValue> = {};
+ for (const interpreter of available.filter(entry => entry.group === group)) {
+ for (const [name, property] of Object.entries(interpreter.properties)) {
+ const value = property.defaultValue ?? '';
+ properties[name] = {
+ name,
+ type: property.type,
+ value: property.type === 'checkbox' ? value === true || value ===
'true' : String(value)
+ };
+ }
+ }
+ return properties;
+};
+
+const creationGroup = (available: Interpreter[]): Interpreter => {
+ const group = available.find(entry => Object.keys(entry.properties).length >
0);
+ if (!group) {
+ throw new Error('The E2E server must provide an available interpreter
group with default properties');
+ }
+ return group;
+};
+
+test.describe('Interpreter Settings', () => {
+ addPageAnnotationBeforeEach(PAGES.WORKSPACE.INTERPRETER_ITEM);
+
+ let interpreterPage: InterpreterPage;
+ let item: InterpreterItemPage;
+ let serverData: InterpreterPageData;
+ let setting: Interpreter;
+
+ test.beforeEach(async ({ page }) => {
+ interpreterPage = new InterpreterPage(page);
+ serverData = await openInterpreterPage(page);
+ setting = serverData.settings[0];
+ item = interpreterPage.setting(setting.name);
+ await
expect(interpreterPage.settingTitles).toHaveText(serverData.settings.map(interpreterTitlePattern));
+ });
+
+ test('shows typed values, ready and error states when those settings are
returned', async ({ page }) => {
+ const ready = typedSetting(setting);
+ const failed = {
+ ...structuredClone(setting),
+ name: unusedName(
+ serverData.settings.map(entry => entry.name),
+ 'e2e-failed-setting'
+ ),
+ status: 'ERROR',
+ errorReason: 'Fixture dependency could not be downloaded'
+ };
+ await stubInterpreterRead(page, '/api/interpreter/setting', [ready,
...serverData.settings.slice(1), failed]);
+ await openInterpreterPage(page, 'reload');
+
+ await test.step('Then view mode renders each property type and disables
editing', async () => {
+ await expect(item.card.getByRole('heading', { name: 'Option', exact:
true })).toBeVisible();
+ await expect(item.card.getByRole('heading', { name: 'Properties', exact:
true })).toBeVisible();
+ await expect(item.card.getByRole('heading', { name: 'Dependencies',
exact: true })).toBeVisible();
+ await expect(item.propertyCell('greeting')).toHaveText('hello from
server');
+ await expect(item.propertyCell('timeout')).toHaveText('1000');
+ await expect(item.propertyCell('enabled')).toHaveText('false');
+ await expect(item.propertyCell('secret')).toHaveText('******');
+ await
expect(item.propertyCell('website').getByRole('link')).toHaveAttribute('href',
'https://example.org/');
+ await expect(item.propertyCell('script')).toHaveText('line one line
two');
+ await
expect(item.dependencyRow('org.example:original:1.0')).toContainText('org.example:excluded');
+ await expect(item.properties.getByText('Action', { exact: true
})).toHaveCount(0);
+ });
+
+ await test.step('Then the returned status and error reason are visible',
async () => {
+ await item.status.hover();
+ await expect(item.tooltip).toHaveText('Ready');
+ const errorItem = interpreterPage.setting(failed.name);
+ await errorItem.status.hover();
+ await expect(item.tooltip).toHaveText('Error downloading dependencies');
+ await expect(errorItem.card.getByText(failed.errorReason, { exact: true
})).toBeVisible();
+ });
+ });
+
+ test('validates a new name against actual settings and populates an
available group', async ({
+ interpreterWrites
+ }) => {
+ const available = Object.values(serverData.availableInterpreters);
+ const group = creationGroup(available);
+ const defaults = creationDefaults(available, group.group);
+ const form = interpreterPage.createForm();
+ const name = unusedName(
+ serverData.settings.map(entry => entry.name),
+ 'e2e-new-interpreter'
+ );
+
+ await test.step('When Create opens a blank form', async () => {
+ await interpreterPage.createCard.click();
+ await expect(form.card.getByRole('heading', { name: 'Create new
interpreter', exact: true })).toBeVisible();
+ await expect(interpreterPage.createCard).toHaveCount(0);
+ await expect(form.save).toBeDisabled();
+ await form.name.fill(name);
+ await expect(form.save).toBeDisabled();
+ });
+
+ await test.step('Then a real available group supplies property rows and
existing names are rejected', async () => {
+ await form.selectGroup(group.name);
+ await expect(form.card.getByRole('button', { name: 'Remove property',
exact: true })).toHaveCount(
+ Object.keys(defaults).length
+ );
+ for (const key of Object.keys(defaults)) {
+ await expect(form.propertyRow(key)).toBeVisible();
+ }
+ await expect(form.save).toBeEnabled();
+ await form.name.fill(setting.name);
+ await form.name.blur();
+ await expect(form.card.getByText(`Name '${setting.name}' already
exists`, { exact: true })).toBeVisible();
+ await expect(form.save).toBeDisabled();
+ await form.name.clear();
+ await expect(form.save).toBeDisabled();
+ });
+
+ await test.step('Then Cancel restores Create without changing the
server-backed list or writing', async () => {
+ await form.cancel.click();
+ await expect(form.card).toHaveCount(0);
+ await expect(interpreterPage.createCard).toBeVisible();
+ await
expect(interpreterPage.settingTitles).toHaveText(serverData.settings.map(interpreterTitlePattern));
+ expect(interpreterWrites.requests).toEqual([]);
+ });
+ });
+
+ test('submits real group defaults and renders the independently stubbed
creation response', async ({
+ interpreterWrites
+ }) => {
+ const available = Object.values(serverData.availableInterpreters);
+ const group = creationGroup(available);
+ const name = unusedName(
+ serverData.settings.map(entry => entry.name),
+ 'e2e-created-interpreter'
+ );
+ const created: Interpreter = {
+ ...structuredClone(setting),
+ id: 'created-server-id',
+ name,
+ group: group.group,
+ properties: {
+ 'e2e.server.response': { name: 'e2e.server.response', type: 'string',
value: 'from mutation response' }
+ }
+ };
+ await interpreterWrites.stub('POST', '/api/interpreter/setting', created);
+ const form = interpreterPage.createForm();
+
+ await test.step('When a real available group and an additional property
are submitted', async () => {
+ await interpreterPage.createCard.click();
+ await form.name.fill(name);
+ await form.selectGroup(group.name);
+ await form.propertyKey.fill('e2e.entered.value');
+ await form.propertyValue.fill('entered in browser');
+ await form.card.getByRole('button', { name: 'Add property', exact: true
}).click();
+ await form.save.click();
+ });
+
+ await test.step('Then the request contains the real defaults and the new
card uses the response', async () => {
+ await
expect(interpreterPage.setting(name).propertyCell('e2e.server.response')).toHaveText(
+ 'from mutation response'
+ );
+ await expect(form.card).toHaveCount(0);
+ await expect(interpreterPage.createCard).toBeVisible();
+ await
expect(interpreterPage.settings).toHaveCount(serverData.settings.length + 1);
+ expect(interpreterWrites.requests).toEqual([
+ {
+ method: 'POST',
+ path: '/api/interpreter/setting',
+ body: expect.objectContaining({
+ name,
+ group: group.group,
+ properties: {
+ ...creationDefaults(available, group.group),
+ 'e2e.entered.value': { name: 'e2e.entered.value', type:
'string', value: 'entered in browser' }
+ },
+ dependencies: []
+ })
+ }
+ ]);
+ });
+ });
+
+ test('Cancel restores every typed property on reopening', async ({ page,
interpreterWrites }) => {
+ const controlled = typedSetting(setting);
+ await stubInterpreterRead(page, '/api/interpreter/setting', [controlled,
...serverData.settings.slice(1)]);
+ await openInterpreterPage(page, 'reload');
+
+ await test.step('When typed values and process options are changed without
saving', async () => {
+ await item.edit.click();
+ await expect(item.properties.getByText('Action', { exact: true
})).toBeVisible();
+ await item.propertyCell('greeting').getByRole('textbox').fill('unsaved
greeting');
+ await item.propertyCell('timeout').getByRole('spinbutton').fill('1234');
+ await item.propertyCell('enabled').getByRole('checkbox').check();
+ await item.passwordValue('secret').fill('unsaved secret');
+ await
item.propertyCell('website').getByRole('textbox').fill('https://changed.example.org/');
+ await item.propertyCell('script').getByRole('textbox').fill('unsaved
script');
+ await item.existingProcess.check();
+ await item.host.fill('unsaved.example.org');
+ await item.cancel.click();
+ });
+
+ await test.step('Then view and reopened edit mode retain the returned
configuration', async () => {
+ await expect(item.edit).toBeVisible();
+ await expect(item.propertyCell('greeting')).toHaveText('hello from
server');
+ await item.edit.click();
+ await
expect(item.propertyCell('greeting').getByRole('textbox')).toHaveValue('hello
from server');
+ await
expect(item.propertyCell('timeout').getByRole('spinbutton')).toHaveValue('1000');
+ await
expect(item.propertyCell('enabled').getByRole('checkbox')).not.toBeChecked();
+ await expect(item.passwordValue('secret')).toHaveValue('fixture-secret');
+ await
expect(item.propertyCell('website').getByRole('textbox')).toHaveValue('https://example.org/');
+ await
expect(item.propertyCell('script').getByRole('textbox')).toHaveValue('line
one\nline two');
+ await expect(item.existingProcess).not.toBeChecked();
+ await expect(item.host).toHaveCount(0);
+ expect(interpreterWrites.requests).toEqual([]);
+ });
+ });
+
+ test('reveals and submits process and permission fields on a real setting',
async ({ interpreterWrites }) => {
+ const owner = unusedName(setting.option.owners ?? [], 'e2e-owner');
+ const path =
`/api/interpreter/setting/${encodeURIComponent(setting.name)}`;
+ await interpreterWrites.stub('PUT', path, setting);
+
+ await test.step('When options on a returned setting are toggled in edit
mode', async () => {
+ await item.edit.click();
+ await item.existingProcess.uncheck();
+ await item.setPermission.uncheck();
+ await expect(item.host).toHaveCount(0);
+ await expect(item.port).toHaveCount(0);
+ await expect(item.owners).toHaveCount(0);
+ await item.existingProcess.check();
+ await expect(item.host).toBeVisible();
+ await expect(item.port).toBeVisible();
+ await item.setPermission.check();
+ await expect(item.owners.getByRole('textbox')).toBeVisible();
+ await item.existingProcess.uncheck();
+ await item.setPermission.uncheck();
+ await expect(item.host).toHaveCount(0);
+ await expect(item.port).toHaveCount(0);
+ await expect(item.owners).toHaveCount(0);
+ });
+
+ await test.step('Then the entered host, valid port and owner are submitted
without persisting', async () => {
+ await item.existingProcess.check();
+ await item.host.fill('worker.example.org');
+ await item.port.fill('99999');
+ await expect(item.save).toBeDisabled();
+ await item.port.fill('8080');
+ await item.setPermission.check();
+ await item.owners.getByRole('textbox').fill(`${owner},`);
+ await expect(item.owners.getByText(owner, { exact: true
})).toBeVisible();
+ await expect(item.save).toBeEnabled();
+ await item.save.click();
+ await expect(item.edit).toBeVisible();
+ await expect
+ .poll(() => interpreterWrites.requests)
+ .toEqual([
+ {
+ method: 'PUT',
+ path,
+ body: expect.objectContaining({
+ option: expect.objectContaining({
+ isExistingProcess: true,
+ host: 'worker.example.org',
+ port: 8080,
+ setPermission: true,
+ owners: [...(setting.option.owners ?? []), owner]
+ })
+ })
+ }
+ ]);
+ });
+ });
+
+ test('adds, updates and removes properties and dependencies locally', async
({ page, interpreterWrites }) => {
+ const controlled: Interpreter = {
+ ...structuredClone(setting),
+ dependencies: [
+ { groupArtifactVersion: 'org.example:original:1.0', exclusions:
['org.example:excluded'], local: false }
+ ]
+ };
+ await stubInterpreterRead(page, '/api/interpreter/setting', [controlled,
...serverData.settings.slice(1)]);
+ await openInterpreterPage(page, 'reload');
+
+ await test.step('When a property and dependency are added in edit mode',
async () => {
+ await item.edit.click();
+ await item.propertyKey.fill('custom.property');
+ await item.propertyValue.fill('first value');
+ await item.card.getByRole('button', { name: 'Add property', exact: true
}).click();
+ await
expect(item.propertyCell('custom.property').getByRole('textbox')).toHaveValue('first
value');
+ await item.propertyKey.fill('custom.property');
+ await item.selectPropertyType('number');
+ await item.newProperty.getByRole('spinbutton').fill('42');
+ await item.card.getByRole('button', { name: 'Add property', exact: true
}).click();
+ await expect(item.propertyRow('custom.property')).toHaveCount(1);
+ await
expect(item.propertyCell('custom.property').getByRole('spinbutton')).toHaveValue('42');
+ await item.artifact.fill('org.example:added:2.0');
+ await item.exclusions.fill('org.example:omit');
+ await item.card.getByRole('button', { name: 'Add dependency', exact:
true }).click();
+ await
expect(item.editableDependencyRow(1).getByRole('textbox').nth(0)).toHaveValue('org.example:added:2.0');
+ await
expect(item.editableDependencyRow(1).getByRole('textbox').nth(1)).toHaveValue('org.example:omit');
+ });
+
+ await test.step('Then removal discards only added entries without a
write', async () => {
+ await item.propertyRow('custom.property').getByRole('button', { name:
'Remove property', exact: true }).click();
+ await expect(item.propertyRow('custom.property')).toHaveCount(0);
+ await item.editableDependencyRow(1).getByRole('button', { name: 'Remove
dependency', exact: true }).click();
+ await expect(item.dependencies.getByRole('textbox')).toHaveCount(4);
+ await item.cancel.click();
+ await
expect(item.dependencyRow('org.example:original:1.0')).toContainText('org.example:excluded');
+ expect(interpreterWrites.requests).toEqual([]);
+ });
+ });
+
+ test('updates by name rather than ID with typed properties and edited
dependencies', async ({
+ page,
+ interpreterWrites
+ }) => {
+ const controlled = typedSetting(setting);
+ await stubInterpreterRead(page, '/api/interpreter/setting', [controlled,
...serverData.settings.slice(1)]);
+ await openInterpreterPage(page, 'reload');
+ const updated = structuredClone(controlled);
+ updated.properties.greeting.value = 'submitted greeting';
+ const path =
`/api/interpreter/setting/${encodeURIComponent(controlled.name)}`;
+ await interpreterWrites.stub('PUT', path, updated);
+
+ await test.step('When changed values, an edited dependency and a pending
addition are saved', async () => {
+ await item.edit.click();
+ await item.propertyCell('greeting').getByRole('textbox').fill('submitted
greeting');
+ await item.propertyCell('timeout').getByRole('spinbutton').fill('60000');
+ await item.propertyCell('enabled').getByRole('checkbox').check();
+ await
item.editableDependencyRow(0).getByRole('textbox').nth(0).fill('org.example:edited:4.0');
+ await
item.editableDependencyRow(0).getByRole('textbox').nth(1).fill('org.example:edited-exclusion');
+ await item.artifact.fill('org.example:new:3.0');
+ await item.exclusions.fill(' org.example:first, , org.example:second ');
+ const response = page.waitForResponse(
+ result => new URL(result.url()).pathname === path &&
result.request().method() === 'PUT'
+ );
+ await item.save.click();
+ await (await response).finished();
+ });
+
+ await test.step('Then submitted values remain editable and the exact
request targets the name', async () => {
+ await expect(item.propertyCell('greeting')).toHaveText('submitted
greeting');
+ await item.edit.click();
+ await
expect(item.propertyCell('greeting').getByRole('textbox')).toHaveValue('submitted
greeting');
+ expect(interpreterWrites.requests).toEqual([
+ {
+ method: 'PUT',
+ path,
+ body: {
+ option: expect.objectContaining({ perNote: 'shared', perUser:
'shared', remote: true }),
+ properties: {
+ greeting: { name: 'greeting', type: 'string', value: 'submitted
greeting' },
+ timeout: { name: 'timeout', type: 'number', value: '60000' },
+ enabled: { name: 'enabled', type: 'checkbox', value: true },
+ secret: { name: 'secret', type: 'password', value:
'fixture-secret' },
+ website: { name: 'website', type: 'url', value:
'https://example.org/' },
+ script: { name: 'script', type: 'textarea', value: 'line
one\nline two' }
+ },
+ dependencies: [
+ { groupArtifactVersion: 'org.example:edited:4.0', exclusions:
['org.example:edited-exclusion'] },
+ { groupArtifactVersion: 'org.example:new:3.0', exclusions:
['org.example:first', 'org.example:second'] }
+ ]
+ }
+ }
+ ]);
+ });
+ });
+
+ for (const principal of ['anonymous', 'fixture-user']) {
+ test(`exposes and submits binding choices for an isolated ${principal}
ticket`, async ({
+ page,
+ interpreterWrites
+ }) => {
+ await page.route('**/api/security/ticket', route =>
+ route.fulfill({
+ json: {
+ status: 'OK',
+ message: '',
+ body: {
+ principal,
+ ticket: principal === 'anonymous' ? 'anonymous' :
'fixture-ticket',
+ roles: principal === 'anonymous' ? '[]' : '["users"]'
+ }
+ }
+ })
+ );
+ await openInterpreterPage(page, 'reload');
+ await item.edit.click();
+
+ await test.step('When binding choices are shown for the selected
identity', async () => {
+ await item.bindingMode.click();
+ await expect(item.bindingMenu.getByText('Globally', { exact: true
})).toBeVisible();
+ await expect(item.bindingMenu.getByText('Per Note', { exact: true
})).toBeVisible();
+ await expect(item.bindingMenu.getByText('Per User', { exact: true
})).toHaveCount(
+ principal === 'anonymous' ? 0 : 1
+ );
+ await item.bindingMenu.getByText('Per Note', { exact: true }).click();
+ await expect(item.bindingMode).toContainText('Per Note');
+ await item.card.getByText('scoped', { exact: true }).click();
+ await item.bindingMenu.getByText('isolated per note', { exact: true
}).click();
+ await expect(item.card.getByText('isolated', { exact: true
})).toBeVisible();
+ });
+
+ await test.step('Then per-user isolation controls follow the identity
capability', async () => {
+ if (principal !== 'anonymous') {
+ await item.bindingMode.click();
+ await item.bindingMenu.getByText('Per User', { exact: true
}).click();
+ await item.card.getByText('scoped', { exact: true }).click();
+ await item.bindingMenu.getByText('isolated per user', { exact: true
}).click();
+ }
+ await expect(item.card.getByRole('checkbox', { name: 'User
Impersonate', exact: true })).toHaveCount(
+ principal === 'anonymous' ? 0 : 1
+ );
+ await expect(item.card.getByRole('button', { name: 'Add per-note
process', exact: true })).toHaveCount(
+ principal === 'anonymous' ? 0 : 1
+ );
+ });
+
+ await test.step('Then isolation and optional impersonation are sent in
the intercepted request', async () => {
+ if (principal !== 'anonymous') {
+ await item.card.getByRole('checkbox', { name: 'User Impersonate',
exact: true }).check();
+ await item.card.getByRole('button', { name: 'Add per-note process',
exact: true }).click();
+ }
+ await expect(item.card.getByRole('button', { name: 'Remove per-note
process', exact: true })).toHaveCount(
+ principal === 'anonymous' ? 0 : 1
+ );
+ if (principal !== 'anonymous') {
+ await item.card.getByRole('button', { name: 'Remove per-note
process', exact: true }).click();
+ }
+ await expect(item.card.getByRole('button', { name: 'Add per-note
process', exact: true })).toHaveCount(
+ principal === 'anonymous' ? 0 : 1
+ );
+ const path =
`/api/interpreter/setting/${encodeURIComponent(setting.name)}`;
+ await interpreterWrites.stub('PUT', path, setting);
+ await item.save.click();
+ await expect(item.edit).toBeVisible();
+ await expect
+ .poll(() => interpreterWrites.requests)
+ .toEqual([
+ {
+ method: 'PUT',
+ path,
+ body: expect.objectContaining({
+ option: expect.objectContaining({
+ perNote: principal === 'anonymous' ? 'isolated' : 'shared',
+ perUser: principal === 'anonymous' ? '' : 'isolated',
+ isUserImpersonate: principal !== 'anonymous'
+ })
+ })
+ }
+ ]);
+ });
+ });
+ }
+
+ test('cancels and confirms restart of a real setting without executing it',
async ({ page, interpreterWrites }) => {
+ const dialog = page.getByRole('dialog');
+
+ await test.step('When Restart is cancelled, no request is dispatched',
async () => {
+ await item.restart.click();
+ await expect(dialog).toContainText('Restart Interpreter');
+ await expect(dialog).toContainText('Do you want to restart this
interpreter?');
+ await dialog.getByRole('button', { name: 'Cancel', exact: true
}).click();
+ await expect(dialog).toHaveCount(0);
+ expect(interpreterWrites.requests).toEqual([]);
+ });
+
+ await test.step('Then confirmation targets the returned name and shows the
acknowledgement', async () => {
+ const path =
`/api/interpreter/setting/restart/${encodeURIComponent(setting.name)}`;
+ await interpreterWrites.stub('PUT', path, null);
+ await item.restart.click();
+ await dialog.getByRole('button', { name: 'OK', exact: true }).click();
+ await expect(
+ page.getByText('Interpreter stopped. Will be lazily started on next
run.', { exact: true })
+ ).toBeVisible();
+ await
expect(interpreterPage.settingTitles).toHaveText(serverData.settings.map(interpreterTitlePattern));
+ expect(interpreterWrites.requests).toEqual([{ method: 'PUT', path, body:
null }]);
+ });
+ });
+
+ test('cancels and confirms removal of the selected real setting', async ({
page, interpreterWrites }) => {
+ const dialog = page.getByRole('dialog');
+
+ await test.step('When Remove is cancelled, the setting remains without a
request', async () => {
+ await item.remove.click();
+ await expect(dialog).toContainText('Remove Interpreter');
+ await expect(dialog).toContainText('Do you want to delete this
interpreter setting?');
+ await dialog.getByRole('button', { name: 'Cancel', exact: true
}).click();
+ await expect(dialog).toHaveCount(0);
+ await expect(item.card).toBeVisible();
+ expect(interpreterWrites.requests).toEqual([]);
+ });
+
+ await test.step('Then confirmation removes only that card using an
intercepted DELETE', async () => {
+ const path =
`/api/interpreter/setting/${encodeURIComponent(setting.name)}`;
+ await interpreterWrites.stub('DELETE', path, null);
+ await item.remove.click();
+ await dialog.getByRole('button', { name: 'OK', exact: true }).click();
+ await expect(item.card).toHaveCount(0);
+ await
expect(interpreterPage.settingTitles).toHaveText(serverData.settings.slice(1).map(interpreterTitlePattern));
+ expect(interpreterWrites.requests).toEqual([{ method: 'DELETE', path,
body: null }]);
+ });
+ });
+});
diff --git
a/zeppelin-web-angular/src/app/pages/workspace/interpreter/create-repository-modal/create-repository-modal.component.html
b/zeppelin-web-angular/src/app/pages/workspace/interpreter/create-repository-modal/create-repository-modal.component.html
index 607e4987f8..46faac0ee6 100644
---
a/zeppelin-web-angular/src/app/pages/workspace/interpreter/create-repository-modal/create-repository-modal.component.html
+++
b/zeppelin-web-angular/src/app/pages/workspace/interpreter/create-repository-modal/create-repository-modal.component.html
@@ -27,7 +27,12 @@
<input type="text" nz-input formControlName="url"
placeholder="Repository url" />
</nz-input-group>
<ng-template #addOnBeforeTemplate>
- <nz-select name="urlProtocol" [(ngModel)]="urlProtocol"
[ngModelOptions]="{ standalone: true }">
+ <nz-select
+ name="urlProtocol"
+ data-testid="repository-url-protocol"
+ [(ngModel)]="urlProtocol"
+ [ngModelOptions]="{ standalone: true }"
+ >
<nz-option [nzLabel]="'http://'" [nzValue]="'http://'"></nz-option>
<nz-option [nzLabel]="'https://'" [nzValue]="'https://'"></nz-option>
<nz-option [nzLabel]="'file://'" [nzValue]="'file://'"></nz-option>
diff --git
a/zeppelin-web-angular/src/app/pages/workspace/interpreter/interpreter.component.html
b/zeppelin-web-angular/src/app/pages/workspace/interpreter/interpreter.component.html
index d8b4d39a43..58fce28837 100644
---
a/zeppelin-web-angular/src/app/pages/workspace/interpreter/interpreter.component.html
+++
b/zeppelin-web-angular/src/app/pages/workspace/interpreter/interpreter.component.html
@@ -32,11 +32,11 @@
[nzType]="showRepository ? 'primary' : 'default'"
(click)="triggerRepository()"
>
- <i nz-icon nzType="database" nzTheme="outline"></i>
+ <i nz-icon nzType="database" nzTheme="outline" aria-hidden="true"></i>
Repository
</button>
</ng-template>
- <div animation-collapse [open]="showRepository">
+ <div animation-collapse [open]="showRepository"
data-testid="interpreter-repositories">
<div class="collapse-content">
<nz-divider nzType="horizontal"></nz-divider>
<h2>Repositories</h2>
@@ -44,6 +44,7 @@
@for (repo of repositories; track repo) {
<nz-tag
class="repo-item"
+ data-testid="interpreter-repository"
[nz-tooltip]="repo.url"
nzTooltipPlacement="topLeft"
[nzMode]="['central', 'local'].indexOf(repo.id) === -1 ? 'closeable'
: 'default'"
@@ -52,7 +53,7 @@
{{ repo.id }}
</nz-tag>
}
- <nz-tag class="editable-tag" (click)="createRepository()">
+ <nz-tag class="editable-tag" data-testid="create-interpreter-repository"
(click)="createRepository()">
<i nz-icon nzType="plus"></i>
</nz-tag>
</div>
diff --git
a/zeppelin-web-angular/src/app/pages/workspace/interpreter/item/item.component.html
b/zeppelin-web-angular/src/app/pages/workspace/interpreter/item/item.component.html
index cb07f5a4b5..01d1e7826f 100644
---
a/zeppelin-web-angular/src/app/pages/workspace/interpreter/item/item.component.html
+++
b/zeppelin-web-angular/src/app/pages/workspace/interpreter/item/item.component.html
@@ -11,6 +11,7 @@
-->
<nz-card
+ [attr.data-testid]="mode === 'create' ? 'interpreter-create-form' :
'interpreter-setting'"
[class.edit]="mode === 'edit'"
class="interpreter-item"
[nzTitle]="mode !== 'create' ? viewModeTitleTemplate : undefined"
@@ -31,7 +32,7 @@
}
</small>
}
- <span class="interpreter-status">
+ <span class="interpreter-status" data-testid="interpreter-status">
@switch (interpreter.status) {
@case ('READY') {
<nz-badge nz-tooltip="Ready" nzStatus="success"></nz-badge>
@@ -62,15 +63,15 @@
<ng-template #extraTemplate>
<div class="extra-wrap">
<button nz-button nzSize="small" (click)="switchToEditMode()">
- <i nz-icon nzType="edit"></i>
+ <i nz-icon nzType="edit" aria-hidden="true"></i>
Edit
</button>
<button nz-button nzSize="small" (click)="handleRestart()">
- <i nz-icon nzType="reload"></i>
+ <i nz-icon nzType="reload" aria-hidden="true"></i>
Restart
</button>
<button nz-button nzSize="small" (click)="handleRemove()">
- <i nz-icon nzType="delete"></i>
+ <i nz-icon nzType="delete" aria-hidden="true"></i>
Remove
</button>
</div>
@@ -93,7 +94,7 @@
<nz-form-item>
<nz-form-label nzRequired>Interpreter group</nz-form-label>
<nz-form-control>
- <nz-select formControlName="group" nzShowSearch>
+ <nz-select formControlName="group" nzShowSearch
data-testid="interpreter-group">
@for (item of parent.availableInterpreters; track
interpretersTrackFn($index, item)) {
<nz-option [nzValue]="item.name"
[nzLabel]="item.name"></nz-option>
}
@@ -249,7 +250,7 @@
<nz-form-item>
<nz-form-label>Host</nz-form-label>
<nz-form-control>
- <input nz-input formControlName="host" placeholder="" />
+ <input nz-input formControlName="host" placeholder=""
aria-label="Host" />
</nz-form-control>
</nz-form-item>
<nz-form-item>
@@ -259,6 +260,7 @@
nz-input
type="number"
formControlName="port"
+ aria-label="Port"
placeholder=""
pattern="^()([1-9]|[1-5]?[0-9]{2,4}|6[1-4][0-9]{3}|65[1-4][0-9]{2}|655[1-2][0-9]|6553[1-5])$"
/>
@@ -282,6 +284,7 @@
>
<nz-select
nzMode="tags"
+ data-testid="interpreter-owners"
class="owners-input"
[nzTokenSeparators]="[',']"
style="width: 100%"
@@ -304,7 +307,7 @@
@if (propertiesFormArray.controls?.length || mode !== 'view') {
<h3 class="form-title">Properties</h3>
<form nz-form [formGroup]="formGroup">
- <nz-table nzTemplateMode nzSize="small">
+ <nz-table nzTemplateMode nzSize="small"
data-testid="interpreter-properties">
<thead>
<tr>
<th>Name</th>
@@ -451,7 +454,7 @@
@if (dependenciesFormArray.controls?.length || mode !== 'view') {
<h3 class="form-title">Dependencies</h3>
<form nz-form [formGroup]="formGroup">
- <nz-table nzTemplateMode nzSize="small">
+ <nz-table nzTemplateMode nzSize="small"
data-testid="interpreter-dependencies">
<thead>
<tr>
<th>Artifact</th>