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

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


The following commit(s) were added to refs/heads/master by this push:
     new 6c87bece0a [ZEPPELIN-6675] Prove the notebook route can consume the 
Shared Notebook Core port
6c87bece0a is described below

commit 6c87bece0ae0bc1cec68a66b59f5d1ffdbad4262
Author: 김예나 <[email protected]>
AuthorDate: Wed Oct 7 12:20:04 2026 +0900

    [ZEPPELIN-6675] Prove the notebook route can consume the Shared Notebook 
Core port
    
    ### What is this PR for?
    
    Add a browser proof that the current Angular notebook route can hand one 
host-owned `NotebookCorePort` to a separately built React consumer. This builds 
on the ZEPPELIN-6674 port identity proof and moves it onto the real notebook 
route.
    
    * The proof harness bootstraps the production `WorkspaceModule` and 
`NotebookModule` lazy routes and asserts that the activated component is the 
production `NotebookComponent`.
    * It reads the activated `/notebook/:noteId` and 
`/notebook/:noteId/revision/:revisionId` parameters into one host-owned test 
Core. The React consumer reads the snapshot and receives route-driven 
subscription updates (note → other note → revision), always through the same 
port object.
    * A browser-only `MessageService` double records the production component's 
`getNote`, `noteRevision` and `listRevisionHistory` requests, and the test 
asserts their exact order.
    
    This is a seam-only proof. It does not switch the production renderer, move 
notebook state out of Angular, or implement re-subscription, recovery or 
mutation reducers (ZEPPELIN-6687).
    
    The implementation is based on the `ZEPPELIN-6675-notebook-route-boundary` 
branch in the voidmatcha fork (`823183338`), which was written before 
ZEPPELIN-6674 merged. Porting it onto current `master`:
    
    * **CI wiring:** the route proof runs in the `integration-test` phase 
behind `web.e2e.core.port.proof.disabled`, the same as the merged port identity 
proof, so it runs on the anonymous leg of `run-playwright-e2e-tests`. The 
branch bound it to the `test` phase with `${skipTests}`, which `frontend.yml`'s 
`-DskipTests` build skips.
    * **Shared static server:** it now returns 404 for unknown paths, matching 
the merged ZEPPELIN-6674 review change, instead of serving `index.html`.
    * **Message double:** it gains `receiveEnvelope`, added to `MessageService` 
by ZEPPELIN-6683. Without it, the workspace route did not render.
    * **Theme double:** it gains `getCurrentTheme` and `theme$`. Without them, 
`ThemeToggleComponent` throws in `ngOnInit`. The original test did not catch 
this, because Angular's `ErrorHandler` reports it through 
`console.error('ERROR', ...)` and never as a `pageerror`. The test now fails on 
those errors too. Removing the theme fix makes it fail with 
`this.themeService.getCurrentTheme is not a function`.
    * **Lifecycle boundary is now asserted, not only documented:**
      * the port's own keys are exactly `getSnapshot` and `subscribe`, and the 
port is frozen;
      * route activation and port consumption make no `bootstrap`, `connect` or 
`close` call.
    * **README:** the section moves to the end of `e2e/core-contract/README.md` 
instead of splitting the replay subsections. It describes the actual CI wiring 
and notes that ZEPPELIN-6683's stale-reply rejection lives inside 
`NotebookComponent` and is not moved into the Core.
    * **Kept as is:** the route path constants (`notebook-route-boundary.ts`). 
Production code changes are limited to extracting the existing route paths into 
those constants, which both the routing modules and the proof use.
    * **Dropped from the branch:** the `./NotebookRouteBoundaryProbe` webpack 
alias (it re-exposed the same module), and unused route-proof `proofs` state.
    
    ### What type of PR is it?
    Improvement
    
    ### Todos
    * [x] Run the route proof on the production notebook routes with one 
host-owned port
    * [x] Assert route-driven snapshot and subscription updates through the 
