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);
+    });
+  });
+});

Reply via email to