This is an automated email from the ASF dual-hosted git repository.

tbonelee pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new 8328f6a73a [ZEPPELIN-6660] Add notebook parity registry
8328f6a73a is described below

commit 8328f6a73a9c53917ecef74b96b35be3d57bb33c
Author: YONGJAE LEE (이용재) <[email protected]>
AuthorDate: Thu Sep 10 00:55:06 2026 +0900

    [ZEPPELIN-6660] Add notebook parity registry
    
    ### What is this PR for?
    
    This PR records the current Angular Notebook behaviour in a reusable parity 
registry.
    
    The editable source is JSON and the human-readable Markdown table is 
generated from it. Each scenario has a stable `NB-PARITY-###` identifier and 
links either to an existing Playwright E2E test or to the Jira issue that 
tracks the remaining work.
    
    The validator rejects drift between JSON and generated Markdown, duplicate 
or invalid scenario identifiers, unsupported coverage states, a `covered` claim 
without an executable Playwright test carrying the same identifier, incomplete 
scenarios without a Jira issue, and invalid evidence or test paths.
    
    Existing Notebook Playwright test titles now carry the stable scenario 
identifiers, and the frontend CI workflow runs the registry check.
    
    This does not implement missing E2E behaviour or claim that React Notebook 
parity is complete. It establishes the characterization baseline that later 
Angular, Shared Core, and React work can compare against.
    
    The generated registry and validation approach follows Apache Ambari's 
