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 710edf257c [ZEPPELIN-6517] Add E2E tests for notebook paragraph 
components
710edf257c is described below

commit 710edf257c43826ffabc47cefc2617e828e3363e
Author: Minho Jang <[email protected]>
AuthorDate: Wed Sep 16 22:24:21 2026 +0900

    [ZEPPELIN-6517] Add E2E tests for notebook paragraph components
    
    ### What is this PR for?
    This PR improves the behavior-focused E2E coverage for the notebook 
paragraph sub-components.
    
    It replaces shallow visibility checks and interpreter-error fallbacks with 
scenarios that verify user-observable paragraph behavior:
    - Add paragraphs above and below the current paragraph
    - Edit Monaco content and verify focused, dirty, and rendered line states
    - Inspect the paragraph settings available for single and multiple 
paragraphs
    - Clone, insert, move, and remove paragraphs
    - Toggle the editor, output, line numbers, and run state
    - Edit paragraph titles and verify Enter, Escape, and blur behavior
    - Verify running, finished, outdated, and cancelled paragraph states
    - Verify progress and footer timing information
    - Verify that paragraph insertion is disabled while execution is in progress
    
    The paragraph page object is also updated to:
    
    - Scope component locators to the primary paragraph
    - Provide helpers for add-paragraph and settings-menu interactions
    - Replace individual settings-menu locators with a typed 
`settingsMenuItem()` accessor
    - Support interactions with settings menus belonging to different paragraphs
    - Use stable, component-specific locators instead of broad fallback 
selectors
    
    ### What type of PR is it?
    Improvement
    
    ### What is the Jira issue?
    * https://issues.apache.org/jira/browse/ZEPPELIN-6517
    
    ### How should this be tested?
    From `zeppelin-web-angular/`, run:
    
    ```bash
    npm run e2e:fast -- tests/notebook/paragraph/paragraph-functionality.spec.ts
    ```
    
    ### Questions:
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? No
    
    
    Closes #5476 from miinhho/test/notebook-paragraph-sub-component.
    
    Signed-off-by: YONGJAE LEE <[email protected]>
---
 .../e2e/models/notebook-paragraph-page.ts          | 126 ++++++--
 .../paragraph/paragraph-functionality.spec.ts      | 357 +++++++++++++++------
 2 files changed, 353 insertions(+), 130 deletions(-)

diff --git a/zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts 
b/zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts
index f50359a780..f0083edb26 100644
--- a/zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts
+++ b/zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts
@@ -13,46 +13,102 @@
 import { expect, Locator, Page } from '@playwright/test';
 import { BasePage } from './base-page';
 
