This is an automated email from the ASF dual-hosted git repository.
tbonelee pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git
The following commit(s) were added to refs/heads/master by this push:
new 788d45aa37 [ZEPPELIN-6670] Add Shared Notebook Core mount contract
788d45aa37 is described below
commit 788d45aa372dcdeb35fa7903fbf2a2f267201e96
Author: YONGJAE LEE (이용재) <[email protected]>
AuthorDate: Sun Sep 13 17:17:27 2026 +0900
[ZEPPELIN-6670] Add Shared Notebook Core mount contract
### What is this PR for?
Add `<at>zeppelin/notebook-core` as a shared package for the Angular host
and React remote.
Define common types for the read-only snapshot, `getSnapshot`, `subscribe`,
and mount props. Add dependency-boundary checks and connect the package build,
typecheck, and unit tests to the existing Maven/CI lifecycle.
This change does not implement the Core runtime. Separate-build object
identity and notebook route integration will be verified in ZEPPELIN-6674 and
ZEPPELIN-6675.
### What type of PR is it?
Improvement
### What is the Jira issue?
ZEPPELIN-6670
### How should this be tested?
The following checks passed locally in `zeppelin-web-angular`:
* `npm run test:notebook-core`: 103 tests passed
* `npm run typecheck:notebook-core`: passed
* `npm run test:shell`: 49 tests passed
* `npm run check:notebook-parity-scenarios`: 21 tests and the registry
check passed
* ESLint and Prettier checks for the changed code: passed
### Screenshots (if appropriate)
No UI changes
### Questions:
* Does the license files need to update? No
* Is there breaking changes for older versions? No
* Does this needs documentation? Updated README and AGENTS.md with the
check commands and file locations.
Closes #5469 from voidmatcha/ZEPPELIN-6670-shared-core-contract.
Signed-off-by: ChanHo Lee <[email protected]>
---
zeppelin-web-angular/AGENTS.md | 15 +-
zeppelin-web-angular/README.md | 4 +
zeppelin-web-angular/angular.json | 21 +
zeppelin-web-angular/eslint.config.js | 10 +-
zeppelin-web-angular/package.json | 5 +-
zeppelin-web-angular/pom.xml | 22 +
.../zeppelin-notebook-core/ng-package.json | 7 +
.../projects/zeppelin-notebook-core/package.json | 8 +
.../src/host-remote-contract.spec.ts | 78 +++
.../src/host-remote-contract.ts} | 20 +-
.../src/public-api.ts} | 4 +-
.../projects/zeppelin-notebook-core/tsconfig.json | 13 +
.../zeppelin-notebook-core/tsconfig.spec.json | 9 +
.../projects/zeppelin-react/package-lock.json | 12 +
.../projects/zeppelin-react/package.json | 1 +
.../projects/zeppelin-react/src/main.ts | 1 +
.../src/{main.ts => notebookCoreContract.ts} | 4 +-
.../projects/zeppelin-react/tsconfig.json | 1 +
.../tsconfig.notebook-core.dist.json | 8 +
.../zeppelin-react/tsconfig.notebook-core.json | 5 +
.../projects/zeppelin-react/vitest.config.mts | 14 +-
.../projects/zeppelin-react/webpack.config.js | 1 +
zeppelin-web-angular/src/tsconfig.spec.json | 2 +
.../notebook-core/compiler-fixture.ts} | 12 +-
.../test/notebook-core/import-boundary.spec.ts | 611 +++++++++++++++++++++
.../test/notebook-core/import-boundary.ts | 521 ++++++++++++++++++
zeppelin-web-angular/tsconfig.base.json | 1 +
.../main.ts => vitest.notebook-core.config.mts} | 11 +-
zeppelin-web-angular/vitest.shell.config.mts | 9 +-
29 files changed, 1400 insertions(+), 30 deletions(-)
diff --git a/zeppelin-web-angular/AGENTS.md b/zeppelin-web-angular/AGENTS.md
index 92b08f597a..29aab36887 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-6 [...]
+`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. Gi [...]
## 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 f90c26f32b..ddd3350278 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 2dc47a4154..d1595667a7 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 cca475fe7c..239a119789 100644
--- a/zeppelin-web-angular/eslint.config.js
+++ b/zeppelin-web-angular/eslint.config.js
@@ -187,9 +187,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: {
@@ -201,7 +203,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 8c72a7eb2c..c5e994ea0a 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,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",
+ "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",
diff --git a/zeppelin-web-angular/pom.xml b/zeppelin-web-angular/pom.xml
index b31d8825c9..9c62c32d14 100644
--- a/zeppelin-web-angular/pom.xml
+++ b/zeppelin-web-angular/pom.xml
@@ -155,6 +155,28 @@
</configuration>
</execution>
+ <execution>
+ <id>npm typecheck notebook core</id>
+ <goals>
+ <goal>npm</goal>
+ </goals>
+ <phase>test</phase>
+ <configuration>
+ <arguments>run typecheck:notebook-core</arguments>
+ </configuration>
+ </execution>
+
+ <execution>
+ <id>npm test notebook core</id>
+ <goals>
+ <goal>npm</goal>
+ </goals>
+ <phase>test</phase>
+ <configuration>
+ <arguments>run test:notebook-core</arguments>
+ </configuration>
+ </execution>
+
<execution>
<id>npm e2e</id>
<goals>
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 0000000000..b7ef11c43a
--- /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 0000000000..5431edbe97
--- /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 0000000000..b3e3ee86c8
--- /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<NotebookCoreSnapshotListener>();
+ 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-react/src/main.ts
b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/host-remote-contract.ts
similarity index 54%
copy from zeppelin-web-angular/projects/zeppelin-react/src/main.ts
copy to
zeppelin-web-angular/projects/zeppelin-notebook-core/src/host-remote-contract.ts
index ce7edc883f..9a62c63e67 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
+++
b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/host-remote-contract.ts
@@ -10,6 +10,20 @@
* limitations under the License.
*/
-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 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-react/src/main.ts
b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/public-api.ts
similarity index 66%
copy from zeppelin-web-angular/projects/zeppelin-react/src/main.ts
copy to zeppelin-web-angular/projects/zeppelin-notebook-core/src/public-api.ts
index ce7edc883f..0d82f21dde 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
+++ b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/public-api.ts
@@ -10,6 +10,4 @@
* limitations under the License.
*/
-export { ConfigurationTable, mount as mountConfigurationTable } from
'./pages/ConfigurationTable';
-export { PublishedParagraph, mount } from './pages/PublishedParagraph';
-export { ParagraphFooter, mount as mountParagraphFooter } from
'./components/paragraph/ParagraphFooter';
+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 0000000000..fcfd714eac
--- /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 0000000000..b491576100
--- /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 8d4f3da1cf..11ce482ea1 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 8f4d609441..6905d5001d 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 ce7edc883f..66f7f95a0a 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/main.ts
b/zeppelin-web-angular/projects/zeppelin-react/src/notebookCoreContract.ts
similarity index 66%
copy from zeppelin-web-angular/projects/zeppelin-react/src/main.ts
copy to zeppelin-web-angular/projects/zeppelin-react/src/notebookCoreContract.ts
index ce7edc883f..1eceaf420c 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/notebookCoreContract.ts
@@ -10,6 +10,4 @@
* limitations under the License.
*/
-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 '@zeppelin/notebook-core';
diff --git a/zeppelin-web-angular/projects/zeppelin-react/tsconfig.json
b/zeppelin-web-angular/projects/zeppelin-react/tsconfig.json
index 3dba52c585..ea736257f2 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 0000000000..12ee26106c
--- /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 0000000000..8516d2cf5f
--- /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 e2ebae3144..c21ab1ab78 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 d7de57b3d9..aa73dc1647 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 409fd8b367..07cf05eef6 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/projects/zeppelin-react/src/main.ts
b/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
similarity index 59%
copy from zeppelin-web-angular/projects/zeppelin-react/src/main.ts
copy to zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
index ce7edc883f..7b1ad5f998 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
+++ b/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
@@ -10,6 +10,12 @@
* limitations under the License.
*/
-export { ConfigurationTable, mount as mountConfigurationTable } from
'./pages/ConfigurationTable';
-export { PublishedParagraph, mount } from './pages/PublishedParagraph';
-export { ParagraphFooter, mount as mountParagraphFooter } from
'./components/paragraph/ParagraphFooter';
+import ts from 'typescript';
+
+export const createFixtureHost = (options: ts.CompilerOptions, files:
ReadonlyMap<string, string>): ts.CompilerHost => {
+ const host = ts.createCompilerHost(options);
+ const { readFile, fileExists } = host;
+ host.readFile = file => files.get(file) ?? readFile(file);
+ host.fileExists = file => files.has(file) || fileExists(file);
+ return host;
+};
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 0000000000..be3fc00f56
--- /dev/null
+++ b/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts
@@ -0,0 +1,611 @@
+/*
+ * 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 = `/// <reference types="node" />\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' ? '/// <reference
path="../framework.d.ts" />\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([]);
+ });
+
+ // This builds and walks a TypeScript program over the whole React source
tree, which runs past
+ // Vitest's default 5s timeout on slower machines/CI runners even with no
violations.
+ it('checks all production React consumers of the notebook contract', () => {
+ expect(findReactNotebookConsumerViolations()).toEqual([]);
+ }, 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 => {
+ 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<string, string>([
+ [
+ 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' ? '<computed module
dependency>' : '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, `/// <reference ${kind}="${target}"
/>`)).toEqual([`${path}: reference ${target}`]);
+ });
+
+ it.each(['dom', 'dom.iterable', 'webworker', 'webworker.importscripts'])(
+ 'rejects browser library reference %s',
+ lib => {
+ expect(findViolations('library.ts', `/// <reference lib="${lib}"
/>`)).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', '/// <reference lib="es2020"
/>')).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 = `/// <reference lib="dom" />\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, '/// <reference
path="./host-remote-contract.spec.ts" />')).toEqual([
+ `${path}: reference ./host-remote-contract.spec.ts`
+ ]);
+ expect(findViolations(path, '/// <reference
types="./host-remote-contract.spec.ts" />')).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, '/// <reference
path="./host-remote-contract.ts" />')).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 0000000000..e79389112e
--- /dev/null
+++ b/zeppelin-web-angular/test/notebook-core/import-boundary.ts
@@ -0,0 +1,521 @@
+/*
+ * 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<string, { source: string; dependencies: Set<string>
}>();
+ const pending = [...program.getSourceFiles()];
+ for (const file of pending) {
+ if (modules.has(file.fileName) ||
file.fileName.includes('/node_modules/')) {
+ continue;
+ }
+ const dependencies = new Set<string>();
+ const visit = (node: ts.Node): void => {
+ const specifier = getModuleSpecifier(node);
+ if (specifier) {
+ const target = ts.resolveModuleName(specifier, file.fileName, options,
host).resolvedModule?.resolvedFileName;
+ if (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<string>();
+ 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 if (consumers.has(target)) {
+ // Only walk further into files the upward propagation above already
marked as
+ // consumers. Otherwise a shared route/aggregator file that imports
both a real
+ // notebook-core consumer and an unrelated pre-existing SDK-backed
page would drag
+ // that page's own dependency subtree into this scan, flagging its
legitimate
+ // `@zeppelin/sdk`/`rxjs` imports as boundary violations.
+ 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 === '<computed module dependency>' ||
+ 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
+ : '<computed module dependency>';
+ }
+ 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 7fee7c8e79..61c7c1ac8f 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/projects/zeppelin-react/src/main.ts
b/zeppelin-web-angular/vitest.notebook-core.config.mts
similarity index 66%
copy from zeppelin-web-angular/projects/zeppelin-react/src/main.ts
copy to zeppelin-web-angular/vitest.notebook-core.config.mts
index ce7edc883f..d4da29c1a6 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
+++ b/zeppelin-web-angular/vitest.notebook-core.config.mts
@@ -10,6 +10,11 @@
* limitations under the License.
*/
-export { ConfigurationTable, mount as mountConfigurationTable } from
'./pages/ConfigurationTable';
-export { PublishedParagraph, mount } from './pages/PublishedParagraph';
-export { ParagraphFooter, mount as mountParagraphFooter } from
'./components/paragraph/ParagraphFooter';
+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 c6b35960aa..c7174a4943 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',