From 2163d387dcc050fcbeecdf05fc7c6e2069dc4723 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Thu, 3 Sep 2026 01:00:28 +0900 Subject: [PATCH 1/3] [ZEPPELIN-6660] Add notebook parity registry --- .github/workflows/frontend.yml | 6 + .../e2e/scenarios/notebook-parity.json | 516 ++++++++++++++++++ .../e2e/scenarios/notebook-parity.md | 169 ++++++ .../action-bar-functionality.spec.ts | 2 +- .../tests/notebook/inline-completion.spec.ts | 7 +- .../notebook-keyboard-shortcuts.spec.ts | 3 +- .../notebook/main/notebook-container.spec.ts | 2 +- .../paragraph/paragraph-functionality.spec.ts | 6 +- zeppelin-web-angular/package.json | 2 + .../check-notebook-parity-scenarios.mjs | 34 ++ .../check-notebook-parity-scenarios.test.mjs | 294 ++++++++++ .../generate-notebook-parity-scenarios.mjs | 25 + .../scripts/notebook-parity-scenarios.mjs | 477 ++++++++++++++++ 13 files changed, 1535 insertions(+), 8 deletions(-) create mode 100644 zeppelin-web-angular/e2e/scenarios/notebook-parity.json create mode 100644 zeppelin-web-angular/e2e/scenarios/notebook-parity.md create mode 100644 zeppelin-web-angular/scripts/check-notebook-parity-scenarios.mjs create mode 100644 zeppelin-web-angular/scripts/check-notebook-parity-scenarios.test.mjs create mode 100644 zeppelin-web-angular/scripts/generate-notebook-parity-scenarios.mjs create mode 100644 zeppelin-web-angular/scripts/notebook-parity-scenarios.mjs diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 9e59901692f..69bc73cf5dd 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -56,6 +56,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + fetch-depth: 0 - name: Tune Runner VM uses: ./.github/actions/tune-runner-vm - name: Set up JDK 11 @@ -96,6 +98,10 @@ jobs: if: matrix.mode == 'anonymous' working-directory: zeppelin-web-angular run: ./node/npm run check:websocket-contract + - name: Check notebook parity scenarios + if: matrix.mode == 'anonymous' + working-directory: zeppelin-web-angular + run: ./node/npm run check:notebook-parity-scenarios # Keeps the Karma coverage previously run by the removed zeppelin-web e2e job - name: Run zeppelin-web unit tests if: matrix.mode == 'anonymous' diff --git a/zeppelin-web-angular/e2e/scenarios/notebook-parity.json b/zeppelin-web-angular/e2e/scenarios/notebook-parity.json new file mode 100644 index 00000000000..681344e395e --- /dev/null +++ b/zeppelin-web-angular/e2e/scenarios/notebook-parity.json @@ -0,0 +1,516 @@ +{ + "schemaVersion": 1, + "reviewedCommit": "d5b57b12fd0c5e1d885767aabe06b242debf8300", + "scenarios": [ + { + "id": "NB-PARITY-001", + "name": "Notebook container structure is visible", + "area": "navigation", + "preconditions": ["A disposable notebook route is open."], + "action": "Render the notebook route.", + "observableOutcomes": ["The notebook container is visible with the expected container class."], + "interpreter": null, + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "allow", + "runner": "not-applicable" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "unverified", + "runner": "not-applicable" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts", + "symbol": "Notebook Container Component" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-page.ts", + "symbol": "NotebookPage" + } + ], + "coverage": { + "status": "covered", + "tests": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts", + "title": "[NB-PARITY-001] should display notebook container with proper structure", + "projects": ["chromium", "firefox", "webkit"] + } + ], + "issues": [], + "uncoveredOutcomes": [] + } + }, + { + "id": "NB-PARITY-002", + "name": "Notebook title can be displayed and edited", + "area": "navigation", + "preconditions": ["A disposable notebook route is open."], + "action": "Open the title editor and rename the notebook.", + "observableOutcomes": ["The title editor is visible and the changed title is reflected in the notebook header."], + "interpreter": null, + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "deny", + "runner": "not-applicable" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "unverified", + "runner": "not-applicable" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts", + "symbol": "Notebook Action Bar Functionality" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-action-bar-page.ts", + "symbol": "NotebookActionBarPage" + } + ], + "coverage": { + "status": "covered", + "tests": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts", + "title": "[NB-PARITY-002] should display and allow title editing with tooltip", + "projects": ["chromium", "firefox", "webkit"] + } + ], + "issues": [], + "uncoveredOutcomes": [] + } + }, + { + "id": "NB-PARITY-003", + "name": "Paragraph enters editing mode on double click", + "area": "editor", + "preconditions": ["A disposable notebook with at least one paragraph is open."], + "action": "Double-click the paragraph.", + "observableOutcomes": ["The code editor becomes visible for the paragraph."], + "interpreter": null, + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "deny", + "runner": "not-applicable" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "unverified", + "runner": "not-applicable" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts", + "symbol": "Notebook Paragraph Functionality" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts", + "symbol": "NotebookParagraphPage" + } + ], + "coverage": { + "status": "covered", + "tests": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts", + "title": "[NB-PARITY-003] should support double-click editing functionality", + "projects": ["chromium", "firefox", "webkit"] + } + ], + "issues": [], + "uncoveredOutcomes": [] + } + }, + { + "id": "NB-PARITY-004", + "name": "Paragraph add buttons are visible", + "area": "editor", + "preconditions": ["A disposable notebook with a paragraph is open."], + "action": "Inspect the paragraph controls.", + "observableOutcomes": ["Add-paragraph controls are visible where a user can add another paragraph."], + "interpreter": null, + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "deny", + "runner": "not-applicable" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "unverified", + "runner": "not-applicable" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts", + "symbol": "Notebook Paragraph Functionality" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts", + "symbol": "NotebookParagraphPage" + } + ], + "coverage": { + "status": "covered", + "tests": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts", + "title": "[NB-PARITY-004] should display add paragraph buttons", + "projects": ["chromium", "firefox", "webkit"] + } + ], + "issues": [], + "uncoveredOutcomes": [] + } + }, + { + "id": "NB-PARITY-005", + "name": "Shift+Enter executes a markdown paragraph", + "area": "shortcut", + "preconditions": ["A disposable notebook paragraph is focused in the code editor."], + "action": "Type Markdown content and press Shift+Enter.", + "observableOutcomes": ["The paragraph executes and renders the Markdown heading result."], + "interpreter": "md", + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "deny", + "runner": "allow" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "unverified", + "runner": "unverified" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts", + "symbol": "ParagraphActions.Run" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts", + "symbol": "NotebookKeyboardPage" + } + ], + "coverage": { + "status": "covered", + "tests": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts", + "title": "[NB-PARITY-005] should execute markdown paragraph with Shift+Enter", + "projects": ["chromium", "firefox", "webkit"] + } + ], + "issues": [], + "uncoveredOutcomes": [] + } + }, + { + "id": "NB-PARITY-010", + "name": "History inline completion can be dismissed without losing editor focus", + "area": "editor", + "preconditions": [ + "A notebook contains a Python paragraph with prior text that can seed inline completion.", + "The notebook route is opened with aiInlineComplete enabled." + ], + "action": "Type a completion prefix in Monaco and press Escape while the completion is visible.", + "observableOutcomes": [ + "The inline completion suggestion is shown from notebook history.", + "The Monaco input remains focused after the first Escape dismisses the suggestion." + ], + "interpreter": "python", + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "not-applicable", + "runner": "not-applicable" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "not-applicable", + "runner": "not-applicable" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts", + "symbol": "Inline completion" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts", + "symbol": "NotebookKeyboardPage" + } + ], + "coverage": { + "status": "covered", + "tests": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts", + "title": "[NB-PARITY-010] shows history completion and preserves focus when dismissed", + "projects": ["chromium", "firefox", "webkit"] + } + ], + "issues": [], + "uncoveredOutcomes": [] + } + }, + { + "id": "NB-PARITY-011", + "name": "The second Escape after inline completion dismissal blurs the editor", + "area": "editor", + "preconditions": [ + "A notebook contains a Python paragraph with prior text that can seed inline completion.", + "The notebook route is opened with aiInlineComplete enabled.", + "The browser is Chromium." + ], + "action": "Press Escape once to dismiss the completion and press Escape again.", + "observableOutcomes": [ + "The first Escape keeps Monaco focused.", + "The second Escape blurs the Monaco input in Chromium." + ], + "interpreter": "python", + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "not-applicable", + "runner": "not-applicable" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "not-applicable", + "runner": "not-applicable" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts", + "symbol": "Inline completion" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts", + "symbol": "NotebookKeyboardPage" + } + ], + "coverage": { + "status": "covered", + "tests": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts", + "title": "[NB-PARITY-011] blurs the editor on the second Escape after dismissing completion", + "projects": ["chromium"] + } + ], + "issues": [], + "uncoveredOutcomes": [] + } + }, + { + "id": "NB-PARITY-021", + "name": "Text and table result displays preserve output semantics after paragraph execution", + "area": "result", + "preconditions": [ + "A notebook has a Python paragraph that prints text.", + "A notebook has a paragraph that returns tabular output." + ], + "action": "Run the paragraph from the paragraph control and inspect the rendered result panel.", + "observableOutcomes": [ + "The result display becomes visible and is not empty.", + "The UI offers every display mode that the Angular notebook exposes for the returned result type.", + "Visualization control changes preserve the field mapping from result columns to configured dimensions or measures.", + "The paragraph's persisted config reflects the resulting configuration object after a visualization option changes.", + "Text and table results expose accessible table output row by row so migrated React rendering can be compared without relying on screenshots alone." + ], + "interpreter": "python", + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "deny", + "runner": "allow" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "unverified", + "runner": "unverified" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.html", + "symbol": "paragraph result display" + }, + { + "path": "zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/progress/progress.component.ts", + "symbol": "ProgressComponent" + }, + { + "path": "zeppelin-web-angular/projects/zeppelin-visualization/src/table-transformation.ts", + "symbol": "TableTransformation" + }, + { + "path": "zeppelin-web-angular/projects/zeppelin-visualization/src/pivot-transformation.ts", + "symbol": "PivotTransformation" + }, + { + "path": "zeppelin-web-angular/projects/zeppelin-visualization/src/visualization.ts", + "symbol": "Visualization" + } + ], + "coverage": { + "status": "partial", + "tests": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts", + "title": "[NB-PARITY-021] should display result system properly", + "projects": ["chromium", "firefox", "webkit"] + } + ], + "issues": ["ZEPPELIN-6514", "ZEPPELIN-6516"], + "uncoveredOutcomes": [ + "The UI offers every display mode that the Angular notebook exposes for the returned result type.", + "Visualization control changes preserve the field mapping from result columns to configured dimensions or measures.", + "The paragraph's persisted config reflects the resulting configuration object after a visualization option changes.", + "Text and table results expose accessible table output row by row so migrated React rendering can be compared without relying on screenshots alone." + ] + } + }, + { + "id": "NB-PARITY-050", + "name": "Notebook editor persists the latest text after typing stops", + "area": "persistence", + "preconditions": [ + "A disposable notebook with one editable paragraph is open.", + "The user can edit the paragraph." + ], + "action": "Replace the paragraph text and stop typing long enough for the notebook save path to acknowledge the edit.", + "observableOutcomes": [ + "The persisted paragraph text equals the latest typed text.", + "The save assertion is based on observable persistence or wire evidence, not an internal timer." + ], + "interpreter": null, + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "deny", + "runner": "not-applicable" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "unverified", + "runner": "not-applicable" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts", + "symbol": "CodeEditorComponent" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts", + "symbol": "NotebookKeyboardPage" + } + ], + "coverage": { + "status": "gap", + "tests": [], + "issues": ["ZEPPELIN-6661"], + "uncoveredOutcomes": [] + } + }, + { + "id": "NB-PARITY-051", + "name": "Notebook editor does not lose an edit made while a prior save is in flight", + "area": "persistence", + "preconditions": [ + "A disposable notebook with one editable paragraph is open.", + "The first paragraph save request can be observed before it completes." + ], + "action": "Edit the paragraph, keep the first save in flight, then make a second edit.", + "observableOutcomes": [ + "The first in-flight save does not overwrite or drop the second edit.", + "A later observable save or reconciliation persists the second edit." + ], + "interpreter": null, + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "deny", + "runner": "not-applicable" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "unverified", + "runner": "not-applicable" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts", + "symbol": "CodeEditorComponent" + }, + { + "path": "zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts", + "symbol": "NotebookComponent" + } + ], + "coverage": { + "status": "gap", + "tests": [], + "issues": ["ZEPPELIN-6661"], + "uncoveredOutcomes": [] + } + }, + { + "id": "NB-PARITY-060", + "name": "Notebook honors host theme selection", + "area": "theme", + "preconditions": ["The user can choose light, dark, or system theme from the host shell."], + "action": "Change the host theme while a notebook surface is mounted.", + "observableOutcomes": [ + "Notebook text remains readable.", + "Result and chart output inherit the host theme tokens.", + "The selected theme persists after reload." + ], + "interpreter": null, + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "allow", + "runner": "not-applicable" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "unverified", + "runner": "not-applicable" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.tsx", + "symbol": "ZeppelinThemeProvider" + }, + { + "path": "zeppelin-web-angular/e2e/tests/theme/dark-mode.spec.ts", + "symbol": "Dark Mode" + } + ], + "coverage": { + "status": "gap", + "tests": [], + "issues": ["ZEPPELIN-6640"], + "uncoveredOutcomes": [] + } + } + ] +} diff --git a/zeppelin-web-angular/e2e/scenarios/notebook-parity.md b/zeppelin-web-angular/e2e/scenarios/notebook-parity.md new file mode 100644 index 00000000000..818a58cc3ce --- /dev/null +++ b/zeppelin-web-angular/e2e/scenarios/notebook-parity.md @@ -0,0 +1,169 @@ + + +# Notebook Parity Scenarios + + + +Schema version: 1 + +Scenario/Angular baseline commit: `d5b57b12fd0c5e1d885767aabe06b242debf8300` + +Scope note: This is a prioritized baseline, not a complete Notebook inventory. Before a React vertical slice is declared ready, add every affected behavior to this registry and classify its evidence. + +Coverage note: `covered` mechanically means this registry points to a matching executable Playwright test declaration. Semantic adequacy and runtime pass/fail remain review and CI evidence. Role expectations and role verification are deliberately separate. + +| ID | Area | Scenario | Coverage | Roles | Tests | Issues | +| --- | --- | --- | --- | --- | --- | --- | +| NB-PARITY-001 | navigation | Notebook container structure is visible | covered | owner: allow
writer: allow
reader: allow
runner: not-applicable | zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts
[NB-PARITY-001] should display notebook container with proper structure | | +| NB-PARITY-002 | navigation | Notebook title can be displayed and edited | covered | owner: allow
writer: allow
reader: deny
runner: not-applicable | zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts
[NB-PARITY-002] should display and allow title editing with tooltip | | +| NB-PARITY-003 | editor | Paragraph enters editing mode on double click | covered | owner: allow
writer: allow
reader: deny
runner: not-applicable | zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts
[NB-PARITY-003] should support double-click editing functionality | | +| NB-PARITY-004 | editor | Paragraph add buttons are visible | covered | owner: allow
writer: allow
reader: deny
runner: not-applicable | zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts
[NB-PARITY-004] should display add paragraph buttons | | +| NB-PARITY-005 | shortcut | Shift+Enter executes a markdown paragraph | covered | owner: allow
writer: allow
reader: deny
runner: allow | zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts
[NB-PARITY-005] should execute markdown paragraph with Shift+Enter | | +| NB-PARITY-010 | editor | History inline completion can be dismissed without losing editor focus | covered | owner: allow
writer: allow
reader: not-applicable
runner: not-applicable | zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts
[NB-PARITY-010] shows history completion and preserves focus when dismissed | | +| NB-PARITY-011 | editor | The second Escape after inline completion dismissal blurs the editor | covered | owner: allow
writer: allow
reader: not-applicable
runner: not-applicable | zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts
[NB-PARITY-011] blurs the editor on the second Escape after dismissing completion | | +| NB-PARITY-021 | result | Text and table result displays preserve output semantics after paragraph execution | partial | owner: allow
writer: allow
reader: deny
runner: allow | zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts
[NB-PARITY-021] should display result system properly | ZEPPELIN-6514, ZEPPELIN-6516 | +| NB-PARITY-050 | persistence | Notebook editor persists the latest text after typing stops | gap | owner: allow
writer: allow
reader: deny
runner: not-applicable | | ZEPPELIN-6661 | +| NB-PARITY-051 | persistence | Notebook editor does not lose an edit made while a prior save is in flight | gap | owner: allow
writer: allow
reader: deny
runner: not-applicable | | ZEPPELIN-6661 | +| NB-PARITY-060 | theme | Notebook honors host theme selection | gap | owner: allow
writer: allow
reader: allow
runner: not-applicable | | ZEPPELIN-6640 | + +## Scenario Details + +### NB-PARITY-001 Notebook container structure is visible + +- Area: navigation +- Coverage: covered +- Interpreter: not-applicable +- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable +- Preconditions: A disposable notebook route is open. +- Action: Render the notebook route. +- Observable outcomes: The notebook container is visible with the expected container class. +- Evidence: zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts (Notebook Container Component); zeppelin-web-angular/e2e/models/notebook-page.ts (NotebookPage) +- Browser projects: [NB-PARITY-001] should display notebook container with proper structure: chromium, firefox, webkit + +### NB-PARITY-002 Notebook title can be displayed and edited + +- Area: navigation +- Coverage: covered +- Interpreter: not-applicable +- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable +- Preconditions: A disposable notebook route is open. +- Action: Open the title editor and rename the notebook. +- Observable outcomes: The title editor is visible and the changed title is reflected in the notebook header. +- Evidence: zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts (Notebook Action Bar Functionality); zeppelin-web-angular/e2e/models/notebook-action-bar-page.ts (NotebookActionBarPage) +- Browser projects: [NB-PARITY-002] should display and allow title editing with tooltip: chromium, firefox, webkit + +### NB-PARITY-003 Paragraph enters editing mode on double click + +- Area: editor +- Coverage: covered +- Interpreter: not-applicable +- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable +- Preconditions: A disposable notebook with at least one paragraph is open. +- Action: Double-click the paragraph. +- Observable outcomes: The code editor becomes visible for the paragraph. +- Evidence: zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts (Notebook Paragraph Functionality); zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts (NotebookParagraphPage) +- Browser projects: [NB-PARITY-003] should support double-click editing functionality: chromium, firefox, webkit + +### NB-PARITY-004 Paragraph add buttons are visible + +- Area: editor +- Coverage: covered +- Interpreter: not-applicable +- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable +- Preconditions: A disposable notebook with a paragraph is open. +- Action: Inspect the paragraph controls. +- Observable outcomes: Add-paragraph controls are visible where a user can add another paragraph. +- Evidence: zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts (Notebook Paragraph Functionality); zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts (NotebookParagraphPage) +- Browser projects: [NB-PARITY-004] should display add paragraph buttons: chromium, firefox, webkit + +### NB-PARITY-005 Shift+Enter executes a markdown paragraph + +- Area: shortcut +- Coverage: covered +- Interpreter: md +- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: unverified +- Preconditions: A disposable notebook paragraph is focused in the code editor. +- Action: Type Markdown content and press Shift+Enter. +- Observable outcomes: The paragraph executes and renders the Markdown heading result. +- Evidence: zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts (ParagraphActions.Run); zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts (NotebookKeyboardPage) +- Browser projects: [NB-PARITY-005] should execute markdown paragraph with Shift+Enter: chromium, firefox, webkit + +### NB-PARITY-010 History inline completion can be dismissed without losing editor focus + +- Area: editor +- Coverage: covered +- Interpreter: python +- Role verification: owner: unverified; writer: unverified; reader: not-applicable; runner: not-applicable +- Preconditions: A notebook contains a Python paragraph with prior text that can seed inline completion. The notebook route is opened with aiInlineComplete enabled. +- Action: Type a completion prefix in Monaco and press Escape while the completion is visible. +- Observable outcomes: The inline completion suggestion is shown from notebook history. The Monaco input remains focused after the first Escape dismisses the suggestion. +- Evidence: zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts (Inline completion); zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts (NotebookKeyboardPage) +- Browser projects: [NB-PARITY-010] shows history completion and preserves focus when dismissed: chromium, firefox, webkit + +### NB-PARITY-011 The second Escape after inline completion dismissal blurs the editor + +- Area: editor +- Coverage: covered +- Interpreter: python +- Role verification: owner: unverified; writer: unverified; reader: not-applicable; runner: not-applicable +- Preconditions: A notebook contains a Python paragraph with prior text that can seed inline completion. The notebook route is opened with aiInlineComplete enabled. The browser is Chromium. +- Action: Press Escape once to dismiss the completion and press Escape again. +- Observable outcomes: The first Escape keeps Monaco focused. The second Escape blurs the Monaco input in Chromium. +- Evidence: zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts (Inline completion); zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts (NotebookKeyboardPage) +- Browser projects: [NB-PARITY-011] blurs the editor on the second Escape after dismissing completion: chromium + +### NB-PARITY-021 Text and table result displays preserve output semantics after paragraph execution + +- Area: result +- Coverage: partial +- Interpreter: python +- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: unverified +- Preconditions: A notebook has a Python paragraph that prints text. A notebook has a paragraph that returns tabular output. +- Action: Run the paragraph from the paragraph control and inspect the rendered result panel. +- Observable outcomes: The result display becomes visible and is not empty. The UI offers every display mode that the Angular notebook exposes for the returned result type. Visualization control changes preserve the field mapping from result columns to configured dimensions or measures. The paragraph's persisted config reflects the resulting configuration object after a visualization option changes. Text and table results expose accessible table output row by row so migrated React rendering can be compared without relying on screenshots alone. +- Evidence: zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.html (paragraph result display); zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/progress/progress.component.ts (ProgressComponent); zeppelin-web-angular/projects/zeppelin-visualization/src/table-transformation.ts (TableTransformation); zeppelin-web-angular/projects/zeppelin-visualization/src/pivot-transformation.ts (PivotTransformation); zeppelin-web-angular/projects/zeppelin-visualization/src/visualization.ts (Visualization) +- Browser projects: [NB-PARITY-021] should display result system properly: chromium, firefox, webkit +- Uncovered outcomes: The UI offers every display mode that the Angular notebook exposes for the returned result type. Visualization control changes preserve the field mapping from result columns to configured dimensions or measures. The paragraph's persisted config reflects the resulting configuration object after a visualization option changes. Text and table results expose accessible table output row by row so migrated React rendering can be compared without relying on screenshots alone. + +### NB-PARITY-050 Notebook editor persists the latest text after typing stops + +- Area: persistence +- Coverage: gap +- Interpreter: not-applicable +- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable +- Preconditions: A disposable notebook with one editable paragraph is open. The user can edit the paragraph. +- Action: Replace the paragraph text and stop typing long enough for the notebook save path to acknowledge the edit. +- Observable outcomes: The persisted paragraph text equals the latest typed text. The save assertion is based on observable persistence or wire evidence, not an internal timer. +- Evidence: zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts (CodeEditorComponent); zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts (NotebookKeyboardPage) + +### NB-PARITY-051 Notebook editor does not lose an edit made while a prior save is in flight + +- Area: persistence +- Coverage: gap +- Interpreter: not-applicable +- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable +- Preconditions: A disposable notebook with one editable paragraph is open. The first paragraph save request can be observed before it completes. +- Action: Edit the paragraph, keep the first save in flight, then make a second edit. +- Observable outcomes: The first in-flight save does not overwrite or drop the second edit. A later observable save or reconciliation persists the second edit. +- Evidence: zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts (CodeEditorComponent); zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts (NotebookComponent) + +### NB-PARITY-060 Notebook honors host theme selection + +- Area: theme +- Coverage: gap +- Interpreter: not-applicable +- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable +- Preconditions: The user can choose light, dark, or system theme from the host shell. +- Action: Change the host theme while a notebook surface is mounted. +- Observable outcomes: Notebook text remains readable. Result and chart output inherit the host theme tokens. The selected theme persists after reload. +- Evidence: zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.tsx (ZeppelinThemeProvider); zeppelin-web-angular/e2e/tests/theme/dark-mode.spec.ts (Dark Mode) diff --git a/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts index 0ef046d3c14..acd66e6575d 100644 --- a/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts +++ b/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts @@ -39,7 +39,7 @@ test.describe('Notebook Action Bar Functionality', () => { await navigateToNotebookWithFallback(page, testNotebook.noteId); }); - test('should display and allow title editing with tooltip', async ({ page }) => { + test('[NB-PARITY-002] should display and allow title editing with tooltip', async ({ page }) => { const notebookName = `TestNotebook_${Date.now()}`; await expect(actionBarPage.titleEditor).toBeVisible(); diff --git a/zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts index 24f70245735..30ebeb16fda 100644 --- a/zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts +++ b/zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts @@ -51,7 +51,7 @@ const openInlineCompletionEditor = async (page: Page) => { test.describe('Inline completion', () => { addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH_CODE_EDITOR); - test('shows history completion and preserves focus when dismissed', async ({ page }) => { + test('[NB-PARITY-010] shows history completion and preserves focus when dismissed', async ({ page }) => { const { noteId, inputArea } = await openInlineCompletionEditor(page); try { @@ -63,7 +63,10 @@ test.describe('Inline completion', () => { } }); - test('blurs the editor on the second Escape after dismissing completion', async ({ page, browserName }) => { + test('[NB-PARITY-011] blurs the editor on the second Escape after dismissing completion', async ({ + page, + browserName + }) => { test.skip(browserName !== 'chromium', 'Monaco handles the second Escape differently in Firefox and WebKit'); const { noteId, inputArea } = await openInlineCompletionEditor(page); 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 a6c87814b10..e234a91c7fd 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 @@ -67,7 +67,7 @@ test.describe.serial('Comprehensive Keyboard Shortcuts (ShortcutsMap)', () => { // ===== CORE EXECUTION SHORTCUTS ===== test.describe('ParagraphActions.Run: Shift+Enter', () => { - test('should execute markdown paragraph with Shift+Enter', async () => { + test('[NB-PARITY-005] should execute markdown paragraph with Shift+Enter', async () => { // Given: A paragraph with markdown content await keyboardPage.tryFocusCodeEditor(); await keyboardPage.setCodeEditorContent('%md\n# Test Heading\n\nThis is **bold** text.'); @@ -80,6 +80,7 @@ test.describe.serial('Comprehensive Keyboard Shortcuts (ShortcutsMap)', () => { // waitForParagraphExecution gates on the status text, so it is the assertion and throws if the run never settles. await keyboardPage.waitForParagraphExecution(0); + await expect(keyboardPage.paragraphResult.getByRole('heading', { name: 'Test Heading' })).toBeVisible(); }); }); diff --git a/zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts index a8656cc8f39..d15d15f5414 100644 --- a/zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts +++ b/zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts @@ -39,7 +39,7 @@ test.describe('Notebook Container Component', () => { await navigateToNotebookWithFallback(page, testNotebook.noteId); }); - test('should display notebook container with proper structure', async () => { + test('[NB-PARITY-001] should display notebook container with proper structure', async () => { await expect(notebookPage.notebookContainer).toBeVisible(); expect(await notebookPage.getNotebookContainerClass()).toContain('notebook-container'); }); 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 105c4226202..3a7f87be960 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 @@ -47,13 +47,13 @@ test.describe('Notebook Paragraph Functionality', () => { await expect(paragraphPage.controlPanel).toBeVisible(); }); - test('should support double-click editing functionality', async () => { + test('[NB-PARITY-003] should support double-click editing functionality', async () => { await expect(paragraphPage.paragraphContainer).toBeVisible(); await paragraphPage.doubleClickToEdit(); await expect(paragraphPage.codeEditor).toBeVisible(); }); - test('should display add paragraph buttons', async () => { + test('[NB-PARITY-004] should display add paragraph buttons', async () => { await expect(paragraphPage.addParagraphAbove).toBeVisible(); await expect(paragraphPage.addParagraphAbove).toHaveCount(1); await expect(paragraphPage.addParagraphBelow).toBeVisible(); @@ -66,7 +66,7 @@ test.describe('Notebook Paragraph Functionality', () => { await expect(paragraphPage.runButton).toBeEnabled(); }); - test('should display result system properly', async ({ page }) => { + test('[NB-PARITY-021] should display result system properly', async ({ page }) => { await expect(page).toHaveURL(/\/notebook\/[^\/]+/, { timeout: 10000 }); await page.waitForLoadState('domcontentloaded'); await expect(paragraphPage.paragraphContainer).toBeVisible({ timeout: 15000 }); diff --git a/zeppelin-web-angular/package.json b/zeppelin-web-angular/package.json index f40fadc5e8c..62719a295fb 100644 --- a/zeppelin-web-angular/package.json +++ b/zeppelin-web-angular/package.json @@ -14,6 +14,8 @@ "build:projects": "npm run build-project:sdk && npm run build-project:vis", "build-project:sdk": "ng build --project zeppelin-sdk", "check:websocket-contract": "node --test scripts/check-websocket-contract.test.js && node scripts/check-websocket-contract.js", + "generate:notebook-parity-scenarios": "node scripts/generate-notebook-parity-scenarios.mjs", + "check:notebook-parity-scenarios": "node --test scripts/check-notebook-parity-scenarios.test.mjs && node scripts/check-notebook-parity-scenarios.mjs", "build-project:vis": "ng build --project zeppelin-visualization", "lint": "cross-env NODE_OPTIONS='--max-old-space-size=8192' ng lint && npm run lint:react && prettier --check \"**/*.{ts,tsx,mts,js,json,css,html}\"", "lint:fix": "cross-env NODE_OPTIONS='--max-old-space-size=8192' ng lint --fix && npm run lint:fix:react && prettier --write \"**/*.{ts,tsx,mts,js,json,css,html}\"", diff --git a/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.mjs b/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.mjs new file mode 100644 index 00000000000..cd650bebb97 --- /dev/null +++ b/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.mjs @@ -0,0 +1,34 @@ +/* + * 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 { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { loadRegistry, markdownPath, renderMarkdown, validateRegistry, webRoot } from './notebook-parity-scenarios.mjs'; + +const registry = loadRegistry(webRoot); +const errors = validateRegistry(registry, webRoot, { checkMarkdown: false }); +const expectedMarkdown = renderMarkdown(registry); +const actualMarkdownPath = path.join(webRoot, markdownPath); + +if (!existsSync(actualMarkdownPath)) { + errors.push(`${markdownPath} does not exist`); +} else { + const actualMarkdown = readFileSync(actualMarkdownPath, 'utf8'); + if (actualMarkdown !== expectedMarkdown) { + errors.push(`${markdownPath} is stale; run npm run generate:notebook-parity-scenarios`); + } +} + +if (errors.length > 0) { + console.error(errors.join('\n')); + process.exit(1); +} diff --git a/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.test.mjs b/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.test.mjs new file mode 100644 index 00000000000..45b0f75808d --- /dev/null +++ b/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.test.mjs @@ -0,0 +1,294 @@ +/* + * 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 assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { spawnSync } from 'node:child_process'; + +import { renderMarkdown, validateRegistry } from './notebook-parity-scenarios.mjs'; + +function createFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'notebook-parity-')); + const webRoot = path.join(root, 'zeppelin-web-angular'); + fs.mkdirSync(path.join(root, 'zeppelin-web-angular/e2e/tests/notebook/main'), { recursive: true }); + fs.mkdirSync(path.join(root, 'zeppelin-web-angular/e2e/scenarios'), { recursive: true }); + fs.mkdirSync(path.join(root, 'e2e/scenarios'), { recursive: true }); + fs.mkdirSync(path.join(root, 'zeppelin-web-angular/src/app/pages/workspace/notebook'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts'), + 'class NotebookComponent {}' + ); + fs.writeFileSync( + path.join(root, 'zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts'), + "import { test } from '@playwright/test';\ntest('[NB-PARITY-001] should render', async () => {});" + ); + spawnSync('git', ['init'], { cwd: root, stdio: 'ignore' }); + spawnSync('git', ['config', 'user.email', 'test@example.invalid'], { cwd: root, stdio: 'ignore' }); + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: root, stdio: 'ignore' }); + spawnSync('git', ['add', '.'], { cwd: root, stdio: 'ignore' }); + spawnSync('git', ['commit', '-m', 'fixture'], { cwd: root, stdio: 'ignore' }); + const commit = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).stdout.trim(); + return { commit, root, webRoot }; +} + +function writeFixtureSpec(webRoot, source) { + fs.writeFileSync(path.join(webRoot, 'e2e/tests/notebook/main/notebook-container.spec.ts'), source); +} + +function baseRegistry(commit) { + return { + schemaVersion: 1, + reviewedCommit: commit, + scenarios: [ + { + id: 'NB-PARITY-001', + name: 'Container renders', + area: 'navigation', + preconditions: ['note exists'], + action: 'open the route', + observableOutcomes: ['container is visible'], + interpreter: null, + roleExpectations: { + owner: 'allow', + writer: 'allow', + reader: 'allow', + runner: 'allow' + }, + roleVerification: { + owner: 'unverified', + writer: 'unverified', + reader: 'unverified', + runner: 'unverified' + }, + evidence: [ + { + path: 'zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts', + symbol: 'NotebookComponent' + } + ], + coverage: { + status: 'covered', + tests: [ + { + path: 'zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts', + title: '[NB-PARITY-001] should render', + projects: ['chromium'] + } + ], + issues: [], + uncoveredOutcomes: [] + } + } + ] + }; +} + +test('validates a current registry and generated markdown', () => { + const { commit, root, webRoot } = createFixture(); + const registry = baseRegistry(commit); + fs.writeFileSync(path.join(webRoot, 'e2e/scenarios/notebook-parity.md'), renderMarkdown(registry)); + + assert.deepEqual(validateRegistry(registry, webRoot), []); +}); + +test('accepts multiline executable Playwright test declarations', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + writeFixtureSpec( + webRoot, + `import { test } from '@playwright/test'; + test( + '[NB-PARITY-001] should render', + async () => {} + );` + ); + fs.writeFileSync(path.join(webRoot, 'e2e/scenarios/notebook-parity.md'), renderMarkdown(registry)); + + assert.deepEqual(validateRegistry(registry, webRoot), []); +}); + +test('rejects duplicate ids and stale markdown', () => { + const { commit, root, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios.push(structuredClone(registry.scenarios[0])); + fs.writeFileSync(path.join(webRoot, 'e2e/scenarios/notebook-parity.md'), 'stale\n'); + + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /is duplicated/); +}); + +test('rejects false covered claims without matching Playwright id', () => { + const { commit, root, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].coverage.tests[0].title = 'should render'; + fs.writeFileSync(path.join(webRoot, 'e2e/scenarios/notebook-parity.md'), renderMarkdown(registry)); + + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /title must contain/); +}); + +test('rejects commented-only, helper-string, and skipped Playwright coverage claims', () => { + const cases = [ + { + name: 'commented-only', + source: "// test('[NB-PARITY-001] should render', async () => {});" + }, + { + name: 'helper-string', + source: "const title = '[NB-PARITY-001] should render';\ntest(title, async () => {});" + }, + { + name: 'test.skip', + source: "import { test } from '@playwright/test';\ntest.skip('[NB-PARITY-001] should render', async () => {});" + }, + { + name: 'skipped describe', + source: + "import { test } from '@playwright/test';\ntest.describe.skip('disabled', () => { test('[NB-PARITY-001] should render', async () => {}); });" + }, + { + name: 'non-Playwright test helper', + source: "const test = () => undefined;\ntest('[NB-PARITY-001] should render', async () => {});" + } + ]; + + for (const { name, source } of cases) { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + writeFixtureSpec(webRoot, source); + fs.writeFileSync(path.join(webRoot, 'e2e/scenarios/notebook-parity.md'), renderMarkdown(registry)); + + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /title is not declared by an executable test\(\)/, name); + } +}); + +test('requires Jira issues for gaps', () => { + const { commit, root, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].coverage.status = 'gap'; + registry.scenarios[0].coverage.tests = []; + fs.writeFileSync(path.join(webRoot, 'e2e/scenarios/notebook-parity.md'), renderMarkdown(registry)); + + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /issues is required for gap scenarios/); +}); + +test('requires executable coverage, a Jira issue, and named uncovered outcomes for partial scenarios', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].coverage.status = 'partial'; + registry.scenarios[0].coverage.issues = []; + + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /issues is required for partial scenarios/); + + registry.scenarios[0].coverage.issues = ['ZEPPELIN-1234']; + registry.scenarios[0].coverage.tests = []; + const missingTestErrors = validateRegistry(registry, webRoot).join('\n'); + assert.match(missingTestErrors, /tests is required for partial scenarios/); + assert.match(missingTestErrors, /uncoveredOutcomes is required for partial scenarios/); +}); + +test('rejects invalid Jira issue keys and extra role expectation fields', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].coverage.issues = ['OTHER-123']; + registry.scenarios[0].roleExpectations.admin = 'allow'; + + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /must match ZEPPELIN-####/); + assert.match(errors, /roleExpectations must contain exactly/); +}); + +test('normalizes malformed coverage arrays before applying coverage rules', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].coverage.tests = null; + registry.scenarios[0].coverage.issues = null; + registry.scenarios[0].coverage.uncoveredOutcomes = null; + + assert.doesNotThrow(() => validateRegistry(registry, webRoot)); + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /coverage.tests must be an array/); + assert.match(errors, /coverage.issues must be an array/); + assert.match(errors, /coverage.uncoveredOutcomes must be an array/); + assert.match(errors, /coverage.tests is required for covered scenarios/); +}); + +test('rejects malformed test entries and invalid execution metadata without throwing', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].coverage.tests = [null]; + + assert.doesNotThrow(() => validateRegistry(registry, webRoot)); + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /coverage.tests\[0\] must be an object/); + + registry.scenarios[0].coverage.tests = [ + { + path: 'zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts', + title: '[NB-PARITY-001] should render', + projects: ['unknown-browser'] + } + ]; + const projectErrors = validateRegistry(registry, webRoot).join('\n'); + assert.match(projectErrors, /projects contains an invalid project/); +}); + +test('requires role verification to distinguish expected permissions from tested permissions', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].roleVerification.reader = 'covered'; + + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /roleVerification.reader is invalid/); +}); + +test('rejects evidence paths outside the repository', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].evidence[0].path = '../outside-repository.ts'; + + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /evidence\[0\].path does not exist/); +}); + +test('renders generated Markdown with exactly one trailing newline', () => { + const { commit } = createFixture(); + const markdown = renderMarkdown(baseRegistry(commit)); + + assert.equal(markdown.endsWith('\n'), true); + assert.equal(markdown.endsWith('\n\n'), false); +}); + +test('renders generated Markdown with an Apache License header', () => { + const { commit } = createFixture(); + const markdown = renderMarkdown(baseRegistry(commit)); + + assert.match(markdown, /^', + '', + '# Notebook Parity Scenarios', + '', + '', + '', + `Schema version: ${registry.schemaVersion}`, + '', + `Scenario/Angular baseline commit: \`${registry.reviewedCommit}\``, + '', + 'Scope note: This is a prioritized baseline, not a complete Notebook inventory. Before a React vertical slice is declared ready, add every affected behavior to this registry and classify its evidence.', + '', + 'Coverage note: `covered` mechanically means this registry points to a matching executable Playwright test declaration. Semantic adequacy and runtime pass/fail remain review and CI evidence. Role expectations and role verification are deliberately separate.', + '', + '| ID | Area | Scenario | Coverage | Roles | Tests | Issues |', + '| --- | --- | --- | --- | --- | --- | --- |' + ]; + + for (const scenario of registry.scenarios) { + const rolesText = roles.map(role => `${role}: ${scenario.roleExpectations[role]}`).join('
'); + const testsText = + scenario.coverage.tests.length === 0 + ? '' + : scenario.coverage.tests.map(test => `${test.path}
${test.title}`).join('

'); + const issuesText = scenario.coverage.issues.join(', '); + lines.push( + `| ${scenario.id} | ${scenario.area} | ${escapeTableCell(scenario.name)} | ${scenario.coverage.status} | ${escapeTableCell(rolesText)} | ${escapeTableCell(testsText)} | ${issuesText} |` + ); + } + + lines.push('', '## Scenario Details', ''); + + for (const scenario of registry.scenarios) { + lines.push(`### ${scenario.id} ${scenario.name}`); + lines.push(''); + lines.push(`- Area: ${scenario.area}`); + lines.push(`- Coverage: ${scenario.coverage.status}`); + lines.push(`- Interpreter: ${scenario.interpreter ?? 'not-applicable'}`); + lines.push(`- Role verification: ${roles.map(role => `${role}: ${scenario.roleVerification[role]}`).join('; ')}`); + lines.push(`- Preconditions: ${scenario.preconditions.join(' ')}`); + lines.push(`- Action: ${scenario.action}`); + lines.push(`- Observable outcomes: ${scenario.observableOutcomes.join(' ')}`); + lines.push(`- Evidence: ${scenario.evidence.map(item => `${item.path} (${item.symbol})`).join('; ')}`); + if (scenario.coverage.tests.length > 0) { + lines.push( + `- Browser projects: ${scenario.coverage.tests + .map(test => `${test.title}: ${test.projects.join(', ')}`) + .join('; ')}` + ); + } + if (scenario.coverage.uncoveredOutcomes.length > 0) { + lines.push(`- Uncovered outcomes: ${scenario.coverage.uncoveredOutcomes.join(' ')}`); + } + lines.push(''); + } + + return `${lines.join('\n').replace(/\n+$/, '')}\n`; +}; + +export const validateRegistry = (registry, root = webRoot, { checkMarkdown = true } = {}) => { + const errors = []; + + if (registry.schemaVersion !== 1) { + errors.push('schemaVersion must be 1'); + } + if (typeof registry.reviewedCommit !== 'string' || !/^[0-9a-f]{40}$/.test(registry.reviewedCommit)) { + errors.push('reviewedCommit must be a full 40-character commit hash'); + } else { + try { + execFileSync('git', ['cat-file', '-e', `${registry.reviewedCommit}^{commit}`], { cwd: root, stdio: 'ignore' }); + } catch { + errors.push(`reviewedCommit is not available in this checkout: ${registry.reviewedCommit}`); + } + } + if (!Array.isArray(registry.scenarios) || registry.scenarios.length === 0) { + errors.push('scenarios must be a non-empty array'); + return errors; + } + + const ids = new Set(); + let previousId = ''; + for (const [index, scenario] of registry.scenarios.entries()) { + const prefix = `scenarios[${index}]`; + if (typeof scenario.id !== 'string' || !/^NB-PARITY-\d{3}$/.test(scenario.id)) { + errors.push(`${prefix}.id must match NB-PARITY-###`); + } else if (ids.has(scenario.id)) { + errors.push(`${prefix}.id is duplicated: ${scenario.id}`); + } else { + if (previousId && scenario.id <= previousId) { + errors.push(`${prefix}.id must sort after ${previousId}`); + } + previousId = scenario.id; + ids.add(scenario.id); + } + + if (typeof scenario.name !== 'string' || scenario.name.length === 0) { + errors.push(`${prefix}.name is required`); + } + if (!allowedAreas.has(scenario.area)) { + errors.push(`${prefix}.area is invalid: ${scenario.area}`); + } + assertArrayOfStrings(errors, scenario.preconditions, `${prefix}.preconditions`); + if (typeof scenario.action !== 'string' || scenario.action.length === 0) { + errors.push(`${prefix}.action is required`); + } + assertArrayOfStrings(errors, scenario.observableOutcomes, `${prefix}.observableOutcomes`); + if (!(typeof scenario.interpreter === 'string' || scenario.interpreter === null)) { + errors.push(`${prefix}.interpreter must be a string or null`); + } + for (const role of roles) { + if (!allowedRoleExpectations.has(scenario.roleExpectations?.[role])) { + errors.push(`${prefix}.roleExpectations.${role} is invalid`); + } + } + const roleKeys = Object.keys(scenario.roleExpectations ?? {}).sort(); + if (roleKeys.join(',') !== [...roles].sort().join(',')) { + errors.push(`${prefix}.roleExpectations must contain exactly ${roles.join(', ')}`); + } + for (const role of roles) { + if (!allowedRoleVerificationStatuses.has(scenario.roleVerification?.[role])) { + errors.push(`${prefix}.roleVerification.${role} is invalid`); + } + if ( + scenario.roleExpectations?.[role] === 'not-applicable' && + scenario.roleVerification?.[role] !== 'not-applicable' + ) { + errors.push(`${prefix}.roleVerification.${role} must be not-applicable`); + } + } + const roleVerificationKeys = Object.keys(scenario.roleVerification ?? {}).sort(); + if (roleVerificationKeys.join(',') !== [...roles].sort().join(',')) { + errors.push(`${prefix}.roleVerification must contain exactly ${roles.join(', ')}`); + } + + if (!Array.isArray(scenario.evidence) || scenario.evidence.length === 0) { + errors.push(`${prefix}.evidence must be a non-empty array`); + } else { + for (const [evidenceIndex, evidence] of scenario.evidence.entries()) { + const evidencePath = resolveRepositoryPath(root, evidence.path); + if (!evidencePath || !existsSync(evidencePath)) { + errors.push(`${prefix}.evidence[${evidenceIndex}].path does not exist: ${evidence.path}`); + } + if (typeof evidence.symbol !== 'string' || evidence.symbol.length === 0) { + errors.push(`${prefix}.evidence[${evidenceIndex}].symbol is required`); + } + } + } + + const coverage = scenario.coverage; + if (!allowedCoverageStatuses.has(coverage?.status)) { + errors.push(`${prefix}.coverage.status is invalid`); + continue; + } + if (!Array.isArray(coverage.tests)) { + errors.push(`${prefix}.coverage.tests must be an array`); + } + if (!Array.isArray(coverage.issues)) { + errors.push(`${prefix}.coverage.issues must be an array`); + } + if (!Array.isArray(coverage.uncoveredOutcomes)) { + errors.push(`${prefix}.coverage.uncoveredOutcomes must be an array`); + } + const coverageTests = Array.isArray(coverage.tests) ? coverage.tests : []; + const coverageIssues = Array.isArray(coverage.issues) ? coverage.issues : []; + const uncoveredOutcomes = Array.isArray(coverage.uncoveredOutcomes) ? coverage.uncoveredOutcomes : []; + if (coverage.status === 'covered' && coverageTests.length === 0) { + errors.push(`${prefix}.coverage.tests is required for covered scenarios`); + } + if (coverage.status === 'partial' && coverageTests.length === 0) { + errors.push(`${prefix}.coverage.tests is required for partial scenarios`); + } + if (coverage.status === 'partial' && coverageIssues.length === 0) { + errors.push(`${prefix}.coverage.issues is required for partial scenarios`); + } + if (coverage.status === 'partial' && uncoveredOutcomes.length === 0) { + errors.push(`${prefix}.coverage.uncoveredOutcomes is required for partial scenarios`); + } + if (coverage.status !== 'partial' && uncoveredOutcomes.length > 0) { + errors.push(`${prefix}.coverage.uncoveredOutcomes is only valid for partial scenarios`); + } + for (const [outcomeIndex, outcome] of uncoveredOutcomes.entries()) { + if (typeof outcome !== 'string' || !scenario.observableOutcomes.includes(outcome)) { + errors.push(`${prefix}.coverage.uncoveredOutcomes[${outcomeIndex}] must reference an observable outcome`); + } + } + if ((coverage.status === 'gap' || coverage.status === 'blocked') && coverageIssues.length === 0) { + errors.push(`${prefix}.coverage.issues is required for ${coverage.status} scenarios`); + } + for (const [issueIndex, issue] of coverageIssues.entries()) { + if (typeof issue !== 'string' || !jiraIssuePattern.test(issue)) { + errors.push(`${prefix}.coverage.issues[${issueIndex}] must match ZEPPELIN-####`); + } + } + for (const [testIndex, test] of coverageTests.entries()) { + if (!test || typeof test !== 'object') { + errors.push(`${prefix}.coverage.tests[${testIndex}] must be an object`); + continue; + } + const testPath = resolveRepositoryPath(root, test.path); + if (!testPath || !existsSync(testPath)) { + errors.push(`${prefix}.coverage.tests[${testIndex}].path does not exist: ${test.path}`); + } + if (coverage.status === 'covered' && !test.path.startsWith('zeppelin-web-angular/e2e/tests/notebook/')) { + errors.push(`${prefix}.coverage.tests[${testIndex}].path must be in the notebook E2E suite`); + } + if (typeof test.title !== 'string' || !test.title.includes(scenario.id)) { + errors.push(`${prefix}.coverage.tests[${testIndex}].title must contain ${scenario.id}`); + } else if (!testDeclaresExecutableTitle(root, test)) { + errors.push( + `${prefix}.coverage.tests[${testIndex}].title is not declared by an executable test() in ${test.path}` + ); + } + if (!Array.isArray(test.projects) || test.projects.length === 0) { + errors.push(`${prefix}.coverage.tests[${testIndex}].projects must be a non-empty array`); + } else { + for (const project of test.projects) { + if (!allowedProjects.has(project)) { + errors.push(`${prefix}.coverage.tests[${testIndex}].projects contains an invalid project: ${project}`); + } + } + } + } + } + + if (checkMarkdown && errors.length === 0) { + const actualMarkdownPath = path.join(root, markdownPath); + if (!existsSync(actualMarkdownPath)) { + errors.push(`${markdownPath} does not exist`); + } else if (readFileSync(actualMarkdownPath, 'utf8') !== renderMarkdown(registry)) { + errors.push(`${markdownPath} is stale; run npm run generate:notebook-parity-scenarios`); + } + } + + return errors; +}; + +export const loadRegistry = (root = webRoot) => readJson(path.join(root, registryPath)); From 8ad58074584e99bfca8eed1667c11f64af5df2d6 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Sun, 6 Sep 2026 11:10:09 +0900 Subject: [PATCH 2/3] [ZEPPELIN-6660] Address notebook parity review feedback --- zeppelin-web-angular/e2e/AGENTS.md | 5 +- .../e2e/scenarios/notebook-parity.json | 420 +++++++++-------- .../e2e/scenarios/notebook-parity.md | 95 ++-- .../e2e/scenarios/notebook-parity.schema.json | 258 +++++++++++ .../action-bar-functionality.spec.ts | 2 +- .../tests/notebook/inline-completion.spec.ts | 33 +- .../notebook-keyboard-shortcuts.spec.ts | 2 +- .../notebook/main/notebook-container.spec.ts | 2 +- .../paragraph/paragraph-functionality.spec.ts | 6 +- zeppelin-web-angular/package-lock.json | 3 + zeppelin-web-angular/package.json | 1 + .../check-notebook-parity-scenarios.test.mjs | 96 ++-- .../scripts/notebook-parity-scenarios.mjs | 432 ++++++------------ 13 files changed, 761 insertions(+), 594 deletions(-) create mode 100644 zeppelin-web-angular/e2e/scenarios/notebook-parity.schema.json diff --git a/zeppelin-web-angular/e2e/AGENTS.md b/zeppelin-web-angular/e2e/AGENTS.md index 9b76c1f3874..4516eb84909 100644 --- a/zeppelin-web-angular/e2e/AGENTS.md +++ b/zeppelin-web-angular/e2e/AGENTS.md @@ -117,11 +117,12 @@ Use an existing key from the `PAGES` object in `e2e/utils.ts`; add a new one the 1. Pick/confirm the target route and the `PAGES` key. 2. Copy the shape of an existing spec in the same ``; reuse or extend the matching POM (`models/.ts` + `.util.ts`). Do not inline selectors the POM already owns. 3. Annotate the page (`addPageAnnotationBeforeEach`), navigate, then `waitForZeppelinReady`. -4. Run `npm run e2e:fast` and iterate until green. +4. If the test covers a scenario in `e2e/scenarios/notebook-parity.json`, add its stable ID as a Playwright tag such as `{ tag: '@NB-PARITY-001' }`. Keep the title human-readable; the registry links coverage by tag and path. Browser-specific restrictions stay in the spec rather than being copied into the registry. +5. Run `npm run e2e:fast` and iterate until green. ## Migration (Angular to React Microfrontend) -Pages are moving from Angular to React fragments incrementally. Today this is narrow: the published paragraph route reads a `?react=true` flag (`published/paragraph/paragraph.component`), the notebook footer swaps via a `?reactFooter=true` flag (read into the notebook component's `useReactFooter` input), and the configuration table swaps via a `?reactConfiguration=true` flag (`configuration/configuration.component`). All three are query params inside the hash. There is no app-wide "flip this route to React" flag, and no cross-framework parity project in this config. Write specs so they survive a route being reimplemented, but do not build parity infrastructure ahead of need. +Pages are moving from Angular to React fragments incrementally. Today this is narrow: the published paragraph route reads a `?react=true` flag (`published/paragraph/paragraph.component`), the notebook footer swaps via a `?reactFooter=true` flag (read into the notebook component's `useReactFooter` input), and the configuration table swaps via a `?reactConfiguration=true` flag (`configuration/configuration.component`). All three are query params inside the hash. There is no app-wide "flip this route to React" flag and no separate cross-framework Playwright project in this config. The notebook parity registry records the Angular behavior baseline and links it to existing framework-neutral tests; add scenarios as migration work reaches them rather than duplicating the suite for both frameworks. ### Write Framework-Neutral Specs diff --git a/zeppelin-web-angular/e2e/scenarios/notebook-parity.json b/zeppelin-web-angular/e2e/scenarios/notebook-parity.json index 681344e395e..0a110ee8318 100644 --- a/zeppelin-web-angular/e2e/scenarios/notebook-parity.json +++ b/zeppelin-web-angular/e2e/scenarios/notebook-parity.json @@ -1,5 +1,5 @@ { - "schemaVersion": 1, + "$schema": "./notebook-parity.schema.json", "reviewedCommit": "d5b57b12fd0c5e1d885767aabe06b242debf8300", "scenarios": [ { @@ -8,42 +8,35 @@ "area": "navigation", "preconditions": ["A disposable notebook route is open."], "action": "Render the notebook route.", - "observableOutcomes": ["The notebook container is visible with the expected container class."], - "interpreter": null, - "roleExpectations": { - "owner": "allow", - "writer": "allow", - "reader": "allow", - "runner": "not-applicable" - }, - "roleVerification": { - "owner": "unverified", - "writer": "unverified", - "reader": "unverified", - "runner": "not-applicable" - }, - "evidence": [ - { - "path": "zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts", - "symbol": "Notebook Container Component" - }, + "observableOutcomes": [ { - "path": "zeppelin-web-angular/e2e/models/notebook-page.ts", - "symbol": "NotebookPage" + "id": "NB-PARITY-001-OUTCOME-001", + "description": "The notebook container is visible with the expected container class." } ], + "interpreter": null, "coverage": { "status": "covered", "tests": [ { "path": "zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts", - "title": "[NB-PARITY-001] should display notebook container with proper structure", - "projects": ["chromium", "firefox", "webkit"] + "tag": "@NB-PARITY-001" } ], "issues": [], "uncoveredOutcomes": [] - } + }, + "implementationEvidence": [], + "verificationEvidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts", + "symbol": "Notebook Container Component" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-page.ts", + "symbol": "NotebookPage" + } + ] }, { "id": "NB-PARITY-002", @@ -51,7 +44,12 @@ "area": "navigation", "preconditions": ["A disposable notebook route is open."], "action": "Open the title editor and rename the notebook.", - "observableOutcomes": ["The title editor is visible and the changed title is reflected in the notebook header."], + "observableOutcomes": [ + { + "id": "NB-PARITY-002-OUTCOME-001", + "description": "The title editor is visible and the changed title is reflected in the notebook header." + } + ], "interpreter": null, "roleExpectations": { "owner": "allow", @@ -65,28 +63,28 @@ "reader": "unverified", "runner": "not-applicable" }, - "evidence": [ - { - "path": "zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts", - "symbol": "Notebook Action Bar Functionality" - }, - { - "path": "zeppelin-web-angular/e2e/models/notebook-action-bar-page.ts", - "symbol": "NotebookActionBarPage" - } - ], "coverage": { "status": "covered", "tests": [ { "path": "zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts", - "title": "[NB-PARITY-002] should display and allow title editing with tooltip", - "projects": ["chromium", "firefox", "webkit"] + "tag": "@NB-PARITY-002" } ], "issues": [], "uncoveredOutcomes": [] - } + }, + "implementationEvidence": [], + "verificationEvidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts", + "symbol": "Notebook Action Bar Functionality" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-action-bar-page.ts", + "symbol": "NotebookActionBarPage" + } + ] }, { "id": "NB-PARITY-003", @@ -94,7 +92,12 @@ "area": "editor", "preconditions": ["A disposable notebook with at least one paragraph is open."], "action": "Double-click the paragraph.", - "observableOutcomes": ["The code editor becomes visible for the paragraph."], + "observableOutcomes": [ + { + "id": "NB-PARITY-003-OUTCOME-001", + "description": "The code editor becomes visible for the paragraph." + } + ], "interpreter": null, "roleExpectations": { "owner": "allow", @@ -108,28 +111,28 @@ "reader": "unverified", "runner": "not-applicable" }, - "evidence": [ - { - "path": "zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts", - "symbol": "Notebook Paragraph Functionality" - }, - { - "path": "zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts", - "symbol": "NotebookParagraphPage" - } - ], "coverage": { "status": "covered", "tests": [ { "path": "zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts", - "title": "[NB-PARITY-003] should support double-click editing functionality", - "projects": ["chromium", "firefox", "webkit"] + "tag": "@NB-PARITY-003" } ], "issues": [], "uncoveredOutcomes": [] - } + }, + "implementationEvidence": [], + "verificationEvidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts", + "symbol": "Notebook Paragraph Functionality" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts", + "symbol": "NotebookParagraphPage" + } + ] }, { "id": "NB-PARITY-004", @@ -137,7 +140,12 @@ "area": "editor", "preconditions": ["A disposable notebook with a paragraph is open."], "action": "Inspect the paragraph controls.", - "observableOutcomes": ["Add-paragraph controls are visible where a user can add another paragraph."], + "observableOutcomes": [ + { + "id": "NB-PARITY-004-OUTCOME-001", + "description": "Add-paragraph controls are visible where a user can add another paragraph." + } + ], "interpreter": null, "roleExpectations": { "owner": "allow", @@ -151,28 +159,28 @@ "reader": "unverified", "runner": "not-applicable" }, - "evidence": [ - { - "path": "zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts", - "symbol": "Notebook Paragraph Functionality" - }, - { - "path": "zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts", - "symbol": "NotebookParagraphPage" - } - ], "coverage": { "status": "covered", "tests": [ { "path": "zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts", - "title": "[NB-PARITY-004] should display add paragraph buttons", - "projects": ["chromium", "firefox", "webkit"] + "tag": "@NB-PARITY-004" } ], "issues": [], "uncoveredOutcomes": [] - } + }, + "implementationEvidence": [], + "verificationEvidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts", + "symbol": "Notebook Paragraph Functionality" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts", + "symbol": "NotebookParagraphPage" + } + ] }, { "id": "NB-PARITY-005", @@ -180,7 +188,12 @@ "area": "shortcut", "preconditions": ["A disposable notebook paragraph is focused in the code editor."], "action": "Type Markdown content and press Shift+Enter.", - "observableOutcomes": ["The paragraph executes and renders the Markdown heading result."], + "observableOutcomes": [ + { + "id": "NB-PARITY-005-OUTCOME-001", + "description": "The paragraph executes and renders the Markdown heading result." + } + ], "interpreter": "md", "roleExpectations": { "owner": "allow", @@ -194,28 +207,28 @@ "reader": "unverified", "runner": "unverified" }, - "evidence": [ - { - "path": "zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts", - "symbol": "ParagraphActions.Run" - }, - { - "path": "zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts", - "symbol": "NotebookKeyboardPage" - } - ], "coverage": { "status": "covered", "tests": [ { "path": "zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts", - "title": "[NB-PARITY-005] should execute markdown paragraph with Shift+Enter", - "projects": ["chromium", "firefox", "webkit"] + "tag": "@NB-PARITY-005" } ], "issues": [], "uncoveredOutcomes": [] - } + }, + "implementationEvidence": [], + "verificationEvidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts", + "symbol": "ParagraphActions.Run" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts", + "symbol": "NotebookKeyboardPage" + } + ] }, { "id": "NB-PARITY-010", @@ -227,44 +240,38 @@ ], "action": "Type a completion prefix in Monaco and press Escape while the completion is visible.", "observableOutcomes": [ - "The inline completion suggestion is shown from notebook history.", - "The Monaco input remains focused after the first Escape dismisses the suggestion." - ], - "interpreter": "python", - "roleExpectations": { - "owner": "allow", - "writer": "allow", - "reader": "not-applicable", - "runner": "not-applicable" - }, - "roleVerification": { - "owner": "unverified", - "writer": "unverified", - "reader": "not-applicable", - "runner": "not-applicable" - }, - "evidence": [ { - "path": "zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts", - "symbol": "Inline completion" + "id": "NB-PARITY-010-OUTCOME-001", + "description": "The inline completion suggestion is shown from notebook history." }, { - "path": "zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts", - "symbol": "NotebookKeyboardPage" + "id": "NB-PARITY-010-OUTCOME-002", + "description": "The Monaco input remains focused after the first Escape dismisses the suggestion." } ], + "interpreter": "python", "coverage": { "status": "covered", "tests": [ { "path": "zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts", - "title": "[NB-PARITY-010] shows history completion and preserves focus when dismissed", - "projects": ["chromium", "firefox", "webkit"] + "tag": "@NB-PARITY-010" } ], "issues": [], "uncoveredOutcomes": [] - } + }, + "implementationEvidence": [], + "verificationEvidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts", + "symbol": "Inline completion" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts", + "symbol": "NotebookKeyboardPage" + } + ] }, { "id": "NB-PARITY-011", @@ -277,44 +284,38 @@ ], "action": "Press Escape once to dismiss the completion and press Escape again.", "observableOutcomes": [ - "The first Escape keeps Monaco focused.", - "The second Escape blurs the Monaco input in Chromium." - ], - "interpreter": "python", - "roleExpectations": { - "owner": "allow", - "writer": "allow", - "reader": "not-applicable", - "runner": "not-applicable" - }, - "roleVerification": { - "owner": "unverified", - "writer": "unverified", - "reader": "not-applicable", - "runner": "not-applicable" - }, - "evidence": [ { - "path": "zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts", - "symbol": "Inline completion" + "id": "NB-PARITY-011-OUTCOME-001", + "description": "The first Escape keeps Monaco focused." }, { - "path": "zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts", - "symbol": "NotebookKeyboardPage" + "id": "NB-PARITY-011-OUTCOME-002", + "description": "The second Escape blurs the Monaco input in Chromium." } ], + "interpreter": "python", "coverage": { "status": "covered", "tests": [ { "path": "zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts", - "title": "[NB-PARITY-011] blurs the editor on the second Escape after dismissing completion", - "projects": ["chromium"] + "tag": "@NB-PARITY-011" } ], "issues": [], "uncoveredOutcomes": [] - } + }, + "implementationEvidence": [], + "verificationEvidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts", + "symbol": "Inline completion" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts", + "symbol": "NotebookKeyboardPage" + } + ] }, { "id": "NB-PARITY-021", @@ -326,11 +327,26 @@ ], "action": "Run the paragraph from the paragraph control and inspect the rendered result panel.", "observableOutcomes": [ - "The result display becomes visible and is not empty.", - "The UI offers every display mode that the Angular notebook exposes for the returned result type.", - "Visualization control changes preserve the field mapping from result columns to configured dimensions or measures.", - "The paragraph's persisted config reflects the resulting configuration object after a visualization option changes.", - "Text and table results expose accessible table output row by row so migrated React rendering can be compared without relying on screenshots alone." + { + "id": "NB-PARITY-021-OUTCOME-001", + "description": "The result display becomes visible and is not empty." + }, + { + "id": "NB-PARITY-021-OUTCOME-002", + "description": "The UI offers every display mode that the Angular notebook exposes for the returned result type." + }, + { + "id": "NB-PARITY-021-OUTCOME-003", + "description": "Visualization control changes preserve the field mapping from result columns to configured dimensions or measures." + }, + { + "id": "NB-PARITY-021-OUTCOME-004", + "description": "The paragraph's persisted config reflects the resulting configuration object after a visualization option changes." + }, + { + "id": "NB-PARITY-021-OUTCOME-005", + "description": "Text and table results expose accessible table output row by row so migrated React rendering can be compared without relying on screenshots alone." + } ], "interpreter": "python", "roleExpectations": { @@ -345,7 +361,23 @@ "reader": "unverified", "runner": "unverified" }, - "evidence": [ + "coverage": { + "status": "partial", + "tests": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts", + "tag": "@NB-PARITY-021" + } + ], + "issues": ["ZEPPELIN-6514", "ZEPPELIN-6516"], + "uncoveredOutcomes": [ + "NB-PARITY-021-OUTCOME-002", + "NB-PARITY-021-OUTCOME-003", + "NB-PARITY-021-OUTCOME-004", + "NB-PARITY-021-OUTCOME-005" + ] + }, + "implementationEvidence": [ { "path": "zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.html", "symbol": "paragraph result display" @@ -367,23 +399,7 @@ "symbol": "Visualization" } ], - "coverage": { - "status": "partial", - "tests": [ - { - "path": "zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts", - "title": "[NB-PARITY-021] should display result system properly", - "projects": ["chromium", "firefox", "webkit"] - } - ], - "issues": ["ZEPPELIN-6514", "ZEPPELIN-6516"], - "uncoveredOutcomes": [ - "The UI offers every display mode that the Angular notebook exposes for the returned result type.", - "Visualization control changes preserve the field mapping from result columns to configured dimensions or measures.", - "The paragraph's persisted config reflects the resulting configuration object after a visualization option changes.", - "Text and table results expose accessible table output row by row so migrated React rendering can be compared without relying on screenshots alone." - ] - } + "verificationEvidence": [] }, { "id": "NB-PARITY-050", @@ -395,8 +411,14 @@ ], "action": "Replace the paragraph text and stop typing long enough for the notebook save path to acknowledge the edit.", "observableOutcomes": [ - "The persisted paragraph text equals the latest typed text.", - "The save assertion is based on observable persistence or wire evidence, not an internal timer." + { + "id": "NB-PARITY-050-OUTCOME-001", + "description": "The persisted paragraph text equals the latest typed text." + }, + { + "id": "NB-PARITY-050-OUTCOME-002", + "description": "The save assertion is based on observable persistence or wire evidence, not an internal timer." + } ], "interpreter": null, "roleExpectations": { @@ -411,22 +433,24 @@ "reader": "unverified", "runner": "not-applicable" }, - "evidence": [ + "coverage": { + "status": "gap", + "tests": [], + "issues": ["ZEPPELIN-6661"], + "uncoveredOutcomes": [] + }, + "implementationEvidence": [ { "path": "zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts", "symbol": "CodeEditorComponent" - }, + } + ], + "verificationEvidence": [ { "path": "zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts", "symbol": "NotebookKeyboardPage" } - ], - "coverage": { - "status": "gap", - "tests": [], - "issues": ["ZEPPELIN-6661"], - "uncoveredOutcomes": [] - } + ] }, { "id": "NB-PARITY-051", @@ -438,8 +462,14 @@ ], "action": "Edit the paragraph, keep the first save in flight, then make a second edit.", "observableOutcomes": [ - "The first in-flight save does not overwrite or drop the second edit.", - "A later observable save or reconciliation persists the second edit." + { + "id": "NB-PARITY-051-OUTCOME-001", + "description": "The first in-flight save does not overwrite or drop the second edit." + }, + { + "id": "NB-PARITY-051-OUTCOME-002", + "description": "A later observable save or reconciliation persists the second edit." + } ], "interpreter": null, "roleExpectations": { @@ -454,7 +484,13 @@ "reader": "unverified", "runner": "not-applicable" }, - "evidence": [ + "coverage": { + "status": "gap", + "tests": [], + "issues": ["ZEPPELIN-6661"], + "uncoveredOutcomes": [] + }, + "implementationEvidence": [ { "path": "zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts", "symbol": "CodeEditorComponent" @@ -464,12 +500,7 @@ "symbol": "NotebookComponent" } ], - "coverage": { - "status": "gap", - "tests": [], - "issues": ["ZEPPELIN-6661"], - "uncoveredOutcomes": [] - } + "verificationEvidence": [] }, { "id": "NB-PARITY-060", @@ -478,39 +509,38 @@ "preconditions": ["The user can choose light, dark, or system theme from the host shell."], "action": "Change the host theme while a notebook surface is mounted.", "observableOutcomes": [ - "Notebook text remains readable.", - "Result and chart output inherit the host theme tokens.", - "The selected theme persists after reload." - ], - "interpreter": null, - "roleExpectations": { - "owner": "allow", - "writer": "allow", - "reader": "allow", - "runner": "not-applicable" - }, - "roleVerification": { - "owner": "unverified", - "writer": "unverified", - "reader": "unverified", - "runner": "not-applicable" - }, - "evidence": [ { - "path": "zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.tsx", - "symbol": "ZeppelinThemeProvider" + "id": "NB-PARITY-060-OUTCOME-001", + "description": "Notebook text remains readable." }, { - "path": "zeppelin-web-angular/e2e/tests/theme/dark-mode.spec.ts", - "symbol": "Dark Mode" + "id": "NB-PARITY-060-OUTCOME-002", + "description": "Result and chart output inherit the host theme tokens." + }, + { + "id": "NB-PARITY-060-OUTCOME-003", + "description": "The selected theme persists after reload." } ], + "interpreter": null, "coverage": { "status": "gap", "tests": [], "issues": ["ZEPPELIN-6640"], "uncoveredOutcomes": [] - } + }, + "implementationEvidence": [ + { + "path": "zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.tsx", + "symbol": "ZeppelinThemeProvider" + } + ], + "verificationEvidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/theme/dark-mode.spec.ts", + "symbol": "Dark Mode" + } + ] } ] } diff --git a/zeppelin-web-angular/e2e/scenarios/notebook-parity.md b/zeppelin-web-angular/e2e/scenarios/notebook-parity.md index 818a58cc3ce..ab31cdd73b4 100644 --- a/zeppelin-web-angular/e2e/scenarios/notebook-parity.md +++ b/zeppelin-web-angular/e2e/scenarios/notebook-parity.md @@ -14,27 +14,27 @@ -Schema version: 1 +Schema: [notebook-parity.schema.json](./notebook-parity.schema.json) Scenario/Angular baseline commit: `d5b57b12fd0c5e1d885767aabe06b242debf8300` Scope note: This is a prioritized baseline, not a complete Notebook inventory. Before a React vertical slice is declared ready, add every affected behavior to this registry and classify its evidence. -Coverage note: `covered` mechanically means this registry points to a matching executable Playwright test declaration. Semantic adequacy and runtime pass/fail remain review and CI evidence. Role expectations and role verification are deliberately separate. +Coverage note: `covered` mechanically means this registry points to a matching executable Playwright test tag. Semantic adequacy and runtime pass/fail remain review and CI evidence. Role expectations are recorded only when the outcome varies by role, and role verification records whether that expectation has been tested. | ID | Area | Scenario | Coverage | Roles | Tests | Issues | | --- | --- | --- | --- | --- | --- | --- | -| NB-PARITY-001 | navigation | Notebook container structure is visible | covered | owner: allow
writer: allow
reader: allow
runner: not-applicable | zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts
[NB-PARITY-001] should display notebook container with proper structure | | -| NB-PARITY-002 | navigation | Notebook title can be displayed and edited | covered | owner: allow
writer: allow
reader: deny
runner: not-applicable | zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts
[NB-PARITY-002] should display and allow title editing with tooltip | | -| NB-PARITY-003 | editor | Paragraph enters editing mode on double click | covered | owner: allow
writer: allow
reader: deny
runner: not-applicable | zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts
[NB-PARITY-003] should support double-click editing functionality | | -| NB-PARITY-004 | editor | Paragraph add buttons are visible | covered | owner: allow
writer: allow
reader: deny
runner: not-applicable | zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts
[NB-PARITY-004] should display add paragraph buttons | | -| NB-PARITY-005 | shortcut | Shift+Enter executes a markdown paragraph | covered | owner: allow
writer: allow
reader: deny
runner: allow | zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts
[NB-PARITY-005] should execute markdown paragraph with Shift+Enter | | -| NB-PARITY-010 | editor | History inline completion can be dismissed without losing editor focus | covered | owner: allow
writer: allow
reader: not-applicable
runner: not-applicable | zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts
[NB-PARITY-010] shows history completion and preserves focus when dismissed | | -| NB-PARITY-011 | editor | The second Escape after inline completion dismissal blurs the editor | covered | owner: allow
writer: allow
reader: not-applicable
runner: not-applicable | zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts
[NB-PARITY-011] blurs the editor on the second Escape after dismissing completion | | -| NB-PARITY-021 | result | Text and table result displays preserve output semantics after paragraph execution | partial | owner: allow
writer: allow
reader: deny
runner: allow | zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts
[NB-PARITY-021] should display result system properly | ZEPPELIN-6514, ZEPPELIN-6516 | +| NB-PARITY-001 | navigation | Notebook container structure is visible | covered | not-applicable | zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts
@NB-PARITY-001 | | +| NB-PARITY-002 | navigation | Notebook title can be displayed and edited | covered | owner: allow
writer: allow
reader: deny
runner: not-applicable | zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts
@NB-PARITY-002 | | +| NB-PARITY-003 | editor | Paragraph enters editing mode on double click | covered | owner: allow
writer: allow
reader: deny
runner: not-applicable | zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts
@NB-PARITY-003 | | +| NB-PARITY-004 | editor | Paragraph add buttons are visible | covered | owner: allow
writer: allow
reader: deny
runner: not-applicable | zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts
@NB-PARITY-004 | | +| NB-PARITY-005 | shortcut | Shift+Enter executes a markdown paragraph | covered | owner: allow
writer: allow
reader: deny
runner: allow | zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts
@NB-PARITY-005 | | +| NB-PARITY-010 | editor | History inline completion can be dismissed without losing editor focus | covered | not-applicable | zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts
@NB-PARITY-010 | | +| NB-PARITY-011 | editor | The second Escape after inline completion dismissal blurs the editor | covered | not-applicable | zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts
@NB-PARITY-011 | | +| NB-PARITY-021 | result | Text and table result displays preserve output semantics after paragraph execution | partial | owner: allow
writer: allow
reader: deny
runner: allow | zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts
@NB-PARITY-021 | ZEPPELIN-6514, ZEPPELIN-6516 | | NB-PARITY-050 | persistence | Notebook editor persists the latest text after typing stops | gap | owner: allow
writer: allow
reader: deny
runner: not-applicable | | ZEPPELIN-6661 | | NB-PARITY-051 | persistence | Notebook editor does not lose an edit made while a prior save is in flight | gap | owner: allow
writer: allow
reader: deny
runner: not-applicable | | ZEPPELIN-6661 | -| NB-PARITY-060 | theme | Notebook honors host theme selection | gap | owner: allow
writer: allow
reader: allow
runner: not-applicable | | ZEPPELIN-6640 | +| NB-PARITY-060 | theme | Notebook honors host theme selection | gap | not-applicable | | ZEPPELIN-6640 | ## Scenario Details @@ -43,12 +43,12 @@ Coverage note: `covered` mechanically means this registry points to a matching e - Area: navigation - Coverage: covered - Interpreter: not-applicable -- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable +- Role verification: not-applicable - Preconditions: A disposable notebook route is open. - Action: Render the notebook route. -- Observable outcomes: The notebook container is visible with the expected container class. -- Evidence: zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts (Notebook Container Component); zeppelin-web-angular/e2e/models/notebook-page.ts (NotebookPage) -- Browser projects: [NB-PARITY-001] should display notebook container with proper structure: chromium, firefox, webkit +- Observable outcomes: NB-PARITY-001-OUTCOME-001: The notebook container is visible with the expected container class. +- Implementation evidence: not-applicable +- Verification evidence: zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts (Notebook Container Component); zeppelin-web-angular/e2e/models/notebook-page.ts (NotebookPage) ### NB-PARITY-002 Notebook title can be displayed and edited @@ -58,9 +58,9 @@ Coverage note: `covered` mechanically means this registry points to a matching e - Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable - Preconditions: A disposable notebook route is open. - Action: Open the title editor and rename the notebook. -- Observable outcomes: The title editor is visible and the changed title is reflected in the notebook header. -- Evidence: zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts (Notebook Action Bar Functionality); zeppelin-web-angular/e2e/models/notebook-action-bar-page.ts (NotebookActionBarPage) -- Browser projects: [NB-PARITY-002] should display and allow title editing with tooltip: chromium, firefox, webkit +- Observable outcomes: NB-PARITY-002-OUTCOME-001: The title editor is visible and the changed title is reflected in the notebook header. +- Implementation evidence: not-applicable +- Verification evidence: zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts (Notebook Action Bar Functionality); zeppelin-web-angular/e2e/models/notebook-action-bar-page.ts (NotebookActionBarPage) ### NB-PARITY-003 Paragraph enters editing mode on double click @@ -70,9 +70,9 @@ Coverage note: `covered` mechanically means this registry points to a matching e - Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable - Preconditions: A disposable notebook with at least one paragraph is open. - Action: Double-click the paragraph. -- Observable outcomes: The code editor becomes visible for the paragraph. -- Evidence: zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts (Notebook Paragraph Functionality); zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts (NotebookParagraphPage) -- Browser projects: [NB-PARITY-003] should support double-click editing functionality: chromium, firefox, webkit +- Observable outcomes: NB-PARITY-003-OUTCOME-001: The code editor becomes visible for the paragraph. +- Implementation evidence: not-applicable +- Verification evidence: zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts (Notebook Paragraph Functionality); zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts (NotebookParagraphPage) ### NB-PARITY-004 Paragraph add buttons are visible @@ -82,9 +82,9 @@ Coverage note: `covered` mechanically means this registry points to a matching e - Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable - Preconditions: A disposable notebook with a paragraph is open. - Action: Inspect the paragraph controls. -- Observable outcomes: Add-paragraph controls are visible where a user can add another paragraph. -- Evidence: zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts (Notebook Paragraph Functionality); zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts (NotebookParagraphPage) -- Browser projects: [NB-PARITY-004] should display add paragraph buttons: chromium, firefox, webkit +- Observable outcomes: NB-PARITY-004-OUTCOME-001: Add-paragraph controls are visible where a user can add another paragraph. +- Implementation evidence: not-applicable +- Verification evidence: zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts (Notebook Paragraph Functionality); zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts (NotebookParagraphPage) ### NB-PARITY-005 Shift+Enter executes a markdown paragraph @@ -94,33 +94,33 @@ Coverage note: `covered` mechanically means this registry points to a matching e - Role verification: owner: unverified; writer: unverified; reader: unverified; runner: unverified - Preconditions: A disposable notebook paragraph is focused in the code editor. - Action: Type Markdown content and press Shift+Enter. -- Observable outcomes: The paragraph executes and renders the Markdown heading result. -- Evidence: zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts (ParagraphActions.Run); zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts (NotebookKeyboardPage) -- Browser projects: [NB-PARITY-005] should execute markdown paragraph with Shift+Enter: chromium, firefox, webkit +- Observable outcomes: NB-PARITY-005-OUTCOME-001: The paragraph executes and renders the Markdown heading result. +- Implementation evidence: not-applicable +- Verification evidence: zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts (ParagraphActions.Run); zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts (NotebookKeyboardPage) ### NB-PARITY-010 History inline completion can be dismissed without losing editor focus - Area: editor - Coverage: covered - Interpreter: python -- Role verification: owner: unverified; writer: unverified; reader: not-applicable; runner: not-applicable +- Role verification: not-applicable - Preconditions: A notebook contains a Python paragraph with prior text that can seed inline completion. The notebook route is opened with aiInlineComplete enabled. - Action: Type a completion prefix in Monaco and press Escape while the completion is visible. -- Observable outcomes: The inline completion suggestion is shown from notebook history. The Monaco input remains focused after the first Escape dismisses the suggestion. -- Evidence: zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts (Inline completion); zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts (NotebookKeyboardPage) -- Browser projects: [NB-PARITY-010] shows history completion and preserves focus when dismissed: chromium, firefox, webkit +- Observable outcomes: NB-PARITY-010-OUTCOME-001: The inline completion suggestion is shown from notebook history. NB-PARITY-010-OUTCOME-002: The Monaco input remains focused after the first Escape dismisses the suggestion. +- Implementation evidence: not-applicable +- Verification evidence: zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts (Inline completion); zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts (NotebookKeyboardPage) ### NB-PARITY-011 The second Escape after inline completion dismissal blurs the editor - Area: editor - Coverage: covered - Interpreter: python -- Role verification: owner: unverified; writer: unverified; reader: not-applicable; runner: not-applicable +- Role verification: not-applicable - Preconditions: A notebook contains a Python paragraph with prior text that can seed inline completion. The notebook route is opened with aiInlineComplete enabled. The browser is Chromium. - Action: Press Escape once to dismiss the completion and press Escape again. -- Observable outcomes: The first Escape keeps Monaco focused. The second Escape blurs the Monaco input in Chromium. -- Evidence: zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts (Inline completion); zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts (NotebookKeyboardPage) -- Browser projects: [NB-PARITY-011] blurs the editor on the second Escape after dismissing completion: chromium +- Observable outcomes: NB-PARITY-011-OUTCOME-001: The first Escape keeps Monaco focused. NB-PARITY-011-OUTCOME-002: The second Escape blurs the Monaco input in Chromium. +- Implementation evidence: not-applicable +- Verification evidence: zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts (Inline completion); zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts (NotebookKeyboardPage) ### NB-PARITY-021 Text and table result displays preserve output semantics after paragraph execution @@ -130,10 +130,10 @@ Coverage note: `covered` mechanically means this registry points to a matching e - Role verification: owner: unverified; writer: unverified; reader: unverified; runner: unverified - Preconditions: A notebook has a Python paragraph that prints text. A notebook has a paragraph that returns tabular output. - Action: Run the paragraph from the paragraph control and inspect the rendered result panel. -- Observable outcomes: The result display becomes visible and is not empty. The UI offers every display mode that the Angular notebook exposes for the returned result type. Visualization control changes preserve the field mapping from result columns to configured dimensions or measures. The paragraph's persisted config reflects the resulting configuration object after a visualization option changes. Text and table results expose accessible table output row by row so migrated React rendering can be compared without relying on screenshots alone. -- Evidence: zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.html (paragraph result display); zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/progress/progress.component.ts (ProgressComponent); zeppelin-web-angular/projects/zeppelin-visualization/src/table-transformation.ts (TableTransformation); zeppelin-web-angular/projects/zeppelin-visualization/src/pivot-transformation.ts (PivotTransformation); zeppelin-web-angular/projects/zeppelin-visualization/src/visualization.ts (Visualization) -- Browser projects: [NB-PARITY-021] should display result system properly: chromium, firefox, webkit -- Uncovered outcomes: The UI offers every display mode that the Angular notebook exposes for the returned result type. Visualization control changes preserve the field mapping from result columns to configured dimensions or measures. The paragraph's persisted config reflects the resulting configuration object after a visualization option changes. Text and table results expose accessible table output row by row so migrated React rendering can be compared without relying on screenshots alone. +- Observable outcomes: NB-PARITY-021-OUTCOME-001: The result display becomes visible and is not empty. NB-PARITY-021-OUTCOME-002: The UI offers every display mode that the Angular notebook exposes for the returned result type. NB-PARITY-021-OUTCOME-003: Visualization control changes preserve the field mapping from result columns to configured dimensions or measures. NB-PARITY-021-OUTCOME-004: The paragraph's persisted config reflects the resulting configuration object after a visualization option changes. NB-PARITY-021-OUTCOME-005: Text and table results expose accessible table output row by row so migrated React rendering can be compared without relying on screenshots alone. +- Implementation evidence: zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.html (paragraph result display); zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/progress/progress.component.ts (ProgressComponent); zeppelin-web-angular/projects/zeppelin-visualization/src/table-transformation.ts (TableTransformation); zeppelin-web-angular/projects/zeppelin-visualization/src/pivot-transformation.ts (PivotTransformation); zeppelin-web-angular/projects/zeppelin-visualization/src/visualization.ts (Visualization) +- Verification evidence: not-applicable +- Uncovered outcomes: NB-PARITY-021-OUTCOME-002: The UI offers every display mode that the Angular notebook exposes for the returned result type. NB-PARITY-021-OUTCOME-003: Visualization control changes preserve the field mapping from result columns to configured dimensions or measures. NB-PARITY-021-OUTCOME-004: The paragraph's persisted config reflects the resulting configuration object after a visualization option changes. NB-PARITY-021-OUTCOME-005: Text and table results expose accessible table output row by row so migrated React rendering can be compared without relying on screenshots alone. ### NB-PARITY-050 Notebook editor persists the latest text after typing stops @@ -143,8 +143,9 @@ Coverage note: `covered` mechanically means this registry points to a matching e - Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable - Preconditions: A disposable notebook with one editable paragraph is open. The user can edit the paragraph. - Action: Replace the paragraph text and stop typing long enough for the notebook save path to acknowledge the edit. -- Observable outcomes: The persisted paragraph text equals the latest typed text. The save assertion is based on observable persistence or wire evidence, not an internal timer. -- Evidence: zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts (CodeEditorComponent); zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts (NotebookKeyboardPage) +- Observable outcomes: NB-PARITY-050-OUTCOME-001: The persisted paragraph text equals the latest typed text. NB-PARITY-050-OUTCOME-002: The save assertion is based on observable persistence or wire evidence, not an internal timer. +- Implementation evidence: zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts (CodeEditorComponent) +- Verification evidence: zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts (NotebookKeyboardPage) ### NB-PARITY-051 Notebook editor does not lose an edit made while a prior save is in flight @@ -154,16 +155,18 @@ Coverage note: `covered` mechanically means this registry points to a matching e - Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable - Preconditions: A disposable notebook with one editable paragraph is open. The first paragraph save request can be observed before it completes. - Action: Edit the paragraph, keep the first save in flight, then make a second edit. -- Observable outcomes: The first in-flight save does not overwrite or drop the second edit. A later observable save or reconciliation persists the second edit. -- Evidence: zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts (CodeEditorComponent); zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts (NotebookComponent) +- Observable outcomes: NB-PARITY-051-OUTCOME-001: The first in-flight save does not overwrite or drop the second edit. NB-PARITY-051-OUTCOME-002: A later observable save or reconciliation persists the second edit. +- Implementation evidence: zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts (CodeEditorComponent); zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts (NotebookComponent) +- Verification evidence: not-applicable ### NB-PARITY-060 Notebook honors host theme selection - Area: theme - Coverage: gap - Interpreter: not-applicable -- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable +- Role verification: not-applicable - Preconditions: The user can choose light, dark, or system theme from the host shell. - Action: Change the host theme while a notebook surface is mounted. -- Observable outcomes: Notebook text remains readable. Result and chart output inherit the host theme tokens. The selected theme persists after reload. -- Evidence: zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.tsx (ZeppelinThemeProvider); zeppelin-web-angular/e2e/tests/theme/dark-mode.spec.ts (Dark Mode) +- Observable outcomes: NB-PARITY-060-OUTCOME-001: Notebook text remains readable. NB-PARITY-060-OUTCOME-002: Result and chart output inherit the host theme tokens. NB-PARITY-060-OUTCOME-003: The selected theme persists after reload. +- Implementation evidence: zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.tsx (ZeppelinThemeProvider) +- Verification evidence: zeppelin-web-angular/e2e/tests/theme/dark-mode.spec.ts (Dark Mode) diff --git a/zeppelin-web-angular/e2e/scenarios/notebook-parity.schema.json b/zeppelin-web-angular/e2e/scenarios/notebook-parity.schema.json new file mode 100644 index 00000000000..6d8155119d2 --- /dev/null +++ b/zeppelin-web-angular/e2e/scenarios/notebook-parity.schema.json @@ -0,0 +1,258 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "notebook-parity.schema.json", + "title": "Notebook parity registry", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "reviewedCommit", "scenarios"], + "properties": { + "$schema": { + "const": "./notebook-parity.schema.json" + }, + "reviewedCommit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "scenarios": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/scenario" + } + } + }, + "definitions": { + "stringArray": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "evidence": { + "type": "object", + "additionalProperties": false, + "required": ["path", "symbol"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "symbol": { + "type": "string", + "minLength": 1 + } + } + }, + "roleExpectations": { + "type": "object", + "additionalProperties": false, + "required": ["owner", "writer", "reader", "runner"], + "properties": { + "owner": { "$ref": "#/definitions/roleExpectation" }, + "writer": { "$ref": "#/definitions/roleExpectation" }, + "reader": { "$ref": "#/definitions/roleExpectation" }, + "runner": { "$ref": "#/definitions/roleExpectation" } + } + }, + "roleExpectation": { + "enum": ["allow", "deny", "not-applicable"] + }, + "roleVerification": { + "type": "object", + "additionalProperties": false, + "required": ["owner", "writer", "reader", "runner"], + "properties": { + "owner": { "$ref": "#/definitions/roleVerificationStatus" }, + "writer": { "$ref": "#/definitions/roleVerificationStatus" }, + "reader": { "$ref": "#/definitions/roleVerificationStatus" }, + "runner": { "$ref": "#/definitions/roleVerificationStatus" } + } + }, + "roleVerificationStatus": { + "enum": ["verified", "unverified", "not-applicable"] + }, + "outcome": { + "type": "object", + "additionalProperties": false, + "required": ["id", "description"], + "properties": { + "id": { + "type": "string", + "pattern": "^NB-PARITY-[0-9]{3}-OUTCOME-[0-9]{3}$" + }, + "description": { + "type": "string", + "minLength": 1 + } + } + }, + "coverageTest": { + "type": "object", + "additionalProperties": false, + "required": ["path", "tag"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "tag": { + "type": "string", + "pattern": "^@NB-PARITY-[0-9]{3}$" + } + } + }, + "coverage": { + "type": "object", + "additionalProperties": false, + "required": ["status", "tests", "issues", "uncoveredOutcomes"], + "properties": { + "status": { + "enum": ["covered", "partial", "gap", "blocked"] + }, + "tests": { + "type": "array", + "items": { + "$ref": "#/definitions/coverageTest" + } + }, + "issues": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^ZEPPELIN-[0-9]+$" + } + }, + "uncoveredOutcomes": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^NB-PARITY-[0-9]{3}-OUTCOME-[0-9]{3}$" + } + } + }, + "allOf": [ + { + "if": { + "properties": { "status": { "const": "covered" } } + }, + "then": { + "properties": { + "tests": { "type": "array", "minItems": 1 }, + "uncoveredOutcomes": { "type": "array", "maxItems": 0 } + } + } + }, + { + "if": { + "properties": { "status": { "const": "partial" } } + }, + "then": { + "properties": { + "tests": { "type": "array", "minItems": 1 }, + "issues": { "type": "array", "minItems": 1 }, + "uncoveredOutcomes": { "type": "array", "minItems": 1 } + } + } + }, + { + "if": { + "properties": { "status": { "enum": ["gap", "blocked"] } } + }, + "then": { + "properties": { + "tests": { "type": "array", "maxItems": 0 }, + "issues": { "type": "array", "minItems": 1 }, + "uncoveredOutcomes": { "type": "array", "maxItems": 0 } + } + } + } + ] + }, + "scenario": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "area", + "preconditions", + "action", + "observableOutcomes", + "interpreter", + "implementationEvidence", + "verificationEvidence", + "coverage" + ], + "dependencies": { + "roleExpectations": ["roleVerification"], + "roleVerification": ["roleExpectations"] + }, + "anyOf": [ + { + "properties": { + "implementationEvidence": { "type": "array", "minItems": 1 } + } + }, + { + "properties": { + "verificationEvidence": { "type": "array", "minItems": 1 } + } + } + ], + "properties": { + "id": { + "type": "string", + "pattern": "^NB-PARITY-[0-9]{3}$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "area": { + "enum": [ + "editor", + "execution", + "result", + "visualization", + "shortcut", + "permission", + "collaboration", + "navigation", + "persistence", + "lifecycle", + "theme", + "accessibility" + ] + }, + "preconditions": { "$ref": "#/definitions/stringArray" }, + "action": { + "type": "string", + "minLength": 1 + }, + "observableOutcomes": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/outcome" } + }, + "interpreter": { + "type": ["string", "null"] + }, + "roleExpectations": { "$ref": "#/definitions/roleExpectations" }, + "roleVerification": { "$ref": "#/definitions/roleVerification" }, + "implementationEvidence": { + "type": "array", + "items": { "$ref": "#/definitions/evidence" } + }, + "verificationEvidence": { + "type": "array", + "items": { "$ref": "#/definitions/evidence" } + }, + "coverage": { "$ref": "#/definitions/coverage" } + } + } + } +} diff --git a/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts index acd66e6575d..8ae9749bc0f 100644 --- a/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts +++ b/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts @@ -39,7 +39,7 @@ test.describe('Notebook Action Bar Functionality', () => { await navigateToNotebookWithFallback(page, testNotebook.noteId); }); - test('[NB-PARITY-002] should display and allow title editing with tooltip', async ({ page }) => { + test('should display and allow title editing with tooltip', { tag: '@NB-PARITY-002' }, async ({ page }) => { const notebookName = `TestNotebook_${Date.now()}`; await expect(actionBarPage.titleEditor).toBeVisible(); diff --git a/zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts index 30ebeb16fda..5273e8dfeef 100644 --- a/zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts +++ b/zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts @@ -51,7 +51,7 @@ const openInlineCompletionEditor = async (page: Page) => { test.describe('Inline completion', () => { addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH_CODE_EDITOR); - test('[NB-PARITY-010] shows history completion and preserves focus when dismissed', async ({ page }) => { + test('shows history completion and preserves focus when dismissed', { tag: '@NB-PARITY-010' }, async ({ page }) => { const { noteId, inputArea } = await openInlineCompletionEditor(page); try { @@ -63,21 +63,22 @@ test.describe('Inline completion', () => { } }); - test('[NB-PARITY-011] blurs the editor on the second Escape after dismissing completion', async ({ - page, - browserName - }) => { - test.skip(browserName !== 'chromium', 'Monaco handles the second Escape differently in Firefox and WebKit'); - const { noteId, inputArea } = await openInlineCompletionEditor(page); + test( + 'blurs the editor on the second Escape after dismissing completion', + { tag: '@NB-PARITY-011' }, + async ({ page, browserName }) => { + test.skip(browserName !== 'chromium', 'Monaco handles the second Escape differently in Firefox and WebKit'); + const { noteId, inputArea } = await openInlineCompletionEditor(page); - try { - await expect(inputArea).toBeFocused(); - await page.keyboard.press('Escape'); - await expect(inputArea).toBeFocused(); - await page.keyboard.press('Escape'); - await expect(inputArea).not.toBeFocused(); - } finally { - await page.request.delete(`/api/notebook/${noteId}`); + try { + await expect(inputArea).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(inputArea).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(inputArea).not.toBeFocused(); + } finally { + await page.request.delete(`/api/notebook/${noteId}`); + } } - }); + ); }); 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 e234a91c7fd..a7ae17a6fe3 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 @@ -67,7 +67,7 @@ test.describe.serial('Comprehensive Keyboard Shortcuts (ShortcutsMap)', () => { // ===== CORE EXECUTION SHORTCUTS ===== test.describe('ParagraphActions.Run: Shift+Enter', () => { - test('[NB-PARITY-005] should execute markdown paragraph with Shift+Enter', async () => { + test('should execute markdown paragraph with Shift+Enter', { tag: '@NB-PARITY-005' }, async () => { // Given: A paragraph with markdown content await keyboardPage.tryFocusCodeEditor(); await keyboardPage.setCodeEditorContent('%md\n# Test Heading\n\nThis is **bold** text.'); diff --git a/zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts index d15d15f5414..406e73e8462 100644 --- a/zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts +++ b/zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts @@ -39,7 +39,7 @@ test.describe('Notebook Container Component', () => { await navigateToNotebookWithFallback(page, testNotebook.noteId); }); - test('[NB-PARITY-001] should display notebook container with proper structure', async () => { + test('should display notebook container with proper structure', { tag: '@NB-PARITY-001' }, async () => { await expect(notebookPage.notebookContainer).toBeVisible(); expect(await notebookPage.getNotebookContainerClass()).toContain('notebook-container'); }); 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 3a7f87be960..8a937dc1a16 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 @@ -47,13 +47,13 @@ test.describe('Notebook Paragraph Functionality', () => { await expect(paragraphPage.controlPanel).toBeVisible(); }); - test('[NB-PARITY-003] should support double-click editing functionality', async () => { + 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('[NB-PARITY-004] should display add paragraph buttons', async () => { + 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(); @@ -66,7 +66,7 @@ test.describe('Notebook Paragraph Functionality', () => { await expect(paragraphPage.runButton).toBeEnabled(); }); - test('[NB-PARITY-021] should display result system properly', async ({ page }) => { + 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 }); diff --git a/zeppelin-web-angular/package-lock.json b/zeppelin-web-angular/package-lock.json index 68ed893fabc..205811edd6c 100644 --- a/zeppelin-web-angular/package-lock.json +++ b/zeppelin-web-angular/package-lock.json @@ -62,6 +62,7 @@ "@types/webpack-env": "^1.18.8", "@vitest/coverage-v8": "4.1.8", "@vitest/eslint-plugin": "^1.6.27", + "ajv": "^8.18.0", "angular-eslint": "21.4.0", "concurrently": "9.2.1", "cross-env": "^10.1.0", @@ -8214,6 +8215,8 @@ }, "node_modules/ajv": { "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { diff --git a/zeppelin-web-angular/package.json b/zeppelin-web-angular/package.json index 62719a295fb..af41721f558 100644 --- a/zeppelin-web-angular/package.json +++ b/zeppelin-web-angular/package.json @@ -93,6 +93,7 @@ "@types/webpack-env": "^1.18.8", "@vitest/coverage-v8": "4.1.8", "@vitest/eslint-plugin": "^1.6.27", + "ajv": "^8.18.0", "angular-eslint": "21.4.0", "concurrently": "9.2.1", "cross-env": "^10.1.0", diff --git a/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.test.mjs b/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.test.mjs index 45b0f75808d..a762974c475 100644 --- a/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.test.mjs +++ b/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.test.mjs @@ -32,7 +32,7 @@ function createFixture() { ); fs.writeFileSync( path.join(root, 'zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts'), - "import { test } from '@playwright/test';\ntest('[NB-PARITY-001] should render', async () => {});" + "import { test } from '@playwright/test';\ntest('should render', { tag: '@NB-PARITY-001' }, async () => {});" ); spawnSync('git', ['init'], { cwd: root, stdio: 'ignore' }); spawnSync('git', ['config', 'user.email', 'test@example.invalid'], { cwd: root, stdio: 'ignore' }); @@ -49,7 +49,7 @@ function writeFixtureSpec(webRoot, source) { function baseRegistry(commit) { return { - schemaVersion: 1, + $schema: './notebook-parity.schema.json', reviewedCommit: commit, scenarios: [ { @@ -58,7 +58,7 @@ function baseRegistry(commit) { area: 'navigation', preconditions: ['note exists'], action: 'open the route', - observableOutcomes: ['container is visible'], + observableOutcomes: [{ id: 'NB-PARITY-001-OUTCOME-001', description: 'container is visible' }], interpreter: null, roleExpectations: { owner: 'allow', @@ -72,19 +72,19 @@ function baseRegistry(commit) { reader: 'unverified', runner: 'unverified' }, - evidence: [ + implementationEvidence: [ { path: 'zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts', symbol: 'NotebookComponent' } ], + verificationEvidence: [], coverage: { status: 'covered', tests: [ { path: 'zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts', - title: '[NB-PARITY-001] should render', - projects: ['chromium'] + tag: '@NB-PARITY-001' } ], issues: [], @@ -110,7 +110,8 @@ test('accepts multiline executable Playwright test declarations', () => { webRoot, `import { test } from '@playwright/test'; test( - '[NB-PARITY-001] should render', + 'should render', + { tag: '@NB-PARITY-001' }, async () => {} );` ); @@ -129,38 +130,39 @@ test('rejects duplicate ids and stale markdown', () => { assert.match(errors, /is duplicated/); }); -test('rejects false covered claims without matching Playwright id', () => { +test('rejects false covered claims without matching Playwright tag', () => { const { commit, root, webRoot } = createFixture(); const registry = baseRegistry(commit); - registry.scenarios[0].coverage.tests[0].title = 'should render'; + registry.scenarios[0].coverage.tests[0].tag = '@NB-PARITY-999'; fs.writeFileSync(path.join(webRoot, 'e2e/scenarios/notebook-parity.md'), renderMarkdown(registry)); const errors = validateRegistry(registry, webRoot).join('\n'); - assert.match(errors, /title must contain/); + assert.match(errors, /tag must be @NB-PARITY-001/); }); test('rejects commented-only, helper-string, and skipped Playwright coverage claims', () => { const cases = [ { name: 'commented-only', - source: "// test('[NB-PARITY-001] should render', async () => {});" + source: "// test('should render', { tag: '@NB-PARITY-001' }, async () => {});" }, { name: 'helper-string', - source: "const title = '[NB-PARITY-001] should render';\ntest(title, async () => {});" + source: "const parityTag = '@NB-PARITY-001';\ntest('should render', { tag: parityTag }, async () => {});" }, { name: 'test.skip', - source: "import { test } from '@playwright/test';\ntest.skip('[NB-PARITY-001] should render', async () => {});" + source: + "import { test } from '@playwright/test';\ntest.skip('should render', { tag: '@NB-PARITY-001' }, async () => {});" }, { name: 'skipped describe', source: - "import { test } from '@playwright/test';\ntest.describe.skip('disabled', () => { test('[NB-PARITY-001] should render', async () => {}); });" + "import { test } from '@playwright/test';\ntest.describe.skip('disabled', () => { test('should render', { tag: '@NB-PARITY-001' }, async () => {}); });" }, { name: 'non-Playwright test helper', - source: "const test = () => undefined;\ntest('[NB-PARITY-001] should render', async () => {});" + source: "const test = () => undefined;\ntest('should render', { tag: '@NB-PARITY-001' }, async () => {});" } ]; @@ -171,7 +173,7 @@ test('rejects commented-only, helper-string, and skipped Playwright coverage cla fs.writeFileSync(path.join(webRoot, 'e2e/scenarios/notebook-parity.md'), renderMarkdown(registry)); const errors = validateRegistry(registry, webRoot).join('\n'); - assert.match(errors, /title is not declared by an executable test\(\)/, name); + assert.match(errors, /tag is not declared by an executable test\(\)/, name); } }); @@ -183,7 +185,7 @@ test('requires Jira issues for gaps', () => { fs.writeFileSync(path.join(webRoot, 'e2e/scenarios/notebook-parity.md'), renderMarkdown(registry)); const errors = validateRegistry(registry, webRoot).join('\n'); - assert.match(errors, /issues is required for gap scenarios/); + assert.match(errors, /coverage\.issues must NOT have fewer than 1 items/); }); test('requires executable coverage, a Jira issue, and named uncovered outcomes for partial scenarios', () => { @@ -193,13 +195,13 @@ test('requires executable coverage, a Jira issue, and named uncovered outcomes f registry.scenarios[0].coverage.issues = []; const errors = validateRegistry(registry, webRoot).join('\n'); - assert.match(errors, /issues is required for partial scenarios/); + assert.match(errors, /coverage\.issues must NOT have fewer than 1 items/); registry.scenarios[0].coverage.issues = ['ZEPPELIN-1234']; registry.scenarios[0].coverage.tests = []; const missingTestErrors = validateRegistry(registry, webRoot).join('\n'); - assert.match(missingTestErrors, /tests is required for partial scenarios/); - assert.match(missingTestErrors, /uncoveredOutcomes is required for partial scenarios/); + assert.match(missingTestErrors, /coverage\.tests must NOT have fewer than 1 items/); + assert.match(missingTestErrors, /coverage\.uncoveredOutcomes must NOT have fewer than 1 items/); }); test('rejects invalid Jira issue keys and extra role expectation fields', () => { @@ -209,11 +211,11 @@ test('rejects invalid Jira issue keys and extra role expectation fields', () => registry.scenarios[0].roleExpectations.admin = 'allow'; const errors = validateRegistry(registry, webRoot).join('\n'); - assert.match(errors, /must match ZEPPELIN-####/); - assert.match(errors, /roleExpectations must contain exactly/); + assert.match(errors, /must match pattern "\^ZEPPELIN-\[0-9\]\+\$"/); + assert.match(errors, /roleExpectations must NOT have additional properties/); }); -test('normalizes malformed coverage arrays before applying coverage rules', () => { +test('rejects malformed coverage arrays without applying unsafe coverage rules', () => { const { commit, webRoot } = createFixture(); const registry = baseRegistry(commit); registry.scenarios[0].coverage.tests = null; @@ -222,48 +224,60 @@ test('normalizes malformed coverage arrays before applying coverage rules', () = assert.doesNotThrow(() => validateRegistry(registry, webRoot)); const errors = validateRegistry(registry, webRoot).join('\n'); - assert.match(errors, /coverage.tests must be an array/); - assert.match(errors, /coverage.issues must be an array/); - assert.match(errors, /coverage.uncoveredOutcomes must be an array/); - assert.match(errors, /coverage.tests is required for covered scenarios/); + assert.match(errors, /coverage\.tests must be array/); + assert.match(errors, /coverage\.issues must be array/); + assert.match(errors, /coverage\.uncoveredOutcomes must be array/); }); -test('rejects malformed test entries and invalid execution metadata without throwing', () => { +test('rejects malformed test entries without throwing', () => { const { commit, webRoot } = createFixture(); const registry = baseRegistry(commit); registry.scenarios[0].coverage.tests = [null]; assert.doesNotThrow(() => validateRegistry(registry, webRoot)); const errors = validateRegistry(registry, webRoot).join('\n'); - assert.match(errors, /coverage.tests\[0\] must be an object/); + assert.match(errors, /coverage\.tests\.0 must be object/); registry.scenarios[0].coverage.tests = [ { path: 'zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts', - title: '[NB-PARITY-001] should render', - projects: ['unknown-browser'] + tag: '@NB-PARITY-001', + unexpected: true } ]; - const projectErrors = validateRegistry(registry, webRoot).join('\n'); - assert.match(projectErrors, /projects contains an invalid project/); + const metadataErrors = validateRegistry(registry, webRoot).join('\n'); + assert.match(metadataErrors, /must NOT have additional properties/); }); -test('requires role verification to distinguish expected permissions from tested permissions', () => { +test('allows verified role evidence and optional role metadata', () => { const { commit, webRoot } = createFixture(); const registry = baseRegistry(commit); - registry.scenarios[0].roleVerification.reader = 'covered'; + registry.scenarios[0].roleVerification.reader = 'verified'; + assert.deepEqual(validateRegistry(registry, webRoot, { checkMarkdown: false }), []); - const errors = validateRegistry(registry, webRoot).join('\n'); - assert.match(errors, /roleVerification.reader is invalid/); + delete registry.scenarios[0].roleExpectations; + delete registry.scenarios[0].roleVerification; + assert.deepEqual(validateRegistry(registry, webRoot, { checkMarkdown: false }), []); }); -test('rejects evidence paths outside the repository', () => { +test('rejects implementation evidence paths outside the repository', () => { const { commit, webRoot } = createFixture(); const registry = baseRegistry(commit); - registry.scenarios[0].evidence[0].path = '../outside-repository.ts'; + registry.scenarios[0].implementationEvidence[0].path = '../outside-repository.ts'; const errors = validateRegistry(registry, webRoot).join('\n'); - assert.match(errors, /evidence\[0\].path does not exist/); + assert.match(errors, /implementationEvidence\[0\].path does not exist/); +}); + +test('references uncovered outcomes by stable id', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].coverage.status = 'partial'; + registry.scenarios[0].coverage.issues = ['ZEPPELIN-1234']; + registry.scenarios[0].coverage.uncoveredOutcomes = ['NB-PARITY-001-OUTCOME-999']; + + const errors = validateRegistry(registry, webRoot, { checkMarkdown: false }).join('\n'); + assert.match(errors, /must reference an observable outcome id/); }); test('renders generated Markdown with exactly one trailing newline', () => { @@ -288,7 +302,7 @@ test('renders baseline commit and coverage evidence caveats', () => { assert.match(markdown, new RegExp(`Scenario/Angular baseline commit: \`${commit}\``)); assert.match( markdown, - /`covered` mechanically means this registry points to a matching executable Playwright test declaration/ + /`covered` mechanically means this registry points to a matching executable Playwright test tag/ ); assert.match(markdown, /Semantic adequacy and runtime pass\/fail remain review and CI evidence/); }); diff --git a/zeppelin-web-angular/scripts/notebook-parity-scenarios.mjs b/zeppelin-web-angular/scripts/notebook-parity-scenarios.mjs index 043b637924f..334079be504 100644 --- a/zeppelin-web-angular/scripts/notebook-parity-scenarios.mjs +++ b/zeppelin-web-angular/scripts/notebook-parity-scenarios.mjs @@ -16,33 +16,20 @@ import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import Ajv from 'ajv'; +import ts from 'typescript'; + export const registryPath = 'e2e/scenarios/notebook-parity.json'; +export const schemaPath = 'e2e/scenarios/notebook-parity.schema.json'; export const markdownPath = 'e2e/scenarios/notebook-parity.md'; export const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const allowedAreas = new Set([ - 'editor', - 'execution', - 'result', - 'visualization', - 'shortcut', - 'permission', - 'collaboration', - 'navigation', - 'persistence', - 'lifecycle', - 'theme', - 'accessibility' -]); -const allowedCoverageStatuses = new Set(['covered', 'partial', 'gap', 'blocked']); -const allowedRoleExpectations = new Set(['allow', 'deny', 'not-applicable']); -const allowedRoleVerificationStatuses = new Set(['unverified', 'not-applicable']); -const allowedProjects = new Set(['chromium', 'firefox', 'webkit']); -const jiraIssuePattern = /^ZEPPELIN-\d+$/; const roles = ['owner', 'writer', 'reader', 'runner']; const escapeTableCell = value => String(value).replace(/\|/g, '\\|').replace(/\n/g, '
'); const readJson = file => JSON.parse(readFileSync(file, 'utf8')); +const registrySchema = readJson(path.join(webRoot, schemaPath)); +const validateSchema = new Ajv({ allErrors: true, allowUnionTypes: true }).compile(registrySchema); const resolveRepositoryPath = (root, relativePath) => { if (typeof relativePath !== 'string' || relativePath.length === 0 || path.isAbsolute(relativePath)) { @@ -59,171 +46,84 @@ const resolveRepositoryPath = (root, relativePath) => { ); }; -const assertArrayOfStrings = (errors, value, field) => { - if ( - !Array.isArray(value) || - value.length === 0 || - value.some(item => typeof item !== 'string' || item.length === 0) - ) { - errors.push(`${field} must be a non-empty string array`); - } -}; - -const skipWhitespaceAndComments = (source, start) => { - let index = start; - while (index < source.length) { - if (/\s/.test(source[index])) { - index += 1; - } else if (source[index] === '/' && source[index + 1] === '/') { - index = source.indexOf('\n', index + 2); - if (index === -1) { - return source.length; - } - } else if (source[index] === '/' && source[index + 1] === '*') { - index = source.indexOf('*/', index + 2); - if (index === -1) { - return source.length; - } - index += 2; - } else { - return index; +const isPlaywrightTestImport = node => + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) && + node.moduleSpecifier.text === '@playwright/test' && + node.importClause?.namedBindings && + ts.isNamedImports(node.importClause.namedBindings) && + node.importClause.namedBindings.elements.some(element => element.name.text === 'test'); + +const isSkippedDescribeCall = node => + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === 'skip' && + ts.isPropertyAccessExpression(node.expression.expression) && + node.expression.expression.name.text === 'describe' && + ts.isIdentifier(node.expression.expression.expression) && + node.expression.expression.expression.text === 'test'; + +const isInsideSkippedDescribe = node => { + for (let current = node.parent; current; current = current.parent) { + if (isSkippedDescribeCall(current)) { + return true; } } - return index; + return false; }; -const readStringLiteral = (source, start) => { - const quote = source[start]; - if (quote !== "'" && quote !== '"' && quote !== '`') { - return null; - } - - let value = ''; - for (let index = start + 1; index < source.length; index += 1) { - const character = source[index]; - if (character === '\\') { - value += source[index + 1] ?? ''; - index += 1; - } else if (quote === '`' && character === '$' && source[index + 1] === '{') { - return null; - } else if (character === quote) { - return value; - } else { - value += character; - } +const readStaticTags = options => { + if (!options || !ts.isObjectLiteralExpression(options)) { + return []; } - return null; -}; - -const skipStringLiteral = (source, start) => { - const quote = source[start]; - for (let index = start + 1; index < source.length; index += 1) { - const character = source[index]; - if (character === '\\') { - index += 1; - } else if (character === quote) { - return index + 1; - } + const tagProperty = options.properties.find( + property => + ts.isPropertyAssignment(property) && + ((ts.isIdentifier(property.name) && property.name.text === 'tag') || + (ts.isStringLiteral(property.name) && property.name.text === 'tag')) + ); + if (!tagProperty || !ts.isPropertyAssignment(tagProperty)) { + return []; } - return source.length; -}; - -const isIdentifierCharacter = character => /[A-Za-z0-9_$]/.test(character ?? ''); - -const previousNonWhitespaceCharacter = (source, start) => { - for (let index = start - 1; index >= 0; index -= 1) { - if (!/\s/.test(source[index])) { - return source[index]; - } + if (ts.isStringLiteralLike(tagProperty.initializer)) { + return [tagProperty.initializer.text]; } - return ''; -}; - -const importsPlaywrightTest = source => - /import\s+(?:[\s\S]*?\btest\b[\s\S]*?)\s+from\s+['"]@playwright\/test['"]/.test(source); - -const findSkippedDescribeBlocks = source => { - const blocks = []; - const marker = 'test.describe.skip'; - for (let start = source.indexOf(marker); start !== -1; start = source.indexOf(marker, start + marker.length)) { - const bodyStart = source.indexOf('{', start + marker.length); - if (bodyStart === -1) { - continue; - } - let depth = 0; - for (let index = bodyStart; index < source.length; index += 1) { - if (source[index] === "'" || source[index] === '"' || source[index] === '`') { - index = skipStringLiteral(source, index) - 1; - } else if (source[index] === '/' && source[index + 1] === '/') { - index = source.indexOf('\n', index + 2); - if (index === -1) { - break; - } - } else if (source[index] === '/' && source[index + 1] === '*') { - index = source.indexOf('*/', index + 2); - if (index === -1) { - break; - } - index += 1; - } else if (source[index] === '{') { - depth += 1; - } else if (source[index] === '}' && --depth === 0) { - blocks.push([start, index]); - break; - } - } + if (ts.isArrayLiteralExpression(tagProperty.initializer)) { + return tagProperty.initializer.elements.filter(ts.isStringLiteralLike).map(element => element.text); } - return blocks; + return []; }; -const getExecutablePlaywrightTestTitles = source => { - if (!importsPlaywrightTest(source)) { +const getExecutablePlaywrightTestTags = source => { + const sourceFile = ts.createSourceFile('spec.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + if (!sourceFile.statements.some(isPlaywrightTestImport)) { return new Set(); } - const titles = new Set(); - const skippedDescribeBlocks = findSkippedDescribeBlocks(source); - for (let index = 0; index < source.length; index += 1) { - if (source[index] === '/' && source[index + 1] === '/') { - index = source.indexOf('\n', index + 2); - if (index === -1) { - break; - } - } else if (source[index] === '/' && source[index + 1] === '*') { - index = source.indexOf('*/', index + 2); - if (index === -1) { - break; - } - index += 1; - } else if (source[index] === "'" || source[index] === '"' || source[index] === '`') { - index = skipStringLiteral(source, index) - 1; - } else if ( - source.startsWith('test', index) && - !isIdentifierCharacter(source[index - 1]) && - !isIdentifierCharacter(source[index + 4]) && - previousNonWhitespaceCharacter(source, index) !== '.' + const tags = new Set(); + const visit = node => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'test' && + !isInsideSkippedDescribe(node) ) { - const openParen = skipWhitespaceAndComments(source, index + 4); - if (source[openParen] !== '(') { - continue; - } - const titleStart = skipWhitespaceAndComments(source, openParen + 1); - const title = readStringLiteral(source, titleStart); - if (title !== null && !skippedDescribeBlocks.some(([start, end]) => index >= start && index <= end)) { - titles.add(title); + for (const tag of readStaticTags(node.arguments[1])) { + tags.add(tag); } } - } - - return titles; + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return tags; }; -const testDeclaresExecutableTitle = (root, test) => { +const testDeclaresExecutableTag = (root, test) => { const absolutePath = resolveRepositoryPath(root, test.path); if (!absolutePath || !existsSync(absolutePath)) { return false; } - return getExecutablePlaywrightTestTitles(readFileSync(absolutePath, 'utf8')).has(test.title); + return getExecutablePlaywrightTestTags(readFileSync(absolutePath, 'utf8')).has(test.tag); }; export const renderMarkdown = registry => { @@ -244,24 +144,26 @@ export const renderMarkdown = registry => { '', '', '', - `Schema version: ${registry.schemaVersion}`, + 'Schema: [notebook-parity.schema.json](./notebook-parity.schema.json)', '', `Scenario/Angular baseline commit: \`${registry.reviewedCommit}\``, '', 'Scope note: This is a prioritized baseline, not a complete Notebook inventory. Before a React vertical slice is declared ready, add every affected behavior to this registry and classify its evidence.', '', - 'Coverage note: `covered` mechanically means this registry points to a matching executable Playwright test declaration. Semantic adequacy and runtime pass/fail remain review and CI evidence. Role expectations and role verification are deliberately separate.', + 'Coverage note: `covered` mechanically means this registry points to a matching executable Playwright test tag. Semantic adequacy and runtime pass/fail remain review and CI evidence. Role expectations are recorded only when the outcome varies by role, and role verification records whether that expectation has been tested.', '', '| ID | Area | Scenario | Coverage | Roles | Tests | Issues |', '| --- | --- | --- | --- | --- | --- | --- |' ]; for (const scenario of registry.scenarios) { - const rolesText = roles.map(role => `${role}: ${scenario.roleExpectations[role]}`).join('
'); + const rolesText = scenario.roleExpectations + ? roles.map(role => `${role}: ${scenario.roleExpectations[role]}`).join('
') + : 'not-applicable'; const testsText = scenario.coverage.tests.length === 0 ? '' - : scenario.coverage.tests.map(test => `${test.path}
${test.title}`).join('

'); + : scenario.coverage.tests.map(test => `${test.path}
${test.tag}`).join('

'); const issuesText = scenario.coverage.issues.join(', '); lines.push( `| ${scenario.id} | ${scenario.area} | ${escapeTableCell(scenario.name)} | ${scenario.coverage.status} | ${escapeTableCell(rolesText)} | ${escapeTableCell(testsText)} | ${issuesText} |` @@ -276,21 +178,26 @@ export const renderMarkdown = registry => { lines.push(`- Area: ${scenario.area}`); lines.push(`- Coverage: ${scenario.coverage.status}`); lines.push(`- Interpreter: ${scenario.interpreter ?? 'not-applicable'}`); - lines.push(`- Role verification: ${roles.map(role => `${role}: ${scenario.roleVerification[role]}`).join('; ')}`); + lines.push( + `- Role verification: ${scenario.roleVerification ? roles.map(role => `${role}: ${scenario.roleVerification[role]}`).join('; ') : 'not-applicable'}` + ); lines.push(`- Preconditions: ${scenario.preconditions.join(' ')}`); lines.push(`- Action: ${scenario.action}`); - lines.push(`- Observable outcomes: ${scenario.observableOutcomes.join(' ')}`); - lines.push(`- Evidence: ${scenario.evidence.map(item => `${item.path} (${item.symbol})`).join('; ')}`); - if (scenario.coverage.tests.length > 0) { + lines.push( + `- Observable outcomes: ${scenario.observableOutcomes.map(outcome => `${outcome.id}: ${outcome.description}`).join(' ')}` + ); + lines.push( + `- Implementation evidence: ${scenario.implementationEvidence.map(item => `${item.path} (${item.symbol})`).join('; ') || 'not-applicable'}` + ); + lines.push( + `- Verification evidence: ${scenario.verificationEvidence.map(item => `${item.path} (${item.symbol})`).join('; ') || 'not-applicable'}` + ); + if (scenario.coverage.uncoveredOutcomes.length > 0) { + const outcomesById = new Map(scenario.observableOutcomes.map(outcome => [outcome.id, outcome.description])); lines.push( - `- Browser projects: ${scenario.coverage.tests - .map(test => `${test.title}: ${test.projects.join(', ')}`) - .join('; ')}` + `- Uncovered outcomes: ${scenario.coverage.uncoveredOutcomes.map(id => `${id}: ${outcomesById.get(id)}`).join(' ')}` ); } - if (scenario.coverage.uncoveredOutcomes.length > 0) { - lines.push(`- Uncovered outcomes: ${scenario.coverage.uncoveredOutcomes.join(' ')}`); - } lines.push(''); } @@ -300,12 +207,16 @@ export const renderMarkdown = registry => { export const validateRegistry = (registry, root = webRoot, { checkMarkdown = true } = {}) => { const errors = []; - if (registry.schemaVersion !== 1) { - errors.push('schemaVersion must be 1'); + if (!validateSchema(registry)) { + errors.push( + ...validateSchema.errors.map(error => { + const location = error.instancePath ? error.instancePath.slice(1).replaceAll('/', '.') : 'registry'; + return `${location} ${error.message}`; + }) + ); } - if (typeof registry.reviewedCommit !== 'string' || !/^[0-9a-f]{40}$/.test(registry.reviewedCommit)) { - errors.push('reviewedCommit must be a full 40-character commit hash'); - } else { + + if (typeof registry.reviewedCommit === 'string' && /^[0-9a-f]{40}$/.test(registry.reviewedCommit)) { try { execFileSync('git', ['cat-file', '-e', `${registry.reviewedCommit}^{commit}`], { cwd: root, stdio: 'ignore' }); } catch { @@ -313,7 +224,6 @@ export const validateRegistry = (registry, root = webRoot, { checkMarkdown = tru } } if (!Array.isArray(registry.scenarios) || registry.scenarios.length === 0) { - errors.push('scenarios must be a non-empty array'); return errors; } @@ -321,11 +231,12 @@ export const validateRegistry = (registry, root = webRoot, { checkMarkdown = tru let previousId = ''; for (const [index, scenario] of registry.scenarios.entries()) { const prefix = `scenarios[${index}]`; - if (typeof scenario.id !== 'string' || !/^NB-PARITY-\d{3}$/.test(scenario.id)) { - errors.push(`${prefix}.id must match NB-PARITY-###`); - } else if (ids.has(scenario.id)) { + if (!scenario || typeof scenario !== 'object') { + continue; + } + if (typeof scenario.id === 'string' && ids.has(scenario.id)) { errors.push(`${prefix}.id is duplicated: ${scenario.id}`); - } else { + } else if (typeof scenario.id === 'string') { if (previousId && scenario.id <= previousId) { errors.push(`${prefix}.id must sort after ${previousId}`); } @@ -333,132 +244,77 @@ export const validateRegistry = (registry, root = webRoot, { checkMarkdown = tru ids.add(scenario.id); } - if (typeof scenario.name !== 'string' || scenario.name.length === 0) { - errors.push(`${prefix}.name is required`); - } - if (!allowedAreas.has(scenario.area)) { - errors.push(`${prefix}.area is invalid: ${scenario.area}`); - } - assertArrayOfStrings(errors, scenario.preconditions, `${prefix}.preconditions`); - if (typeof scenario.action !== 'string' || scenario.action.length === 0) { - errors.push(`${prefix}.action is required`); - } - assertArrayOfStrings(errors, scenario.observableOutcomes, `${prefix}.observableOutcomes`); - if (!(typeof scenario.interpreter === 'string' || scenario.interpreter === null)) { - errors.push(`${prefix}.interpreter must be a string or null`); - } - for (const role of roles) { - if (!allowedRoleExpectations.has(scenario.roleExpectations?.[role])) { - errors.push(`${prefix}.roleExpectations.${role} is invalid`); + if (scenario.roleExpectations && scenario.roleVerification) { + for (const role of roles) { + if ( + scenario.roleExpectations[role] === 'not-applicable' && + scenario.roleVerification[role] !== 'not-applicable' + ) { + errors.push(`${prefix}.roleVerification.${role} must be not-applicable`); + } } } - const roleKeys = Object.keys(scenario.roleExpectations ?? {}).sort(); - if (roleKeys.join(',') !== [...roles].sort().join(',')) { - errors.push(`${prefix}.roleExpectations must contain exactly ${roles.join(', ')}`); - } - for (const role of roles) { - if (!allowedRoleVerificationStatuses.has(scenario.roleVerification?.[role])) { - errors.push(`${prefix}.roleVerification.${role} is invalid`); - } - if ( - scenario.roleExpectations?.[role] === 'not-applicable' && - scenario.roleVerification?.[role] !== 'not-applicable' - ) { - errors.push(`${prefix}.roleVerification.${role} must be not-applicable`); + + for (const evidenceField of ['implementationEvidence', 'verificationEvidence']) { + if (Array.isArray(scenario[evidenceField])) { + for (const [evidenceIndex, evidence] of scenario[evidenceField].entries()) { + if (!evidence || typeof evidence.path !== 'string') { + continue; + } + const evidencePath = resolveRepositoryPath(root, evidence.path); + if (!evidencePath || !existsSync(evidencePath)) { + errors.push(`${prefix}.${evidenceField}[${evidenceIndex}].path does not exist: ${evidence.path}`); + } + } } } - const roleVerificationKeys = Object.keys(scenario.roleVerification ?? {}).sort(); - if (roleVerificationKeys.join(',') !== [...roles].sort().join(',')) { - errors.push(`${prefix}.roleVerification must contain exactly ${roles.join(', ')}`); - } - if (!Array.isArray(scenario.evidence) || scenario.evidence.length === 0) { - errors.push(`${prefix}.evidence must be a non-empty array`); - } else { - for (const [evidenceIndex, evidence] of scenario.evidence.entries()) { - const evidencePath = resolveRepositoryPath(root, evidence.path); - if (!evidencePath || !existsSync(evidencePath)) { - errors.push(`${prefix}.evidence[${evidenceIndex}].path does not exist: ${evidence.path}`); + const outcomeIds = new Set(); + if (Array.isArray(scenario.observableOutcomes)) { + for (const [outcomeIndex, outcome] of scenario.observableOutcomes.entries()) { + if (!outcome || typeof outcome.id !== 'string') { + continue; } - if (typeof evidence.symbol !== 'string' || evidence.symbol.length === 0) { - errors.push(`${prefix}.evidence[${evidenceIndex}].symbol is required`); + if (!outcome.id.startsWith(`${scenario.id}-OUTCOME-`)) { + errors.push(`${prefix}.observableOutcomes[${outcomeIndex}].id must start with ${scenario.id}-OUTCOME-`); + } else if (outcomeIds.has(outcome.id)) { + errors.push(`${prefix}.observableOutcomes[${outcomeIndex}].id is duplicated: ${outcome.id}`); } + outcomeIds.add(outcome.id); } } const coverage = scenario.coverage; - if (!allowedCoverageStatuses.has(coverage?.status)) { - errors.push(`${prefix}.coverage.status is invalid`); + if (!coverage || typeof coverage !== 'object') { continue; } - if (!Array.isArray(coverage.tests)) { - errors.push(`${prefix}.coverage.tests must be an array`); - } - if (!Array.isArray(coverage.issues)) { - errors.push(`${prefix}.coverage.issues must be an array`); - } - if (!Array.isArray(coverage.uncoveredOutcomes)) { - errors.push(`${prefix}.coverage.uncoveredOutcomes must be an array`); - } - const coverageTests = Array.isArray(coverage.tests) ? coverage.tests : []; - const coverageIssues = Array.isArray(coverage.issues) ? coverage.issues : []; const uncoveredOutcomes = Array.isArray(coverage.uncoveredOutcomes) ? coverage.uncoveredOutcomes : []; - if (coverage.status === 'covered' && coverageTests.length === 0) { - errors.push(`${prefix}.coverage.tests is required for covered scenarios`); - } - if (coverage.status === 'partial' && coverageTests.length === 0) { - errors.push(`${prefix}.coverage.tests is required for partial scenarios`); - } - if (coverage.status === 'partial' && coverageIssues.length === 0) { - errors.push(`${prefix}.coverage.issues is required for partial scenarios`); - } - if (coverage.status === 'partial' && uncoveredOutcomes.length === 0) { - errors.push(`${prefix}.coverage.uncoveredOutcomes is required for partial scenarios`); - } - if (coverage.status !== 'partial' && uncoveredOutcomes.length > 0) { - errors.push(`${prefix}.coverage.uncoveredOutcomes is only valid for partial scenarios`); - } - for (const [outcomeIndex, outcome] of uncoveredOutcomes.entries()) { - if (typeof outcome !== 'string' || !scenario.observableOutcomes.includes(outcome)) { - errors.push(`${prefix}.coverage.uncoveredOutcomes[${outcomeIndex}] must reference an observable outcome`); - } - } - if ((coverage.status === 'gap' || coverage.status === 'blocked') && coverageIssues.length === 0) { - errors.push(`${prefix}.coverage.issues is required for ${coverage.status} scenarios`); - } - for (const [issueIndex, issue] of coverageIssues.entries()) { - if (typeof issue !== 'string' || !jiraIssuePattern.test(issue)) { - errors.push(`${prefix}.coverage.issues[${issueIndex}] must match ZEPPELIN-####`); + for (const [outcomeIndex, outcomeId] of uncoveredOutcomes.entries()) { + if (!outcomeIds.has(outcomeId)) { + errors.push(`${prefix}.coverage.uncoveredOutcomes[${outcomeIndex}] must reference an observable outcome id`); } } + const coverageTests = Array.isArray(coverage.tests) ? coverage.tests : []; for (const [testIndex, test] of coverageTests.entries()) { if (!test || typeof test !== 'object') { - errors.push(`${prefix}.coverage.tests[${testIndex}] must be an object`); continue; } - const testPath = resolveRepositoryPath(root, test.path); - if (!testPath || !existsSync(testPath)) { - errors.push(`${prefix}.coverage.tests[${testIndex}].path does not exist: ${test.path}`); - } - if (coverage.status === 'covered' && !test.path.startsWith('zeppelin-web-angular/e2e/tests/notebook/')) { - errors.push(`${prefix}.coverage.tests[${testIndex}].path must be in the notebook E2E suite`); + if (typeof test.path === 'string') { + const testPath = resolveRepositoryPath(root, test.path); + if (!testPath || !existsSync(testPath)) { + errors.push(`${prefix}.coverage.tests[${testIndex}].path does not exist: ${test.path}`); + } + if (coverage.status === 'covered' && !test.path.startsWith('zeppelin-web-angular/e2e/tests/notebook/')) { + errors.push(`${prefix}.coverage.tests[${testIndex}].path must be in the notebook E2E suite`); + } } - if (typeof test.title !== 'string' || !test.title.includes(scenario.id)) { - errors.push(`${prefix}.coverage.tests[${testIndex}].title must contain ${scenario.id}`); - } else if (!testDeclaresExecutableTitle(root, test)) { + if (test.tag !== `@${scenario.id}`) { + errors.push(`${prefix}.coverage.tests[${testIndex}].tag must be @${scenario.id}`); + } else if (typeof test.path === 'string' && !testDeclaresExecutableTag(root, test)) { errors.push( - `${prefix}.coverage.tests[${testIndex}].title is not declared by an executable test() in ${test.path}` + `${prefix}.coverage.tests[${testIndex}].tag is not declared by an executable test() in ${test.path}` ); } - if (!Array.isArray(test.projects) || test.projects.length === 0) { - errors.push(`${prefix}.coverage.tests[${testIndex}].projects must be a non-empty array`); - } else { - for (const project of test.projects) { - if (!allowedProjects.has(project)) { - errors.push(`${prefix}.coverage.tests[${testIndex}].projects contains an invalid project: ${project}`); - } - } - } } } From fd4037ce1f52f446d8fefa550d22f1e21b7b384f Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Sun, 6 Sep 2026 11:11:56 +0900 Subject: [PATCH 3/3] [ZEPPELIN-6660] Harden notebook parity validation --- zeppelin-web-angular/e2e/AGENTS.md | 2 +- .../e2e/scenarios/notebook-parity.json | 10 +-- .../e2e/scenarios/notebook-parity.md | 8 +- zeppelin-web-angular/package-lock.json | 25 +++++- zeppelin-web-angular/package.json | 2 +- .../check-notebook-parity-scenarios.test.mjs | 65 ++++++++++++--- .../scripts/notebook-parity-scenarios.mjs | 81 ++++++++++++++----- 7 files changed, 146 insertions(+), 47 deletions(-) diff --git a/zeppelin-web-angular/e2e/AGENTS.md b/zeppelin-web-angular/e2e/AGENTS.md index 4516eb84909..35a7da5d978 100644 --- a/zeppelin-web-angular/e2e/AGENTS.md +++ b/zeppelin-web-angular/e2e/AGENTS.md @@ -117,7 +117,7 @@ Use an existing key from the `PAGES` object in `e2e/utils.ts`; add a new one the 1. Pick/confirm the target route and the `PAGES` key. 2. Copy the shape of an existing spec in the same ``; reuse or extend the matching POM (`models/.ts` + `.util.ts`). Do not inline selectors the POM already owns. 3. Annotate the page (`addPageAnnotationBeforeEach`), navigate, then `waitForZeppelinReady`. -4. If the test covers a scenario in `e2e/scenarios/notebook-parity.json`, add its stable ID as a Playwright tag such as `{ tag: '@NB-PARITY-001' }`. Keep the title human-readable; the registry links coverage by tag and path. Browser-specific restrictions stay in the spec rather than being copied into the registry. +4. If the test covers a scenario in `e2e/scenarios/notebook-parity.json`, add its stable ID as a Playwright tag such as `{ tag: '@NB-PARITY-001' }`. Keep the title human-readable; the registry links coverage by tag and path. Browser execution controls such as project lists and skip conditions stay in the spec rather than being copied into the registry. Keep browser assumptions in the registry only when they define the scenario's behavior or expected outcome. 5. Run `npm run e2e:fast` and iterate until green. ## Migration (Angular to React Microfrontend) diff --git a/zeppelin-web-angular/e2e/scenarios/notebook-parity.json b/zeppelin-web-angular/e2e/scenarios/notebook-parity.json index 0a110ee8318..9ee808f9078 100644 --- a/zeppelin-web-angular/e2e/scenarios/notebook-parity.json +++ b/zeppelin-web-angular/e2e/scenarios/notebook-parity.json @@ -380,11 +380,11 @@ "implementationEvidence": [ { "path": "zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.html", - "symbol": "paragraph result display" + "symbol": "zeppelin-notebook-paragraph-result" }, { "path": "zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/progress/progress.component.ts", - "symbol": "ProgressComponent" + "symbol": "NotebookParagraphProgressComponent" }, { "path": "zeppelin-web-angular/projects/zeppelin-visualization/src/table-transformation.ts", @@ -442,7 +442,7 @@ "implementationEvidence": [ { "path": "zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts", - "symbol": "CodeEditorComponent" + "symbol": "NotebookParagraphCodeEditorComponent" } ], "verificationEvidence": [ @@ -493,7 +493,7 @@ "implementationEvidence": [ { "path": "zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts", - "symbol": "CodeEditorComponent" + "symbol": "NotebookParagraphCodeEditorComponent" }, { "path": "zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts", @@ -538,7 +538,7 @@ "verificationEvidence": [ { "path": "zeppelin-web-angular/e2e/tests/theme/dark-mode.spec.ts", - "symbol": "Dark Mode" + "symbol": "Dark Mode Theme Switching" } ] } diff --git a/zeppelin-web-angular/e2e/scenarios/notebook-parity.md b/zeppelin-web-angular/e2e/scenarios/notebook-parity.md index ab31cdd73b4..3da42b6e890 100644 --- a/zeppelin-web-angular/e2e/scenarios/notebook-parity.md +++ b/zeppelin-web-angular/e2e/scenarios/notebook-parity.md @@ -131,7 +131,7 @@ Coverage note: `covered` mechanically means this registry points to a matching e - Preconditions: A notebook has a Python paragraph that prints text. A notebook has a paragraph that returns tabular output. - Action: Run the paragraph from the paragraph control and inspect the rendered result panel. - Observable outcomes: NB-PARITY-021-OUTCOME-001: The result display becomes visible and is not empty. NB-PARITY-021-OUTCOME-002: The UI offers every display mode that the Angular notebook exposes for the returned result type. NB-PARITY-021-OUTCOME-003: Visualization control changes preserve the field mapping from result columns to configured dimensions or measures. NB-PARITY-021-OUTCOME-004: The paragraph's persisted config reflects the resulting configuration object after a visualization option changes. NB-PARITY-021-OUTCOME-005: Text and table results expose accessible table output row by row so migrated React rendering can be compared without relying on screenshots alone. -- Implementation evidence: zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.html (paragraph result display); zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/progress/progress.component.ts (ProgressComponent); zeppelin-web-angular/projects/zeppelin-visualization/src/table-transformation.ts (TableTransformation); zeppelin-web-angular/projects/zeppelin-visualization/src/pivot-transformation.ts (PivotTransformation); zeppelin-web-angular/projects/zeppelin-visualization/src/visualization.ts (Visualization) +- Implementation evidence: zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.html (zeppelin-notebook-paragraph-result); zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/progress/progress.component.ts (NotebookParagraphProgressComponent); zeppelin-web-angular/projects/zeppelin-visualization/src/table-transformation.ts (TableTransformation); zeppelin-web-angular/projects/zeppelin-visualization/src/pivot-transformation.ts (PivotTransformation); zeppelin-web-angular/projects/zeppelin-visualization/src/visualization.ts (Visualization) - Verification evidence: not-applicable - Uncovered outcomes: NB-PARITY-021-OUTCOME-002: The UI offers every display mode that the Angular notebook exposes for the returned result type. NB-PARITY-021-OUTCOME-003: Visualization control changes preserve the field mapping from result columns to configured dimensions or measures. NB-PARITY-021-OUTCOME-004: The paragraph's persisted config reflects the resulting configuration object after a visualization option changes. NB-PARITY-021-OUTCOME-005: Text and table results expose accessible table output row by row so migrated React rendering can be compared without relying on screenshots alone. @@ -144,7 +144,7 @@ Coverage note: `covered` mechanically means this registry points to a matching e - Preconditions: A disposable notebook with one editable paragraph is open. The user can edit the paragraph. - Action: Replace the paragraph text and stop typing long enough for the notebook save path to acknowledge the edit. - Observable outcomes: NB-PARITY-050-OUTCOME-001: The persisted paragraph text equals the latest typed text. NB-PARITY-050-OUTCOME-002: The save assertion is based on observable persistence or wire evidence, not an internal timer. -- Implementation evidence: zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts (CodeEditorComponent) +- Implementation evidence: zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts (NotebookParagraphCodeEditorComponent) - Verification evidence: zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts (NotebookKeyboardPage) ### NB-PARITY-051 Notebook editor does not lose an edit made while a prior save is in flight @@ -156,7 +156,7 @@ Coverage note: `covered` mechanically means this registry points to a matching e - Preconditions: A disposable notebook with one editable paragraph is open. The first paragraph save request can be observed before it completes. - Action: Edit the paragraph, keep the first save in flight, then make a second edit. - Observable outcomes: NB-PARITY-051-OUTCOME-001: The first in-flight save does not overwrite or drop the second edit. NB-PARITY-051-OUTCOME-002: A later observable save or reconciliation persists the second edit. -- Implementation evidence: zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts (CodeEditorComponent); zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts (NotebookComponent) +- Implementation evidence: zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts (NotebookParagraphCodeEditorComponent); zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts (NotebookComponent) - Verification evidence: not-applicable ### NB-PARITY-060 Notebook honors host theme selection @@ -169,4 +169,4 @@ Coverage note: `covered` mechanically means this registry points to a matching e - Action: Change the host theme while a notebook surface is mounted. - Observable outcomes: NB-PARITY-060-OUTCOME-001: Notebook text remains readable. NB-PARITY-060-OUTCOME-002: Result and chart output inherit the host theme tokens. NB-PARITY-060-OUTCOME-003: The selected theme persists after reload. - Implementation evidence: zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.tsx (ZeppelinThemeProvider) -- Verification evidence: zeppelin-web-angular/e2e/tests/theme/dark-mode.spec.ts (Dark Mode) +- Verification evidence: zeppelin-web-angular/e2e/tests/theme/dark-mode.spec.ts (Dark Mode Theme Switching) diff --git a/zeppelin-web-angular/package-lock.json b/zeppelin-web-angular/package-lock.json index 205811edd6c..2fcf37e2562 100644 --- a/zeppelin-web-angular/package-lock.json +++ b/zeppelin-web-angular/package-lock.json @@ -62,7 +62,7 @@ "@types/webpack-env": "^1.18.8", "@vitest/coverage-v8": "4.1.8", "@vitest/eslint-plugin": "^1.6.27", - "ajv": "^8.18.0", + "ajv": "^8.20.0", "angular-eslint": "21.4.0", "concurrently": "9.2.1", "cross-env": "^10.1.0", @@ -1343,6 +1343,23 @@ } } }, + "node_modules/@angular-devkit/core/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@angular-devkit/schematics": { "version": "21.2.13", "dev": true, @@ -8214,9 +8231,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/zeppelin-web-angular/package.json b/zeppelin-web-angular/package.json index af41721f558..2e9a509bb28 100644 --- a/zeppelin-web-angular/package.json +++ b/zeppelin-web-angular/package.json @@ -93,7 +93,7 @@ "@types/webpack-env": "^1.18.8", "@vitest/coverage-v8": "4.1.8", "@vitest/eslint-plugin": "^1.6.27", - "ajv": "^8.18.0", + "ajv": "^8.20.0", "angular-eslint": "21.4.0", "concurrently": "9.2.1", "cross-env": "^10.1.0", diff --git a/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.test.mjs b/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.test.mjs index a762974c475..fd66c915e62 100644 --- a/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.test.mjs +++ b/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.test.mjs @@ -120,14 +120,33 @@ test('accepts multiline executable Playwright test declarations', () => { assert.deepEqual(validateRegistry(registry, webRoot), []); }); -test('rejects duplicate ids and stale markdown', () => { - const { commit, root, webRoot } = createFixture(); +test('accepts an aliased Playwright test import', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + writeFixtureSpec( + webRoot, + "import { test as pwTest } from '@playwright/test';\npwTest('should render', { tag: '@NB-PARITY-001' }, async () => {});" + ); + + assert.deepEqual(validateRegistry(registry, webRoot, { checkMarkdown: false }), []); +}); + +test('rejects duplicate ids', () => { + const { commit, webRoot } = createFixture(); const registry = baseRegistry(commit); registry.scenarios.push(structuredClone(registry.scenarios[0])); + + const errors = validateRegistry(registry, webRoot, { checkMarkdown: false }).join('\n'); + assert.match(errors, /is duplicated/); +}); + +test('rejects stale generated markdown', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); fs.writeFileSync(path.join(webRoot, 'e2e/scenarios/notebook-parity.md'), 'stale\n'); const errors = validateRegistry(registry, webRoot).join('\n'); - assert.match(errors, /is duplicated/); + assert.match(errors, /notebook-parity\.md is stale/); }); test('rejects false covered claims without matching Playwright tag', () => { @@ -185,7 +204,7 @@ test('requires Jira issues for gaps', () => { fs.writeFileSync(path.join(webRoot, 'e2e/scenarios/notebook-parity.md'), renderMarkdown(registry)); const errors = validateRegistry(registry, webRoot).join('\n'); - assert.match(errors, /coverage\.issues must NOT have fewer than 1 items/); + assert.match(errors, /coverage\.issues must contain at least 1 item/); }); test('requires executable coverage, a Jira issue, and named uncovered outcomes for partial scenarios', () => { @@ -195,13 +214,13 @@ test('requires executable coverage, a Jira issue, and named uncovered outcomes f registry.scenarios[0].coverage.issues = []; const errors = validateRegistry(registry, webRoot).join('\n'); - assert.match(errors, /coverage\.issues must NOT have fewer than 1 items/); + assert.match(errors, /coverage\.issues must contain at least 1 item/); registry.scenarios[0].coverage.issues = ['ZEPPELIN-1234']; registry.scenarios[0].coverage.tests = []; const missingTestErrors = validateRegistry(registry, webRoot).join('\n'); - assert.match(missingTestErrors, /coverage\.tests must NOT have fewer than 1 items/); - assert.match(missingTestErrors, /coverage\.uncoveredOutcomes must NOT have fewer than 1 items/); + assert.match(missingTestErrors, /coverage\.tests must contain at least 1 item/); + assert.match(missingTestErrors, /coverage\.uncoveredOutcomes must contain at least 1 item/); }); test('rejects invalid Jira issue keys and extra role expectation fields', () => { @@ -211,8 +230,8 @@ test('rejects invalid Jira issue keys and extra role expectation fields', () => registry.scenarios[0].roleExpectations.admin = 'allow'; const errors = validateRegistry(registry, webRoot).join('\n'); - assert.match(errors, /must match pattern "\^ZEPPELIN-\[0-9\]\+\$"/); - assert.match(errors, /roleExpectations must NOT have additional properties/); + assert.match(errors, /must match \^ZEPPELIN-\[0-9\]\+\$/); + assert.match(errors, /roleExpectations\.admin is not allowed/); }); test('rejects malformed coverage arrays without applying unsafe coverage rules', () => { @@ -246,7 +265,7 @@ test('rejects malformed test entries without throwing', () => { } ]; const metadataErrors = validateRegistry(registry, webRoot).join('\n'); - assert.match(metadataErrors, /must NOT have additional properties/); + assert.match(metadataErrors, /coverage\.tests\.0\.unexpected is not allowed/); }); test('allows verified role evidence and optional role metadata', () => { @@ -260,10 +279,34 @@ test('allows verified role evidence and optional role metadata', () => { assert.deepEqual(validateRegistry(registry, webRoot, { checkMarkdown: false }), []); }); +test('rejects not-applicable verification for an applicable role expectation', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].roleVerification.reader = 'not-applicable'; + + const errors = validateRegistry(registry, webRoot, { checkMarkdown: false }).join('\n'); + assert.match(errors, /roleVerification\.reader must not be not-applicable/); +}); + +test('rejects applicable verification for a not-applicable role expectation', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].roleExpectations.reader = 'not-applicable'; + registry.scenarios[0].roleVerification.reader = 'verified'; + + const errors = validateRegistry(registry, webRoot, { checkMarkdown: false }).join('\n'); + assert.match(errors, /roleVerification\.reader must be not-applicable/); +}); + +test('rejects a null registry without throwing', () => { + assert.doesNotThrow(() => validateRegistry(null, undefined, { checkMarkdown: false })); + assert.match(validateRegistry(null, undefined, { checkMarkdown: false }).join('\n'), /registry must be object/); +}); + test('rejects implementation evidence paths outside the repository', () => { const { commit, webRoot } = createFixture(); const registry = baseRegistry(commit); - registry.scenarios[0].implementationEvidence[0].path = '../outside-repository.ts'; + registry.scenarios[0].implementationEvidence[0].path = '../../outside-repository.ts'; const errors = validateRegistry(registry, webRoot).join('\n'); assert.match(errors, /implementationEvidence\[0\].path does not exist/); diff --git a/zeppelin-web-angular/scripts/notebook-parity-scenarios.mjs b/zeppelin-web-angular/scripts/notebook-parity-scenarios.mjs index 334079be504..06733fa3b85 100644 --- a/zeppelin-web-angular/scripts/notebook-parity-scenarios.mjs +++ b/zeppelin-web-angular/scripts/notebook-parity-scenarios.mjs @@ -46,26 +46,38 @@ const resolveRepositoryPath = (root, relativePath) => { ); }; -const isPlaywrightTestImport = node => - ts.isImportDeclaration(node) && - ts.isStringLiteral(node.moduleSpecifier) && - node.moduleSpecifier.text === '@playwright/test' && - node.importClause?.namedBindings && - ts.isNamedImports(node.importClause.namedBindings) && - node.importClause.namedBindings.elements.some(element => element.name.text === 'test'); - -const isSkippedDescribeCall = node => +const getPlaywrightTestBindings = sourceFile => { + const bindings = new Set(); + for (const node of sourceFile.statements) { + if ( + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) && + node.moduleSpecifier.text === '@playwright/test' && + node.importClause?.namedBindings && + ts.isNamedImports(node.importClause.namedBindings) + ) { + for (const element of node.importClause.namedBindings.elements) { + if ((element.propertyName ?? element.name).text === 'test') { + bindings.add(element.name.text); + } + } + } + } + return bindings; +}; + +const isSkippedDescribeCall = (node, testBindings) => ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'skip' && ts.isPropertyAccessExpression(node.expression.expression) && node.expression.expression.name.text === 'describe' && ts.isIdentifier(node.expression.expression.expression) && - node.expression.expression.expression.text === 'test'; + testBindings.has(node.expression.expression.expression.text); -const isInsideSkippedDescribe = node => { +const isInsideSkippedDescribe = (node, testBindings) => { for (let current = node.parent; current; current = current.parent) { - if (isSkippedDescribeCall(current)) { + if (isSkippedDescribeCall(current, testBindings)) { return true; } } @@ -96,7 +108,8 @@ const readStaticTags = options => { const getExecutablePlaywrightTestTags = source => { const sourceFile = ts.createSourceFile('spec.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); - if (!sourceFile.statements.some(isPlaywrightTestImport)) { + const testBindings = getPlaywrightTestBindings(sourceFile); + if (testBindings.size === 0) { return new Set(); } @@ -105,8 +118,8 @@ const getExecutablePlaywrightTestTags = source => { if ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && - node.expression.text === 'test' && - !isInsideSkippedDescribe(node) + testBindings.has(node.expression.text) && + !isInsideSkippedDescribe(node, testBindings) ) { for (const tag of readStaticTags(node.arguments[1])) { tags.add(tag); @@ -118,6 +131,26 @@ const getExecutablePlaywrightTestTags = source => { return tags; }; +const formatSchemaError = error => { + const location = error.instancePath ? error.instancePath.slice(1).replaceAll('/', '.') : 'registry'; + switch (error.keyword) { + case 'additionalProperties': + return `${location}.${error.params.additionalProperty} is not allowed`; + case 'minItems': + return `${location} must contain at least ${error.params.limit} item(s)`; + case 'maxItems': + return `${location} must contain at most ${error.params.limit} item(s)`; + case 'pattern': + return `${location} must match ${error.params.pattern}`; + case 'required': + return `${location}.${error.params.missingProperty} is required`; + case 'type': + return `${location} must be ${error.params.type}`; + default: + return `${location} ${error.message}`; + } +}; + const testDeclaresExecutableTag = (root, test) => { const absolutePath = resolveRepositoryPath(root, test.path); if (!absolutePath || !existsSync(absolutePath)) { @@ -208,12 +241,11 @@ export const validateRegistry = (registry, root = webRoot, { checkMarkdown = tru const errors = []; if (!validateSchema(registry)) { - errors.push( - ...validateSchema.errors.map(error => { - const location = error.instancePath ? error.instancePath.slice(1).replaceAll('/', '.') : 'registry'; - return `${location} ${error.message}`; - }) - ); + errors.push(...validateSchema.errors.map(formatSchemaError)); + } + + if (!registry || typeof registry !== 'object' || Array.isArray(registry)) { + return errors; } if (typeof registry.reviewedCommit === 'string' && /^[0-9a-f]{40}$/.test(registry.reviewedCommit)) { @@ -251,6 +283,13 @@ export const validateRegistry = (registry, root = webRoot, { checkMarkdown = tru scenario.roleVerification[role] !== 'not-applicable' ) { errors.push(`${prefix}.roleVerification.${role} must be not-applicable`); + } else if ( + scenario.roleExpectations[role] !== 'not-applicable' && + scenario.roleVerification[role] === 'not-applicable' + ) { + errors.push( + `${prefix}.roleVerification.${role} must not be not-applicable when roleExpectations.${role} is ${scenario.roleExpectations[role]}` + ); } } }