+type ParagraphSettingsMenuItem =
+  | 'Width'
+  | 'Font size'
+  | 'Insert new'
+  | 'Clone paragraph'
+  | 'Remove'
+  | 'Move paragraph up'
+  | 'Move paragraph down'
+  | 'Clear output'
+  | 'Show Title'
+  | 'Show line numbers'
+  | 'Hide line numbers'
+  | 'Disable run'
+  | 'Enable run';
+
 export class NotebookParagraphPage extends BasePage {
+  readonly paragraphContainers: Locator;
   readonly paragraphContainer: Locator;
-  readonly addParagraphAbove: Locator;
-  readonly addParagraphBelow: Locator;
+  readonly addParagraphAboveLink: Locator;
+  readonly addParagraphBelowLink: Locator;
   readonly controlPanel: Locator;
   readonly codeEditor: Locator;
+  readonly codeEditorHost: Locator;
+  readonly editorInput: Locator;
+  readonly editorViewLinesAll: Locator;
+  readonly editorViewLines: Locator;
+  readonly editorLines: Locator;
   readonly dynamicForms: Locator;
   readonly resultDisplay: Locator;
-  readonly footerInfo: Locator;
+  readonly executionTime: Locator;
+  readonly elapsedTime: Locator;
+  readonly progressIndicator: Locator;
+  readonly progressBar: Locator;
+  readonly paragraphTitleText: Locator;
+  readonly paragraphTitleInput: Locator;
+  readonly lineNumbers: Locator;
   readonly runButton: Locator;
-  readonly settingsDropdown: Locator;
+  readonly toggleEditorButton: Locator;
+  readonly toggleOutputButton: Locator;
+  readonly settingsDropdowns: Locator;
+  readonly settingsMenu: Locator;
+  readonly paragraphIdMenuItem: Locator;
+  readonly confirmButton: Locator;
+  readonly runAllButton: Locator;
   readonly status: Locator;
   readonly cancelButton: Locator;
   readonly exportDropdownTrigger: Locator;
   readonly exportMenu: Locator;
+  private readonly settingsDropdown: Locator;
+  private readonly addParagraphAbove: Locator;
+  private readonly addParagraphBelow: Locator;
 
   constructor(page: Page) {
     super(page);
-    this.paragraphContainer = page.locator('.paragraph-container').first();
-    this.addParagraphAbove = 
page.locator('zeppelin-notebook-add-paragraph').first();
-    this.addParagraphBelow = 
page.locator('zeppelin-notebook-add-paragraph').last();
-    this.controlPanel = 
page.locator('zeppelin-notebook-paragraph-control').first();
-    this.codeEditor = 
page.locator('zeppelin-notebook-paragraph-code-editor').first();
-    this.dynamicForms = 
page.locator('zeppelin-notebook-paragraph-dynamic-forms').first();
-    this.resultDisplay = 
page.locator('zeppelin-notebook-paragraph-result').first();
-    this.footerInfo = 
page.locator('zeppelin-notebook-paragraph-footer').first();
-    this.runButton = page
-      .locator('.paragraph-container')
-      .first()
-      .locator(
-        'button[nzTooltipTitle*="Run"], button[title*="Run"], 
button:has-text("Run"), .run-button, [aria-label*="Run"], 
i[nzType="play-circle"]:visible, button:has(i[nzType="play-circle"])'
-      )
-      .first();
-    this.settingsDropdown = page
-      .locator('.paragraph-container')
-      .first()
-      .locator('zeppelin-notebook-paragraph-control a[nz-dropdown]')
-      .first();
+    this.paragraphContainers = page.locator('.paragraph-container');
+    // JUSTIFIED: fresh test notebooks contain one paragraph; its container is 
the primary target.
+    this.paragraphContainer = this.paragraphContainers.first();
+    const addParagraphControls = 
page.locator('zeppelin-notebook-add-paragraph');
+    // JUSTIFIED: the first add control inserts above the first paragraph.
+    this.addParagraphAbove = addParagraphControls.first();
+    // JUSTIFIED: the last add control trails the final paragraph and inserts 
below it.
+    this.addParagraphBelow = addParagraphControls.last();
+    this.addParagraphAboveLink = this.addParagraphAbove.getByText('Add 
Paragraph', { exact: true });
+    this.addParagraphBelowLink = this.addParagraphBelow.getByText('Add 
Paragraph', { exact: true });
+    const controlPanels = page.locator('zeppelin-notebook-paragraph-control');
+    this.controlPanel = 
this.paragraphContainer.locator('zeppelin-notebook-paragraph-control');
+    const codeEditors = 
page.locator('zeppelin-notebook-paragraph-code-editor');
+    this.codeEditor = 
this.paragraphContainer.locator('zeppelin-notebook-paragraph-code-editor');
+    this.codeEditorHost = this.codeEditor.locator('zeppelin-code-editor');
+    this.editorInput = this.codeEditor.getByRole('textbox', { name: 'Editor 
content', exact: true });
+    this.editorViewLines = this.codeEditor.locator('.view-lines');
+    this.editorViewLinesAll = codeEditors.locator('.view-lines');
+    this.editorLines = this.codeEditor.locator('.view-line');
+    this.dynamicForms = 
this.paragraphContainer.locator('zeppelin-notebook-paragraph-dynamic-forms');
+    this.resultDisplay = 
this.paragraphContainer.locator('zeppelin-notebook-paragraph-result');
+    const footerInfo = 
this.paragraphContainer.getByTestId('angular-paragraph-footer');
+    this.executionTime = footerInfo.locator('.execution-time');
+    this.elapsedTime = footerInfo.locator('.elapsed-time');
+    // JUSTIFIED: only the running paragraph renders a progress component.
+    this.progressIndicator = 
page.locator('zeppelin-notebook-paragraph-progress').first();
+    this.progressBar = this.progressIndicator.locator('.ant-progress');
+    const paragraphTitle = 
this.paragraphContainer.locator('zeppelin-elastic-input');
+    this.paragraphTitleText = paragraphTitle.locator('p');
+    this.paragraphTitleInput = paragraphTitle.getByRole('textbox');
+    // Monaco renders gutter line numbers inside its margin overlay.
+    this.lineNumbers = this.codeEditor.locator('.margin-view-overlays 
.line-numbers');
+    this.runButton = this.controlPanel.locator('.run-para');
+    this.toggleEditorButton = 
this.controlPanel.locator('a[nzTooltipTitle="Show/hide the code"]');
+    this.toggleOutputButton = 
this.controlPanel.locator('a[nzTooltipTitle="Show/hide the output"]');
+    this.settingsDropdowns = controlPanels.locator('a[nz-dropdown]');
+    this.settingsDropdown = this.controlPanel.locator('a[nz-dropdown]');
+    this.settingsMenu = page.locator('ul.setting-menu');
+    this.paragraphIdMenuItem = this.settingsMenu.locator('.paragraph-id');
+    this.confirmButton = page.getByRole('button', { name: 'OK', exact: true });
+    this.runAllButton = page.locator('button[nzTooltipTitle="Run all 
paragraphs"]');
     this.status = this.controlPanel.locator('.status');
-    // The control renders the cancel icon only while the paragraph is PENDING 
or RUNNING.
     this.cancelButton = this.controlPanel.locator('.cancel-para');
     // The export controls render only for a TABLE result.
     this.exportDropdownTrigger = 
this.resultDisplay.locator('.export-dropdown-icon-btn');
@@ -69,11 +125,27 @@ export class NotebookParagraphPage extends BasePage {
     await this.paragraphContainer.dblclick();
   }
 
+  async clickAddParagraphAbove(): Promise<void> {
+    await this.addParagraphAbove.hover();
+    await this.addParagraphAboveLink.click();
+  }
+
+  async clickAddParagraphBelow(): Promise<void> {
+    await this.addParagraphBelow.hover();
+    await this.addParagraphBelowLink.click();
+  }
+
   async runParagraph(): Promise<void> {
     await this.runButton.click();
   }
 
-  async openSettingsDropdown(): Promise<void> {
-    await this.settingsDropdown.click();
+  settingsMenuItem(label: ParagraphSettingsMenuItem): Locator {
+    return this.settingsMenu.locator('li.list-item').filter({ hasText: label 
});
+  }
+
+  async openSettingsDropdown(settingsDropdown: Locator = 
this.settingsDropdown): Promise<void> {
+    await expect(this.settingsMenu).toBeHidden();
+    await settingsDropdown.click();
+    await expect(this.settingsMenu).toBeVisible();
   }
 }
diff --git 
a/zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts
 
b/zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts
index 6d01796326..21bd0c6c8c 100644
--- 
a/zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts
+++ 
b/zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts
@@ -14,6 +14,7 @@ import { expect, test } from '@playwright/test';
 import { NotebookParagraphPage } from 'e2e/models/notebook-paragraph-page';
 import { NotebookKeyboardPage } from 'e2e/models/notebook-keyboard-page';
 import {
+  addPageAnnotation,
   addPageAnnotationBeforeEach,
   createTestNotebook,
   performLoginIfRequired,
@@ -23,8 +24,9 @@ import {
 } from '../../../utils';
 
 test.describe('Notebook Paragraph Functionality', () => {
+  // JUSTIFIED: this legacy spec stores its page object and notebook id in 
describe scope.
+  test.describe.configure({ mode: 'default' });
   addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH);
-  addPageAnnotationBeforeEach(PAGES.SHARE.CODE_EDITOR);
 
   let paragraphPage: NotebookParagraphPage;
   let testNotebook: { noteId: string; paragraphId: string };
@@ -43,57 +45,80 @@ test.describe('Notebook Paragraph Functionality', () => {
     await expect(paragraphPage.paragraphContainer).toBeVisible({ timeout: 
30000 });
   });
 
-  test('should display paragraph container with proper structure', async () => 
{
+  test('should display the paragraph container and control panel', async () => 
{
     await expect(paragraphPage.paragraphContainer).toBeVisible();
     await expect(paragraphPage.controlPanel).toBeVisible();
   });
 
-  test('should support double-click editing functionality', { tag: 
'@NB-PARITY-003' }, async () => {
-    await expect(paragraphPage.paragraphContainer).toBeVisible();
-    await paragraphPage.doubleClickToEdit();
-    await expect(paragraphPage.codeEditor).toBeVisible();
-  });
+  test(
+    'should reflect user edits in the code editor state and rendered lines',
+    { tag: '@NB-PARITY-003' },
+    async ({ page }, testInfo) => {
+      addPageAnnotation(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH_CODE_EDITOR, 
testInfo);
+      const keyboard = new NotebookKeyboardPage(page);
+
+      await test.step('Given the paragraph is in edit mode', async () => {
+        await paragraphPage.doubleClickToEdit();
+        await expect(paragraphPage.codeEditor).toBeVisible();
+        await paragraphPage.editorInput.focus();
+        await expect(paragraphPage.editorInput).toBeFocused();
+      });
 
-  test('should display add paragraph buttons', { tag: '@NB-PARITY-004' }, 
async () => {
-    await expect(paragraphPage.addParagraphAbove).toBeVisible();
-    await expect(paragraphPage.addParagraphAbove).toHaveCount(1);
-    await expect(paragraphPage.addParagraphBelow).toBeVisible();
-    await expect(paragraphPage.addParagraphBelow).toHaveCount(1);
-  });
+      await test.step('When the user replaces the paragraph with five lines', 
async () => {
+        await keyboard.pressSelectAll();
+        await page.keyboard.type('%md\nline one\nline two\nline three\nline 
four');
+      });
 
-  test('should display comprehensive control interface', async () => {
-    await expect(paragraphPage.controlPanel).toBeVisible();
-    await expect(paragraphPage.runButton).toBeVisible();
-    await expect(paragraphPage.runButton).toBeEnabled();
-  });
+      await test.step('Then Monaco renders the text and marks the editor 
focused and dirty', async () => {
+        await expect(paragraphPage.editorViewLines).toContainText('line four');
+        await expect(paragraphPage.editorLines).toHaveCount(5);
+        await expect(paragraphPage.codeEditorHost).toHaveClass(/\bfocused\b/);
+        await expect(paragraphPage.codeEditorHost).toHaveClass(/\bdirty\b/);
 
-  test('should display result system properly', { tag: '@NB-PARITY-021' }, 
async ({ page }) => {
-    await expect(page).toHaveURL(/\/notebook\/[^\/]+/, { timeout: 10000 });
-    await page.waitForLoadState('domcontentloaded');
-    await expect(paragraphPage.paragraphContainer).toBeVisible({ timeout: 
15000 });
-    // JUSTIFIED: codeEditor is visibility:hidden before double-click; 
toBeAttached confirms it's in the DOM
-    await expect(paragraphPage.codeEditor).toBeAttached({ timeout: 10000 });
+        await page.keyboard.press('Escape');
+        await 
expect(paragraphPage.codeEditorHost).not.toHaveClass(/\bfocused\b/);
+      });
+    }
+  );
 
-    await paragraphPage.doubleClickToEdit();
-    await expect(paragraphPage.codeEditor).toBeVisible();
+  test(
+    'should insert default paragraphs above and below the original paragraph',
+    { tag: '@NB-PARITY-004' },
+    async ({ page }, testInfo) => {
+      addPageAnnotation(PAGES.WORKSPACE.NOTEBOOK_ADD_PARAGRAPH, testInfo);
+      const keyboard = new NotebookKeyboardPage(page);
+      const originalText = 'Original paragraph marker';
+
+      await test.step('Given one paragraph with distinctive text', async () => 
{
+        await keyboard.setCodeEditorContent(`%md\n${originalText}`);
+        await expect(paragraphPage.paragraphContainers).toHaveCount(1);
+      });
 
-    // JUSTIFIED: compound selector; first() picks primary Monaco input
-    const codeEditor = paragraphPage.codeEditor.locator('textarea, 
.monaco-editor .input-area').first();
-    // JUSTIFIED: Monaco textarea may be visibility:hidden before focus; 
toBeAttached confirms DOM presence
-    await expect(codeEditor).toBeAttached({ timeout: 10000 });
-    await expect(codeEditor).toBeEnabled({ timeout: 10000 });
+      await test.step('When the trailing Add Paragraph control is clicked', 
async () => {
+        await paragraphPage.clickAddParagraphBelow();
+      });
 
-    await codeEditor.focus();
-    await expect(codeEditor).toBeFocused({ timeout: 5000 });
+      await test.step('Then a new paragraph is inserted below the original', 
async () => {
+        await expect(paragraphPage.paragraphContainers).toHaveCount(2);
+        // JUSTIFIED: the first rendered editor is the original paragraph 
after inserting below.
+        await 
expect(paragraphPage.editorViewLinesAll.first()).toContainText(originalText);
+        // JUSTIFIED: the last rendered editor is the newly inserted default 
paragraph.
+        await 
expect(paragraphPage.editorViewLinesAll.last()).toHaveText('%md');
+      });
 
-    const notebookKeyboardPage = new NotebookKeyboardPage(page);
-    await notebookKeyboardPage.pressSelectAll();
-    await page.keyboard.type('%python\nprint("Hello World")');
+      await test.step('When the leading Add Paragraph control is clicked', 
async () => {
+        await paragraphPage.clickAddParagraphAbove();
+      });
 
-    await paragraphPage.runParagraph();
-    await expect(paragraphPage.resultDisplay).toBeVisible({ timeout: 15000 });
-    await expect(paragraphPage.resultDisplay).not.toBeEmpty();
-  });
+      await test.step('Then a default paragraph is inserted above the 
original', async () => {
+        await expect(paragraphPage.paragraphContainers).toHaveCount(3);
+        // JUSTIFIED: inserting above moves the original paragraph to the 
second position.
+        await 
expect(paragraphPage.editorViewLinesAll.nth(1)).toContainText(originalText);
+        // JUSTIFIED: the first rendered editor is the newly inserted default 
paragraph.
+        await 
expect(paragraphPage.editorViewLinesAll.first()).toHaveText('%md');
+      });
+    }
+  );
 
   test(
     'should accumulate interpreter output while the paragraph is running',
@@ -126,88 +151,214 @@ test.describe('Notebook Paragraph Functionality', () => {
     }
   );
 
-  test('should display dynamic forms', async ({ page }) => {
-    test.skip(!!process.env.CI, 'Dynamic form tests require a Spark 
interpreter — skipped on CI');
+  test('should expose the settings available for a single paragraph', async 
({}, testInfo) => {
+    addPageAnnotation(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH_CONTROL, testInfo);
 
-    await paragraphPage.doubleClickToEdit();
-    await expect(paragraphPage.codeEditor).toBeVisible();
+    await paragraphPage.openSettingsDropdown();
 
-    // JUSTIFIED: compound selector; first() picks primary Monaco input
-    const codeEditor = paragraphPage.codeEditor.locator('textarea, 
.monaco-editor .input-area').first();
-    await expect(codeEditor).toBeAttached({ timeout: 10000 });
-    await expect(codeEditor).toBeEnabled({ timeout: 10000 });
-
-    await codeEditor.focus();
-    await expect(codeEditor).toBeFocused({ timeout: 5000 });
-
-    const notebookKeyboardPage = new NotebookKeyboardPage(page);
-    await notebookKeyboardPage.pressSelectAll();
-    await page.keyboard.type(`%spark
-println("Name: " + z.input("name", "World"))
-println("Age: " + z.select("age", Seq(("1","Under 18"), ("2","18-65"), 
("3","Over 65"))))
-`);
-
-    await paragraphPage.runParagraph();
-    await expect(paragraphPage.resultDisplay).toBeVisible({ timeout: 15000 });
-
-    // Handles error cases gracefully — Spark may not be available
-    // JUSTIFIED: result display may not exist when interpreter is 
unavailable; null triggers graceful fallback below
-    const resultText = await 
paragraphPage.resultDisplay.textContent().catch(() => null);
-    const hasInterpreterError =
-      resultText &&
-      ((resultText.toLowerCase().includes('interpreter') && 
resultText.toLowerCase().includes('not found')) ||
-        resultText.toLowerCase().includes('error'));
-
-    if (hasInterpreterError) {
-      await expect(paragraphPage.resultDisplay).toBeVisible();
-    } else {
-      await expect(paragraphPage.dynamicForms).toBeVisible();
-    }
+    await expect(paragraphPage.settingsMenu).toBeVisible();
+    await 
expect(paragraphPage.paragraphIdMenuItem).toHaveText(/paragraph_\d+_\d+/);
+    await expect(paragraphPage.settingsMenuItem('Width')).toBeVisible();
+    await expect(paragraphPage.settingsMenuItem('Font size')).toBeVisible();
+    await expect(paragraphPage.settingsMenuItem('Insert new')).toBeVisible();
+    await expect(paragraphPage.settingsMenuItem('Clone 
paragraph')).toBeVisible();
+    await expect(paragraphPage.settingsMenuItem('Clear output')).toBeVisible();
+    await expect(paragraphPage.settingsMenuItem('Show Title')).toBeVisible();
+    await expect(paragraphPage.settingsMenuItem('Show line 
numbers')).toBeVisible();
+    await expect(paragraphPage.settingsMenuItem('Disable run')).toBeVisible();
+    await expect(paragraphPage.settingsMenuItem('Remove')).toHaveCount(0);
+    await expect(paragraphPage.settingsMenuItem('Move paragraph 
up')).toHaveCount(0);
+    await expect(paragraphPage.settingsMenuItem('Move paragraph 
down')).toHaveCount(0);
   });
 
-  test('should render footer element in paragraph DOM', async () => {
-    // JUSTIFIED: footer is visibility:hidden by default (hover-only); 
toBeAttached confirms it's rendered in DOM
-    await expect(paragraphPage.footerInfo).toBeAttached();
+  test('should report running and finished execution state', { tag: 
'@NB-PARITY-021' }, async ({ page }, testInfo) => {
+    addPageAnnotation(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH_CONTROL, testInfo);
+    addPageAnnotation(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH_FOOTER, testInfo);
+    addPageAnnotation(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH_PROGRESS, testInfo);
+    const keyboard = new NotebookKeyboardPage(page);
+
+    await test.step('Given a paragraph that runs long enough to observe', 
async () => {
+      await keyboard.setCodeEditorContent('%python\nimport 
time\ntime.sleep(2)\nprint("lifecycle complete")');
+      await expect(paragraphPage.status).toHaveText('READY');
+    });
+
+    await test.step('When the paragraph starts', async () => {
+      await paragraphPage.runParagraph();
+    });
+
+    await test.step('Then running controls, progress, and elapsed time are 
shown', async () => {
+      await expect(paragraphPage.status).toHaveText('RUNNING', { timeout: 
60000 });
+      await expect(paragraphPage.cancelButton).toBeVisible();
+      await expect(paragraphPage.progressBar).toBeVisible();
+      await expect(paragraphPage.elapsedTime).toHaveText(/^Started .+ ago\.$/, 
{ timeout: 15000 });
+    });
+
+    await test.step('Then completion removes progress and shows result 
timing', async () => {
+      await expect(paragraphPage.status).toHaveText('FINISHED', { timeout: 
60000 });
+      await expect(paragraphPage.resultDisplay).toContainText('lifecycle 
complete');
+      await expect(paragraphPage.progressIndicator).toHaveCount(0);
+      await paragraphPage.paragraphContainer.hover();
+      await expect(paragraphPage.executionTime).toHaveText(/^Took .+\. Last 
updated by .+ at .+\./);
+    });
+
+    await test.step('When the finished paragraph is edited', async () => {
+      await paragraphPage.editorInput.focus();
+      await page.keyboard.type(' ');
+      await page.keyboard.press('Escape');
+    });
+
+    await test.step('Then the previous result is marked outdated and can be 
cleared', async () => {
+      await expect(paragraphPage.executionTime).toContainText('(outdated)');
+      await paragraphPage.openSettingsDropdown();
+      await paragraphPage.settingsMenuItem('Clear output').click();
+      await expect(paragraphPage.resultDisplay).toHaveCount(0);
+    });
   });
 
-  test('should provide paragraph control actions', async ({ page }) => {
-    await expect(page).toHaveURL(/\/notebook\/[^\/]+/, { timeout: 10000 });
-    await expect(paragraphPage.paragraphContainer).toBeVisible({ timeout: 
15000 });
+  test('should edit a paragraph title through the elastic input', async ({}, 
testInfo) => {
+    addPageAnnotation(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH_CONTROL, testInfo);
+    addPageAnnotation(PAGES.WORKSPACE.NOTEBOOK_ELASTIC_INPUT, testInfo);
+
+    await test.step('Given paragraph titles are enabled', async () => {
+      await paragraphPage.openSettingsDropdown();
+      await paragraphPage.settingsMenuItem('Show Title').click();
+      await expect(paragraphPage.paragraphTitleText).toHaveText('Untitled');
+    });
+
+    await test.step('When a title is entered and committed with Enter', async 
() => {
+      await paragraphPage.paragraphTitleText.click();
+      await paragraphPage.paragraphTitleInput.fill('My paragraph');
+      await paragraphPage.paragraphTitleInput.press('Enter');
+    });
+
+    await test.step('Then the committed title replaces the input', async () => 
{
+      await expect(paragraphPage.paragraphTitleInput).toHaveCount(0);
+      await expect(paragraphPage.paragraphTitleText).toHaveText('My 
paragraph');
+    });
+
+    await test.step('When a different title is cancelled with Escape', async 
() => {
+      await paragraphPage.paragraphTitleText.click();
+      await paragraphPage.paragraphTitleInput.fill('Temporary title');
+      await paragraphPage.paragraphTitleInput.press('Escape');
+    });
+
+    await test.step('Then the committed title remains unchanged', async () => {
+      await expect(paragraphPage.paragraphTitleText).toHaveText('My 
paragraph');
+      await expect(paragraphPage.paragraphTitleInput).toHaveCount(0);
+    });
+
+    await test.step('When a longer title is entered and the input loses 
focus', async () => {
+      await paragraphPage.paragraphTitleText.click();
+      const longTitle = 'A much longer paragraph title that commits on blur';
+      await paragraphPage.paragraphTitleInput.fill(longTitle);
+      await paragraphPage.paragraphTitleInput.blur();
+      await expect(paragraphPage.paragraphTitleText).toHaveText(longTitle);
+      await expect(paragraphPage.paragraphTitleInput).toHaveCount(0);
+    });
+  });
+
+  test('should clone a paragraph with its editor content', async ({ page }, 
testInfo) => {
+    addPageAnnotation(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH_CONTROL, testInfo);
+    const keyboard = new NotebookKeyboardPage(page);
+    const cloneMarker = 'Distinctive clone marker';
 
+    await keyboard.setCodeEditorContent(`%md\n${cloneMarker}`);
     await paragraphPage.openSettingsDropdown();
+    await paragraphPage.settingsMenuItem('Clone paragraph').click();
 
-    const dropdownMenu = page.locator('ul.ant-dropdown-menu, .dropdown-menu');
-    await expect(dropdownMenu).toBeVisible({ timeout: 5000 });
-    await expect(page.locator('li:has-text("Insert")')).toBeVisible();
-    await expect(page.locator('li:has-text("Clone")')).toBeVisible();
+    await expect(paragraphPage.paragraphContainers).toHaveCount(2);
+    await expect(paragraphPage.editorViewLinesAll).toHaveText([
+      /Distinctive\s+clone\s+marker/,
+      /Distinctive\s+clone\s+marker/
+    ]);
+  });
 
-    await page.keyboard.press('Escape');
+  test('should insert, move, and remove paragraphs through the settings menu', 
async ({ page }, testInfo) => {
+    addPageAnnotation(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH_CONTROL, testInfo);
+    const keyboard = new NotebookKeyboardPage(page);
+    const originalMarker = 'Paragraph to move';
+
+    await keyboard.setCodeEditorContent(`%md\n${originalMarker}`);
+    await paragraphPage.openSettingsDropdown();
+    await paragraphPage.settingsMenuItem('Insert new').click();
+    await expect(paragraphPage.paragraphContainers).toHaveCount(2);
+
+    await paragraphPage.openSettingsDropdown();
+    await expect(paragraphPage.settingsMenuItem('Remove')).toBeVisible();
+    await expect(paragraphPage.settingsMenuItem('Move paragraph 
down')).toBeVisible();
+    await expect(paragraphPage.settingsMenuItem('Move paragraph 
up')).toHaveCount(0);
+    await paragraphPage.settingsMenuItem('Move paragraph down').click();
+
+    // JUSTIFIED: after moving the original first paragraph down, its editor 
is last.
+    await 
expect(paragraphPage.editorViewLinesAll.last()).toContainText(originalMarker);
+
+    // JUSTIFIED: remove the moved paragraph through its last settings trigger.
+    await 
paragraphPage.openSettingsDropdown(paragraphPage.settingsDropdowns.last());
+    await paragraphPage.settingsMenuItem('Remove').click();
+    await expect(paragraphPage.confirmButton).toBeVisible();
+    await paragraphPage.confirmButton.click();
+    await expect(paragraphPage.paragraphContainers).toHaveCount(1);
+    // JUSTIFIED: removing the moved original leaves the inserted default 
paragraph first.
+    await expect(paragraphPage.editorViewLinesAll.first()).toHaveText('%md');
   });
 
-  test('should show cancel button during execution', async ({ page }) => {
-    await expect(page).toHaveURL(/\/notebook\/[^\/]+/, { timeout: 10000 });
-    await expect(paragraphPage.paragraphContainer).toBeVisible({ timeout: 
15000 });
+  test('should toggle paragraph code, output, line numbers, and run state', 
async ({ page }, testInfo) => {
+    addPageAnnotation(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH_CONTROL, testInfo);
+    addPageAnnotation(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH_CODE_EDITOR, 
testInfo);
+
+    await paragraphPage.toggleEditorButton.click();
+    await expect(paragraphPage.codeEditor).toHaveCount(0);
+    await paragraphPage.toggleEditorButton.click();
+    await expect(paragraphPage.codeEditor).toBeVisible();
+
+    await paragraphPage.toggleOutputButton.click();
+    await expect(paragraphPage.dynamicForms).toHaveCount(0);
+    await paragraphPage.toggleOutputButton.click();
+    await expect(paragraphPage.dynamicForms).toHaveCount(1);
+
+    await paragraphPage.openSettingsDropdown();
+    await paragraphPage.settingsMenuItem('Show line numbers').click();
+    await expect(paragraphPage.lineNumbers).toBeVisible();
+    await expect(paragraphPage.settingsMenuItem('Hide line 
numbers')).toBeVisible();
+    await paragraphPage.settingsMenuItem('Hide line numbers').click();
+    await expect(paragraphPage.lineNumbers).toHaveCount(0);
+    await page.keyboard.press('Escape');
+
+    await paragraphPage.openSettingsDropdown();
+    await paragraphPage.settingsMenuItem('Disable run').click();
+    await expect(paragraphPage.runButton).toHaveCount(0);
+    await expect(paragraphPage.settingsMenuItem('Enable run')).toBeVisible();
+    await paragraphPage.settingsMenuItem('Enable run').click();
     await expect(paragraphPage.runButton).toBeVisible();
-    await expect(paragraphPage.runButton).toBeEnabled();
+  });
 
-    const notebookKeyboardPage = new NotebookKeyboardPage(page);
-    const code = '%python\nimport time;time.sleep(10)\nprint("Done")';
-    // Seed the run prerequisite without Monaco's per-keystroke auto-closing 
edits.
-    await notebookKeyboardPage.setCodeEditorContent(code);
-    await expect.poll(() => 
notebookKeyboardPage.getParagraphTextByIndex(0)).toBe(code);
+  test('should disable insertion and expose cancellation state while running', 
async ({ page }, testInfo) => {
+    addPageAnnotation(PAGES.WORKSPACE.NOTEBOOK_ADD_PARAGRAPH, testInfo);
+    addPageAnnotation(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH_CONTROL, testInfo);
+    addPageAnnotation(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH_FOOTER, testInfo);
+    addPageAnnotation(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH_PROGRESS, testInfo);
+    const keyboard = new NotebookKeyboardPage(page);
 
-    await paragraphPage.runParagraph();
+    const code = '%python\nimport time\ntime.sleep(10)\nprint("Done")';
+    await keyboard.setCodeEditorContent(code);
+    await expect.poll(() => keyboard.getParagraphTextByIndex(0)).toBe(code);
+    await paragraphPage.runAllButton.click();
+    await expect(paragraphPage.confirmButton).toBeVisible();
+    await paragraphPage.confirmButton.click();
 
-    // The control also renders while the paragraph is PENDING, so wait for 
the run to start
-    // before cancelling it.
     await expect(paragraphPage.cancelButton).toBeVisible({ timeout: 10000 });
-    await expect(paragraphPage.status).toHaveText('RUNNING', { timeout: 30000 
});
+    await expect(paragraphPage.status).toHaveText('RUNNING', { timeout: 60000 
});
+    await expect(paragraphPage.progressBar).toBeVisible();
+    await expect(paragraphPage.elapsedTime).toHaveText(/^Started .+ ago\.$/, { 
timeout: 15000 });
+    await 
expect(paragraphPage.addParagraphAboveLink).toHaveClass(/\bdisabled\b/);
+    await 
expect(paragraphPage.addParagraphBelowLink).toHaveClass(/\bdisabled\b/);
 
-    await paragraphPage.cancelButton.click();
+    await paragraphPage.clickAddParagraphBelow();
+    await expect(paragraphPage.paragraphContainers).toHaveCount(1);
 
-    // Waiting for the button to disappear would also pass on natural 
completion, since the
-    // control is hidden once the paragraph leaves PENDING or RUNNING. Only 
cancelling reaches
-    // ABORT. The interpreter finishes the statement it is on first, so allow 
for the sleep.
+    await paragraphPage.cancelButton.click();
     await expect(paragraphPage.status).toHaveText('ABORT', { timeout: 30000 });
+    await expect(paragraphPage.progressIndicator).toHaveCount(0);
+    await expect(paragraphPage.elapsedTime).toHaveCount(0);
   });
 });

Reply via email to