This is an automated email from the ASF dual-hosted git repository.
tbonelee pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git
The following commit(s) were added to refs/heads/master by this push:
new de33e02651 [ZEPPELIN-6674] Prove Shared Notebook Core port identity
de33e02651 is described below
commit de33e026519cfa5b67a9cf45f79637cecb190a68
Author: YONGJAE LEE (이용재) <[email protected]>
AuthorDate: Sun Sep 27 15:53:03 2026 +0900
[ZEPPELIN-6674] Prove Shared Notebook Core port identity
### What is this PR for?
Prove that separately built Angular host and React remote code receive the
exact same host-owned NotebookCorePort object. The build also rejects a React
remote that bundles its own Shared Notebook Core runtime.
### What type of PR is it?
Improvement
### Todos
* [x] Add the Angular host and React remote proof
* [x] Reject duplicate Core runtime bundles
* [x] Add the proof to the frontend build
### What is the Jira issue?
ZEPPELIN-6674
### How should this be tested?
```bash
cd zeppelin-web-angular
npm run typecheck:notebook-core
npm run build:notebook-core-port-proof
npm run test:notebook-core-port-identity
```
### Screenshots (if appropriate)
Not applicable.
### Questions:
* Does the license files need to update? No
* Is there breaking changes for older versions? No
* Does this needs documentation? No
Closes #5481 from voidmatcha/ZEPPELIN-6674-shared-core-port-identity.
Signed-off-by: ChanHo Lee <[email protected]>
---
.github/workflows/frontend.yml | 4 +-
zeppelin-web-angular/angular.json | 46 ++++
.../e2e/core-contract/angular-host/index.html | 29 ++
.../e2e/core-contract/angular-host/main.ts | 85 ++++++
.../e2e/core-contract/angular-host/tsconfig.json | 13 +
.../notebook-core-port-identity.test.mjs | 149 +++++++++++
.../react-remote/NotebookCorePortProbe.tsx | 90 +++++++
.../core-contract/react-remote/empty.ts} | 10 +-
.../e2e/core-contract/react-remote/tsconfig.json | 16 ++
.../core-contract/react-remote/webpack.config.js | 91 +++++++
.../reject-notebook-core-runtime-plugin.test.mjs | 67 +++++
zeppelin-web-angular/package.json | 7 +-
zeppelin-web-angular/pom.xml | 27 ++
.../test/notebook-core/compiler-fixture.ts | 5 +-
.../test/notebook-core/import-boundary.spec.ts | 295 +++++++++++++++++++--
.../test/notebook-core/import-boundary.ts | 233 ++++++++++++++--
16 files changed, 1106 insertions(+), 61 deletions(-)
diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml
index e7da04d72d..3e6f4ba206 100644
--- a/.github/workflows/frontend.yml
+++ b/.github/workflows/frontend.yml
@@ -119,8 +119,8 @@ jobs:
mkdir -p $ZEPPELIN_E2E_TEST_NOTEBOOK_DIR
echo "Created test notebook directory:
$ZEPPELIN_E2E_TEST_NOTEBOOK_DIR"
- name: Run headless E2E test with Maven
- # Classic UI e2e runs only on the anonymous leg, like the legacy
Protractor suite
- run: xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24"
./mvnw verify -pl zeppelin-web-angular -Pweb-e2e -Dweb.e2e.classic.disabled=${{
matrix.mode != 'anonymous' }} ${MAVEN_ARGS}
+ # Classic UI e2e and the notebook core port proof run only on the
anonymous leg
+ run: xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24"
./mvnw verify -pl zeppelin-web-angular -Pweb-e2e -Dweb.e2e.classic.disabled=${{
matrix.mode != 'anonymous' }} -Dweb.e2e.core.port.proof.disabled=${{
matrix.mode != 'anonymous' }} ${MAVEN_ARGS}
- name: Run revision isolation E2E test with Git storage
env:
CI: 'true'
diff --git a/zeppelin-web-angular/angular.json
b/zeppelin-web-angular/angular.json
index d1595667a7..9062ad69a8 100644
--- a/zeppelin-web-angular/angular.json
+++ b/zeppelin-web-angular/angular.json
@@ -156,6 +156,52 @@
}
}
},
+ "notebook-core-port-proof": {
+ "root": "e2e/core-contract/angular-host",
+ "sourceRoot": "e2e/core-contract/angular-host",
+ "projectType": "application",
+ "prefix": "zeppelin",
+ "architect": {
+ "build": {
+ "builder": "@angular-devkit/build-angular:browser",
+ "options": {
+ "outputPath": "dist/notebook-core-port-proof",
+ "index": "e2e/core-contract/angular-host/index.html",
+ "main": "e2e/core-contract/angular-host/main.ts",
+ "polyfills": ["zone.js"],
+ "tsConfig": "e2e/core-contract/angular-host/tsconfig.json",
+ "assets": [
+ {
+ "glob": "**/*",
+ "input": "./e2e/core-contract/react-remote/dist",
+ "output": "/assets/react/"
+ }
+ ],
+ "styles": [],
+ "scripts": []
+ },
+ "configurations": {
+ "production": {
+ "fileReplacements": [
+ {
+ "replace": "src/environments/environment.ts",
+ "with": "src/environments/environment.prod.ts"
+ }
+ ],
+ "optimization": true,
+ "outputHashing": "none",
+ "sourceMap": false,
+ "namedChunks": false,
+ "aot": true,
+ "extractLicenses": true,
+ "vendorChunk": false,
+ "buildOptimizer": false
+ }
+ },
+ "defaultConfiguration": "production"
+ }
+ }
+ },
"zeppelin-visualization": {
"projectType": "library",
"root": "projects/zeppelin-visualization",
diff --git a/zeppelin-web-angular/e2e/core-contract/angular-host/index.html
b/zeppelin-web-angular/e2e/core-contract/angular-host/index.html
new file mode 100644
index 0000000000..34aa7aed45
--- /dev/null
+++ b/zeppelin-web-angular/e2e/core-contract/angular-host/index.html
@@ -0,0 +1,29 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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.
+-->
+
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <title>Notebook core port proof</title>
+ <base href="/" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ </head>
+ <body>
+ <zeppelin-notebook-core-port-proof></zeppelin-notebook-core-port-proof>
+ </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
new file mode 100644
index 0000000000..447d1cccd3
--- /dev/null
+++ b/zeppelin-web-angular/e2e/core-contract/angular-host/main.ts
@@ -0,0 +1,85 @@
+/*
+ * 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 { CommonModule } from '@angular/common';
+import { Component, 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 type { NotebookCorePort, NotebookCoreSnapshot } from
'@zeppelin/notebook-core';
+
+declare global {
+ interface Window {
+ __zeppelinNotebookCorePortProof?: {
+ hostCore: NotebookCorePort;
+ proofs: unknown[];
+ receivedCore?: NotebookCorePort;
+ };
+ }
+}
+
+@Component({
+ selector: 'zeppelin-notebook-core-port-proof',
+ standalone: false,
+ template: `
+ <button type="button" data-testid="publish-notebook-core-revision"
(click)="publishRevision()">
+ publish revision
+ </button>
+ <div [zeppelin-react-mount]="'./NotebookCorePortProbe'"
[reactProps]="reactProps"></div>
+ `
+})
+export class NotebookCorePortProofComponent {
+ readonly core: NotebookCorePort = Object.freeze({
+ getSnapshot: () => this.snapshot,
+ subscribe: listener => {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+ });
+
+ readonly reactProps = {
+ core: this.core,
+ expectedCore: this.core,
+ onProof: (proof: unknown) => {
+ window.__zeppelinNotebookCorePortProof?.proofs.push(proof);
+ },
+ onReceivedCore: (receivedCore: NotebookCorePort) => {
+ window.__zeppelinNotebookCorePortProof!.receivedCore = receivedCore;
+ }
+ };
+
+ private snapshot: NotebookCoreSnapshot = { noteId: 'note-host-owned',
revisionId: null };
+ private readonly listeners = new Set<() => void>();
+
+ constructor() {
+ window.__zeppelinNotebookCorePortProof = {
+ hostCore: this.core,
+ proofs: []
+ };
+ }
+
+ publishRevision(): void {
+ this.snapshot = { noteId: 'note-host-owned', revisionId:
'revision-from-angular-host' };
+ for (const listener of this.listeners) {
+ listener();
+ }
+ }
+}
+
+@NgModule({
+ bootstrap: [NotebookCorePortProofComponent],
+ declarations: [NotebookCorePortProofComponent, ReactMountDirective],
+ imports: [BrowserModule, CommonModule]
+})
+export class NotebookCorePortProofModule {}
+
+void platformBrowserDynamic().bootstrapModule(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
new file mode 100644
index 0000000000..e637f8f629
--- /dev/null
+++ b/zeppelin-web-angular/e2e/core-contract/angular-host/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "ignoreDeprecations": "5.0",
+ "outDir": "../../../out-tsc/notebook-core-port-proof",
+ "types": []
+ },
+ "files": ["main.ts"],
+ "angularCompilerOptions": {
+ "strictInjectionParameters": true,
+ "strictTemplates": true
+ }
+}
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
new file mode 100644
index 0000000000..f75c774dfa
--- /dev/null
+++
b/zeppelin-web-angular/e2e/core-contract/notebook-core-port-identity.test.mjs
@@ -0,0 +1,149 @@
+/*
+ * 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 } from 'node:path';
+import { after, before, test } from 'node:test';
+import { pathToFileURL } from 'node:url';
+
+import { chromium, 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);
+}
+
+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();
+});
+
+after(async () => {
+ await browser?.close();
+ await new Promise(resolveClose => server?.close(resolveClose));
+});
+
+test('React remote receives the exact host-owned NotebookCorePort object',
async () => {
+ const page = await browser.newPage();
+
+ await page.goto(baseUrl);
+
+ const probe = page.getByTestId('notebook-core-port-probe');
+ await expect(probe).toHaveAttribute('data-same-identity', 'true', { timeout:
15_000 });
+ await expect(probe).toHaveAttribute('data-note-id', 'note-host-owned');
+ await expect(probe).toHaveAttribute('data-update-count', '0');
+
+ await page.waitForFunction(() =>
globalThis.__zeppelinNotebookCorePortProof?.receivedCore !== undefined);
+ const hostIdentity = await page.evaluate(() =>
+ Object.is(
+ globalThis.__zeppelinNotebookCorePortProof.hostCore,
+ globalThis.__zeppelinNotebookCorePortProof.receivedCore
+ )
+ );
+ assert.equal(hostIdentity, true);
+
+ await page.getByTestId('publish-notebook-core-revision').click();
+
+ await expect(probe).toHaveAttribute('data-revision-id',
'revision-from-angular-host');
+ await expect(probe).toHaveAttribute('data-update-count', '1');
+
+ await expect
+ .poll(() => page.evaluate(() =>
globalThis.__zeppelinNotebookCorePortProof.proofs.at(-1)))
+ .toEqual({
+ sameIdentity: true,
+ snapshot: { noteId: 'note-host-owned', revisionId:
'revision-from-angular-host' },
+ updateCount: 1
+ });
+
+ await page.close();
+});
diff --git
a/zeppelin-web-angular/e2e/core-contract/react-remote/NotebookCorePortProbe.tsx
b/zeppelin-web-angular/e2e/core-contract/react-remote/NotebookCorePortProbe.tsx
new file mode 100644
index 0000000000..43b304b917
--- /dev/null
+++
b/zeppelin-web-angular/e2e/core-contract/react-remote/NotebookCorePortProbe.tsx
@@ -0,0 +1,90 @@
+/*
+ * 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 { useEffect, useState } from 'react';
+import { createRoot, Root } from 'react-dom/client';
+import type { NotebookCorePort, NotebookCoreRemoteProps, NotebookCoreSnapshot
} from '@zeppelin/notebook-core';
+
+export type NotebookCorePortProbeProps = NotebookCoreRemoteProps &
+ Readonly<{
+ expectedCore?: NotebookCorePort;
+ onReceivedCore?: (core: NotebookCorePort) => void;
+ onProof?: (proof: NotebookCorePortProbeProof) => void;
+ }>;
+
+export type NotebookCorePortProbeProof = Readonly<{
+ sameIdentity: boolean;
+ snapshot: NotebookCoreSnapshot;
+ updateCount: number;
+}>;
+
+export const NotebookCorePortProbe = ({ core, expectedCore, onProof,
onReceivedCore }: NotebookCorePortProbeProps) => {
+ const [snapshot, setSnapshot] = useState(() => core.getSnapshot());
+ const [updateCount, setUpdateCount] = useState(0);
+ const sameIdentity = Object.is(core, expectedCore);
+
+ useEffect(() => {
+ onProof?.({ sameIdentity, snapshot, updateCount });
+ onReceivedCore?.(core);
+ }, [core, onProof, onReceivedCore, sameIdentity, snapshot, updateCount]);
+
+ useEffect(() => {
+ return core.subscribe(() => {
+ setSnapshot(core.getSnapshot());
+ setUpdateCount(value => value + 1);
+ });
+ }, [core]);
+
+ return (
+ <section
+ data-testid="notebook-core-port-probe"
+ data-same-identity={sameIdentity ? 'true' : 'false'}
+ data-note-id={snapshot.noteId}
+ data-revision-id={snapshot.revisionId ?? ''}
+ data-update-count={String(updateCount)}
+ >
+ <span>{snapshot.noteId}</span>
+ <span>{snapshot.revisionId ?? 'live'}</span>
+ </section>
+ );
+};
+
+export interface NotebookCorePortProbeMountHandle {
+ update: (props: NotebookCorePortProbeProps) => void;
+ unmount: () => void;
+}
+
+export const mount = (
+ element: HTMLElement,
+ initialProps: NotebookCorePortProbeProps
+): NotebookCorePortProbeMountHandle => {
+ if (!element) {
+ throw new Error('Mount element is required');
+ }
+
+ const root: Root = createRoot(element);
+
+ const renderWith = (props: NotebookCorePortProbeProps) => {
+ root.render(<NotebookCorePortProbe {...props} />);
+ };
+
+ renderWith(initialProps);
+
+ return {
+ update: (newProps: NotebookCorePortProbeProps) => {
+ renderWith(newProps);
+ },
+ unmount: () => {
+ root.unmount();
+ }
+ };
+};
diff --git a/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
b/zeppelin-web-angular/e2e/core-contract/react-remote/empty.ts
similarity index 59%
copy from zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
copy to zeppelin-web-angular/e2e/core-contract/react-remote/empty.ts
index 7b1ad5f998..7afbc20c3e 100644
--- a/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
+++ b/zeppelin-web-angular/e2e/core-contract/react-remote/empty.ts
@@ -10,12 +10,4 @@
* limitations under the License.
*/
-import ts from 'typescript';
-
-export const createFixtureHost = (options: ts.CompilerOptions, files:
ReadonlyMap<string, string>): ts.CompilerHost => {
- const host = ts.createCompilerHost(options);
- const { readFile, fileExists } = host;
- host.readFile = file => files.get(file) ?? readFile(file);
- host.fileExists = file => files.has(file) || fileExists(file);
- return host;
-};
+export {};
diff --git a/zeppelin-web-angular/e2e/core-contract/react-remote/tsconfig.json
b/zeppelin-web-angular/e2e/core-contract/react-remote/tsconfig.json
new file mode 100644
index 0000000000..b8084eda2b
--- /dev/null
+++ b/zeppelin-web-angular/e2e/core-contract/react-remote/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../../../projects/zeppelin-react/tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": "../../../",
+ "noEmit": false,
+ "paths": {
+ "@zeppelin/notebook-core":
["projects/zeppelin-notebook-core/src/public-api.ts"],
+ "@zeppelin/notebook-core/*": ["projects/zeppelin-notebook-core/src/*"],
+ "react":
["projects/zeppelin-react/node_modules/@types/react/index.d.ts"],
+ "react-dom/client":
["projects/zeppelin-react/node_modules/@types/react-dom/client.d.ts"],
+ "react/jsx-runtime":
["projects/zeppelin-react/node_modules/@types/react/jsx-runtime.d.ts"]
+ },
+ "typeRoots": ["projects/zeppelin-react/node_modules/@types",
"node_modules/@types"]
+ },
+ "include": ["*.ts", "*.tsx"]
+}
diff --git
a/zeppelin-web-angular/e2e/core-contract/react-remote/webpack.config.js
b/zeppelin-web-angular/e2e/core-contract/react-remote/webpack.config.js
new file mode 100644
index 0000000000..d5266dd47a
--- /dev/null
+++ b/zeppelin-web-angular/e2e/core-contract/react-remote/webpack.config.js
@@ -0,0 +1,91 @@
+/*
+ * 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.
+ */
+
+const path = require('path');
+
+const webRoot = path.resolve(__dirname, '../../..');
+const reactRemoteRoot = path.resolve(webRoot, 'projects/zeppelin-react');
+const ModuleFederationPlugin = require(
+ path.join(reactRemoteRoot,
'node_modules/webpack/lib/container/ModuleFederationPlugin')
+);
+
+class RejectNotebookCoreRuntimePlugin {
+ apply(compiler) {
+ compiler.hooks.compilation.tap('RejectNotebookCoreRuntimePlugin',
compilation => {
+ compilation.hooks.finishModules.tap('RejectNotebookCoreRuntimePlugin',
modules => {
+ const coreRoots = [
+ path.resolve(webRoot, 'projects/zeppelin-notebook-core'),
+ path.resolve(webRoot, 'dist/zeppelin-notebook-core')
+ ];
+ const bundledCoreModules = [...modules]
+ .map(module => module.resource)
+ .filter(
+ resource =>
+ typeof resource === 'string' &&
+ coreRoots.some(coreRoot =>
path.resolve(resource).startsWith(`${coreRoot}${path.sep}`))
+ );
+ if (bundledCoreModules.length > 0) {
+ compilation.errors.push(
+ new Error(`React remote bundled Shared Notebook Core runtime:
${bundledCoreModules.join(', ')}`)
+ );
+ }
+ });
+ });
+ }
+}
+
+module.exports = {
+ entry: './empty.ts',
+ context: __dirname,
+ resolve: {
+ extensions: ['.tsx', '.ts', '.js', '.jsx'],
+ modules: [path.resolve(reactRemoteRoot, 'node_modules'),
path.resolve(webRoot, 'node_modules'), 'node_modules'],
+ alias: {
+ '@zeppelin/notebook-core': path.resolve(webRoot,
'projects/zeppelin-notebook-core/src/public-api.ts')
+ }
+ },
+ resolveLoader: {
+ modules: [path.resolve(reactRemoteRoot, 'node_modules'),
path.resolve(webRoot, 'node_modules'), 'node_modules']
+ },
+ module: {
+ rules: [
+ {
+ test: /\.tsx?$/,
+ use: {
+ loader: 'ts-loader',
+ options: {
+ configFile: path.resolve(__dirname, 'tsconfig.json'),
+ transpileOnly: true
+ }
+ },
+ exclude: /node_modules/
+ }
+ ]
+ },
+ output: {
+ clean: true,
+ path: path.resolve(__dirname, 'dist'),
+ publicPath: '/assets/react/',
+ scriptType: 'text/javascript',
+ uniqueName: 'notebookCorePortProof'
+ },
+ plugins: [
+ new RejectNotebookCoreRuntimePlugin(),
+ new ModuleFederationPlugin({
+ exposes: {
+ './NotebookCorePortProbe': './NotebookCorePortProbe'
+ },
+ filename: 'remoteEntry.js',
+ name: 'reactApp'
+ })
+ ]
+};
diff --git
a/zeppelin-web-angular/e2e/core-contract/reject-notebook-core-runtime-plugin.test.mjs
b/zeppelin-web-angular/e2e/core-contract/reject-notebook-core-runtime-plugin.test.mjs
new file mode 100644
index 0000000000..9ea8927a3a
--- /dev/null
+++
b/zeppelin-web-angular/e2e/core-contract/reject-notebook-core-runtime-plugin.test.mjs
@@ -0,0 +1,67 @@
+/*
+ * 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 { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { createRequire } from 'node:module';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { test } from 'node:test';
+import { fileURLToPath } from 'node:url';
+
+const reactRemoteRoot = fileURLToPath(new
URL('../../projects/zeppelin-react/', import.meta.url));
+const requireFromReactRemote = createRequire(join(reactRemoteRoot,
'package.json'));
+const webpack = requireFromReactRemote('webpack');
+const ModuleFederationPlugin =
requireFromReactRemote('webpack/lib/container/ModuleFederationPlugin');
+const proofConfig =
createRequire(import.meta.url)('./react-remote/webpack.config.js');
+const coreEntryPoint = fileURLToPath(
+ new URL('../../projects/zeppelin-notebook-core/src/public-api.ts',
import.meta.url)
+);
+
+test('rejects a React remote that bundles the Shared Notebook Core runtime',
async () => {
+ const fixtureRoot = mkdtempSync(join(tmpdir(),
'zeppelin-notebook-core-runtime-'));
+ try {
+ const exposed = join(fixtureRoot, 'BundledCore.ts');
+ writeFileSync(exposed, "export * as notebookCore from
'@zeppelin/notebook-core';\n");
+ const outputPath = join(fixtureRoot, 'dist');
+ const compiler = webpack({
+ ...proofConfig,
+ mode: 'production',
+ output: { ...proofConfig.output, path: outputPath },
+ plugins: [
+ ...proofConfig.plugins.filter(plugin => !(plugin instanceof
ModuleFederationPlugin)),
+ new ModuleFederationPlugin({
+ exposes: { './BundledCore': exposed },
+ filename: 'remoteEntry.js',
+ name: 'reactApp'
+ })
+ ]
+ });
+
+ const stats = await new Promise((resolveRun, rejectRun) => {
+ compiler.run((error, result) => {
+ compiler.close(() => (error ? rejectRun(error) : resolveRun(result)));
+ });
+ });
+
+ const errors = stats.compilation.errors.map(error => error.message);
+ assert.ok(
+ errors.some(
+ message => message.includes('bundled Shared Notebook Core runtime') &&
message.includes(coreEntryPoint)
+ ),
+ `expected a Shared Notebook Core rejection naming ${coreEntryPoint},
got: ${errors.join('\n')}`
+ );
+ assert.equal(existsSync(join(outputPath, 'remoteEntry.js')), false);
+ } finally {
+ rmSync(fixtureRoot, { recursive: true, force: true });
+ }
+});
diff --git a/zeppelin-web-angular/package.json
b/zeppelin-web-angular/package.json
index 492b631cb8..0b6cf15458 100644
--- a/zeppelin-web-angular/package.json
+++ b/zeppelin-web-angular/package.json
@@ -10,6 +10,8 @@
"start:react": "cd projects/zeppelin-react && npm run dev",
"build": "npm run build:projects && npm run build:react && npm run
build:angular",
"build:angular": "ng build --configuration production",
+ "build:notebook-core-port-proof": "npm run
build:notebook-core-port-proof:react && ng build --project
notebook-core-port-proof --configuration production",
+ "build:notebook-core-port-proof:react": "cd projects/zeppelin-react && npx
webpack --config ../../e2e/core-contract/react-remote/webpack.config.js --mode
production",
"build:react": "cd projects/zeppelin-react && npm run build",
"build:projects": "npm run build-project:sdk && npm run
build-project:notebook-core && npm run build-project:vis",
"build-project:notebook-core": "ng build --project zeppelin-notebook-core",
@@ -22,13 +24,14 @@
"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,mjs,json,css,html}\"",
"lint:react": "cd projects/zeppelin-react && npm run lint",
"lint:fix:react": "cd projects/zeppelin-react && npm run lint:fix",
- "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",
+ "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-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",
"e2e": "playwright test",
- "check:core-contract-fixtures": "node --test
e2e/core-contract/notebook-transport-fixture.test.mjs
e2e/core-contract/playwright-runner.test.mjs",
+ "check:core-contract-fixtures": "node --test
e2e/core-contract/notebook-transport-fixture.test.mjs
e2e/core-contract/playwright-runner.test.mjs
e2e/core-contract/reject-notebook-core-runtime-plugin.test.mjs",
"check:core-contract-auth": "cross-env ZEPPELIN_RUN_AUTH_SETUP_TEST=1 node
--test --test-name-pattern='live anonymous setup'
e2e/core-contract/playwright-runner.test.mjs",
"check:core-contract-server": "node --test
e2e/core-contract/capture-server.test.mjs",
"e2e:core-contract": "playwright test --config
playwright.core-contract.config.js --project=chromium",
diff --git a/zeppelin-web-angular/pom.xml b/zeppelin-web-angular/pom.xml
index 93b28c184e..ad6de57998 100644
--- a/zeppelin-web-angular/pom.xml
+++ b/zeppelin-web-angular/pom.xml
@@ -35,6 +35,8 @@
<web.e2e.enabled>false</web.e2e.enabled>
<!-- Classic UI e2e runs opt-in: CI enables it on the anonymous leg only.
-->
<web.e2e.classic.disabled>true</web.e2e.classic.disabled>
+ <!-- The port identity proof does not depend on the auth mode, so CI runs
it on the anonymous leg only. -->
+
<web.e2e.core.port.proof.disabled>${web.e2e.disabled}</web.e2e.core.port.proof.disabled>
<zeppelin.daemon.package.base>../bin</zeppelin.daemon.package.base>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -188,6 +190,31 @@
</configuration>
</execution>
+ <execution>
+ <id>npm build notebook core port identity proof</id>
+ <goals>
+ <goal>npm</goal>
+ </goals>
+ <phase>integration-test</phase>
+ <configuration>
+ <!-- Built only where the Chromium proof below runs. -->
+ <skip>${web.e2e.core.port.proof.disabled}</skip>
+ <arguments>run build:notebook-core-port-proof</arguments>
+ </configuration>
+ </execution>
+
+ <execution>
+ <id>npm test notebook core port identity</id>
+ <goals>
+ <goal>npm</goal>
+ </goals>
+ <phase>integration-test</phase>
+ <configuration>
+ <skip>${web.e2e.core.port.proof.disabled}</skip>
+ <arguments>run test:notebook-core-port-identity</arguments>
+ </configuration>
+ </execution>
+
<execution>
<id>npm e2e</id>
<goals>
diff --git a/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
b/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
index 7b1ad5f998..c6f0370361 100644
--- a/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
+++ b/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
@@ -14,8 +14,11 @@ import ts from 'typescript';
export const createFixtureHost = (options: ts.CompilerOptions, files:
ReadonlyMap<string, string>): ts.CompilerHost => {
const host = ts.createCompilerHost(options);
- const { readFile, fileExists } = host;
+ const { readFile, fileExists, directoryExists } = host;
host.readFile = file => files.get(file) ?? readFile(file);
host.fileExists = file => files.has(file) || fileExists(file);
+ // Module resolution does not probe files in directories it considers
missing.
+ host.directoryExists = directory =>
+ [...files.keys()].some(file => file.startsWith(`${directory}/`)) ||
(directoryExists?.(directory) ?? true);
return host;
};
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 be3fc00f56..e8e957ad47 100644
--- a/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts
+++ b/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts
@@ -11,7 +11,7 @@
*/
import { readFileSync } from 'node:fs';
-import { dirname, resolve } from 'node:path';
+import { dirname, relative, resolve } from 'node:path';
import ts from 'typescript';
import { describe, expect, it } from 'vitest';
@@ -20,11 +20,16 @@ import { createFixtureHost } from './compiler-fixture';
import {
sourceRoot,
zeppelinWebAngularRoot,
+ reactNotebookCoreProofConsumer,
+ reactNotebookCoreProofConsumers,
+ reactNotebookCoreProofRoot,
reactNotebookCoreBoundaryFiles,
forbiddenModulePrefixes,
forbiddenReactNotebookCoreConsumerModulePrefixes,
sourceFiles,
findReactNotebookConsumerViolations,
+ findNotebookCoreValueImportViolations,
+ findNotebookRemoteConsumerViolations,
findNotebookContractViolations,
findViolations,
formatViolations,
@@ -32,6 +37,18 @@ import {
} from './import-boundary';
describe('notebook core import boundary', () => {
+ it('runs the browser-dependent separate-build proof with the browser e2e
gate', () => {
+ const pom = readFileSync(resolve(zeppelinWebAngularRoot, 'pom.xml'),
'utf8');
+ 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']) {
+ 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>/);
+ }
+ });
+
it('resolves the React public contract without exposing source subpaths', ()
=> {
const path = reactNotebookCoreBoundaryFiles[1];
const options = readCompilerOptions(resolve(zeppelinWebAngularRoot,
'projects/zeppelin-react/tsconfig.json'));
@@ -135,10 +152,12 @@ describe('notebook core import boundary', () => {
}
);
- it('rejects direct transport imports in the React entry point and notebook
core contract', () => {
- const violations = reactNotebookCoreBoundaryFiles.flatMap(path => {
+ it('rejects direct transport imports in the React entry point, contract
bridge and remote probe', () => {
+ const violations = [...reactNotebookCoreBoundaryFiles,
reactNotebookCoreProofConsumer].flatMap(path => {
const source = readFileSync(path, 'utf8');
- return findViolations(path, source,
forbiddenReactNotebookCoreConsumerModulePrefixes);
+ return path === reactNotebookCoreProofConsumer
+ ? findNotebookRemoteConsumerViolations(path, source)
+ : findViolations(path, source,
forbiddenReactNotebookCoreConsumerModulePrefixes);
});
expect(formatViolations(violations)).toEqual([]);
@@ -150,6 +169,217 @@ describe('notebook core import boundary', () => {
expect(findReactNotebookConsumerViolations()).toEqual([]);
}, 30_000);
+ it('rejects a runtime or factory import in the separate-build remote probe',
() => {
+ expect(
+ findNotebookRemoteConsumerViolations(
+ reactNotebookCoreProofConsumer,
+ "import { createNotebookCore } from '@zeppelin/notebook-core';"
+ )
+ ).toEqual([
+ `${reactNotebookCoreProofConsumer}: runtime notebook core import`,
+ `${reactNotebookCoreProofConsumer}: remote must import only
NotebookCorePort contract types`
+ ]);
+ expect(
+ findNotebookCoreValueImportViolations(
+ resolve(zeppelinWebAngularRoot,
'e2e/core-contract/react-remote/helper.ts'),
+ "export { createNotebookCore } from '@zeppelin/notebook-core';"
+ )
+ ).toEqual([
+ `${resolve(zeppelinWebAngularRoot,
'e2e/core-contract/react-remote/helper.ts')}: runtime notebook core import`
+ ]);
+ });
+
+ it('rejects require.resolve of the core runtime', () => {
+ expect(
+ findNotebookCoreValueImportViolations(
+ reactNotebookCoreProofConsumer,
+ "export const corePath = require.resolve('@zeppelin/notebook-core');"
+ )
+ ).toEqual([`${reactNotebookCoreProofConsumer}: runtime notebook core
import`]);
+ });
+
+ it.each([
+ "import '../../../projects/zeppelin-notebook-core/src/public-api';",
+ "export { createNotebookCore } from
'../../../projects/zeppelin-notebook-core/src/public-api';"
+ ])('rejects a runtime core dependency by its resolved target: %s', source =>
{
+ const path = resolve(zeppelinWebAngularRoot,
'e2e/core-contract/react-remote/helper.ts');
+ expect(findNotebookCoreValueImportViolations(path,
source)).toEqual([`${path}: runtime notebook core import`]);
+ });
+
+ it('allows a type-only core dependency by its resolved target and restricts
its public contract types', () => {
+ const path = reactNotebookCoreProofConsumer;
+ const target = resolve(sourceRoot, 'public-api.ts');
+ const relativeTarget = `./${relative(dirname(path), target).replace(/\\/g,
'/').replace(/\.ts$/, '')}`;
+ const source = `import type { NotebookCorePort } from
'${relativeTarget}';\nexport type Port = NotebookCorePort;`;
+
+ expect(findNotebookCoreValueImportViolations(path, source)).toEqual([]);
+ expect(findNotebookRemoteConsumerViolations(path, source)).toEqual([]);
+
+ expect(
+ findNotebookRemoteConsumerViolations(path, `export type Port =
import('${relativeTarget}').NotebookCorePort;`)
+ ).toEqual([]);
+
+ const invalidSource = `import type { NotebookCoreUnsubscribe } from
'${relativeTarget}';`;
+ expect(findNotebookRemoteConsumerViolations(path,
invalidSource)).toContain(
+ `${path}: remote notebook core import NotebookCoreUnsubscribe`
+ );
+ expect(
+ findNotebookRemoteConsumerViolations(
+ path,
+ `export type Unsubscribe =
import('${relativeTarget}').NotebookCoreUnsubscribe;`
+ )
+ ).toContain(`${path}: remote notebook core import
NotebookCoreUnsubscribe`);
+ });
+
+ it('enforces the core boundary for built-package aliases and relative
targets', () => {
+ const path = reactNotebookCoreProofConsumer;
+ const builtDeclaration = resolve(
+ zeppelinWebAngularRoot,
+ 'dist/zeppelin-notebook-core/types/zeppelin-notebook-core.d.ts'
+ );
+ const options = {
+ ...readCompilerOptions(resolve(zeppelinWebAngularRoot,
'projects/zeppelin-react/tsconfig.json')),
+ baseUrl: zeppelinWebAngularRoot,
+ paths: { '@built-core':
['dist/zeppelin-notebook-core/types/zeppelin-notebook-core.d.ts'] }
+ };
+ const host = createFixtureHost(
+ options,
+ new Map([
+ [
+ builtDeclaration,
+ 'export type NotebookCorePort = Readonly<{}>; export declare const
createNotebookCore: () => void;'
+ ]
+ ])
+ );
+ const relativeTarget = relative(dirname(path), builtDeclaration)
+ .replace(/\\/g, '/')
+ .replace(/\.d\.ts$/, '');
+
+ for (const source of [
+ "import '@built-core';",
+ "export { createNotebookCore } from '@built-core';",
+ `import '${relativeTarget.startsWith('.') ? relativeTarget :
`./${relativeTarget}`}';`
+ ]) {
+ expect(findNotebookCoreValueImportViolations(path, source, options,
host)).toEqual([
+ `${path}: runtime notebook core import`
+ ]);
+ }
+ expect(
+ findNotebookRemoteConsumerViolations(
+ path,
+ "import type { NotebookCorePort } from '@built-core'; export type Port
= NotebookCorePort;",
+ options,
+ host
+ )
+ ).toEqual([]);
+ expect(
+ findNotebookRemoteConsumerViolations(
+ path,
+ "import type { createNotebookCore } from '@built-core';",
+ options,
+ host
+ )
+ ).toContain(`${path}: remote notebook core import createNotebookCore`);
+ }, 30_000);
+
+ it('rejects a transitive runtime re-export of the core reached through a
consumer helper', () => {
+ const root = resolve(zeppelinWebAngularRoot,
'projects/zeppelin-react/src');
+ const consumer = resolve(root, 'NotebookBoundaryFixture.tsx');
+ const helper = resolve(root, 'NotebookBoundaryHelper.ts');
+ const publicApi = resolve(sourceRoot, 'public-api.ts');
+ const helperTarget = relative(dirname(helper), publicApi).replace(/\\/g,
'/').replace(/\.ts$/, '');
+ const files = new Map<string, string>([
+ [
+ consumer,
+ "import type { NotebookCoreRemoteProps } from
'./notebookCoreContract'; export type Props = NotebookCoreRemoteProps; export *
from './NotebookBoundaryHelper';"
+ ],
+ [
+ helper,
+ `export { createNotebookCore } from '${helperTarget.startsWith('.') ?
helperTarget : `./${helperTarget}`}';`
+ ]
+ ]);
+ const options = readCompilerOptions(resolve(root, '../tsconfig.json'));
+ const host = createFixtureHost(options, files);
+
+
expect(findReactNotebookConsumerViolations([...reactNotebookCoreBoundaryFiles,
consumer], options, host)).toContain(
+ `${helper}: runtime notebook core import`
+ );
+ }, 30_000);
+
+ it('rejects a transitive runtime re-export through a resolved node_modules
package', () => {
+ const wrapper = resolve(zeppelinWebAngularRoot,
'node_modules/zeppelin-notebook-boundary-fixture/index.ts');
+ const wrapperSpecifier = relative(dirname(reactNotebookCoreProofConsumer),
wrapper).replace(/\\/g, '/');
+ const source = `import type { NotebookCorePort } from
'@zeppelin/notebook-core'; export type Port = NotebookCorePort; export * from
'${
+ wrapperSpecifier.startsWith('.') ? wrapperSpecifier :
`./${wrapperSpecifier}`
+ }';`;
+ const options =
readCompilerOptions(resolve(dirname(reactNotebookCoreProofConsumer),
'tsconfig.json'));
+ const violationsWithWrapper = (wrapperSource: string): string[] =>
+ findReactNotebookConsumerViolations(
+ [reactNotebookCoreProofConsumer],
+ options,
+ createFixtureHost(
+ options,
+ new Map([
+ [reactNotebookCoreProofConsumer, source],
+ [wrapper, wrapperSource]
+ ])
+ )
+ );
+
+ expect(violationsWithWrapper("export { createNotebookCore } from
'@zeppelin/notebook-core';\n")).toContain(
+ `${wrapper}: runtime notebook core import`
+ );
+ expect(violationsWithWrapper("export type { NotebookCorePort } from
'@zeppelin/notebook-core';\n")).toEqual([]);
+ }, 30_000);
+
+ it('restricts notebook core types throughout the separate remote dependency
graph', () => {
+ const helper = resolve(dirname(reactNotebookCoreProofConsumer),
'contract-helper.ts');
+ const files = new Map<string, string>([
+ [
+ reactNotebookCoreProofConsumer,
+ "import type { Factory } from './contract-helper'; export type
RemoteFactory = Factory;"
+ ],
+ [helper, "export type Factory =
import('@zeppelin/notebook-core').NotebookCoreUnsubscribe;"]
+ ]);
+ const options =
readCompilerOptions(resolve(dirname(reactNotebookCoreProofConsumer),
'tsconfig.json'));
+ const host = createFixtureHost(options, files);
+
+ expect(
+ ts
+
.getPreEmitDiagnostics(ts.createProgram([reactNotebookCoreProofConsumer],
options, host))
+ .filter(diagnostic => (diagnostic.file ?
files.has(diagnostic.file.fileName) : false))
+ ).toEqual([]);
+
expect(findReactNotebookConsumerViolations([reactNotebookCoreProofConsumer],
options, host)).toContain(
+ `${helper}: remote notebook core import NotebookCoreUnsubscribe`
+ );
+ }, 30_000);
+
+ it('scans every source of the separate-build remote, not only the probe', ()
=> {
+ const consumers = reactNotebookCoreProofConsumers();
+ expect(consumers).toContain(reactNotebookCoreProofConsumer);
+ expect(consumers).toContain(resolve(reactNotebookCoreProofRoot,
'empty.ts'));
+ expect(
+ consumers.filter(path =>
+
/^(?:dist|build|node_modules)\/|webpack\.config/.test(relative(reactNotebookCoreProofRoot,
path))
+ )
+ ).toEqual([]);
+
+ const exposed = resolve(reactNotebookCoreProofRoot, 'SecondProbe.tsx');
+ const options = readCompilerOptions(resolve(reactNotebookCoreProofRoot,
'tsconfig.json'));
+ const host = createFixtureHost(
+ options,
+ new Map([
+ [
+ exposed,
+ "import type { NotebookCoreUnsubscribe } from
'@zeppelin/notebook-core'; export type U = NotebookCoreUnsubscribe;"
+ ]
+ ])
+ );
+ expect(findReactNotebookConsumerViolations([exposed], options,
host)).toContain(
+ `${exposed}: remote notebook core import NotebookCoreUnsubscribe`
+ );
+ }, 30_000);
+
it.each(['direct', 'helper', 'route', 'barrel', 'javascript', 'cycle',
'computed', 'require-outside'])(
'discovers a new notebook consumer and rejects its %s transport
dependency',
form => {
@@ -183,7 +413,11 @@ describe('notebook core import boundary', () => {
const options = readCompilerOptions(resolve(root, '../tsconfig.json'));
const host = createFixtureHost(options, files);
const roots = [consumer, route, barrel,
...reactNotebookCoreBoundaryFiles];
- expect(ts.getPreEmitDiagnostics(ts.createProgram(roots, options,
host))).toEqual([]);
+ expect(
+ ts
+ .getPreEmitDiagnostics(ts.createProgram(roots, options, host))
+ .filter(diagnostic => (diagnostic.file ?
files.has(diagnostic.file.fileName) : false))
+ ).toEqual([]);
const violations = findReactNotebookConsumerViolations(roots, options,
host);
const offender = ['helper', 'javascript', 'cycle',
'require-outside'].includes(form)
? helper
@@ -193,7 +427,8 @@ describe('notebook core import boundary', () => {
expect(violations).toContain(
`${offender}: import ${form === 'computed' ? '<computed module
dependency>' : 'rxjs/webSocket'}`
);
- }
+ },
+ 30_000
);
it.each([
@@ -202,16 +437,20 @@ describe('notebook core import boundary', () => {
["export type { ClientHttp2Session } from 'node:http2';", 'import
node:http2'],
['export const request = fetch;', 'global fetch'],
['export const socket = WebSocket;', 'global WebSocket']
- ])('rejects transport access in a consumer of the public core API: %s',
(transport, violation) => {
- const root = resolve(zeppelinWebAngularRoot,
'projects/zeppelin-react/src');
- const consumer = resolve(root, 'NotebookBoundaryFixture.tsx');
- const source = `import type { NotebookCoreRemoteProps } from
'@zeppelin/notebook-core';
+ ])(
+ 'rejects transport access in a consumer of the public core API: %s',
+ (transport, violation) => {
+ const root = resolve(zeppelinWebAngularRoot,
'projects/zeppelin-react/src');
+ const consumer = resolve(root, 'NotebookBoundaryFixture.tsx');
+ const source = `import type { NotebookCoreRemoteProps } from
'@zeppelin/notebook-core';
export const read = (props: NotebookCoreRemoteProps) =>
props.core.getSnapshot(); ${transport}`;
- const options = readCompilerOptions(resolve(root, '../tsconfig.json'));
- const host = createFixtureHost(options, new Map([[consumer, source]]));
- expect(ts.getPreEmitDiagnostics(ts.createProgram([consumer], options,
host))).toEqual([]);
- expect(findReactNotebookConsumerViolations([consumer], options,
host)).toContain(`${consumer}: ${violation}`);
- });
+ const options = readCompilerOptions(resolve(root, '../tsconfig.json'));
+ const host = createFixtureHost(options, new Map([[consumer, source]]));
+ expect(ts.getPreEmitDiagnostics(ts.createProgram([consumer], options,
host))).toEqual([]);
+ expect(findReactNotebookConsumerViolations([consumer], options,
host)).toContain(`${consumer}: ${violation}`);
+ },
+ 30_000
+ );
it('rejects the mixed public aggregator as an internal notebook consumer
dependency', () => {
const root = resolve(zeppelinWebAngularRoot,
'projects/zeppelin-react/src');
@@ -222,7 +461,7 @@ describe('notebook core import boundary', () => {
expect(findReactNotebookConsumerViolations([consumer], options,
host)).toContain(
`${consumer}: notebook consumer must use the contract bridge instead of
the public aggregator`
);
- });
+ }, 30_000);
it('allows a neutral notebook consumer without traversing unrelated legacy
page exports', () => {
const root = resolve(zeppelinWebAngularRoot,
'projects/zeppelin-react/src');
@@ -234,7 +473,7 @@ describe('notebook core import boundary', () => {
expect(findReactNotebookConsumerViolations([...reactNotebookCoreBoundaryFiles,
consumer], options, host)).toEqual(
[]
);
- });
+ }, 30_000);
it('keeps the React contract dependent only on the public core entry point',
() => {
const path = reactNotebookCoreBoundaryFiles[1];
@@ -451,15 +690,19 @@ describe('notebook core import boundary', () => {
['let fetch: typeof window.fetch; ({ fetch } = window); export const
request = fetch;', 'global fetch'],
['export const browser = { fetch };', 'global fetch'],
['type fetch = string; export const request = fetch;', 'global fetch']
- ])('rejects wrapped transport globals: %s', (source, violation) => {
- const path = reactNotebookCoreBoundaryFiles[1];
- const options = readCompilerOptions(resolve(dirname(path),
'../tsconfig.json'));
- const host = createFixtureHost(options, new Map([[path, source]]));
- expect(ts.getPreEmitDiagnostics(ts.createProgram([path], options,
host))).toEqual([]);
- expect(findViolations(path, source,
forbiddenReactNotebookCoreConsumerModulePrefixes)).toContain(
- `${path}: ${violation}`
- );
- });
+ ])(
+ 'rejects wrapped transport globals: %s',
+ (source, violation) => {
+ const path = reactNotebookCoreBoundaryFiles[1];
+ const options = readCompilerOptions(resolve(dirname(path),
'../tsconfig.json'));
+ const host = createFixtureHost(options, new Map([[path, source]]));
+ expect(ts.getPreEmitDiagnostics(ts.createProgram([path], options,
host))).toEqual([]);
+ expect(findViolations(path, source,
forbiddenReactNotebookCoreConsumerModulePrefixes)).toContain(
+ `${path}: ${violation}`
+ );
+ },
+ 30_000
+ );
it.each([
"export const fetch = () => 'cached'; export const result = fetch();",
diff --git a/zeppelin-web-angular/test/notebook-core/import-boundary.ts
b/zeppelin-web-angular/test/notebook-core/import-boundary.ts
index b99b0a811a..1fcfa4928b 100644
--- a/zeppelin-web-angular/test/notebook-core/import-boundary.ts
+++ b/zeppelin-web-angular/test/notebook-core/import-boundary.ts
@@ -18,6 +18,8 @@ import ts from 'typescript';
export const zeppelinWebAngularRoot = resolve(fileURLToPath(new URL('../../',
import.meta.url)));
export const sourceRoot = fileURLToPath(new
URL('../../projects/zeppelin-notebook-core/src/', import.meta.url));
+export const reactNotebookCoreProofRoot = resolve(zeppelinWebAngularRoot,
'e2e/core-contract/react-remote');
+export const reactNotebookCoreProofConsumer =
resolve(reactNotebookCoreProofRoot, 'NotebookCorePortProbe.tsx');
export const reactNotebookCoreBoundaryFiles = [
resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src/main.ts'),
resolve(zeppelinWebAngularRoot,
'projects/zeppelin-react/src/notebookCoreContract.ts')
@@ -69,26 +71,50 @@ const isCheckedSourceFile = (path: string): boolean => {
return checkedSourceExtensions.some(extension => path.endsWith(extension))
&& !isSpecSourceFile(path);
};
+const isReactNotebookCoreProofModule = (path: string): boolean =>
+ path.startsWith(`${reactNotebookCoreProofRoot}/`) &&
!path.includes('/node_modules/');
+
+// Any source in the separate-build remote can be exposed, so scan the
directory
+// instead of one probe. Build output and webpack configuration are not remote
code.
+export const reactNotebookCoreProofConsumers = (): string[] =>
+ sourceFiles(
+ reactNotebookCoreProofRoot,
+ path =>
+ /\.[cm]?[jt]sx?$/.test(path) &&
+ !isSpecSourceFile(path) &&
+ !/^(?:(?:dist|build|node_modules)\/|webpack\.config\.[cm]?js$)/.test(
+ relative(reactNotebookCoreProofRoot, path).replace(/\\/g, '/')
+ )
+ );
+
export const findReactNotebookConsumerViolations = (
- roots: string[] = sourceFiles(
- resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src'),
- path => /\.[cm]?[jt]sx?$/.test(path) && !isSpecSourceFile(path)
- ),
+ roots: string[] = [
+ ...sourceFiles(
+ resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src'),
+ path => /\.[cm]?[jt]sx?$/.test(path) && !isSpecSourceFile(path)
+ ),
+ ...reactNotebookCoreProofConsumers()
+ ],
options = readCompilerOptions(resolve(zeppelinWebAngularRoot,
'projects/zeppelin-react/tsconfig.json')),
host: ts.CompilerHost = ts.createCompilerHost(options)
): string[] => {
const program = ts.createProgram(roots, options, host);
const modules = new Map<string, { source: string; dependencies: Set<string>
}>();
- const pending = [...program.getSourceFiles()];
- for (const file of pending) {
- if (modules.has(file.fileName) ||
file.fileName.includes('/node_modules/')) {
- continue;
+ const loadModule = (path: string, source?: string): { source: string;
dependencies: Set<string> } | undefined => {
+ const existing = modules.get(path);
+ if (existing) {
+ return existing;
+ }
+ const text = source ?? host.readFile(path);
+ if (text === undefined) {
+ return undefined;
}
+ const file = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true,
getScriptKind(path));
const dependencies = new Set<string>();
const visit = (node: ts.Node): void => {
const specifier = getModuleSpecifier(node);
- if (specifier) {
- const target = ts.resolveModuleName(specifier, file.fileName, options,
host).resolvedModule?.resolvedFileName;
+ if (specifier && specifier !== '<computed module dependency>') {
+ const target = ts.resolveModuleName(specifier, path, options,
host).resolvedModule?.resolvedFileName;
if (target) {
dependencies.add(target);
}
@@ -97,16 +123,26 @@ export const findReactNotebookConsumerViolations = (
};
visit(file);
for (const reference of file.referencedFiles) {
- dependencies.add(resolve(dirname(file.fileName), reference.fileName));
+ dependencies.add(resolve(dirname(path), reference.fileName));
}
for (const reference of file.typeReferenceDirectives) {
- const target = ts.resolveTypeReferenceDirective(reference.fileName,
file.fileName, options, host)
+ const target = ts.resolveTypeReferenceDirective(reference.fileName,
path, options, host)
.resolvedTypeReferenceDirective?.resolvedFileName;
if (target) {
dependencies.add(target);
}
}
- modules.set(file.fileName, { source: file.text, dependencies });
+ const loaded = { source: text, dependencies };
+ modules.set(path, loaded);
+ return loaded;
+ };
+ const pending = [...program.getSourceFiles()];
+ for (const file of pending) {
+ if (modules.has(file.fileName) ||
file.fileName.includes('/node_modules/')) {
+ continue;
+ }
+ const module = loadModule(file.fileName, file.text);
+ const dependencies = module?.dependencies ?? new Set<string>();
// TypeScript can resolve require() without adding its target to the
program.
// Inspect those local sources too, including helpers outside the root
list.
for (const target of dependencies) {
@@ -122,7 +158,8 @@ export const findReactNotebookConsumerViolations = (
// Discover adapters, re-export barrels and routes from their dependency on
the
// shared contract. A new consumer must not require editing a scanner file
list.
const consumers = new Set([
- ...reactNotebookCoreBoundaryFiles,
+ ...reactNotebookCoreBoundaryFiles.filter(path => modules.has(path)),
+ ...[...modules.keys()].filter(isReactNotebookCoreProofModule),
...[...modules.keys()].filter(path => path.startsWith(sourceRoot))
]);
let changed = true;
@@ -139,24 +176,42 @@ export const findReactNotebookConsumerViolations = (
const violations: string[] = [];
const checked = new Set<string>();
const main = reactNotebookCoreBoundaryFiles[0];
+ const remoteModules = new Set<string>();
+ const collectRemoteModules = (path: string): void => {
+ if (remoteModules.has(path) || path.startsWith(sourceRoot) ||
path.includes('/node_modules/')) {
+ return;
+ }
+ remoteModules.add(path);
+ loadModule(path)?.dependencies.forEach(collectRemoteModules);
+ };
+ // Start from remote sources in the checked program only; package graphs are
not remote code.
+
[...modules.keys()].filter(isReactNotebookCoreProofModule).forEach(collectRemoteModules);
const check = (path: string): void => {
if (checked.has(path) || path.startsWith(sourceRoot)) {
return;
}
checked.add(path);
- const module = modules.get(path);
+ const module = loadModule(path);
if (!module) {
- if (!path.includes('/node_modules/')) {
- violations.push(`${path}: cannot inspect local notebook dependency`);
- }
+ violations.push(`${path}: cannot inspect notebook dependency`);
return;
}
+ // Third-party declarations are only checked for re-exporting the core
runtime;
+ // consumer transport rules apply to Zeppelin sources, not to package
internals.
violations.push(
- ...findViolations(path, module.source,
forbiddenReactNotebookCoreConsumerModulePrefixes, options, host)
+ ...(path.includes('/node_modules/')
+ ? findNotebookCoreValueImportViolations(path, module.source, options,
host)
+ : remoteModules.has(path)
+ ? findNotebookRemoteConsumerViolations(path, module.source, options,
host)
+ : [
+ ...findViolations(path, module.source,
forbiddenReactNotebookCoreConsumerModulePrefixes, options, host),
+ ...findNotebookCoreValueImportViolations(path, module.source,
options, host)
+ ])
);
// The public aggregator also exports existing SDK-backed pages. Check its
// own imports, but do not include those unrelated pages in the core
boundary.
- if (path === main) {
+ // Package declarations are checked for their own re-exports only.
+ if (path === main || path.includes('/node_modules/')) {
return;
}
for (const target of module.dependencies) {
@@ -171,6 +226,127 @@ export const findReactNotebookConsumerViolations = (
return violations;
};
+const notebookRemoteContractTypes = new Set(['NotebookCorePort',
'NotebookCoreRemoteProps', 'NotebookCoreSnapshot']);
+
+const isTypeOnlyImportDeclaration = (node: ts.ImportDeclaration): boolean => {
+ const clause = node.importClause;
+ const bindings = clause?.namedBindings;
+ return (
+ clause?.isTypeOnly === true ||
+ (clause?.name === undefined &&
+ bindings !== undefined &&
+ ts.isNamedImports(bindings) &&
+ bindings.elements.length > 0 &&
+ bindings.elements.every(binding => binding.isTypeOnly))
+ );
+};
+
+export const findNotebookCoreValueImportViolations = (
+ path: string,
+ source: string,
+ compilerOptions = readCompilerOptions(resolve(zeppelinWebAngularRoot,
'projects/zeppelin-react/tsconfig.json')),
+ resolutionHost: ts.ModuleResolutionHost = ts.sys
+): string[] => {
+ const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest,
true, getScriptKind(path));
+ const violations: string[] = [];
+ const visit = (node: ts.Node): void => {
+ const specifier = getModuleSpecifier(node);
+ if (specifier && resolvesToNotebookCore(specifier, path, compilerOptions,
resolutionHost)) {
+ const typeOnlyImport =
+ ts.isImportTypeNode(node) ||
+ (ts.isImportDeclaration(node) && isTypeOnlyImportDeclaration(node)) ||
+ (ts.isExportDeclaration(node) &&
+ (node.isTypeOnly ||
+ (node.exportClause !== undefined &&
+ ts.isNamedExports(node.exportClause) &&
+ node.exportClause.elements.every(binding =>
binding.isTypeOnly))));
+ if (!typeOnlyImport) {
+ violations.push(`${path}: runtime notebook core import`);
+ }
+ }
+ ts.forEachChild(node, visit);
+ };
+
+ visit(sourceFile);
+ return violations;
+};
+
+export const findNotebookRemoteConsumerViolations = (
+ path: string,
+ source: string,
+ compilerOptions = readCompilerOptions(resolve(zeppelinWebAngularRoot,
'projects/zeppelin-react/tsconfig.json')),
+ resolutionHost: ts.ModuleResolutionHost = ts.sys
+): string[] => {
+ const violations = findViolations(
+ path,
+ source,
+ forbiddenReactNotebookCoreConsumerModulePrefixes,
+ compilerOptions,
+ resolutionHost
+ );
+ violations.push(...findNotebookCoreValueImportViolations(path, source,
compilerOptions, resolutionHost));
+ const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest,
true, getScriptKind(path));
+
+ for (const statement of sourceFile.statements) {
+ if (!ts.isImportDeclaration(statement)) {
+ continue;
+ }
+ const specifier = getModuleSpecifier(statement);
+ if (!specifier || !resolvesToNotebookCore(specifier, path,
compilerOptions, resolutionHost)) {
+ continue;
+ }
+ const clause = statement.importClause;
+ const bindings = clause?.namedBindings;
+
+ if (!isTypeOnlyImportDeclaration(statement) || clause?.name || !bindings
|| !ts.isNamedImports(bindings)) {
+ violations.push(`${path}: remote must import only NotebookCorePort
contract types`);
+ continue;
+ }
+ for (const binding of bindings.elements) {
+ const importedName = binding.propertyName?.text ?? binding.name.text;
+ if (!notebookRemoteContractTypes.has(importedName)) {
+ violations.push(`${path}: remote notebook core import
${importedName}`);
+ }
+ }
+ }
+
+ const visit = (node: ts.Node): void => {
+ const specifier = getModuleSpecifier(node);
+ if (
+ specifier &&
+ !ts.isImportDeclaration(node) &&
+ resolvesToNotebookCore(specifier, path, compilerOptions, resolutionHost)
+ ) {
+ if (ts.isImportTypeNode(node) && node.qualifier &&
ts.isIdentifier(node.qualifier)) {
+ if (!notebookRemoteContractTypes.has(node.qualifier.text)) {
+ violations.push(`${path}: remote notebook core import
${node.qualifier.text}`);
+ }
+ } else if (
+ ts.isExportDeclaration(node) &&
+ (node.isTypeOnly ||
+ (node.exportClause &&
+ ts.isNamedExports(node.exportClause) &&
+ node.exportClause.elements.every(binding => binding.isTypeOnly)))
&&
+ node.exportClause &&
+ ts.isNamedExports(node.exportClause)
+ ) {
+ for (const binding of node.exportClause.elements) {
+ const importedName = binding.propertyName?.text ?? binding.name.text;
+ if (!notebookRemoteContractTypes.has(importedName)) {
+ violations.push(`${path}: remote notebook core import
${importedName}`);
+ }
+ }
+ } else {
+ violations.push(`${path}: remote must import only NotebookCorePort
contract types`);
+ }
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(sourceFile);
+
+ return violations;
+};
+
// This type-only bridge has one dependency. Rejecting other imports also
prevents
// local helpers from hiding transport re-exports without restricting existing
pages.
export const findNotebookContractViolations = (path: string, source: string):
string[] => {
@@ -337,7 +513,11 @@ const getModuleSpecifier = (node: ts.Node): string | null
=> {
ts.isCallExpression(node) &&
node.arguments.length >= 1 &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
- (ts.isIdentifier(node.expression) && node.expression.text === 'require'))
+ (ts.isIdentifier(node.expression) && node.expression.text === 'require')
||
+ (ts.isPropertyAccessExpression(node.expression) &&
+ ts.isIdentifier(node.expression.expression) &&
+ node.expression.expression.text === 'require' &&
+ node.expression.name.text === 'resolve'))
) {
let argument = node.arguments[0];
while (ts.isParenthesizedExpression(argument)) {
@@ -403,6 +583,17 @@ const moduleIdentities = (
return identities;
};
+const resolvesToNotebookCore = (
+ specifier: string,
+ path: string,
+ compilerOptions: ts.CompilerOptions,
+ resolutionHost: ts.ModuleResolutionHost
+): boolean => {
+ return moduleIdentities(specifier, path, compilerOptions,
resolutionHost).some(identity =>
+ matchesModulePrefix(identity, '@zeppelin/notebook-core')
+ );
+};
+
const matchesModulePrefix = (moduleSpecifier: string, prefix: string): boolean
=> {
return moduleSpecifier === prefix ||
moduleSpecifier.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`);
};