React parity matrix work:
    
    * [React parity 
matrix](https://github.com/apache/ambari/blob/trunk/docs/frontend-refactor/react-current/README.md)
    * [Parity matrix 
validator](https://github.com/apache/ambari/blob/trunk/docs/frontend-refactor/react-current/tools/validate-react-parity-matrix.mjs)
    * [Related implementation PR 
#4176](https://github.com/apache/ambari/pull/4176)
    
    ### What type of PR is it?
    
    Improvement
    
    ### Todos
    
    * [x] Add a JSON Notebook parity registry
    * [x] Generate the reviewable Markdown view from the registry
    * [x] Add generation and validation tests, including failure cases
    * [x] Link existing Notebook Playwright coverage through stable scenario 
identifiers
    * [x] Run the registry validation in frontend CI
    
    ### What is the Jira issue?
    
    ZEPPELIN-6660
    
    ### How should this be tested?
    
    ```bash
    cd zeppelin-web-angular
    npm run check:notebook-parity-scenarios
    ```
    
    The command passes after rebasing on the latest `origin/master`: 15 
validator tests pass and the checked-in registry matches its generated Markdown 
view.
    
    The `quick` GitHub Actions workflow has passed. The `frontend` and `core` 
workflows are still running for the rebased commit.
    
    ### Screenshots (if appropriate)
    
    N/A
    
    ### Questions:
    
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? Yes. `notebook-parity.md` is the generated 
review document for the registry.
    
    
    Closes #5456 from voidmatcha/ZEPPELIN-6660-parity-scenarios.
    
    Signed-off-by: ChanHo Lee <[email protected]>
---
 .github/workflows/frontend.yml                     |   6 +
 zeppelin-web-angular/e2e/AGENTS.md                 |   5 +-
 .../e2e/scenarios/notebook-parity.json             | 546 +++++++++++++++++++++
 .../e2e/scenarios/notebook-parity.md               | 172 +++++++
 .../e2e/scenarios/notebook-parity.schema.json      | 258 ++++++++++
 .../action-bar/action-bar-functionality.spec.ts    |   2 +-
 .../e2e/tests/notebook/inline-completion.spec.ts   |  30 +-
 .../keyboard/notebook-keyboard-shortcuts.spec.ts   |   3 +-
 .../tests/notebook/main/notebook-container.spec.ts |   2 +-
 .../paragraph/paragraph-functionality.spec.ts      |   6 +-
 zeppelin-web-angular/package-lock.json             |  22 +-
 zeppelin-web-angular/package.json                  |   3 +
 .../scripts/check-notebook-parity-scenarios.mjs    |  34 ++
 .../check-notebook-parity-scenarios.test.mjs       | 351 +++++++++++++
 .../scripts/generate-notebook-parity-scenarios.mjs |  25 +
 .../scripts/notebook-parity-scenarios.mjs          | 372 ++++++++++++++
 16 files changed, 1815 insertions(+), 22 deletions(-)

diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml
index 9e59901692..69bc73cf5d 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/AGENTS.md 
b/zeppelin-web-angular/e2e/AGENTS.md
index 9b76c1f387..35a7da5d97 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 `<area>`; reuse or extend 
the matching POM (`models/<name>.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 
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)
 
-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  [...]
+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  [...]
 
 ### Write Framework-Neutral Specs
 
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 0000000000..9ee808f907
--- /dev/null
+++ b/zeppelin-web-angular/e2e/scenarios/notebook-parity.json
@@ -0,0 +1,546 @@
+{
+  "$schema": "./notebook-parity.schema.json",
+  "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": [
+        {
+          "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",
+            "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",
+      "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": [
+        {
+          "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",
+        "writer": "allow",
+        "reader": "deny",
+        "runner": "not-applicable"
+      },
+      "roleVerification": {
+        "owner": "unverified",
+        "writer": "unverified",
+        "reader": "unverified",
+        "runner": "not-applicable"
+      },
+      "coverage": {
+        "status": "covered",
+        "tests": [
+          {
+            "path": 
"zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts",
+            "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",
+      "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": [
+        {
+          "id": "NB-PARITY-003-OUTCOME-001",
+          "description": "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"
+      },
+      "coverage": {
+        "status": "covered",
+        "tests": [
+          {
+            "path": 
"zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts",
+            "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",
+      "name": "Paragraph add buttons are visible",
+      "area": "editor",
+      "preconditions": ["A disposable notebook with a paragraph is open."],
+      "action": "Inspect the paragraph controls.",
+      "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",
+        "writer": "allow",
+        "reader": "deny",
+        "runner": "not-applicable"
+      },
+      "roleVerification": {
+        "owner": "unverified",
+        "writer": "unverified",
+        "reader": "unverified",
+        "runner": "not-applicable"
+      },
+      "coverage": {
+        "status": "covered",
+        "tests": [
+          {
+            "path": 
"zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts",
+            "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",
+      "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": [
+        {
+          "id": "NB-PARITY-005-OUTCOME-001",
+          "description": "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"
+      },
+      "coverage": {
+        "status": "covered",
+        "tests": [
+          {
+            "path": 
"zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts",
+            "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",
+      "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": [
+        {
+          "id": "NB-PARITY-010-OUTCOME-001",
+          "description": "The inline completion suggestion is shown from 
notebook history."
+        },
+        {
+          "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",
+            "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",
+      "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": [
+        {
+          "id": "NB-PARITY-011-OUTCOME-001",
+          "description": "The first Escape keeps Monaco focused."
+        },
+        {
+          "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",
+            "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",
+      "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": [
+        {
+          "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": {
+        "owner": "allow",
+        "writer": "allow",
+        "reader": "deny",
+        "runner": "allow"
+      },
+      "roleVerification": {
+        "owner": "unverified",
+        "writer": "unverified",
+        "reader": "unverified",
+        "runner": "unverified"
+      },
+      "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": "zeppelin-notebook-paragraph-result"
+        },
+        {
+          "path": 
"zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/progress/progress.component.ts",
+          "symbol": "NotebookParagraphProgressComponent"
+        },
+        {
+          "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"
+        }
+      ],
+      "verificationEvidence": []
+    },
+    {
+      "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": [
+        {
+          "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": {
+        "owner": "allow",
+        "writer": "allow",
+        "reader": "deny",
+        "runner": "not-applicable"
+      },
+      "roleVerification": {
+        "owner": "unverified",
+        "writer": "unverified",
+        "reader": "unverified",
+        "runner": "not-applicable"
+      },
+      "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": "NotebookParagraphCodeEditorComponent"
+        }
+      ],
+      "verificationEvidence": [
+        {
+          "path": "zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts",
+          "symbol": "NotebookKeyboardPage"
+        }
+      ]
+    },
+    {
+      "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": [
+        {
+          "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": {
+        "owner": "allow",
+        "writer": "allow",
+        "reader": "deny",
+        "runner": "not-applicable"
+      },
+      "roleVerification": {
+        "owner": "unverified",
+        "writer": "unverified",
+        "reader": "unverified",
+        "runner": "not-applicable"
+      },
+      "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": "NotebookParagraphCodeEditorComponent"
+        },
+        {
+          "path": 
"zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts",
+          "symbol": "NotebookComponent"
+        }
+      ],
+      "verificationEvidence": []
+    },
+    {
+      "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": [
+        {
+          "id": "NB-PARITY-060-OUTCOME-001",
+          "description": "Notebook text remains readable."
+        },
+        {
+          "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 Theme Switching"
+        }
+      ]
+    }
+  ]
+}
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 0000000000..3da42b6e89
--- /dev/null
+++ b/zeppelin-web-angular/e2e/scenarios/notebook-parity.md
@@ -0,0 +1,172 @@
+<!--
+  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.
+-->
+
+# Notebook Parity Scenarios
+
+<!-- Generated by scripts/generate-notebook-parity-scenarios.mjs. Do not edit 
directly. -->
+
+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 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 | not-applicable | 
zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts<br>@NB-PARITY-001
 |  |
+| NB-PARITY-002 | navigation | Notebook title can be displayed and edited | 
covered | owner: allow<br>writer: allow<br>reader: deny<br>runner: 
not-applicable | 
zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts<br>@NB-PARITY-002
 |  |
+| NB-PARITY-003 | editor | Paragraph enters editing mode on double click | 
covered | owner: allow<br>writer: allow<br>reader: deny<br>runner: 
not-applicable | 
zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts<br>@NB-PARITY-003
 |  |
+| NB-PARITY-004 | editor | Paragraph add buttons are visible | covered | 
owner: allow<br>writer: allow<br>reader: deny<br>runner: not-applicable | 
zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts<br>@NB-PARITY-004
 |  |
+| NB-PARITY-005 | shortcut | Shift+Enter executes a markdown paragraph | 
covered | owner: allow<br>writer: allow<br>reader: deny<br>runner: allow | 
zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts<br>@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<br>@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<br>@NB-PARITY-011
 |  |
+| NB-PARITY-021 | result | Text and table result displays preserve output 
semantics after paragraph execution | partial | owner: allow<br>writer: 
allow<br>reader: deny<br>runner: allow | 
zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts<br>@NB-PARITY-021
 | ZEPPELIN-6514, ZEPPELIN-6516 |
+| NB-PARITY-050 | persistence | Notebook editor persists the latest text after 
typing stops | gap | owner: allow<br>writer: allow<br>reader: deny<br>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<br>writer: 
allow<br>reader: deny<br>runner: not-applicable |  | ZEPPELIN-6661 |
+| NB-PARITY-060 | theme | Notebook honors host theme selection | gap | 
not-applicable |  | ZEPPELIN-6640 |
+
+## Scenario Details
+
+### NB-PARITY-001 Notebook container structure is visible
+
+- Area: navigation
+- Coverage: covered
+- Interpreter: not-applicable
+- Role verification: not-applicable
+- Preconditions: A disposable notebook route is open.
+- Action: Render the notebook route.
+- 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
+
+- 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: 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
+
+- 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: 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
+
+- 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: 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
+
+- 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: 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: 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: 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: 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: 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
+
+- 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: 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 visualizati [...]
+- 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 [...]
+- 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 acces [...]
+
+### 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: 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
 (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
+
+- 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: 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
 (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
+
+- Area: theme
+- Coverage: gap
+- Interpreter: 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: 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 Theme 
Switching)
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 0000000000..6d8155119d
--- /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 0ef046d3c1..8ae9749bc0 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('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 24f7024573..5273e8dfee 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('shows history completion and preserves focus when dismissed', { tag: 
'@NB-PARITY-010' }, async ({ page }) => {
     const { noteId, inputArea } = await openInlineCompletionEditor(page);
 
     try {
@@ -63,18 +63,22 @@ test.describe('Inline completion', () => {
     }
   });
 
-  test('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 a6c87814b1..a7ae17a6fe 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('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.');
@@ -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 a8656cc8f3..406e73e846 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('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 105c422620..8a937dc1a1 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('should support double-click editing functionality', { tag: 
'@NB-PARITY-003' }, async () => {
     await expect(paragraphPage.paragraphContainer).toBeVisible();
     await paragraphPage.doubleClickToEdit();
     await expect(paragraphPage.codeEditor).toBeVisible();
   });
 
-  test('should 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('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 68ed893fab..2fcf37e256 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.20.0",
         "angular-eslint": "21.4.0",
         "concurrently": "9.2.1",
         "cross-env": "^10.1.0",
@@ -1342,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,
@@ -8213,7 +8231,9 @@
       }
     },
     "node_modules/ajv": {
-      "version": "8.18.0",
+      "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 f40fadc5e8..2e9a509bb2 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}\"",
@@ -91,6 +93,7 @@
     "@types/webpack-env": "^1.18.8",
     "@vitest/coverage-v8": "4.1.8",
     "@vitest/eslint-plugin": "^1.6.27",
+    "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.mjs 
b/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.mjs
new file mode 100644
index 0000000000..cd650bebb9
--- /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 0000000000..fd66c915e6
--- /dev/null
+++ b/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.test.mjs
@@ -0,0 +1,351 @@
+/*
+ * 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('should render', { tag: 
'@NB-PARITY-001' }, async () => {});"
+  );
+  spawnSync('git', ['init'], { cwd: root, stdio: 'ignore' });
+  spawnSync('git', ['config', 'user.email', '[email protected]'], { 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 {
+    $schema: './notebook-parity.schema.json',
+    reviewedCommit: commit,
+    scenarios: [
+      {
+        id: 'NB-PARITY-001',
+        name: 'Container renders',
+        area: 'navigation',
+        preconditions: ['note exists'],
+        action: 'open the route',
+        observableOutcomes: [{ id: 'NB-PARITY-001-OUTCOME-001', description: 
'container is visible' }],
+        interpreter: null,
+        roleExpectations: {
+          owner: 'allow',
+          writer: 'allow',
+          reader: 'allow',
+          runner: 'allow'
+        },
+        roleVerification: {
+          owner: 'unverified',
+          writer: 'unverified',
+          reader: 'unverified',
+          runner: 'unverified'
+        },
+        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',
+              tag: '@NB-PARITY-001'
+            }
+          ],
+          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(
+      'should render',
+      { tag: '@NB-PARITY-001' },
+      async () => {}
+    );`
+  );
+  fs.writeFileSync(path.join(webRoot, 'e2e/scenarios/notebook-parity.md'), 
renderMarkdown(registry));
+
+  assert.deepEqual(validateRegistry(registry, webRoot), []);
+});
+
+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, /notebook-parity\.md is stale/);
+});
+
+test('rejects false covered claims without matching Playwright tag', () => {
+  const { commit, root, webRoot } = createFixture();
+  const registry = baseRegistry(commit);
+  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, /tag must be @NB-PARITY-001/);
+});
+
+test('rejects commented-only, helper-string, and skipped Playwright coverage 
claims', () => {
+  const cases = [
+    {
+      name: 'commented-only',
+      source: "// test('should render', { tag: '@NB-PARITY-001' }, async () => 
{});"
+    },
+    {
+      name: 'helper-string',
+      source: "const parityTag = '@NB-PARITY-001';\ntest('should render', { 
tag: parityTag }, async () => {});"
+    },
+    {
+      name: 'test.skip',
+      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('should 
render', { tag: '@NB-PARITY-001' }, async () => {}); });"
+    },
+    {
+      name: 'non-Playwright test helper',
+      source: "const test = () => undefined;\ntest('should render', { tag: 
'@NB-PARITY-001' }, 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, /tag 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, /coverage\.issues must contain at least 1 item/);
+});
+
+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, /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 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', () 
=> {
+  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-\[0-9\]\+\$/);
+  assert.match(errors, /roleExpectations\.admin is not allowed/);
+});
+
+test('rejects malformed coverage arrays without applying unsafe 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 array/);
+  assert.match(errors, /coverage\.issues must be array/);
+  assert.match(errors, /coverage\.uncoveredOutcomes must be array/);
+});
+
+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 object/);
+
+  registry.scenarios[0].coverage.tests = [
+    {
+      path: 
'zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts',
+      tag: '@NB-PARITY-001',
+      unexpected: true
+    }
+  ];
+  const metadataErrors = validateRegistry(registry, webRoot).join('\n');
+  assert.match(metadataErrors, /coverage\.tests\.0\.unexpected is not 
allowed/);
+});
+
+test('allows verified role evidence and optional role metadata', () => {
+  const { commit, webRoot } = createFixture();
+  const registry = baseRegistry(commit);
+  registry.scenarios[0].roleVerification.reader = 'verified';
+  assert.deepEqual(validateRegistry(registry, webRoot, { checkMarkdown: false 
}), []);
+
+  delete registry.scenarios[0].roleExpectations;
+  delete registry.scenarios[0].roleVerification;
+  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';
+
+  const errors = validateRegistry(registry, webRoot).join('\n');
+  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', () => {
+  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, /^<!--\n  Licensed under the Apache License, Version 
2\.0/);
+});
+
+test('renders baseline commit and coverage evidence caveats', () => {
+  const { commit } = createFixture();
+  const markdown = renderMarkdown(baseRegistry(commit));
+
+  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 tag/
+  );
+  assert.match(markdown, /Semantic adequacy and runtime pass\/fail remain 
review and CI evidence/);
+});
diff --git 
a/zeppelin-web-angular/scripts/generate-notebook-parity-scenarios.mjs 
b/zeppelin-web-angular/scripts/generate-notebook-parity-scenarios.mjs
new file mode 100644
index 0000000000..9f65976b3c
--- /dev/null
+++ b/zeppelin-web-angular/scripts/generate-notebook-parity-scenarios.mjs
@@ -0,0 +1,25 @@
+/*
+ * 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 { writeFileSync } 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 });
+
+if (errors.length > 0) {
+  console.error(errors.join('\n'));
+  process.exit(1);
+}
+
+writeFileSync(path.join(webRoot, markdownPath), renderMarkdown(registry));
diff --git a/zeppelin-web-angular/scripts/notebook-parity-scenarios.mjs 
b/zeppelin-web-angular/scripts/notebook-parity-scenarios.mjs
new file mode 100644
index 0000000000..06733fa3b8
--- /dev/null
+++ b/zeppelin-web-angular/scripts/notebook-parity-scenarios.mjs
@@ -0,0 +1,372 @@
+#!/usr/bin/env node
+/*
+ * 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 { execFileSync } from 'node:child_process';
+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 roles = ['owner', 'writer', 'reader', 'runner'];
+
+const escapeTableCell = value => String(value).replace(/\|/g, 
'\\|').replace(/\n/g, '<br>');
+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)) {
+    return null;
+  }
+
+  const repositoryRoot = path.resolve(root, '..');
+  const isWithinRepository = candidate => 
candidate.startsWith(`${repositoryRoot}${path.sep}`);
+  const candidates = [path.resolve(root, relativePath), 
path.resolve(repositoryRoot, relativePath)];
+  return (
+    candidates.find(candidate => isWithinRepository(candidate) && 
existsSync(candidate)) ??
+    candidates.find(isWithinRepository) ??
+    null
+  );
+};
+
+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) &&
+  testBindings.has(node.expression.expression.expression.text);
+
+const isInsideSkippedDescribe = (node, testBindings) => {
+  for (let current = node.parent; current; current = current.parent) {
+    if (isSkippedDescribeCall(current, testBindings)) {
+      return true;
+    }
+  }
+  return false;
+};
+
+const readStaticTags = options => {
+  if (!options || !ts.isObjectLiteralExpression(options)) {
+    return [];
+  }
+  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 [];
+  }
+  if (ts.isStringLiteralLike(tagProperty.initializer)) {
+    return [tagProperty.initializer.text];
+  }
+  if (ts.isArrayLiteralExpression(tagProperty.initializer)) {
+    return 
tagProperty.initializer.elements.filter(ts.isStringLiteralLike).map(element => 
element.text);
+  }
+  return [];
+};
+
+const getExecutablePlaywrightTestTags = source => {
+  const sourceFile = ts.createSourceFile('spec.ts', source, 
ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
+  const testBindings = getPlaywrightTestBindings(sourceFile);
+  if (testBindings.size === 0) {
+    return new Set();
+  }
+
+  const tags = new Set();
+  const visit = node => {
+    if (
+      ts.isCallExpression(node) &&
+      ts.isIdentifier(node.expression) &&
+      testBindings.has(node.expression.text) &&
+      !isInsideSkippedDescribe(node, testBindings)
+    ) {
+      for (const tag of readStaticTags(node.arguments[1])) {
+        tags.add(tag);
+      }
+    }
+    ts.forEachChild(node, visit);
+  };
+  visit(sourceFile);
+  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)) {
+    return false;
+  }
+  return getExecutablePlaywrightTestTags(readFileSync(absolutePath, 
'utf8')).has(test.tag);
+};
+
+export const renderMarkdown = registry => {
+  const lines = [
+    '<!--',
+    '  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.',
+    '-->',
+    '',
+    '# Notebook Parity Scenarios',
+    '',
+    '<!-- Generated by scripts/generate-notebook-parity-scenarios.mjs. Do not 
edit directly. -->',
+    '',
+    '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 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 = scenario.roleExpectations
+      ? roles.map(role => `${role}: 
${scenario.roleExpectations[role]}`).join('<br>')
+      : 'not-applicable';
+    const testsText =
+      scenario.coverage.tests.length === 0
+        ? ''
+        : scenario.coverage.tests.map(test => 
`${test.path}<br>${test.tag}`).join('<br><br>');
+    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: ${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.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(
+        `- Uncovered outcomes: ${scenario.coverage.uncoveredOutcomes.map(id => 
`${id}: ${outcomesById.get(id)}`).join(' ')}`
+      );
+    }
+    lines.push('');
+  }
+
+  return `${lines.join('\n').replace(/\n+$/, '')}\n`;
+};
+
+export const validateRegistry = (registry, root = webRoot, { checkMarkdown = 
true } = {}) => {
+  const errors = [];
+
+  if (!validateSchema(registry)) {
+    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)) {
+    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) {
+    return errors;
+  }
+
+  const ids = new Set();
+  let previousId = '';
+  for (const [index, scenario] of registry.scenarios.entries()) {
+    const prefix = `scenarios[${index}]`;
+    if (!scenario || typeof scenario !== 'object') {
+      continue;
+    }
+    if (typeof scenario.id === 'string' && ids.has(scenario.id)) {
+      errors.push(`${prefix}.id is duplicated: ${scenario.id}`);
+    } else if (typeof scenario.id === 'string') {
+      if (previousId && scenario.id <= previousId) {
+        errors.push(`${prefix}.id must sort after ${previousId}`);
+      }
+      previousId = scenario.id;
+      ids.add(scenario.id);
+    }
+
+    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`);
+        } 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]}`
+          );
+        }
+      }
+    }
+
+    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 outcomeIds = new Set();
+    if (Array.isArray(scenario.observableOutcomes)) {
+      for (const [outcomeIndex, outcome] of 
scenario.observableOutcomes.entries()) {
+        if (!outcome || typeof outcome.id !== 'string') {
+          continue;
+        }
+        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 (!coverage || typeof coverage !== 'object') {
+      continue;
+    }
+    const uncoveredOutcomes = Array.isArray(coverage.uncoveredOutcomes) ? 
coverage.uncoveredOutcomes : [];
+    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') {
+        continue;
+      }
+      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 (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}].tag is not declared by an 
executable test() in ${test.path}`
+        );
+      }
+    }
+  }
+
+  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));

Reply via email to