From d9f19207ad209e2a41ad147958eaadb5222c93d4 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Mon, 31 Aug 2026 21:03:32 +0900 Subject: [PATCH] [ZEPPELIN-6670] Add Shared Notebook Core mount contract --- 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 + .../zeppelin-notebook-core/package.json | 8 + .../src/host-remote-contract.spec.ts | 78 +++ .../src/host-remote-contract.ts | 29 + .../zeppelin-notebook-core/src/public-api.ts | 13 + .../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/notebookCoreContract.ts | 13 + .../projects/zeppelin-react/tsconfig.json | 1 + .../tsconfig.notebook-core.dist.json | 8 + .../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 + .../test/notebook-core/compiler-fixture.ts | 21 + .../notebook-core/import-boundary.spec.ts | 609 ++++++++++++++++++ .../test/notebook-core/import-boundary.ts | 516 +++++++++++++++ zeppelin-web-angular/tsconfig.base.json | 1 + .../vitest.notebook-core.config.mts | 20 + zeppelin-web-angular/vitest.shell.config.mts | 9 +- 29 files changed, 1453 insertions(+), 15 deletions(-) create mode 100644 zeppelin-web-angular/projects/zeppelin-notebook-core/ng-package.json create mode 100644 zeppelin-web-angular/projects/zeppelin-notebook-core/package.json create mode 100644 zeppelin-web-angular/projects/zeppelin-notebook-core/src/host-remote-contract.spec.ts create mode 100644 zeppelin-web-angular/projects/zeppelin-notebook-core/src/host-remote-contract.ts create mode 100644 zeppelin-web-angular/projects/zeppelin-notebook-core/src/public-api.ts create mode 100644 zeppelin-web-angular/projects/zeppelin-notebook-core/tsconfig.json create mode 100644 zeppelin-web-angular/projects/zeppelin-notebook-core/tsconfig.spec.json create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/notebookCoreContract.ts create mode 100644 zeppelin-web-angular/projects/zeppelin-react/tsconfig.notebook-core.dist.json create mode 100644 zeppelin-web-angular/projects/zeppelin-react/tsconfig.notebook-core.json create mode 100644 zeppelin-web-angular/test/notebook-core/compiler-fixture.ts create mode 100644 zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts create mode 100644 zeppelin-web-angular/test/notebook-core/import-boundary.ts create mode 100644 zeppelin-web-angular/vitest.notebook-core.config.mts 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',