This is an automated email from the ASF dual-hosted git repository.

jongyoul pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new 904e9136d9 [ZEPPELIN-6456] Share websocket operation contract
904e9136d9 is described below

commit 904e9136d95377d3a04deba531250719ccf1483c
Author: namuuCY <[email protected]>
AuthorDate: Sat Aug 22 17:07:12 2026 +0900

    [ZEPPELIN-6456] Share websocket operation contract
    
    ### What is this PR for?
    
    The Java server and clients already share 
`org.apache.zeppelin.common.Message`, but the class still contained a stale 
comment claiming it was copied from `zeppelin-server`. The Angular SDK also 
maintained a separate websocket operation enum, allowing the Java and 
TypeScript definitions to drift.
    
    This PR treats Java `Message.OP` as the wire-operation authority and adds 
fail-closed CI validation that compares it directly with the TypeScript `OP` 
enum. It does not generate either definition and does not introduce a third 
complete operation list. Frontend-only events such as `PARAGRAPH_STATUS` are 
marked inline with `<at>frontendOnly`.
    
    The change also corrects `LIST_UPDATE_NOTE_JOBS` to server-to-client and 
removes its stale TypeScript send-map entry. CI does not infer directions from 
arbitrary code usage; any future direction validation should be based on the 
server's actual handlers and emissions.
    
    ### Adding a websocket operation
    
    The checker is intentionally read-only and fails when only one side of the 
contract changes.
    
    - For a wire operation, add the same name and string value to both Java 
`Message.OP` and TypeScript `OP`. This applies even when the Angular SDK does 
not actively use that operation.
    - For an SDK-local event, add it only to TypeScript `OP` and document it 
with an `<at>frontendOnly` JSDoc tag.
    - Add an operation to `MessageSendDataTypeMap` or 
`MessageReceiveDataTypeMap` only when the Angular SDK sends or receives it.
    
    If only one side is updated, CI reports the missing or extra operation. 
Updating both definitions in the same PR restores the contract check.
    
    ### What type of PR is it?
    
    Refactoring
    
    ### Todos
    
    - [x] Compare Java `Message.OP` directly with TypeScript `OP`
    - [x] Mark frontend-only operations next to their TypeScript declarations
    - [x] Correct the `LIST_UPDATE_NOTE_JOBS` direction
    - [x] Add Java compatibility tests
    - [x] Add checker regression tests
    - [x] Add Angular contract validation to CI
    - [x] Preserve existing wire operation names and values
    
    ### What is the Jira issue?
    
    - https://issues.apache.org/jira/browse/ZEPPELIN-6456
    
    ### How should this be tested?
    
    Run the focused Java contract tests:
    
    ```bash
    ./mvnw test -pl zeppelin-common -Dtest=MessageContractTest
    ```
    
    Validate the Java/TypeScript websocket contracts and checker regression 
tests:
    
    ```bash
    cd zeppelin-web-angular
    npm run check:websocket-contract
    ```
    
    Build the Angular SDK:
    ```bash
    cd zeppelin-web-angular
    npm run build-project:sdk
    ```
    
    Run the Apache license check:
    
    ```bash
    ./mvnw clean org.apache.rat:apache-rat-plugin:check -Prat
    ```
    
    ### Screenshots (if appropriate)
    N/A — this PR does not change the UI.
    
    ### Questions:
    - Do the license files need to be updated? No.
    - Are there breaking changes for older versions? No. Existing wire 
operation names and values remain unchanged.
    - Does this need documentation? No user-facing documentation is required. 
The direct contract checker and inline frontend-only marker document the 
maintenance rules.
    
    Closes #5406 from namuuCY/master.
    
    Signed-off-by: Jongyoul Lee <[email protected]>
---
 .github/workflows/frontend.yml                     |   4 +
 .../java/org/apache/zeppelin/common/Message.java   |  12 +-
 .../zeppelin/common/MessageContractTest.java       |  59 ++++
 zeppelin-web-angular/package.json                  |   1 +
 .../interfaces/message-data-type-map.interface.ts  |   1 -
 .../src/interfaces/message-operator.interface.ts   |  21 +-
 .../scripts/check-websocket-contract.js            | 306 +++++++++++++++++++++
 .../scripts/check-websocket-contract.test.js       | 140 ++++++++++
 8 files changed, 537 insertions(+), 7 deletions(-)

diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml
index 1998142a41..9e59901692 100644
--- a/.github/workflows/frontend.yml
+++ b/.github/workflows/frontend.yml
@@ -92,6 +92,10 @@ jobs:
           channel-priority: strict
       - name: Install application
         run: ./mvnw clean install -DskipTests -am -pl 
python,zeppelin-jupyter-interpreter,zeppelin-web,zeppelin-web-angular 
-Pweb-classic ${MAVEN_ARGS}
+      - name: Check websocket operation contract
+        if: matrix.mode == 'anonymous'
+        working-directory: zeppelin-web-angular
+        run: ./node/npm run check:websocket-contract
       # Keeps the Karma coverage previously run by the removed zeppelin-web 
e2e job
       - name: Run zeppelin-web unit tests
         if: matrix.mode == 'anonymous'
diff --git 
a/zeppelin-common/src/main/java/org/apache/zeppelin/common/Message.java 
b/zeppelin-common/src/main/java/org/apache/zeppelin/common/Message.java
index 126855bcff..fc8fd3a8eb 100644
--- a/zeppelin-common/src/main/java/org/apache/zeppelin/common/Message.java
+++ b/zeppelin-common/src/main/java/org/apache/zeppelin/common/Message.java
@@ -28,8 +28,12 @@ import java.util.Map;
 import java.util.Set;
 
 /**
- * Copied from zeppelin-server (TODO, zjffdu). Should resume the same piece of 
code instead of copying.
- * Zeppelin websocket message template class.
+ * Shared websocket message contract used by Zeppelin server and Java clients.
+ *
+ * <p>Operation names are part of the public wire protocol and must remain 
backward compatible. CI
+ * verifies that the Angular websocket operation enum remains synchronized 
with this enum.
+ * When adding a wire operation, add the same name and value to the Angular 
enum. Frontend-only
+ * events belong only in the Angular enum and must be marked {@code 
@frontendOnly}.
  */
 public class Message implements JsonSerializable {
   /**
@@ -174,7 +178,7 @@ public class Message implements JsonSerializable {
     APP_STATUS_CHANGE,            // [s-c] on app status change
 
     LIST_NOTE_JOBS,               // [c-s] get note job management information
-    LIST_UPDATE_NOTE_JOBS,        // [c-s] get job management information for 
until unixtime
+    LIST_UPDATE_NOTE_JOBS,        // [s-c] update job management information
     UNSUBSCRIBE_UPDATE_NOTE_JOBS, // [c-s] unsubscribe job information for job 
management
     JOB_MANAGER_DISABLED,         // [s-c] send when job manager is disabled
     // @param unixTime
@@ -186,7 +190,7 @@ public class Message implements JsonSerializable {
     INTERPRETER_SETTINGS,         // [s-c] interpreter settings
     ERROR_INFO,                   // [s-c] error information to be sent
     SESSION_LOGOUT,               // [s-c] error information to be sent
-    WATCHER,                      // [s-c] Change websocket to watcher mode.
+    WATCHER,                      // [c-s] Change websocket to watcher mode.
     PARAGRAPH_ADDED,              // [s-c] paragraph is added
     PARAGRAPH_REMOVED,            // [s-c] paragraph deleted
     PARAGRAPH_MOVED,              // [s-c] paragraph moved
diff --git 
a/zeppelin-common/src/test/java/org/apache/zeppelin/common/MessageContractTest.java
 
b/zeppelin-common/src/test/java/org/apache/zeppelin/common/MessageContractTest.java
new file mode 100644
index 0000000000..bca7b1d5fb
--- /dev/null
+++ 
b/zeppelin-common/src/test/java/org/apache/zeppelin/common/MessageContractTest.java
@@ -0,0 +1,59 @@
+/*
+ * 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.
+ */
+
+package org.apache.zeppelin.common;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.apache.zeppelin.common.Message.OP;
+import org.junit.jupiter.api.Test;
+
+class MessageContractTest {
+  @Test
+  void operationNamesRoundTripWithoutChangingWireValues() {
+    for (OP operation : OP.values()) {
+      Message message = new Message(operation);
+      JsonObject json = 
JsonParser.parseString(message.toJson()).getAsJsonObject();
+      assertEquals(operation.name(), json.get("op").getAsString());
+      assertEquals(operation, Message.fromJson(json.toString()).op);
+    }
+  }
+
+  @Test
+  void messageDefaultsRemainBackwardCompatible() {
+    Message message = new Message(OP.GET_NOTE);
+
+    assertTrue(message.data.isEmpty());
+    assertEquals("anonymous", message.ticket);
+    assertEquals("anonymous", message.principal);
+    assertEquals("", message.roles);
+    assertNull(message.msgId);
+
+    JsonObject json = 
JsonParser.parseString(message.toJson()).getAsJsonObject();
+    assertEquals("GET_NOTE", json.get("op").getAsString());
+    assertTrue(json.getAsJsonObject("data").entrySet().isEmpty());
+    assertEquals("anonymous", json.get("ticket").getAsString());
+    assertEquals("anonymous", json.get("principal").getAsString());
+    assertEquals("", json.get("roles").getAsString());
+    assertFalse(json.has("msgId"));
+  }
+}
diff --git a/zeppelin-web-angular/package.json 
b/zeppelin-web-angular/package.json
index 5a724f9a9c..dbf8a7ab08 100644
--- a/zeppelin-web-angular/package.json
+++ b/zeppelin-web-angular/package.json
@@ -13,6 +13,7 @@
     "build:react": "cd projects/zeppelin-react && npm run build",
     "build:projects": "npm run build-project:sdk && npm run build-project:vis",
     "build-project:sdk": "ng build --project zeppelin-sdk",
+    "check:websocket-contract": "node --test 
scripts/check-websocket-contract.test.js && node 
scripts/check-websocket-contract.js",
     "build-project:vis": "ng build --project zeppelin-visualization",
     "lint": "cross-env NODE_OPTIONS='--max-old-space-size=8192' ng lint && npm 
run lint:react && prettier --check \"**/*.{ts,tsx,mts,js,json,css,html}\"",
     "lint:fix": "cross-env NODE_OPTIONS='--max-old-space-size=8192' ng lint 
--fix && npm run lint:fix:react && prettier --write 
\"**/*.{ts,tsx,mts,js,json,css,html}\"",
diff --git 
a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-data-type-map.interface.ts
 
b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-data-type-map.interface.ts
index 5fba20a132..f05583afb3 100644
--- 
a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-data-type-map.interface.ts
+++ 
b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-data-type-map.interface.ts
@@ -171,7 +171,6 @@ export interface MessageSendDataTypeMap {
   [OP.EDITOR_SETTING]: EditorSettingSend;
   [OP.LIST_NOTE_JOBS]: undefined;
   [OP.UNSUBSCRIBE_UPDATE_NOTE_JOBS]: undefined;
-  [OP.LIST_UPDATE_NOTE_JOBS]: undefined;
   [OP.GET_INTERPRETER_BINDINGS]: GetInterpreterBindings;
   [OP.SAVE_INTERPRETER_BINDINGS]: SaveInterpreterBindings;
   [OP.GET_INTERPRETER_SETTINGS]: undefined;
diff --git 
a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-operator.interface.ts
 
b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-operator.interface.ts
index 322fb8f388..a43549fa71 100644
--- 
a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-operator.interface.ts
+++ 
b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-operator.interface.ts
@@ -12,6 +12,9 @@
 
 /**
  * Representation of event type.
+ *
+ * Wire operations must match org.apache.zeppelin.common.Message.OP. Add 
SDK-local events only here
+ * and mark them with @frontendOnly.
  */
 export enum OP {
   /**
@@ -54,6 +57,7 @@ export enum OP {
    * paragraph status update
    *  @param id paragraph id
    *  @param progress percentage progress
+   * @frontendOnly Emitted locally by the SDK without using the websocket.
    */
   PARAGRAPH_STATUS = 'PARAGRAPH_STATUS',
 
@@ -91,6 +95,19 @@ export enum OP {
    * @param object notebook
    */
   IMPORT_NOTE = 'IMPORT_NOTE',
+
+  /**
+   * [c-s]
+   * convert a note to nbformat
+   */
+  CONVERT_NOTE_NBFORMAT = 'CONVERT_NOTE_NBFORMAT',
+
+  /**
+   * [s-c]
+   * converted nbformat note
+   */
+  CONVERTED_NOTE_NBFORMAT = 'CONVERTED_NOTE_NBFORMAT',
+
   NOTE_UPDATE = 'NOTE_UPDATE',
   NOTE_RENAME = 'NOTE_RENAME',
 
@@ -347,8 +364,8 @@ export enum OP {
   LIST_NOTE_JOBS = 'LIST_NOTE_JOBS',
 
   /**
-   * [c-s]
-   * get job management information for until unixtime
+   * [s-c]
+   * update job management information
    */
   LIST_UPDATE_NOTE_JOBS = 'LIST_UPDATE_NOTE_JOBS',
 
diff --git a/zeppelin-web-angular/scripts/check-websocket-contract.js 
b/zeppelin-web-angular/scripts/check-websocket-contract.js
new file mode 100644
index 0000000000..28096fb6b4
--- /dev/null
+++ b/zeppelin-web-angular/scripts/check-websocket-contract.js
@@ -0,0 +1,306 @@
+/*
+ * 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.
+ */
+
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const ts = require('typescript');
+
+const WEB_ROOT = path.resolve(__dirname, '..');
+const REPOSITORY_ROOT = path.resolve(WEB_ROOT, '..');
+const JAVA_OPERATOR_SOURCE = path.join(
+  REPOSITORY_ROOT,
+  'zeppelin-common/src/main/java/org/apache/zeppelin/common/Message.java'
+);
+const TYPESCRIPT_OPERATOR_SOURCE = path.join(
+  WEB_ROOT,
+  'projects/zeppelin-sdk/src/interfaces/message-operator.interface.ts'
+);
+const DATA_TYPE_MAP_SOURCE = path.join(
+  WEB_ROOT,
+  'projects/zeppelin-sdk/src/interfaces/message-data-type-map.interface.ts'
+);
+const OPERATION_NAME = /^[A-Z][A-Z0-9_]*$/;
+const FRONTEND_ONLY_TAG = 'frontendOnly';
+
+function fail(message) {
+  throw new Error(`Websocket contract check failed: ${message}`);
+}
+
+function addOperation(operations, name, description) {
+  if (!OPERATION_NAME.test(name)) {
+    fail(`${description} contains invalid operation '${name}'`);
+  }
+  if (operations.has(name)) {
+    fail(`${description} contains duplicate operation ${name}`);
+  }
+  operations.add(name);
+}
+
+function readJavaEnumBody(source, file) {
+  const declarationPattern = /^\s*public\s+enum\s+OP\s*\{/gm;
+  const declarations = [...source.matchAll(declarationPattern)];
+  if (declarations.length !== 1) {
+    fail(`Expected exactly one public enum OP in ${file}, found 
${declarations.length}`);
+  }
+
+  const declaration = declarations[0];
+  const openingBrace = declaration.index + declaration[0].lastIndexOf('{');
+  let state = 'code';
+  let body = '';
+
+  for (let index = openingBrace + 1; index < source.length; index += 1) {
+    const character = source[index];
+    const nextCharacter = source[index + 1];
+
+    if (state === 'line-comment') {
+      if (character === '\n') {
+        state = 'code';
+        body += '\n';
+      }
+      continue;
+    }
+
+    if (state === 'block-comment') {
+      if (character === '*' && nextCharacter === '/') {
+        state = 'code';
+        body += ' ';
+        index += 1;
+      }
+      continue;
+    }
+
+    if (character === '/' && nextCharacter === '/') {
+      state = 'line-comment';
+      index += 1;
+      continue;
+    }
+    if (character === '/' && nextCharacter === '*') {
+      state = 'block-comment';
+      index += 1;
+      continue;
+    }
+    if (character === '}') {
+      return body;
+    }
+    if (character === '{' || character === '"' || character === "'") {
+      fail(`${file} enum OP must contain only simple enum constants`);
+    }
+    body += character;
+  }
+
+  const detail = state === 'block-comment' ? 'an unclosed block comment' : 'no 
closing brace';
+  fail(`Could not parse enum OP in ${file}: ${detail}`);
+}
+
+function parseJavaOperations(source, file = 'Message.java') {
+  let body = readJavaEnumBody(source, file).trim();
+  if (body.endsWith(';')) {
+    body = body.slice(0, -1).trimEnd();
+  }
+
+  const entries = body.split(',');
+  if (entries.at(-1).trim() === '') {
+    entries.pop();
+  }
+  if (entries.length === 0) {
+    fail(`${file} enum OP must contain at least one operation`);
+  }
+
+  const operations = new Set();
+  for (const entry of entries) {
+    const name = entry.trim();
+    if (name === '') {
+      fail(`${file} enum OP contains an empty operation`);
+    }
+    addOperation(operations, name, `${file} enum OP`);
+  }
+  return operations;
+}
+
+function parseTypeScript(source, file) {
+  const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, 
true, ts.ScriptKind.TS);
+  if (sourceFile.parseDiagnostics.length) {
+    const diagnostics = sourceFile.parseDiagnostics
+      .map(diagnostic => 
ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
+      .join('; ');
+    fail(`Could not parse ${file}: ${diagnostics}`);
+  }
+  return sourceFile;
+}
+
+function findSingleDeclaration(sourceFile, predicate, description) {
+  const declarations = sourceFile.statements.filter(predicate);
+  if (declarations.length !== 1) {
+    fail(`Expected exactly one ${description} in ${sourceFile.fileName}, found 
${declarations.length}`);
+  }
+  return declarations[0];
+}
+
+function jsDocCommentText(comment) {
+  if (typeof comment === 'string') {
+    return comment;
+  }
+  if (Array.isArray(comment)) {
+    return comment.map(part => part.text).join('');
+  }
+  return '';
+}
+
+function parseTypeScriptOperations(source, file = 
'message-operator.interface.ts') {
+  const sourceFile = parseTypeScript(source, file);
+  const declaration = findSingleDeclaration(
+    sourceFile,
+    statement => ts.isEnumDeclaration(statement) && statement.name.text === 
'OP',
+    'enum OP'
+  );
+  const wireOperations = new Set();
+  const frontendOnlyOperations = new Set();
+  const allOperations = new Set();
+
+  for (const member of declaration.members) {
+    if (!ts.isIdentifier(member.name) || !member.initializer || 
!ts.isStringLiteral(member.initializer)) {
+      fail('Every TypeScript OP member must be an identifier with an explicit 
string literal value');
+    }
+
+    const name = member.name.text;
+    if (name !== member.initializer.text) {
+      fail(`OP.${name} must use the wire value '${name}', found 
'${member.initializer.text}'`);
+    }
+    addOperation(allOperations, name, 'TypeScript enum OP');
+
+    const frontendOnlyTags = ts.getJSDocTags(member).filter(tag => 
tag.tagName.text === FRONTEND_ONLY_TAG);
+    if (frontendOnlyTags.length > 1) {
+      fail(`OP.${name} has multiple @${FRONTEND_ONLY_TAG} tags`);
+    }
+    if (frontendOnlyTags.length === 1) {
+      if (jsDocCommentText(frontendOnlyTags[0].comment).trim() === '') {
+        fail(`OP.${name} must explain why it is @${FRONTEND_ONLY_TAG}`);
+      }
+      frontendOnlyOperations.add(name);
+    } else {
+      wireOperations.add(name);
+    }
+  }
+
+  return { allOperations, frontendOnlyOperations, wireOperations };
+}
+
+function parseDataTypeMapOperations(source, interfaceName, file = 
'message-data-type-map.interface.ts') {
+  const sourceFile = parseTypeScript(source, file);
+  const declaration = findSingleDeclaration(
+    sourceFile,
+    statement => ts.isInterfaceDeclaration(statement) && statement.name.text 
=== interfaceName,
+    `interface ${interfaceName}`
+  );
+  if (declaration.heritageClauses && declaration.heritageClauses.length) {
+    fail(`interface ${interfaceName} must not use inheritance`);
+  }
+
+  const operations = new Set();
+  for (const member of declaration.members) {
+    if (
+      !ts.isPropertySignature(member) ||
+      !member.name ||
+      !ts.isComputedPropertyName(member.name) ||
+      !ts.isPropertyAccessExpression(member.name.expression) ||
+      !ts.isIdentifier(member.name.expression.expression) ||
+      member.name.expression.expression.text !== 'OP' ||
+      !ts.isIdentifier(member.name.expression.name)
+    ) {
+      fail(`Every ${interfaceName} member must use the form [OP.NAME]: Type`);
+    }
+    addOperation(operations, member.name.expression.name.text, interfaceName);
+  }
+  return operations;
+}
+
+function difference(left, right) {
+  return [...left].filter(value => !right.has(value)).sort();
+}
+
+function validateContract(javaOperations, typeScriptOperations, 
sendOperations, receiveOperations) {
+  const overlap = [...typeScriptOperations.frontendOnlyOperations]
+    .filter(operation => javaOperations.has(operation))
+    .sort();
+  if (overlap.length) {
+    fail(`Java wire operations cannot be @${FRONTEND_ONLY_TAG}: 
[${overlap.join(', ')}]`);
+  }
+
+  const missing = difference(javaOperations, 
typeScriptOperations.wireOperations);
+  const extra = difference(typeScriptOperations.wireOperations, 
javaOperations);
+  if (missing.length || extra.length) {
+    fail(
+      `Java Message.OP and TypeScript wire OP differ; ` +
+        `missing from TypeScript=[${missing.join(', ')}], extra in 
TypeScript=[${extra.join(', ')}]`
+    );
+  }
+
+  for (const operation of sendOperations) {
+    if (typeScriptOperations.frontendOnlyOperations.has(operation)) {
+      fail(`Frontend-only operation ${operation} cannot be in 
MessageSendDataTypeMap`);
+    }
+    if (!javaOperations.has(operation)) {
+      fail(`MessageSendDataTypeMap operation ${operation} is not a Java wire 
operation`);
+    }
+  }
+
+  for (const operation of receiveOperations) {
+    if (!typeScriptOperations.allOperations.has(operation)) {
+      fail(`MessageReceiveDataTypeMap operation ${operation} is not declared 
in TypeScript enum OP`);
+    }
+  }
+
+  for (const operation of typeScriptOperations.frontendOnlyOperations) {
+    if (!receiveOperations.has(operation)) {
+      fail(`Frontend-only operation ${operation} must be in 
MessageReceiveDataTypeMap`);
+    }
+  }
+}
+
+function main() {
+  const javaOperations = 
parseJavaOperations(fs.readFileSync(JAVA_OPERATOR_SOURCE, 'utf8'), 
JAVA_OPERATOR_SOURCE);
+  const typeScriptOperations = parseTypeScriptOperations(
+    fs.readFileSync(TYPESCRIPT_OPERATOR_SOURCE, 'utf8'),
+    TYPESCRIPT_OPERATOR_SOURCE
+  );
+  const dataTypeMapSource = fs.readFileSync(DATA_TYPE_MAP_SOURCE, 'utf8');
+  const sendOperations = parseDataTypeMapOperations(dataTypeMapSource, 
'MessageSendDataTypeMap', DATA_TYPE_MAP_SOURCE);
+  const receiveOperations = parseDataTypeMapOperations(
+    dataTypeMapSource,
+    'MessageReceiveDataTypeMap',
+    DATA_TYPE_MAP_SOURCE
+  );
+
+  validateContract(javaOperations, typeScriptOperations, sendOperations, 
receiveOperations);
+  console.log(
+    `Websocket contract is synchronized: ${javaOperations.size} wire 
operations, ` +
+      `${typeScriptOperations.frontendOnlyOperations.size} frontend-only 
operation(s).`
+  );
+}
+
+module.exports = {
+  parseDataTypeMapOperations,
+  parseJavaOperations,
+  parseTypeScriptOperations,
+  validateContract
+};
+
+if (require.main === module) {
+  main();
+}
diff --git a/zeppelin-web-angular/scripts/check-websocket-contract.test.js 
b/zeppelin-web-angular/scripts/check-websocket-contract.test.js
new file mode 100644
index 0000000000..94a7ba896a
--- /dev/null
+++ b/zeppelin-web-angular/scripts/check-websocket-contract.test.js
@@ -0,0 +1,140 @@
+/*
+ * 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.
+ */
+
+'use strict';
+
+const assert = require('node:assert/strict');
+const test = require('node:test');
+const {
+  parseDataTypeMapOperations,
+  parseJavaOperations,
+  parseTypeScriptOperations,
+  validateContract
+} = require('./check-websocket-contract');
+
+test('parses simple Java enum constants while ignoring comments', () => {
+  const operations = parseJavaOperations(`
+    public class Message {
+      public enum OP {
+        FIRST, // a comment containing },
+        /* another comment containing }, */ SECOND,
+        THIRD,
+      }
+    }
+  `);
+
+  assert.deepEqual([...operations], ['FIRST', 'SECOND', 'THIRD']);
+});
+
+test('accepts an optional Java enum semicolon', () => {
+  const operations = parseJavaOperations('public enum OP { FIRST, SECOND; }');
+
+  assert.deepEqual([...operations], ['FIRST', 'SECOND']);
+});
+
+test('fails closed for unsupported or malformed Java enum declarations', () => 
{
+  assert.throws(() => parseJavaOperations('public enum OP { FIRST,,SECOND }'), 
/contains an empty operation/);
+  assert.throws(
+    () => parseJavaOperations('public enum OP { FIRST("value") }'),
+    /must contain only simple enum constants/
+  );
+  assert.throws(() => parseJavaOperations('public enum OP { FIRST, \/\* 
unclosed'), /unclosed block comment/);
+  assert.throws(
+    () => parseJavaOperations('public enum OP { FIRST }\npublic enum OP { 
SECOND }'),
+    /Expected exactly one public enum OP/
+  );
+});
+
+test('classifies frontend-only TypeScript operations from inline JSDoc', () => 
{
+  const operations = parseTypeScriptOperations(`
+    export enum OP {
+      WIRE = 'WIRE',
+      /** @frontendOnly Emitted locally without using the websocket. */
+      LOCAL = 'LOCAL'
+    }
+  `);
+
+  assert.deepEqual([...operations.wireOperations], ['WIRE']);
+  assert.deepEqual([...operations.frontendOnlyOperations], ['LOCAL']);
+});
+
+test('requires valid TypeScript wire values and frontend-only explanations', 
() => {
+  assert.throws(
+    () => parseTypeScriptOperations("export enum OP { FIRST = 'SECOND' }"),
+    /must use the wire value 'FIRST'/
+  );
+  assert.throws(
+    () =>
+      parseTypeScriptOperations(`
+        export enum OP {
+          /** @frontendOnly */
+          LOCAL = 'LOCAL'
+        }
+      `),
+    /must explain why it is @frontendOnly/
+  );
+});
+
+test('compares Java and TypeScript operation sets without requiring the same 
order', () => {
+  const javaOperations = new Set(['FIRST', 'SECOND']);
+  const typeScriptOperations = parseTypeScriptOperations(`
+    export enum OP {
+      SECOND = 'SECOND',
+      FIRST = 'FIRST',
+      /** @frontendOnly Emitted locally without using the websocket. */
+      LOCAL = 'LOCAL'
+    }
+  `);
+  const dataTypeMaps = `
+    interface MessageSendDataTypeMap { [OP.FIRST]: undefined; }
+    interface MessageReceiveDataTypeMap { [OP.SECOND]: undefined; [OP.LOCAL]: 
undefined; }
+  `;
+  const sendOperations = parseDataTypeMapOperations(dataTypeMaps, 
'MessageSendDataTypeMap');
+  const receiveOperations = parseDataTypeMapOperations(dataTypeMaps, 
'MessageReceiveDataTypeMap');
+
+  assert.doesNotThrow(() => validateContract(javaOperations, 
typeScriptOperations, sendOperations, receiveOperations));
+});
+
+test('reports operation drift and rejects frontend-only send operations', () 
=> {
+  const javaOperations = new Set(['FIRST', 'MISSING']);
+  const typeScriptOperations = parseTypeScriptOperations(`
+    export enum OP {
+      FIRST = 'FIRST',
+      EXTRA = 'EXTRA',
+      /** @frontendOnly Emitted locally without using the websocket. */
+      LOCAL = 'LOCAL'
+    }
+  `);
+
+  assert.throws(
+    () => validateContract(javaOperations, typeScriptOperations, new Set(), 
new Set(['LOCAL'])),
+    /missing from TypeScript=\[MISSING\], extra in TypeScript=\[EXTRA\]/
+  );
+
+  const matchingTypeScriptOperations = parseTypeScriptOperations(`
+    export enum OP {
+      FIRST = 'FIRST',
+      MISSING = 'MISSING',
+      /** @frontendOnly Emitted locally without using the websocket. */
+      LOCAL = 'LOCAL'
+    }
+  `);
+  assert.throws(
+    () => validateContract(javaOperations, matchingTypeScriptOperations, new 
Set(['LOCAL']), new Set(['LOCAL'])),
+    /Frontend-only operation LOCAL cannot be in MessageSendDataTypeMap/
+  );
+});

Reply via email to