diff --git a/zeppelin-web-angular/AGENTS.md b/zeppelin-web-angular/AGENTS.md
index 92b08f597aa..29aab368876 100644
--- a/zeppelin-web-angular/AGENTS.md
+++ b/zeppelin-web-angular/AGENTS.md
@@ -17,7 +17,7 @@ limitations under the License.
# AGENTS.md
-Unit test conventions for this package. They apply to the Angular shell in `src/`, package-level infrastructure specs under `test/`, and the libraries under `projects/` that have no file of their own: `zeppelin-sdk`, which is framework-neutral, and `zeppelin-visualization`, which is mostly so apart from one `@Component` base class.
+Unit test conventions for this package. They apply to the Angular shell in `src/`, package-level infrastructure specs under `test/`, and the libraries under `projects/` that have no file of their own: `zeppelin-notebook-core` and `zeppelin-sdk`, which are framework-neutral, and `zeppelin-visualization`, which is mostly so apart from one `@Component` base class.
Two subtrees override this file: [`e2e/AGENTS.md`](e2e/AGENTS.md) for the Playwright suite, and [`projects/zeppelin-react/AGENTS.md`](projects/zeppelin-react/AGENTS.md) for the React remote, which has a different CI status and one exception of its own.
@@ -26,8 +26,9 @@ The repository root `AGENTS.md` asks every change to include unit tests. This fi
## Layout
- A product-code spec lives next to its source: `foo.ts` / `foo.spec.ts`. Specs for package-level test and reporting infrastructure live under `test/`.
-- The runner is Vitest on jsdom. There is no Karma. `test/test-setup.ts` loads `zone.js` and reflection metadata, initializes Angular `TestBed`, and resets the test environment after each spec.
+- The shell runner is Vitest on jsdom. There is no Karma. `test/test-setup.ts` loads `zone.js` and reflection metadata, initializes Angular `TestBed`, and resets the test environment after each spec.
- `npm run test:shell` covers `src/`, package-level specs under `test/`, `projects/zeppelin-sdk` and `projects/zeppelin-visualization`. The two libraries have no runner of their own; they ride on the shell config because their code needs nothing extra. `projects/zeppelin-react` is separate. It has its own Vitest config and its own file here.
+- `projects/zeppelin-notebook-core` has a dedicated Vitest config (`vitest.notebook-core.config.mts`) using Node without Angular test setup. It covers the package's contract specs and the dependency-boundary checks under `test/notebook-core`. These specs are not part of the shell or React suites. The core compiler excludes DOM libraries and application path aliases.
## Running
@@ -36,8 +37,10 @@ The repository root `AGENTS.md` asks every change to include unit tests. This fi
| `npm run test:shell` | Run the unit tests for `src/`, `test/` and the two libraries |
| `npm run test:shell -- --coverage` | Same, with a coverage report |
| `npm run test:shell -- foo.spec.ts` | Run one file |
+| `npm run test:notebook-core` | Run the dedicated notebook-core Node suite |
+| `npm run typecheck:notebook-core` | Check core source and specs, rebuild the package, and check the React type-only contract against built declarations and the same core source |
-`test:shell` is bound to the Maven `test` phase (`pom.xml`), so a spec added here starts running in CI the day it merges. It does not run where you would expect. `frontend.yml` builds this module with `-DskipTests`, which frontend-maven-plugin honours by skipping `test`-phase executions, so the run that counts is `mvnw verify -Pweb-e2e` inside the `run-playwright-e2e-tests` job. A failing spec surfaces there, under an e2e job name. Giving the unit tests a step of their own is [ZEPPELIN-6566](https://issues.apache.org/jira/browse/ZEPPELIN-6566).
+`test:shell`, `test:notebook-core`, and `typecheck:notebook-core` are bound to the Maven `test` phase (`pom.xml`), so a spec added here starts running in CI the day it merges. It does not run where you would expect. `frontend.yml` builds this module with `-DskipTests`, which frontend-maven-plugin honours by skipping `test`-phase executions, so the run that counts is `mvnw verify -Pweb-e2e` inside the `run-playwright-e2e-tests` job. A failing spec surfaces there, under an e2e job name. Giving the unit tests a step of their own is [ZEPPELIN-6566](https://issues.apache.org/jira/browse/ZEPPELIN-6566).
## Where a test belongs
@@ -45,13 +48,15 @@ The frontend has two test layers, not three. There is no integration tier.
| Layer | Runner | Answers |
| --- | --- | --- |
-| Unit | Vitest + jsdom | Is the judgement we wrote correct? |
+| Unit | Vitest + jsdom (shell/React) or Node (notebook-core) | Is the judgement we wrote correct? |
| E2E | Playwright | Does the page actually work in a browser? |
Prefer a unit test when the question can be answered without a browser. Reach for e2e when the answer depends on wiring: routing, mounting a federated remote, authentication, or anything a user would have to click.
The two layers do not substitute for each other, and neither replaces the cross-framework parity checks the React migration needs.
+The notebook-core host/remote contract is currently a type-only scaffold; it does not implement a notebook runtime.
+
A third kind is planned but does not exist yet: contract specs that replay captured WebSocket traffic against the notebook runtime, arriving with [ZEPPELIN-6627](https://issues.apache.org/jira/browse/ZEPPELIN-6627). Those will run on Vitest as well and live under `test/contract/`, with the captured traffic beside them. Conventions for them are added once they exist.
## What to test
@@ -139,4 +144,4 @@ This is a different measurement from `e2e/reporter.coverage.ts`, which counts an
3. Import from `vitest` (`describe`, `expect`, `it`), not from Jasmine or Jest.
Check the target has callers before you invest in it. `get-keyword-positions.spec.ts` is a worked example of a function that turned out to have none.
4. Construct the class directly unless the behavior depends on Angular wiring; use `TestBed` when it does.
-5. Run `npm run test:shell` and confirm it passes before opening a PR.
+5. Run `npm run test:shell` for shell/SDK/visualization changes. For notebook-core changes, run `npm run test:notebook-core` and `npm run typecheck:notebook-core`. Confirm the relevant checks pass before opening a PR.
diff --git a/zeppelin-web-angular/README.md b/zeppelin-web-angular/README.md
index f90c26f32b7..ddd33502782 100644
--- a/zeppelin-web-angular/README.md
+++ b/zeppelin-web-angular/README.md
@@ -55,6 +55,10 @@ Run `npm run build` to build the project. The build artifacts will be stored in
Run `npm run test:shell` to execute the Angular shell unit tests via [Vitest](https://vitest.dev).
+Run `npm run test:notebook-core` for the dedicated Node-based Vitest suite. Contract specs live in `projects/zeppelin-notebook-core`; dependency-boundary checks live in `test/notebook-core`. Run `npm run typecheck:notebook-core` to check the core and its test infrastructure, rebuild the core package, and check the React contract against both built declarations and the same core source. The React contract is a type-only scaffold; it does not mount a notebook or implement a runtime.
+
+The React remote has a separate jsdom suite: run `npm test --prefix projects/zeppelin-react`. The shell and React suites do not include the notebook-core suite.
+
## Implementation Progress
### Pages
diff --git a/zeppelin-web-angular/angular.json b/zeppelin-web-angular/angular.json
index 0561cd77b29..5085eeede30 100644
--- a/zeppelin-web-angular/angular.json
+++ b/zeppelin-web-angular/angular.json
@@ -177,6 +177,27 @@
}
}
},
+ "zeppelin-notebook-core": {
+ "projectType": "library",
+ "root": "projects/zeppelin-notebook-core",
+ "sourceRoot": "projects/zeppelin-notebook-core/src",
+ "prefix": "lib",
+ "architect": {
+ "build": {
+ "builder": "@angular-devkit/build-angular:ng-packagr",
+ "options": {
+ "tsConfig": "projects/zeppelin-notebook-core/tsconfig.json",
+ "project": "projects/zeppelin-notebook-core/ng-package.json"
+ }
+ },
+ "lint": {
+ "builder": "@angular-eslint/builder:lint",
+ "options": {
+ "lintFilePatterns": ["projects/zeppelin-notebook-core/**/*.ts"]
+ }
+ }
+ }
+ },
"zeppelin-sdk": {
"projectType": "library",
"root": "projects/zeppelin-sdk",
diff --git a/zeppelin-web-angular/eslint.config.js b/zeppelin-web-angular/eslint.config.js
index fa0e046c595..c7d9da3b246 100644
--- a/zeppelin-web-angular/eslint.config.js
+++ b/zeppelin-web-angular/eslint.config.js
@@ -163,9 +163,11 @@ module.exports = tseslint.config(
// *.spec.ts. Point type-aware linting at the spec program explicitly.
files: [
'src/**/*.spec.ts',
- 'projects/zeppelin-{sdk,visualization}/**/*.spec.ts',
+ 'projects/zeppelin-{notebook-core,sdk,visualization}/**/*.spec.ts',
'test/**/*.spec.ts',
'test/test-setup.ts',
+ 'test/notebook-core/**/*.ts',
+ 'vitest.notebook-core.config.mts',
'vitest.shell.config.mts'
],
languageOptions: {
@@ -177,7 +179,11 @@ module.exports = tseslint.config(
},
{
// Catch specs that cannot fail, as eslint-plugin-playwright does for e2e.
- files: ['src/**/*.spec.ts', 'projects/zeppelin-{sdk,visualization}/**/*.spec.ts', 'test/**/*.spec.ts'],
+ files: [
+ 'src/**/*.spec.ts',
+ 'projects/zeppelin-{notebook-core,sdk,visualization}/**/*.spec.ts',
+ 'test/**/*.spec.ts'
+ ],
plugins: { vitest },
rules: {
'vitest/expect-expect': 'error',
diff --git a/zeppelin-web-angular/package.json b/zeppelin-web-angular/package.json
index 2e9a509bb28..ea60a152d2d 100644
--- a/zeppelin-web-angular/package.json
+++ b/zeppelin-web-angular/package.json
@@ -11,7 +11,8 @@
"build": "npm run build:projects && npm run build:react && npm run build:angular",
"build:angular": "ng build --configuration production",
"build:react": "cd projects/zeppelin-react && npm run build",
- "build:projects": "npm run build-project:sdk && npm run build-project:vis",
+ "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",
"build-project:sdk": "ng build --project zeppelin-sdk",
"check:websocket-contract": "node --test scripts/check-websocket-contract.test.js && node scripts/check-websocket-contract.js",
"generate:notebook-parity-scenarios": "node scripts/generate-notebook-parity-scenarios.mjs",
@@ -21,6 +22,8 @@
"lint:fix": "cross-env NODE_OPTIONS='--max-old-space-size=8192' ng lint --fix && npm run lint:fix:react && prettier --write \"**/*.{ts,tsx,mts,js,json,css,html}\"",
"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",
+ "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/",
"e2e": "playwright test",
diff --git a/zeppelin-web-angular/pom.xml b/zeppelin-web-angular/pom.xml
index 3f2fee17ff1..bc6294d07a1 100644
--- a/zeppelin-web-angular/pom.xml
+++ b/zeppelin-web-angular/pom.xml
@@ -129,6 +129,28 @@
+
+ npm typecheck notebook core
+
+ npm
+
+ test
+
+ run typecheck:notebook-core
+
+
+
+
+ npm test notebook core
+
+ npm
+
+ test
+
+ run test:notebook-core
+
+
+
npm e2e
diff --git a/zeppelin-web-angular/projects/zeppelin-notebook-core/ng-package.json b/zeppelin-web-angular/projects/zeppelin-notebook-core/ng-package.json
new file mode 100644
index 00000000000..b7ef11c43a5
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-notebook-core/ng-package.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
+ "dest": "../../dist/zeppelin-notebook-core",
+ "lib": {
+ "entryFile": "src/public-api.ts"
+ }
+}
diff --git a/zeppelin-web-angular/projects/zeppelin-notebook-core/package.json b/zeppelin-web-angular/projects/zeppelin-notebook-core/package.json
new file mode 100644
index 00000000000..5431edbe978
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-notebook-core/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@zeppelin/notebook-core",
+ "version": "0.0.1",
+ "sideEffects": false,
+ "dependencies": {
+ "tslib": "^2.0.0"
+ }
+}
diff --git a/zeppelin-web-angular/projects/zeppelin-notebook-core/src/host-remote-contract.spec.ts b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/host-remote-contract.spec.ts
new file mode 100644
index 00000000000..b3e3ee86c8c
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/host-remote-contract.spec.ts
@@ -0,0 +1,78 @@
+// @vitest-environment node
+
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { assertType, describe, expect, it } from 'vitest';
+
+import type {
+ NotebookCorePort,
+ NotebookCoreRemoteProps,
+ NotebookCoreSnapshot,
+ NotebookCoreSnapshotListener
+} from './public-api';
+
+// assertType does not invoke this callback; tsc checks the rejected mutations.
+assertType<(props: NotebookCoreRemoteProps) => void>(props => {
+ const snapshot = props.core.getSnapshot();
+
+ // @ts-expect-error Snapshot note IDs are readonly.
+ snapshot.noteId = 'another-note';
+ // @ts-expect-error Snapshot revision IDs are readonly.
+ snapshot.revisionId = 'another-revision';
+ // @ts-expect-error The remote cannot replace the shared core port.
+ props.core = { ...props.core };
+ // @ts-expect-error The core snapshot reader is readonly.
+ props.core.getSnapshot = () => snapshot;
+ // @ts-expect-error The core subscription method is readonly.
+ props.core.subscribe = () => () => undefined;
+});
+
+const fakeCorePort = (initialSnapshot: NotebookCoreSnapshot) => {
+ let snapshot = initialSnapshot;
+ const listeners = new Set();
+ const core: NotebookCorePort = {
+ getSnapshot: () => snapshot,
+ subscribe: listener => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ }
+ };
+
+ return {
+ core,
+ publish: (nextSnapshot: NotebookCoreSnapshot) => {
+ snapshot = nextSnapshot;
+ for (const listener of listeners) {
+ listener();
+ }
+ }
+ };
+};
+
+describe('notebook core host and remote contract', () => {
+ it('demonstrates snapshot subscription and cleanup with a fake host-owned port', () => {
+ const host = fakeCorePort({ noteId: '2A94M5J1Z', revisionId: null });
+ const remoteProps: NotebookCoreRemoteProps = { core: host.core };
+ const snapshots: unknown[] = [];
+
+ expect(remoteProps.core).toBe(host.core);
+
+ const unsubscribe = remoteProps.core.subscribe(() => snapshots.push(remoteProps.core.getSnapshot()));
+ host.publish({ noteId: '2A94M5J1Z', revisionId: 'rev-1' });
+ unsubscribe();
+ host.publish({ noteId: '2A94M5J1Z', revisionId: 'rev-2' });
+
+ expect(snapshots).toEqual([{ noteId: '2A94M5J1Z', revisionId: 'rev-1' }]);
+ expect(remoteProps.core.getSnapshot()).toEqual({ noteId: '2A94M5J1Z', revisionId: 'rev-2' });
+ });
+});
diff --git a/zeppelin-web-angular/projects/zeppelin-notebook-core/src/host-remote-contract.ts b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/host-remote-contract.ts
new file mode 100644
index 00000000000..9a62c63e673
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/host-remote-contract.ts
@@ -0,0 +1,29 @@
+/*
+ * 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 type NotebookCoreSnapshot = Readonly<{
+ noteId: string;
+ revisionId: string | null;
+}>;
+
+export type NotebookCoreUnsubscribe = () => void;
+
+export type NotebookCoreSnapshotListener = () => void;
+
+export type NotebookCorePort = Readonly<{
+ getSnapshot: () => NotebookCoreSnapshot;
+ subscribe: (listener: NotebookCoreSnapshotListener) => NotebookCoreUnsubscribe;
+}>;
+
+export type NotebookCoreRemoteProps = Readonly<{
+ core: NotebookCorePort;
+}>;
diff --git a/zeppelin-web-angular/projects/zeppelin-notebook-core/src/public-api.ts b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/public-api.ts
new file mode 100644
index 00000000000..0d82f21dde2
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/public-api.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 * from './host-remote-contract';
diff --git a/zeppelin-web-angular/projects/zeppelin-notebook-core/tsconfig.json b/zeppelin-web-angular/projects/zeppelin-notebook-core/tsconfig.json
new file mode 100644
index 00000000000..fcfd714eac1
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-notebook-core/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "../../out-tsc/lib",
+ "target": "es2020",
+ "lib": ["es2020"],
+ "paths": {},
+ "declaration": true,
+ "inlineSources": true,
+ "types": []
+ },
+ "exclude": ["**/*.spec.ts"]
+}
diff --git a/zeppelin-web-angular/projects/zeppelin-notebook-core/tsconfig.spec.json b/zeppelin-web-angular/projects/zeppelin-notebook-core/tsconfig.spec.json
new file mode 100644
index 00000000000..b491576100c
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-notebook-core/tsconfig.spec.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../out-tsc/spec/notebook-core",
+ "types": ["node"]
+ },
+ "include": ["src/**/*.spec.ts", "../../test/notebook-core/**/*.ts"],
+ "exclude": []
+}
diff --git a/zeppelin-web-angular/projects/zeppelin-react/package-lock.json b/zeppelin-web-angular/projects/zeppelin-react/package-lock.json
index 8d4f3da1cf2..11ce482ea14 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/package-lock.json
+++ b/zeppelin-web-angular/projects/zeppelin-react/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.0.1",
"dependencies": {
"@ant-design/icons": "5.4.0",
+ "@zeppelin/notebook-core": "file:../zeppelin-notebook-core",
"@zeppelin/sdk": "file:../zeppelin-sdk",
"ansi-to-react": "6.2.6",
"antd": "5.21.0",
@@ -51,6 +52,13 @@
"webpack-dev-server": "6.0.0"
}
},
+ "../zeppelin-notebook-core": {
+ "name": "@zeppelin/notebook-core",
+ "version": "0.0.1",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ }
+ },
"../zeppelin-sdk": {
"name": "@zeppelin/sdk",
"version": "0.0.1",
@@ -2698,6 +2706,10 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/@zeppelin/notebook-core": {
+ "resolved": "../zeppelin-notebook-core",
+ "link": true
+ },
"node_modules/@zeppelin/sdk": {
"resolved": "../zeppelin-sdk",
"link": true
diff --git a/zeppelin-web-angular/projects/zeppelin-react/package.json b/zeppelin-web-angular/projects/zeppelin-react/package.json
index 8f4d6094418..6905d5001d1 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/package.json
+++ b/zeppelin-web-angular/projects/zeppelin-react/package.json
@@ -15,6 +15,7 @@
},
"dependencies": {
"@ant-design/icons": "5.4.0",
+ "@zeppelin/notebook-core": "file:../zeppelin-notebook-core",
"@zeppelin/sdk": "file:../zeppelin-sdk",
"ansi-to-react": "6.2.6",
"highlight.js": "^10.7.3",
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts b/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
index ce7edc883f1..66f7f95a0ab 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
@@ -13,3 +13,4 @@
export { ConfigurationTable, mount as mountConfigurationTable } from './pages/ConfigurationTable';
export { PublishedParagraph, mount } from './pages/PublishedParagraph';
export { ParagraphFooter, mount as mountParagraphFooter } from './components/paragraph/ParagraphFooter';
+export type { NotebookCoreRemoteProps } from './notebookCoreContract';
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/notebookCoreContract.ts b/zeppelin-web-angular/projects/zeppelin-react/src/notebookCoreContract.ts
new file mode 100644
index 00000000000..1eceaf420ce
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/notebookCoreContract.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 type { NotebookCoreRemoteProps } from '@zeppelin/notebook-core';
diff --git a/zeppelin-web-angular/projects/zeppelin-react/tsconfig.json b/zeppelin-web-angular/projects/zeppelin-react/tsconfig.json
index 3dba52c585b..ea736257f24 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/tsconfig.json
+++ b/zeppelin-web-angular/projects/zeppelin-react/tsconfig.json
@@ -19,6 +19,7 @@
"baseUrl": "src",
"paths": {
"@/*": ["./*"],
+ "@zeppelin/notebook-core": ["../../zeppelin-notebook-core/src/public-api.ts"],
"@zeppelin/sdk": ["../../zeppelin-sdk/src"],
"@zeppelin/sdk/*": ["../../zeppelin-sdk/src/*"]
}
diff --git a/zeppelin-web-angular/projects/zeppelin-react/tsconfig.notebook-core.dist.json b/zeppelin-web-angular/projects/zeppelin-react/tsconfig.notebook-core.dist.json
new file mode 100644
index 00000000000..12ee26106c6
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/tsconfig.notebook-core.dist.json
@@ -0,0 +1,8 @@
+{
+ "extends": "./tsconfig.notebook-core.json",
+ "compilerOptions": {
+ "paths": {
+ "@zeppelin/notebook-core": ["../../../dist/zeppelin-notebook-core"]
+ }
+ }
+}
diff --git a/zeppelin-web-angular/projects/zeppelin-react/tsconfig.notebook-core.json b/zeppelin-web-angular/projects/zeppelin-react/tsconfig.notebook-core.json
new file mode 100644
index 00000000000..8516d2cf5fa
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/tsconfig.notebook-core.json
@@ -0,0 +1,5 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["src/notebookCoreContract.ts"],
+ "exclude": []
+}
diff --git a/zeppelin-web-angular/projects/zeppelin-react/vitest.config.mts b/zeppelin-web-angular/projects/zeppelin-react/vitest.config.mts
index e2ebae31443..c21ab1ab78c 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/vitest.config.mts
+++ b/zeppelin-web-angular/projects/zeppelin-react/vitest.config.mts
@@ -15,12 +15,16 @@ import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
export default defineConfig({
- // Kept in sync with the `resolve.alias` block in webpack.config.js.
+ // Source aliases mirror tsconfig.json; the SDK alias also matches webpack.config.js.
resolve: {
- alias: {
- '@': fileURLToPath(new URL('./src', import.meta.url)),
- '@zeppelin/sdk': fileURLToPath(new URL('../zeppelin-sdk/src', import.meta.url))
- }
+ alias: [
+ {
+ find: /^@zeppelin\/notebook-core$/,
+ replacement: fileURLToPath(new URL('../zeppelin-notebook-core/src/public-api.ts', import.meta.url))
+ },
+ { find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
+ { find: '@zeppelin/sdk', replacement: fileURLToPath(new URL('../zeppelin-sdk/src', import.meta.url)) }
+ ]
},
test: {
environment: 'jsdom',
diff --git a/zeppelin-web-angular/projects/zeppelin-react/webpack.config.js b/zeppelin-web-angular/projects/zeppelin-react/webpack.config.js
index d7de57b3d91..aa73dc16479 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/webpack.config.js
+++ b/zeppelin-web-angular/projects/zeppelin-react/webpack.config.js
@@ -39,6 +39,7 @@ module.exports = (_env, argv) => {
modules: ['node_modules', path.resolve(__dirname, '../../node_modules')],
alias: {
'@': path.resolve(__dirname, 'src'),
+ '@zeppelin/notebook-core$': path.resolve(__dirname, '../zeppelin-notebook-core/src/public-api.ts'),
'@zeppelin/sdk': path.resolve(__dirname, '../zeppelin-sdk/src')
}
},
diff --git a/zeppelin-web-angular/src/tsconfig.spec.json b/zeppelin-web-angular/src/tsconfig.spec.json
index 409fd8b3675..07cf05eef65 100644
--- a/zeppelin-web-angular/src/tsconfig.spec.json
+++ b/zeppelin-web-angular/src/tsconfig.spec.json
@@ -6,10 +6,12 @@
},
"include": [
"**/*.spec.ts",
+ "../projects/zeppelin-notebook-core/**/*.spec.ts",
"../projects/zeppelin-sdk/**/*.spec.ts",
"../projects/zeppelin-visualization/**/*.spec.ts",
"../test/**/*.spec.ts",
"../test/test-setup.ts",
+ "../vitest.notebook-core.config.mts",
"../vitest.shell.config.mts"
],
"exclude": []
diff --git a/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts b/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
new file mode 100644
index 00000000000..7b1ad5f9981
--- /dev/null
+++ b/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
@@ -0,0 +1,21 @@
+/*
+ * 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 ts from 'typescript';
+
+export const createFixtureHost = (options: ts.CompilerOptions, files: ReadonlyMap): 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;
+};
diff --git a/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts b/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts
new file mode 100644
index 00000000000..c3a68652776
--- /dev/null
+++ b/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts
@@ -0,0 +1,609 @@
+/*
+ * 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 { readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+
+import ts from 'typescript';
+import { describe, expect, it } from 'vitest';
+
+import { createFixtureHost } from './compiler-fixture';
+import {
+ sourceRoot,
+ zeppelinWebAngularRoot,
+ reactNotebookCoreBoundaryFiles,
+ forbiddenModulePrefixes,
+ forbiddenReactNotebookCoreConsumerModulePrefixes,
+ sourceFiles,
+ findReactNotebookConsumerViolations,
+ findNotebookContractViolations,
+ findViolations,
+ formatViolations,
+ readCompilerOptions
+} from './import-boundary';
+
+describe('notebook core import boundary', () => {
+ it('resolves the React public contract without exposing source subpaths', () => {
+ const path = reactNotebookCoreBoundaryFiles[1];
+ const options = readCompilerOptions(resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/tsconfig.json'));
+
+ expect(
+ ts.resolveModuleName('@zeppelin/notebook-core', path, options, ts.sys).resolvedModule?.resolvedFileName
+ ).toBe(resolve(sourceRoot, 'public-api.ts'));
+ expect(
+ ts.resolveModuleName('@zeppelin/notebook-core/host-remote-contract', path, options, ts.sys).resolvedModule
+ ).toBeUndefined();
+ });
+
+ it.each(['http', 'https', 'node:http', 'node:https', 'http2', 'node:http2'])(
+ 'rejects Node HTTP transport %s in core and consumer',
+ specifier => {
+ for (const [path, prefixes] of [
+ [resolve(sourceRoot, 'host-remote-contract.ts'), forbiddenModulePrefixes],
+ [reactNotebookCoreBoundaryFiles[1], forbiddenReactNotebookCoreConsumerModulePrefixes]
+ ] as const) {
+ expect(findViolations(path, `export * from '${specifier}';`, prefixes)).toEqual([
+ `${path}: import ${specifier}`
+ ]);
+ }
+ }
+ );
+
+ it('rejects a compiler-valid Node HTTP handle in the snapshot', () => {
+ const path = resolve(sourceRoot, 'host-remote-contract.ts');
+ const source = `/// \nimport type { ClientRequest } from 'node:http';\n${readFileSync(
+ path,
+ 'utf8'
+ ).replace('noteId: string;', 'noteId: string; transport?: ClientRequest;')}`;
+ const options = readCompilerOptions(resolve(sourceRoot, '../tsconfig.json'));
+ const host = createFixtureHost(options, new Map([[path, source]]));
+
+ expect(ts.getPreEmitDiagnostics(ts.createProgram([path], options, host))).toEqual([]);
+ expect(findViolations(path, source)).toEqual([`${path}: reference node`, `${path}: import node:http`]);
+ });
+
+ it('rejects a compiler-valid framework type exposed by an external wrapper', () => {
+ const path = resolve(sourceRoot, 'host-remote-contract.ts');
+ const source = readFileSync(path, 'utf8').replace(
+ 'noteId: string;',
+ "noteId: string; button?: import('ng-zorro-antd/button').NzButtonComponent;"
+ );
+ const options = readCompilerOptions(resolve(sourceRoot, '../tsconfig.json'));
+ const host = createFixtureHost(options, new Map([[path, source]]));
+ expect(ts.getPreEmitDiagnostics(ts.createProgram([path], options, host))).toEqual([]);
+ expect(findViolations(path, source)).toEqual([`${path}: import ng-zorro-antd/button`]);
+ });
+
+ it('allows only checked local sources and the reviewed tslib dependency in core', () => {
+ const path = resolve(sourceRoot, 'public-api.ts');
+ expect(findViolations(path, "export * from './host-remote-contract';")).toEqual([]);
+ expect(findViolations(path, "export { __assign } from 'tslib';")).toEqual([]);
+ expect(findViolations(path, "export * from 'unknown-wrapper';")).toEqual([`${path}: import unknown-wrapper`]);
+ });
+
+ it('stays framework-neutral and transport-neutral', () => {
+ const violations = sourceFiles(sourceRoot).flatMap(path => {
+ const source = readFileSync(path, 'utf8');
+ return findViolations(path, source);
+ });
+
+ expect(violations).toEqual([]);
+ });
+
+ it.each(['import', 'alias', 'reference'])(
+ 'rejects a compiler-valid declaration outside the scanned core source through %s',
+ form => {
+ const path = resolve(sourceRoot, 'host-remote-contract.ts');
+ const helper = resolve(sourceRoot, '../framework.d.ts');
+ const options = {
+ ...readCompilerOptions(resolve(sourceRoot, '../tsconfig.json')),
+ rootDir: sourceRoot,
+ baseUrl: sourceRoot,
+ paths: { '@contract-helper': ['../framework.d.ts'] }
+ };
+ const specifier = form === 'alias' ? '@contract-helper' : '../framework';
+ const declaration =
+ form === 'reference'
+ ? "type AngularHandle = import('@angular/core').Provider;"
+ : "export type { Provider as AngularHandle } from '@angular/core';";
+ const field = form === 'reference' ? 'AngularHandle' : `import('${specifier}').AngularHandle`;
+ const source = `${form === 'reference' ? '/// \n' : ''}${readFileSync(
+ path,
+ 'utf8'
+ ).replace('noteId: string;', `noteId: string; framework?: ${field};`)}`;
+ const files = new Map([
+ [path, source],
+ [helper, declaration]
+ ]);
+ const host = createFixtureHost(options, files);
+ const program = ts.createProgram([path], options, host);
+
+ expect(ts.getPreEmitDiagnostics(program)).toEqual([]);
+ expect(program.getSourceFile(helper)).toBeDefined();
+ expect(findViolations(path, source, forbiddenModulePrefixes, options, host)).toEqual([
+ `${path}: ${form === 'reference' ? 'reference ../framework.d.ts' : `import ${specifier}`}`
+ ]);
+ }
+ );
+
+ it('rejects direct transport imports in the React entry point and notebook core contract', () => {
+ const violations = reactNotebookCoreBoundaryFiles.flatMap(path => {
+ const source = readFileSync(path, 'utf8');
+ return findViolations(path, source, forbiddenReactNotebookCoreConsumerModulePrefixes);
+ });
+
+ expect(formatViolations(violations)).toEqual([]);
+ });
+
+ it('checks all production React consumers of the notebook contract', () => {
+ expect(findReactNotebookConsumerViolations()).toEqual([]);
+ });
+
+ it.each(['direct', 'helper', 'route', 'barrel', 'javascript', 'cycle', 'computed', 'require-outside'])(
+ 'discovers a new notebook consumer and rejects its %s transport dependency',
+ form => {
+ const root = resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src');
+ const consumer = resolve(root, 'NotebookBoundaryFixture.tsx');
+ const helper = resolve(
+ root,
+ form === 'javascript'
+ ? 'transportFixture.js'
+ : form === 'require-outside'
+ ? '../transportFixture.ts'
+ : 'transportFixture.ts'
+ );
+ const route = resolve(root, 'NotebookRouteFixture.tsx');
+ const barrel = resolve(root, 'contractFixture.ts');
+ const transport = "export { webSocket } from 'rxjs/webSocket';";
+ const files = new Map([
+ [
+ consumer,
+ `import type { NotebookCoreRemoteProps } from '${form === 'barrel' ? './contractFixture' : './notebookCoreContract'}';
+ export const read = (props: NotebookCoreRemoteProps) => props.core.getSnapshot();
+ ${['helper', 'javascript', 'cycle'].includes(form) ? "export * from '@/transportFixture';" : ''}
+ ${form === 'direct' || form === 'barrel' ? transport : ''}
+ ${form === 'require-outside' ? "export const transport = require('../transportFixture');" : ''}
+ ${form === 'computed' ? "export const load = () => import('rxjs/' + 'webSocket');" : ''}`
+ ],
+ [helper, `${transport} ${form === 'cycle' ? "export { read } from './NotebookBoundaryFixture';" : ''}`],
+ [route, `export { read } from './NotebookBoundaryFixture'; ${form === 'route' ? transport : ''}`],
+ [barrel, "export type { NotebookCoreRemoteProps } from './notebookCoreContract';"]
+ ]);
+ 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([]);
+ const violations = findReactNotebookConsumerViolations(roots, options, host);
+ const offender = ['helper', 'javascript', 'cycle', 'require-outside'].includes(form)
+ ? helper
+ : form === 'route'
+ ? route
+ : consumer;
+ expect(violations).toContain(
+ `${offender}: import ${form === 'computed' ? '' : 'rxjs/webSocket'}`
+ );
+ }
+ );
+
+ it.each([
+ ["export type { Message } from '@zeppelin/sdk';", 'import @zeppelin/sdk'],
+ ["export { request } from 'node:http';", 'import node:http'],
+ ["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';
+ 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}`);
+ });
+
+ it('rejects the mixed public aggregator as an internal notebook consumer dependency', () => {
+ const root = resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src');
+ const consumer = resolve(root, 'NotebookBoundaryFixture.tsx');
+ const source = "export type { NotebookCoreRemoteProps } from './main';";
+ const options = readCompilerOptions(resolve(root, '../tsconfig.json'));
+ const host = createFixtureHost(options, new Map([[consumer, source]]));
+ expect(findReactNotebookConsumerViolations([consumer], options, host)).toContain(
+ `${consumer}: notebook consumer must use the contract bridge instead of the public aggregator`
+ );
+ });
+
+ it('allows a neutral notebook consumer without traversing unrelated legacy page exports', () => {
+ const root = resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src');
+ const consumer = resolve(root, 'NotebookBoundaryFixture.tsx');
+ const source =
+ "import type { NotebookCoreRemoteProps } from './notebookCoreContract'; export const read = (props: NotebookCoreRemoteProps) => props.core.getSnapshot();";
+ const options = readCompilerOptions(resolve(root, '../tsconfig.json'));
+ const host = createFixtureHost(options, new Map([[consumer, source]]));
+ expect(findReactNotebookConsumerViolations([...reactNotebookCoreBoundaryFiles, consumer], options, host)).toEqual(
+ []
+ );
+ });
+
+ it('keeps the React contract dependent only on the public core entry point', () => {
+ const path = reactNotebookCoreBoundaryFiles[1];
+ expect(findNotebookContractViolations(path, readFileSync(path, 'utf8'))).toEqual([]);
+ });
+
+ it('rejects a compiler-valid transport re-export through a local helper', () => {
+ const path = reactNotebookCoreBoundaryFiles[1];
+ const helper = resolve(dirname(path), 'notebookTransport.ts');
+ const source = `${readFileSync(path, 'utf8')}\nexport { webSocket } from './notebookTransport';`;
+ const files = new Map([
+ [path, source],
+ [helper, "export { webSocket } from 'rxjs/webSocket';"]
+ ]);
+ const options = readCompilerOptions(resolve(dirname(path), '../tsconfig.json'));
+ const host = createFixtureHost(options, files);
+
+ expect(ts.getPreEmitDiagnostics(ts.createProgram([path], options, host))).toEqual([]);
+ expect(findNotebookContractViolations(path, source)).toEqual([
+ `${path}: notebook contract dependency ./notebookTransport`
+ ]);
+ });
+
+ it.each(["import(('rxjs/webSocket'))", "import('rxjs/' + 'webSocket')"])(
+ 'rejects wrapped or computed dynamic dependencies: %s',
+ expression => {
+ const source = `export const load = () => ${expression};`;
+ expect(findNotebookContractViolations(reactNotebookCoreBoundaryFiles[1], source).length).toBeGreaterThan(0);
+ expect(findViolations(resolve(sourceRoot, 'public-api.ts'), source).length).toBeGreaterThan(0);
+ }
+ );
+
+ it.each([
+ "import type { Port } from './helper';",
+ "export type { Port } from '@/helper';",
+ "export type Port = import('./helper').Port;",
+ "export const load = () => import('./helper');"
+ ])('rejects other dependency forms in the type-only contract: %s', source => {
+ expect(findNotebookContractViolations(reactNotebookCoreBoundaryFiles[1], source)).toHaveLength(1);
+ });
+
+ it.each(['import(`rxjs/webSocket`)', "import('rxjs/webSocket', {})"])(
+ 'rejects dynamic transport syntax %s',
+ expression => {
+ const path = reactNotebookCoreBoundaryFiles[1];
+ expect(
+ findViolations(
+ path,
+ `export const load = () => ${expression};`,
+ forbiddenReactNotebookCoreConsumerModulePrefixes
+ )
+ ).toEqual([`${path}: import rxjs/webSocket`]);
+ }
+ );
+
+ it.each([
+ ['path', '../../zeppelin-react/node_modules/@types/react/index.d.ts'],
+ ['types', 'react'],
+ ['types', 'ws']
+ ])('rejects forbidden reference %s=%s', (kind, target) => {
+ const path = resolve(sourceRoot, 'host-remote-contract.ts');
+ expect(findViolations(path, `/// `)).toEqual([`${path}: reference ${target}`]);
+ });
+
+ it.each(['dom', 'dom.iterable', 'webworker', 'webworker.importscripts'])(
+ 'rejects browser library reference %s',
+ lib => {
+ expect(findViolations('library.ts', `/// `)).toEqual([`library.ts: library ${lib}`]);
+ }
+ );
+
+ it.each(['globalThis', 'window', 'self'])('rejects indexed transport types on %s', owner => {
+ expect(findViolations('indexed.ts', `export type Socket = InstanceType<(typeof ${owner})["WebSocket"]>;`)).toEqual([
+ 'indexed.ts: global WebSocket'
+ ]);
+ expect(findViolations('indexed.ts', `export type Request = (typeof ${owner})['fetch'];`)).toEqual([
+ 'indexed.ts: global fetch'
+ ]);
+ });
+
+ it('rejects template-literal global transport access', () => {
+ expect(findViolations('template.ts', 'export const request = window[`fetch`];')).toEqual([
+ 'template.ts: global fetch'
+ ]);
+ });
+
+ it('allows standard libraries and local indexed properties', () => {
+ expect(findViolations('local.ts', '/// ')).toEqual([]);
+ expect(findViolations('local.ts', 'type Local = { fetch: string }; export type Value = Local["fetch"];')).toEqual(
+ []
+ );
+ expect(findViolations('local.ts', 'export type Parse = (typeof globalThis)["parseInt"];')).toEqual([]);
+ });
+
+ it('rejects a compiler-valid WebSocket leak in the actual snapshot contract', () => {
+ const path = resolve(sourceRoot, 'host-remote-contract.ts');
+ const source = `/// \n${readFileSync(path, 'utf8').replace(
+ 'noteId: string;',
+ 'noteId: string; socket?: InstanceType<(typeof globalThis)["WebSocket"]>;'
+ )}`;
+ const options = readCompilerOptions(resolve(sourceRoot, '../tsconfig.json'));
+ const host = createFixtureHost(options, new Map([[path, source]]));
+ const program = ts.createProgram([path], options, host);
+
+ expect(ts.getPreEmitDiagnostics(program)).toEqual([]);
+ expect(findViolations(path, source)).toEqual([`${path}: library dom`, `${path}: global WebSocket`]);
+ });
+
+ it.each([
+ "export type Leak = import('./host-remote-contract.spec').AngularHandle;",
+ "export * from './host-remote-contract.spec';",
+ "export const load = () => import('./host-remote-contract.spec');"
+ ])('rejects a production dependency on an excluded spec: %s', source => {
+ const path = resolve(sourceRoot, 'host-remote-contract.ts');
+ expect(findViolations(path, source)).toEqual([`${path}: import ./host-remote-contract.spec`]);
+ });
+
+ it('rejects an alias and reference directive resolving to an excluded spec', () => {
+ const path = resolve(sourceRoot, 'host-remote-contract.ts');
+ const options = {
+ ...readCompilerOptions(resolve(sourceRoot, '../tsconfig.json')),
+ baseUrl: sourceRoot,
+ paths: { '@test-contract': ['host-remote-contract.spec.ts'] }
+ };
+ expect(findViolations(path, "export * from '@test-contract';", forbiddenModulePrefixes, options)).toEqual([
+ `${path}: import @test-contract`
+ ]);
+ expect(findViolations(path, '/// ')).toEqual([
+ `${path}: reference ./host-remote-contract.spec.ts`
+ ]);
+ expect(findViolations(path, '/// ')).toEqual([
+ `${path}: reference ./host-remote-contract.spec.ts`
+ ]);
+ });
+
+ it('rejects a compiler-valid framework leak through an imported spec', () => {
+ const path = resolve(sourceRoot, 'host-remote-contract.ts');
+ const spec = resolve(sourceRoot, 'host-remote-contract.spec.ts');
+ const source = readFileSync(path, 'utf8').replace(
+ 'noteId: string;',
+ "noteId: string; framework?: import('./host-remote-contract.spec').AngularHandle;"
+ );
+ const files = new Map([
+ [path, source],
+ [spec, `${readFileSync(spec, 'utf8')}\nexport type { Provider as AngularHandle } from '@angular/core';`]
+ ]);
+ const options = readCompilerOptions(resolve(sourceRoot, '../tsconfig.json'));
+ const host = createFixtureHost(options, files);
+ const program = ts.createProgram([resolve(sourceRoot, 'public-api.ts')], options, host);
+
+ expect(ts.getPreEmitDiagnostics(program)).toEqual([]);
+ expect(program.getSourceFile(spec)).toBeDefined();
+ expect(findViolations(path, source)).toEqual([`${path}: import ./host-remote-contract.spec`]);
+ });
+
+ it('allows a local reference and a core dynamic import', () => {
+ const path = resolve(sourceRoot, 'public-api.ts');
+ expect(findViolations(path, '/// ')).toEqual([]);
+ expect(
+ findViolations(
+ reactNotebookCoreBoundaryFiles[1],
+ 'export const load = () => import(`@zeppelin/notebook-core`);',
+ forbiddenReactNotebookCoreConsumerModulePrefixes
+ )
+ ).toEqual([]);
+ });
+
+ it.each(['ws', '@types/ws', 'isomorphic-ws', 'sockjs-client', '@types/sockjs-client'])(
+ 'rejects WebSocket implementation %s in core and consumer',
+ specifier => {
+ for (const [path, prefixes] of [
+ [resolve(sourceRoot, 'host-remote-contract.ts'), forbiddenModulePrefixes],
+ [reactNotebookCoreBoundaryFiles[1], forbiddenReactNotebookCoreConsumerModulePrefixes]
+ ] as const) {
+ expect(findViolations(path, `export * from '${specifier}';`, prefixes)).toEqual([
+ `${path}: import ${specifier}`
+ ]);
+ }
+ }
+ );
+
+ it('rejects worker-global transport access', () => {
+ expect(
+ findViolations(
+ reactNotebookCoreBoundaryFiles[1],
+ 'export const request = self.fetch;',
+ forbiddenReactNotebookCoreConsumerModulePrefixes
+ )
+ ).toEqual([`${reactNotebookCoreBoundaryFiles[1]}: global fetch`]);
+ });
+
+ it.each([
+ ['const browser = window; export const request = browser.fetch;', 'window'],
+ ['const browser = (globalThis); export const request = browser.fetch;', 'globalThis'],
+ ['let browser: Window; browser = window; export const request = browser.fetch;', 'window'],
+ ['const browser = self as typeof self; export const socket = browser.WebSocket;', 'self']
+ ])('rejects capturing a transport global in a local alias: %s', (source, owner) => {
+ 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}: global object alias ${owner}`
+ );
+ });
+
+ it.each([
+ ['export const request = (window).fetch;', 'global fetch'],
+ ['export const request = (window as Window).fetch;', 'global fetch'],
+ ['export const request = (globalThis)["fetch"];', 'global fetch'],
+ ['export const request = (self as typeof self)["fetch"];', 'global fetch'],
+ ['const { fetch } = window; export const request = fetch;', 'global fetch'],
+ ['const { fetch: request } = (window); export { request };', 'global fetch'],
+ ['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}`
+ );
+ });
+
+ it.each([
+ "export const fetch = () => 'cached'; export const result = fetch();",
+ 'export const read = (fetch: () => string) => fetch();',
+ "const window = { fetch: () => 'cached' }; const browser = window; export const result = (window).fetch() + browser.fetch();",
+ 'export function read({ fetch }: { fetch: () => string }) { return fetch(); }',
+ "const fetch = () => 'cached'; export const local = { fetch };",
+ "const local = { fetch: () => 'cached' }; const { fetch: read } = local; export const result = read();"
+ ])('allows lexical local bindings with transport-like names: %s', source => {
+ expect(
+ findViolations(reactNotebookCoreBoundaryFiles[1], source, forbiddenReactNotebookCoreConsumerModulePrefixes)
+ ).toEqual([]);
+ });
+
+ it('does not let a nested local binding hide an outer transport global', () => {
+ const source = 'export function read(fetch: () => string) { return fetch(); } export const request = fetch;';
+ const path = reactNotebookCoreBoundaryFiles[1];
+ expect(findViolations(path, source, forbiddenReactNotebookCoreConsumerModulePrefixes)).toEqual([
+ `${path}: global fetch`
+ ]);
+ });
+
+ it('allows direct non-transport browser properties and ordinary local objects', () => {
+ const source = 'const browser = { innerWidth: 42 }; export const width = browser.innerWidth + window.innerWidth;';
+ expect(
+ findViolations(reactNotebookCoreBoundaryFiles[1], source, forbiddenReactNotebookCoreConsumerModulePrefixes)
+ ).toEqual([]);
+ });
+
+ it('ignores forbidden words in comments and string values', () => {
+ const source = `// React may render this later.\nexport const note = 'fetch over WebSocket';`;
+
+ expect(findViolations('comment-fixture.ts', source)).toEqual([]);
+ });
+
+ it.each([
+ '../../zeppelin-sdk/src/public-api',
+ '../../zeppelin-react/node_modules/@types/react',
+ '../../../node_modules/rxjs/dist/types/index',
+ '@zeppelin/services/interpreter.service'
+ ])('rejects forbidden dependencies through %s', moduleSpecifier => {
+ const path = resolve(sourceRoot, 'host-remote-contract.ts');
+ const source = `export type { Leaked } from '${moduleSpecifier}';`;
+
+ expect(findViolations(path, source)).toEqual([`${path}: import ${moduleSpecifier}`]);
+ });
+
+ it('rejects a compiler alias to the SDK even when its spelling is neutral', () => {
+ const path = resolve(sourceRoot, 'host-remote-contract.ts');
+ const options = {
+ ...readCompilerOptions(resolve(sourceRoot, '../tsconfig.json')),
+ baseUrl: zeppelinWebAngularRoot,
+ paths: { '@mount-data': ['projects/zeppelin-sdk/src/public-api.ts'] }
+ };
+
+ expect(
+ findViolations(path, "export type { Message } from '@mount-data';", forbiddenModulePrefixes, options)
+ ).toEqual([`${path}: import @mount-data`]);
+ });
+
+ it('rejects a relative SDK import from the React contract consumer', () => {
+ const path = reactNotebookCoreBoundaryFiles[1];
+ const source = `export type { Message } from '../../zeppelin-sdk/src/public-api';`;
+
+ expect(findViolations(path, source, forbiddenReactNotebookCoreConsumerModulePrefixes)).toEqual([
+ `${path}: import ../../zeppelin-sdk/src/public-api`
+ ]);
+ });
+
+ it.each([
+ '../../../src/app/services/message.service',
+ '../../../src/app/services/notebook.service',
+ '../../../src/app/services/base-rest',
+ '../../../node_modules/rxjs/dist/types/webSocket/index'
+ ])('rejects direct shell and WebSocket transport imports through %s', moduleSpecifier => {
+ const path = reactNotebookCoreBoundaryFiles[1];
+
+ expect(
+ findViolations(path, `export * from '${moduleSpecifier}';`, forbiddenReactNotebookCoreConsumerModulePrefixes)
+ ).toEqual([`${path}: import ${moduleSpecifier}`]);
+ });
+
+ it('rejects transport types and computed global transport access', () => {
+ const source = [
+ 'export type Socket = WebSocket;',
+ 'export type Request = XMLHttpRequest;',
+ "export const request = globalThis['fetch'];",
+ "export const socket = new window['WebSocket']('/ws');"
+ ].join('\n');
+
+ expect(findViolations('transport-types.ts', source)).toEqual([
+ 'transport-types.ts: global WebSocket',
+ 'transport-types.ts: global XMLHttpRequest',
+ 'transport-types.ts: global fetch',
+ 'transport-types.ts: global WebSocket'
+ ]);
+ });
+
+ it('allows transport-like property names without permitting transport types', () => {
+ const source = [
+ 'type Port = { fetch: () => string; WebSocket(): string };',
+ 'interface OtherPort { XMLHttpRequest: string; fetch(): string }',
+ 'const port = { fetch: () => "local" }; port.fetch();'
+ ].join('\n');
+
+ expect(findViolations('property-names.ts', source)).toEqual([]);
+ });
+
+ it('allows local contract re-exports and the React shared-core type import', () => {
+ const path = resolve(sourceRoot, 'public-api.ts');
+
+ expect(findViolations(path, "export * from './host-remote-contract';")).toEqual([]);
+ expect(
+ findViolations(
+ reactNotebookCoreBoundaryFiles[1],
+ "export type { NotebookCoreRemoteProps } from '@zeppelin/notebook-core';",
+ forbiddenReactNotebookCoreConsumerModulePrefixes
+ )
+ ).toEqual([]);
+ });
+
+ it('rejects static, dynamic and direct transport dependencies', () => {
+ const source = [
+ `import type { OP } from '@zeppelin/sdk';`,
+ `export { useMemo } from 'react';`,
+ `type LeakedMessage = import('@zeppelin/sdk').Message;`,
+ `import { createRoot } from 'react-dom/client';`,
+ `import { Provider } from 'react-redux';`,
+ `const router = () => import('react-router-dom');`,
+ `const load = () => import('rxjs/operators');`,
+ `const request = () => fetch('/api/notebook');`,
+ `const socket = new globalThis.WebSocket('/ws');`,
+ `const xhr = new window.XMLHttpRequest();`
+ ].join('\n');
+
+ expect(findViolations('violation-fixture.ts', source)).toEqual([
+ 'violation-fixture.ts: import @zeppelin/sdk',
+ 'violation-fixture.ts: import react',
+ 'violation-fixture.ts: import @zeppelin/sdk',
+ 'violation-fixture.ts: import react-dom/client',
+ 'violation-fixture.ts: import react-redux',
+ 'violation-fixture.ts: import react-router-dom',
+ 'violation-fixture.ts: import rxjs/operators',
+ 'violation-fixture.ts: global fetch',
+ 'violation-fixture.ts: global WebSocket',
+ 'violation-fixture.ts: global XMLHttpRequest'
+ ]);
+ });
+});
diff --git a/zeppelin-web-angular/test/notebook-core/import-boundary.ts b/zeppelin-web-angular/test/notebook-core/import-boundary.ts
new file mode 100644
index 00000000000..b99b0a811a5
--- /dev/null
+++ b/zeppelin-web-angular/test/notebook-core/import-boundary.ts
@@ -0,0 +1,516 @@
+/*
+ * 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 { readdirSync, statSync } from 'node:fs';
+import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+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 reactNotebookCoreBoundaryFiles = [
+ resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src/main.ts'),
+ resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src/notebookCoreContract.ts')
+];
+const forbiddenWebSocketModulePrefixes = ['ws', '@types/ws', 'isomorphic-ws', 'sockjs-client', '@types/sockjs-client'];
+const forbiddenNodeHttpModulePrefixes = ['http', 'https', 'http2', 'node:http', 'node:https', 'node:http2'];
+export const forbiddenModulePrefixes = [
+ ...forbiddenWebSocketModulePrefixes,
+ ...forbiddenNodeHttpModulePrefixes,
+ '@angular/',
+ '@zeppelin/',
+ '@types/react',
+ '@types/react-dom',
+ 'react',
+ 'react-dom',
+ 'react-redux',
+ 'react-router',
+ 'react-router-dom',
+ 'rxjs',
+ 'axios'
+];
+export const forbiddenReactNotebookCoreConsumerModulePrefixes = [
+ ...forbiddenWebSocketModulePrefixes,
+ ...forbiddenNodeHttpModulePrefixes,
+ '@angular/common/http',
+ '@zeppelin/sdk',
+ '@zeppelin/src/',
+ '@zeppelin/services/',
+ 'axios',
+ 'rxjs'
+];
+const forbiddenGlobals = new Set(['fetch', 'WebSocket', 'XMLHttpRequest']);
+const transportGlobalOwners = new Set(['globalThis', 'window', 'self']);
+
+export const sourceFiles = (dir: string, accepts = isCheckedSourceFile): string[] =>
+ readdirSync(dir).flatMap(entry => {
+ const path = join(dir, entry);
+ if (statSync(path).isDirectory()) {
+ return sourceFiles(path, accepts);
+ }
+ return accepts(path) ? [path] : [];
+ });
+
+const checkedSourceExtensions = ['.ts', '.tsx', '.mts', '.cts'];
+
+const isSpecSourceFile = (path: string): boolean => /\.spec\.[cm]?[jt]sx?$/.test(path);
+
+const isCheckedSourceFile = (path: string): boolean => {
+ return checkedSourceExtensions.some(extension => path.endsWith(extension)) && !isSpecSourceFile(path);
+};
+
+export const findReactNotebookConsumerViolations = (
+ roots: string[] = sourceFiles(
+ resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src'),
+ path => /\.[cm]?[jt]sx?$/.test(path) && !isSpecSourceFile(path)
+ ),
+ 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 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 (target) {
+ dependencies.add(target);
+ }
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(file);
+ for (const reference of file.referencedFiles) {
+ dependencies.add(resolve(dirname(file.fileName), reference.fileName));
+ }
+ for (const reference of file.typeReferenceDirectives) {
+ const target = ts.resolveTypeReferenceDirective(reference.fileName, file.fileName, options, host)
+ .resolvedTypeReferenceDirective?.resolvedFileName;
+ if (target) {
+ dependencies.add(target);
+ }
+ }
+ modules.set(file.fileName, { source: file.text, dependencies });
+ // 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) {
+ if (!modules.has(target) && !target.includes('/node_modules/')) {
+ const source = host.readFile(target);
+ if (source !== undefined) {
+ pending.push(ts.createSourceFile(target, source, ts.ScriptTarget.Latest, true, getScriptKind(target)));
+ }
+ }
+ }
+ }
+
+ // 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,
+ ...[...modules.keys()].filter(path => path.startsWith(sourceRoot))
+ ]);
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const [path, module] of modules) {
+ if (!consumers.has(path) && [...module.dependencies].some(target => consumers.has(target))) {
+ consumers.add(path);
+ changed = true;
+ }
+ }
+ }
+
+ const violations: string[] = [];
+ const checked = new Set();
+ const main = reactNotebookCoreBoundaryFiles[0];
+ const check = (path: string): void => {
+ if (checked.has(path) || path.startsWith(sourceRoot)) {
+ return;
+ }
+ checked.add(path);
+ const module = modules.get(path);
+ if (!module) {
+ if (!path.includes('/node_modules/')) {
+ violations.push(`${path}: cannot inspect local notebook dependency`);
+ }
+ return;
+ }
+ violations.push(
+ ...findViolations(path, module.source, forbiddenReactNotebookCoreConsumerModulePrefixes, 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) {
+ return;
+ }
+ for (const target of module.dependencies) {
+ if (target === main) {
+ violations.push(`${path}: notebook consumer must use the contract bridge instead of the public aggregator`);
+ } else {
+ check(target);
+ }
+ }
+ };
+ consumers.forEach(check);
+ 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[] => {
+ const violations = findViolations(path, source, forbiddenReactNotebookCoreConsumerModulePrefixes);
+ const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, getScriptKind(path));
+ const visit = (node: ts.Node): void => {
+ const specifier = getModuleSpecifier(node);
+ if (specifier && specifier !== '@zeppelin/notebook-core') {
+ violations.push(`${path}: notebook contract dependency ${specifier}`);
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(sourceFile);
+ return violations;
+};
+
+export const findViolations = (
+ path: string,
+ source: string,
+ forbiddenPrefixes: readonly string[] = forbiddenModulePrefixes,
+ compilerOptions = readCompilerOptions(
+ resolve(
+ zeppelinWebAngularRoot,
+ 'projects',
+ path.includes('zeppelin-react') ? 'zeppelin-react' : 'zeppelin-notebook-core',
+ 'tsconfig.json'
+ )
+ ),
+ resolutionHost: ts.ModuleResolutionHost = ts.sys
+): string[] => {
+ const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, getScriptKind(path));
+ const violations: string[] = [];
+
+ // Bind lexical declarations without loading libraries or following imports.
+ // Import provenance is checked separately; unresolved reserved names here
+ // represent globals, while parameters, imports and local declarations do not.
+ let bindingChecker: ts.TypeChecker | undefined;
+ const isGlobalIdentifier = (node: ts.Identifier): boolean => {
+ if (!bindingChecker) {
+ const options: ts.CompilerOptions = { noLib: true, noResolve: true, types: [], allowJs: true };
+ const host = ts.createCompilerHost(options);
+ host.getSourceFile = file => (file === path ? sourceFile : undefined);
+ bindingChecker = ts.createProgram([path], options, host).getTypeChecker();
+ }
+ const symbol = ts.isShorthandPropertyAssignment(node.parent)
+ ? bindingChecker.getShorthandAssignmentValueSymbol(node.parent)
+ : bindingChecker.getSymbolAtLocation(node);
+ return !symbol?.declarations?.length;
+ };
+
+ // Only tslib is a reviewed external dependency of this scaffold. Resolve it
+ // without project aliases so a package name cannot disguise another target.
+ const tslibFile = ts.resolveModuleName(
+ 'tslib',
+ path,
+ {
+ ...compilerOptions,
+ baseUrl: undefined,
+ paths: {}
+ },
+ resolutionHost
+ ).resolvedModule?.resolvedFileName;
+ const isForbidden = (identities: readonly string[]): boolean => {
+ if (path.startsWith(sourceRoot)) {
+ const target = identities.filter(isAbsolute).pop();
+ const checkedLocalSource =
+ target !== undefined &&
+ target.startsWith(sourceRoot) &&
+ !target.includes('/node_modules/') &&
+ isCheckedSourceFile(target) &&
+ resolutionHost.fileExists(target);
+ if (!checkedLocalSource && !(target !== undefined && target === tslibFile)) {
+ return true;
+ }
+ }
+ return identities.some(
+ identity =>
+ identity === '' ||
+ isSpecSourceFile(identity) ||
+ (!matchesModulePrefix(identity, '@zeppelin/notebook-core') &&
+ forbiddenPrefixes.some(prefix => matchesModulePrefix(identity, prefix)))
+ );
+ };
+
+ // Reference directives are source-file metadata, not import AST nodes.
+ for (const reference of sourceFile.libReferenceDirectives) {
+ if (/^(?:dom|webworker)(?:\.|$)/i.test(reference.fileName)) {
+ violations.push(`${path}: library ${reference.fileName}`);
+ }
+ }
+ for (const reference of sourceFile.referencedFiles) {
+ const target = resolve(dirname(path), reference.fileName);
+ if (isForbidden(moduleIdentities(target, path, compilerOptions, resolutionHost))) {
+ violations.push(`${path}: reference ${reference.fileName}`);
+ }
+ }
+ for (const reference of sourceFile.typeReferenceDirectives) {
+ const resolved = ts.resolveTypeReferenceDirective(
+ reference.fileName,
+ path,
+ compilerOptions,
+ resolutionHost
+ ).resolvedTypeReferenceDirective;
+ const identities = [reference.fileName];
+ if (resolved?.resolvedFileName) {
+ identities.push(...moduleIdentities(resolved.resolvedFileName, path, compilerOptions, resolutionHost));
+ }
+ if (isForbidden(identities)) {
+ violations.push(`${path}: reference ${reference.fileName}`);
+ }
+ }
+
+ const visit = (node: ts.Node): void => {
+ const moduleSpecifier = getModuleSpecifier(node);
+ if (moduleSpecifier && isForbidden(moduleIdentities(moduleSpecifier, path, compilerOptions, resolutionHost))) {
+ violations.push(`${path}: import ${moduleSpecifier}`);
+ }
+
+ const forbiddenGlobal = getForbiddenTransportGlobalName(node, isGlobalIdentifier);
+ if (forbiddenGlobal) {
+ violations.push(`${path}: global ${forbiddenGlobal}`);
+ }
+ const globalAlias = getGlobalObjectAlias(node, isGlobalIdentifier);
+ if (globalAlias) {
+ violations.push(`${path}: global object alias ${globalAlias}`);
+ }
+
+ ts.forEachChild(node, visit);
+ };
+
+ visit(sourceFile);
+ return violations;
+};
+
+export const formatViolations = (violations: readonly string[]): string[] => {
+ return violations.map(violation => violation.replace(`${zeppelinWebAngularRoot}/`, ''));
+};
+
+const getScriptKind = (path: string): ts.ScriptKind => {
+ if (path.endsWith('.tsx')) {
+ return ts.ScriptKind.TSX;
+ }
+ if (path.endsWith('.jsx')) {
+ return ts.ScriptKind.JSX;
+ }
+ if (/\.[cm]?js$/.test(path)) {
+ return ts.ScriptKind.JS;
+ }
+ return ts.ScriptKind.TS;
+};
+
+const getModuleSpecifier = (node: ts.Node): string | null => {
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier) {
+ return ts.isStringLiteral(node.moduleSpecifier) ? node.moduleSpecifier.text : null;
+ }
+ if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && ts.isStringLiteral(node.argument.literal)) {
+ return node.argument.literal.text;
+ }
+ if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
+ const expression = node.moduleReference.expression;
+ return expression && ts.isStringLiteral(expression) ? expression.text : null;
+ }
+ if (
+ ts.isCallExpression(node) &&
+ node.arguments.length >= 1 &&
+ (node.expression.kind === ts.SyntaxKind.ImportKeyword ||
+ (ts.isIdentifier(node.expression) && node.expression.text === 'require'))
+ ) {
+ let argument = node.arguments[0];
+ while (ts.isParenthesizedExpression(argument)) {
+ argument = argument.expression;
+ }
+ // Restricted core/bridge dependencies must be statically identifiable.
+ return ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument)
+ ? argument.text
+ : '';
+ }
+ return null;
+};
+
+export const readCompilerOptions = (path: string): ts.CompilerOptions => {
+ const config = ts.readConfigFile(path, ts.sys.readFile);
+ if (config.error) {
+ throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'));
+ }
+ const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, dirname(path));
+ if (parsed.errors.length > 0) {
+ throw new Error(
+ ts.formatDiagnostics(parsed.errors, {
+ getCanonicalFileName: file => file,
+ getCurrentDirectory: () => zeppelinWebAngularRoot,
+ getNewLine: () => '\n'
+ })
+ );
+ }
+ return parsed.options;
+};
+
+// Resolve aliases and relative paths before comparing package identities. Checking
+// the import spelling alone lets the same forbidden dependency cross the boundary.
+const moduleIdentities = (
+ specifier: string,
+ path: string,
+ compilerOptions: ts.CompilerOptions,
+ resolutionHost: ts.ModuleResolutionHost = ts.sys
+): string[] => {
+ const identities = [specifier];
+ const resolved = ts.resolveModuleName(specifier, path, compilerOptions, resolutionHost).resolvedModule;
+ if (!resolved) {
+ return identities;
+ }
+ if (resolved.packageId) {
+ identities.push(resolved.packageId.name, `${resolved.packageId.name}/${resolved.packageId.subModuleName}`);
+ }
+ const file = resolved.resolvedFileName.replace(/\\/g, '/');
+ // Keep the resolved filename so aliases cannot hide excluded test sources.
+ identities.push(file);
+ const nodeModules = file.lastIndexOf('/node_modules/');
+ if (nodeModules !== -1) {
+ identities.push(file.slice(nodeModules + '/node_modules/'.length));
+ }
+ const localPath = relative(zeppelinWebAngularRoot, file).replace(/\\/g, '/');
+ const project = /^(?:projects|dist)\/zeppelin-([^/]+)(\/.*)?$/.exec(localPath);
+ if (project) {
+ identities.push(`@zeppelin/${project[1]}${project[2] ?? ''}`);
+ }
+ if (/^src\/(?:app|environments)\//.test(localPath)) {
+ identities.push(`@zeppelin/${localPath}`);
+ }
+ return identities;
+};
+
+const matchesModulePrefix = (moduleSpecifier: string, prefix: string): boolean => {
+ return moduleSpecifier === prefix || moduleSpecifier.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`);
+};
+
+const isReferenceIdentifier = (node: ts.Identifier): boolean => {
+ const parent = node.parent;
+ return !(
+ (ts.isPropertyAccessExpression(parent) && parent.name === node) ||
+ (ts.isBindingElement(parent) && parent.propertyName === node) ||
+ (ts.isPropertyAssignment(parent) && parent.name === node) ||
+ (ts.isMethodDeclaration(parent) && parent.name === node) ||
+ (ts.isPropertyDeclaration(parent) && parent.name === node) ||
+ (ts.isPropertySignature(parent) && parent.name === node) ||
+ (ts.isMethodSignature(parent) && parent.name === node)
+ );
+};
+
+// Keep direct, non-transport browser properties usable, but do not capture the
+// entire global object in a local alias that hides later transport access.
+// This is a declaration/assignment rule, not general alias or dataflow analysis.
+const unwrapExpression = (input: ts.Expression): ts.Expression => {
+ let expression = input;
+ while (
+ ts.isParenthesizedExpression(expression) ||
+ ts.isAsExpression(expression) ||
+ ts.isTypeAssertionExpression(expression) ||
+ ts.isNonNullExpression(expression) ||
+ ts.isSatisfiesExpression(expression)
+ ) {
+ expression = expression.expression;
+ }
+ return expression;
+};
+
+const getGlobalObjectAlias = (node: ts.Node, isGlobal: (node: ts.Identifier) => boolean): string | null => {
+ const initializer =
+ ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)
+ ? node.initializer
+ : ts.isBinaryExpression(node) &&
+ node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
+ ts.isIdentifier(node.left)
+ ? node.right
+ : undefined;
+ const value = initializer && unwrapExpression(initializer);
+ return value && ts.isIdentifier(value) && transportGlobalOwners.has(value.text) && isGlobal(value)
+ ? value.text
+ : null;
+};
+
+const getForbiddenTransportGlobalName = (node: ts.Node, isGlobal: (node: ts.Identifier) => boolean): string | null => {
+ const destructuring =
+ ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name) && node.initializer
+ ? { elements: node.name.elements, value: node.initializer }
+ : ts.isBinaryExpression(node) &&
+ node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
+ ts.isObjectLiteralExpression(node.left)
+ ? { elements: node.left.properties, value: node.right }
+ : undefined;
+ if (destructuring) {
+ const owner = unwrapExpression(destructuring.value);
+ if (ts.isIdentifier(owner) && transportGlobalOwners.has(owner.text) && isGlobal(owner)) {
+ for (const element of destructuring.elements) {
+ const name = ts.isBindingElement(element)
+ ? (element.propertyName ?? element.name)
+ : ts.isShorthandPropertyAssignment(element) || ts.isPropertyAssignment(element)
+ ? element.name
+ : undefined;
+ if (name && (ts.isIdentifier(name) || ts.isStringLiteral(name)) && forbiddenGlobals.has(name.text)) {
+ return name.text;
+ }
+ }
+ }
+ }
+ if (ts.isIndexedAccessTypeNode(node) && ts.isLiteralTypeNode(node.indexType)) {
+ const index = node.indexType.literal;
+ let owner = node.objectType;
+ while (ts.isParenthesizedTypeNode(owner)) {
+ owner = owner.type;
+ }
+ if (
+ (ts.isStringLiteral(index) || ts.isNoSubstitutionTemplateLiteral(index)) &&
+ forbiddenGlobals.has(index.text) &&
+ ts.isTypeQueryNode(owner) &&
+ ts.isIdentifier(owner.exprName) &&
+ transportGlobalOwners.has(owner.exprName.text) &&
+ isGlobal(owner.exprName)
+ ) {
+ return index.text;
+ }
+ }
+ if (
+ ts.isElementAccessExpression(node) &&
+ (ts.isStringLiteral(node.argumentExpression) || ts.isNoSubstitutionTemplateLiteral(node.argumentExpression)) &&
+ forbiddenGlobals.has(node.argumentExpression.text)
+ ) {
+ const owner = unwrapExpression(node.expression);
+ if (ts.isIdentifier(owner) && transportGlobalOwners.has(owner.text) && isGlobal(owner)) {
+ return node.argumentExpression.text;
+ }
+ }
+ if (ts.isIdentifier(node) && forbiddenGlobals.has(node.text) && isReferenceIdentifier(node) && isGlobal(node)) {
+ return node.text;
+ }
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name) && forbiddenGlobals.has(node.name.text)) {
+ const owner = unwrapExpression(node.expression);
+ if (ts.isIdentifier(owner) && transportGlobalOwners.has(owner.text) && isGlobal(owner)) {
+ return node.name.text;
+ }
+ }
+ return null;
+};
diff --git a/zeppelin-web-angular/tsconfig.base.json b/zeppelin-web-angular/tsconfig.base.json
index 7fee7c8e794..61c7c1ac8f4 100644
--- a/zeppelin-web-angular/tsconfig.base.json
+++ b/zeppelin-web-angular/tsconfig.base.json
@@ -6,6 +6,7 @@
"@zeppelin/*": ["./src/app/*", "./src/environments/*"],
"@zeppelin/visualization": ["dist/zeppelin-visualization"],
"@zeppelin/visualization/*": ["dist/zeppelin-visualization/*"],
+ "@zeppelin/notebook-core": ["dist/zeppelin-notebook-core"],
"@zeppelin/sdk": ["dist/zeppelin-sdk"],
"@zeppelin/sdk/*": ["dist/zeppelin-sdk/*"]
},
diff --git a/zeppelin-web-angular/vitest.notebook-core.config.mts b/zeppelin-web-angular/vitest.notebook-core.config.mts
new file mode 100644
index 00000000000..d4da29c1a60
--- /dev/null
+++ b/zeppelin-web-angular/vitest.notebook-core.config.mts
@@ -0,0 +1,20 @@
+/*
+ * 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 { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'node',
+ include: ['projects/zeppelin-notebook-core/**/*.spec.ts', 'test/notebook-core/**/*.spec.ts']
+ }
+});
diff --git a/zeppelin-web-angular/vitest.shell.config.mts b/zeppelin-web-angular/vitest.shell.config.mts
index c6b35960aae..c7174a49435 100644
--- a/zeppelin-web-angular/vitest.shell.config.mts
+++ b/zeppelin-web-angular/vitest.shell.config.mts
@@ -12,11 +12,11 @@
// vite is pinned in package.json: the React remote keeps its own lockfile and drifted to a different minor.
import { fileURLToPath } from 'node:url';
-import { defineConfig } from 'vitest/config';
+import { configDefaults, defineConfig } from 'vitest/config';
export default defineConfig({
// Mirrors the `paths` block in tsconfig.base.json, which Vite does not read.
- // The two library aliases resolve to source, not `dist/`, so a unit run does not wait on a build.
+ // The library aliases resolve to source, not `dist/`, so a unit run does not wait on a build.
resolve: {
alias: [
// monaco-editor ships no `main` and no `exports` (microsoft/monaco-editor#4848).
@@ -39,6 +39,10 @@ export default defineConfig({
find: /^@zeppelin\/visualization\/(.*)$/,
replacement: `${fileURLToPath(new URL('./projects/zeppelin-visualization/src', import.meta.url))}/$1`
},
+ {
+ find: /^@zeppelin\/notebook-core$/,
+ replacement: fileURLToPath(new URL('./projects/zeppelin-notebook-core/src/public-api.ts', import.meta.url))
+ },
// `@zeppelin/*` falls back to src/environments in tsconfig; Vite aliases do not.
{
find: /^@zeppelin\/environment$/,
@@ -64,6 +68,7 @@ export default defineConfig({
'projects/zeppelin-visualization/**/*.spec.ts',
'test/**/*.spec.ts'
],
+ exclude: [...configDefaults.exclude, 'test/notebook-core/**'],
setupFiles: ['./test/test-setup.ts'],
coverage: {
provider: 'v8',