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>/);