From ad84ee17a4b05db252afd51951b087387b4c84a0 Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Thu, 17 Sep 2026 04:18:03 +0900 Subject: [PATCH 1/2] [ZEPPELIN-6671] Add execution streaming fixtures --- .../e2e/core-contract/README.md | 85 +++- .../core-contract/capture-build-manifest.mjs | 226 ++++++++++ .../capture-build-manifest.test.mjs | 221 ++++++++++ .../e2e/core-contract/capture-server.sh | 95 +++- .../e2e/core-contract/capture-server.test.mjs | 210 +++++++++ .../core-contract/execution-fixtures.test.mjs | 244 ++++++++++ .../fixtures/build-manifest.json | 149 +++++++ .../fixtures/execution-cancel.json | 311 +++++++++++++ .../execution-streaming-disabled.json | 286 ++++++++++++ .../fixtures/execution-streaming-enabled.json | 337 ++++++++++++++ .../notebook-transport-fixture.d.mts | 42 +- .../notebook-transport-fixture.mjs | 205 +++++++++ .../notebook-transport-fixture.test.mjs | 91 ++++ .../core-contract/execution-fixtures.spec.ts | 417 ++++++++++++++++++ zeppelin-web-angular/package.json | 2 +- .../playwright.core-contract.config.js | 2 +- 16 files changed, 2907 insertions(+), 16 deletions(-) create mode 100644 zeppelin-web-angular/e2e/core-contract/capture-build-manifest.mjs create mode 100644 zeppelin-web-angular/e2e/core-contract/capture-build-manifest.test.mjs create mode 100644 zeppelin-web-angular/e2e/core-contract/execution-fixtures.test.mjs create mode 100644 zeppelin-web-angular/e2e/core-contract/fixtures/build-manifest.json create mode 100644 zeppelin-web-angular/e2e/core-contract/fixtures/execution-cancel.json create mode 100644 zeppelin-web-angular/e2e/core-contract/fixtures/execution-streaming-disabled.json create mode 100644 zeppelin-web-angular/e2e/core-contract/fixtures/execution-streaming-enabled.json create mode 100644 zeppelin-web-angular/e2e/tests/notebook/core-contract/execution-fixtures.spec.ts diff --git a/zeppelin-web-angular/e2e/core-contract/README.md b/zeppelin-web-angular/e2e/core-contract/README.md index cfb94293b78..b433a53ede4 100644 --- a/zeppelin-web-angular/e2e/core-contract/README.md +++ b/zeppelin-web-angular/e2e/core-contract/README.md @@ -174,9 +174,53 @@ server. It does not contact `PLAYWRIGHT_BASE_URL` or clean notebooks from anothe The focused command runs Chromium. The ordinary E2E suite still includes the synthetic browser tests in its Chromium, Firefox and WebKit projects and excludes `@live`. -The capture server requires `lsof` to verify listener ownership and a built checkout -(`./mvnw clean install -DskipTests -pl zeppelin-web-angular -am`). A startup failure -reports the server log; a successful HTTP response alone does not establish ownership. +The capture server requires `lsof` to verify listener ownership. Build a clean detached +`origin/master` checkout with +`./mvnw clean install -DskipTests -pl zeppelin-server,zeppelin-web-angular,shell -am`, +then create a build manifest before starting a committed-fixture capture. The manifest +hashes every server, frontend and interpreter output used by the launcher. Startup +recomputes those hashes and refuses a stale or modified build. Symlinked outputs must +resolve inside the build root. A startup failure reports the server log; a successful +HTTP response alone does not establish ownership. + +A committed fixture is evidence of what the server on `origin/master` sends, so the +build must come from `origin/master` itself with no tracked changes: a fixture +captured from a branch would record that branch's behaviour as the contract. The +manifest refuses a checkout whose `HEAD` is not the local `origin/master` ref; fetch +first, because the ref is not compared with the remote. A pull request that changes +server behaviour therefore cannot capture fixtures for its own change. Recapture +them from `origin/master` after that change is merged. + +`fixtures/build-manifest.json` is the manifest, as written by +`capture-build-manifest.mjs create`, of the build the committed execution fixtures +were captured from. `npm run check:core-contract-fixtures` requires every committed +execution fixture to embed that same manifest, so a recapture from a different build +replaces this file in the same change. + +Committed live captures also identify the environment that produced them. Set +`ZEPPELIN_E2E_SOURCE_COMMIT` to the exact checkout commit used to run the capture, +`ZEPPELIN_E2E_BASE_COMMIT` to its `origin/master` commit, +`ZEPPELIN_E2E_BUILD_MANIFEST` to the manifest used to start the server, and +`ZEPPELIN_E2E_CAPTURE_ROOT` to the server's capture root. On start, +`capture-server.sh` writes the settings it actually launched with to +`capture-provenance.json` in that root: authentication mode, port, streaming +setting, isolated directories and the verified build-manifest id. The capture reads +that file rather than the test environment, fails if it is missing or belongs to +another root, fails if its manifest id differs from `ZEPPELIN_E2E_BUILD_MANIFEST`, +and fails if its port or streaming setting does not match the browser origin or +`ZEPPELIN_CAPTURE_EXPECT_STREAMING`. The fixture records both commits, the +build-manifest identity and relative artifact hashes, the browser name and version, +the explicit loopback origin and port, the `execution` capture mode, authentication +mode, interpreter configuration, and the isolated notebook, search index, log, pid, +and recovery directories. Directory values are sanitized relative to +`` before the fixture is committed. Validation rejects a live capture +when any of this provenance is missing. The committed manifest data contains no +checkout path. + +Captured execution fixtures are written only when `ZEPPELIN_WRITE_EXECUTION_FIXTURES=1`. +They go to `e2e/core-contract/fixtures` unless `ZEPPELIN_E2E_FIXTURE_OUTPUT_DIR` +names another directory. + The capture root and repository paths must not contain whitespace, including in physical paths reached through symlinks. The Zeppelin launcher splits JVM arguments on whitespace; the capture script rejects these paths before creating files or @@ -206,13 +250,46 @@ still select the installed toolchain. ```bash CAPTURE_ROOT="$(mktemp -d)" -e2e/core-contract/capture-server.sh start --root "${CAPTURE_ROOT}" --port 18080 +BUILD_ROOT=/path/to/clean-origin-master-checkout +BUILD_MANIFEST="$(mktemp)" +node e2e/core-contract/capture-build-manifest.mjs create "${BUILD_MANIFEST}" "${BUILD_ROOT}" +e2e/core-contract/capture-server.sh start --root "${CAPTURE_ROOT}" --port 18080 \ + --build-root "${BUILD_ROOT}" --build-manifest "${BUILD_MANIFEST}" ZEPPELIN_E2E_SHIRO_INI="${CAPTURE_ROOT}/conf/shiro.ini" \ + ZEPPELIN_E2E_BUILD_MANIFEST="${BUILD_MANIFEST}" \ + ZEPPELIN_E2E_SOURCE_COMMIT="$(git -C "${BUILD_ROOT}" rev-parse HEAD)" \ + ZEPPELIN_E2E_BASE_COMMIT="$(git -C "${BUILD_ROOT}" rev-parse origin/master)" \ ZEPPELIN_CORE_CONTRACT_RUN_DIR="${CAPTURE_ROOT}/browser" \ CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run e2e:core-contract:live e2e/core-contract/capture-server.sh stop --root "${CAPTURE_ROOT}" ``` +Execution fixtures use a named `sh` interpreter and pin the server-side streaming +switch explicitly. Start one isolated server for each value; do not rewrite the +setting in a running server: + +```bash +e2e/core-contract/capture-server.sh start --root "${CAPTURE_ROOT}" --port 18081 \ + --paragraph-status-progress true --build-root "${BUILD_ROOT}" --build-manifest "${BUILD_MANIFEST}" +ZEPPELIN_CAPTURE_EXPECT_STREAMING=true \ + ZEPPELIN_WRITE_EXECUTION_FIXTURES=1 \ + ZEPPELIN_E2E_CAPTURE_ROOT="${CAPTURE_ROOT}" \ + ZEPPELIN_E2E_SHIRO_INI="${CAPTURE_ROOT}/conf/shiro.ini" \ + ZEPPELIN_E2E_BUILD_MANIFEST="${BUILD_MANIFEST}" \ + ZEPPELIN_E2E_SOURCE_COMMIT="$(git -C "${BUILD_ROOT}" rev-parse HEAD)" \ + ZEPPELIN_E2E_BASE_COMMIT="$(git -C "${BUILD_ROOT}" rev-parse origin/master)" \ + ZEPPELIN_CORE_CONTRACT_RUN_DIR="${CAPTURE_ROOT}/browser" \ + CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:18081 npm run e2e:core-contract:live +e2e/core-contract/capture-server.sh stop --root "${CAPTURE_ROOT}" +``` + +Repeat with a new capture root, port 18082, `--paragraph-status-progress false`, and +`ZEPPELIN_CAPTURE_EXPECT_STREAMING=false` for the disabled fixture. The committed +fixtures were captured on these ports, and their recorded origin shows which server +produced each one. The live +execution scenario skips with a named missing-interpreter reason if `sh` is not +installed; that result is not evidence that the fixture scenario passed. + For authenticated capture, add `--mode auth` to start. That installs `shiro.ini.template` in the capture root; the same `ZEPPELIN_E2E_SHIRO_INI` setting selects it. The helper wiring and a successful authenticated capture are separate diff --git a/zeppelin-web-angular/e2e/core-contract/capture-build-manifest.mjs b/zeppelin-web-angular/e2e/core-contract/capture-build-manifest.mjs new file mode 100644 index 00000000000..dc2547dbc07 --- /dev/null +++ b/zeppelin-web-angular/e2e/core-contract/capture-build-manifest.mjs @@ -0,0 +1,226 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 { createHash } from 'node:crypto'; +import { + existsSync, + lstatSync, + readlinkSync, + readdirSync, + readFileSync, + realpathSync, + statSync, + writeFileSync +} from 'node:fs'; +import { resolve, sep } from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const artifactInputs = [ + { path: '.', selection: 'root-jars' }, + { path: '.', selection: 'all-wars' }, + { path: 'bin' }, + { path: 'conf' }, + { path: 'lib', required: false }, + { path: 'lib/interpreter', required: false }, + { path: 'zeppelin-interpreter/target/classes' }, + { path: 'zeppelin-server/target/classes' }, + { path: 'zeppelin-interpreter/target/lib', required: false }, + { path: 'zeppelin-server/target/lib' }, + { path: 'zeppelin-web/target/lib', required: false }, + { path: 'zeppelin-web/dist', required: false }, + { path: 'zeppelin-web-angular/target/lib', required: false }, + { path: 'zeppelin-server/target/test-classes' }, + { path: 'interpreter' }, + { path: 'zeppelin-interpreter-shaded/target' }, + { path: 'zeppelin-web-angular/dist/zeppelin' }, + { path: 'shell/target/classes' } +]; +// Repository-wide WAR searches skip VCS metadata and frontend toolchains; they never hold launch artifacts. +const skippedSearchDirectories = new Set(['.git', 'node', 'node_modules']); +const license = + 'Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. ' + + 'See the NOTICE file distributed with this work for additional information regarding copyright ownership. ' + + 'The ASF licenses this file to You under the Apache License, Version 2.0.'; + +const hashInput = (root, input) => { + const { path: relativeRoot, required = true, selection = 'tree' } = input; + const absoluteRoot = resolve(root, relativeRoot); + const rootStat = statSync(absoluteRoot, { throwIfNoEntry: false }); + if (!rootStat?.isDirectory()) { + if (required) throw new Error(`required build output is missing: ${relativeRoot}`); + const digest = createHash('sha256').update('missing\0').digest('hex'); + return { exists: false, fileCount: 0, path: relativeRoot, selection, sha256: digest }; + } + const realBuildRoot = realpathSync(root); + const assertInsideBuildRoot = (absolutePath, logicalPath) => { + const target = realpathSync(absolutePath); + if (target !== realBuildRoot && !target.startsWith(`${realBuildRoot}${sep}`)) { + throw new Error(`build output resolves outside the build root: ${logicalPath} -> ${target}`); + } + }; + assertInsideBuildRoot(absoluteRoot, relativeRoot); + const files = []; + const activeDirectories = new Set(); + const visit = (directory, logicalDirectory = '') => { + const realDirectory = realpathSync(directory); + if (activeDirectories.has(realDirectory)) throw new Error(`build output contains a symlink cycle: ${relativeRoot}`); + activeDirectories.add(realDirectory); + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + if (selection === 'root-jars' && logicalDirectory === '' && !entry.name.toLowerCase().endsWith('.jar')) continue; + if (selection === 'all-wars' && skippedSearchDirectories.has(entry.name)) continue; + const absolutePath = resolve(directory, entry.name); + const logicalPath = logicalDirectory ? `${logicalDirectory}/${entry.name}` : entry.name; + const linkStat = lstatSync(absolutePath); + if (linkStat.isSymbolicLink()) { + assertInsideBuildRoot(absolutePath, relativeRoot === '.' ? logicalPath : `${relativeRoot}/${logicalPath}`); + } + const targetStat = statSync(absolutePath); + if (targetStat.isDirectory()) { + if (selection === 'root-jars') continue; + visit(absolutePath, logicalPath); + } else if (targetStat.isFile()) { + if (selection === 'all-wars' && !entry.name.toLowerCase().endsWith('.war')) continue; + files.push({ absolutePath, logicalPath, symlink: linkStat.isSymbolicLink() }); + } else { + throw new Error(`unsupported build output entry: ${relativeRoot}/${logicalPath}`); + } + } + activeDirectories.delete(realDirectory); + }; + visit(absoluteRoot); + const digest = createHash('sha256'); + for (const file of files) { + digest.update(file.logicalPath); + digest.update('\0'); + digest.update(file.symlink ? 'symlink\0' : 'file\0'); + if (file.symlink) { + digest.update(readlinkSync(file.absolutePath)); + digest.update('\0'); + } + digest.update(readFileSync(file.absolutePath)); + digest.update('\0'); + } + return { exists: true, fileCount: files.length, path: relativeRoot, selection, sha256: digest.digest('hex') }; +}; + +const git = (root, ...args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8' }).trim(); +const sortValue = value => { + if (Array.isArray(value)) return value.map(sortValue); + if (value && typeof value === 'object') + return Object.fromEntries( + Object.keys(value) + .sort() + .map(key => [key, sortValue(value[key])]) + ); + return value; +}; +const canonical = value => `${JSON.stringify(sortValue(value), null, 2)}\n`; +const withId = body => ({ + _license: license, + ...body, + manifestId: createHash('sha256').update(canonical(body)).digest('hex') +}); + +const hashTrackedSource = root => { + const output = execFileSync('git', ['-C', root, 'ls-tree', '-rz', '--full-tree', 'HEAD'], { encoding: 'buffer' }); + const digest = createHash('sha256'); + let count = 0; + for (const entry of output.toString('utf8').split('\0').filter(Boolean)) { + const tab = entry.indexOf('\t'); + const [mode, type] = entry.slice(0, tab).split(' '); + if (type !== 'blob') continue; + const path = entry.slice(tab + 1); + const absolutePath = resolve(root, path); + const stat = lstatSync(absolutePath, { throwIfNoEntry: false }); + if (!stat) throw new Error(`tracked source is missing: ${path}`); + const content = stat.isSymbolicLink() ? Buffer.from(readlinkSync(absolutePath)) : readFileSync(absolutePath); + const actual = createHash('sha1').update(`blob ${content.length}\0`).update(content).digest('hex'); + const expected = entry.slice(0, tab).split(' ')[2]; + if (actual !== expected) throw new Error(`tracked source differs from HEAD: ${path}`); + digest.update(mode).update('\0').update(path).update('\0').update(content).update('\0'); + count += 1; + } + return { fileCount: count, sha256: digest.digest('hex') }; +}; + +const findMatchingFiles = (root, pattern) => { + const matches = []; + const active = new Set(); + const visit = (directory, logical = '') => { + const real = realpathSync(directory); + if (active.has(real)) throw new Error('launcher fallback search contains a symlink cycle'); + active.add(real); + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + if (skippedSearchDirectories.has(entry.name)) continue; + const absolute = resolve(directory, entry.name); + const relative = logical ? `${logical}/${entry.name}` : entry.name; + const target = statSync(absolute); + if (target.isDirectory()) visit(absolute, relative); + else if (target.isFile() && pattern.test(entry.name)) matches.push(relative); + } + active.delete(real); + }; + visit(root); + return matches; +}; + +const launchTargets = root => { + const select = (directory, pattern, label) => { + if (existsSync(resolve(root, directory))) return { kind: 'directory', path: directory }; + const candidates = findMatchingFiles(root, pattern); + if (candidates.length > 1) throw new Error(`${label} fallback is ambiguous: ${candidates.join(', ')}`); + return candidates.length === 1 ? { kind: 'war', path: candidates[0] } : { kind: 'missing', path: '' }; + }; + return { + angularWeb: select('zeppelin-web-angular/dist/zeppelin', /^zeppelin-web-angular.*\.war$/, 'Angular WAR'), + classicWeb: select('zeppelin-web/dist', /^zeppelin-web-[0-9].*\.war$/, 'classic WAR') + }; +}; +const createManifest = root => { + const sourceCommit = git(root, 'rev-parse', 'HEAD'); + const baseCommit = git(root, 'rev-parse', 'origin/master'); + if (sourceCommit !== baseCommit) + throw new Error(`build checkout ${sourceCommit} must equal origin/master ${baseCommit}`); + if (git(root, 'status', '--porcelain', '--untracked-files=no')) throw new Error('build checkout has tracked changes'); + return withId({ + artifacts: artifactInputs.map(input => hashInput(root, input)), + baseCommit, + launchTargets: launchTargets(root), + sourceCommit, + sourceTree: hashTrackedSource(root), + version: 3 + }); +}; + +const [command, manifestPath, rootArgument] = process.argv.slice(2); +if (!['create', 'id', 'verify'].includes(command) || !manifestPath || (command !== 'id' && !rootArgument)) { + throw new Error('usage: capture-build-manifest.mjs create|verify | id '); +} +if (command === 'create') { + const manifest = createManifest(resolve(rootArgument)); + writeFileSync(resolve(manifestPath), canonical(manifest)); +} else { + const manifest = JSON.parse(readFileSync(resolve(manifestPath), 'utf8')); + if (command === 'id') { + process.stdout.write(`${manifest.manifestId}\n`); + } else { + const expected = createManifest(resolve(rootArgument)); + if (canonical(manifest) !== canonical(expected)) { + throw new Error('build manifest does not match the current source and launched artifacts'); + } + } +} diff --git a/zeppelin-web-angular/e2e/core-contract/capture-build-manifest.test.mjs b/zeppelin-web-angular/e2e/core-contract/capture-build-manifest.test.mjs new file mode 100644 index 00000000000..be21d46c935 --- /dev/null +++ b/zeppelin-web-angular/e2e/core-contract/capture-build-manifest.test.mjs @@ -0,0 +1,221 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 { execFileSync, spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +const script = new URL('./capture-build-manifest.mjs', import.meta.url).pathname; +const roots = [ + 'interpreter', + 'bin', + 'conf', + 'zeppelin-server/target/classes', + 'zeppelin-server/target/test-classes', + 'zeppelin-server/target/lib', + 'zeppelin-interpreter/target/classes', + 'zeppelin-interpreter-shaded/target', + 'zeppelin-web-angular/dist/zeppelin', + 'shell/target/classes' +]; +const optionalLauncherRoots = [ + 'lib', + 'lib/interpreter', + 'zeppelin-interpreter/target/lib', + 'zeppelin-web/dist', + 'zeppelin-web/target/lib', + 'zeppelin-web-angular/target/lib' +]; + +test('build manifest binds a clean origin/master checkout to the exact launched artifacts', () => { + const root = mkdtempSync(join(tmpdir(), 'zeppelin-build-manifest-')); + try { + execFileSync('git', ['init', '-q', root]); + execFileSync('git', ['-C', root, 'config', 'user.email', 'fixture@example.test']); + execFileSync('git', ['-C', root, 'config', 'user.name', 'Fixture Test']); + for (const directory of roots) { + mkdirSync(join(root, directory), { recursive: true }); + writeFileSync(join(root, directory, 'artifact'), directory); + } + writeFileSync(join(root, 'tracked'), 'source'); + execFileSync('git', ['-C', root, 'add', 'tracked']); + execFileSync('git', ['-C', root, 'commit', '-qm', 'source']); + const head = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + execFileSync('git', ['-C', root, 'update-ref', 'refs/remotes/origin/master', head]); + const manifest = join(root, 'manifest.json'); + + execFileSync(process.execPath, [script, 'create', manifest, root]); + execFileSync(process.execPath, [script, 'verify', manifest, root]); + const captured = JSON.parse(readFileSync(manifest, 'utf8')); + assert.equal(captured.sourceCommit, head); + assert.equal(captured.baseCommit, head); + assert.equal(captured.version, 3); + assert.ok(captured.sourceTree.fileCount > 0); + assert.match(captured.manifestId, /^[0-9a-f]{64}$/); + + writeFileSync(join(root, roots[0], 'artifact'), 'stale replacement'); + const stale = spawnSync(process.execPath, [script, 'verify', manifest, root], { encoding: 'utf8' }); + assert.notEqual(stale.status, 0); + assert.match(stale.stderr, /does not match the current source and launched artifacts/); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + +test('build manifest rejects added launcher jars and changed symlink targets', () => { + const root = mkdtempSync(join(tmpdir(), 'zeppelin-build-manifest-runtime-')); + try { + execFileSync('git', ['init', '-q', root]); + execFileSync('git', ['-C', root, 'config', 'user.email', 'fixture@example.test']); + execFileSync('git', ['-C', root, 'config', 'user.name', 'Fixture Test']); + for (const directory of [...roots, ...optionalLauncherRoots]) mkdirSync(join(root, directory), { recursive: true }); + for (const directory of roots) writeFileSync(join(root, directory, 'artifact'), directory); + writeFileSync(join(root, 'runtime-a'), 'runtime-a'); + writeFileSync(join(root, 'runtime-b'), 'runtime-b'); + symlinkSync(join(root, 'runtime-a'), join(root, 'lib', 'runtime.jar')); + writeFileSync(join(root, 'tracked'), 'source'); + execFileSync('git', ['-C', root, 'add', 'tracked']); + execFileSync('git', ['-C', root, 'commit', '-qm', 'source']); + const head = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + execFileSync('git', ['-C', root, 'update-ref', 'refs/remotes/origin/master', head]); + const manifest = join(root, 'manifest.json'); + + execFileSync(process.execPath, [script, 'create', manifest, root]); + writeFileSync(join(root, 'extra-runtime.jar'), 'unmanifested runtime input'); + const extraJar = spawnSync(process.execPath, [script, 'verify', manifest, root], { encoding: 'utf8' }); + assert.notEqual(extraJar.status, 0); + assert.match(extraJar.stderr, /does not match the current source and launched artifacts/); + + rmSync(join(root, 'extra-runtime.jar')); + rmSync(join(root, 'lib', 'runtime.jar')); + symlinkSync(join(root, 'runtime-b'), join(root, 'lib', 'runtime.jar')); + const changedLink = spawnSync(process.execPath, [script, 'verify', manifest, root], { encoding: 'utf8' }); + assert.notEqual(changedLink.status, 0); + assert.match(changedLink.stderr, /does not match the current source and launched artifacts/); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + +test('build manifest rejects fallback WAR additions and symlink retargeting', () => { + const root = createRepository(); + try { + const manifest = join(root, 'manifest.json'); + execFileSync(process.execPath, [script, 'create', manifest, root]); + mkdirSync(join(root, 'fallback'), { recursive: true }); + writeFileSync(join(root, 'fallback', 'zeppelin-web-0.13.0.war'), 'unexpected'); + assert.notEqual(spawnSync(process.execPath, [script, 'verify', manifest, root]).status, 0); + rmSync(join(root, 'fallback'), { recursive: true }); + + writeFileSync(join(root, 'war-a'), 'same bytes'); + writeFileSync(join(root, 'war-b'), 'same bytes'); + symlinkSync(join(root, 'war-a'), join(root, 'zeppelin-web-angular-test.war')); + execFileSync(process.execPath, [script, 'create', manifest, root]); + rmSync(join(root, 'zeppelin-web-angular-test.war')); + symlinkSync(join(root, 'war-b'), join(root, 'zeppelin-web-angular-test.war')); + assert.notEqual(spawnSync(process.execPath, [script, 'verify', manifest, root]).status, 0); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + +test('build manifest WAR search skips VCS metadata and frontend toolchain directories', () => { + const root = createRepository(); + try { + const manifest = join(root, 'manifest.json'); + execFileSync(process.execPath, [script, 'create', manifest, root]); + for (const directory of ['.git/nested', 'node_modules/pkg', 'zeppelin-web-angular/node']) { + mkdirSync(join(root, directory), { recursive: true }); + writeFileSync(join(root, directory, 'zeppelin-web-0.13.0.war'), 'ignored'); + } + execFileSync(process.execPath, [script, 'verify', manifest, root]); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + +test('build manifest rejects tracked launcher changes hidden with skip-worktree', () => { + const root = createRepository({ trackLauncher: true }); + try { + const manifest = join(root, 'manifest.json'); + execFileSync(process.execPath, [script, 'create', manifest, root]); + execFileSync('git', ['-C', root, 'update-index', '--skip-worktree', 'bin/common.sh']); + writeFileSync(join(root, 'bin', 'common.sh'), 'changed while hidden'); + const result = spawnSync(process.execPath, [script, 'verify', manifest, root], { encoding: 'utf8' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /tracked source differs from HEAD/); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + +test('build manifest rejects changed classic frontend assets', () => { + const root = createRepository(); + try { + const manifest = join(root, 'manifest.json'); + writeFileSync(join(root, 'zeppelin-web', 'dist', 'index.html'), 'captured classic frontend'); + execFileSync(process.execPath, [script, 'create', manifest, root]); + writeFileSync(join(root, 'zeppelin-web', 'dist', 'index.html'), 'changed classic frontend'); + + const changed = spawnSync(process.execPath, [script, 'verify', manifest, root], { encoding: 'utf8' }); + assert.notEqual(changed.status, 0); + assert.match(changed.stderr, /does not match the current source and launched artifacts/); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + +test('build manifest rejects symlinked build outputs that resolve outside the build root', () => { + const root = createRepository(); + const outside = mkdtempSync(join(tmpdir(), 'zeppelin-build-manifest-outside-')); + try { + const manifest = join(root, 'manifest.json'); + writeFileSync(join(outside, 'runtime.jar'), 'outside runtime'); + symlinkSync(join(outside, 'runtime.jar'), join(root, 'lib', 'runtime.jar')); + const linkedFile = spawnSync(process.execPath, [script, 'create', manifest, root], { encoding: 'utf8' }); + assert.notEqual(linkedFile.status, 0); + assert.match(linkedFile.stderr, /build output resolves outside the build root: lib\/runtime\.jar/); + + rmSync(join(root, 'lib', 'runtime.jar')); + symlinkSync(outside, join(root, 'lib', 'external')); + const linkedDirectory = spawnSync(process.execPath, [script, 'create', manifest, root], { encoding: 'utf8' }); + assert.notEqual(linkedDirectory.status, 0); + assert.match(linkedDirectory.stderr, /build output resolves outside the build root: lib\/external/); + } finally { + rmSync(root, { force: true, recursive: true }); + rmSync(outside, { force: true, recursive: true }); + } +}); + +function createRepository({ trackLauncher = false } = {}) { + const root = mkdtempSync(join(tmpdir(), 'zeppelin-build-manifest-adversarial-')); + execFileSync('git', ['init', '-q', root]); + execFileSync('git', ['-C', root, 'config', 'user.email', 'fixture@example.test']); + execFileSync('git', ['-C', root, 'config', 'user.name', 'Fixture Test']); + for (const directory of [...roots, ...optionalLauncherRoots]) mkdirSync(join(root, directory), { recursive: true }); + for (const directory of roots) writeFileSync(join(root, directory, 'artifact'), directory); + writeFileSync(join(root, 'bin', 'common.sh'), 'launcher'); + writeFileSync(join(root, 'tracked'), 'source'); + execFileSync('git', ['-C', root, 'add', 'tracked', ...(trackLauncher ? ['bin/common.sh'] : [])]); + execFileSync('git', ['-C', root, 'commit', '-qm', 'source']); + const head = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + execFileSync('git', ['-C', root, 'update-ref', 'refs/remotes/origin/master', head]); + return root; +} diff --git a/zeppelin-web-angular/e2e/core-contract/capture-server.sh b/zeppelin-web-angular/e2e/core-contract/capture-server.sh index 2100faa8e0f..7152d1141fa 100755 --- a/zeppelin-web-angular/e2e/core-contract/capture-server.sh +++ b/zeppelin-web-angular/e2e/core-contract/capture-server.sh @@ -18,7 +18,7 @@ set -euo pipefail usage() { - echo "usage: $0 start|stop --root [--mode anonymous|auth] [--port ]" >&2 + echo "usage: $0 start|stop --root [--mode anonymous|auth] [--port ] [--paragraph-status-progress true|false] [--build-root --build-manifest ]" >&2 } command="${1:-}" @@ -27,6 +27,10 @@ capture_root="" capture_mode="anonymous" zeppelin_port="8080" port_given="no" +paragraph_status_progress="true" +build_root="" +build_manifest="" +build_manifest_id="" while [[ $# -gt 0 ]]; do case "$1" in @@ -48,6 +52,18 @@ while [[ $# -gt 0 ]]; do fi shift 2 ;; + --paragraph-status-progress) + paragraph_status_progress="${2:-}" + shift 2 + ;; + --build-root) + build_root="${2:-}" + shift 2 + ;; + --build-manifest) + build_manifest="${2:-}" + shift 2 + ;; *) usage exit 2 @@ -59,6 +75,10 @@ if [[ -z "${command}" || -z "${capture_root}" ]]; then usage exit 2 fi +if [[ "${paragraph_status_progress}" != "true" && "${paragraph_status_progress}" != "false" ]]; then + echo "--paragraph-status-progress must be true or false, got '${paragraph_status_progress}'" >&2 + exit 2 +fi reject_whitespace_path() { if [[ "$1" =~ [[:space:]] ]]; then @@ -80,11 +100,23 @@ reject_whitespace_path "${physical_parent}" "canonical capture root" repo_root="$(cd -P "$(dirname "$0")/../../.." && printf '%s/.' "$PWD")" reject_whitespace_path "${repo_root}" "repository" repo_root="${repo_root%/.}" +build_root="${build_root:-${repo_root}}" +if [[ "${build_root}" != "${repo_root}" || -n "${build_manifest}" ]]; then + if [[ -z "${build_manifest}" ]]; then + echo "--build-root requires --build-manifest" >&2 + exit 2 + fi + reject_whitespace_path "${build_root}" "build root" + build_root="$(cd -P "${build_root}" && pwd)" + node "${repo_root}/zeppelin-web-angular/e2e/core-contract/capture-build-manifest.mjs" verify "${build_manifest}" "${build_root}" + build_manifest_id="$(node "${repo_root}/zeppelin-web-angular/e2e/core-contract/capture-build-manifest.mjs" id "${build_manifest}")" +fi # Use the physical path so symlink and direct access produce the same marker. capture_root="$(mkdir -p "${capture_root}" && cd "${capture_root}" && pwd -P)" capture_marker="-Dzeppelin.capture.root=${capture_root}" marker_file="${capture_root}/.zeppelin-capture-root" zeppelin_pid_file="${capture_root}/zeppelin.pid" +provenance_file="${capture_root}/capture-provenance.json" operation_lock="${capture_root}/.capture-operation-lock" acquire_operation_lock() { @@ -204,7 +236,41 @@ stop_pid() { rm -f "${pid_file}" } +json_string() { + local value="${1//\\/\\\\}" + printf '"%s"' "${value//\"/\\\"}" +} + +# Fixture capture reads these settings instead of trusting the capture shell's environment. +write_capture_provenance() { + { + echo "{" + echo " \"authentication\": $(json_string "$1")," + if [[ -n "${build_manifest_id}" ]]; then + echo " \"buildManifestId\": $(json_string "${build_manifest_id}")," + else + echo " \"buildManifestId\": null," + fi + echo " \"configuration\": { \"zeppelin.websocket.paragraph_status_progress.enable\": ${paragraph_status_progress} }," + echo " \"isolation\": {" + echo " \"logs\": $(json_string "$4")," + echo " \"notebook\": $(json_string "$2")," + echo " \"pid\": $(json_string "$5")," + echo " \"recovery\": $(json_string "$6")," + echo " \"root\": $(json_string "${capture_root}")," + echo " \"searchIndex\": $(json_string "$3")" + echo " }," + echo " \"mode\": $(json_string "${capture_mode}")," + echo " \"port\": ${zeppelin_port}" + echo "}" + } > "${provenance_file}" +} + start_zeppelin() { + if [[ -z "${CAPTURE_ZEPPELIN_COMMAND:-}" && -z "${build_manifest}" ]]; then + echo "real capture startup requires --build-root and --build-manifest" >&2 + exit 2 + fi # Environment and JVM properties override the temporary site XML. # Clear inherited settings that could redirect storage, classpaths or remote connections. # Keep JAVA_HOME and PATH to select the installed toolchain. @@ -221,23 +287,34 @@ start_zeppelin() { # different notebook dir or bind address) would silently override the isolation this script # promises. This script never writes that file itself, so any copy here is stale. rm -f "${capture_root}/conf/zeppelin-env.sh" - cp "${repo_root}/conf/log4j2.properties" "${capture_root}/conf/log4j2.properties" - cp "${repo_root}/conf/zeppelin-site.xml.template" "${capture_root}/conf/zeppelin-site.xml" + cp "${build_root}/conf/log4j2.properties" "${capture_root}/conf/log4j2.properties" + cp "${build_root}/conf/zeppelin-site.xml.template" "${capture_root}/conf/zeppelin-site.xml" if [[ "${capture_mode}" == "auth" ]]; then - cp "${repo_root}/conf/shiro.ini.template" "${capture_root}/conf/shiro.ini" + cp "${build_root}/conf/shiro.ini.template" "${capture_root}/conf/shiro.ini" else rm -f "${capture_root}/conf/shiro.ini" fi + local notebook_dir="${capture_root}/notebook" + local index_dir="${capture_root}/index" + local log_dir="${capture_root}/logs" + local pid_dir="${capture_root}/run" + local recovery_dir="${capture_root}/recovery" + local authentication="anonymous" + if [[ "${capture_mode}" == "auth" ]]; then + authentication="authenticated" + fi + write_capture_provenance "${authentication}" "${notebook_dir}" "${index_dir}" "${log_dir}" "${pid_dir}" "${recovery_dir}" + export ZEPPELIN_CONF_DIR="${capture_root}/conf" export ZEPPELIN_ADDR="127.0.0.1" export ZEPPELIN_NOTEBOOK_STORAGE="org.apache.zeppelin.notebook.repo.VFSNotebookRepo" - export ZEPPELIN_NOTEBOOK_DIR="${capture_root}/notebook" - export ZEPPELIN_LOG_DIR="${capture_root}/logs" - export ZEPPELIN_PID_DIR="${capture_root}/run" + export ZEPPELIN_NOTEBOOK_DIR="${notebook_dir}" + export ZEPPELIN_LOG_DIR="${log_dir}" + export ZEPPELIN_PID_DIR="${pid_dir}" export ZEPPELIN_WAR_TEMPDIR="${capture_root}/webapps" # Zeppelin ignores this marker; verify_pid_identity matches it as a whole JVM argument. - export ZEPPELIN_JAVA_OPTS="-Dzeppelin.server.port=${zeppelin_port} -Dzeppelin.notebook.dir=${capture_root}/notebook -Dzeppelin.search.index.path=${capture_root}/index -Dzeppelin.recovery.dir=${capture_root}/recovery ${capture_marker}" + export ZEPPELIN_JAVA_OPTS="-Dzeppelin.server.port=${zeppelin_port} -Dzeppelin.notebook.dir=${notebook_dir} -Dzeppelin.search.index.path=${index_dir} -Dzeppelin.recovery.dir=${recovery_dir} -Dzeppelin.websocket.paragraph_status_progress.enable=${paragraph_status_progress} ${capture_marker}" export ZEPPELIN_CAPTURE_ROOT="${capture_root}" export ZEPPELIN_PORT="${zeppelin_port}" # Do not inherit Hadoop settings for this fixture server. @@ -252,7 +329,7 @@ start_zeppelin() { bash -c "${CAPTURE_ZEPPELIN_COMMAND} $(printf '%q' "${capture_marker}")" "${capture_root}/logs/zeppelin-stdout.log" 2>"${capture_root}/logs/zeppelin-stderr.log" & echo "$!" > "${zeppelin_pid_file}" else - "${repo_root}/bin/zeppelin.sh" "${capture_root}/logs/zeppelin-stdout.log" 2>"${capture_root}/logs/zeppelin-stderr.log" & + "${build_root}/bin/zeppelin.sh" "${capture_root}/logs/zeppelin-stdout.log" 2>"${capture_root}/logs/zeppelin-stderr.log" & echo "$!" > "${zeppelin_pid_file}" fi set +m diff --git a/zeppelin-web-angular/e2e/core-contract/capture-server.test.mjs b/zeppelin-web-angular/e2e/core-contract/capture-server.test.mjs index 6cbce2c31cb..3a13dacc10c 100644 --- a/zeppelin-web-angular/e2e/core-contract/capture-server.test.mjs +++ b/zeppelin-web-angular/e2e/core-contract/capture-server.test.mjs @@ -95,6 +95,106 @@ test('capture server rejects a repository path with whitespace before creating t assert.equal(existsSync(root), false); }); +const buildOutputs = [ + 'bin', + 'conf', + 'interpreter', + 'shell/target/classes', + 'zeppelin-interpreter-shaded/target', + 'zeppelin-interpreter/target/classes', + 'zeppelin-server/target/classes', + 'zeppelin-server/target/lib', + 'zeppelin-server/target/test-classes', + 'zeppelin-web-angular/dist/zeppelin' +]; + +for (const [name, prepare, expected] of [ + [ + 'a checkout that is not origin/master', + buildRoot => { + writeFileSync(path.join(buildRoot, 'tracked'), 'local commit'); + git(buildRoot, 'commit', '-qam', 'local'); + }, + /^Error: build checkout [0-9a-f]{40} must equal origin\/master [0-9a-f]{40}$/m + ], + [ + 'a checkout with tracked changes', + buildRoot => writeFileSync(path.join(buildRoot, 'tracked'), 'uncommitted'), + /^Error: build checkout has tracked changes$/m + ], + [ + 'a build with a missing required output', + buildRoot => rmSync(path.join(buildRoot, 'zeppelin-server/target/lib'), { recursive: true }), + /^Error: required build output is missing: zeppelin-server\/target\/lib$/m + ], + [ + 'a manifest that does not match the build', + () => {}, + /^Error: build manifest does not match the current source and launched artifacts$/m + ] +]) { + test(`capture server refuses ${name} before launching it`, () => { + const parent = createRoot(); + const buildRoot = createBuildRepository(parent.root); + prepare(buildRoot); + const root = path.join(parent.root, 'capture'); + const manifest = path.join(parent.root, 'manifest.json'); + const launched = path.join(parent.root, 'launched'); + writeFileSync(manifest, '{}\n'); + + const result = run( + [ + 'start', + '--root', + root, + '--port', + String(parent.zeppelinPort), + '--build-root', + buildRoot, + '--build-manifest', + manifest + ], + { CAPTURE_ZEPPELIN_COMMAND: `touch '${launched}'` } + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, expected); + assert.equal(existsSync(root), false); + assert.equal(existsSync(launched), false); + }); +} + +test('capture server requires a manifest for an external build root', () => { + const parent = createRoot(); + const buildRoot = createBuildRepository(parent.root); + const root = path.join(parent.root, 'capture'); + const result = run(['start', '--root', root, '--port', String(parent.zeppelinPort), '--build-root', buildRoot]); + assert.equal(result.status, 2, result.stderr); + assert.equal(result.stderr, '--build-root requires --build-manifest\n'); + assert.equal(existsSync(root), false); +}); + +function git(root, ...args) { + return spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' }); +} + +function createBuildRepository(parent) { + const buildRoot = path.join(parent, 'build'); + mkdirSync(buildRoot); + git(buildRoot, 'init', '-q'); + git(buildRoot, 'config', 'user.email', 'fixture@example.test'); + git(buildRoot, 'config', 'user.name', 'Fixture Test'); + for (const directory of buildOutputs) { + mkdirSync(path.join(buildRoot, directory), { recursive: true }); + writeFileSync(path.join(buildRoot, directory, 'artifact'), directory); + } + writeFileSync(path.join(buildRoot, 'tracked'), 'source'); + git(buildRoot, 'add', 'tracked'); + git(buildRoot, 'commit', '-qm', 'source'); + git(buildRoot, 'update-ref', 'refs/remotes/origin/master', 'HEAD'); + return buildRoot; +} + for (const action of ['start', 'stop']) { test(`capture server refuses concurrent ${action} while startup owns the root`, async () => { const root = createRoot(); @@ -235,6 +335,43 @@ await import(${JSON.stringify(stub)}); } }); +test('capture server pins paragraph status and progress streaming for execution fixtures', () => { + const root = createRoot(); + const probe = path.join(root.root, 'probe.mjs'); + const observed = path.join(root.root, 'jvm-options'); + writeFileSync( + probe, + `import { writeFileSync } from 'node:fs'; +writeFileSync(${JSON.stringify(observed)}, process.env.ZEPPELIN_JAVA_OPTS); +await import(${JSON.stringify(stub)}); +` + ); + const result = run( + ['start', '--root', root.root, '--port', String(root.zeppelinPort), '--paragraph-status-progress', 'false'], + { CAPTURE_ZEPPELIN_COMMAND: `node ${probe}` } + ); + try { + assert.equal(result.status, 0, result.stderr); + assert.match(readFileSync(observed, 'utf8'), /-Dzeppelin\.websocket\.paragraph_status_progress\.enable=false/); + } finally { + if (existsSync(path.join(root.root, 'zeppelin.pid'))) stop(root); + } +}); + +test('capture server rejects an invalid paragraph status and progress setting before side effects', () => { + const parent = createRoot(); + const root = path.join(parent.root, 'invalid-streaming'); + const launched = path.join(parent.root, 'launched'); + const result = run( + ['start', '--root', root, '--port', String(parent.zeppelinPort), '--paragraph-status-progress', 'sometimes'], + { CAPTURE_ZEPPELIN_COMMAND: `touch '${launched}'` } + ); + assert.equal(result.status, 2, result.stderr); + assert.match(result.stderr, /must be true or false/); + assert.equal(existsSync(root), false); + assert.equal(existsSync(launched), false); +}); + test('capture-server starts and stops a server in its own root', () => { const root = createRoot(); @@ -258,6 +395,79 @@ test('capture-server writes anonymous and auth config in an isolated temp root', assert.equal(existsSync(path.join(auth.root, 'conf/shiro.ini')), true); }); +test('capture-server records the settings it launched with for fixture provenance', () => { + const root = createRoot(); + const result = run( + [ + 'start', + '--root', + root.root, + '--mode', + 'auth', + '--port', + String(root.zeppelinPort), + '--paragraph-status-progress', + 'false' + ], + { CAPTURE_ZEPPELIN_COMMAND: `node ${stub}` } + ); + try { + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(readFileSync(path.join(root.root, 'capture-provenance.json'), 'utf8')), { + authentication: 'authenticated', + buildManifestId: null, + configuration: { 'zeppelin.websocket.paragraph_status_progress.enable': false }, + isolation: { + logs: `${root.root}/logs`, + notebook: `${root.root}/notebook`, + pid: `${root.root}/run`, + recovery: `${root.root}/recovery`, + root: root.root, + searchIndex: `${root.root}/index` + }, + mode: 'auth', + port: root.zeppelinPort + }); + } finally { + if (existsSync(path.join(root.root, 'zeppelin.pid'))) stop(root); + } +}); + +test('capture-server records the verified build manifest id for fixture provenance', () => { + const parent = createRoot(); + const buildRoot = createBuildRepository(parent.root); + for (const file of ['log4j2.properties', 'zeppelin-site.xml.template']) { + writeFileSync(path.join(buildRoot, 'conf', file), ''); + } + const manifest = path.join(parent.root, 'manifest.json'); + const manifestScript = path.resolve('e2e/core-contract/capture-build-manifest.mjs'); + const created = spawnSync(process.execPath, [manifestScript, 'create', manifest, buildRoot], { encoding: 'utf8' }); + assert.equal(created.status, 0, created.stderr); + const root = { root: path.join(parent.root, 'capture'), zeppelinPort: parent.zeppelinPort }; + const result = run( + [ + 'start', + '--root', + root.root, + '--port', + String(root.zeppelinPort), + '--build-root', + buildRoot, + '--build-manifest', + manifest + ], + { CAPTURE_ZEPPELIN_COMMAND: `node ${stub}` } + ); + try { + assert.equal(result.status, 0, result.stderr); + const provenance = JSON.parse(readFileSync(path.join(root.root, 'capture-provenance.json'), 'utf8')); + assert.equal(provenance.buildManifestId, JSON.parse(readFileSync(manifest, 'utf8')).manifestId); + assert.match(provenance.buildManifestId, /^[0-9a-f]{64}$/); + } finally { + if (existsSync(path.join(root.root, 'zeppelin.pid'))) stop(root); + } +}); + test('capture-server reports explicit port conflicts', async () => { const server = await listen(); const root = createRoot(); diff --git a/zeppelin-web-angular/e2e/core-contract/execution-fixtures.test.mjs b/zeppelin-web-angular/e2e/core-contract/execution-fixtures.test.mjs new file mode 100644 index 00000000000..c582ee4c9c3 --- /dev/null +++ b/zeppelin-web-angular/e2e/core-contract/execution-fixtures.test.mjs @@ -0,0 +1,244 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; + +import { createPlaywrightFixtureAdapter, validateFixture } from './notebook-transport-fixture.mjs'; + +const fixturePath = name => new URL(`./fixtures/${name}.json`, import.meta.url); +const loadFixture = async name => JSON.parse(await readFile(fixturePath(name), 'utf8')); +const cloneFixture = fixture => JSON.parse(JSON.stringify(fixture)); +const envelope = record => JSON.parse(record.websocket.payloadText); +const operations = (fixture, direction) => + fixture.records + .filter(record => record.kind === 'websocket' && (!direction || record.websocket.direction === direction)) + .map(envelope); +const terminalParagraph = fixture => + operations(fixture, 'receive') + .filter(message => message.op === 'PARAGRAPH') + .findLast(message => ['FINISHED', 'ERROR', 'ABORT'].includes(message.data?.paragraph?.status)); +const paragraphTextOutput = paragraph => + paragraph?.data?.paragraph?.results?.msg + ?.filter(message => message.type === 'TEXT') + .map(message => message.data) + .join(''); + +const replay = async fixture => { + const routeHandlers = []; + const socketHandlers = []; + const received = []; + const adapter = createPlaywrightFixtureAdapter(fixture); + await adapter.install({ + route: async (_pattern, handler) => routeHandlers.push(handler), + routeWebSocket: async (_pattern, handler) => socketHandlers.push(handler) + }); + assert.equal(routeHandlers.length, 1); + assert.equal(socketHandlers.length, 1); + + const clientHandlers = []; + socketHandlers[0]({ + onMessage: handler => clientHandlers.push(handler), + send: payload => received.push(JSON.parse(payload)) + }); + assert.equal(clientHandlers.length, 1); + + let runtimeMessageId = 0; + for (const record of fixture.records.filter( + entry => entry.kind === 'websocket' && entry.websocket.direction === 'send' + )) { + const message = envelope(record); + clientHandlers[0]( + JSON.stringify({ + ...message, + ...(typeof message.msgId === 'string' ? { msgId: `runtime-${++runtimeMessageId}` } : {}) + }) + ); + } + adapter.assertComplete(); + return received; +}; + +const resequence = fixture => ({ + ...fixture, + records: fixture.records.map((record, index) => ({ ...record, sequence: index + 1 })) +}); + +test('streaming-enabled execution captures and replays progress, update, append and terminal delivery', async () => { + const fixture = await loadFixture('execution-streaming-enabled'); + assert.deepEqual(validateFixture(fixture), []); + assert.equal(fixture.metadata.interpreter, 'sh'); + assert.equal(fixture.metadata.configuration['zeppelin.websocket.paragraph_status_progress.enable'], true); + + const captured = operations(fixture, 'receive').map(message => message.op); + for (const operation of ['PROGRESS', 'PARAGRAPH_UPDATE_OUTPUT', 'PARAGRAPH_APPEND_OUTPUT']) { + assert.ok(captured.includes(operation), `missing captured ${operation}`); + } + assert.ok(['FINISHED', 'ERROR', 'ABORT'].includes(terminalParagraph(fixture)?.data.paragraph.status)); + + const replayed = await replay(fixture); + assert.deepEqual( + replayed.map(message => message.op), + captured + ); + assert.equal( + terminalParagraph({ + records: replayed.map((message, index) => ({ + kind: 'websocket', + sequence: index + 1, + websocket: { direction: 'receive', payloadText: JSON.stringify(message) } + })) + })?.data.paragraph.status, + 'FINISHED' + ); +}); + +test('committed captures bind the canonical source and artifact manifest', async () => { + const canonical = JSON.parse(await readFile(fixturePath('build-manifest'), 'utf8')); + const expected = { ...canonical, id: canonical.manifestId }; + delete expected._license; + delete expected.manifestId; + for (const name of ['execution-streaming-enabled', 'execution-streaming-disabled', 'execution-cancel']) { + const fixture = await loadFixture(name); + assert.deepEqual(fixture.metadata.provenance.buildManifest, expected, name); + for (const [mutation, message] of [ + [manifest => (manifest.id = '0'.repeat(64)), /buildManifest\.id must match the manifest content/], + [manifest => (manifest.baseCommit = '0'.repeat(40)), /buildManifest\.baseCommit must match/], + [manifest => (manifest.sourceTree.sha256 = '0'.repeat(64)), /buildManifest\.id must match the manifest content/], + [manifest => (manifest.artifacts[0].sha256 = '0'.repeat(64)), /buildManifest\.id must match the manifest content/] + ]) { + const changed = cloneFixture(fixture); + mutation(changed.metadata.provenance.buildManifest); + assert.match(validateFixture(changed).join('\n'), message, `${name} accepted changed manifest`); + } + } +}); + +test('execution coverage cannot claim absent operations or omit represented operations', async () => { + const fixture = await loadFixture('execution-streaming-disabled'); + assert.deepEqual(validateFixture(fixture), []); + + const falseCoverage = cloneFixture(fixture); + falseCoverage.metadata.coveredOperations.push('PROGRESS'); + assert.match(validateFixture(falseCoverage).join('\n'), /exactly match operations represented/); + + const missingCoverage = cloneFixture(fixture); + missingCoverage.metadata.coveredOperations = missingCoverage.metadata.coveredOperations.filter( + operation => operation !== 'RUN_PARAGRAPH' + ); + assert.match(validateFixture(missingCoverage).join('\n'), /exactly match operations represented/); + + const otherInterpreter = cloneFixture(falseCoverage); + otherInterpreter.metadata.interpreter = 'python'; + otherInterpreter.metadata.provenance.interpreter = 'python'; + assert.match(validateFixture(otherInterpreter).join('\n'), /exactly match operations represented/); +}); + +test('streaming-disabled execution omits incremental events and retains a terminal paragraph', async () => { + const fixture = await loadFixture('execution-streaming-disabled'); + assert.deepEqual(validateFixture(fixture), []); + assert.equal(fixture.metadata.interpreter, 'sh'); + assert.equal(fixture.metadata.configuration['zeppelin.websocket.paragraph_status_progress.enable'], false); + + const received = operations(fixture, 'receive'); + const executionOperations = received.filter(message => + ['PROGRESS', 'PARAGRAPH_UPDATE_OUTPUT', 'PARAGRAPH_APPEND_OUTPUT', 'PARAGRAPH'].includes(message.op) + ); + assert.equal( + executionOperations.some(message => + ['PROGRESS', 'PARAGRAPH_UPDATE_OUTPUT', 'PARAGRAPH_APPEND_OUTPUT'].includes(message.op) + ), + false + ); + const terminal = terminalParagraph(fixture); + assert.equal(terminal?.data.paragraph.status, 'FINISHED'); + assert.equal(paragraphTextOutput(terminal), 'first\nsecond\n'); + const replayed = await replay(fixture); + assert.deepEqual( + replayed.map(message => message.op), + received.map(message => message.op) + ); + assert.equal(paragraphTextOutput(replayed.findLast(message => message.op === 'PARAGRAPH')), 'first\nsecond\n'); +}); + +test('cancellation replays run and explicit cancel before an ABORT terminal paragraph', async () => { + const fixture = await loadFixture('execution-cancel'); + assert.deepEqual(validateFixture(fixture), []); + assert.deepEqual( + operations(fixture, 'send') + .map(message => message.op) + .filter(operation => operation !== 'GET_NOTE'), + ['RUN_PARAGRAPH', 'CANCEL_PARAGRAPH'] + ); + assert.equal(terminalParagraph(fixture)?.data.paragraph.status, 'ABORT'); + const replayed = await replay(fixture); + assert.equal(replayed.findLast(message => message.op === 'PARAGRAPH')?.data.paragraph.status, 'ABORT'); +}); + +test('replay preserves a delayed APPEND after UPDATE without treating one capture order as universal', async () => { + const fixture = await loadFixture('execution-streaming-enabled'); + const records = [...fixture.records]; + const appendIndex = records.findIndex(record => envelope(record).op === 'PARAGRAPH_APPEND_OUTPUT'); + const [append] = records.splice(appendIndex, 1); + const terminalIndex = records.findLastIndex(record => envelope(record).op === 'PARAGRAPH'); + records.splice(terminalIndex, 0, append); + const variant = resequence({ ...fixture, records }); + + const opOrder = recordList => recordList.map(record => envelope(record).op); + assert.notDeepEqual(opOrder(variant.records), opOrder(fixture.records)); + assert.ok( + records.indexOf(append) < records.findLastIndex(record => envelope(record).op === 'PARAGRAPH'), + 'moved APPEND must still precede the terminal PARAGRAPH' + ); + assert.deepEqual(validateFixture(variant), []); + const received = await replay(variant); + assert.deepEqual( + received + .filter(message => ['PARAGRAPH_UPDATE_OUTPUT', 'PARAGRAPH_APPEND_OUTPUT'].includes(message.op)) + .map(message => [message.op, message.data.data]), + [ + ['PARAGRAPH_UPDATE_OUTPUT', ''], + ['PARAGRAPH_APPEND_OUTPUT', 'first\n'], + ['PARAGRAPH_APPEND_OUTPUT', 'second\n'] + ] + ); +}); + +test('replay delivers an APPEND delayed until after the terminal PARAGRAPH', async () => { + const fixture = await loadFixture('execution-streaming-enabled'); + const records = [...fixture.records]; + const terminalIndex = records.findLastIndex(record => envelope(record).op === 'PARAGRAPH'); + const appendIndex = records.findIndex(record => envelope(record).op === 'PARAGRAPH_APPEND_OUTPUT'); + assert.ok(appendIndex >= 0 && appendIndex < terminalIndex, 'fixture needs an APPEND before the terminal PARAGRAPH'); + const [append] = records.splice(appendIndex, 1); + records.push(append); + const variant = resequence({ ...fixture, records }); + + const opOrder = recordList => recordList.map(record => envelope(record).op); + assert.notDeepEqual(opOrder(variant.records), opOrder(fixture.records)); + assert.deepEqual(validateFixture(variant), []); + const received = await replay(variant); + const delayed = envelope(append).data.data; + const deliveredAt = received.findIndex( + message => message.op === 'PARAGRAPH_APPEND_OUTPUT' && message.data.data === delayed + ); + const terminalAt = received.findLastIndex(message => message.op === 'PARAGRAPH'); + assert.equal(received[terminalAt].data.paragraph.status, 'FINISHED'); + assert.ok(terminalAt >= 0 && deliveredAt > terminalAt, 'moved APPEND must be delivered after the terminal PARAGRAPH'); + assert.equal(deliveredAt, received.length - 1); +}); diff --git a/zeppelin-web-angular/e2e/core-contract/fixtures/build-manifest.json b/zeppelin-web-angular/e2e/core-contract/fixtures/build-manifest.json new file mode 100644 index 00000000000..f800bfda50c --- /dev/null +++ b/zeppelin-web-angular/e2e/core-contract/fixtures/build-manifest.json @@ -0,0 +1,149 @@ +{ + "artifacts": [ + { + "exists": true, + "fileCount": 0, + "path": ".", + "selection": "root-jars", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "exists": true, + "fileCount": 1, + "path": ".", + "selection": "all-wars", + "sha256": "bf4d480bf55a2683bee76b13e6470acd2bfbe258e23cd6da02f25d36783f73e1" + }, + { + "exists": true, + "fileCount": 13, + "path": "bin", + "selection": "tree", + "sha256": "37d85b2da8ca20e0d705e0b0e7041507a41c109a64987e20b62cd5fba2ff89eb" + }, + { + "exists": true, + "fileCount": 10, + "path": "conf", + "selection": "tree", + "sha256": "193334ddf2c654ed7a544b2951a7c2517ba32f183ddbb8fe42e10544688e16b4" + }, + { + "exists": false, + "fileCount": 0, + "path": "lib", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": false, + "fileCount": 0, + "path": "lib/interpreter", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": true, + "fileCount": 1034, + "path": "zeppelin-interpreter/target/classes", + "selection": "tree", + "sha256": "e3e940f06876febb19f4ffbb9fd4d34d232d6b711fa673ad144d7f5cffaddf03" + }, + { + "exists": true, + "fileCount": 345, + "path": "zeppelin-server/target/classes", + "selection": "tree", + "sha256": "73509c0b8161145ac9a9f972bbe7948d269109b7a17294a65727d82c710d408e" + }, + { + "exists": false, + "fileCount": 0, + "path": "zeppelin-interpreter/target/lib", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": true, + "fileCount": 246, + "path": "zeppelin-server/target/lib", + "selection": "tree", + "sha256": "f90f1520c7f09cb2bfe590bbf6ebd131a67e67cae61a65cda174323e2a28b238" + }, + { + "exists": false, + "fileCount": 0, + "path": "zeppelin-web/target/lib", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": false, + "fileCount": 0, + "path": "zeppelin-web/dist", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": false, + "fileCount": 0, + "path": "zeppelin-web-angular/target/lib", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": true, + "fileCount": 291, + "path": "zeppelin-server/target/test-classes", + "selection": "tree", + "sha256": "4f9dd82ee7bdca57d13b61c219c898af54fb7374aa72249a477e6c6eda1e231c" + }, + { + "exists": true, + "fileCount": 11, + "path": "interpreter", + "selection": "tree", + "sha256": "a009e8eff2d2736c4c9843a8e06b3b85dc110f13a93624a53e1a7d4fccf9928e" + }, + { + "exists": true, + "fileCount": 14, + "path": "zeppelin-interpreter-shaded/target", + "selection": "tree", + "sha256": "7b4de0e8a4b24ac3ea74147a3a11cc4e268c0d1a312435421300f6947578fce8" + }, + { + "exists": true, + "fileCount": 5182, + "path": "zeppelin-web-angular/dist/zeppelin", + "selection": "tree", + "sha256": "ac2af3ebcf9704292c888ce0309345f989583f3b5ce6d4b694429ae6bec0b33f" + }, + { + "exists": true, + "fileCount": 18, + "path": "shell/target/classes", + "selection": "tree", + "sha256": "c2d25e7912828176d0b85fb6f04904473b33142bf3598265ea555ff420fb2ffa" + } + ], + "baseCommit": "e816bf1b76b50282cc32b284cdb8755f932f657e", + "launchTargets": { + "angularWeb": { + "kind": "directory", + "path": "zeppelin-web-angular/dist/zeppelin" + }, + "classicWeb": { + "kind": "missing", + "path": "" + } + }, + "manifestId": "97518d114acf7884a920337ce2696a87d2c14859411064aa523658a82329343a", + "sourceCommit": "e816bf1b76b50282cc32b284cdb8755f932f657e", + "sourceTree": { + "fileCount": 2644, + "sha256": "7c6149a7c0ab8111f096b0f60595636074bfc77adb73695c7923dd5e32b225d6" + }, + "version": 3, + "_license": "Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0." +} diff --git a/zeppelin-web-angular/e2e/core-contract/fixtures/execution-cancel.json b/zeppelin-web-angular/e2e/core-contract/fixtures/execution-cancel.json new file mode 100644 index 00000000000..e1ab7e09c42 --- /dev/null +++ b/zeppelin-web-angular/e2e/core-contract/fixtures/execution-cancel.json @@ -0,0 +1,311 @@ +{ + "_license": "Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0.", + "metadata": { + "captureSource": "live-server", + "capturedAt": "2026-09-13T19:33:08.906Z", + "configuration": { + "zeppelin.websocket.paragraph_status_progress.enable": true + }, + "coveredOperations": [ + "GET_NOTE", + "COLLABORATIVE_MODE_STATUS", + "NOTE", + "RUN_PARAGRAPH", + "SAVE_NOTE_FORMS", + "PARAGRAPH", + "PARAGRAPH_ADDED", + "CANCEL_PARAGRAPH" + ], + "interpreter": "sh", + "knownExclusions": [], + "owner": "zeppelin-web-angular", + "provenance": { + "baseCommit": "e816bf1b76b50282cc32b284cdb8755f932f657e", + "authentication": "anonymous", + "browser": { + "name": "chromium", + "version": "140.0.7339.186" + }, + "buildManifest": { + "artifacts": [ + { + "exists": true, + "fileCount": 0, + "path": ".", + "selection": "root-jars", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "exists": true, + "fileCount": 1, + "path": ".", + "selection": "all-wars", + "sha256": "bf4d480bf55a2683bee76b13e6470acd2bfbe258e23cd6da02f25d36783f73e1" + }, + { + "exists": true, + "fileCount": 13, + "path": "bin", + "selection": "tree", + "sha256": "37d85b2da8ca20e0d705e0b0e7041507a41c109a64987e20b62cd5fba2ff89eb" + }, + { + "exists": true, + "fileCount": 10, + "path": "conf", + "selection": "tree", + "sha256": "193334ddf2c654ed7a544b2951a7c2517ba32f183ddbb8fe42e10544688e16b4" + }, + { + "exists": false, + "fileCount": 0, + "path": "lib", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": false, + "fileCount": 0, + "path": "lib/interpreter", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": true, + "fileCount": 1034, + "path": "zeppelin-interpreter/target/classes", + "selection": "tree", + "sha256": "e3e940f06876febb19f4ffbb9fd4d34d232d6b711fa673ad144d7f5cffaddf03" + }, + { + "exists": true, + "fileCount": 345, + "path": "zeppelin-server/target/classes", + "selection": "tree", + "sha256": "73509c0b8161145ac9a9f972bbe7948d269109b7a17294a65727d82c710d408e" + }, + { + "exists": false, + "fileCount": 0, + "path": "zeppelin-interpreter/target/lib", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": true, + "fileCount": 246, + "path": "zeppelin-server/target/lib", + "selection": "tree", + "sha256": "f90f1520c7f09cb2bfe590bbf6ebd131a67e67cae61a65cda174323e2a28b238" + }, + { + "exists": false, + "fileCount": 0, + "path": "zeppelin-web/target/lib", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": false, + "fileCount": 0, + "path": "zeppelin-web/dist", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": false, + "fileCount": 0, + "path": "zeppelin-web-angular/target/lib", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": true, + "fileCount": 291, + "path": "zeppelin-server/target/test-classes", + "selection": "tree", + "sha256": "4f9dd82ee7bdca57d13b61c219c898af54fb7374aa72249a477e6c6eda1e231c" + }, + { + "exists": true, + "fileCount": 11, + "path": "interpreter", + "selection": "tree", + "sha256": "a009e8eff2d2736c4c9843a8e06b3b85dc110f13a93624a53e1a7d4fccf9928e" + }, + { + "exists": true, + "fileCount": 14, + "path": "zeppelin-interpreter-shaded/target", + "selection": "tree", + "sha256": "7b4de0e8a4b24ac3ea74147a3a11cc4e268c0d1a312435421300f6947578fce8" + }, + { + "exists": true, + "fileCount": 5182, + "path": "zeppelin-web-angular/dist/zeppelin", + "selection": "tree", + "sha256": "ac2af3ebcf9704292c888ce0309345f989583f3b5ce6d4b694429ae6bec0b33f" + }, + { + "exists": true, + "fileCount": 18, + "path": "shell/target/classes", + "selection": "tree", + "sha256": "c2d25e7912828176d0b85fb6f04904473b33142bf3598265ea555ff420fb2ffa" + } + ], + "baseCommit": "e816bf1b76b50282cc32b284cdb8755f932f657e", + "id": "97518d114acf7884a920337ce2696a87d2c14859411064aa523658a82329343a", + "launchTargets": { + "angularWeb": { + "kind": "directory", + "path": "zeppelin-web-angular/dist/zeppelin" + }, + "classicWeb": { + "kind": "missing", + "path": "" + } + }, + "sourceCommit": "e816bf1b76b50282cc32b284cdb8755f932f657e", + "sourceTree": { + "fileCount": 2644, + "sha256": "7c6149a7c0ab8111f096b0f60595636074bfc77adb73695c7923dd5e32b225d6" + }, + "version": 3 + }, + "captureMode": "execution", + "configuration": { + "zeppelin.websocket.paragraph_status_progress.enable": true + }, + "interpreter": "sh", + "isolation": { + "logs": "/logs", + "notebook": "/notebook", + "pid": "/run", + "recovery": "/recovery", + "root": "", + "searchIndex": "/index" + }, + "origin": "http://127.0.0.1:18081", + "sourceCommit": "e816bf1b76b50282cc32b284cdb8755f932f657e" + }, + "scenario": "Cancel a running paragraph", + "zeppelinVersion": "0.13.0-SNAPSHOT" + }, + "records": [ + { + "kind": "websocket", + "websocket": { + "direction": "send", + "payloadText": "{\"op\":\"GET_NOTE\",\"msgId\":\"\",\"data\":{\"id\":\"2N5BSSMFA\"},\"principal\":\"\",\"ticket\":\"\",\"roles\":\"\"}" + }, + "sequence": 1 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"COLLABORATIVE_MODE_STATUS\",\"data\":{\"status\":false},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 2 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"NOTE\",\"data\":{\"note\":{\"paragraphs\":[{\"user\":\"\",\"progress\":0,\"config\":{},\"settings\":{\"params\":{},\"forms\":{}},\"apps\":[],\"runtimeInfos\":{},\"progressUpdateIntervalMs\":500,\"jobName\":\"paragraph_1789327988894_422040464\",\"id\":\"paragraph_1789327988894_422040464\",\"dateCreated\":\"\",\"status\":\"READY\"}],\"name\":\"ExecutionContractCapture_1789327988891\",\"id\":\"2N5BSSMFA\",\"defaultInterpreterGroup\":\"sh\",\"version\":\"0.13.0-SNAPSHOT\",\"noteParams\":{},\"noteForms\":{},\"angularObjects\":{},\"config\":{\"isZeppelinNotebookCronEnable\":false},\"info\":{},\"path\":\"/E2E_TEST_FOLDER/ExecutionContractCapture_1789327988891\"}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 3 + }, + { + "kind": "websocket", + "websocket": { + "direction": "send", + "payloadText": "{\"op\":\"RUN_PARAGRAPH\",\"msgId\":\"\",\"data\":{\"id\":\"paragraph_1789327988894_422040464\",\"title\":\"\",\"paragraph\":\"%sh\\nsleep 30\",\"config\":{},\"params\":{}},\"principal\":\"\",\"ticket\":\"\",\"roles\":\"\"}" + }, + "sequence": 4 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"SAVE_NOTE_FORMS\",\"data\":{\"formsData\":{\"params\":{},\"forms\":{}}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 5 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"PARAGRAPH\",\"data\":{\"paragraph\":{\"title\":\"\",\"text\":\"%sh\\nsleep 30\",\"user\":\"\",\"dateUpdated\":\"\",\"progress\":0,\"config\":{},\"settings\":{\"params\":{},\"forms\":{}},\"apps\":[],\"runtimeInfos\":{},\"progressUpdateIntervalMs\":500,\"jobName\":\"paragraph_1789327988894_422040464\",\"id\":\"paragraph_1789327988894_422040464\",\"dateCreated\":\"\",\"status\":\"PENDING\"}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 6 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"PARAGRAPH_ADDED\",\"data\":{\"paragraph\":{\"text\":\"%sh\\n\",\"user\":\"\",\"dateUpdated\":\"\",\"progress\":0,\"config\":{},\"settings\":{\"params\":{},\"forms\":{}},\"apps\":[],\"runtimeInfos\":{},\"progressUpdateIntervalMs\":500,\"jobName\":\"paragraph_1789327989498_345048741\",\"id\":\"paragraph_1789327989498_345048741\",\"dateCreated\":\"\",\"status\":\"READY\"},\"index\":1},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\",\"msgId\":\"\"}" + }, + "sequence": 7 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"SAVE_NOTE_FORMS\",\"data\":{\"formsData\":{\"params\":{},\"forms\":{}}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 8 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"PARAGRAPH\",\"data\":{\"paragraph\":{\"title\":\"\",\"text\":\"%sh\\nsleep 30\",\"user\":\"\",\"dateUpdated\":\"\",\"progress\":0,\"config\":{},\"settings\":{\"params\":{},\"forms\":{}},\"apps\":[],\"runtimeInfos\":{},\"progressUpdateIntervalMs\":500,\"jobName\":\"paragraph_1789327988894_422040464\",\"id\":\"paragraph_1789327988894_422040464\",\"dateCreated\":\"\",\"dateStarted\":\"\",\"status\":\"PENDING\"}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 9 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"SAVE_NOTE_FORMS\",\"data\":{\"formsData\":{\"params\":{},\"forms\":{}}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 10 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"PARAGRAPH\",\"data\":{\"paragraph\":{\"title\":\"\",\"text\":\"%sh\\nsleep 30\",\"user\":\"\",\"dateUpdated\":\"\",\"progress\":0,\"config\":{},\"settings\":{\"params\":{},\"forms\":{}},\"apps\":[],\"runtimeInfos\":{},\"progressUpdateIntervalMs\":500,\"jobName\":\"paragraph_1789327988894_422040464\",\"id\":\"paragraph_1789327988894_422040464\",\"dateCreated\":\"\",\"dateStarted\":\"\",\"status\":\"RUNNING\"}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 11 + }, + { + "kind": "websocket", + "websocket": { + "direction": "send", + "payloadText": "{\"op\":\"CANCEL_PARAGRAPH\",\"msgId\":\"\",\"data\":{\"id\":\"paragraph_1789327988894_422040464\"},\"principal\":\"\",\"ticket\":\"\",\"roles\":\"\"}" + }, + "sequence": 12 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"SAVE_NOTE_FORMS\",\"data\":{\"formsData\":{\"params\":{},\"forms\":{}}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 13 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"PARAGRAPH\",\"data\":{\"paragraph\":{\"title\":\"\",\"text\":\"%sh\\nsleep 30\",\"user\":\"\",\"dateUpdated\":\"\",\"progress\":0,\"config\":{},\"settings\":{\"params\":{},\"forms\":{}},\"results\":{\"code\":\"INCOMPLETE\",\"msg\":[{\"type\":\"TEXT\",\"data\":\"Paragraph received a SIGTERM\\nExitValue: 143\"}]},\"apps\":[],\"runtimeInfos\":{},\"progressUpdateIntervalMs\":500,\"jobName\":\"paragraph_1789327988894_422040464\",\"id\":\"paragraph_1789327988894_422040464\",\"dateCreated\":\"\",\"dateStarted\":\"\",\"dateFinished\":\"\",\"status\":\"ABORT\"}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 14 + } + ], + "version": 1 +} diff --git a/zeppelin-web-angular/e2e/core-contract/fixtures/execution-streaming-disabled.json b/zeppelin-web-angular/e2e/core-contract/fixtures/execution-streaming-disabled.json new file mode 100644 index 00000000000..fef5390d4a7 --- /dev/null +++ b/zeppelin-web-angular/e2e/core-contract/fixtures/execution-streaming-disabled.json @@ -0,0 +1,286 @@ +{ + "_license": "Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0.", + "metadata": { + "captureSource": "live-server", + "capturedAt": "2026-09-13T19:33:43.724Z", + "configuration": { + "zeppelin.websocket.paragraph_status_progress.enable": false + }, + "coveredOperations": [ + "GET_NOTE", + "COLLABORATIVE_MODE_STATUS", + "NOTE", + "RUN_PARAGRAPH", + "SAVE_NOTE_FORMS", + "PARAGRAPH", + "PARAGRAPH_ADDED" + ], + "interpreter": "sh", + "knownExclusions": [], + "owner": "zeppelin-web-angular", + "provenance": { + "baseCommit": "e816bf1b76b50282cc32b284cdb8755f932f657e", + "authentication": "anonymous", + "browser": { + "name": "chromium", + "version": "140.0.7339.186" + }, + "buildManifest": { + "artifacts": [ + { + "exists": true, + "fileCount": 0, + "path": ".", + "selection": "root-jars", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "exists": true, + "fileCount": 1, + "path": ".", + "selection": "all-wars", + "sha256": "bf4d480bf55a2683bee76b13e6470acd2bfbe258e23cd6da02f25d36783f73e1" + }, + { + "exists": true, + "fileCount": 13, + "path": "bin", + "selection": "tree", + "sha256": "37d85b2da8ca20e0d705e0b0e7041507a41c109a64987e20b62cd5fba2ff89eb" + }, + { + "exists": true, + "fileCount": 10, + "path": "conf", + "selection": "tree", + "sha256": "193334ddf2c654ed7a544b2951a7c2517ba32f183ddbb8fe42e10544688e16b4" + }, + { + "exists": false, + "fileCount": 0, + "path": "lib", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": false, + "fileCount": 0, + "path": "lib/interpreter", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": true, + "fileCount": 1034, + "path": "zeppelin-interpreter/target/classes", + "selection": "tree", + "sha256": "e3e940f06876febb19f4ffbb9fd4d34d232d6b711fa673ad144d7f5cffaddf03" + }, + { + "exists": true, + "fileCount": 345, + "path": "zeppelin-server/target/classes", + "selection": "tree", + "sha256": "73509c0b8161145ac9a9f972bbe7948d269109b7a17294a65727d82c710d408e" + }, + { + "exists": false, + "fileCount": 0, + "path": "zeppelin-interpreter/target/lib", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": true, + "fileCount": 246, + "path": "zeppelin-server/target/lib", + "selection": "tree", + "sha256": "f90f1520c7f09cb2bfe590bbf6ebd131a67e67cae61a65cda174323e2a28b238" + }, + { + "exists": false, + "fileCount": 0, + "path": "zeppelin-web/target/lib", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": false, + "fileCount": 0, + "path": "zeppelin-web/dist", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": false, + "fileCount": 0, + "path": "zeppelin-web-angular/target/lib", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": true, + "fileCount": 291, + "path": "zeppelin-server/target/test-classes", + "selection": "tree", + "sha256": "4f9dd82ee7bdca57d13b61c219c898af54fb7374aa72249a477e6c6eda1e231c" + }, + { + "exists": true, + "fileCount": 11, + "path": "interpreter", + "selection": "tree", + "sha256": "a009e8eff2d2736c4c9843a8e06b3b85dc110f13a93624a53e1a7d4fccf9928e" + }, + { + "exists": true, + "fileCount": 14, + "path": "zeppelin-interpreter-shaded/target", + "selection": "tree", + "sha256": "7b4de0e8a4b24ac3ea74147a3a11cc4e268c0d1a312435421300f6947578fce8" + }, + { + "exists": true, + "fileCount": 5182, + "path": "zeppelin-web-angular/dist/zeppelin", + "selection": "tree", + "sha256": "ac2af3ebcf9704292c888ce0309345f989583f3b5ce6d4b694429ae6bec0b33f" + }, + { + "exists": true, + "fileCount": 18, + "path": "shell/target/classes", + "selection": "tree", + "sha256": "c2d25e7912828176d0b85fb6f04904473b33142bf3598265ea555ff420fb2ffa" + } + ], + "baseCommit": "e816bf1b76b50282cc32b284cdb8755f932f657e", + "id": "97518d114acf7884a920337ce2696a87d2c14859411064aa523658a82329343a", + "launchTargets": { + "angularWeb": { + "kind": "directory", + "path": "zeppelin-web-angular/dist/zeppelin" + }, + "classicWeb": { + "kind": "missing", + "path": "" + } + }, + "sourceCommit": "e816bf1b76b50282cc32b284cdb8755f932f657e", + "sourceTree": { + "fileCount": 2644, + "sha256": "7c6149a7c0ab8111f096b0f60595636074bfc77adb73695c7923dd5e32b225d6" + }, + "version": 3 + }, + "captureMode": "execution", + "configuration": { + "zeppelin.websocket.paragraph_status_progress.enable": false + }, + "interpreter": "sh", + "isolation": { + "logs": "/logs", + "notebook": "/notebook", + "pid": "/run", + "recovery": "/recovery", + "root": "", + "searchIndex": "/index" + }, + "origin": "http://127.0.0.1:18082", + "sourceCommit": "e816bf1b76b50282cc32b284cdb8755f932f657e" + }, + "scenario": "Run a paragraph with status and output streaming disabled", + "zeppelinVersion": "0.13.0-SNAPSHOT" + }, + "records": [ + { + "kind": "websocket", + "websocket": { + "direction": "send", + "payloadText": "{\"op\":\"GET_NOTE\",\"msgId\":\"\",\"data\":{\"id\":\"2N1MA1YWC\"},\"principal\":\"\",\"ticket\":\"\",\"roles\":\"\"}" + }, + "sequence": 1 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"COLLABORATIVE_MODE_STATUS\",\"data\":{\"status\":false},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 2 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"NOTE\",\"data\":{\"note\":{\"paragraphs\":[{\"user\":\"\",\"progress\":0,\"config\":{},\"settings\":{\"params\":{},\"forms\":{}},\"apps\":[],\"runtimeInfos\":{},\"progressUpdateIntervalMs\":500,\"jobName\":\"paragraph_1789328023711_1848134837\",\"id\":\"paragraph_1789328023711_1848134837\",\"dateCreated\":\"\",\"status\":\"READY\"}],\"name\":\"ExecutionContractCapture_1789328023708\",\"id\":\"2N1MA1YWC\",\"defaultInterpreterGroup\":\"sh\",\"version\":\"0.13.0-SNAPSHOT\",\"noteParams\":{},\"noteForms\":{},\"angularObjects\":{},\"config\":{\"isZeppelinNotebookCronEnable\":false},\"info\":{},\"path\":\"/E2E_TEST_FOLDER/ExecutionContractCapture_1789328023708\"}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 3 + }, + { + "kind": "websocket", + "websocket": { + "direction": "send", + "payloadText": "{\"op\":\"RUN_PARAGRAPH\",\"msgId\":\"\",\"data\":{\"id\":\"paragraph_1789328023711_1848134837\",\"title\":\"\",\"paragraph\":\"%sh\\nprintf 'first\\\\n'\\nsleep 1\\nprintf 'second\\\\n'\",\"config\":{},\"params\":{}},\"principal\":\"\",\"ticket\":\"\",\"roles\":\"\"}" + }, + "sequence": 4 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"SAVE_NOTE_FORMS\",\"data\":{\"formsData\":{\"params\":{},\"forms\":{}}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 5 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"PARAGRAPH\",\"data\":{\"paragraph\":{\"title\":\"\",\"text\":\"%sh\\nprintf 'first\\\\n'\\nsleep 1\\nprintf 'second\\\\n'\",\"user\":\"\",\"dateUpdated\":\"\",\"progress\":0,\"config\":{},\"settings\":{\"params\":{},\"forms\":{}},\"apps\":[],\"runtimeInfos\":{},\"progressUpdateIntervalMs\":500,\"jobName\":\"paragraph_1789328023711_1848134837\",\"id\":\"paragraph_1789328023711_1848134837\",\"dateCreated\":\"\",\"status\":\"PENDING\"}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 6 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"PARAGRAPH_ADDED\",\"data\":{\"paragraph\":{\"text\":\"%sh\\n\",\"user\":\"\",\"dateUpdated\":\"\",\"progress\":0,\"config\":{},\"settings\":{\"params\":{},\"forms\":{}},\"apps\":[],\"runtimeInfos\":{},\"progressUpdateIntervalMs\":500,\"jobName\":\"paragraph_1789328024398_322364939\",\"id\":\"paragraph_1789328024398_322364939\",\"dateCreated\":\"\",\"status\":\"READY\"},\"index\":1},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\",\"msgId\":\"\"}" + }, + "sequence": 7 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"SAVE_NOTE_FORMS\",\"data\":{\"formsData\":{\"params\":{},\"forms\":{}}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 8 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"PARAGRAPH\",\"data\":{\"paragraph\":{\"title\":\"\",\"text\":\"%sh\\nprintf 'first\\\\n'\\nsleep 1\\nprintf 'second\\\\n'\",\"user\":\"\",\"dateUpdated\":\"\",\"progress\":0,\"config\":{},\"settings\":{\"params\":{},\"forms\":{}},\"apps\":[],\"runtimeInfos\":{},\"progressUpdateIntervalMs\":500,\"jobName\":\"paragraph_1789328023711_1848134837\",\"id\":\"paragraph_1789328023711_1848134837\",\"dateCreated\":\"\",\"dateStarted\":\"\",\"status\":\"RUNNING\"}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 9 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"SAVE_NOTE_FORMS\",\"data\":{\"formsData\":{\"params\":{},\"forms\":{}}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 10 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"PARAGRAPH\",\"data\":{\"paragraph\":{\"title\":\"\",\"text\":\"%sh\\nprintf 'first\\\\n'\\nsleep 1\\nprintf 'second\\\\n'\",\"user\":\"\",\"dateUpdated\":\"\",\"progress\":0,\"config\":{},\"settings\":{\"params\":{},\"forms\":{}},\"results\":{\"code\":\"SUCCESS\",\"msg\":[{\"type\":\"TEXT\",\"data\":\"first\\nsecond\\n\"}]},\"apps\":[],\"runtimeInfos\":{},\"progressUpdateIntervalMs\":500,\"jobName\":\"paragraph_1789328023711_1848134837\",\"id\":\"paragraph_1789328023711_1848134837\",\"dateCreated\":\"\",\"dateStarted\":\"\",\"dateFinished\":\"\",\"status\":\"FINISHED\"}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 11 + } + ], + "version": 1 +} diff --git a/zeppelin-web-angular/e2e/core-contract/fixtures/execution-streaming-enabled.json b/zeppelin-web-angular/e2e/core-contract/fixtures/execution-streaming-enabled.json new file mode 100644 index 00000000000..351762f6e8c --- /dev/null +++ b/zeppelin-web-angular/e2e/core-contract/fixtures/execution-streaming-enabled.json @@ -0,0 +1,337 @@ +{ + "_license": "Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0.", + "metadata": { + "captureSource": "live-server", + "capturedAt": "2026-09-13T19:32:59.537Z", + "configuration": { + "zeppelin.websocket.paragraph_status_progress.enable": true + }, + "coveredOperations": [ + "GET_NOTE", + "COLLABORATIVE_MODE_STATUS", + "NOTE", + "RUN_PARAGRAPH", + "SAVE_NOTE_FORMS", + "PARAGRAPH", + "PARAGRAPH_ADDED", + "PARAGRAPH_UPDATE_OUTPUT", + "PARAGRAPH_APPEND_OUTPUT", + "PROGRESS" + ], + "interpreter": "sh", + "knownExclusions": [], + "owner": "zeppelin-web-angular", + "provenance": { + "baseCommit": "e816bf1b76b50282cc32b284cdb8755f932f657e", + "authentication": "anonymous", + "browser": { + "name": "chromium", + "version": "140.0.7339.186" + }, + "buildManifest": { + "artifacts": [ + { + "exists": true, + "fileCount": 0, + "path": ".", + "selection": "root-jars", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "exists": true, + "fileCount": 1, + "path": ".", + "selection": "all-wars", + "sha256": "bf4d480bf55a2683bee76b13e6470acd2bfbe258e23cd6da02f25d36783f73e1" + }, + { + "exists": true, + "fileCount": 13, + "path": "bin", + "selection": "tree", + "sha256": "37d85b2da8ca20e0d705e0b0e7041507a41c109a64987e20b62cd5fba2ff89eb" + }, + { + "exists": true, + "fileCount": 10, + "path": "conf", + "selection": "tree", + "sha256": "193334ddf2c654ed7a544b2951a7c2517ba32f183ddbb8fe42e10544688e16b4" + }, + { + "exists": false, + "fileCount": 0, + "path": "lib", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": false, + "fileCount": 0, + "path": "lib/interpreter", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": true, + "fileCount": 1034, + "path": "zeppelin-interpreter/target/classes", + "selection": "tree", + "sha256": "e3e940f06876febb19f4ffbb9fd4d34d232d6b711fa673ad144d7f5cffaddf03" + }, + { + "exists": true, + "fileCount": 345, + "path": "zeppelin-server/target/classes", + "selection": "tree", + "sha256": "73509c0b8161145ac9a9f972bbe7948d269109b7a17294a65727d82c710d408e" + }, + { + "exists": false, + "fileCount": 0, + "path": "zeppelin-interpreter/target/lib", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": true, + "fileCount": 246, + "path": "zeppelin-server/target/lib", + "selection": "tree", + "sha256": "f90f1520c7f09cb2bfe590bbf6ebd131a67e67cae61a65cda174323e2a28b238" + }, + { + "exists": false, + "fileCount": 0, + "path": "zeppelin-web/target/lib", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": false, + "fileCount": 0, + "path": "zeppelin-web/dist", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": false, + "fileCount": 0, + "path": "zeppelin-web-angular/target/lib", + "selection": "tree", + "sha256": "b7b3aede84b3cb0edbea85abfb62dab3e0d8e43975e68b80a5a411cf67c32ff4" + }, + { + "exists": true, + "fileCount": 291, + "path": "zeppelin-server/target/test-classes", + "selection": "tree", + "sha256": "4f9dd82ee7bdca57d13b61c219c898af54fb7374aa72249a477e6c6eda1e231c" + }, + { + "exists": true, + "fileCount": 11, + "path": "interpreter", + "selection": "tree", + "sha256": "a009e8eff2d2736c4c9843a8e06b3b85dc110f13a93624a53e1a7d4fccf9928e" + }, + { + "exists": true, + "fileCount": 14, + "path": "zeppelin-interpreter-shaded/target", + "selection": "tree", + "sha256": "7b4de0e8a4b24ac3ea74147a3a11cc4e268c0d1a312435421300f6947578fce8" + }, + { + "exists": true, + "fileCount": 5182, + "path": "zeppelin-web-angular/dist/zeppelin", + "selection": "tree", + "sha256": "ac2af3ebcf9704292c888ce0309345f989583f3b5ce6d4b694429ae6bec0b33f" + }, + { + "exists": true, + "fileCount": 18, + "path": "shell/target/classes", + "selection": "tree", + "sha256": "c2d25e7912828176d0b85fb6f04904473b33142bf3598265ea555ff420fb2ffa" + } + ], + "baseCommit": "e816bf1b76b50282cc32b284cdb8755f932f657e", + "id": "97518d114acf7884a920337ce2696a87d2c14859411064aa523658a82329343a", + "launchTargets": { + "angularWeb": { + "kind": "directory", + "path": "zeppelin-web-angular/dist/zeppelin" + }, + "classicWeb": { + "kind": "missing", + "path": "" + } + }, + "sourceCommit": "e816bf1b76b50282cc32b284cdb8755f932f657e", + "sourceTree": { + "fileCount": 2644, + "sha256": "7c6149a7c0ab8111f096b0f60595636074bfc77adb73695c7923dd5e32b225d6" + }, + "version": 3 + }, + "captureMode": "execution", + "configuration": { + "zeppelin.websocket.paragraph_status_progress.enable": true + }, + "interpreter": "sh", + "isolation": { + "logs": "/logs", + "notebook": "/notebook", + "pid": "/run", + "recovery": "/recovery", + "root": "", + "searchIndex": "/index" + }, + "origin": "http://127.0.0.1:18081", + "sourceCommit": "e816bf1b76b50282cc32b284cdb8755f932f657e" + }, + "scenario": "Run a paragraph with status and output streaming enabled", + "zeppelinVersion": "0.13.0-SNAPSHOT" + }, + "records": [ + { + "kind": "websocket", + "websocket": { + "direction": "send", + "payloadText": "{\"op\":\"GET_NOTE\",\"msgId\":\"\",\"data\":{\"id\":\"2N35G3T5R\"},\"principal\":\"\",\"ticket\":\"\",\"roles\":\"\"}" + }, + "sequence": 1 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"COLLABORATIVE_MODE_STATUS\",\"data\":{\"status\":false},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 2 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"NOTE\",\"data\":{\"note\":{\"paragraphs\":[{\"user\":\"\",\"progress\":0,\"config\":{},\"settings\":{\"params\":{},\"forms\":{}},\"apps\":[],\"runtimeInfos\":{},\"progressUpdateIntervalMs\":500,\"jobName\":\"paragraph_1789327979524_639743774\",\"id\":\"paragraph_1789327979524_639743774\",\"dateCreated\":\"\",\"status\":\"READY\"}],\"name\":\"ExecutionContractCapture_1789327979521\",\"id\":\"2N35G3T5R\",\"defaultInterpreterGroup\":\"sh\",\"version\":\"0.13.0-SNAPSHOT\",\"noteParams\":{},\"noteForms\":{},\"angularObjects\":{},\"config\":{\"isZeppelinNotebookCronEnable\":false},\"info\":{},\"path\":\"/E2E_TEST_FOLDER/ExecutionContractCapture_1789327979521\"}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 3 + }, + { + "kind": "websocket", + "websocket": { + "direction": "send", + "payloadText": "{\"op\":\"RUN_PARAGRAPH\",\"msgId\":\"\",\"data\":{\"id\":\"paragraph_1789327979524_639743774\",\"title\":\"\",\"paragraph\":\"%sh\\nprintf 'first\\\\n'\\nsleep 1\\nprintf 'second\\\\n'\",\"config\":{},\"params\":{}},\"principal\":\"\",\"ticket\":\"\",\"roles\":\"\"}" + }, + "sequence": 4 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"SAVE_NOTE_FORMS\",\"data\":{\"formsData\":{\"params\":{},\"forms\":{}}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 5 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"PARAGRAPH\",\"data\":{\"paragraph\":{\"title\":\"\",\"text\":\"%sh\\nprintf 'first\\\\n'\\nsleep 1\\nprintf 'second\\\\n'\",\"user\":\"\",\"dateUpdated\":\"\",\"progress\":0,\"config\":{},\"settings\":{\"params\":{},\"forms\":{}},\"apps\":[],\"runtimeInfos\":{},\"progressUpdateIntervalMs\":500,\"jobName\":\"paragraph_1789327979524_639743774\",\"id\":\"paragraph_1789327979524_639743774\",\"dateCreated\":\"\",\"status\":\"PENDING\"}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 6 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"PARAGRAPH_ADDED\",\"data\":{\"paragraph\":{\"text\":\"%sh\\n\",\"user\":\"\",\"dateUpdated\":\"\",\"progress\":0,\"config\":{},\"settings\":{\"params\":{},\"forms\":{}},\"apps\":[],\"runtimeInfos\":{},\"progressUpdateIntervalMs\":500,\"jobName\":\"paragraph_1789327980165_1593849011\",\"id\":\"paragraph_1789327980165_1593849011\",\"dateCreated\":\"\",\"status\":\"READY\"},\"index\":1},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\",\"msgId\":\"\"}" + }, + "sequence": 7 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"SAVE_NOTE_FORMS\",\"data\":{\"formsData\":{\"params\":{},\"forms\":{}}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 8 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"PARAGRAPH\",\"data\":{\"paragraph\":{\"title\":\"\",\"text\":\"%sh\\nprintf 'first\\\\n'\\nsleep 1\\nprintf 'second\\\\n'\",\"user\":\"\",\"dateUpdated\":\"\",\"progress\":0,\"config\":{},\"settings\":{\"params\":{},\"forms\":{}},\"apps\":[],\"runtimeInfos\":{},\"progressUpdateIntervalMs\":500,\"jobName\":\"paragraph_1789327979524_639743774\",\"id\":\"paragraph_1789327979524_639743774\",\"dateCreated\":\"\",\"dateStarted\":\"\",\"status\":\"PENDING\"}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 9 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"SAVE_NOTE_FORMS\",\"data\":{\"formsData\":{\"params\":{},\"forms\":{}}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 10 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"PARAGRAPH\",\"data\":{\"paragraph\":{\"title\":\"\",\"text\":\"%sh\\nprintf 'first\\\\n'\\nsleep 1\\nprintf 'second\\\\n'\",\"user\":\"\",\"dateUpdated\":\"\",\"progress\":0,\"config\":{},\"settings\":{\"params\":{},\"forms\":{}},\"apps\":[],\"runtimeInfos\":{},\"progressUpdateIntervalMs\":500,\"jobName\":\"paragraph_1789327979524_639743774\",\"id\":\"paragraph_1789327979524_639743774\",\"dateCreated\":\"\",\"dateStarted\":\"\",\"status\":\"RUNNING\"}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 11 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"PARAGRAPH_UPDATE_OUTPUT\",\"data\":{\"data\":\"\",\"index\":0,\"noteId\":\"2N35G3T5R\",\"paragraphId\":\"paragraph_1789327979524_639743774\",\"type\":\"TEXT\"},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 12 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"PARAGRAPH_APPEND_OUTPUT\",\"data\":{\"data\":\"first\\n\",\"index\":0,\"noteId\":\"2N35G3T5R\",\"paragraphId\":\"paragraph_1789327979524_639743774\"},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 13 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"PROGRESS\",\"data\":{\"progress\":0,\"id\":\"paragraph_1789327979524_639743774\"},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 14 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"SAVE_NOTE_FORMS\",\"data\":{\"formsData\":{\"params\":{},\"forms\":{}}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 15 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"PARAGRAPH\",\"data\":{\"paragraph\":{\"title\":\"\",\"text\":\"%sh\\nprintf 'first\\\\n'\\nsleep 1\\nprintf 'second\\\\n'\",\"user\":\"\",\"dateUpdated\":\"\",\"progress\":0,\"config\":{},\"settings\":{\"params\":{},\"forms\":{}},\"results\":{\"code\":\"SUCCESS\",\"msg\":[{\"type\":\"TEXT\",\"data\":\"first\\nsecond\\n\"}]},\"apps\":[],\"runtimeInfos\":{},\"progressUpdateIntervalMs\":500,\"jobName\":\"paragraph_1789327979524_639743774\",\"id\":\"paragraph_1789327979524_639743774\",\"dateCreated\":\"\",\"dateStarted\":\"\",\"dateFinished\":\"\",\"status\":\"FINISHED\"}},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 16 + }, + { + "kind": "websocket", + "websocket": { + "direction": "receive", + "payloadText": "{\"op\":\"PARAGRAPH_APPEND_OUTPUT\",\"data\":{\"data\":\"second\\n\",\"index\":0,\"noteId\":\"2N35G3T5R\",\"paragraphId\":\"paragraph_1789327979524_639743774\"},\"ticket\":\"\",\"principal\":\"\",\"roles\":\"\"}" + }, + "sequence": 17 + } + ], + "version": 1 +} diff --git a/zeppelin-web-angular/e2e/core-contract/notebook-transport-fixture.d.mts b/zeppelin-web-angular/e2e/core-contract/notebook-transport-fixture.d.mts index 3ba3ce406d3..b06eac91c95 100644 --- a/zeppelin-web-angular/e2e/core-contract/notebook-transport-fixture.d.mts +++ b/zeppelin-web-angular/e2e/core-contract/notebook-transport-fixture.d.mts @@ -52,9 +52,48 @@ export interface FixtureMetadata { knownExclusions: string[]; owner: string; scenario: string; - // Recorded for provenance; neither is read or validated by this module. + configuration?: Record; + interpreter?: string; + // Recorded capture identity and environment. capturedAt?: string; zeppelinVersion?: string; + captureSource?: 'live-server'; + provenance?: { + baseCommit: string; + authentication: string; + browser: { name: string; version: string }; + buildManifest: { + artifacts: Array<{ + exists: boolean; + fileCount: number; + path: string; + selection: 'all-wars' | 'root-jars' | 'tree'; + sha256: string; + }>; + baseCommit: string; + id: string; + launchTargets: { + angularWeb: { kind: 'directory' | 'missing' | 'war'; path: string }; + classicWeb: { kind: 'directory' | 'missing' | 'war'; path: string }; + }; + sourceCommit: string; + sourceTree: { fileCount: number; sha256: string }; + version: number; + }; + captureMode: string; + configuration: Record; + interpreter: string; + isolation: { + logs: string; + notebook: string; + pid: string; + recovery: string; + root: string; + searchIndex: string; + }; + origin: string; + sourceCommit: string; + }; } export interface TransportFixture { @@ -112,6 +151,7 @@ export declare function normalizeFixtureRecord(value: unknown): unknown; export declare function sanitizeFixture(fixture: TransportFixture): TransportFixture; export declare function validateFixture(fixture: unknown): string[]; export declare function validateReplayFixture(fixture: unknown): string[]; +export declare function validateCaptureProvenance(metadata: unknown): string[]; export declare function createPlaywrightFixtureAdapter(fixture: TransportFixture): PlaywrightFixtureAdapter; export declare function createNotebookTransportRecorder(metadata: FixtureMetadata): NotebookTransportRecorder; diff --git a/zeppelin-web-angular/e2e/core-contract/notebook-transport-fixture.mjs b/zeppelin-web-angular/e2e/core-contract/notebook-transport-fixture.mjs index 7967c32ef1b..fba0b9c641e 100644 --- a/zeppelin-web-angular/e2e/core-contract/notebook-transport-fixture.mjs +++ b/zeppelin-web-angular/e2e/core-contract/notebook-transport-fixture.mjs @@ -15,6 +15,7 @@ * limitations under the License. */ +import { createHash } from 'node:crypto'; import { mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; @@ -118,9 +119,198 @@ export function validateFixture(fixture) { return errors; } validateFixtureMetadata(errors, fixture.metadata); + validateExecutionCoveredOperations(errors, fixture); return errors; } +function validateExecutionCoveredOperations(errors, fixture) { + if (fixture.metadata?.provenance?.captureMode !== 'execution') return; + + const represented = new Set( + (fixture.records ?? []) + .filter(record => record?.kind === 'websocket' && typeof record.websocket?.payloadText === 'string') + .map(record => parseEnvelope(record.websocket.payloadText)?.op) + .filter(nonEmptyString) + ); + const declared = fixture.metadata?.coveredOperations; + const declaredSet = new Set(Array.isArray(declared) ? declared : []); + if ( + !Array.isArray(declared) || + declared.length !== declaredSet.size || + stableJson([...declaredSet].sort()) !== stableJson([...represented].sort()) + ) { + errors.push('metadata.coveredOperations must exactly match operations represented by execution records'); + } +} + +export function validateCaptureProvenance(metadata) { + const errors = []; + if (metadata?.captureSource !== 'live-server') return errors; + + const provenance = metadata.provenance; + if (!provenance || typeof provenance !== 'object' || Array.isArray(provenance)) { + return ['metadata.provenance is required for live-server fixtures']; + } + if (!/^[0-9a-f]{40}$/.test(provenance.sourceCommit ?? '')) { + errors.push('metadata.provenance.sourceCommit must be an exact Git commit'); + } + if (!/^[0-9a-f]{40}$/.test(provenance.baseCommit ?? '')) { + errors.push('metadata.provenance.baseCommit must be a full Git commit hash'); + } + if (provenance.sourceCommit !== provenance.baseCommit) { + errors.push('metadata.provenance.sourceCommit must equal baseCommit'); + } + validateBuildManifest(errors, provenance); + if (!nonEmptyString(provenance.browser?.name) || !nonEmptyString(provenance.browser?.version)) { + errors.push('metadata.provenance.browser must identify the browser and version'); + } + if (!/^https?:\/\/(127\.0\.0\.1|localhost):[0-9]+$/.test(provenance.origin ?? '')) { + errors.push('metadata.provenance.origin must be an explicit loopback origin and port'); + } + for (const field of ['captureMode', 'interpreter']) { + if (!nonEmptyString(provenance[field])) { + errors.push(`metadata.provenance.${field} must be a non-empty string`); + } + } + if (!['anonymous', 'authenticated'].includes(provenance.authentication)) { + errors.push('metadata.provenance.authentication must be anonymous or authenticated'); + } + if ( + !provenance.configuration || + typeof provenance.configuration !== 'object' || + Array.isArray(provenance.configuration) + ) { + errors.push('metadata.provenance.configuration must be an object'); + } else if (Object.keys(provenance.configuration).length === 0) { + errors.push('metadata.provenance.configuration must identify the capture configuration'); + } + const isolation = provenance.isolation; + if (!isolation || typeof isolation !== 'object' || Array.isArray(isolation)) { + errors.push('metadata.provenance.isolation is required'); + } else { + const expected = { logs: 'logs', notebook: 'notebook', pid: 'run', recovery: 'recovery', searchIndex: 'index' }; + if (isolation.root !== '') { + errors.push('metadata.provenance.isolation.root must use the sanitized placeholder'); + } + for (const [field, relativePath] of Object.entries(expected)) { + if (isolation[field] !== `/${relativePath}`) { + errors.push(`metadata.provenance.isolation.${field} must identify the isolated ${relativePath} directory`); + } + } + } + return errors; +} + +const expectedManifestArtifacts = [ + { path: '.', selection: 'root-jars' }, + { path: '.', selection: 'all-wars' }, + 'bin', + 'conf', + 'interpreter', + 'lib', + 'lib/interpreter', + 'shell/target/classes', + 'zeppelin-interpreter-shaded/target', + 'zeppelin-interpreter/target/classes', + 'zeppelin-interpreter/target/lib', + 'zeppelin-server/target/classes', + 'zeppelin-server/target/lib', + 'zeppelin-server/target/test-classes', + 'zeppelin-web-angular/dist/zeppelin', + 'zeppelin-web-angular/target/lib', + 'zeppelin-web/dist', + 'zeppelin-web/target/lib' +].map(artifact => (typeof artifact === 'string' ? { path: artifact, selection: 'tree' } : artifact)); + +const sha256Pattern = /^[0-9a-f]{64}$/; +const manifestArtifactKey = artifact => `${artifact.path}\0${artifact.selection}`; + +function validateBuildManifest(errors, provenance) { + const prefix = 'metadata.provenance.buildManifest'; + const buildManifest = provenance.buildManifest; + if (!buildManifest || typeof buildManifest !== 'object' || Array.isArray(buildManifest)) { + errors.push(`${prefix} is required`); + return; + } + if (!sha256Pattern.test(buildManifest.id ?? '')) { + errors.push(`${prefix}.id must be a SHA-256 digest`); + } else { + const manifestBody = { + artifacts: buildManifest.artifacts, + baseCommit: buildManifest.baseCommit, + launchTargets: buildManifest.launchTargets, + sourceCommit: buildManifest.sourceCommit, + sourceTree: buildManifest.sourceTree, + version: buildManifest.version + }; + const computedId = createHash('sha256') + .update(`${JSON.stringify(sortJson(manifestBody), null, 2)}\n`) + .digest('hex'); + if (buildManifest.id !== computedId) { + errors.push(`${prefix}.id must match the manifest content`); + } + } + if (buildManifest.sourceCommit !== provenance.sourceCommit) { + errors.push(`${prefix}.sourceCommit must match metadata.provenance.sourceCommit`); + } + if (buildManifest.baseCommit !== provenance.baseCommit) { + errors.push(`${prefix}.baseCommit must match metadata.provenance.baseCommit`); + } + if (buildManifest.version !== 3) { + errors.push(`${prefix}.version must be 3`); + } + for (const target of ['angularWeb', 'classicWeb']) { + if (!['directory', 'war', 'missing'].includes(buildManifest.launchTargets?.[target]?.kind)) { + errors.push(`${prefix}.launchTargets.${target}.kind must be directory, war or missing`); + } + } + const sourceTree = buildManifest.sourceTree; + if (!Number.isInteger(sourceTree?.fileCount) || sourceTree.fileCount < 1) { + errors.push(`${prefix}.sourceTree.fileCount must be a positive integer`); + } + if (!sha256Pattern.test(sourceTree?.sha256 ?? '')) { + errors.push(`${prefix}.sourceTree.sha256 must be a SHA-256 digest`); + } + const artifacts = buildManifest.artifacts; + if (!Array.isArray(artifacts)) { + errors.push(`${prefix}.artifacts must be an array`); + return; + } + let artifactShapesValid = true; + for (const [index, artifact] of artifacts.entries()) { + const artifactPrefix = `${prefix}.artifacts[${index}]`; + if ( + !artifact || + typeof artifact !== 'object' || + Array.isArray(artifact) || + typeof artifact.path !== 'string' || + typeof artifact.selection !== 'string' + ) { + errors.push(`${artifactPrefix} must be an object with string path and selection`); + artifactShapesValid = false; + continue; + } + if (typeof artifact.exists !== 'boolean') { + errors.push(`${artifactPrefix}.exists must be a boolean`); + } + if (!Number.isInteger(artifact.fileCount) || artifact.fileCount < 0) { + errors.push(`${artifactPrefix}.fileCount must be a non-negative integer`); + } else if (artifact.exists === false && artifact.fileCount !== 0) { + errors.push(`${artifactPrefix}.fileCount must be 0 for a missing artifact`); + } + if (!sha256Pattern.test(artifact.sha256 ?? '')) { + errors.push(`${artifactPrefix}.sha256 must be a SHA-256 digest`); + } + } + if ( + artifactShapesValid && + stableJson(artifacts.map(manifestArtifactKey).sort()) !== + stableJson(expectedManifestArtifacts.map(manifestArtifactKey).sort()) + ) { + errors.push(`${prefix}.artifacts must list exactly the launcher build outputs`); + } +} + export function validateReplayFixture(fixture) { const errors = []; if (!fixture || typeof fixture !== 'object') { @@ -788,6 +978,18 @@ function stableJson(value) { return JSON.stringify(value); } +function sortJson(value) { + if (Array.isArray(value)) return value.map(sortJson); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value) + .sort() + .map(key => [key, sortJson(value[key])]) + ); + } + return value; +} + function parseEnvelope(payload) { if (typeof payload !== 'string') { return undefined; @@ -1058,8 +1260,11 @@ const validateFixtureMetadata = (errors, metadata) => { ) { errors.push('metadata.knownExclusions must be a string array'); } + errors.push(...validateCaptureProvenance(metadata)); }; +const nonEmptyString = value => typeof value === 'string' && value.trim().length > 0; + const validateWebSocketRecord = (errors, prefix, record) => { if (!record.websocket || typeof record.websocket !== 'object') { errors.push(`${prefix}.websocket is required`); diff --git a/zeppelin-web-angular/e2e/core-contract/notebook-transport-fixture.test.mjs b/zeppelin-web-angular/e2e/core-contract/notebook-transport-fixture.test.mjs index 42590e529c0..34d2222d572 100644 --- a/zeppelin-web-angular/e2e/core-contract/notebook-transport-fixture.test.mjs +++ b/zeppelin-web-angular/e2e/core-contract/notebook-transport-fixture.test.mjs @@ -32,9 +32,100 @@ import { isNotebookRestUrl, parseRestBody, validateFixture, + validateCaptureProvenance, webSocketPayloadMatches } from './notebook-transport-fixture.mjs'; +test('live fixture provenance fails closed when capture identity or isolation is missing', () => { + const manifest = JSON.parse(readFileSync(new URL('./fixtures/build-manifest.json', import.meta.url))); + const metadata = { + ...fixtureMetadata(), + captureSource: 'live-server', + provenance: { + baseCommit: 'e816bf1b76b50282cc32b284cdb8755f932f657e', + authentication: 'anonymous', + browser: { name: 'chromium', version: '140.0.0.0' }, + buildManifest: { ...manifest, id: manifest.manifestId, manifestId: undefined }, + captureMode: 'execution', + configuration: { paragraphStatusProgressEnabled: true }, + interpreter: 'sh', + isolation: { + logs: '/logs', + notebook: '/notebook', + pid: '/run', + recovery: '/recovery', + root: '', + searchIndex: '/index' + }, + origin: 'http://127.0.0.1:18080', + sourceCommit: 'e816bf1b76b50282cc32b284cdb8755f932f657e' + } + }; + assert.deepEqual(validateCaptureProvenance(metadata), []); + assert.match( + validateCaptureProvenance({ ...metadata, provenance: { ...metadata.provenance, sourceCommit: 'master' } }).join( + '\n' + ), + /exact Git commit/ + ); + assert.match( + validateCaptureProvenance({ ...metadata, provenance: { ...metadata.provenance, baseCommit: 'master' } }).join('\n'), + /baseCommit/ + ); + assert.match( + validateCaptureProvenance({ + ...metadata, + provenance: { + ...metadata.provenance, + buildManifest: { id: 'missing', sourceCommit: metadata.provenance.sourceCommit } + } + }).join('\n'), + /buildManifest\.id must be a SHA-256 digest/ + ); + const buildManifest = metadata.provenance.buildManifest; + const withManifest = changes => ({ + ...metadata, + provenance: { ...metadata.provenance, buildManifest: { ...buildManifest, ...changes } } + }); + assert.deepEqual(validateCaptureProvenance(withManifest({ id: '0'.repeat(64) })), [ + 'metadata.provenance.buildManifest.id must match the manifest content' + ]); + assert.deepEqual( + validateCaptureProvenance(withManifest({ artifacts: [...buildManifest.artifacts, { sha256: '0'.repeat(64) }] })), + [ + 'metadata.provenance.buildManifest.id must match the manifest content', + `metadata.provenance.buildManifest.artifacts[${buildManifest.artifacts.length}] must be an object with string path and selection` + ] + ); + assert.deepEqual( + validateCaptureProvenance({ + ...metadata, + provenance: { ...metadata.provenance, baseCommit: '0'.repeat(40) } + }), + [ + 'metadata.provenance.sourceCommit must equal baseCommit', + 'metadata.provenance.buildManifest.baseCommit must match metadata.provenance.baseCommit' + ] + ); + assert.match( + validateCaptureProvenance({ ...metadata, provenance: { ...metadata.provenance, authentication: 'unknown' } }).join( + '\n' + ), + /authentication/ + ); + assert.match( + validateCaptureProvenance({ ...metadata, provenance: { ...metadata.provenance, configuration: {} } }).join('\n'), + /capture configuration/ + ); + assert.match( + validateCaptureProvenance({ + ...metadata, + provenance: { ...metadata.provenance, isolation: { ...metadata.provenance.isolation, recovery: undefined } } + }).join('\n'), + /isolated recovery directory/ + ); +}); + // Temporary roots accumulate across repeated suite runs. const temporaryRoots = []; process.on('exit', () => { diff --git a/zeppelin-web-angular/e2e/tests/notebook/core-contract/execution-fixtures.spec.ts b/zeppelin-web-angular/e2e/tests/notebook/core-contract/execution-fixtures.spec.ts new file mode 100644 index 00000000000..f8a30d18b6a --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/notebook/core-contract/execution-fixtures.spec.ts @@ -0,0 +1,417 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +import { expect, Page, test } from '@playwright/test'; + +import { + createNotebookTransportRecorder, + type TransportFixture, + validateFixture +} from '../../../core-contract/notebook-transport-fixture.mjs'; +import { E2E_TEST_FOLDER } from '../../../models/base-page'; +import { addPageAnnotationBeforeEach, performLoginIfRequired, PAGES, waitForZeppelinReady } from '../../../utils'; + +const fixtureLicense = + 'Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. ' + + 'See the NOTICE file distributed with this work for additional information regarding copyright ownership. ' + + 'The ASF licenses this file to You under the Apache License, Version 2.0.'; +const terminalStatuses = new Set(['FINISHED', 'ERROR', 'ABORT']); + +const streamingSetting = 'zeppelin.websocket.paragraph_status_progress.enable'; + +type CaptureIsolation = Record<'logs' | 'notebook' | 'pid' | 'recovery' | 'root' | 'searchIndex', string>; + +interface CaptureServerSettings { + authentication: string; + buildManifestId: string | null; + configuration: Record; + isolation: CaptureIsolation; + mode: string; + port: number; +} + +const readCaptureServerSettings = (): CaptureServerSettings => { + const captureRoot = process.env.ZEPPELIN_E2E_CAPTURE_ROOT; + if (!captureRoot) throw new Error('ZEPPELIN_E2E_CAPTURE_ROOT is required for live fixture capture'); + const settingsPath = resolve(captureRoot, 'capture-provenance.json'); + if (!existsSync(settingsPath)) { + throw new Error(`${settingsPath} is missing; start the capture server with capture-server.sh for this root`); + } + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as CaptureServerSettings; + if (settings.isolation?.root !== realpathSync(captureRoot)) { + throw new Error(`${settingsPath} was written for ${settings.isolation?.root}, not ${captureRoot}`); + } + return settings; +}; + +const captureProvenance = (page: Page, browserName: string, browserVersion: string, streaming: boolean) => { + const sourceCommit = process.env.ZEPPELIN_E2E_SOURCE_COMMIT; + const baseCommit = process.env.ZEPPELIN_E2E_BASE_COMMIT; + const buildManifestPath = process.env.ZEPPELIN_E2E_BUILD_MANIFEST; + if (!sourceCommit) throw new Error('ZEPPELIN_E2E_SOURCE_COMMIT is required for live fixture capture'); + if (!baseCommit) throw new Error('ZEPPELIN_E2E_BASE_COMMIT is required for live fixture capture'); + if (!buildManifestPath) throw new Error('ZEPPELIN_E2E_BUILD_MANIFEST is required for live fixture capture'); + const manifest = JSON.parse(readFileSync(buildManifestPath, 'utf8')); + if (manifest.sourceCommit !== sourceCommit) throw new Error('build manifest source does not match capture source'); + const server = readCaptureServerSettings(); + if (!server.buildManifestId) { + throw new Error('capture server was not started with a verified --build-manifest'); + } + if (manifest.manifestId !== server.buildManifestId) { + throw new Error( + `build manifest ${manifest.manifestId} does not match the capture server's verified manifest ${server.buildManifestId}` + ); + } + if (server.configuration?.[streamingSetting] !== streaming) { + throw new Error( + `capture server ${streamingSetting}=${server.configuration?.[streamingSetting]}, expected ${streaming}` + ); + } + const origin = new URL(page.url()); + if (Number(origin.port) !== server.port) { + throw new Error(`capture origin ${origin.origin} does not use capture server port ${server.port}`); + } + const captureRoot = server.isolation.root; + const isolation = Object.fromEntries( + Object.entries(server.isolation).map(([name, directory]) => [ + name, + directory === captureRoot || directory.startsWith(`${captureRoot}/`) + ? `${directory.slice(captureRoot.length)}` + : directory + ]) + ) as CaptureIsolation; + return { + baseCommit, + authentication: server.authentication, + browser: { name: browserName, version: browserVersion }, + buildManifest: { + artifacts: manifest.artifacts, + baseCommit: manifest.baseCommit, + id: manifest.manifestId, + launchTargets: manifest.launchTargets, + sourceCommit: manifest.sourceCommit, + sourceTree: manifest.sourceTree, + version: manifest.version + }, + captureMode: 'execution', + configuration: server.configuration, + interpreter: 'sh', + isolation, + origin: origin.origin, + sourceCommit + }; +}; + +interface ZeppelinResponse { + body?: T; +} + +interface InterpreterSetting { + group?: string; + name?: string; +} + +interface NoteResponse { + id?: string; + paragraphs?: Array<{ id?: string }>; +} + +interface TicketResponse { + principal: string; + roles: string; + ticket: string; +} + +const websocketMessages = (fixture: TransportFixture, direction: 'send' | 'receive') => + fixture.records + .filter(record => record.kind === 'websocket' && record.websocket?.direction === direction) + .map(record => JSON.parse(record.websocket!.payloadText!) as { op: string; data?: Record }); + +const createShellNotebook = async (page: Page): Promise<{ noteId: string; paragraphId: string }> => { + const response = await page.request.post('/api/notebook', { + data: { + notePath: `${E2E_TEST_FOLDER}/ExecutionContractCapture_${Date.now()}`, + addingEmptyParagraph: true, + defaultInterpreterGroup: 'sh' + }, + failOnStatusCode: false + }); + if (!response.ok()) { + throw new Error(`Create execution fixture notebook failed: ${response.status()} ${await response.text()}`); + } + const created = (await response.json()) as ZeppelinResponse; + if (!created.body) { + throw new Error(`Create execution fixture notebook returned no id: ${JSON.stringify(created)}`); + } + + const noteResponse = await page.request.get(`/api/notebook/${created.body}`, { failOnStatusCode: false }); + if (!noteResponse.ok()) { + throw new Error(`Fetch execution fixture notebook failed: ${noteResponse.status()} ${await noteResponse.text()}`); + } + const note = (await noteResponse.json()) as ZeppelinResponse; + const paragraphId = note.body?.paragraphs?.[0]?.id; + if (!paragraphId) { + throw new Error(`Execution fixture notebook returned no paragraph: ${JSON.stringify(note.body)}`); + } + return { noteId: created.body, paragraphId }; +}; + +const deleteNotebook = async (page: Page, noteId: string): Promise => { + const response = await page.request.delete(`/api/notebook/${noteId}`, { failOnStatusCode: false }); + if (!response.ok()) { + console.warn(`execution fixture note ${noteId} was not deleted; remove it manually`); + } +}; + +const captureExecution = async ( + page: Page, + browserName: string, + browserVersion: string, + noteId: string, + paragraphId: string, + streaming: boolean, + cancel: boolean +): Promise => { + const ticketResponse = await page.request.get('/api/security/ticket'); + const ticketJson = (await ticketResponse.json()) as ZeppelinResponse & TicketResponse; + const ticket = ticketJson.body ?? ticketJson; + const versionResponse = await page.request.get('/api/version'); + const versionJson = (await versionResponse.json()) as ZeppelinResponse<{ version?: string }> & { version?: string }; + const zeppelinVersion = versionJson.body?.version ?? versionJson.version ?? 'unknown'; + const code = cancel ? '%sh\nsleep 30' : "%sh\nprintf 'first\\n'\nsleep 1\nprintf 'second\\n'"; + const recorder = createNotebookTransportRecorder({ + captureSource: 'live-server', + capturedAt: new Date().toISOString(), + configuration: { 'zeppelin.websocket.paragraph_status_progress.enable': streaming }, + // GET_NOTE is the mandatory first frame. The completed fixture replaces this + // bootstrap value with the operations actually observed in both directions. + coveredOperations: ['GET_NOTE'], + interpreter: 'sh', + knownExclusions: [], + owner: 'zeppelin-web-angular', + provenance: captureProvenance(page, browserName, browserVersion, streaming), + scenario: cancel + ? 'Cancel a running paragraph' + : `Run a paragraph with status and output streaming ${streaming ? 'enabled' : 'disabled'}`, + zeppelinVersion + }); + const recorderPage = await page.context().newPage(); + let captureFailed = false; + try { + await recorderPage.goto('/'); + await waitForZeppelinReady(recorderPage); + recorder.install(recorderPage); + await recorderPage.evaluate( + ({ captureTicket, codeToRun, note, paragraph, shouldCancel }) => + new Promise((resolvePromise, rejectPromise) => { + const locationUrl = new URL(window.location.href); + locationUrl.protocol = locationUrl.protocol === 'https:' ? 'wss:' : 'ws:'; + locationUrl.pathname = `${locationUrl.pathname.replace(/\/$/, '')}/ws`; + locationUrl.hash = ''; + locationUrl.search = ''; + const socket = new WebSocket(locationUrl); + let messageSequence = 0; + let runSent = false; + let cancelSent = false; + let terminalSeen = false; + const timeout = window.setTimeout( + () => { + socket.close(); + rejectPromise(new Error(`Execution fixture capture timed out for paragraph ${paragraph}`)); + }, + shouldCancel ? 120000 : 60000 + ); + const send = (op: string, data: Record) => { + socket.send( + JSON.stringify({ + op, + msgId: `execution-capture-${++messageSequence}`, + data, + ...captureTicket + }) + ); + }; + + socket.onerror = () => rejectPromise(new Error('Execution fixture WebSocket failed')); + socket.onclose = () => { + if (!terminalSeen) rejectPromise(new Error('Execution fixture WebSocket closed before terminal PARAGRAPH')); + }; + socket.onopen = () => send('GET_NOTE', { id: note }); + socket.onmessage = event => { + const message = JSON.parse(String(event.data)) as { + op?: string; + data?: { note?: { id?: string }; paragraph?: { id?: string; status?: string } }; + }; + if (message.op === 'NOTE' && message.data?.note?.id === note && !runSent) { + runSent = true; + send('RUN_PARAGRAPH', { id: paragraph, title: '', paragraph: codeToRun, config: {}, params: {} }); + return; + } + const target = message.data?.paragraph; + if (shouldCancel && target?.id === paragraph && target.status === 'RUNNING' && !cancelSent) { + cancelSent = true; + send('CANCEL_PARAGRAPH', { id: paragraph }); + return; + } + if (target?.id === paragraph && target.status && ['FINISHED', 'ERROR', 'ABORT'].includes(target.status)) { + terminalSeen = true; + window.clearTimeout(timeout); + window.setTimeout(() => { + socket.close(); + resolvePromise(); + }, 250); + } + }; + }), + { captureTicket: ticket, codeToRun: code, note: noteId, paragraph: paragraphId, shouldCancel: cancel } + ); + await recorder.stop(); + const fixture = recorder.snapshot(); + fixture.metadata!.coveredOperations = [ + ...new Set( + websocketMessages(fixture, 'send') + .concat(websocketMessages(fixture, 'receive')) + .map(message => message.op) + ) + ]; + return fixture; + } catch (error) { + captureFailed = true; + throw error; + } finally { + const [stopResult, closeResult] = await Promise.allSettled([recorder.stop(), recorderPage.close()]); + if (!captureFailed && stopResult.status === 'rejected') throw stopResult.reason; + if (!captureFailed && closeResult.status === 'rejected') throw closeResult.reason; + } +}; + +const writeFixture = (name: string, fixture: TransportFixture): void => { + if (process.env.ZEPPELIN_WRITE_EXECUTION_FIXTURES !== '1') return; + const directory = process.env.ZEPPELIN_E2E_FIXTURE_OUTPUT_DIR ?? resolve('e2e/core-contract/fixtures'); + const output = resolve(directory, `${name}.json`); + mkdirSync(dirname(output), { recursive: true }); + writeFileSync(output, `${JSON.stringify({ _license: fixtureLicense, ...fixture }, null, 2)}\n`); +}; + +test.describe('Notebook execution transport capture', () => { + addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK); + + test( + 'captures run, progress, streaming output and terminal delivery', + { tag: '@live' }, + async ({ browserName, page }) => { + const streamingValue = process.env.ZEPPELIN_CAPTURE_EXPECT_STREAMING; + test.skip( + streamingValue !== 'true' && streamingValue !== 'false', + 'ZEPPELIN-6671 live execution capture requires ZEPPELIN_CAPTURE_EXPECT_STREAMING=true or false' + ); + const streaming = streamingValue === 'true'; + + await page.goto('/#/'); + await waitForZeppelinReady(page); + await performLoginIfRequired(page); + const settingsResponse = await page.request.get('/api/interpreter/setting', { failOnStatusCode: false }); + const settingsJson = (await settingsResponse.json()) as ZeppelinResponse; + const shellAvailable = settingsResponse.ok() && settingsJson.body?.some(setting => setting.name === 'sh'); + test.skip(!shellAvailable, 'ZEPPELIN-6671 requires the named sh interpreter setting'); + + const { noteId, paragraphId } = await createShellNotebook(page); + try { + const fixture = await captureExecution( + page, + browserName, + page.context().browser()!.version(), + noteId, + paragraphId, + streaming, + false + ); + expect(validateFixture(fixture)).toEqual([]); + const received = websocketMessages(fixture, 'receive'); + const executionOperations = received.filter(message => + ['PROGRESS', 'PARAGRAPH_UPDATE_OUTPUT', 'PARAGRAPH_APPEND_OUTPUT', 'PARAGRAPH'].includes(message.op) + ); + const terminal = [...executionOperations] + .reverse() + .find(message => terminalStatuses.has(String((message.data?.paragraph as { status?: string })?.status))); + expect(terminal).toBeDefined(); + const terminalParagraph = terminal?.data?.paragraph as + | { results?: { code?: string; msg?: Array<{ type?: string; data?: string }> }; status?: string } + | undefined; + expect(terminalParagraph?.status).toBe('FINISHED'); + expect(terminalParagraph?.results?.code).toBe('SUCCESS'); + expect( + terminalParagraph?.results?.msg + ?.filter(message => message.type === 'TEXT') + .map(message => message.data) + .join('') + ).toBe('first\nsecond\n'); + const executionOperationNames = executionOperations.map(message => message.op); + expect({ + append: executionOperationNames.includes('PARAGRAPH_APPEND_OUTPUT'), + progress: executionOperationNames.includes('PROGRESS'), + update: executionOperationNames.includes('PARAGRAPH_UPDATE_OUTPUT') + }).toEqual({ append: streaming, progress: streaming, update: streaming }); + writeFixture(streaming ? 'execution-streaming-enabled' : 'execution-streaming-disabled', fixture); + } finally { + await deleteNotebook(page, noteId); + } + } + ); + + test('captures explicit cancellation through terminal ABORT', { tag: '@live' }, async ({ browserName, page }) => { + test.skip( + process.env.ZEPPELIN_CAPTURE_EXPECT_STREAMING !== 'true', + 'ZEPPELIN-6671 cancellation capture runs on the streaming-enabled fixture server' + ); + await page.goto('/#/'); + await waitForZeppelinReady(page); + await performLoginIfRequired(page); + const settingsResponse = await page.request.get('/api/interpreter/setting', { failOnStatusCode: false }); + const settingsJson = (await settingsResponse.json()) as ZeppelinResponse; + const shellAvailable = settingsResponse.ok() && settingsJson.body?.some(setting => setting.name === 'sh'); + test.skip(!shellAvailable, 'ZEPPELIN-6671 requires the named sh interpreter setting'); + + const { noteId, paragraphId } = await createShellNotebook(page); + try { + const fixture = await captureExecution( + page, + browserName, + page.context().browser()!.version(), + noteId, + paragraphId, + true, + true + ); + expect(validateFixture(fixture)).toEqual([]); + expect(websocketMessages(fixture, 'send').map(message => message.op)).toEqual( + expect.arrayContaining(['RUN_PARAGRAPH', 'CANCEL_PARAGRAPH']) + ); + const terminal = websocketMessages(fixture, 'receive') + .filter(message => message.op === 'PARAGRAPH') + .reverse() + .find(message => terminalStatuses.has(String((message.data?.paragraph as { status?: string })?.status))); + expect((terminal?.data?.paragraph as { status?: string })?.status).toBe('ABORT'); + writeFixture('execution-cancel', fixture); + } finally { + await deleteNotebook(page, noteId); + } + }); +}); diff --git a/zeppelin-web-angular/package.json b/zeppelin-web-angular/package.json index fe9a70398bf..a4b9c7dc0fe 100644 --- a/zeppelin-web-angular/package.json +++ b/zeppelin-web-angular/package.json @@ -31,7 +31,7 @@ "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 e2e/core-contract/reject-notebook-core-runtime-plugin.test.mjs", + "check:core-contract-fixtures": "node --test e2e/core-contract/capture-build-manifest.test.mjs e2e/core-contract/notebook-transport-fixture.test.mjs e2e/core-contract/execution-fixtures.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/playwright.core-contract.config.js b/zeppelin-web-angular/playwright.core-contract.config.js index 68851e18dbc..36f9f023fc2 100644 --- a/zeppelin-web-angular/playwright.core-contract.config.js +++ b/zeppelin-web-angular/playwright.core-contract.config.js @@ -51,7 +51,7 @@ module.exports = defineConfig({ ...(live ? [{ name: 'setup', testMatch: /global\.setup\.ts/, metadata: { authStatePath } }] : []), { name: 'chromium', - testMatch: '**/tests/notebook/core-contract/capture-fixtures.spec.ts', + testMatch: '**/tests/notebook/core-contract/*.spec.ts', grep: live ? /@live/ : undefined, grepInvert: live ? undefined : /@live/, use: { From b1a4567fee09f6f1f958fb6e00105e39eaf778af Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Tue, 29 Sep 2026 01:49:14 +0900 Subject: [PATCH 2/2] [ZEPPELIN-6671] Align launcher WAR discovery with manifest Prune VCS and frontend toolchain directories from the launcher fallback WAR search so it cannot select artifacts that capture-build-manifest intentionally excludes. Exercise the real launcher search from the manifest regression test. Confidence: high Scope-risk: Custom deployments storing WAR files beneath node or node_modules are no longer discovered by fallback search. Not-tested: Full core-contract suite requires Playwright and webpack dependencies unavailable in the clean worktree. Signed-off-by: YONGJAE LEE --- bin/common.sh | 8 ++++-- .../capture-build-manifest.test.mjs | 27 ++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/bin/common.sh b/bin/common.sh index 397115a674a..19b5883493e 100644 --- a/bin/common.sh +++ b/bin/common.sh @@ -45,7 +45,9 @@ if [[ -z "${ZEPPELIN_WAR}" ]]; then if [[ -d "${ZEPPELIN_HOME}/zeppelin-web/dist" ]]; then export ZEPPELIN_WAR="${ZEPPELIN_HOME}/zeppelin-web/dist" else - ZEPPELIN_WAR=$(find -L "${ZEPPELIN_HOME}" -name "zeppelin-web-[0-9]*.war") + ZEPPELIN_WAR=$(find -L "${ZEPPELIN_HOME}" \ + \( -name .git -o -name node -o -name node_modules \) -prune -o \ + -name "zeppelin-web-[0-9]*.war" -print) if [[ -n "${ZEPPELIN_WAR}" ]]; then export ZEPPELIN_WAR fi @@ -56,7 +58,9 @@ if [[ -z "${ZEPPELIN_ANGULAR_WAR}" ]]; then if [[ -d "${ZEPPELIN_HOME}/zeppelin-web-angular/dist/zeppelin" ]]; then export ZEPPELIN_ANGULAR_WAR="${ZEPPELIN_HOME}/zeppelin-web-angular/dist/zeppelin" else - ZEPPELIN_ANGULAR_WAR=$(find -L "${ZEPPELIN_HOME}" -name "zeppelin-web-angular*.war") + ZEPPELIN_ANGULAR_WAR=$(find -L "${ZEPPELIN_HOME}" \ + \( -name .git -o -name node -o -name node_modules \) -prune -o \ + -name "zeppelin-web-angular*.war" -print) if [[ -n "${ZEPPELIN_ANGULAR_WAR}" ]]; then export ZEPPELIN_ANGULAR_WAR fi diff --git a/zeppelin-web-angular/e2e/core-contract/capture-build-manifest.test.mjs b/zeppelin-web-angular/e2e/core-contract/capture-build-manifest.test.mjs index be21d46c935..fe6b84a7ec0 100644 --- a/zeppelin-web-angular/e2e/core-contract/capture-build-manifest.test.mjs +++ b/zeppelin-web-angular/e2e/core-contract/capture-build-manifest.test.mjs @@ -23,6 +23,7 @@ import { join } from 'node:path'; import test from 'node:test'; const script = new URL('./capture-build-manifest.mjs', import.meta.url).pathname; +const launcherScript = new URL('../../../bin/common.sh', import.meta.url).pathname; const roots = [ 'interpreter', 'bin', @@ -136,7 +137,7 @@ test('build manifest rejects fallback WAR additions and symlink retargeting', () } }); -test('build manifest WAR search skips VCS metadata and frontend toolchain directories', () => { +test('build manifest and launcher WAR searches skip VCS metadata and frontend toolchain directories', () => { const root = createRepository(); try { const manifest = join(root, 'manifest.json'); @@ -146,6 +147,30 @@ test('build manifest WAR search skips VCS metadata and frontend toolchain direct writeFileSync(join(root, directory, 'zeppelin-web-0.13.0.war'), 'ignored'); } execFileSync(process.execPath, [script, 'verify', manifest, root]); + + rmSync(join(root, 'zeppelin-web', 'dist'), { recursive: true }); + rmSync(join(root, 'zeppelin-web-angular', 'dist'), { recursive: true }); + mkdirSync(join(root, 'fallback'), { recursive: true }); + const classicWar = join(root, 'fallback', 'zeppelin-web-0.13.0.war'); + const angularWar = join(root, 'fallback', 'zeppelin-web-angular-0.13.0.war'); + writeFileSync(classicWar, 'classic'); + writeFileSync(angularWar, 'angular'); + const selectedWars = execFileSync( + 'bash', + ['-c', '. "$1"; printf "%s\\n%s\\n" "$ZEPPELIN_WAR" "$ZEPPELIN_ANGULAR_WAR"', 'bash', launcherScript], + { + encoding: 'utf8', + env: { + ...process.env, + ZEPPELIN_ANGULAR_WAR: '', + ZEPPELIN_HOME: root, + ZEPPELIN_WAR: '' + } + } + ) + .trim() + .split('\n'); + assert.deepEqual(selectedWars, [classicWar, angularWar]); } finally { rmSync(root, { force: true, recursive: true }); }