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 ed98a60a5d [ZEPPELIN-6360] Add E2E coverage for the search page
ed98a60a5d is described below
commit ed98a60a5d3569b46be53f342968fedc7cb4586b
Author: Minho Jang <[email protected]>
AuthorDate: Thu Nov 12 00:12:45 2026 +0900
[ZEPPELIN-6360] Add E2E coverage for the search page
### What is this PR for?
Add Playwright E2E coverage for the existing Angular search route before
future React migration work.
Cover:
- Header search navigation and query preservation, including whitespace
trimming.
- Notebook-name results and navigation to the matching notebook.
- Paragraph snippets, highlighted keywords and titles, and interpreter
badges.
- Paragraph navigation through both the title link and card body,
preserving paragraph and search-term query parameters.
- Result replacement when searching again and the completed empty state.
Tests create isolated notebooks, wait for their real search-index entries,
and delete them after each test.
### What type of PR is it?
Improvement
### What is the Jira issue?
* https://issues.apache.org/jira/browse/ZEPPELIN-6360
### 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/notebook-search.spec.ts --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 #5610 from miinhho/ZEPPELIN-6360-search-e2e.
Signed-off-by: YONGJAE LEE <[email protected]>
---
.../e2e/models/notebook-search-page.ts | 49 +++++
.../e2e/models/notebook-search-page.util.ts | 30 +++
.../e2e/tests/workspace/notebook-search.spec.ts | 203 +++++++++++++++++++++
3 files changed, 282 insertions(+)
diff --git a/zeppelin-web-angular/e2e/models/notebook-search-page.ts
b/zeppelin-web-angular/e2e/models/notebook-search-page.ts
new file mode 100644
index 0000000000..3f763ce59b
--- /dev/null
+++ b/zeppelin-web-angular/e2e/models/notebook-search-page.ts
@@ -0,0 +1,49 @@
+/*
+ * 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 NotebookSearchPage {
+ readonly cards: Locator;
+ readonly emptyState: Locator;
+ readonly notebookTitle: Locator;
+
+ constructor(private readonly page: Page) {
+ this.cards = page.locator('.result-card');
+ this.emptyState = page.getByText(/We couldn't find any notebook matching/);
+ this.notebookTitle = page.getByTestId('notebook-title');
+ }
+
+ resultLink(name: string, destination: string): Locator {
+ return this.page.getByRole('link', { name, exact: true
}).and(this.page.locator(`a[href="${destination}"]`));
+ }
+
+ resultCard(name: string, destination: string): Locator {
+ return this.cards.filter({ has: this.resultLink(name, destination) });
+ }
+
+ snippet(name: string, destination: string): Locator {
+ return this.resultCard(name, destination).locator('pre');
+ }
+
+ highlightedKeyword(name: string, destination: string): Locator {
+ return this.snippet(name, destination).locator('mark');
+ }
+
+ highlightedTitle(name: string, destination: string): Locator {
+ return this.resultCard(name, destination).locator('.title-block mark');
+ }
+
+ paragraphEditor(paragraphId: string): Locator {
+ return this.page.getByTestId(paragraphId).getByRole('textbox', { name:
'Editor content', exact: true });
+ }
+}
diff --git a/zeppelin-web-angular/e2e/models/notebook-search-page.util.ts
b/zeppelin-web-angular/e2e/models/notebook-search-page.util.ts
new file mode 100644
index 0000000000..70ea33db13
--- /dev/null
+++ b/zeppelin-web-angular/e2e/models/notebook-search-page.util.ts
@@ -0,0 +1,30 @@
+/*
+ * 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 } from '@playwright/test';
+
+export const waitForSearchIndex = async (page: Page, query: string,
documentId: string): Promise<void> => {
+ await expect
+ .poll(
+ async () => {
+ const response = await page.request.get('/api/notebook/search', {
+ params: { q: query },
+ failOnStatusCode: true
+ });
+ return response.json();
+ },
+ { message: `Search index contains ${documentId}`, timeout: 30000,
intervals: [250, 500, 1000] }
+ )
+ .toMatchObject({
+ body: expect.arrayContaining([expect.objectContaining({ id: documentId
})])
+ });
+};
diff --git a/zeppelin-web-angular/e2e/tests/workspace/notebook-search.spec.ts
b/zeppelin-web-angular/e2e/tests/workspace/notebook-search.spec.ts
new file mode 100644
index 0000000000..aa081a7f0f
--- /dev/null
+++ b/zeppelin-web-angular/e2e/tests/workspace/notebook-search.spec.ts
@@ -0,0 +1,203 @@
+/*
+ * 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 { randomUUID } from 'crypto';
+import { expect, test } from '@playwright/test';
+import { HeaderPage } from '../../models/header-page';
+import { NotebookSearchPage } from '../../models/notebook-search-page';
+import { waitForSearchIndex } from '../../models/notebook-search-page.util';
+import {
+ addPageAnnotationBeforeEach,
+ createNotebookViaRest,
+ PAGES,
+ setParagraphText,
+ waitForZeppelinReady
+} from '../../utils';
+
+test.describe('Notebook search route', () => {
+ addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_SEARCH);
+ addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_SEARCH_RESULT);
+
+ let search: NotebookSearchPage;
+ let header: HeaderPage;
+ let noteId: string;
+ let paragraphId: string;
+ let noteName: string;
+ let paragraphTerm: string;
+ let source: string;
+ let ownedNoteIds: string[];
+
+ test.beforeEach(async ({ page }) => {
+ search = new NotebookSearchPage(page);
+ header = new HeaderPage(page);
+ ownedNoteIds = [];
+ const token = randomUUID().replace(/-/g, '');
+ noteName = `Search${token} Route${token}`;
+ paragraphTerm = `paragraph${token}`;
+ source = `%sql\nSELECT '${paragraphTerm}' AS marker WHERE 1 < 2`;
+
+ await test.step('Given an indexed notebook with a distinct name and
non-first matching paragraph', async () => {
+ const note = await createNotebookViaRest(page, noteName);
+ noteId = note.noteId;
+ ownedNoteIds.push(noteId);
+ const response = await
page.request.post(`/api/notebook/${noteId}/paragraph`, {
+ data: { title: `Title ${paragraphTerm}` },
+ failOnStatusCode: true
+ });
+ const payload: unknown = await response.json();
+ if (!payload || typeof payload !== 'object' || !('body' in payload) ||
typeof payload.body !== 'string') {
+ throw new Error('Paragraph creation response did not contain a
paragraph id');
+ }
+ paragraphId = payload.body;
+ await setParagraphText(page, noteId, paragraphId, source);
+ await waitForSearchIndex(page, noteName, noteId);
+ await waitForSearchIndex(page, paragraphTerm,
`${noteId}/paragraph/${paragraphId}`);
+ await page.goto('/');
+ await waitForZeppelinReady(page);
+ });
+ });
+
+ test.afterEach(async ({ page }) => {
+ for (const id of ownedNoteIds) {
+ const response = await page.request.delete(`/api/notebook/${id}`, {
failOnStatusCode: true });
+ expect(response.ok()).toBe(true);
+ }
+ });
+
+ test('preserves the trimmed search phrase and opens a notebook-name match',
async ({ page }) => {
+ const destination = `#/notebook/${noteId}`;
+
+ await test.step('When the header submits a phrase with outer and internal
spaces', async () => {
+ await header.searchNote(` ${noteName} `);
+ });
+
+ await test.step('Then the route and header preserve the trimmed phrase and
render its result', async () => {
+ await expect(page).toHaveURL(url => decodeURIComponent(url.hash) ===
`#/search/${noteName}`);
+ await expect(header.searchInput).toHaveValue(noteName);
+ await expect(search.resultLink(noteName, destination)).toBeVisible();
+ await expect(search.snippet(noteName,
destination)).toContainText(noteName);
+ await expect(search.emptyState).not.toBeVisible();
+ });
+
+ await test.step('When the notebook-name link is clicked', async () => {
+ await search.resultLink(noteName, destination).click();
+ });
+
+ await test.step('Then the matching notebook loads without paragraph query
parameters', async () => {
+ await expect(page).toHaveURL(url => url.hash === `#/notebook/${noteId}`);
+ await expect(search.notebookTitle).toHaveText(noteName);
+ });
+ });
+
+ test('restores the search input and results from a direct URL and after
reload', async ({ page }) => {
+ const destination = `#/notebook/${noteId}`;
+ const searchUrl = `/#/search/${encodeURIComponent(noteName)}`;
+ const expectRestoredSearch = async () => {
+ await expect(page).toHaveURL(url => decodeURIComponent(url.hash) ===
`#/search/${noteName}`);
+ await expect(header.searchInput).toHaveValue(noteName);
+ await expect(search.resultLink(noteName, destination)).toBeVisible();
+ await expect(search.snippet(noteName,
destination)).toContainText(noteName);
+ await expect(search.emptyState).not.toBeVisible();
+ };
+
+ await test.step('When an encoded search URL is opened without the existing
application state', async () => {
+ await page.goto('about:blank');
+ await page.goto(searchUrl);
+ await waitForZeppelinReady(page);
+ });
+
+ await test.step('Then the search phrase and matching result are restored
from the URL', expectRestoredSearch);
+
+ await test.step('When the search page is reloaded', async () => {
+ await page.reload();
+ await waitForZeppelinReady(page);
+ });
+
+ await test.step('Then the search phrase and matching result are restored
again', expectRestoredSearch);
+ });
+
+ for (const action of ['title link', 'card body'] as const) {
+ test(`opens the matching paragraph through its ${action} with paragraph
query parameters`, async ({ page }) => {
+ const destination =
`#/notebook/${noteId}?paragraph=${paragraphId}&term=${paragraphTerm}`;
+
+ await test.step('When the header searches for text present only in the
second paragraph', async () => {
+ await header.searchNote(paragraphTerm);
+ });
+
+ await test.step('Then the result shows the code, highlighted keyword and
interpreter', async () => {
+ await expect(page).toHaveURL(url => decodeURIComponent(url.hash) ===
`#/search/${paragraphTerm}`);
+ await expect(header.searchInput).toHaveValue(paragraphTerm);
+ await expect(search.resultLink(noteName, destination)).toBeVisible();
+ await expect(search.snippet(noteName, destination)).toHaveText(source);
+ await expect(search.highlightedKeyword(noteName,
destination)).toHaveText(paragraphTerm);
+ await expect(search.highlightedTitle(noteName,
destination)).toHaveText(paragraphTerm);
+ await expect(search.resultCard(noteName, destination).getByText('sql',
{ exact: true })).toBeVisible();
+ });
+
+ await test.step(`When the result ${action} is clicked`, async () => {
+ if (action === 'title link') {
+ await search.resultLink(noteName, destination).click();
+ } else {
+ await search.snippet(noteName, destination).click();
+ }
+ });
+
+ await test.step('Then the matching notebook and paragraph load with the
exact search query parameters', async () => {
+ await expect(page).toHaveURL(url => {
+ const route = new URL(url.hash.slice(1), url.origin);
+ return (
+ route.pathname === `/notebook/${noteId}` &&
+ route.searchParams.get('paragraph') === paragraphId &&
+ route.searchParams.get('term') === paragraphTerm
+ );
+ });
+ await expect(search.notebookTitle).toHaveText(noteName);
+ await expect(search.paragraphEditor(paragraphId)).toHaveValue(source);
+ });
+ });
+ }
+
+ test('replaces results on the same search route and renders a completed
empty state', async ({ page }) => {
+ const noteDestination = `#/notebook/${noteId}`;
+ const paragraphDestination =
`#/notebook/${noteId}?paragraph=${paragraphId}&term=${paragraphTerm}`;
+ const missingTerm = `missing${randomUUID().replace(/-/g, '')}`;
+
+ await test.step('Given a successful notebook-name search', async () => {
+ await header.searchNote(noteName);
+ await expect(search.resultLink(noteName, noteDestination)).toBeVisible();
+ });
+
+ await test.step('When another query is submitted from the search page',
async () => {
+ await header.searchNote(paragraphTerm);
+ });
+
+ await test.step('Then the new result replaces the previous document',
async () => {
+ await expect(page).toHaveURL(url => decodeURIComponent(url.hash) ===
`#/search/${paragraphTerm}`);
+ await expect(header.searchInput).toHaveValue(paragraphTerm);
+ await expect(search.resultLink(noteName,
paragraphDestination)).toBeVisible();
+ await expect(search.resultLink(noteName,
noteDestination)).toHaveCount(0);
+ await expect(search.emptyState).not.toBeVisible();
+ });
+
+ await test.step('When a valid query with no matching document is
submitted', async () => {
+ await header.searchNote(missingTerm);
+ });
+
+ await test.step('Then the completed empty state includes the current query
and no stale cards', async () => {
+ await expect(page).toHaveURL(url => decodeURIComponent(url.hash) ===
`#/search/${missingTerm}`);
+ await expect(header.searchInput).toHaveValue(missingTerm);
+ await expect(search.emptyState).toBeVisible();
+ await expect(search.emptyState).toContainText(`'${missingTerm}'`);
+ await expect(search.cards).toHaveCount(0);
+ });
+ });
+});