diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index e7da04d72d5..3e6f4ba206d 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -119,8 +119,8 @@ jobs: mkdir -p $ZEPPELIN_E2E_TEST_NOTEBOOK_DIR echo "Created test notebook directory: $ZEPPELIN_E2E_TEST_NOTEBOOK_DIR" - name: Run headless E2E test with Maven - # Classic UI e2e runs only on the anonymous leg, like the legacy Protractor suite - run: xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" ./mvnw verify -pl zeppelin-web-angular -Pweb-e2e -Dweb.e2e.classic.disabled=${{ matrix.mode != 'anonymous' }} ${MAVEN_ARGS} + # Classic UI e2e and the notebook core port proof run only on the anonymous leg + run: xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" ./mvnw verify -pl zeppelin-web-angular -Pweb-e2e -Dweb.e2e.classic.disabled=${{ matrix.mode != 'anonymous' }} -Dweb.e2e.core.port.proof.disabled=${{ matrix.mode != 'anonymous' }} ${MAVEN_ARGS} - name: Run revision isolation E2E test with Git storage env: CI: 'true' diff --git a/zeppelin-web-angular/angular.json b/zeppelin-web-angular/angular.json index d1595667a75..9062ad69a8e 100644 --- a/zeppelin-web-angular/angular.json +++ b/zeppelin-web-angular/angular.json @@ -156,6 +156,52 @@ } } }, + "notebook-core-port-proof": { + "root": "e2e/core-contract/angular-host", + "sourceRoot": "e2e/core-contract/angular-host", + "projectType": "application", + "prefix": "zeppelin", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/notebook-core-port-proof", + "index": "e2e/core-contract/angular-host/index.html", + "main": "e2e/core-contract/angular-host/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "e2e/core-contract/angular-host/tsconfig.json", + "assets": [ + { + "glob": "**/*", + "input": "./e2e/core-contract/react-remote/dist", + "output": "/assets/react/" + } + ], + "styles": [], + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "none", + "sourceMap": false, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": false + } + }, + "defaultConfiguration": "production" + } + } + }, "zeppelin-visualization": { "projectType": "library", "root": "projects/zeppelin-visualization", diff --git a/zeppelin-web-angular/e2e/core-contract/angular-host/index.html b/zeppelin-web-angular/e2e/core-contract/angular-host/index.html new file mode 100644 index 00000000000..34aa7aed455 --- /dev/null +++ b/zeppelin-web-angular/e2e/core-contract/angular-host/index.html @@ -0,0 +1,29 @@ + + + + + + + Notebook core port proof + + + + + + + diff --git a/zeppelin-web-angular/e2e/core-contract/angular-host/main.ts b/zeppelin-web-angular/e2e/core-contract/angular-host/main.ts new file mode 100644 index 00000000000..447d1cccd3d --- /dev/null +++ b/zeppelin-web-angular/e2e/core-contract/angular-host/main.ts @@ -0,0 +1,85 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CommonModule } from '@angular/common'; +import { Component, NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; +import { ReactMountDirective } from '@zeppelin/share/react-mount'; +import type { NotebookCorePort, NotebookCoreSnapshot } from '@zeppelin/notebook-core'; + +declare global { + interface Window { + __zeppelinNotebookCorePortProof?: { + hostCore: NotebookCorePort; + proofs: unknown[]; + receivedCore?: NotebookCorePort; + }; + } +} + +@Component({ + selector: 'zeppelin-notebook-core-port-proof', + standalone: false, + template: ` + +
+ ` +}) +export class NotebookCorePortProofComponent { + readonly core: NotebookCorePort = Object.freeze({ + getSnapshot: () => this.snapshot, + subscribe: listener => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + }); + + readonly reactProps = { + core: this.core, + expectedCore: this.core, + onProof: (proof: unknown) => { + window.__zeppelinNotebookCorePortProof?.proofs.push(proof); + }, + onReceivedCore: (receivedCore: NotebookCorePort) => { + window.__zeppelinNotebookCorePortProof!.receivedCore = receivedCore; + } + }; + + private snapshot: NotebookCoreSnapshot = { noteId: 'note-host-owned', revisionId: null }; + private readonly listeners = new Set<() => void>(); + + constructor() { + window.__zeppelinNotebookCorePortProof = { + hostCore: this.core, + proofs: [] + }; + } + + publishRevision(): void { + this.snapshot = { noteId: 'note-host-owned', revisionId: 'revision-from-angular-host' }; + for (const listener of this.listeners) { + listener(); + } + } +} + +@NgModule({ + bootstrap: [NotebookCorePortProofComponent], + declarations: [NotebookCorePortProofComponent, ReactMountDirective], + imports: [BrowserModule, CommonModule] +}) +export class NotebookCorePortProofModule {} + +void platformBrowserDynamic().bootstrapModule(NotebookCorePortProofModule); diff --git a/zeppelin-web-angular/e2e/core-contract/angular-host/tsconfig.json b/zeppelin-web-angular/e2e/core-contract/angular-host/tsconfig.json new file mode 100644 index 00000000000..e637f8f629e --- /dev/null +++ b/zeppelin-web-angular/e2e/core-contract/angular-host/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "ignoreDeprecations": "5.0", + "outDir": "../../../out-tsc/notebook-core-port-proof", + "types": [] + }, + "files": ["main.ts"], + "angularCompilerOptions": { + "strictInjectionParameters": true, + "strictTemplates": true + } +} diff --git a/zeppelin-web-angular/e2e/core-contract/notebook-core-port-identity.test.mjs b/zeppelin-web-angular/e2e/core-contract/notebook-core-port-identity.test.mjs new file mode 100644 index 00000000000..f75c774dfa6 --- /dev/null +++ b/zeppelin-web-angular/e2e/core-contract/notebook-core-port-identity.test.mjs @@ -0,0 +1,149 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import assert from 'node:assert/strict'; +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { extname, isAbsolute, join, relative, resolve } from 'node:path'; +import { after, before, test } from 'node:test'; +import { pathToFileURL } from 'node:url'; + +import { chromium, expect } from '@playwright/test'; + +const angularDistRoot = resolve('dist/notebook-core-port-proof'); +const angularIndexPath = join(angularDistRoot, 'index.html'); +const remoteEntryPath = join(angularDistRoot, 'assets/react/remoteEntry.js'); + +let browser; +let server; +let baseUrl; + +const contentTypes = new Map([ + ['.css', 'text/css; charset=utf-8'], + ['.html', 'text/html; charset=utf-8'], + ['.js', 'text/javascript; charset=utf-8'] +]); + +function resolveInsideAngularDist(requestPath) { + const decodedPath = decodeURIComponent(requestPath.replace(/^\//, '')); + const filePath = resolve(angularDistRoot, decodedPath); + const rootRelativePath = relative(angularDistRoot, filePath); + + if (rootRelativePath.startsWith('..') || isAbsolute(rootRelativePath)) { + return null; + } + + return filePath; +} + +function isFile(filePath) { + return statSync(filePath, { throwIfNoEntry: false })?.isFile() ?? false; +} + +function serveStaticFile(response, requestPath) { + const filePath = resolveInsideAngularDist(requestPath); + + if (!filePath || !isFile(filePath)) { + response.writeHead(404); + response.end('not found'); + return; + } + + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-type': contentTypes.get(extname(filePath)) ?? 'application/octet-stream' + }); + createReadStream(filePath).pipe(response); +} + +before(async () => { + assert.ok( + existsSync(angularIndexPath), + `Angular host build output is missing: run "npm run build:notebook-core-port-proof" before this proof (${pathToFileURL( + angularIndexPath + )})` + ); + assert.ok( + existsSync(remoteEntryPath), + `React remote asset is missing: run "npm run build:notebook-core-port-proof" before this proof (${pathToFileURL( + remoteEntryPath + )})` + ); + + server = createServer((request, response) => { + const requestPath = request.url?.split('?')[0] ?? '/'; + if (requestPath === '/') { + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-type': 'text/html; charset=utf-8' + }); + createReadStream(angularIndexPath).pipe(response); + return; + } + + if (isFile(resolveInsideAngularDist(requestPath) ?? '')) { + serveStaticFile(response, requestPath); + return; + } + + response.writeHead(404, { 'cache-control': 'no-store' }); + response.end(); + }); + + await new Promise(resolveListen => { + server.listen(0, '127.0.0.1', resolveListen); + }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + baseUrl = `http://127.0.0.1:${address.port}`; + browser = await chromium.launch(); +}); + +after(async () => { + await browser?.close(); + await new Promise(resolveClose => server?.close(resolveClose)); +}); + +test('React remote receives the exact host-owned NotebookCorePort object', async () => { + const page = await browser.newPage(); + + await page.goto(baseUrl); + + const probe = page.getByTestId('notebook-core-port-probe'); + await expect(probe).toHaveAttribute('data-same-identity', 'true', { timeout: 15_000 }); + await expect(probe).toHaveAttribute('data-note-id', 'note-host-owned'); + await expect(probe).toHaveAttribute('data-update-count', '0'); + + await page.waitForFunction(() => globalThis.__zeppelinNotebookCorePortProof?.receivedCore !== undefined); + const hostIdentity = await page.evaluate(() => + Object.is( + globalThis.__zeppelinNotebookCorePortProof.hostCore, + globalThis.__zeppelinNotebookCorePortProof.receivedCore + ) + ); + assert.equal(hostIdentity, true); + + await page.getByTestId('publish-notebook-core-revision').click(); + + await expect(probe).toHaveAttribute('data-revision-id', 'revision-from-angular-host'); + await expect(probe).toHaveAttribute('data-update-count', '1'); + + await expect + .poll(() => page.evaluate(() => globalThis.__zeppelinNotebookCorePortProof.proofs.at(-1))) + .toEqual({ + sameIdentity: true, + snapshot: { noteId: 'note-host-owned', revisionId: 'revision-from-angular-host' }, + updateCount: 1 + }); + + await page.close(); +}); diff --git a/zeppelin-web-angular/e2e/core-contract/react-remote/NotebookCorePortProbe.tsx b/zeppelin-web-angular/e2e/core-contract/react-remote/NotebookCorePortProbe.tsx new file mode 100644 index 00000000000..43b304b917f --- /dev/null +++ b/zeppelin-web-angular/e2e/core-contract/react-remote/NotebookCorePortProbe.tsx @@ -0,0 +1,90 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useState } from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import type { NotebookCorePort, NotebookCoreRemoteProps, NotebookCoreSnapshot } from '@zeppelin/notebook-core'; + +export type NotebookCorePortProbeProps = NotebookCoreRemoteProps & + Readonly<{ + expectedCore?: NotebookCorePort; + onReceivedCore?: (core: NotebookCorePort) => void; + onProof?: (proof: NotebookCorePortProbeProof) => void; + }>; + +export type NotebookCorePortProbeProof = Readonly<{ + sameIdentity: boolean; + snapshot: NotebookCoreSnapshot; + updateCount: number; +}>; + +export const NotebookCorePortProbe = ({ core, expectedCore, onProof, onReceivedCore }: NotebookCorePortProbeProps) => { + const [snapshot, setSnapshot] = useState(() => core.getSnapshot()); + const [updateCount, setUpdateCount] = useState(0); + const sameIdentity = Object.is(core, expectedCore); + + useEffect(() => { + onProof?.({ sameIdentity, snapshot, updateCount }); + onReceivedCore?.(core); + }, [core, onProof, onReceivedCore, sameIdentity, snapshot, updateCount]); + + useEffect(() => { + return core.subscribe(() => { + setSnapshot(core.getSnapshot()); + setUpdateCount(value => value + 1); + }); + }, [core]); + + return ( +
+ {snapshot.noteId} + {snapshot.revisionId ?? 'live'} +
+ ); +}; + +export interface NotebookCorePortProbeMountHandle { + update: (props: NotebookCorePortProbeProps) => void; + unmount: () => void; +} + +export const mount = ( + element: HTMLElement, + initialProps: NotebookCorePortProbeProps +): NotebookCorePortProbeMountHandle => { + if (!element) { + throw new Error('Mount element is required'); + } + + const root: Root = createRoot(element); + + const renderWith = (props: NotebookCorePortProbeProps) => { + root.render(); + }; + + renderWith(initialProps); + + return { + update: (newProps: NotebookCorePortProbeProps) => { + renderWith(newProps); + }, + unmount: () => { + root.unmount(); + } + }; +}; diff --git a/zeppelin-web-angular/e2e/core-contract/react-remote/empty.ts b/zeppelin-web-angular/e2e/core-contract/react-remote/empty.ts new file mode 100644 index 00000000000..7afbc20c3ed --- /dev/null +++ b/zeppelin-web-angular/e2e/core-contract/react-remote/empty.ts @@ -0,0 +1,13 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export {}; diff --git a/zeppelin-web-angular/e2e/core-contract/react-remote/tsconfig.json b/zeppelin-web-angular/e2e/core-contract/react-remote/tsconfig.json new file mode 100644 index 00000000000..b8084eda2b9 --- /dev/null +++ b/zeppelin-web-angular/e2e/core-contract/react-remote/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../projects/zeppelin-react/tsconfig.json", + "compilerOptions": { + "baseUrl": "../../../", + "noEmit": false, + "paths": { + "@zeppelin/notebook-core": ["projects/zeppelin-notebook-core/src/public-api.ts"], + "@zeppelin/notebook-core/*": ["projects/zeppelin-notebook-core/src/*"], + "react": ["projects/zeppelin-react/node_modules/@types/react/index.d.ts"], + "react-dom/client": ["projects/zeppelin-react/node_modules/@types/react-dom/client.d.ts"], + "react/jsx-runtime": ["projects/zeppelin-react/node_modules/@types/react/jsx-runtime.d.ts"] + }, + "typeRoots": ["projects/zeppelin-react/node_modules/@types", "node_modules/@types"] + }, + "include": ["*.ts", "*.tsx"] +} diff --git a/zeppelin-web-angular/e2e/core-contract/react-remote/webpack.config.js b/zeppelin-web-angular/e2e/core-contract/react-remote/webpack.config.js new file mode 100644 index 00000000000..d5266dd47a2 --- /dev/null +++ b/zeppelin-web-angular/e2e/core-contract/react-remote/webpack.config.js @@ -0,0 +1,91 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('path'); + +const webRoot = path.resolve(__dirname, '../../..'); +const reactRemoteRoot = path.resolve(webRoot, 'projects/zeppelin-react'); +const ModuleFederationPlugin = require( + path.join(reactRemoteRoot, 'node_modules/webpack/lib/container/ModuleFederationPlugin') +); + +class RejectNotebookCoreRuntimePlugin { + apply(compiler) { + compiler.hooks.compilation.tap('RejectNotebookCoreRuntimePlugin', compilation => { + compilation.hooks.finishModules.tap('RejectNotebookCoreRuntimePlugin', modules => { + const coreRoots = [ + path.resolve(webRoot, 'projects/zeppelin-notebook-core'), + path.resolve(webRoot, 'dist/zeppelin-notebook-core') + ]; + const bundledCoreModules = [...modules] + .map(module => module.resource) + .filter( + resource => + typeof resource === 'string' && + coreRoots.some(coreRoot => path.resolve(resource).startsWith(`${coreRoot}${path.sep}`)) + ); + if (bundledCoreModules.length > 0) { + compilation.errors.push( + new Error(`React remote bundled Shared Notebook Core runtime: ${bundledCoreModules.join(', ')}`) + ); + } + }); + }); + } +} + +module.exports = { + entry: './empty.ts', + context: __dirname, + resolve: { + extensions: ['.tsx', '.ts', '.js', '.jsx'], + modules: [path.resolve(reactRemoteRoot, 'node_modules'), path.resolve(webRoot, 'node_modules'), 'node_modules'], + alias: { + '@zeppelin/notebook-core': path.resolve(webRoot, 'projects/zeppelin-notebook-core/src/public-api.ts') + } + }, + resolveLoader: { + modules: [path.resolve(reactRemoteRoot, 'node_modules'), path.resolve(webRoot, 'node_modules'), 'node_modules'] + }, + module: { + rules: [ + { + test: /\.tsx?$/, + use: { + loader: 'ts-loader', + options: { + configFile: path.resolve(__dirname, 'tsconfig.json'), + transpileOnly: true + } + }, + exclude: /node_modules/ + } + ] + }, + output: { + clean: true, + path: path.resolve(__dirname, 'dist'), + publicPath: '/assets/react/', + scriptType: 'text/javascript', + uniqueName: 'notebookCorePortProof' + }, + plugins: [ + new RejectNotebookCoreRuntimePlugin(), + new ModuleFederationPlugin({ + exposes: { + './NotebookCorePortProbe': './NotebookCorePortProbe' + }, + filename: 'remoteEntry.js', + name: 'reactApp' + }) + ] +}; diff --git a/zeppelin-web-angular/e2e/core-contract/reject-notebook-core-runtime-plugin.test.mjs b/zeppelin-web-angular/e2e/core-contract/reject-notebook-core-runtime-plugin.test.mjs new file mode 100644 index 00000000000..9ea8927a3a2 --- /dev/null +++ b/zeppelin-web-angular/e2e/core-contract/reject-notebook-core-runtime-plugin.test.mjs @@ -0,0 +1,67 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const reactRemoteRoot = fileURLToPath(new URL('../../projects/zeppelin-react/', import.meta.url)); +const requireFromReactRemote = createRequire(join(reactRemoteRoot, 'package.json')); +const webpack = requireFromReactRemote('webpack'); +const ModuleFederationPlugin = requireFromReactRemote('webpack/lib/container/ModuleFederationPlugin'); +const proofConfig = createRequire(import.meta.url)('./react-remote/webpack.config.js'); +const coreEntryPoint = fileURLToPath( + new URL('../../projects/zeppelin-notebook-core/src/public-api.ts', import.meta.url) +); + +test('rejects a React remote that bundles the Shared Notebook Core runtime', async () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'zeppelin-notebook-core-runtime-')); + try { + const exposed = join(fixtureRoot, 'BundledCore.ts'); + writeFileSync(exposed, "export * as notebookCore from '@zeppelin/notebook-core';\n"); + const outputPath = join(fixtureRoot, 'dist'); + const compiler = webpack({ + ...proofConfig, + mode: 'production', + output: { ...proofConfig.output, path: outputPath }, + plugins: [ + ...proofConfig.plugins.filter(plugin => !(plugin instanceof ModuleFederationPlugin)), + new ModuleFederationPlugin({ + exposes: { './BundledCore': exposed }, + filename: 'remoteEntry.js', + name: 'reactApp' + }) + ] + }); + + const stats = await new Promise((resolveRun, rejectRun) => { + compiler.run((error, result) => { + compiler.close(() => (error ? rejectRun(error) : resolveRun(result))); + }); + }); + + const errors = stats.compilation.errors.map(error => error.message); + assert.ok( + errors.some( + message => message.includes('bundled Shared Notebook Core runtime') && message.includes(coreEntryPoint) + ), + `expected a Shared Notebook Core rejection naming ${coreEntryPoint}, got: ${errors.join('\n')}` + ); + assert.equal(existsSync(join(outputPath, 'remoteEntry.js')), false); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } +}); diff --git a/zeppelin-web-angular/package.json b/zeppelin-web-angular/package.json index 492b631cb84..0b6cf15458a 100644 --- a/zeppelin-web-angular/package.json +++ b/zeppelin-web-angular/package.json @@ -10,6 +10,8 @@ "start:react": "cd projects/zeppelin-react && npm run dev", "build": "npm run build:projects && npm run build:react && npm run build:angular", "build:angular": "ng build --configuration production", + "build:notebook-core-port-proof": "npm run build:notebook-core-port-proof:react && ng build --project notebook-core-port-proof --configuration production", + "build:notebook-core-port-proof:react": "cd projects/zeppelin-react && npx webpack --config ../../e2e/core-contract/react-remote/webpack.config.js --mode production", "build:react": "cd projects/zeppelin-react && npm run build", "build:projects": "npm run build-project:sdk && npm run build-project:notebook-core && npm run build-project:vis", "build-project:notebook-core": "ng build --project zeppelin-notebook-core", @@ -22,13 +24,14 @@ "lint:fix": "cross-env NODE_OPTIONS='--max-old-space-size=8192' ng lint --fix && npm run lint:fix:react && prettier --write \"**/*.{ts,tsx,mts,js,mjs,json,css,html}\"", "lint:react": "cd projects/zeppelin-react && npm run lint", "lint:fix:react": "cd projects/zeppelin-react && npm run lint:fix", - "typecheck:notebook-core": "tsc -p projects/zeppelin-notebook-core/tsconfig.json --noEmit && tsc -p projects/zeppelin-notebook-core/tsconfig.spec.json --noEmit && npm run build-project:notebook-core && tsc -p projects/zeppelin-react/tsconfig.notebook-core.dist.json --noEmit && tsc -p projects/zeppelin-react/tsconfig.notebook-core.json --noEmit", + "typecheck:notebook-core": "tsc -p projects/zeppelin-notebook-core/tsconfig.json --noEmit && tsc -p projects/zeppelin-notebook-core/tsconfig.spec.json --noEmit && npm run build-project:notebook-core && tsc -p projects/zeppelin-react/tsconfig.notebook-core.dist.json --noEmit && tsc -p projects/zeppelin-react/tsconfig.notebook-core.json --noEmit && tsc -p e2e/core-contract/react-remote/tsconfig.json --noEmit", "typecheck:sdk-contracts": "tsc -p projects/zeppelin-sdk/tsconfig.spec.json --noEmit", + "test:notebook-core-port-identity": "node --test e2e/core-contract/notebook-core-port-identity.test.mjs", "test:notebook-core": "vitest run --config vitest.notebook-core.config.mts", "test:shell": "vitest run --config vitest.shell.config.mts", "test:eslint-rules": "node --test eslint-rules/*.test.js", "e2e": "playwright test", - "check:core-contract-fixtures": "node --test e2e/core-contract/notebook-transport-fixture.test.mjs e2e/core-contract/playwright-runner.test.mjs", + "check:core-contract-fixtures": "node --test e2e/core-contract/notebook-transport-fixture.test.mjs e2e/core-contract/playwright-runner.test.mjs e2e/core-contract/reject-notebook-core-runtime-plugin.test.mjs", "check:core-contract-auth": "cross-env ZEPPELIN_RUN_AUTH_SETUP_TEST=1 node --test --test-name-pattern='live anonymous setup' e2e/core-contract/playwright-runner.test.mjs", "check:core-contract-server": "node --test e2e/core-contract/capture-server.test.mjs", "e2e:core-contract": "playwright test --config playwright.core-contract.config.js --project=chromium", diff --git a/zeppelin-web-angular/pom.xml b/zeppelin-web-angular/pom.xml index 93b28c184ec..ad6de579980 100644 --- a/zeppelin-web-angular/pom.xml +++ b/zeppelin-web-angular/pom.xml @@ -35,6 +35,8 @@ false true + + ${web.e2e.disabled} ../bin UTF-8 @@ -188,6 +190,31 @@ + + npm build notebook core port identity proof + + npm + + integration-test + + + ${web.e2e.core.port.proof.disabled} + run build:notebook-core-port-proof + + + + + npm test notebook core port identity + + npm + + integration-test + + ${web.e2e.core.port.proof.disabled} + run test:notebook-core-port-identity + + + npm e2e diff --git a/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts b/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts index 7b1ad5f9981..c6f03703610 100644 --- a/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts +++ b/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts @@ -14,8 +14,11 @@ import ts from 'typescript'; export const createFixtureHost = (options: ts.CompilerOptions, files: ReadonlyMap): ts.CompilerHost => { const host = ts.createCompilerHost(options); - const { readFile, fileExists } = host; + const { readFile, fileExists, directoryExists } = host; host.readFile = file => files.get(file) ?? readFile(file); host.fileExists = file => files.has(file) || fileExists(file); + // Module resolution does not probe files in directories it considers missing. + host.directoryExists = directory => + [...files.keys()].some(file => file.startsWith(`${directory}/`)) || (directoryExists?.(directory) ?? true); return host; }; diff --git a/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts b/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts index be3fc00f56b..e8e957ad479 100644 --- a/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts +++ b/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts @@ -11,7 +11,7 @@ */ import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { dirname, relative, resolve } from 'node:path'; import ts from 'typescript'; import { describe, expect, it } from 'vitest'; @@ -20,11 +20,16 @@ import { createFixtureHost } from './compiler-fixture'; import { sourceRoot, zeppelinWebAngularRoot, + reactNotebookCoreProofConsumer, + reactNotebookCoreProofConsumers, + reactNotebookCoreProofRoot, reactNotebookCoreBoundaryFiles, forbiddenModulePrefixes, forbiddenReactNotebookCoreConsumerModulePrefixes, sourceFiles, findReactNotebookConsumerViolations, + findNotebookCoreValueImportViolations, + findNotebookRemoteConsumerViolations, findNotebookContractViolations, findViolations, formatViolations, @@ -32,6 +37,18 @@ import { } from './import-boundary'; describe('notebook core import boundary', () => { + it('runs the browser-dependent separate-build proof with the browser e2e gate', () => { + const pom = readFileSync(resolve(zeppelinWebAngularRoot, 'pom.xml'), 'utf8'); + expect(pom).toMatch( + /\$\{web\.e2e\.disabled\}<\/web\.e2e\.core\.port\.proof\.disabled>/ + ); + for (const id of ['npm build notebook core port identity proof', 'npm test notebook core port identity']) { + const execution = pom.match(new RegExp(`\\s*${id}([\\s\\S]*?)`))?.[1]; + expect(execution).toMatch(/integration-test<\/phase>/); + expect(execution).toMatch(/\$\{web\.e2e\.core\.port\.proof\.disabled\}<\/skip>/); + } + }); + it('resolves the React public contract without exposing source subpaths', () => { const path = reactNotebookCoreBoundaryFiles[1]; const options = readCompilerOptions(resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/tsconfig.json')); @@ -135,10 +152,12 @@ describe('notebook core import boundary', () => { } ); - it('rejects direct transport imports in the React entry point and notebook core contract', () => { - const violations = reactNotebookCoreBoundaryFiles.flatMap(path => { + it('rejects direct transport imports in the React entry point, contract bridge and remote probe', () => { + const violations = [...reactNotebookCoreBoundaryFiles, reactNotebookCoreProofConsumer].flatMap(path => { const source = readFileSync(path, 'utf8'); - return findViolations(path, source, forbiddenReactNotebookCoreConsumerModulePrefixes); + return path === reactNotebookCoreProofConsumer + ? findNotebookRemoteConsumerViolations(path, source) + : findViolations(path, source, forbiddenReactNotebookCoreConsumerModulePrefixes); }); expect(formatViolations(violations)).toEqual([]); @@ -150,6 +169,217 @@ describe('notebook core import boundary', () => { expect(findReactNotebookConsumerViolations()).toEqual([]); }, 30_000); + it('rejects a runtime or factory import in the separate-build remote probe', () => { + expect( + findNotebookRemoteConsumerViolations( + reactNotebookCoreProofConsumer, + "import { createNotebookCore } from '@zeppelin/notebook-core';" + ) + ).toEqual([ + `${reactNotebookCoreProofConsumer}: runtime notebook core import`, + `${reactNotebookCoreProofConsumer}: remote must import only NotebookCorePort contract types` + ]); + expect( + findNotebookCoreValueImportViolations( + resolve(zeppelinWebAngularRoot, 'e2e/core-contract/react-remote/helper.ts'), + "export { createNotebookCore } from '@zeppelin/notebook-core';" + ) + ).toEqual([ + `${resolve(zeppelinWebAngularRoot, 'e2e/core-contract/react-remote/helper.ts')}: runtime notebook core import` + ]); + }); + + it('rejects require.resolve of the core runtime', () => { + expect( + findNotebookCoreValueImportViolations( + reactNotebookCoreProofConsumer, + "export const corePath = require.resolve('@zeppelin/notebook-core');" + ) + ).toEqual([`${reactNotebookCoreProofConsumer}: runtime notebook core import`]); + }); + + it.each([ + "import '../../../projects/zeppelin-notebook-core/src/public-api';", + "export { createNotebookCore } from '../../../projects/zeppelin-notebook-core/src/public-api';" + ])('rejects a runtime core dependency by its resolved target: %s', source => { + const path = resolve(zeppelinWebAngularRoot, 'e2e/core-contract/react-remote/helper.ts'); + expect(findNotebookCoreValueImportViolations(path, source)).toEqual([`${path}: runtime notebook core import`]); + }); + + it('allows a type-only core dependency by its resolved target and restricts its public contract types', () => { + const path = reactNotebookCoreProofConsumer; + const target = resolve(sourceRoot, 'public-api.ts'); + const relativeTarget = `./${relative(dirname(path), target).replace(/\\/g, '/').replace(/\.ts$/, '')}`; + const source = `import type { NotebookCorePort } from '${relativeTarget}';\nexport type Port = NotebookCorePort;`; + + expect(findNotebookCoreValueImportViolations(path, source)).toEqual([]); + expect(findNotebookRemoteConsumerViolations(path, source)).toEqual([]); + + expect( + findNotebookRemoteConsumerViolations(path, `export type Port = import('${relativeTarget}').NotebookCorePort;`) + ).toEqual([]); + + const invalidSource = `import type { NotebookCoreUnsubscribe } from '${relativeTarget}';`; + expect(findNotebookRemoteConsumerViolations(path, invalidSource)).toContain( + `${path}: remote notebook core import NotebookCoreUnsubscribe` + ); + expect( + findNotebookRemoteConsumerViolations( + path, + `export type Unsubscribe = import('${relativeTarget}').NotebookCoreUnsubscribe;` + ) + ).toContain(`${path}: remote notebook core import NotebookCoreUnsubscribe`); + }); + + it('enforces the core boundary for built-package aliases and relative targets', () => { + const path = reactNotebookCoreProofConsumer; + const builtDeclaration = resolve( + zeppelinWebAngularRoot, + 'dist/zeppelin-notebook-core/types/zeppelin-notebook-core.d.ts' + ); + const options = { + ...readCompilerOptions(resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/tsconfig.json')), + baseUrl: zeppelinWebAngularRoot, + paths: { '@built-core': ['dist/zeppelin-notebook-core/types/zeppelin-notebook-core.d.ts'] } + }; + const host = createFixtureHost( + options, + new Map([ + [ + builtDeclaration, + 'export type NotebookCorePort = Readonly<{}>; export declare const createNotebookCore: () => void;' + ] + ]) + ); + const relativeTarget = relative(dirname(path), builtDeclaration) + .replace(/\\/g, '/') + .replace(/\.d\.ts$/, ''); + + for (const source of [ + "import '@built-core';", + "export { createNotebookCore } from '@built-core';", + `import '${relativeTarget.startsWith('.') ? relativeTarget : `./${relativeTarget}`}';` + ]) { + expect(findNotebookCoreValueImportViolations(path, source, options, host)).toEqual([ + `${path}: runtime notebook core import` + ]); + } + expect( + findNotebookRemoteConsumerViolations( + path, + "import type { NotebookCorePort } from '@built-core'; export type Port = NotebookCorePort;", + options, + host + ) + ).toEqual([]); + expect( + findNotebookRemoteConsumerViolations( + path, + "import type { createNotebookCore } from '@built-core';", + options, + host + ) + ).toContain(`${path}: remote notebook core import createNotebookCore`); + }, 30_000); + + it('rejects a transitive runtime re-export of the core reached through a consumer helper', () => { + const root = resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src'); + const consumer = resolve(root, 'NotebookBoundaryFixture.tsx'); + const helper = resolve(root, 'NotebookBoundaryHelper.ts'); + const publicApi = resolve(sourceRoot, 'public-api.ts'); + const helperTarget = relative(dirname(helper), publicApi).replace(/\\/g, '/').replace(/\.ts$/, ''); + const files = new Map([ + [ + consumer, + "import type { NotebookCoreRemoteProps } from './notebookCoreContract'; export type Props = NotebookCoreRemoteProps; export * from './NotebookBoundaryHelper';" + ], + [ + helper, + `export { createNotebookCore } from '${helperTarget.startsWith('.') ? helperTarget : `./${helperTarget}`}';` + ] + ]); + const options = readCompilerOptions(resolve(root, '../tsconfig.json')); + const host = createFixtureHost(options, files); + + expect(findReactNotebookConsumerViolations([...reactNotebookCoreBoundaryFiles, consumer], options, host)).toContain( + `${helper}: runtime notebook core import` + ); + }, 30_000); + + it('rejects a transitive runtime re-export through a resolved node_modules package', () => { + const wrapper = resolve(zeppelinWebAngularRoot, 'node_modules/zeppelin-notebook-boundary-fixture/index.ts'); + const wrapperSpecifier = relative(dirname(reactNotebookCoreProofConsumer), wrapper).replace(/\\/g, '/'); + const source = `import type { NotebookCorePort } from '@zeppelin/notebook-core'; export type Port = NotebookCorePort; export * from '${ + wrapperSpecifier.startsWith('.') ? wrapperSpecifier : `./${wrapperSpecifier}` + }';`; + const options = readCompilerOptions(resolve(dirname(reactNotebookCoreProofConsumer), 'tsconfig.json')); + const violationsWithWrapper = (wrapperSource: string): string[] => + findReactNotebookConsumerViolations( + [reactNotebookCoreProofConsumer], + options, + createFixtureHost( + options, + new Map([ + [reactNotebookCoreProofConsumer, source], + [wrapper, wrapperSource] + ]) + ) + ); + + expect(violationsWithWrapper("export { createNotebookCore } from '@zeppelin/notebook-core';\n")).toContain( + `${wrapper}: runtime notebook core import` + ); + expect(violationsWithWrapper("export type { NotebookCorePort } from '@zeppelin/notebook-core';\n")).toEqual([]); + }, 30_000); + + it('restricts notebook core types throughout the separate remote dependency graph', () => { + const helper = resolve(dirname(reactNotebookCoreProofConsumer), 'contract-helper.ts'); + const files = new Map([ + [ + reactNotebookCoreProofConsumer, + "import type { Factory } from './contract-helper'; export type RemoteFactory = Factory;" + ], + [helper, "export type Factory = import('@zeppelin/notebook-core').NotebookCoreUnsubscribe;"] + ]); + const options = readCompilerOptions(resolve(dirname(reactNotebookCoreProofConsumer), 'tsconfig.json')); + const host = createFixtureHost(options, files); + + expect( + ts + .getPreEmitDiagnostics(ts.createProgram([reactNotebookCoreProofConsumer], options, host)) + .filter(diagnostic => (diagnostic.file ? files.has(diagnostic.file.fileName) : false)) + ).toEqual([]); + expect(findReactNotebookConsumerViolations([reactNotebookCoreProofConsumer], options, host)).toContain( + `${helper}: remote notebook core import NotebookCoreUnsubscribe` + ); + }, 30_000); + + it('scans every source of the separate-build remote, not only the probe', () => { + const consumers = reactNotebookCoreProofConsumers(); + expect(consumers).toContain(reactNotebookCoreProofConsumer); + expect(consumers).toContain(resolve(reactNotebookCoreProofRoot, 'empty.ts')); + expect( + consumers.filter(path => + /^(?:dist|build|node_modules)\/|webpack\.config/.test(relative(reactNotebookCoreProofRoot, path)) + ) + ).toEqual([]); + + const exposed = resolve(reactNotebookCoreProofRoot, 'SecondProbe.tsx'); + const options = readCompilerOptions(resolve(reactNotebookCoreProofRoot, 'tsconfig.json')); + const host = createFixtureHost( + options, + new Map([ + [ + exposed, + "import type { NotebookCoreUnsubscribe } from '@zeppelin/notebook-core'; export type U = NotebookCoreUnsubscribe;" + ] + ]) + ); + expect(findReactNotebookConsumerViolations([exposed], options, host)).toContain( + `${exposed}: remote notebook core import NotebookCoreUnsubscribe` + ); + }, 30_000); + it.each(['direct', 'helper', 'route', 'barrel', 'javascript', 'cycle', 'computed', 'require-outside'])( 'discovers a new notebook consumer and rejects its %s transport dependency', form => { @@ -183,7 +413,11 @@ describe('notebook core import boundary', () => { const options = readCompilerOptions(resolve(root, '../tsconfig.json')); const host = createFixtureHost(options, files); const roots = [consumer, route, barrel, ...reactNotebookCoreBoundaryFiles]; - expect(ts.getPreEmitDiagnostics(ts.createProgram(roots, options, host))).toEqual([]); + expect( + ts + .getPreEmitDiagnostics(ts.createProgram(roots, options, host)) + .filter(diagnostic => (diagnostic.file ? files.has(diagnostic.file.fileName) : false)) + ).toEqual([]); const violations = findReactNotebookConsumerViolations(roots, options, host); const offender = ['helper', 'javascript', 'cycle', 'require-outside'].includes(form) ? helper @@ -193,7 +427,8 @@ describe('notebook core import boundary', () => { expect(violations).toContain( `${offender}: import ${form === 'computed' ? '' : 'rxjs/webSocket'}` ); - } + }, + 30_000 ); it.each([ @@ -202,16 +437,20 @@ describe('notebook core import boundary', () => { ["export type { ClientHttp2Session } from 'node:http2';", 'import node:http2'], ['export const request = fetch;', 'global fetch'], ['export const socket = WebSocket;', 'global WebSocket'] - ])('rejects transport access in a consumer of the public core API: %s', (transport, violation) => { - const root = resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src'); - const consumer = resolve(root, 'NotebookBoundaryFixture.tsx'); - const source = `import type { NotebookCoreRemoteProps } from '@zeppelin/notebook-core'; + ])( + 'rejects transport access in a consumer of the public core API: %s', + (transport, violation) => { + const root = resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src'); + const consumer = resolve(root, 'NotebookBoundaryFixture.tsx'); + const source = `import type { NotebookCoreRemoteProps } from '@zeppelin/notebook-core'; export const read = (props: NotebookCoreRemoteProps) => props.core.getSnapshot(); ${transport}`; - const options = readCompilerOptions(resolve(root, '../tsconfig.json')); - const host = createFixtureHost(options, new Map([[consumer, source]])); - expect(ts.getPreEmitDiagnostics(ts.createProgram([consumer], options, host))).toEqual([]); - expect(findReactNotebookConsumerViolations([consumer], options, host)).toContain(`${consumer}: ${violation}`); - }); + const options = readCompilerOptions(resolve(root, '../tsconfig.json')); + const host = createFixtureHost(options, new Map([[consumer, source]])); + expect(ts.getPreEmitDiagnostics(ts.createProgram([consumer], options, host))).toEqual([]); + expect(findReactNotebookConsumerViolations([consumer], options, host)).toContain(`${consumer}: ${violation}`); + }, + 30_000 + ); it('rejects the mixed public aggregator as an internal notebook consumer dependency', () => { const root = resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src'); @@ -222,7 +461,7 @@ describe('notebook core import boundary', () => { expect(findReactNotebookConsumerViolations([consumer], options, host)).toContain( `${consumer}: notebook consumer must use the contract bridge instead of the public aggregator` ); - }); + }, 30_000); it('allows a neutral notebook consumer without traversing unrelated legacy page exports', () => { const root = resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src'); @@ -234,7 +473,7 @@ describe('notebook core import boundary', () => { expect(findReactNotebookConsumerViolations([...reactNotebookCoreBoundaryFiles, consumer], options, host)).toEqual( [] ); - }); + }, 30_000); it('keeps the React contract dependent only on the public core entry point', () => { const path = reactNotebookCoreBoundaryFiles[1]; @@ -451,15 +690,19 @@ describe('notebook core import boundary', () => { ['let fetch: typeof window.fetch; ({ fetch } = window); export const request = fetch;', 'global fetch'], ['export const browser = { fetch };', 'global fetch'], ['type fetch = string; export const request = fetch;', 'global fetch'] - ])('rejects wrapped transport globals: %s', (source, violation) => { - const path = reactNotebookCoreBoundaryFiles[1]; - const options = readCompilerOptions(resolve(dirname(path), '../tsconfig.json')); - const host = createFixtureHost(options, new Map([[path, source]])); - expect(ts.getPreEmitDiagnostics(ts.createProgram([path], options, host))).toEqual([]); - expect(findViolations(path, source, forbiddenReactNotebookCoreConsumerModulePrefixes)).toContain( - `${path}: ${violation}` - ); - }); + ])( + 'rejects wrapped transport globals: %s', + (source, violation) => { + const path = reactNotebookCoreBoundaryFiles[1]; + const options = readCompilerOptions(resolve(dirname(path), '../tsconfig.json')); + const host = createFixtureHost(options, new Map([[path, source]])); + expect(ts.getPreEmitDiagnostics(ts.createProgram([path], options, host))).toEqual([]); + expect(findViolations(path, source, forbiddenReactNotebookCoreConsumerModulePrefixes)).toContain( + `${path}: ${violation}` + ); + }, + 30_000 + ); it.each([ "export const fetch = () => 'cached'; export const result = fetch();", diff --git a/zeppelin-web-angular/test/notebook-core/import-boundary.ts b/zeppelin-web-angular/test/notebook-core/import-boundary.ts index b99b0a811a5..1fcfa4928bd 100644 --- a/zeppelin-web-angular/test/notebook-core/import-boundary.ts +++ b/zeppelin-web-angular/test/notebook-core/import-boundary.ts @@ -18,6 +18,8 @@ import ts from 'typescript'; export const zeppelinWebAngularRoot = resolve(fileURLToPath(new URL('../../', import.meta.url))); export const sourceRoot = fileURLToPath(new URL('../../projects/zeppelin-notebook-core/src/', import.meta.url)); +export const reactNotebookCoreProofRoot = resolve(zeppelinWebAngularRoot, 'e2e/core-contract/react-remote'); +export const reactNotebookCoreProofConsumer = resolve(reactNotebookCoreProofRoot, 'NotebookCorePortProbe.tsx'); export const reactNotebookCoreBoundaryFiles = [ resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src/main.ts'), resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src/notebookCoreContract.ts') @@ -69,26 +71,50 @@ const isCheckedSourceFile = (path: string): boolean => { return checkedSourceExtensions.some(extension => path.endsWith(extension)) && !isSpecSourceFile(path); }; +const isReactNotebookCoreProofModule = (path: string): boolean => + path.startsWith(`${reactNotebookCoreProofRoot}/`) && !path.includes('/node_modules/'); + +// Any source in the separate-build remote can be exposed, so scan the directory +// instead of one probe. Build output and webpack configuration are not remote code. +export const reactNotebookCoreProofConsumers = (): string[] => + sourceFiles( + reactNotebookCoreProofRoot, + path => + /\.[cm]?[jt]sx?$/.test(path) && + !isSpecSourceFile(path) && + !/^(?:(?:dist|build|node_modules)\/|webpack\.config\.[cm]?js$)/.test( + relative(reactNotebookCoreProofRoot, path).replace(/\\/g, '/') + ) + ); + export const findReactNotebookConsumerViolations = ( - roots: string[] = sourceFiles( - resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src'), - path => /\.[cm]?[jt]sx?$/.test(path) && !isSpecSourceFile(path) - ), + roots: string[] = [ + ...sourceFiles( + resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src'), + path => /\.[cm]?[jt]sx?$/.test(path) && !isSpecSourceFile(path) + ), + ...reactNotebookCoreProofConsumers() + ], options = readCompilerOptions(resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/tsconfig.json')), host: ts.CompilerHost = ts.createCompilerHost(options) ): string[] => { const program = ts.createProgram(roots, options, host); const modules = new Map }>(); - const pending = [...program.getSourceFiles()]; - for (const file of pending) { - if (modules.has(file.fileName) || file.fileName.includes('/node_modules/')) { - continue; + const loadModule = (path: string, source?: string): { source: string; dependencies: Set } | undefined => { + const existing = modules.get(path); + if (existing) { + return existing; + } + const text = source ?? host.readFile(path); + if (text === undefined) { + return undefined; } + const file = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, getScriptKind(path)); const dependencies = new Set(); const visit = (node: ts.Node): void => { const specifier = getModuleSpecifier(node); - if (specifier) { - const target = ts.resolveModuleName(specifier, file.fileName, options, host).resolvedModule?.resolvedFileName; + if (specifier && specifier !== '') { + const target = ts.resolveModuleName(specifier, path, options, host).resolvedModule?.resolvedFileName; if (target) { dependencies.add(target); } @@ -97,16 +123,26 @@ export const findReactNotebookConsumerViolations = ( }; visit(file); for (const reference of file.referencedFiles) { - dependencies.add(resolve(dirname(file.fileName), reference.fileName)); + dependencies.add(resolve(dirname(path), reference.fileName)); } for (const reference of file.typeReferenceDirectives) { - const target = ts.resolveTypeReferenceDirective(reference.fileName, file.fileName, options, host) + const target = ts.resolveTypeReferenceDirective(reference.fileName, path, options, host) .resolvedTypeReferenceDirective?.resolvedFileName; if (target) { dependencies.add(target); } } - modules.set(file.fileName, { source: file.text, dependencies }); + const loaded = { source: text, dependencies }; + modules.set(path, loaded); + return loaded; + }; + const pending = [...program.getSourceFiles()]; + for (const file of pending) { + if (modules.has(file.fileName) || file.fileName.includes('/node_modules/')) { + continue; + } + const module = loadModule(file.fileName, file.text); + const dependencies = module?.dependencies ?? new Set(); // TypeScript can resolve require() without adding its target to the program. // Inspect those local sources too, including helpers outside the root list. for (const target of dependencies) { @@ -122,7 +158,8 @@ export const findReactNotebookConsumerViolations = ( // Discover adapters, re-export barrels and routes from their dependency on the // shared contract. A new consumer must not require editing a scanner file list. const consumers = new Set([ - ...reactNotebookCoreBoundaryFiles, + ...reactNotebookCoreBoundaryFiles.filter(path => modules.has(path)), + ...[...modules.keys()].filter(isReactNotebookCoreProofModule), ...[...modules.keys()].filter(path => path.startsWith(sourceRoot)) ]); let changed = true; @@ -139,24 +176,42 @@ export const findReactNotebookConsumerViolations = ( const violations: string[] = []; const checked = new Set(); const main = reactNotebookCoreBoundaryFiles[0]; + const remoteModules = new Set(); + const collectRemoteModules = (path: string): void => { + if (remoteModules.has(path) || path.startsWith(sourceRoot) || path.includes('/node_modules/')) { + return; + } + remoteModules.add(path); + loadModule(path)?.dependencies.forEach(collectRemoteModules); + }; + // Start from remote sources in the checked program only; package graphs are not remote code. + [...modules.keys()].filter(isReactNotebookCoreProofModule).forEach(collectRemoteModules); const check = (path: string): void => { if (checked.has(path) || path.startsWith(sourceRoot)) { return; } checked.add(path); - const module = modules.get(path); + const module = loadModule(path); if (!module) { - if (!path.includes('/node_modules/')) { - violations.push(`${path}: cannot inspect local notebook dependency`); - } + violations.push(`${path}: cannot inspect notebook dependency`); return; } + // Third-party declarations are only checked for re-exporting the core runtime; + // consumer transport rules apply to Zeppelin sources, not to package internals. violations.push( - ...findViolations(path, module.source, forbiddenReactNotebookCoreConsumerModulePrefixes, options, host) + ...(path.includes('/node_modules/') + ? findNotebookCoreValueImportViolations(path, module.source, options, host) + : remoteModules.has(path) + ? findNotebookRemoteConsumerViolations(path, module.source, options, host) + : [ + ...findViolations(path, module.source, forbiddenReactNotebookCoreConsumerModulePrefixes, options, host), + ...findNotebookCoreValueImportViolations(path, module.source, options, host) + ]) ); // The public aggregator also exports existing SDK-backed pages. Check its // own imports, but do not include those unrelated pages in the core boundary. - if (path === main) { + // Package declarations are checked for their own re-exports only. + if (path === main || path.includes('/node_modules/')) { return; } for (const target of module.dependencies) { @@ -171,6 +226,127 @@ export const findReactNotebookConsumerViolations = ( return violations; }; +const notebookRemoteContractTypes = new Set(['NotebookCorePort', 'NotebookCoreRemoteProps', 'NotebookCoreSnapshot']); + +const isTypeOnlyImportDeclaration = (node: ts.ImportDeclaration): boolean => { + const clause = node.importClause; + const bindings = clause?.namedBindings; + return ( + clause?.isTypeOnly === true || + (clause?.name === undefined && + bindings !== undefined && + ts.isNamedImports(bindings) && + bindings.elements.length > 0 && + bindings.elements.every(binding => binding.isTypeOnly)) + ); +}; + +export const findNotebookCoreValueImportViolations = ( + path: string, + source: string, + compilerOptions = readCompilerOptions(resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/tsconfig.json')), + resolutionHost: ts.ModuleResolutionHost = ts.sys +): string[] => { + const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, getScriptKind(path)); + const violations: string[] = []; + const visit = (node: ts.Node): void => { + const specifier = getModuleSpecifier(node); + if (specifier && resolvesToNotebookCore(specifier, path, compilerOptions, resolutionHost)) { + const typeOnlyImport = + ts.isImportTypeNode(node) || + (ts.isImportDeclaration(node) && isTypeOnlyImportDeclaration(node)) || + (ts.isExportDeclaration(node) && + (node.isTypeOnly || + (node.exportClause !== undefined && + ts.isNamedExports(node.exportClause) && + node.exportClause.elements.every(binding => binding.isTypeOnly)))); + if (!typeOnlyImport) { + violations.push(`${path}: runtime notebook core import`); + } + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return violations; +}; + +export const findNotebookRemoteConsumerViolations = ( + path: string, + source: string, + compilerOptions = readCompilerOptions(resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/tsconfig.json')), + resolutionHost: ts.ModuleResolutionHost = ts.sys +): string[] => { + const violations = findViolations( + path, + source, + forbiddenReactNotebookCoreConsumerModulePrefixes, + compilerOptions, + resolutionHost + ); + violations.push(...findNotebookCoreValueImportViolations(path, source, compilerOptions, resolutionHost)); + const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, getScriptKind(path)); + + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) { + continue; + } + const specifier = getModuleSpecifier(statement); + if (!specifier || !resolvesToNotebookCore(specifier, path, compilerOptions, resolutionHost)) { + continue; + } + const clause = statement.importClause; + const bindings = clause?.namedBindings; + + if (!isTypeOnlyImportDeclaration(statement) || clause?.name || !bindings || !ts.isNamedImports(bindings)) { + violations.push(`${path}: remote must import only NotebookCorePort contract types`); + continue; + } + for (const binding of bindings.elements) { + const importedName = binding.propertyName?.text ?? binding.name.text; + if (!notebookRemoteContractTypes.has(importedName)) { + violations.push(`${path}: remote notebook core import ${importedName}`); + } + } + } + + const visit = (node: ts.Node): void => { + const specifier = getModuleSpecifier(node); + if ( + specifier && + !ts.isImportDeclaration(node) && + resolvesToNotebookCore(specifier, path, compilerOptions, resolutionHost) + ) { + if (ts.isImportTypeNode(node) && node.qualifier && ts.isIdentifier(node.qualifier)) { + if (!notebookRemoteContractTypes.has(node.qualifier.text)) { + violations.push(`${path}: remote notebook core import ${node.qualifier.text}`); + } + } else if ( + ts.isExportDeclaration(node) && + (node.isTypeOnly || + (node.exportClause && + ts.isNamedExports(node.exportClause) && + node.exportClause.elements.every(binding => binding.isTypeOnly))) && + node.exportClause && + ts.isNamedExports(node.exportClause) + ) { + for (const binding of node.exportClause.elements) { + const importedName = binding.propertyName?.text ?? binding.name.text; + if (!notebookRemoteContractTypes.has(importedName)) { + violations.push(`${path}: remote notebook core import ${importedName}`); + } + } + } else { + violations.push(`${path}: remote must import only NotebookCorePort contract types`); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + + return violations; +}; + // This type-only bridge has one dependency. Rejecting other imports also prevents // local helpers from hiding transport re-exports without restricting existing pages. export const findNotebookContractViolations = (path: string, source: string): string[] => { @@ -337,7 +513,11 @@ const getModuleSpecifier = (node: ts.Node): string | null => { ts.isCallExpression(node) && node.arguments.length >= 1 && (node.expression.kind === ts.SyntaxKind.ImportKeyword || - (ts.isIdentifier(node.expression) && node.expression.text === 'require')) + (ts.isIdentifier(node.expression) && node.expression.text === 'require') || + (ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'require' && + node.expression.name.text === 'resolve')) ) { let argument = node.arguments[0]; while (ts.isParenthesizedExpression(argument)) { @@ -403,6 +583,17 @@ const moduleIdentities = ( return identities; }; +const resolvesToNotebookCore = ( + specifier: string, + path: string, + compilerOptions: ts.CompilerOptions, + resolutionHost: ts.ModuleResolutionHost +): boolean => { + return moduleIdentities(specifier, path, compilerOptions, resolutionHost).some(identity => + matchesModulePrefix(identity, '@zeppelin/notebook-core') + ); +}; + const matchesModulePrefix = (moduleSpecifier: string, prefix: string): boolean => { return moduleSpecifier === prefix || moduleSpecifier.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`); };