From 79e1a0f1d34c540eca33bf00aa45552ea550e370 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 5 Jan 2026 01:43:30 +0900 Subject: [PATCH 1/3] feat: meshV2: Create broadcast message locally if it doesn't exist when received from mesh - Automatically create broadcast message on the stage when received via meshV2 if it doesn't exist locally. - Ensures the message is selectable in block dropdowns by calling this.runtime.requestBlocksUpdate(). - Uses the newly created or existing broadcast message ID when triggering the broadcast opcode. - Added unit test to verify automatic creation and opcode argument handling. - Updated integration test mock to support new message creation logic. Fixes https://github.com/smalruby/scratch-vm/issues/84 Co-Authored-By: Gemini --- .../scratch3_mesh_v2/mesh-service.js | 11 +- .../mesh_service_v2_broadcast_creation.js | 111 ++++++++++++++++++ test/unit/mesh_service_v2_integration.js | 7 +- 3 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 test/unit/mesh_service_v2_broadcast_creation.js diff --git a/src/extensions/scratch3_mesh_v2/mesh-service.js b/src/extensions/scratch3_mesh_v2/mesh-service.js index fec9e7a8ad..15d2dc71be 100644 --- a/src/extensions/scratch3_mesh_v2/mesh-service.js +++ b/src/extensions/scratch3_mesh_v2/mesh-service.js @@ -615,9 +615,18 @@ class MeshV2Service { broadcastEvent (event) { log.info(`Mesh V2: Executing broadcastEvent for: ${event.name}`); try { + const stage = this.runtime.getTargetForStage(); + let broadcastVar = stage.lookupBroadcastMsg(null, event.name); + if (!broadcastVar) { + log.info(`Mesh V2: Creating missing broadcast message: ${event.name}`); + stage.createVariable(null, event.name, Variable.BROADCAST_MESSAGE_TYPE); + broadcastVar = stage.lookupBroadcastMsg(null, event.name); + this.runtime.requestBlocksUpdate(); + } + const args = { BROADCAST_OPTION: { - id: null, + id: broadcastVar ? broadcastVar.id : null, name: event.name } }; diff --git a/test/unit/mesh_service_v2_broadcast_creation.js b/test/unit/mesh_service_v2_broadcast_creation.js new file mode 100644 index 0000000000..08999f0e61 --- /dev/null +++ b/test/unit/mesh_service_v2_broadcast_creation.js @@ -0,0 +1,111 @@ +const test = require('tap').test; +const MeshV2Service = require('../../src/extensions/scratch3_mesh_v2/mesh-service'); +const BlockUtility = require('../../src/engine/block-utility'); +const Variable = require('../../src/engine/variable'); + +const createMockBlocks = stage => ({ + runtime: { + sequencer: {}, + emit: () => {}, + on: () => {}, + off: () => {}, + getTargetForStage: () => stage, + requestBlocksUpdate: () => {} + }, + opcodeFunctions: { + event_broadcast: () => {} + } +}); + +const createMockStage = () => ({ + variables: {}, + lookupBroadcastMsg: function (id, name) { + for (const varId in this.variables) { + const v = this.variables[varId]; + if (v.type === Variable.BROADCAST_MESSAGE_TYPE && v.name === name) { + return v; + } + } + return null; + }, + createVariable: function (id, name, type) { + const varId = id || `id-${name}`; + this.variables[varId] = new Variable(varId, name, type); + } +}); + +// Mock BlockUtility.lastInstance() +const originalLastInstance = BlockUtility.lastInstance; +const mockUtil = { + sequencer: {} +}; +BlockUtility.lastInstance = () => mockUtil; + +test('MeshV2Service Broadcast Creation', t => { + t.test('broadcastEvent creates missing broadcast message', st => { + const stage = createMockStage(); + const blocks = createMockBlocks(stage); + let broadcastArgs = null; + blocks.opcodeFunctions.event_broadcast = args => { + broadcastArgs = args; + }; + + const service = new MeshV2Service(blocks, 'node1', 'domain1'); + service.groupId = 'group1'; + + const event = { + name: 'new message', + firedByNodeId: 'node2' + }; + + service.broadcastEvent(event); + + // Check if message was created + const broadcastVar = stage.lookupBroadcastMsg(null, 'new message'); + st.ok(broadcastVar, 'Broadcast message should be created'); + st.equal(broadcastVar.name, 'new message'); + st.equal(broadcastVar.type, Variable.BROADCAST_MESSAGE_TYPE); + + // Check if opcode was called with the new ID + st.ok(broadcastArgs, 'event_broadcast should be called'); + st.equal(broadcastArgs.BROADCAST_OPTION.name, 'new message'); + st.equal(broadcastArgs.BROADCAST_OPTION.id, broadcastVar.id); + + st.end(); + }); + + t.test('broadcastEvent uses existing broadcast message', st => { + const stage = createMockStage(); + const existingVar = new Variable('existing-id', 'existing message', Variable.BROADCAST_MESSAGE_TYPE); + stage.variables['existing-id'] = existingVar; + + const blocks = createMockBlocks(stage); + let broadcastArgs = null; + blocks.opcodeFunctions.event_broadcast = args => { + broadcastArgs = args; + }; + + const service = new MeshV2Service(blocks, 'node1', 'domain1'); + service.groupId = 'group1'; + + const event = { + name: 'existing message', + firedByNodeId: 'node2' + }; + + service.broadcastEvent(event); + + // Check if opcode was called with the existing ID + st.ok(broadcastArgs, 'event_broadcast should be called'); + st.equal(broadcastArgs.BROADCAST_OPTION.name, 'existing message'); + st.equal(broadcastArgs.BROADCAST_OPTION.id, 'existing-id'); + + st.end(); + }); + + t.tearDown(() => { + BlockUtility.lastInstance = originalLastInstance; + }); + + t.end(); +}); diff --git a/test/unit/mesh_service_v2_integration.js b/test/unit/mesh_service_v2_integration.js index 889a5ff167..d85be64e3f 100644 --- a/test/unit/mesh_service_v2_integration.js +++ b/test/unit/mesh_service_v2_integration.js @@ -12,7 +12,12 @@ const createMockBlocks = broadcastCallback => ({ sequencer: {}, emit: () => {}, on: () => {}, - off: () => {} + off: () => {}, + getTargetForStage: () => ({ + lookupBroadcastMsg: (id, name) => ({id: `id-${name}`, name: name}), + createVariable: () => {} + }), + requestBlocksUpdate: () => {} }, opcodeFunctions: { event_broadcast: args => { From 4ceca9add9ba4d3121e23855ab5c65effabd6e36 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 5 Jan 2026 02:01:04 +0900 Subject: [PATCH 2/3] fix: meshV2: Prevent deletion of unreferenced persistent messages - Added isPersistent property to Variable class. - Modified emitWorkspaceUpdate in virtual-machine.js to skip deletion of broadcast messages marked as persistent. - Set isPersistent = true for messages automatically created from meshV2. - Updated unit test to verify persistence. This ensures messages received from mesh remain selectable in block dropdowns even if they aren't currently used by any block. Fixes https://github.com/smalruby/scratch-vm/issues/84 Co-Authored-By: Gemini --- src/engine/variable.js | 1 + src/extensions/scratch3_mesh_v2/mesh-service.js | 3 +++ src/virtual-machine.js | 3 +++ test/unit/mesh_service_v2_broadcast_creation.js | 1 + 4 files changed, 8 insertions(+) diff --git a/src/engine/variable.js b/src/engine/variable.js index 7617f9551a..a1d5e63cc2 100644 --- a/src/engine/variable.js +++ b/src/engine/variable.js @@ -19,6 +19,7 @@ class Variable { this.name = name; this.type = type; this.isCloud = isCloud; + this.isPersistent = false; switch (this.type) { case Variable.SCALAR_TYPE: this.value = 0; diff --git a/src/extensions/scratch3_mesh_v2/mesh-service.js b/src/extensions/scratch3_mesh_v2/mesh-service.js index 15d2dc71be..849c98e171 100644 --- a/src/extensions/scratch3_mesh_v2/mesh-service.js +++ b/src/extensions/scratch3_mesh_v2/mesh-service.js @@ -621,6 +621,9 @@ class MeshV2Service { log.info(`Mesh V2: Creating missing broadcast message: ${event.name}`); stage.createVariable(null, event.name, Variable.BROADCAST_MESSAGE_TYPE); broadcastVar = stage.lookupBroadcastMsg(null, event.name); + if (broadcastVar) { + broadcastVar.isPersistent = true; + } this.runtime.requestBlocksUpdate(); } diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 0bbc818202..53def47af3 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -1400,6 +1400,9 @@ class VirtualMachine extends EventEmitter { // Anything left in messageIds is not referenced by a block, so delete it. for (let i = 0; i < messageIds.length; i++) { const id = messageIds[i]; + if (this.runtime.getTargetForStage().variables[id].isPersistent) { + continue; + } delete this.runtime.getTargetForStage().variables[id]; } const globalVarMap = Object.assign({}, this.runtime.getTargetForStage().variables); diff --git a/test/unit/mesh_service_v2_broadcast_creation.js b/test/unit/mesh_service_v2_broadcast_creation.js index 08999f0e61..8defd964b7 100644 --- a/test/unit/mesh_service_v2_broadcast_creation.js +++ b/test/unit/mesh_service_v2_broadcast_creation.js @@ -65,6 +65,7 @@ test('MeshV2Service Broadcast Creation', t => { st.ok(broadcastVar, 'Broadcast message should be created'); st.equal(broadcastVar.name, 'new message'); st.equal(broadcastVar.type, Variable.BROADCAST_MESSAGE_TYPE); + st.equal(broadcastVar.isPersistent, true, 'Broadcast message should be persistent'); // Check if opcode was called with the new ID st.ok(broadcastArgs, 'event_broadcast should be called'); From f749f68102d3480f6feb24fff1e14c8a853c8849 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 5 Jan 2026 02:18:56 +0900 Subject: [PATCH 3/3] fix: meshV2: Use case-insensitive lookup and canonical name for broadcasts - Switched from lookupBroadcastMsg to lookupBroadcastByInputValue for initial message lookup to ensure case-insensitivity. - Use the canonical name from the found/created variable when triggering event_broadcast. - Updated mock objects in unit tests. This prevents duplicate broadcast messages and ensures that event handlers are correctly triggered even when message names have different casing. Fixes duplicate message issue and event triggering failure. Co-Authored-By: Gemini --- .../scratch3_mesh_v2/mesh-service.js | 6 ++--- .../mesh_service_v2_broadcast_creation.js | 22 ++++++++++++++----- test/unit/mesh_service_v2_integration.js | 1 + 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/extensions/scratch3_mesh_v2/mesh-service.js b/src/extensions/scratch3_mesh_v2/mesh-service.js index 849c98e171..95b98fc638 100644 --- a/src/extensions/scratch3_mesh_v2/mesh-service.js +++ b/src/extensions/scratch3_mesh_v2/mesh-service.js @@ -616,11 +616,11 @@ class MeshV2Service { log.info(`Mesh V2: Executing broadcastEvent for: ${event.name}`); try { const stage = this.runtime.getTargetForStage(); - let broadcastVar = stage.lookupBroadcastMsg(null, event.name); + let broadcastVar = stage.lookupBroadcastByInputValue(event.name); if (!broadcastVar) { log.info(`Mesh V2: Creating missing broadcast message: ${event.name}`); stage.createVariable(null, event.name, Variable.BROADCAST_MESSAGE_TYPE); - broadcastVar = stage.lookupBroadcastMsg(null, event.name); + broadcastVar = stage.lookupBroadcastByInputValue(event.name); if (broadcastVar) { broadcastVar.isPersistent = true; } @@ -630,7 +630,7 @@ class MeshV2Service { const args = { BROADCAST_OPTION: { id: broadcastVar ? broadcastVar.id : null, - name: event.name + name: broadcastVar ? broadcastVar.name : event.name } }; const util = BlockUtility.lastInstance(); diff --git a/test/unit/mesh_service_v2_broadcast_creation.js b/test/unit/mesh_service_v2_broadcast_creation.js index 8defd964b7..2a793f948f 100644 --- a/test/unit/mesh_service_v2_broadcast_creation.js +++ b/test/unit/mesh_service_v2_broadcast_creation.js @@ -28,6 +28,15 @@ const createMockStage = () => ({ } return null; }, + lookupBroadcastByInputValue: function (name) { + for (const varId in this.variables) { + const v = this.variables[varId]; + if (v.type === Variable.BROADCAST_MESSAGE_TYPE && v.name.toLowerCase() === name.toLowerCase()) { + return v; + } + } + return null; + }, createVariable: function (id, name, type) { const varId = id || `id-${name}`; this.variables[varId] = new Variable(varId, name, type); @@ -61,7 +70,7 @@ test('MeshV2Service Broadcast Creation', t => { service.broadcastEvent(event); // Check if message was created - const broadcastVar = stage.lookupBroadcastMsg(null, 'new message'); + const broadcastVar = stage.lookupBroadcastByInputValue('new message'); st.ok(broadcastVar, 'Broadcast message should be created'); st.equal(broadcastVar.name, 'new message'); st.equal(broadcastVar.type, Variable.BROADCAST_MESSAGE_TYPE); @@ -75,9 +84,9 @@ test('MeshV2Service Broadcast Creation', t => { st.end(); }); - t.test('broadcastEvent uses existing broadcast message', st => { + t.test('broadcastEvent uses existing broadcast message (case-insensitive)', st => { const stage = createMockStage(); - const existingVar = new Variable('existing-id', 'existing message', Variable.BROADCAST_MESSAGE_TYPE); + const existingVar = new Variable('existing-id', 'Existing Message', Variable.BROADCAST_MESSAGE_TYPE); stage.variables['existing-id'] = existingVar; const blocks = createMockBlocks(stage); @@ -96,10 +105,11 @@ test('MeshV2Service Broadcast Creation', t => { service.broadcastEvent(event); - // Check if opcode was called with the existing ID + // Check if opcode was called with the existing ID and canonical name st.ok(broadcastArgs, 'event_broadcast should be called'); - st.equal(broadcastArgs.BROADCAST_OPTION.name, 'existing message'); - st.equal(broadcastArgs.BROADCAST_OPTION.id, 'existing-id'); + st.equal(broadcastArgs.BROADCAST_OPTION.id, 'existing-id', 'Should use existing variable ID'); + st.equal(broadcastArgs.BROADCAST_OPTION.name, 'Existing Message', 'Should use canonical name'); + st.equal(Object.keys(stage.variables).length, 1, 'Should not create duplicate variable'); st.end(); }); diff --git a/test/unit/mesh_service_v2_integration.js b/test/unit/mesh_service_v2_integration.js index d85be64e3f..7e4c8715e7 100644 --- a/test/unit/mesh_service_v2_integration.js +++ b/test/unit/mesh_service_v2_integration.js @@ -15,6 +15,7 @@ const createMockBlocks = broadcastCallback => ({ off: () => {}, getTargetForStage: () => ({ lookupBroadcastMsg: (id, name) => ({id: `id-${name}`, name: name}), + lookupBroadcastByInputValue: name => ({id: `id-${name}`, name: name}), createVariable: () => {} }), requestBlocksUpdate: () => {}