same port
    * [x] Assert the port shape and that the connection lifecycle is not reached
    * [x] Run it through the normal browser CI path
    * [x] Record host-side responsibilities and what remains outside this proof
    
    ### What is the Jira issue?
    [ZEPPELIN-6675](https://issues.apache.org/jira/browse/ZEPPELIN-6675)
    
    ### How should this be tested?
    
    ```bash
    cd zeppelin-web-angular
    npm run build:projects
    npm run build:notebook-core-port-proof
    npm run test:notebook-core-port-identity
    npm run test:notebook-route-boundary
    ```
    
    What I ran locally (after rebasing onto `6a5ed4459`):
    * Both browser proofs passed. Also passing:
      * `test:notebook-core`, `typecheck:notebook-core`
      * `check:core-contract-fixtures`, `test:shell`
      * prettier and eslint on the changed files
    * `./mvnw verify -pl zeppelin-web-angular -Pweb-e2e` (before the rebase): 
the proof build, the port identity proof and the route proof passed in 
`integration-test`.
    * The main Playwright suite finished with 749 passed and 20 failed. 
Rerunning only the failing specs with one worker left 6 failures:
      * `paragraph-functionality.spec.ts:174` and `:335`, on all three browsers;
      * both execute `%python`, and every failure context shows `Fail to launch 
python process` from my local Python setup;
      * the other 14 failures passed when run serially.
    * **Not run locally:** the classic UI e2e. The maven run stopped after the 
main suite because I ran it without `CI=true`, so the HTML reporter waited for 
input. This PR does not touch `zeppelin-web`.
    * I checked that each build setting kept from the branch is still required 
by building without it:
      * `mathjax` types: without them, the `MathJax` directive fails to compile;
      * the custom webpack builder: without it, Monaco CSS fails to parse;
      * the style include paths: without them, the components' `.less` imports 
fail to resolve.
    
    ### Screenshots (if appropriate)
    
    N/A.
    
    ### Questions:
    * Does the license files need to update? No. New files carry the ASF header.
    * Is there breaking changes for older versions? No. Route shapes and 
behaviour are unchanged.
    * Does this needs documentation? `e2e/core-contract/README.md` is updated.
    
    Unrelated, found while testing: production `main.js` builds the Monaco 
`codicon.ttf` URL from the build machine's absolute `file:///` path, so the 
browser refuses to load that font. It also happens on `master`.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    
    Closes #5527 from kimyenac/ZEPPELIN-6675.
    
    Signed-off-by: YONGJAE LEE <[email protected]>
---
 zeppelin-web-angular/angular.json                  |  10 +-
 zeppelin-web-angular/e2e/core-contract/README.md   |  34 ++++
 .../e2e/core-contract/angular-host/index.html      |   2 +-
 .../e2e/core-contract/angular-host/main.ts         | 206 ++++++++++++++++++++-
 .../e2e/core-contract/angular-host/tsconfig.json   |   2 +-
 .../notebook-core-port-identity.test.mjs           | 104 +----------
 .../core-contract/notebook-route-boundary.test.mjs | 113 +++++++++++
 .../e2e/core-contract/proof-browser-harness.mjs    | 112 +++++++++++
 zeppelin-web-angular/package.json                  |   1 +
 zeppelin-web-angular/pom.xml                       |  12 ++
 ...outing.module.ts => notebook-route-boundary.ts} |  25 +--
 .../workspace/notebook/notebook-routing.module.ts  |  12 +-
 .../pages/workspace/workspace-routing.module.ts    |   3 +-
 .../test/notebook-core/import-boundary.spec.ts     |   6 +-
 14 files changed, 508 insertions(+), 134 deletions(-)

