This is an automated email from the ASF dual-hosted git repository. voidmatcha pushed a commit to branch ZEPPELIN-6660-parity-scenarios in repository https://gitbox.apache.org/repos/asf/zeppelin.git
commit d80c566b47c7df2c746d1b8dc8007b49806ac09a Author: YONGJAE LEE <[email protected]> AuthorDate: Thu Sep 3 01:00:28 2026 +0900 [ZEPPELIN-6660] Add notebook parity registry --- .github/workflows/frontend.yml | 6 + .../e2e/scenarios/notebook-parity.json | 516 +++++++++++++++++++++ .../e2e/scenarios/notebook-parity.md | 169 +++++++ .../action-bar/action-bar-functionality.spec.ts | 2 +- .../e2e/tests/notebook/inline-completion.spec.ts | 7 +- .../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.json | 2 + .../scripts/check-notebook-parity-scenarios.mjs | 34 ++ .../check-notebook-parity-scenarios.test.mjs | 294 ++++++++++++ .../scripts/generate-notebook-parity-scenarios.mjs | 25 + .../scripts/notebook-parity-scenarios.mjs | 477 +++++++++++++++++++ 13 files changed, 1535 insertions(+), 8 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/scenarios/notebook-parity.json b/zeppelin-web-angular/e2e/scenarios/notebook-parity.json new file mode 100644 index 0000000000..681344e395 --- /dev/null +++ b/zeppelin-web-angular/e2e/scenarios/notebook-parity.json @@ -0,0 +1,516 @@ +{ + "schemaVersion": 1, + "reviewedCommit": "d5b57b12fd0c5e1d885767aabe06b242debf8300", + "scenarios": [ + { + "id": "NB-PARITY-001", + "name": "Notebook container structure is visible", + "area": "navigation", + "preconditions": ["A disposable notebook route is open."], + "action": "Render the notebook route.", + "observableOutcomes": ["The notebook container is visible with the expected container class."], + "interpreter": null, + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "allow", + "runner": "not-applicable" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "unverified", + "runner": "not-applicable" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts", + "symbol": "Notebook Container Component" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-page.ts", + "symbol": "NotebookPage" + } + ], + "coverage": { + "status": "covered", + "tests": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts", + "title": "[NB-PARITY-001] should display notebook container with proper structure", + "projects": ["chromium", "firefox", "webkit"] + } + ], + "issues": [], + "uncoveredOutcomes": [] + } + }, + { + "id": "NB-PARITY-002", + "name": "Notebook title can be displayed and edited", + "area": "navigation", + "preconditions": ["A disposable notebook route is open."], + "action": "Open the title editor and rename the notebook.", + "observableOutcomes": ["The title editor is visible and the changed title is reflected in the notebook header."], + "interpreter": null, + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "deny", + "runner": "not-applicable" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "unverified", + "runner": "not-applicable" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts", + "symbol": "Notebook Action Bar Functionality" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-action-bar-page.ts", + "symbol": "NotebookActionBarPage" + } + ], + "coverage": { + "status": "covered", + "tests": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts", + "title": "[NB-PARITY-002] should display and allow title editing with tooltip", + "projects": ["chromium", "firefox", "webkit"] + } + ], + "issues": [], + "uncoveredOutcomes": [] + } + }, + { + "id": "NB-PARITY-003", + "name": "Paragraph enters editing mode on double click", + "area": "editor", + "preconditions": ["A disposable notebook with at least one paragraph is open."], + "action": "Double-click the paragraph.", + "observableOutcomes": ["The code editor becomes visible for the paragraph."], + "interpreter": null, + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "deny", + "runner": "not-applicable" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "unverified", + "runner": "not-applicable" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts", + "symbol": "Notebook Paragraph Functionality" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts", + "symbol": "NotebookParagraphPage" + } + ], + "coverage": { + "status": "covered", + "tests": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts", + "title": "[NB-PARITY-003] should support double-click editing functionality", + "projects": ["chromium", "firefox", "webkit"] + } + ], + "issues": [], + "uncoveredOutcomes": [] + } + }, + { + "id": "NB-PARITY-004", + "name": "Paragraph add buttons are visible", + "area": "editor", + "preconditions": ["A disposable notebook with a paragraph is open."], + "action": "Inspect the paragraph controls.", + "observableOutcomes": ["Add-paragraph controls are visible where a user can add another paragraph."], + "interpreter": null, + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "deny", + "runner": "not-applicable" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "unverified", + "runner": "not-applicable" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts", + "symbol": "Notebook Paragraph Functionality" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts", + "symbol": "NotebookParagraphPage" + } + ], + "coverage": { + "status": "covered", + "tests": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts", + "title": "[NB-PARITY-004] should display add paragraph buttons", + "projects": ["chromium", "firefox", "webkit"] + } + ], + "issues": [], + "uncoveredOutcomes": [] + } + }, + { + "id": "NB-PARITY-005", + "name": "Shift+Enter executes a markdown paragraph", + "area": "shortcut", + "preconditions": ["A disposable notebook paragraph is focused in the code editor."], + "action": "Type Markdown content and press Shift+Enter.", + "observableOutcomes": ["The paragraph executes and renders the Markdown heading result."], + "interpreter": "md", + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "deny", + "runner": "allow" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "unverified", + "runner": "unverified" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts", + "symbol": "ParagraphActions.Run" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts", + "symbol": "NotebookKeyboardPage" + } + ], + "coverage": { + "status": "covered", + "tests": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts", + "title": "[NB-PARITY-005] should execute markdown paragraph with Shift+Enter", + "projects": ["chromium", "firefox", "webkit"] + } + ], + "issues": [], + "uncoveredOutcomes": [] + } + }, + { + "id": "NB-PARITY-010", + "name": "History inline completion can be dismissed without losing editor focus", + "area": "editor", + "preconditions": [ + "A notebook contains a Python paragraph with prior text that can seed inline completion.", + "The notebook route is opened with aiInlineComplete enabled." + ], + "action": "Type a completion prefix in Monaco and press Escape while the completion is visible.", + "observableOutcomes": [ + "The inline completion suggestion is shown from notebook history.", + "The Monaco input remains focused after the first Escape dismisses the suggestion." + ], + "interpreter": "python", + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "not-applicable", + "runner": "not-applicable" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "not-applicable", + "runner": "not-applicable" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts", + "symbol": "Inline completion" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts", + "symbol": "NotebookKeyboardPage" + } + ], + "coverage": { + "status": "covered", + "tests": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts", + "title": "[NB-PARITY-010] shows history completion and preserves focus when dismissed", + "projects": ["chromium", "firefox", "webkit"] + } + ], + "issues": [], + "uncoveredOutcomes": [] + } + }, + { + "id": "NB-PARITY-011", + "name": "The second Escape after inline completion dismissal blurs the editor", + "area": "editor", + "preconditions": [ + "A notebook contains a Python paragraph with prior text that can seed inline completion.", + "The notebook route is opened with aiInlineComplete enabled.", + "The browser is Chromium." + ], + "action": "Press Escape once to dismiss the completion and press Escape again.", + "observableOutcomes": [ + "The first Escape keeps Monaco focused.", + "The second Escape blurs the Monaco input in Chromium." + ], + "interpreter": "python", + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "not-applicable", + "runner": "not-applicable" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "not-applicable", + "runner": "not-applicable" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts", + "symbol": "Inline completion" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts", + "symbol": "NotebookKeyboardPage" + } + ], + "coverage": { + "status": "covered", + "tests": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts", + "title": "[NB-PARITY-011] blurs the editor on the second Escape after dismissing completion", + "projects": ["chromium"] + } + ], + "issues": [], + "uncoveredOutcomes": [] + } + }, + { + "id": "NB-PARITY-021", + "name": "Text and table result displays preserve output semantics after paragraph execution", + "area": "result", + "preconditions": [ + "A notebook has a Python paragraph that prints text.", + "A notebook has a paragraph that returns tabular output." + ], + "action": "Run the paragraph from the paragraph control and inspect the rendered result panel.", + "observableOutcomes": [ + "The result display becomes visible and is not empty.", + "The UI offers every display mode that the Angular notebook exposes for the returned result type.", + "Visualization control changes preserve the field mapping from result columns to configured dimensions or measures.", + "The paragraph's persisted config reflects the resulting configuration object after a visualization option changes.", + "Text and table results expose accessible table output row by row so migrated React rendering can be compared without relying on screenshots alone." + ], + "interpreter": "python", + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "deny", + "runner": "allow" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "unverified", + "runner": "unverified" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.html", + "symbol": "paragraph result display" + }, + { + "path": "zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/progress/progress.component.ts", + "symbol": "ProgressComponent" + }, + { + "path": "zeppelin-web-angular/projects/zeppelin-visualization/src/table-transformation.ts", + "symbol": "TableTransformation" + }, + { + "path": "zeppelin-web-angular/projects/zeppelin-visualization/src/pivot-transformation.ts", + "symbol": "PivotTransformation" + }, + { + "path": "zeppelin-web-angular/projects/zeppelin-visualization/src/visualization.ts", + "symbol": "Visualization" + } + ], + "coverage": { + "status": "partial", + "tests": [ + { + "path": "zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts", + "title": "[NB-PARITY-021] should display result system properly", + "projects": ["chromium", "firefox", "webkit"] + } + ], + "issues": ["ZEPPELIN-6514", "ZEPPELIN-6516"], + "uncoveredOutcomes": [ + "The UI offers every display mode that the Angular notebook exposes for the returned result type.", + "Visualization control changes preserve the field mapping from result columns to configured dimensions or measures.", + "The paragraph's persisted config reflects the resulting configuration object after a visualization option changes.", + "Text and table results expose accessible table output row by row so migrated React rendering can be compared without relying on screenshots alone." + ] + } + }, + { + "id": "NB-PARITY-050", + "name": "Notebook editor persists the latest text after typing stops", + "area": "persistence", + "preconditions": [ + "A disposable notebook with one editable paragraph is open.", + "The user can edit the paragraph." + ], + "action": "Replace the paragraph text and stop typing long enough for the notebook save path to acknowledge the edit.", + "observableOutcomes": [ + "The persisted paragraph text equals the latest typed text.", + "The save assertion is based on observable persistence or wire evidence, not an internal timer." + ], + "interpreter": null, + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "deny", + "runner": "not-applicable" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "unverified", + "runner": "not-applicable" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts", + "symbol": "CodeEditorComponent" + }, + { + "path": "zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts", + "symbol": "NotebookKeyboardPage" + } + ], + "coverage": { + "status": "gap", + "tests": [], + "issues": ["ZEPPELIN-6661"], + "uncoveredOutcomes": [] + } + }, + { + "id": "NB-PARITY-051", + "name": "Notebook editor does not lose an edit made while a prior save is in flight", + "area": "persistence", + "preconditions": [ + "A disposable notebook with one editable paragraph is open.", + "The first paragraph save request can be observed before it completes." + ], + "action": "Edit the paragraph, keep the first save in flight, then make a second edit.", + "observableOutcomes": [ + "The first in-flight save does not overwrite or drop the second edit.", + "A later observable save or reconciliation persists the second edit." + ], + "interpreter": null, + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "deny", + "runner": "not-applicable" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "unverified", + "runner": "not-applicable" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts", + "symbol": "CodeEditorComponent" + }, + { + "path": "zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts", + "symbol": "NotebookComponent" + } + ], + "coverage": { + "status": "gap", + "tests": [], + "issues": ["ZEPPELIN-6661"], + "uncoveredOutcomes": [] + } + }, + { + "id": "NB-PARITY-060", + "name": "Notebook honors host theme selection", + "area": "theme", + "preconditions": ["The user can choose light, dark, or system theme from the host shell."], + "action": "Change the host theme while a notebook surface is mounted.", + "observableOutcomes": [ + "Notebook text remains readable.", + "Result and chart output inherit the host theme tokens.", + "The selected theme persists after reload." + ], + "interpreter": null, + "roleExpectations": { + "owner": "allow", + "writer": "allow", + "reader": "allow", + "runner": "not-applicable" + }, + "roleVerification": { + "owner": "unverified", + "writer": "unverified", + "reader": "unverified", + "runner": "not-applicable" + }, + "evidence": [ + { + "path": "zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.tsx", + "symbol": "ZeppelinThemeProvider" + }, + { + "path": "zeppelin-web-angular/e2e/tests/theme/dark-mode.spec.ts", + "symbol": "Dark Mode" + } + ], + "coverage": { + "status": "gap", + "tests": [], + "issues": ["ZEPPELIN-6640"], + "uncoveredOutcomes": [] + } + } + ] +} diff --git a/zeppelin-web-angular/e2e/scenarios/notebook-parity.md b/zeppelin-web-angular/e2e/scenarios/notebook-parity.md new file mode 100644 index 0000000000..818a58cc3c --- /dev/null +++ b/zeppelin-web-angular/e2e/scenarios/notebook-parity.md @@ -0,0 +1,169 @@ +<!-- + 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 version: 1 + +Scenario/Angular baseline commit: `d5b57b12fd0c5e1d885767aabe06b242debf8300` + +Scope note: This is a prioritized baseline, not a complete Notebook inventory. Before a React vertical slice is declared ready, add every affected behavior to this registry and classify its evidence. + +Coverage note: `covered` mechanically means this registry points to a matching executable Playwright test declaration. Semantic adequacy and runtime pass/fail remain review and CI evidence. Role expectations and role verification are deliberately separate. + +| ID | Area | Scenario | Coverage | Roles | Tests | Issues | +| --- | --- | --- | --- | --- | --- | --- | +| NB-PARITY-001 | navigation | Notebook container structure is visible | covered | owner: allow<br>writer: allow<br>reader: allow<br>runner: not-applicable | zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts<br>[NB-PARITY-001] should display notebook container with proper structure | | +| 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] should display and allow title editing with tooltip | | +| 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] should support double-click editing functionality | | +| 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] should display add paragraph buttons | | +| 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] should execute markdown paragraph with Shift+Enter | | +| NB-PARITY-010 | editor | History inline completion can be dismissed without losing editor focus | covered | owner: allow<br>writer: allow<br>reader: not-applicable<br>runner: not-applicable | zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts<br>[NB-PARITY-010] shows history completion and preserves focus when dismissed | | +| NB-PARITY-011 | editor | The second Escape after inline completion dismissal blurs the editor | covered | owner: allow<br>writer: allow<br>reader: not-applicable<br>runner: not-applicable | zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts<br>[NB-PARITY-011] blurs the editor on the second Escape after dismissing completion | | +| NB-PARITY-021 | result | Text and table result displays preserve output semantics after paragraph execution | partial | owner: allow<br>writer: allow<br>reader: deny<br>runner: allow | zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts<br>[NB-PARITY-021] should display result system properly | ZEPPELIN-6514, ZEPPELIN-6516 | +| NB-PARITY-050 | persistence | Notebook editor persists the latest text after typing stops | gap | owner: allow<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 | owner: allow<br>writer: allow<br>reader: allow<br>runner: not-applicable | | ZEPPELIN-6640 | + +## Scenario Details + +### NB-PARITY-001 Notebook container structure is visible + +- Area: navigation +- Coverage: covered +- Interpreter: not-applicable +- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable +- Preconditions: A disposable notebook route is open. +- Action: Render the notebook route. +- Observable outcomes: The notebook container is visible with the expected container class. +- Evidence: zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts (Notebook Container Component); zeppelin-web-angular/e2e/models/notebook-page.ts (NotebookPage) +- Browser projects: [NB-PARITY-001] should display notebook container with proper structure: chromium, firefox, webkit + +### NB-PARITY-002 Notebook title can be displayed and edited + +- Area: navigation +- Coverage: covered +- Interpreter: not-applicable +- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable +- Preconditions: A disposable notebook route is open. +- Action: Open the title editor and rename the notebook. +- Observable outcomes: The title editor is visible and the changed title is reflected in the notebook header. +- Evidence: zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts (Notebook Action Bar Functionality); zeppelin-web-angular/e2e/models/notebook-action-bar-page.ts (NotebookActionBarPage) +- Browser projects: [NB-PARITY-002] should display and allow title editing with tooltip: chromium, firefox, webkit + +### NB-PARITY-003 Paragraph enters editing mode on double click + +- Area: editor +- Coverage: covered +- Interpreter: not-applicable +- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable +- Preconditions: A disposable notebook with at least one paragraph is open. +- Action: Double-click the paragraph. +- Observable outcomes: The code editor becomes visible for the paragraph. +- Evidence: zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts (Notebook Paragraph Functionality); zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts (NotebookParagraphPage) +- Browser projects: [NB-PARITY-003] should support double-click editing functionality: chromium, firefox, webkit + +### NB-PARITY-004 Paragraph add buttons are visible + +- Area: editor +- Coverage: covered +- Interpreter: not-applicable +- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable +- Preconditions: A disposable notebook with a paragraph is open. +- Action: Inspect the paragraph controls. +- Observable outcomes: Add-paragraph controls are visible where a user can add another paragraph. +- Evidence: zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts (Notebook Paragraph Functionality); zeppelin-web-angular/e2e/models/notebook-paragraph-page.ts (NotebookParagraphPage) +- Browser projects: [NB-PARITY-004] should display add paragraph buttons: chromium, firefox, webkit + +### NB-PARITY-005 Shift+Enter executes a markdown paragraph + +- Area: shortcut +- Coverage: covered +- Interpreter: md +- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: unverified +- Preconditions: A disposable notebook paragraph is focused in the code editor. +- Action: Type Markdown content and press Shift+Enter. +- Observable outcomes: The paragraph executes and renders the Markdown heading result. +- Evidence: zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts (ParagraphActions.Run); zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts (NotebookKeyboardPage) +- Browser projects: [NB-PARITY-005] should execute markdown paragraph with Shift+Enter: chromium, firefox, webkit + +### NB-PARITY-010 History inline completion can be dismissed without losing editor focus + +- Area: editor +- Coverage: covered +- Interpreter: python +- Role verification: owner: unverified; writer: unverified; reader: not-applicable; runner: not-applicable +- Preconditions: A notebook contains a Python paragraph with prior text that can seed inline completion. The notebook route is opened with aiInlineComplete enabled. +- Action: Type a completion prefix in Monaco and press Escape while the completion is visible. +- Observable outcomes: The inline completion suggestion is shown from notebook history. The Monaco input remains focused after the first Escape dismisses the suggestion. +- Evidence: zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts (Inline completion); zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts (NotebookKeyboardPage) +- Browser projects: [NB-PARITY-010] shows history completion and preserves focus when dismissed: chromium, firefox, webkit + +### NB-PARITY-011 The second Escape after inline completion dismissal blurs the editor + +- Area: editor +- Coverage: covered +- Interpreter: python +- Role verification: owner: unverified; writer: unverified; reader: not-applicable; runner: not-applicable +- Preconditions: A notebook contains a Python paragraph with prior text that can seed inline completion. The notebook route is opened with aiInlineComplete enabled. The browser is Chromium. +- Action: Press Escape once to dismiss the completion and press Escape again. +- Observable outcomes: The first Escape keeps Monaco focused. The second Escape blurs the Monaco input in Chromium. +- Evidence: zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts (Inline completion); zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts (NotebookKeyboardPage) +- Browser projects: [NB-PARITY-011] blurs the editor on the second Escape after dismissing completion: chromium + +### NB-PARITY-021 Text and table result displays preserve output semantics after paragraph execution + +- Area: result +- Coverage: partial +- Interpreter: python +- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: unverified +- Preconditions: A notebook has a Python paragraph that prints text. A notebook has a paragraph that returns tabular output. +- Action: Run the paragraph from the paragraph control and inspect the rendered result panel. +- Observable outcomes: The result display becomes visible and is not empty. The UI offers every display mode that the Angular notebook exposes for the returned result type. Visualization control changes preserve the field mapping from result columns to configured dimensions or measures. The paragraph's persisted config reflects the resulting configuration object after a visualization option changes. Text and table results expose accessible table output row by row so migrated React render [...] +- Evidence: zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.html (paragraph result display); zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/progress/progress.component.ts (ProgressComponent); zeppelin-web-angular/projects/zeppelin-visualization/src/table-transformation.ts (TableTransformation); zeppelin-web-angular/projects/zeppelin-visualization/src/pivot-transformation.ts (PivotTransformation); zeppelin-web-angular/projects/zeppelin- [...] +- Browser projects: [NB-PARITY-021] should display result system properly: chromium, firefox, webkit +- Uncovered outcomes: The UI offers every display mode that the Angular notebook exposes for the returned result type. Visualization control changes preserve the field mapping from result columns to configured dimensions or measures. The paragraph's persisted config reflects the resulting configuration object after a visualization option changes. Text and table results expose accessible table output row by row so migrated React rendering can be compared without relying on screenshots alone. + +### NB-PARITY-050 Notebook editor persists the latest text after typing stops + +- Area: persistence +- Coverage: gap +- Interpreter: not-applicable +- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable +- Preconditions: A disposable notebook with one editable paragraph is open. The user can edit the paragraph. +- Action: Replace the paragraph text and stop typing long enough for the notebook save path to acknowledge the edit. +- Observable outcomes: The persisted paragraph text equals the latest typed text. The save assertion is based on observable persistence or wire evidence, not an internal timer. +- Evidence: zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts (CodeEditorComponent); zeppelin-web-angular/e2e/models/notebook-keyboard-page.ts (NotebookKeyboardPage) + +### NB-PARITY-051 Notebook editor does not lose an edit made while a prior save is in flight + +- Area: persistence +- Coverage: gap +- Interpreter: not-applicable +- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable +- Preconditions: A disposable notebook with one editable paragraph is open. The first paragraph save request can be observed before it completes. +- Action: Edit the paragraph, keep the first save in flight, then make a second edit. +- Observable outcomes: The first in-flight save does not overwrite or drop the second edit. A later observable save or reconciliation persists the second edit. +- Evidence: zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component.ts (CodeEditorComponent); zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts (NotebookComponent) + +### NB-PARITY-060 Notebook honors host theme selection + +- Area: theme +- Coverage: gap +- Interpreter: not-applicable +- Role verification: owner: unverified; writer: unverified; reader: unverified; runner: not-applicable +- Preconditions: The user can choose light, dark, or system theme from the host shell. +- Action: Change the host theme while a notebook surface is mounted. +- Observable outcomes: Notebook text remains readable. Result and chart output inherit the host theme tokens. The selected theme persists after reload. +- Evidence: zeppelin-web-angular/projects/zeppelin-react/src/theme/ZeppelinThemeProvider.tsx (ZeppelinThemeProvider); zeppelin-web-angular/e2e/tests/theme/dark-mode.spec.ts (Dark Mode) diff --git a/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts index 0ef046d3c1..acd66e6575 100644 --- a/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts +++ b/zeppelin-web-angular/e2e/tests/notebook/action-bar/action-bar-functionality.spec.ts @@ -39,7 +39,7 @@ test.describe('Notebook Action Bar Functionality', () => { await navigateToNotebookWithFallback(page, testNotebook.noteId); }); - test('should display and allow title editing with tooltip', async ({ page }) => { + test('[NB-PARITY-002] should display and allow title editing with tooltip', async ({ page }) => { const notebookName = `TestNotebook_${Date.now()}`; await expect(actionBarPage.titleEditor).toBeVisible(); diff --git a/zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts index 24f7024573..30ebeb16fd 100644 --- a/zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts +++ b/zeppelin-web-angular/e2e/tests/notebook/inline-completion.spec.ts @@ -51,7 +51,7 @@ const openInlineCompletionEditor = async (page: Page) => { test.describe('Inline completion', () => { addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH_CODE_EDITOR); - test('shows history completion and preserves focus when dismissed', async ({ page }) => { + test('[NB-PARITY-010] shows history completion and preserves focus when dismissed', async ({ page }) => { const { noteId, inputArea } = await openInlineCompletionEditor(page); try { @@ -63,7 +63,10 @@ test.describe('Inline completion', () => { } }); - test('blurs the editor on the second Escape after dismissing completion', async ({ page, browserName }) => { + test('[NB-PARITY-011] blurs the editor on the second Escape after dismissing completion', async ({ + page, + browserName + }) => { test.skip(browserName !== 'chromium', 'Monaco handles the second Escape differently in Firefox and WebKit'); const { noteId, inputArea } = await openInlineCompletionEditor(page); diff --git a/zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts index a6c87814b1..e234a91c7f 100644 --- a/zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts +++ b/zeppelin-web-angular/e2e/tests/notebook/keyboard/notebook-keyboard-shortcuts.spec.ts @@ -67,7 +67,7 @@ test.describe.serial('Comprehensive Keyboard Shortcuts (ShortcutsMap)', () => { // ===== CORE EXECUTION SHORTCUTS ===== test.describe('ParagraphActions.Run: Shift+Enter', () => { - test('should execute markdown paragraph with Shift+Enter', async () => { + test('[NB-PARITY-005] should execute markdown paragraph with Shift+Enter', async () => { // Given: A paragraph with markdown content await keyboardPage.tryFocusCodeEditor(); await keyboardPage.setCodeEditorContent('%md\n# Test Heading\n\nThis is **bold** text.'); @@ -80,6 +80,7 @@ test.describe.serial('Comprehensive Keyboard Shortcuts (ShortcutsMap)', () => { // waitForParagraphExecution gates on the status text, so it is the assertion and throws if the run never settles. await keyboardPage.waitForParagraphExecution(0); + await expect(keyboardPage.paragraphResult.getByRole('heading', { name: 'Test Heading' })).toBeVisible(); }); }); diff --git a/zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts index a8656cc8f3..d15d15f541 100644 --- a/zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts +++ b/zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts @@ -39,7 +39,7 @@ test.describe('Notebook Container Component', () => { await navigateToNotebookWithFallback(page, testNotebook.noteId); }); - test('should display notebook container with proper structure', async () => { + test('[NB-PARITY-001] should display notebook container with proper structure', async () => { await expect(notebookPage.notebookContainer).toBeVisible(); expect(await notebookPage.getNotebookContainerClass()).toContain('notebook-container'); }); diff --git a/zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts index 105c422620..3a7f87be96 100644 --- a/zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts +++ b/zeppelin-web-angular/e2e/tests/notebook/paragraph/paragraph-functionality.spec.ts @@ -47,13 +47,13 @@ test.describe('Notebook Paragraph Functionality', () => { await expect(paragraphPage.controlPanel).toBeVisible(); }); - test('should support double-click editing functionality', async () => { + test('[NB-PARITY-003] should support double-click editing functionality', async () => { await expect(paragraphPage.paragraphContainer).toBeVisible(); await paragraphPage.doubleClickToEdit(); await expect(paragraphPage.codeEditor).toBeVisible(); }); - test('should display add paragraph buttons', async () => { + test('[NB-PARITY-004] should display add paragraph buttons', async () => { await expect(paragraphPage.addParagraphAbove).toBeVisible(); await expect(paragraphPage.addParagraphAbove).toHaveCount(1); await expect(paragraphPage.addParagraphBelow).toBeVisible(); @@ -66,7 +66,7 @@ test.describe('Notebook Paragraph Functionality', () => { await expect(paragraphPage.runButton).toBeEnabled(); }); - test('should display result system properly', async ({ page }) => { + test('[NB-PARITY-021] should display result system properly', async ({ page }) => { await expect(page).toHaveURL(/\/notebook\/[^\/]+/, { timeout: 10000 }); await page.waitForLoadState('domcontentloaded'); await expect(paragraphPage.paragraphContainer).toBeVisible({ timeout: 15000 }); diff --git a/zeppelin-web-angular/package.json b/zeppelin-web-angular/package.json index 8acdda44a6..537dc7f9ba 100644 --- a/zeppelin-web-angular/package.json +++ b/zeppelin-web-angular/package.json @@ -14,6 +14,8 @@ "build:projects": "npm run build-project:sdk && npm run build-project:vis", "build-project:sdk": "ng build --project zeppelin-sdk", "check:websocket-contract": "node --test scripts/check-websocket-contract.test.js && node scripts/check-websocket-contract.js", + "generate:notebook-parity-scenarios": "node scripts/generate-notebook-parity-scenarios.mjs", + "check:notebook-parity-scenarios": "node --test scripts/check-notebook-parity-scenarios.test.mjs && node scripts/check-notebook-parity-scenarios.mjs", "build-project:vis": "ng build --project zeppelin-visualization", "lint": "cross-env NODE_OPTIONS='--max-old-space-size=8192' ng lint && npm run lint:react && prettier --check \"**/*.{ts,tsx,mts,js,json,css,html}\"", "lint:fix": "cross-env NODE_OPTIONS='--max-old-space-size=8192' ng lint --fix && npm run lint:fix:react && prettier --write \"**/*.{ts,tsx,mts,js,json,css,html}\"", diff --git a/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.mjs b/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.mjs new file mode 100644 index 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..45b0f75808 --- /dev/null +++ b/zeppelin-web-angular/scripts/check-notebook-parity-scenarios.test.mjs @@ -0,0 +1,294 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { spawnSync } from 'node:child_process'; + +import { renderMarkdown, validateRegistry } from './notebook-parity-scenarios.mjs'; + +function createFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'notebook-parity-')); + const webRoot = path.join(root, 'zeppelin-web-angular'); + fs.mkdirSync(path.join(root, 'zeppelin-web-angular/e2e/tests/notebook/main'), { recursive: true }); + fs.mkdirSync(path.join(root, 'zeppelin-web-angular/e2e/scenarios'), { recursive: true }); + fs.mkdirSync(path.join(root, 'e2e/scenarios'), { recursive: true }); + fs.mkdirSync(path.join(root, 'zeppelin-web-angular/src/app/pages/workspace/notebook'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts'), + 'class NotebookComponent {}' + ); + fs.writeFileSync( + path.join(root, 'zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts'), + "import { test } from '@playwright/test';\ntest('[NB-PARITY-001] should render', async () => {});" + ); + spawnSync('git', ['init'], { cwd: root, stdio: 'ignore' }); + spawnSync('git', ['config', 'user.email', '[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 { + schemaVersion: 1, + reviewedCommit: commit, + scenarios: [ + { + id: 'NB-PARITY-001', + name: 'Container renders', + area: 'navigation', + preconditions: ['note exists'], + action: 'open the route', + observableOutcomes: ['container is visible'], + interpreter: null, + roleExpectations: { + owner: 'allow', + writer: 'allow', + reader: 'allow', + runner: 'allow' + }, + roleVerification: { + owner: 'unverified', + writer: 'unverified', + reader: 'unverified', + runner: 'unverified' + }, + evidence: [ + { + path: 'zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts', + symbol: 'NotebookComponent' + } + ], + coverage: { + status: 'covered', + tests: [ + { + path: 'zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts', + title: '[NB-PARITY-001] should render', + projects: ['chromium'] + } + ], + issues: [], + uncoveredOutcomes: [] + } + } + ] + }; +} + +test('validates a current registry and generated markdown', () => { + const { commit, root, webRoot } = createFixture(); + const registry = baseRegistry(commit); + fs.writeFileSync(path.join(webRoot, 'e2e/scenarios/notebook-parity.md'), renderMarkdown(registry)); + + assert.deepEqual(validateRegistry(registry, webRoot), []); +}); + +test('accepts multiline executable Playwright test declarations', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + writeFixtureSpec( + webRoot, + `import { test } from '@playwright/test'; + test( + '[NB-PARITY-001] should render', + async () => {} + );` + ); + fs.writeFileSync(path.join(webRoot, 'e2e/scenarios/notebook-parity.md'), renderMarkdown(registry)); + + assert.deepEqual(validateRegistry(registry, webRoot), []); +}); + +test('rejects duplicate ids and stale markdown', () => { + const { commit, root, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios.push(structuredClone(registry.scenarios[0])); + fs.writeFileSync(path.join(webRoot, 'e2e/scenarios/notebook-parity.md'), 'stale\n'); + + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /is duplicated/); +}); + +test('rejects false covered claims without matching Playwright id', () => { + const { commit, root, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].coverage.tests[0].title = 'should render'; + fs.writeFileSync(path.join(webRoot, 'e2e/scenarios/notebook-parity.md'), renderMarkdown(registry)); + + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /title must contain/); +}); + +test('rejects commented-only, helper-string, and skipped Playwright coverage claims', () => { + const cases = [ + { + name: 'commented-only', + source: "// test('[NB-PARITY-001] should render', async () => {});" + }, + { + name: 'helper-string', + source: "const title = '[NB-PARITY-001] should render';\ntest(title, async () => {});" + }, + { + name: 'test.skip', + source: "import { test } from '@playwright/test';\ntest.skip('[NB-PARITY-001] should render', async () => {});" + }, + { + name: 'skipped describe', + source: + "import { test } from '@playwright/test';\ntest.describe.skip('disabled', () => { test('[NB-PARITY-001] should render', async () => {}); });" + }, + { + name: 'non-Playwright test helper', + source: "const test = () => undefined;\ntest('[NB-PARITY-001] should render', async () => {});" + } + ]; + + for (const { name, source } of cases) { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + writeFixtureSpec(webRoot, source); + fs.writeFileSync(path.join(webRoot, 'e2e/scenarios/notebook-parity.md'), renderMarkdown(registry)); + + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /title is not declared by an executable test\(\)/, name); + } +}); + +test('requires Jira issues for gaps', () => { + const { commit, root, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].coverage.status = 'gap'; + registry.scenarios[0].coverage.tests = []; + fs.writeFileSync(path.join(webRoot, 'e2e/scenarios/notebook-parity.md'), renderMarkdown(registry)); + + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /issues is required for gap scenarios/); +}); + +test('requires executable coverage, a Jira issue, and named uncovered outcomes for partial scenarios', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].coverage.status = 'partial'; + registry.scenarios[0].coverage.issues = []; + + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /issues is required for partial scenarios/); + + registry.scenarios[0].coverage.issues = ['ZEPPELIN-1234']; + registry.scenarios[0].coverage.tests = []; + const missingTestErrors = validateRegistry(registry, webRoot).join('\n'); + assert.match(missingTestErrors, /tests is required for partial scenarios/); + assert.match(missingTestErrors, /uncoveredOutcomes is required for partial scenarios/); +}); + +test('rejects invalid Jira issue keys and extra role expectation fields', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].coverage.issues = ['OTHER-123']; + registry.scenarios[0].roleExpectations.admin = 'allow'; + + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /must match ZEPPELIN-####/); + assert.match(errors, /roleExpectations must contain exactly/); +}); + +test('normalizes malformed coverage arrays before applying coverage rules', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].coverage.tests = null; + registry.scenarios[0].coverage.issues = null; + registry.scenarios[0].coverage.uncoveredOutcomes = null; + + assert.doesNotThrow(() => validateRegistry(registry, webRoot)); + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /coverage.tests must be an array/); + assert.match(errors, /coverage.issues must be an array/); + assert.match(errors, /coverage.uncoveredOutcomes must be an array/); + assert.match(errors, /coverage.tests is required for covered scenarios/); +}); + +test('rejects malformed test entries and invalid execution metadata without throwing', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].coverage.tests = [null]; + + assert.doesNotThrow(() => validateRegistry(registry, webRoot)); + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /coverage.tests\[0\] must be an object/); + + registry.scenarios[0].coverage.tests = [ + { + path: 'zeppelin-web-angular/e2e/tests/notebook/main/notebook-container.spec.ts', + title: '[NB-PARITY-001] should render', + projects: ['unknown-browser'] + } + ]; + const projectErrors = validateRegistry(registry, webRoot).join('\n'); + assert.match(projectErrors, /projects contains an invalid project/); +}); + +test('requires role verification to distinguish expected permissions from tested permissions', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].roleVerification.reader = 'covered'; + + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /roleVerification.reader is invalid/); +}); + +test('rejects evidence paths outside the repository', () => { + const { commit, webRoot } = createFixture(); + const registry = baseRegistry(commit); + registry.scenarios[0].evidence[0].path = '../outside-repository.ts'; + + const errors = validateRegistry(registry, webRoot).join('\n'); + assert.match(errors, /evidence\[0\].path does not exist/); +}); + +test('renders generated Markdown with exactly one trailing newline', () => { + const { commit } = createFixture(); + const markdown = renderMarkdown(baseRegistry(commit)); + + assert.equal(markdown.endsWith('\n'), true); + assert.equal(markdown.endsWith('\n\n'), false); +}); + +test('renders generated Markdown with an Apache License header', () => { + const { commit } = createFixture(); + const markdown = renderMarkdown(baseRegistry(commit)); + + assert.match(markdown, /^<!--\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 declaration/ + ); + 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..043b637924 --- /dev/null +++ b/zeppelin-web-angular/scripts/notebook-parity-scenarios.mjs @@ -0,0 +1,477 @@ +#!/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'; + +export const registryPath = 'e2e/scenarios/notebook-parity.json'; +export const markdownPath = 'e2e/scenarios/notebook-parity.md'; +export const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const allowedAreas = new Set([ + 'editor', + 'execution', + 'result', + 'visualization', + 'shortcut', + 'permission', + 'collaboration', + 'navigation', + 'persistence', + 'lifecycle', + 'theme', + 'accessibility' +]); +const allowedCoverageStatuses = new Set(['covered', 'partial', 'gap', 'blocked']); +const allowedRoleExpectations = new Set(['allow', 'deny', 'not-applicable']); +const allowedRoleVerificationStatuses = new Set(['unverified', 'not-applicable']); +const allowedProjects = new Set(['chromium', 'firefox', 'webkit']); +const jiraIssuePattern = /^ZEPPELIN-\d+$/; +const roles = ['owner', 'writer', 'reader', 'runner']; + +const escapeTableCell = value => String(value).replace(/\|/g, '\\|').replace(/\n/g, '<br>'); +const readJson = file => JSON.parse(readFileSync(file, 'utf8')); + +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 assertArrayOfStrings = (errors, value, field) => { + if ( + !Array.isArray(value) || + value.length === 0 || + value.some(item => typeof item !== 'string' || item.length === 0) + ) { + errors.push(`${field} must be a non-empty string array`); + } +}; + +const skipWhitespaceAndComments = (source, start) => { + let index = start; + while (index < source.length) { + if (/\s/.test(source[index])) { + index += 1; + } else if (source[index] === '/' && source[index + 1] === '/') { + index = source.indexOf('\n', index + 2); + if (index === -1) { + return source.length; + } + } else if (source[index] === '/' && source[index + 1] === '*') { + index = source.indexOf('*/', index + 2); + if (index === -1) { + return source.length; + } + index += 2; + } else { + return index; + } + } + return index; +}; + +const readStringLiteral = (source, start) => { + const quote = source[start]; + if (quote !== "'" && quote !== '"' && quote !== '`') { + return null; + } + + let value = ''; + for (let index = start + 1; index < source.length; index += 1) { + const character = source[index]; + if (character === '\\') { + value += source[index + 1] ?? ''; + index += 1; + } else if (quote === '`' && character === '$' && source[index + 1] === '{') { + return null; + } else if (character === quote) { + return value; + } else { + value += character; + } + } + return null; +}; + +const skipStringLiteral = (source, start) => { + const quote = source[start]; + for (let index = start + 1; index < source.length; index += 1) { + const character = source[index]; + if (character === '\\') { + index += 1; + } else if (character === quote) { + return index + 1; + } + } + return source.length; +}; + +const isIdentifierCharacter = character => /[A-Za-z0-9_$]/.test(character ?? ''); + +const previousNonWhitespaceCharacter = (source, start) => { + for (let index = start - 1; index >= 0; index -= 1) { + if (!/\s/.test(source[index])) { + return source[index]; + } + } + return ''; +}; + +const importsPlaywrightTest = source => + /import\s+(?:[\s\S]*?\btest\b[\s\S]*?)\s+from\s+['"]@playwright\/test['"]/.test(source); + +const findSkippedDescribeBlocks = source => { + const blocks = []; + const marker = 'test.describe.skip'; + for (let start = source.indexOf(marker); start !== -1; start = source.indexOf(marker, start + marker.length)) { + const bodyStart = source.indexOf('{', start + marker.length); + if (bodyStart === -1) { + continue; + } + let depth = 0; + for (let index = bodyStart; index < source.length; index += 1) { + if (source[index] === "'" || source[index] === '"' || source[index] === '`') { + index = skipStringLiteral(source, index) - 1; + } else if (source[index] === '/' && source[index + 1] === '/') { + index = source.indexOf('\n', index + 2); + if (index === -1) { + break; + } + } else if (source[index] === '/' && source[index + 1] === '*') { + index = source.indexOf('*/', index + 2); + if (index === -1) { + break; + } + index += 1; + } else if (source[index] === '{') { + depth += 1; + } else if (source[index] === '}' && --depth === 0) { + blocks.push([start, index]); + break; + } + } + } + return blocks; +}; + +const getExecutablePlaywrightTestTitles = source => { + if (!importsPlaywrightTest(source)) { + return new Set(); + } + + const titles = new Set(); + const skippedDescribeBlocks = findSkippedDescribeBlocks(source); + for (let index = 0; index < source.length; index += 1) { + if (source[index] === '/' && source[index + 1] === '/') { + index = source.indexOf('\n', index + 2); + if (index === -1) { + break; + } + } else if (source[index] === '/' && source[index + 1] === '*') { + index = source.indexOf('*/', index + 2); + if (index === -1) { + break; + } + index += 1; + } else if (source[index] === "'" || source[index] === '"' || source[index] === '`') { + index = skipStringLiteral(source, index) - 1; + } else if ( + source.startsWith('test', index) && + !isIdentifierCharacter(source[index - 1]) && + !isIdentifierCharacter(source[index + 4]) && + previousNonWhitespaceCharacter(source, index) !== '.' + ) { + const openParen = skipWhitespaceAndComments(source, index + 4); + if (source[openParen] !== '(') { + continue; + } + const titleStart = skipWhitespaceAndComments(source, openParen + 1); + const title = readStringLiteral(source, titleStart); + if (title !== null && !skippedDescribeBlocks.some(([start, end]) => index >= start && index <= end)) { + titles.add(title); + } + } + } + + return titles; +}; + +const testDeclaresExecutableTitle = (root, test) => { + const absolutePath = resolveRepositoryPath(root, test.path); + if (!absolutePath || !existsSync(absolutePath)) { + return false; + } + return getExecutablePlaywrightTestTitles(readFileSync(absolutePath, 'utf8')).has(test.title); +}; + +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 version: ${registry.schemaVersion}`, + '', + `Scenario/Angular baseline commit: \`${registry.reviewedCommit}\``, + '', + 'Scope note: This is a prioritized baseline, not a complete Notebook inventory. Before a React vertical slice is declared ready, add every affected behavior to this registry and classify its evidence.', + '', + 'Coverage note: `covered` mechanically means this registry points to a matching executable Playwright test declaration. Semantic adequacy and runtime pass/fail remain review and CI evidence. Role expectations and role verification are deliberately separate.', + '', + '| ID | Area | Scenario | Coverage | Roles | Tests | Issues |', + '| --- | --- | --- | --- | --- | --- | --- |' + ]; + + for (const scenario of registry.scenarios) { + const rolesText = roles.map(role => `${role}: ${scenario.roleExpectations[role]}`).join('<br>'); + const testsText = + scenario.coverage.tests.length === 0 + ? '' + : scenario.coverage.tests.map(test => `${test.path}<br>${test.title}`).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: ${roles.map(role => `${role}: ${scenario.roleVerification[role]}`).join('; ')}`); + lines.push(`- Preconditions: ${scenario.preconditions.join(' ')}`); + lines.push(`- Action: ${scenario.action}`); + lines.push(`- Observable outcomes: ${scenario.observableOutcomes.join(' ')}`); + lines.push(`- Evidence: ${scenario.evidence.map(item => `${item.path} (${item.symbol})`).join('; ')}`); + if (scenario.coverage.tests.length > 0) { + lines.push( + `- Browser projects: ${scenario.coverage.tests + .map(test => `${test.title}: ${test.projects.join(', ')}`) + .join('; ')}` + ); + } + if (scenario.coverage.uncoveredOutcomes.length > 0) { + lines.push(`- Uncovered outcomes: ${scenario.coverage.uncoveredOutcomes.join(' ')}`); + } + lines.push(''); + } + + return `${lines.join('\n').replace(/\n+$/, '')}\n`; +}; + +export const validateRegistry = (registry, root = webRoot, { checkMarkdown = true } = {}) => { + const errors = []; + + if (registry.schemaVersion !== 1) { + errors.push('schemaVersion must be 1'); + } + if (typeof registry.reviewedCommit !== 'string' || !/^[0-9a-f]{40}$/.test(registry.reviewedCommit)) { + errors.push('reviewedCommit must be a full 40-character commit hash'); + } else { + try { + execFileSync('git', ['cat-file', '-e', `${registry.reviewedCommit}^{commit}`], { cwd: root, stdio: 'ignore' }); + } catch { + errors.push(`reviewedCommit is not available in this checkout: ${registry.reviewedCommit}`); + } + } + if (!Array.isArray(registry.scenarios) || registry.scenarios.length === 0) { + errors.push('scenarios must be a non-empty array'); + return errors; + } + + const ids = new Set(); + let previousId = ''; + for (const [index, scenario] of registry.scenarios.entries()) { + const prefix = `scenarios[${index}]`; + if (typeof scenario.id !== 'string' || !/^NB-PARITY-\d{3}$/.test(scenario.id)) { + errors.push(`${prefix}.id must match NB-PARITY-###`); + } else if (ids.has(scenario.id)) { + errors.push(`${prefix}.id is duplicated: ${scenario.id}`); + } else { + if (previousId && scenario.id <= previousId) { + errors.push(`${prefix}.id must sort after ${previousId}`); + } + previousId = scenario.id; + ids.add(scenario.id); + } + + if (typeof scenario.name !== 'string' || scenario.name.length === 0) { + errors.push(`${prefix}.name is required`); + } + if (!allowedAreas.has(scenario.area)) { + errors.push(`${prefix}.area is invalid: ${scenario.area}`); + } + assertArrayOfStrings(errors, scenario.preconditions, `${prefix}.preconditions`); + if (typeof scenario.action !== 'string' || scenario.action.length === 0) { + errors.push(`${prefix}.action is required`); + } + assertArrayOfStrings(errors, scenario.observableOutcomes, `${prefix}.observableOutcomes`); + if (!(typeof scenario.interpreter === 'string' || scenario.interpreter === null)) { + errors.push(`${prefix}.interpreter must be a string or null`); + } + for (const role of roles) { + if (!allowedRoleExpectations.has(scenario.roleExpectations?.[role])) { + errors.push(`${prefix}.roleExpectations.${role} is invalid`); + } + } + const roleKeys = Object.keys(scenario.roleExpectations ?? {}).sort(); + if (roleKeys.join(',') !== [...roles].sort().join(',')) { + errors.push(`${prefix}.roleExpectations must contain exactly ${roles.join(', ')}`); + } + for (const role of roles) { + if (!allowedRoleVerificationStatuses.has(scenario.roleVerification?.[role])) { + errors.push(`${prefix}.roleVerification.${role} is invalid`); + } + if ( + scenario.roleExpectations?.[role] === 'not-applicable' && + scenario.roleVerification?.[role] !== 'not-applicable' + ) { + errors.push(`${prefix}.roleVerification.${role} must be not-applicable`); + } + } + const roleVerificationKeys = Object.keys(scenario.roleVerification ?? {}).sort(); + if (roleVerificationKeys.join(',') !== [...roles].sort().join(',')) { + errors.push(`${prefix}.roleVerification must contain exactly ${roles.join(', ')}`); + } + + if (!Array.isArray(scenario.evidence) || scenario.evidence.length === 0) { + errors.push(`${prefix}.evidence must be a non-empty array`); + } else { + for (const [evidenceIndex, evidence] of scenario.evidence.entries()) { + const evidencePath = resolveRepositoryPath(root, evidence.path); + if (!evidencePath || !existsSync(evidencePath)) { + errors.push(`${prefix}.evidence[${evidenceIndex}].path does not exist: ${evidence.path}`); + } + if (typeof evidence.symbol !== 'string' || evidence.symbol.length === 0) { + errors.push(`${prefix}.evidence[${evidenceIndex}].symbol is required`); + } + } + } + + const coverage = scenario.coverage; + if (!allowedCoverageStatuses.has(coverage?.status)) { + errors.push(`${prefix}.coverage.status is invalid`); + continue; + } + if (!Array.isArray(coverage.tests)) { + errors.push(`${prefix}.coverage.tests must be an array`); + } + if (!Array.isArray(coverage.issues)) { + errors.push(`${prefix}.coverage.issues must be an array`); + } + if (!Array.isArray(coverage.uncoveredOutcomes)) { + errors.push(`${prefix}.coverage.uncoveredOutcomes must be an array`); + } + const coverageTests = Array.isArray(coverage.tests) ? coverage.tests : []; + const coverageIssues = Array.isArray(coverage.issues) ? coverage.issues : []; + const uncoveredOutcomes = Array.isArray(coverage.uncoveredOutcomes) ? coverage.uncoveredOutcomes : []; + if (coverage.status === 'covered' && coverageTests.length === 0) { + errors.push(`${prefix}.coverage.tests is required for covered scenarios`); + } + if (coverage.status === 'partial' && coverageTests.length === 0) { + errors.push(`${prefix}.coverage.tests is required for partial scenarios`); + } + if (coverage.status === 'partial' && coverageIssues.length === 0) { + errors.push(`${prefix}.coverage.issues is required for partial scenarios`); + } + if (coverage.status === 'partial' && uncoveredOutcomes.length === 0) { + errors.push(`${prefix}.coverage.uncoveredOutcomes is required for partial scenarios`); + } + if (coverage.status !== 'partial' && uncoveredOutcomes.length > 0) { + errors.push(`${prefix}.coverage.uncoveredOutcomes is only valid for partial scenarios`); + } + for (const [outcomeIndex, outcome] of uncoveredOutcomes.entries()) { + if (typeof outcome !== 'string' || !scenario.observableOutcomes.includes(outcome)) { + errors.push(`${prefix}.coverage.uncoveredOutcomes[${outcomeIndex}] must reference an observable outcome`); + } + } + if ((coverage.status === 'gap' || coverage.status === 'blocked') && coverageIssues.length === 0) { + errors.push(`${prefix}.coverage.issues is required for ${coverage.status} scenarios`); + } + for (const [issueIndex, issue] of coverageIssues.entries()) { + if (typeof issue !== 'string' || !jiraIssuePattern.test(issue)) { + errors.push(`${prefix}.coverage.issues[${issueIndex}] must match ZEPPELIN-####`); + } + } + for (const [testIndex, test] of coverageTests.entries()) { + if (!test || typeof test !== 'object') { + errors.push(`${prefix}.coverage.tests[${testIndex}] must be an object`); + continue; + } + const testPath = resolveRepositoryPath(root, test.path); + if (!testPath || !existsSync(testPath)) { + errors.push(`${prefix}.coverage.tests[${testIndex}].path does not exist: ${test.path}`); + } + if (coverage.status === 'covered' && !test.path.startsWith('zeppelin-web-angular/e2e/tests/notebook/')) { + errors.push(`${prefix}.coverage.tests[${testIndex}].path must be in the notebook E2E suite`); + } + if (typeof test.title !== 'string' || !test.title.includes(scenario.id)) { + errors.push(`${prefix}.coverage.tests[${testIndex}].title must contain ${scenario.id}`); + } else if (!testDeclaresExecutableTitle(root, test)) { + errors.push( + `${prefix}.coverage.tests[${testIndex}].title is not declared by an executable test() in ${test.path}` + ); + } + if (!Array.isArray(test.projects) || test.projects.length === 0) { + errors.push(`${prefix}.coverage.tests[${testIndex}].projects must be a non-empty array`); + } else { + for (const project of test.projects) { + if (!allowedProjects.has(project)) { + errors.push(`${prefix}.coverage.tests[${testIndex}].projects contains an invalid project: ${project}`); + } + } + } + } + } + + if (checkMarkdown && errors.length === 0) { + const actualMarkdownPath = path.join(root, markdownPath); + if (!existsSync(actualMarkdownPath)) { + errors.push(`${markdownPath} does not exist`); + } else if (readFileSync(actualMarkdownPath, 'utf8') !== renderMarkdown(registry)) { + errors.push(`${markdownPath} is stale; run npm run generate:notebook-parity-scenarios`); + } + } + + return errors; +}; + +export const loadRegistry = (root = webRoot) => readJson(path.join(root, registryPath));
