diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml
index e7da04d72d5..3e6f4ba206d 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 d1595667a75..9062ad69a8e 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 00000000000..34aa7aed455
--- /dev/null
+++ b/zeppelin-web-angular/e2e/core-contract/angular-host/index.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+ Notebook core port proof
+
+
+
+
+
+
+
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 00000000000..447d1cccd3d
--- /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: `
+
+
+ `
+})
+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 00000000000..e637f8f629e
--- /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 00000000000..f75c774dfa6
--- /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 00000000000..43b304b917f
--- /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 (
+
+ {snapshot.noteId}
+ {snapshot.revisionId ?? 'live'}
+
+ );
+};
+
+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();
+ };
+
+ renderWith(initialProps);
+
+ return {
+ update: (newProps: NotebookCorePortProbeProps) => {
+ renderWith(newProps);
+ },
+ unmount: () => {
+ root.unmount();
+ }
+ };
+};
diff --git a/zeppelin-web-angular/e2e/core-contract/react-remote/empty.ts b/zeppelin-web-angular/e2e/core-contract/react-remote/empty.ts
new file mode 100644
index 00000000000..7afbc20c3ed
--- /dev/null
+++ b/zeppelin-web-angular/e2e/core-contract/react-remote/empty.ts
@@ -0,0 +1,13 @@
+/*
+ * 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.
+ */
+
+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 00000000000..b8084eda2b9
--- /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 00000000000..d5266dd47a2
--- /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 00000000000..9ea8927a3a2
--- /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 492b631cb84..0b6cf15458a 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 93b28c184ec..ad6de579980 100644
--- a/zeppelin-web-angular/pom.xml
+++ b/zeppelin-web-angular/pom.xml
@@ -35,6 +35,8 @@
false
true
+
+ ${web.e2e.disabled}
../bin
UTF-8
@@ -188,6 +190,31 @@
+
+ npm build notebook core port identity proof
+
+ npm
+
+ integration-test
+
+
+ ${web.e2e.core.port.proof.disabled}
+ run build:notebook-core-port-proof
+
+
+
+
+ npm test notebook core port identity
+
+ npm
+
+ integration-test
+
+ ${web.e2e.core.port.proof.disabled}
+ run test:notebook-core-port-identity
+
+
+
npm e2e
diff --git a/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts b/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
index 7b1ad5f9981..c6f03703610 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): 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 be3fc00f56b..e8e957ad479 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\.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(`\\s*${id}([\\s\\S]*?)`))?.[1];
+ expect(execution).toMatch(/integration-test<\/phase>/);
+ expect(execution).toMatch(/\$\{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([
+ [
+ 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([
+ [
+ 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' ? '' : '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 b99b0a811a5..1fcfa4928bd 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 }>();
- 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 } | 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();
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 !== '') {
+ 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();
// 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();
const main = reactNotebookCoreBoundaryFiles[0];
+ const remoteModules = new Set();
+ 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}/`);
};