diff --git a/zeppelin-web-angular/angular.json 
b/zeppelin-web-angular/angular.json
index 9062ad69a8..b2a5072d58 100644
--- a/zeppelin-web-angular/angular.json
+++ b/zeppelin-web-angular/angular.json
@@ -163,7 +163,7 @@
       "prefix": "zeppelin",
       "architect": {
         "build": {
-          "builder": "@angular-devkit/build-angular:browser",
+          "builder": "@angular-builders/custom-webpack:browser",
           "options": {
             "outputPath": "dist/notebook-core-port-proof",
             "index": "e2e/core-contract/angular-host/index.html",
@@ -178,7 +178,13 @@
               }
             ],
             "styles": [],
-            "scripts": []
+            "scripts": [],
+            "customWebpackConfig": {
+              "path": "./webpack.config.js"
+            },
+            "stylePreprocessorOptions": {
+              "includePaths": ["src/styles/theme", "src/styles/theme/dark", 
"src/styles/theme/light"]
+            }
           },
           "configurations": {
             "production": {
diff --git a/zeppelin-web-angular/e2e/core-contract/README.md 
b/zeppelin-web-angular/e2e/core-contract/README.md
index cfb94293b7..66a48d7616 100644
--- a/zeppelin-web-angular/e2e/core-contract/README.md
+++ b/zeppelin-web-angular/e2e/core-contract/README.md
@@ -345,3 +345,37 @@ holds.
 
 Do not rely on any of this for a fixture captured from a server holding real
 credentials. Capture from the isolated server this directory starts.
+
+## Notebook route boundary proof
+
+`npm run build:notebook-core-port-proof` builds the React consumer and Angular
+route host separately. `npm run test:notebook-route-boundary` then checks the
+current `/notebook/:noteId` and `/notebook/:noteId/revision/:revisionId` route
+shapes in Chromium. The proof bootstraps the production `WorkspaceModule` lazy
+route, follows its `NotebookModule` lazy route, and asserts that the activated
+component is the production `NotebookComponent`. Its browser-only 
message-service
+double records the production component's `getNote`, `noteRevision`, and 
revision
+history requests. The Angular harness reads the resulting activated-route 
snapshot
+into one host-owned test Core; the remote receives only its stable
+`NotebookCorePort`, reads the selected note and revision snapshot, and observes
+route-driven subscription updates. The browser assertion records the two 
production
+paths explicitly, so a route-shape change requires an intentional proof 
update. It
+also fails on uncaught page errors and on errors that Angular's `ErrorHandler`
+logs, so a production component that throws against an incomplete double does 
not
+pass silently.
+
+Maven runs the proof build, this proof and the port identity proof in the
+`integration-test` phase of `-Pweb-e2e`. `web.e2e.core.port.proof.disabled` 
gates
+all three, and `frontend.yml` enables them only on the anonymous leg of
+`run-playwright-e2e-tests`.
+
+These responsibilities stay host-side: route parameters, the physical WebSocket
+connect, close and reconnect lifecycle, the SDK, and Angular services. The port
+exposes only `getSnapshot` and `subscribe`, and the proof asserts that route
+activation and port consumption make no `bootstrap`, `connect` or `close` call.
+Future Core work owns note re-subscription and state recovery. ZEPPELIN-6683
+already rejects stale revision and interpreter-binding replies inside the 
Angular
+`NotebookComponent`; this proof does not move that rule into the Core, and 
detailed
+reconnect recovery remains outside it until the lifecycle rules have an 
enforceable
+stale-reply mechanism. The harness does not implement those lifecycle rules, 
switch
+the production renderer, or move production notebook state out of Angular.
diff --git a/zeppelin-web-angular/e2e/core-contract/angular-host/index.html 
b/zeppelin-web-angular/e2e/core-contract/angular-host/index.html
index 34aa7aed45..d91aa9d1db 100644
--- a/zeppelin-web-angular/e2e/core-contract/angular-host/index.html
+++ b/zeppelin-web-angular/e2e/core-contract/angular-host/index.html
@@ -24,6 +24,6 @@ limitations under the License.
     <meta name="viewport" content="width=device-width, initial-scale=1" />
   </head>
   <body>
-    <zeppelin-notebook-core-port-proof></zeppelin-notebook-core-port-proof>
+    
<zeppelin-notebook-core-port-proof-app></zeppelin-notebook-core-port-proof-app>
   </body>
 </html>
diff --git a/zeppelin-web-angular/e2e/core-contract/angular-host/main.ts 
b/zeppelin-web-angular/e2e/core-contract/angular-host/main.ts
index 447d1cccd3..1cb944de11 100644
--- a/zeppelin-web-angular/e2e/core-contract/angular-host/main.ts
+++ b/zeppelin-web-angular/e2e/core-contract/angular-host/main.ts
@@ -11,11 +11,25 @@
  */
 
 import { CommonModule } from '@angular/common';
-import { Component, NgModule } from '@angular/core';
+import { Component, Injectable, NgModule } from '@angular/core';
 import { BrowserModule } from '@angular/platform-browser';
 import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
-import { ReactMountDirective } from '@zeppelin/share/react-mount';
+import { ActivatedRouteSnapshot, NavigationEnd, Router, RouterModule, 
RouterStateSnapshot } from '@angular/router';
+import { TRASH_FOLDER_ID_TOKEN } from '@zeppelin/interfaces';
 import type { NotebookCorePort, NotebookCoreSnapshot } from 
'@zeppelin/notebook-core';
+import { NotebookComponent } from 
'@zeppelin/pages/workspace/notebook/notebook.component';
+import {
+  NOTEBOOK_CHILD_ROUTE_PATHS,
+  NOTEBOOK_ROUTE_PATH
+} from '@zeppelin/pages/workspace/notebook/notebook-route-boundary';
+import { WorkspaceGuard } from '@zeppelin/pages/workspace/workspace.guard';
+import { MessageService, ReactFeatureService } from '@zeppelin/services';
+import { HeliumService } from '@zeppelin/services/helium.service';
+import { ThemeService } from '@zeppelin/services/theme.service';
+import { TicketService } from '@zeppelin/services/ticket.service';
+import { ShareModule } from '@zeppelin/share';
+import { NzMessageService } from 'ng-zorro-antd/message';
+import { BehaviorSubject, NEVER, filter } from 'rxjs';
 
 declare global {
   interface Window {
@@ -24,9 +38,86 @@ declare global {
       proofs: unknown[];
       receivedCore?: NotebookCorePort;
     };
+    __zeppelinNotebookRouteBoundaryProof?: {
+      activatedProductionNotebookComponents: boolean[];
+      connectionLifecycleCalls: string[];
+      hostCore: NotebookCorePort;
+      messageCalls: Array<{ method: string; noteId: string; revisionId?: 
string }>;
+      receivedCore?: NotebookCorePort;
+      receivedCores: NotebookCorePort[];
+      routePaths: string[];
+      workspaceGuardCalls: string[];
+    };
+  }
+}
+
+@Component({
+  selector: 'zeppelin-notebook-core-port-proof-app',
+  standalone: false,
+  template: `
+    <router-outlet></router-outlet>
+    @if (notebookRouteActive) {
+      <button type="button" data-testid="navigate-notebook-note" 
[routerLink]="['/notebook', 'note-route-updated']">
+        navigate note
+      </button>
+      <button
+        type="button"
+        data-testid="navigate-notebook-revision"
+        [routerLink]="['/notebook', 'note-route-updated', 'revision', 
'revision-from-route']"
+      >
+        navigate revision
+      </button>
+      <div [zeppelin-react-mount]="'./NotebookCorePortProbe'" 
[reactProps]="routeReactProps"></div>
+    }
+  `
+})
+export class NotebookCorePortProofAppComponent {
+  notebookRouteActive = false;
+  readonly routeReactProps: Readonly<{
+    core: NotebookCorePort;
+    expectedCore: NotebookCorePort;
+    onReceivedCore: (receivedCore: NotebookCorePort) => void;
+  }>;
+
+  constructor(router: Router, portHost: NotebookRouteBoundaryPortHost) {
+    this.routeReactProps = {
+      core: portHost.core,
+      expectedCore: portHost.core,
+      onReceivedCore: receivedCore => {
+        const state = window.__zeppelinNotebookRouteBoundaryProof;
+        if (state) {
+          state.receivedCore = receivedCore;
+          state.receivedCores.push(receivedCore);
+        }
+      }
+    };
+    router.events.pipe(filter((event): event is NavigationEnd => event 
instanceof NavigationEnd)).subscribe(() => {
+      const notebookRoute = 
findActivatedNotebookRoute(router.routerState.snapshot.root);
+      this.notebookRouteActive = notebookRoute !== undefined;
+      if (notebookRoute) {
+        
window.__zeppelinNotebookRouteBoundaryProof?.activatedProductionNotebookComponents.push(
+          notebookRoute.component === NotebookComponent
+        );
+        portHost.publish({
+          noteId: notebookRoute.paramMap.get('noteId') ?? '',
+          revisionId: notebookRoute.paramMap.get('revisionId')
+        });
+      }
+    });
   }
 }
 
+const findActivatedNotebookRoute = (root: ActivatedRouteSnapshot): 
ActivatedRouteSnapshot | undefined => {
+  let route: ActivatedRouteSnapshot | null = root;
+  while (route) {
+    if (route.component === NotebookComponent) {
+      return route;
+    }
+    route = route.firstChild;
+  }
+  return undefined;
+};
+
 @Component({
   selector: 'zeppelin-notebook-core-port-proof',
   standalone: false,
@@ -75,10 +166,115 @@ export class NotebookCorePortProofComponent {
   }
 }
 
+@Injectable({ providedIn: 'root' })
+export class NotebookRouteBoundaryPortHost {
+  readonly core: NotebookCorePort = Object.freeze({
+    getSnapshot: () => this.snapshot,
+    subscribe: listener => {
+      this.listeners.add(listener);
+      return () => this.listeners.delete(listener);
+    }
+  });
+  private snapshot: NotebookCoreSnapshot = { noteId: '', revisionId: null };
+  private readonly listeners = new Set<() => void>();
+
+  constructor() {
+    window.__zeppelinNotebookRouteBoundaryProof = {
+      activatedProductionNotebookComponents: [],
+      connectionLifecycleCalls: [],
+      hostCore: this.core,
+      messageCalls: [],
+      receivedCores: [],
+      routePaths: NOTEBOOK_CHILD_ROUTE_PATHS.map(path => 
`${NOTEBOOK_ROUTE_PATH}/${path}`),
+      workspaceGuardCalls: []
+    };
+  }
+
+  publish(snapshot: NotebookCoreSnapshot): void {
+    this.snapshot = snapshot;
+    for (const listener of this.listeners) {
+      listener();
+    }
+  }
+}
+
+class ProofMessageService {
+  readonly connectedStatus = true;
+  readonly connectedStatus$ = new BehaviorSubject(true);
+  receive() {
+    return NEVER;
+  }
+  receiveEnvelope() {
+    return NEVER;
+  }
+  // The physical WebSocket lifecycle belongs to the shell. The proof records 
any call so that route activation
+  // and port consumption can be shown not to reach it.
+  bootstrap() {
+    
window.__zeppelinNotebookRouteBoundaryProof?.connectionLifecycleCalls.push('bootstrap');
+  }
+  close() {
+    
window.__zeppelinNotebookRouteBoundaryProof?.connectionLifecycleCalls.push('close');
+  }
+  connect() {
+    
window.__zeppelinNotebookRouteBoundaryProof?.connectionLifecycleCalls.push('connect');
+  }
+  getNote(noteId: string) {
+    window.__zeppelinNotebookRouteBoundaryProof?.messageCalls.push({ method: 
'getNote', noteId });
+  }
+  noteRevision(noteId: string, revisionId: string) {
+    window.__zeppelinNotebookRouteBoundaryProof?.messageCalls.push({ method: 
'noteRevision', noteId, revisionId });
+  }
+  listRevisionHistory(noteId: string) {
+    window.__zeppelinNotebookRouteBoundaryProof?.messageCalls.push({ method: 
'listRevisionHistory', noteId });
+  }
+}
+
+@Injectable()
+class ProofWorkspaceGuard {
+  canActivate(_route: ActivatedRouteSnapshot, state: RouterStateSnapshot): 
boolean {
+    
window.__zeppelinNotebookRouteBoundaryProof?.workspaceGuardCalls.push(state.url);
+    return true;
+  }
+}
+
 @NgModule({
-  bootstrap: [NotebookCorePortProofComponent],
-  declarations: [NotebookCorePortProofComponent, ReactMountDirective],
-  imports: [BrowserModule, CommonModule]
+  bootstrap: [NotebookCorePortProofAppComponent],
+  declarations: [NotebookCorePortProofAppComponent, 
NotebookCorePortProofComponent],
+  imports: [
+    BrowserModule,
+    CommonModule,
+    ShareModule,
+    RouterModule.forRoot(
+      [
+        { path: 'port-identity', component: NotebookCorePortProofComponent },
+        {
+          path: '',
+          loadChildren: () =>
+            import('@zeppelin/pages/workspace/workspace.module').then(module 
=> module.WorkspaceModule)
+        }
+      ],
+      { useHash: true }
+    )
+  ],
+  providers: [
+    { provide: WorkspaceGuard, useClass: ProofWorkspaceGuard },
+    { provide: MessageService, useClass: ProofMessageService },
+    { provide: HeliumService, useValue: { initPackages: () => undefined } },
+    { provide: NzMessageService, useValue: { loading: () => ({ messageId: 
'proof' }), remove: () => undefined } },
+    { provide: ReactFeatureService, useValue: { isEnabled: () => false } },
+    {
+      provide: ThemeService,
+      useValue: { getCurrentTheme: () => 'light', theme$: NEVER, 
updateMonacoTheme: () => undefined }
+    },
+    {
+      provide: TicketService,
+      useValue: {
+        getTicket: () => NEVER,
+        ticket: { init: true, principal: 'anonymous', screenUsername: 
'anonymous' }
+      }
+    },
+    { provide: TRASH_FOLDER_ID_TOKEN, useValue: '~Trash' }
+  ]
 })
 export class NotebookCorePortProofModule {}
 
diff --git a/zeppelin-web-angular/e2e/core-contract/angular-host/tsconfig.json 
b/zeppelin-web-angular/e2e/core-contract/angular-host/tsconfig.json
index e637f8f629..ba9fbe05ac 100644
--- a/zeppelin-web-angular/e2e/core-contract/angular-host/tsconfig.json
+++ b/zeppelin-web-angular/e2e/core-contract/angular-host/tsconfig.json
@@ -3,7 +3,7 @@
   "compilerOptions": {
     "ignoreDeprecations": "5.0",
     "outDir": "../../../out-tsc/notebook-core-port-proof",
-    "types": []
+    "types": ["mathjax"]
   },
   "files": ["main.ts"],
   "angularCompilerOptions": {
diff --git 
a/zeppelin-web-angular/e2e/core-contract/notebook-core-port-identity.test.mjs 
b/zeppelin-web-angular/e2e/core-contract/notebook-core-port-identity.test.mjs
index f75c774dfa..9d570598f0 100644
--- 
a/zeppelin-web-angular/e2e/core-contract/notebook-core-port-identity.test.mjs
+++ 
b/zeppelin-web-angular/e2e/core-contract/notebook-core-port-identity.test.mjs
@@ -11,112 +11,27 @@
  */
 
 import assert from 'node:assert/strict';
-import { createReadStream, existsSync, statSync } from 'node:fs';
-import { createServer } from 'node:http';
-import { extname, isAbsolute, join, relative, resolve } from 'node:path';
 import { after, before, test } from 'node:test';
-import { pathToFileURL } from 'node:url';
 
-import { chromium, expect } from '@playwright/test';
+import { expect } from '@playwright/test';
 
-const angularDistRoot = resolve('dist/notebook-core-port-proof');
-const angularIndexPath = join(angularDistRoot, 'index.html');
-const remoteEntryPath = join(angularDistRoot, 'assets/react/remoteEntry.js');
-
-let browser;
-let server;
-let baseUrl;
-
-const contentTypes = new Map([
-  ['.css', 'text/css; charset=utf-8'],
-  ['.html', 'text/html; charset=utf-8'],
-  ['.js', 'text/javascript; charset=utf-8']
-]);
-
-function resolveInsideAngularDist(requestPath) {
-  const decodedPath = decodeURIComponent(requestPath.replace(/^\//, ''));
-  const filePath = resolve(angularDistRoot, decodedPath);
-  const rootRelativePath = relative(angularDistRoot, filePath);
-
-  if (rootRelativePath.startsWith('..') || isAbsolute(rootRelativePath)) {
-    return null;
-  }
-
-  return filePath;
-}
-
-function isFile(filePath) {
-  return statSync(filePath, { throwIfNoEntry: false })?.isFile() ?? false;
-}
-
-function serveStaticFile(response, requestPath) {
-  const filePath = resolveInsideAngularDist(requestPath);
-
-  if (!filePath || !isFile(filePath)) {
-    response.writeHead(404);
-    response.end('not found');
-    return;
-  }
-
-  response.writeHead(200, {
-    'cache-control': 'no-store',
-    'content-type': contentTypes.get(extname(filePath)) ?? 
'application/octet-stream'
-  });
-  createReadStream(filePath).pipe(response);
-}
+import { startNotebookCoreProofHarness } from './proof-browser-harness.mjs';
 
+let harness;
 before(async () => {
-  assert.ok(
-    existsSync(angularIndexPath),
-    `Angular host build output is missing: run "npm run 
build:notebook-core-port-proof" before this proof (${pathToFileURL(
-      angularIndexPath
-    )})`
-  );
-  assert.ok(
-    existsSync(remoteEntryPath),
-    `React remote asset is missing: run "npm run 
build:notebook-core-port-proof" before this proof (${pathToFileURL(
-      remoteEntryPath
-    )})`
-  );
-
-  server = createServer((request, response) => {
-    const requestPath = request.url?.split('?')[0] ?? '/';
-    if (requestPath === '/') {
-      response.writeHead(200, {
-        'cache-control': 'no-store',
-        'content-type': 'text/html; charset=utf-8'
-      });
-      createReadStream(angularIndexPath).pipe(response);
-      return;
-    }
-
-    if (isFile(resolveInsideAngularDist(requestPath) ?? '')) {
-      serveStaticFile(response, requestPath);
-      return;
-    }
-
-    response.writeHead(404, { 'cache-control': 'no-store' });
-    response.end();
-  });
-
-  await new Promise(resolveListen => {
-    server.listen(0, '127.0.0.1', resolveListen);
-  });
-  const address = server.address();
-  assert.ok(address && typeof address === 'object');
-  baseUrl = `http://127.0.0.1:${address.port}`;
-  browser = await chromium.launch();
+  harness = await startNotebookCoreProofHarness();
 });
 
 after(async () => {
-  await browser?.close();
-  await new Promise(resolveClose => server?.close(resolveClose));
+  await harness?.close();
 });
 
 test('React remote receives the exact host-owned NotebookCorePort object', 
async () => {
-  const page = await browser.newPage();
+  const page = await harness.browser.newPage();
+  const pageErrors = [];
+  page.on('pageerror', error => pageErrors.push(error.message));
 
-  await page.goto(baseUrl);
+  await page.goto(`${harness.baseUrl}/#/port-identity`);
 
   const probe = page.getByTestId('notebook-core-port-probe');
   await expect(probe).toHaveAttribute('data-same-identity', 'true', { timeout: 
15_000 });
@@ -144,6 +59,7 @@ test('React remote receives the exact host-owned 
NotebookCorePort object', async
       snapshot: { noteId: 'note-host-owned', revisionId: 
'revision-from-angular-host' },
       updateCount: 1
     });
+  assert.deepEqual(pageErrors, []);
 
   await page.close();
 });
diff --git 
a/zeppelin-web-angular/e2e/core-contract/notebook-route-boundary.test.mjs 
b/zeppelin-web-angular/e2e/core-contract/notebook-route-boundary.test.mjs
new file mode 100644
index 0000000000..26f628ce29
--- /dev/null
+++ b/zeppelin-web-angular/e2e/core-contract/notebook-route-boundary.test.mjs
@@ -0,0 +1,113 @@
+/*
+ * 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 { after, before, test } from 'node:test';
+
+import { expect } from '@playwright/test';
+
+import { startNotebookCoreProofHarness } from './proof-browser-harness.mjs';
+
+let harness;
+before(async () => {
+  harness = await startNotebookCoreProofHarness();
+});
+
+after(async () => {
+  await harness?.close();
+});
+
+test('Angular owns notebook route parsing and passes one port to the React 
remote', async () => {
+  const page = await harness.browser.newPage();
+  const pageErrors = [];
+  page.on('pageerror', error => pageErrors.push(error.message));
+  // Angular's ErrorHandler reports errors thrown by production components 
through console.error('ERROR', ...)
+  // instead of letting them reach pageerror.
+  const angularErrors = [];
+  page.on('console', message => {
+    if (message.type() === 'error' && message.text().startsWith('ERROR')) {
+      angularErrors.push(message.text());
+    }
+  });
+  await page.goto(`${harness.baseUrl}/#/notebook/note-from-route`);
+
+  await expect(page.locator('zeppelin-workspace')).toHaveCount(1);
+  await expect(page.locator('zeppelin-notebook')).toHaveCount(1);
+
+  const probe = page.getByTestId('notebook-core-port-probe');
+  await expect(probe).toHaveAttribute('data-same-identity', 'true');
+  await expect(probe).toHaveAttribute('data-note-id', 'note-from-route');
+  await expect(probe).toHaveAttribute('data-revision-id', '');
+  await expect(probe).toHaveAttribute('data-update-count', '0');
+
+  await page.getByTestId('navigate-notebook-note').click();
+  await 
expect(page).toHaveURL(`${harness.baseUrl}/#/notebook/note-route-updated`);
+  await expect(probe).toHaveAttribute('data-note-id', 'note-route-updated');
+  await expect(probe).toHaveAttribute('data-update-count', '1');
+
+  await page.getByTestId('navigate-notebook-revision').click();
+  await 
expect(page).toHaveURL(`${harness.baseUrl}/#/notebook/note-route-updated/revision/revision-from-route`);
+  await expect(probe).toHaveAttribute('data-same-identity', 'true');
+  await expect(probe).toHaveAttribute('data-note-id', 'note-route-updated');
+  await expect(probe).toHaveAttribute('data-revision-id', 
'revision-from-route');
+  await expect(probe).toHaveAttribute('data-update-count', '2');
+  await expect
+    .poll(() =>
+      page.evaluate(() =>
+        globalThis.__zeppelinNotebookRouteBoundaryProof.messageCalls.some(
+          call => call.method === 'noteRevision' && call.revisionId === 
'revision-from-route'
+        )
+      )
+    )
+    .toBe(true);
+
+  const routeProof = await page.evaluate(() => {
+    const proofState = globalThis.__zeppelinNotebookRouteBoundaryProof;
+    return {
+      allReceivedPortsAreHostOwned: proofState.receivedCores.every(core => 
Object.is(core, proofState.hostCore)),
+      activatedProductionNotebookComponents: 
proofState.activatedProductionNotebookComponents,
+      connectionLifecycleCalls: proofState.connectionLifecycleCalls,
+      hostPortIsFrozen: Object.isFrozen(proofState.hostCore),
+      hostPortKeys: Object.keys(proofState.hostCore).sort(),
+      latestPortIsHostOwned: Object.is(proofState.receivedCore, 
proofState.hostCore),
+      messageCalls: proofState.messageCalls,
+      receivedPortCount: new Set(proofState.receivedCores).size,
+      routePaths: proofState.routePaths,
+      snapshot: proofState.hostCore.getSnapshot(),
+      workspaceGuardCalls: proofState.workspaceGuardCalls
+    };
+  });
+  assert.deepEqual(routeProof, {
+    allReceivedPortsAreHostOwned: true,
+    activatedProductionNotebookComponents: [true, true, true],
+    connectionLifecycleCalls: [],
+    hostPortIsFrozen: true,
+    hostPortKeys: ['getSnapshot', 'subscribe'],
+    latestPortIsHostOwned: true,
+    messageCalls: [
+      { method: 'getNote', noteId: 'note-from-route' },
+      { method: 'listRevisionHistory', noteId: 'note-from-route' },
+      { method: 'getNote', noteId: 'note-route-updated' },
+      { method: 'listRevisionHistory', noteId: 'note-route-updated' },
+      { method: 'noteRevision', noteId: 'note-route-updated', revisionId: 
'revision-from-route' },
+      { method: 'listRevisionHistory', noteId: 'note-route-updated' }
+    ],
+    receivedPortCount: 1,
+    routePaths: ['notebook/:noteId', 'notebook/:noteId/revision/:revisionId'],
+    snapshot: { noteId: 'note-route-updated', revisionId: 
'revision-from-route' },
+    workspaceGuardCalls: ['/notebook/note-from-route']
+  });
+  assert.deepEqual(pageErrors, []);
+  assert.deepEqual(angularErrors, []);
+
+  await page.close();
+});
diff --git a/zeppelin-web-angular/e2e/core-contract/proof-browser-harness.mjs 
b/zeppelin-web-angular/e2e/core-contract/proof-browser-harness.mjs
new file mode 100644
index 0000000000..fcf013bdee
--- /dev/null
+++ b/zeppelin-web-angular/e2e/core-contract/proof-browser-harness.mjs
@@ -0,0 +1,112 @@
+/*
+ * 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 { createReadStream, existsSync, statSync } from 'node:fs';
+import { createServer } from 'node:http';
+import { extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+import { chromium } from '@playwright/test';
+
+const angularDistRoot = resolve('dist/notebook-core-port-proof');
+const angularIndexPath = join(angularDistRoot, 'index.html');
+const remoteEntryPath = join(angularDistRoot, 'assets/react/remoteEntry.js');
+
+const contentTypes = new Map([
+  ['.css', 'text/css; charset=utf-8'],
+  ['.html', 'text/html; charset=utf-8'],
+  ['.js', 'text/javascript; charset=utf-8']
+]);
+
+function resolveStaticFile(requestPath) {
+  let decodedPath;
+  try {
+    decodedPath = decodeURIComponent(requestPath.replace(/^\//, ''));
+  } catch {
+    return null;
+  }
+
+  const filePath = resolve(angularDistRoot, decodedPath);
+  const rootRelativePath = relative(angularDistRoot, filePath);
+  const escapesRoot =
+    rootRelativePath === '..' || rootRelativePath.startsWith(`..${sep}`) || 
isAbsolute(rootRelativePath);
+
+  if (escapesRoot || !existsSync(filePath) || !statSync(filePath).isFile()) {
+    return null;
+  }
+
+  return filePath;
+}
+
+function serveFile(response, filePath) {
+  response.writeHead(200, {
+    'cache-control': 'no-store',
+    'content-type': contentTypes.get(extname(filePath)) ?? 
'application/octet-stream'
+  });
+  createReadStream(filePath).pipe(response);
+}
+
+export async function startNotebookCoreProofHarness() {
+  assert.ok(
+    existsSync(angularIndexPath),
+    `Angular host build output is missing: run "npm run 
build:notebook-core-port-proof" before this proof (${pathToFileURL(
+      angularIndexPath
+    )})`
+  );
+  assert.ok(
+    existsSync(remoteEntryPath),
+    `React remote asset is missing: run "npm run 
build:notebook-core-port-proof" before this proof (${pathToFileURL(
+      remoteEntryPath
+    )})`
+  );
+
+  const server = createServer((request, response) => {
+    const requestPath = request.url?.split('?')[0] ?? '/';
+    const staticFilePath = resolveStaticFile(requestPath);
+
+    if (requestPath === '/') {
+      serveFile(response, angularIndexPath);
+      return;
+    }
+
+    if (staticFilePath) {
+      serveFile(response, staticFilePath);
+      return;
+    }
+
+    response.writeHead(404, { 'cache-control': 'no-store' });
+    response.end();
+  });
+
+  await new Promise(resolveListen => {
+    server.listen(0, '127.0.0.1', resolveListen);
+  });
+  const address = server.address();
+  assert.ok(address && typeof address === 'object');
+  let browser;
+  try {
+    browser = await chromium.launch();
+  } catch (error) {
+    await new Promise(resolveClose => server.close(resolveClose));
+    throw error;
+  }
+
+  return {
+    baseUrl: `http://127.0.0.1:${address.port}`,
+    browser,
+    close: async () => {
+      await browser.close();
+      await new Promise(resolveClose => server.close(resolveClose));
+    }
+  };
+}
diff --git a/zeppelin-web-angular/package.json 
b/zeppelin-web-angular/package.json
index 08c1da8017..4288084e8a 100644
--- a/zeppelin-web-angular/package.json
+++ b/zeppelin-web-angular/package.json
@@ -27,6 +27,7 @@
     "typecheck:notebook-core": "tsc -p 
projects/zeppelin-notebook-core/tsconfig.json --noEmit && tsc -p 
projects/zeppelin-notebook-core/tsconfig.spec.json --noEmit && npm run 
build-project:notebook-core && tsc -p 
projects/zeppelin-react/tsconfig.notebook-core.dist.json --noEmit && tsc -p 
projects/zeppelin-react/tsconfig.notebook-core.json --noEmit && tsc -p 
e2e/core-contract/react-remote/tsconfig.json --noEmit",
     "typecheck:sdk-contracts": "tsc -p 
projects/zeppelin-sdk/tsconfig.spec.json --noEmit",
     "test:notebook-core-port-identity": "node --test 
e2e/core-contract/notebook-core-port-identity.test.mjs",
+    "test:notebook-route-boundary": "node --test 
e2e/core-contract/notebook-route-boundary.test.mjs",
     "test:notebook-core": "vitest run --config 
vitest.notebook-core.config.mts",
     "test:shell": "vitest run --config vitest.shell.config.mts",
     "test:eslint-rules": "node --test eslint-rules/*.test.js",
diff --git a/zeppelin-web-angular/pom.xml b/zeppelin-web-angular/pom.xml
index ad6de57998..ab25e21de5 100644
--- a/zeppelin-web-angular/pom.xml
+++ b/zeppelin-web-angular/pom.xml
@@ -215,6 +215,18 @@
             </configuration>
           </execution>
 
+          <execution>
+            <id>npm test notebook route boundary</id>
+            <goals>
+              <goal>npm</goal>
+            </goals>
+            <phase>integration-test</phase>
+            <configuration>
+              <skip>${web.e2e.core.port.proof.disabled}</skip>
+              <arguments>run test:notebook-route-boundary</arguments>
+            </configuration>
+          </execution>
+
           <execution>
             <id>npm e2e</id>
             <goals>
diff --git 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook-routing.module.ts
 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook-route-boundary.ts
similarity index 55%
copy from 
zeppelin-web-angular/src/app/pages/workspace/notebook/notebook-routing.module.ts
copy to 
zeppelin-web-angular/src/app/pages/workspace/notebook/notebook-route-boundary.ts
index 321b788e74..e2b789d103 100644
--- 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook-routing.module.ts
+++ 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook-route-boundary.ts
@@ -10,24 +10,11 @@
  * limitations under the License.
  */
 
-import { NgModule } from '@angular/core';
-import { RouterModule, Routes } from '@angular/router';
+import { Type } from '@angular/core';
+import { Routes } from '@angular/router';
 
-import { NotebookComponent } from './notebook.component';
+export const NOTEBOOK_ROUTE_PATH = 'notebook';
+export const NOTEBOOK_CHILD_ROUTE_PATHS = [':noteId', 
':noteId/revision/:revisionId'] as const;
 
-const routes: Routes = [
-  {
-    path: ':noteId',
-    component: NotebookComponent
-  },
-  {
-    path: ':noteId/revision/:revisionId',
-    component: NotebookComponent
-  }
-];
-
-@NgModule({
-  imports: [RouterModule.forChild(routes)],
-  exports: [RouterModule]
-})
-export class NotebookRoutingModule {}
+export const createNotebookChildRoutes = (component: Type<unknown>): Routes =>
+  NOTEBOOK_CHILD_ROUTE_PATHS.map(path => ({ path, component }));
diff --git 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook-routing.module.ts
 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook-routing.module.ts
index 321b788e74..6712408ac7 100644
--- 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook-routing.module.ts
+++ 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook-routing.module.ts
@@ -14,17 +14,9 @@ import { NgModule } from '@angular/core';
 import { RouterModule, Routes } from '@angular/router';
 
 import { NotebookComponent } from './notebook.component';
+import { createNotebookChildRoutes } from './notebook-route-boundary';
 
-const routes: Routes = [
-  {
-    path: ':noteId',
-    component: NotebookComponent
-  },
-  {
-    path: ':noteId/revision/:revisionId',
-    component: NotebookComponent
-  }
-];
+const routes: Routes = createNotebookChildRoutes(NotebookComponent);
 
 @NgModule({
   imports: [RouterModule.forChild(routes)],
diff --git 
a/zeppelin-web-angular/src/app/pages/workspace/workspace-routing.module.ts 
b/zeppelin-web-angular/src/app/pages/workspace/workspace-routing.module.ts
index d4faf206d8..026a8ff395 100644
--- a/zeppelin-web-angular/src/app/pages/workspace/workspace-routing.module.ts
+++ b/zeppelin-web-angular/src/app/pages/workspace/workspace-routing.module.ts
@@ -15,6 +15,7 @@ import { RouterModule, Routes } from '@angular/router';
 
 import { WorkspaceComponent } from './workspace.component';
 import { WorkspaceGuard } from './workspace.guard';
+import { NOTEBOOK_ROUTE_PATH } from './notebook/notebook-route-boundary';
 
 const routes: Routes = [
   {
@@ -27,7 +28,7 @@ const routes: Routes = [
         loadChildren: () => 
import('@zeppelin/pages/workspace/home/home.module').then(m => m.HomeModule)
       },
       {
-        path: 'notebook',
+        path: NOTEBOOK_ROUTE_PATH,
         loadChildren: () => 
import('@zeppelin/pages/workspace/notebook/notebook.module').then(m => 
m.NotebookModule)
       },
       {
diff --git a/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts 
b/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts
index e8e957ad47..d924b38fb0 100644
--- a/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts
+++ b/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts
@@ -42,7 +42,11 @@ describe('notebook core import boundary', () => {
     expect(pom).toMatch(
       
/<web\.e2e\.core\.port\.proof\.disabled>\$\{web\.e2e\.disabled\}<\/web\.e2e\.core\.port\.proof\.disabled>/
     );
-    for (const id of ['npm build notebook core port identity proof', 'npm test 
notebook core port identity']) {
+    for (const id of [
+      'npm build notebook core port identity proof',
+      'npm test notebook core port identity',
+      'npm test notebook route boundary'
+    ]) {
       const execution = pom.match(new 
RegExp(`<execution>\\s*<id>${id}</id>([\\s\\S]*?)</execution>`))?.[1];
       expect(execution).toMatch(/<phase>integration-test<\/phase>/);
       
expect(execution).toMatch(/<skip>\$\{web\.e2e\.core\.port\.proof\.disabled\}<\/skip>/);

Reply via email to