From 86ed4589ccb28f1909b4e5592c62145713bdbd80 Mon Sep 17 00:00:00 2001 From: namucy Date: Wed, 12 Aug 2026 07:04:43 +0900 Subject: [PATCH] [ZEPPELIN-6456] Share websocket operation contract --- .github/workflows/frontend.yml | 4 + .../org/apache/zeppelin/common/Message.java | 12 +- .../zeppelin/common/MessageContractTest.java | 59 ++++ zeppelin-web-angular/package.json | 1 + .../message-data-type-map.interface.ts | 1 - .../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(-) create mode 100644 zeppelin-common/src/test/java/org/apache/zeppelin/common/MessageContractTest.java create mode 100644 zeppelin-web-angular/scripts/check-websocket-contract.js create mode 100644 zeppelin-web-angular/scripts/check-websocket-contract.test.js diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 1998142a41d..9e59901692f 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 6ec66e63dbd..4ff37e33de3 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.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. + * + *

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 enum OP { 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 enum OP { 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 00000000000..bca7b1d5fb6 --- /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 756dab31275..1fbd5153ed0 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,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,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 6c6088c73ae..4e1d01bad2a 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 @@ -169,7 +169,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 1f8036b3931..7fe9feffa90 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 00000000000..28096fb6b49 --- /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 00000000000..94a7ba896ab --- /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/ + ); +});