This is an automated email from the ASF dual-hosted git repository.
tbonelee 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 386d110c08 [ZEPPELIN-6544] Harden keyboard-shortcut e2e helpers
against silent passes
386d110c08 is described below
commit 386d110c089fb26bbbe6d61b30dd395c6c181037
Author: YONGJAE LEE (이용재) <[email protected]>
AuthorDate: Sun Jul 19 02:24:53 2026 +0900
[ZEPPELIN-6544] Harden keyboard-shortcut e2e helpers against silent passes
### What is this PR for?
Follow-up to ZEPPELIN-6536 (#5304). A cross-review of the merged
keyboard-shortcut suite found four silent-pass risks in the helpers; this PR
closes them:
- `pressShortcutFromHostUntil` now polls for the press's effect before
retrying, so a slow toggle is not double-pressed and the loop cannot return on
the transient state between two in-flight effects.
- The clear-output test asserts the result is visible before pressing;
previously a no-output run let the helper skip the press entirely.
- InsertAbove/InsertBelow emptiness assertions now gate on the editor being
rendered (`waitForEditorRendered`); an unrendered editor reads as `''` and
matched vacuously.
- `setCodeEditorContent` compares normalized equality instead of
containment, so stale fixture text fails at seeding time.
Test-only change; no product code touched.
### What type of PR is it?
Improvement
### What is the Jira issue?
ZEPPELIN-6544
### How should this be tested?
### Questions:
* Does the license files need to update? No
* Is there breaking changes for older versions? No
* Does this needs documentation? No
Closes #5315 from voidmatcha/fix/harden-keyboard-e2e-helpers.
Signed-off-by: ChanHo Lee <[email protected]>
---
.../e2e/models/notebook-keyboard-page.ts | 138 ++++++++++++---------
.../keyboard/notebook-keyboard-shortcuts.spec.ts | 61 +++++----
2 files changed, 121 insertions(+), 78 deletions(-)
diff --git a/zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts
b/zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts
index 44e5c3118c..fb4a24a3a6 100644
--- a/zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts
+++ b/zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts
@@ -10,7 +10,7 @@
* limitations under the License.
*/
-import test, { expect, Locator, Page } from '@playwright/test';
+import { expect, Locator, Page } from '@playwright/test';
import { navigateToNotebookWithFallback } from '../utils';
import { ShortcutsMap } from '../../src/app/key-binding/shortcuts-map';
import { ParagraphActions } from '../../src/app/key-binding/paragraph-actions';
@@ -123,6 +123,8 @@ export class NotebookKeyboardPage extends BasePage {
if (!(await isSettled())) {
await this.focusParagraphHost(paragraphIndex);
await press();
+ // Wait for this press's effect before retrying — an immediate recheck
would double-press a slow toggle. The wait must comfortably exceed legitimate
settle latency so only a genuinely dropped press is retried.
+ await expect.poll(isSettled, { timeout: 10000 }).toBe(true);
}
expect(await isSettled()).toBe(true);
}).toPass({ timeout: 15000 });
@@ -369,27 +371,30 @@ export class NotebookKeyboardPage extends BasePage {
return this.readEditorText(this.paragraphContainer.first());
}
+ // Gate for emptiness assertions — an empty Monaco model still renders one
(empty) .view-line.
+ async waitForEditorRendered(paragraphIndex: number): Promise<void> {
+ const paragraph = this.getParagraphByIndex(paragraphIndex);
+ await expect(paragraph.locator('.monaco-editor
.view-line').first()).toBeAttached({ timeout: 10000 });
+ }
+
// Reconstruct editor text from Monaco's absolutely-positioned `.view-line`
divs sorted by top (DOM order need not match line order), via textContent;
innerText is "" for off-layout lines in headless Chromium.
+ // Constraints: Monaco virtualizes lines (keep fixtures short); returns ''
for both an empty and a not-yet-rendered editor.
private async readEditorText(paragraph: Locator): Promise<string> {
const monaco = paragraph.locator('.monaco-editor').first();
- if ((await monaco.count()) > 0) {
- const text = await monaco.evaluate((el: Element) => {
- const lines = Array.from(el.querySelectorAll('.view-line')) as
HTMLElement[];
- lines.sort((a, b) => parseInt(a.style.top || '0', 10) -
parseInt(b.style.top || '0', 10));
- return lines.map(l => (l.textContent || '').replace(/\u00a0/g, '
')).join('\n');
- });
- if (text.trim().length > 0) {
- return text;
- }
+ if ((await monaco.count()) === 0) {
+ return '';
}
- const textarea = paragraph.locator('.monaco-editor textarea').first();
- if ((await textarea.count()) > 0) {
- const value = await textarea.inputValue().catch(() => '');
- if (value) {
- return value;
- }
- }
- return '';
+ // Reconstruct from view-lines only. The hidden textarea holds Monaco's
IME buffer
+ // (a fragment, not the full model), so falling back to it returns partial
text and
+ // makes reads non-deterministic. Callers poll, so '' while lines render
is fine.
+ return monaco.evaluate((el: Element) => {
+ const lines = Array.from(el.querySelectorAll('.view-line')) as
HTMLElement[];
+ lines.sort((a, b) => parseInt(a.style.top || '0', 10) -
parseInt(b.style.top || '0', 10));
+ // Normalize all Unicode space separators (NBSP etc. that Monaco renders
for
+ // whitespace) to a plain space, but keep line structure so exact-match
assertions
+ // still verify it.
+ return lines.map(l => (l.textContent || '').replace(/\p{Zs}/gu, '
')).join('\n');
+ });
}
async setCodeEditorContent(content: string, paragraphIndex: number = 0):
Promise<void> {
@@ -398,55 +403,74 @@ export class NotebookKeyboardPage extends BasePage {
return;
}
- await this.tryFocusCodeEditor(paragraphIndex);
- if (this.page.isClosed()) {
- console.warn('Cannot set code editor content: page closed after
focusing');
- return;
- }
+ // Seed via REST, not the editor: per-keystroke seeding fights the
collaborative
+ // patch loop, merging a fresh note's "%python" prefix into the content on
Firefox.
+ await this.seedParagraphViaRest(paragraphIndex, content);
- const paragraph = this.getParagraphByIndex(paragraphIndex);
- const editorInput = paragraph.locator('.monaco-editor .inputarea,
.monaco-editor textarea').first();
+ await this.tryFocusCodeEditor(paragraphIndex);
- const browserName = test.info().project.name;
- if (browserName !== 'firefox') {
- await editorInput.waitFor({ state: 'visible', timeout: 30000 });
- await editorInput.click();
- await editorInput.clear();
+ // The REST flush resets the cursor to (1,1); restore end-of-content so
tests that
+ // seed then press End/Enter start from the right line. Keymap-independent.
+ if (content) {
+ await this.pressSelectAll();
+ await this.page.keyboard.press('ArrowRight');
}
+ }
- // Clear existing content with keyboard shortcuts for better reliability
- await editorInput.focus();
+ private noteIdFromUrl(): string {
+ const match = /\/notebook\/([^/?#]+)/.exec(this.page.url());
+ if (!match) {
+ throw new Error(`No noteId in URL ${this.page.url()}`);
+ }
+ return match[1];
+ }
- if (browserName === 'firefox') {
- // Clear by backspacing existing content length
- const currentContent = await editorInput.inputValue();
- const contentLength = currentContent.length;
+ // Read a paragraph's persisted text from the server. Use this instead of
the editor
+ // DOM when asserting content equality: Monaco reconstructs the same text
with
+ // different whitespace codepoints across reads, which breaks byte-exact
matches.
+ async getParagraphTextByIndex(paragraphIndex: number): Promise<string> {
+ const noteId = this.noteIdFromUrl();
+ const response = await this.page.request.get(`/api/notebook/${noteId}`, {
failOnStatusCode: false });
+ if (!response.ok()) {
+ throw new Error(`Fetch notebook REST request failed:
${response.status()} ${await response.text()}`);
+ }
+ const json = (await response.json()) as { body?: { paragraphs?: Array<{
text?: string }> } };
+ return json.body?.paragraphs?.[paragraphIndex]?.text ?? '';
+ }
- // Position cursor at end and backspace all content
- await this.page.keyboard.press('End');
- for (let i = 0; i < contentLength; i++) {
- await this.page.keyboard.press('Backspace');
- }
+ private async seedParagraphViaRest(paragraphIndex: number, content: string):
Promise<void> {
+ const noteId = this.noteIdFromUrl();
+ const paragraph = this.getParagraphByIndex(paragraphIndex);
- // JUSTIFIED: Monaco textarea can be covered by editor overlays during
fixture setup.
- await editorInput.fill(content, { force: true });
- } else {
- // Standard clearing for other browsers
- await this.pressSelectAll();
- await this.page.keyboard.press('Delete');
- // JUSTIFIED: Monaco textarea can be overlaid after select+delete during
fixture setup.
- await editorInput.fill(content, { force: true });
+ const paragraphId = await this.resolveParagraphId(noteId, paragraphIndex);
+ const response = await
this.page.request.put(`/api/notebook/${noteId}/paragraph/${paragraphId}`, {
+ data: { text: content },
+ failOnStatusCode: false
+ });
+ if (!response.ok()) {
+ throw new Error(`Seed paragraph REST request failed:
${response.status()} ${await response.text()}`);
}
- // Wait for the full normalized editor content to avoid stale Monaco
renders.
+ // The PUT broadcasts the paragraph; wait for the flush to render the
exact content.
const expected = content.replace(/\s+/g, '');
- if (expected.length === 0) {
- await expect.poll(async () => (await
this.readEditorText(paragraph)).trim(), { timeout: 10000 }).toBe('');
- } else {
- await expect
- .poll(async () => (await
this.readEditorText(paragraph)).replace(/\s+/g, ''), { timeout: 10000 })
- .toContain(expected);
- }
+ await expect
+ .poll(async () => (await this.readEditorText(paragraph)).replace(/\s+/g,
''), { timeout: 15000 })
+ .toBe(expected);
+ }
+
+ private async resolveParagraphId(noteId: string, paragraphIndex: number):
Promise<string> {
+ // Retry: a paragraph just inserted through the UI may not be persisted
server-side yet.
+ let id: string | undefined;
+ await expect(async () => {
+ const response = await this.page.request.get(`/api/notebook/${noteId}`,
{ failOnStatusCode: false });
+ if (!response.ok()) {
+ throw new Error(`Fetch notebook REST request failed:
${response.status()} ${await response.text()}`);
+ }
+ const json = (await response.json()) as { body?: { paragraphs?: Array<{
id?: string }> } };
+ id = json.body?.paragraphs?.[paragraphIndex]?.id;
+ expect(id, `No paragraph at index ${paragraphIndex} in note
${noteId}`).toBeTruthy();
+ }).toPass({ timeout: 10000, intervals: [200, 400, 800] });
+ return id!;
}
// Helper methods for verifying shortcut effects
diff --git
a/zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts
b/zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts
index 2f43bb6f33..6f3029fa51 100644
---
a/zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts
+++
b/zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts
@@ -309,7 +309,9 @@ test.describe.serial('Comprehensive Keyboard Shortcuts
(ShortcutsMap)', () => {
const finalCount = await keyboardPage.getParagraphCount();
expect(finalCount).toBe(initialCount + 1);
- // And: the new paragraph at index 0 holds no user content; empty or
just an interpreter directive (poll so the async insert/render settles).
+ // And: the new paragraph at index 0 holds no user content; empty or
just an interpreter directive.
+ // Render gate: an unrendered editor reads as '' and would vacuously
match.
+ await keyboardPage.waitForEditorRendered(0);
await expect.poll(() =>
keyboardPage.getCodeEditorContentByIndex(0).then(c =>
c.trim())).toMatch(/^(%\w+)?$/);
// And the original content moved to index 1 (normalize whitespace;
Monaco reflows).
@@ -343,6 +345,8 @@ test.describe.serial('Comprehensive Keyboard Shortcuts
(ShortcutsMap)', () => {
expect(originalParagraphContent).toMatch(/Content\s+for\s+insert\s+below\s+test/);
// And: a new paragraph exists at index 1 holding no user content.
+ // Render gate: an unrendered editor reads as '' and would vacuously
match.
+ await keyboardPage.waitForEditorRendered(1);
await expect.poll(() =>
keyboardPage.getCodeEditorContentByIndex(1).then(c =>
c.trim())).toMatch(/^(%\w+)?$/);
});
});
@@ -354,7 +358,10 @@ test.describe.serial('Comprehensive Keyboard Shortcuts
(ShortcutsMap)', () => {
await keyboardPage.setCodeEditorContent('%md\n# Copy Test\nContent to be
copied below');
const initialCount = await keyboardPage.getParagraphCount();
- const originalContent = await
keyboardPage.getCodeEditorContentByIndex(0);
+ // Compare persisted server text, not the editor DOM: the clone's
correctness is
+ // that the copied paragraph is stored identically, and reading from the
server
+ // avoids Monaco's non-deterministic whitespace rendering.
+ const originalContent = await keyboardPage.getParagraphTextByIndex(0);
// When: User presses Control+Shift+C
await keyboardPage.pressInsertCopy();
@@ -362,8 +369,8 @@ test.describe.serial('Comprehensive Keyboard Shortcuts
(ShortcutsMap)', () => {
// Then: a copy is inserted below carrying the same text, and the
original is unchanged
await keyboardPage.waitForParagraphCountChange(initialCount + 1);
expect(await keyboardPage.getParagraphCount()).toBe(initialCount + 1);
- await expect.poll(() =>
keyboardPage.getCodeEditorContentByIndex(0)).toBe(originalContent);
- await expect.poll(() =>
keyboardPage.getCodeEditorContentByIndex(1)).toBe(originalContent);
+ await expect.poll(() =>
keyboardPage.getParagraphTextByIndex(0)).toBe(originalContent);
+ await expect.poll(() =>
keyboardPage.getParagraphTextByIndex(1)).toBe(originalContent);
});
});
@@ -389,9 +396,9 @@ test.describe.serial('Comprehensive Keyboard Shortcuts
(ShortcutsMap)', () => {
const paragraphCount = await keyboardPage.getParagraphCount();
expect(paragraphCount).toBe(2);
- // Verify initial content before move
- const initialFirst = await keyboardPage.getCodeEditorContentByIndex(0);
- const initialSecond = await keyboardPage.getCodeEditorContentByIndex(1);
+ // Capture server-persisted text (avoids Monaco DOM whitespace
non-determinism)
+ const initialFirst = await keyboardPage.getParagraphTextByIndex(0);
+ const initialSecond = await keyboardPage.getParagraphTextByIndex(1);
// Focus on second paragraph for move operation
await keyboardPage.tryFocusCodeEditor(1);
@@ -403,9 +410,13 @@ test.describe.serial('Comprehensive Keyboard Shortcuts
(ShortcutsMap)', () => {
const finalParagraphCount = await keyboardPage.getParagraphCount();
expect(finalParagraphCount).toBe(2);
- // And: Paragraph positions should be swapped (poll until the move lands
in the DOM)
- await expect.poll(() =>
keyboardPage.getCodeEditorContentByIndex(0)).toBe(initialSecond);
- await expect.poll(() =>
keyboardPage.getCodeEditorContentByIndex(1)).toBe(initialFirst);
+ // And: positions are swapped (poll the server until the move persists)
+ await expect.poll(() =>
keyboardPage.getParagraphTextByIndex(0)).toBe(initialSecond);
+ await expect.poll(() =>
keyboardPage.getParagraphTextByIndex(1)).toBe(initialFirst);
+ // And the visible order reflects the swap. Containment on a distinctive
marker
+ // proves the user-visible reorder without an exact Monaco whitespace
match.
+ await
expect(keyboardPage.getParagraphByIndex(0).locator('.view-lines')).toContainText('Second
Paragraph');
+ await
expect(keyboardPage.getParagraphByIndex(1).locator('.view-lines')).toContainText('First
Paragraph');
});
});
@@ -431,9 +442,9 @@ test.describe.serial('Comprehensive Keyboard Shortcuts
(ShortcutsMap)', () => {
const paragraphCount = await keyboardPage.getParagraphCount();
expect(paragraphCount).toBe(2);
- // Verify initial content before move
- const initialFirst = await keyboardPage.getCodeEditorContentByIndex(0);
- const initialSecond = await keyboardPage.getCodeEditorContentByIndex(1);
+ // Capture server-persisted text (avoids Monaco DOM whitespace
non-determinism)
+ const initialFirst = await keyboardPage.getParagraphTextByIndex(0);
+ const initialSecond = await keyboardPage.getParagraphTextByIndex(1);
// Focus first paragraph for move operation
await keyboardPage.tryFocusCodeEditor(0);
@@ -445,9 +456,13 @@ test.describe.serial('Comprehensive Keyboard Shortcuts
(ShortcutsMap)', () => {
const finalParagraphCount = await keyboardPage.getParagraphCount();
expect(finalParagraphCount).toBe(2);
- // And: Paragraph positions should be swapped (poll until the move lands
in the DOM)
- await expect.poll(() =>
keyboardPage.getCodeEditorContentByIndex(0)).toBe(initialSecond);
- await expect.poll(() =>
keyboardPage.getCodeEditorContentByIndex(1)).toBe(initialFirst);
+ // And: positions are swapped (poll the server until the move persists)
+ await expect.poll(() =>
keyboardPage.getParagraphTextByIndex(0)).toBe(initialSecond);
+ await expect.poll(() =>
keyboardPage.getParagraphTextByIndex(1)).toBe(initialFirst);
+ // And the visible order reflects the swap. Containment on a distinctive
marker
+ // proves the user-visible reorder without an exact Monaco whitespace
match.
+ await
expect(keyboardPage.getParagraphByIndex(0).locator('.view-lines')).toContainText('Second
Paragraph');
+ await
expect(keyboardPage.getParagraphByIndex(1).locator('.view-lines')).toContainText('First
Paragraph');
});
});
@@ -559,8 +574,11 @@ test.describe.serial('Comprehensive Keyboard Shortcuts
(ShortcutsMap)', () => {
const statusElBefore =
keyboardPage.paragraphContainer.first().locator('.status');
await
expect(statusElBefore).toHaveText(/FINISHED|ERROR|PENDING|RUNNING/);
- // When: User presses Control+Alt+L (editor hidden after %md run;
dispatch from the host)
+ // Gate: without visible output, isSettled starts true and the helper
would skip the press entirely.
const resultLocator =
keyboardPage.getParagraphByIndex(0).locator('[data-testid="paragraph-result"]');
+ await expect(resultLocator).toBeVisible();
+
+ // When: User presses Control+Alt+L (editor hidden after %md run;
dispatch from the host)
await keyboardPage.pressShortcutFromHostUntil(
0,
() => keyboardPage.pressClearOutput(),
@@ -932,7 +950,7 @@ test.describe.serial('Comprehensive Keyboard Shortcuts
(ShortcutsMap)', () => {
// Verify error result exists (invalid syntax produces a final ERROR or
FINISHED with error output)
// JUSTIFIED: single-paragraph test notebook; first() is deterministic
const statusElError =
keyboardPage.paragraphContainer.first().locator('.status');
- await expect(statusElError).toHaveText(/FINISHED|ERROR/, { timeout:
30000 });
+ await expect(statusElError).toHaveText(/FINISHED|ERROR/, { timeout:
60000 });
// When: User continues with shortcuts (insert new paragraph)
const initialCount = await keyboardPage.getParagraphCount();
@@ -945,11 +963,12 @@ test.describe.serial('Comprehensive Keyboard Shortcuts
(ShortcutsMap)', () => {
await keyboardPage.setCodeEditorContent('%md\n# Recovery Test\nShortcuts
work after error', newParagraphIndex);
await keyboardPage.pressRunParagraph();
- // Then: Shortcut execution still reaches a terminal state
- await keyboardPage.waitForParagraphExecution(newParagraphIndex);
+ // Then: Shortcut execution still reaches a terminal state (real
interpreter run;
+ // allow extra time as a cold interpreter under CI load can stay RUNNING
past 30s)
+ await keyboardPage.waitForParagraphExecution(newParagraphIndex, 60000);
// JUSTIFIED: newParagraphIndex is dynamically computed from
getParagraphCount(); nth() is the only way to address this specific paragraph
const statusElNew =
keyboardPage.paragraphContainer.nth(newParagraphIndex).locator('.status');
- await expect(statusElNew).toHaveText(/FINISHED|ERROR/, { timeout: 30000
});
+ await expect(statusElNew).toHaveText(/FINISHED|ERROR/, { timeout: 60000
});
});
test('should gracefully handle shortcuts when no paragraph is focused',
async () => {