diff --git a/src/extensions/scratch3_mesh_v2/index.js b/src/extensions/scratch3_mesh_v2/index.js index 5f02c718ad..b4a730448c 100644 --- a/src/extensions/scratch3_mesh_v2/index.js +++ b/src/extensions/scratch3_mesh_v2/index.js @@ -55,6 +55,7 @@ class Scratch3MeshV2Blocks { this.domain = getDomainFromUrl(); this.nodeId = uuidv4().replaceAll('-', ''); this.connectionState = 'disconnected'; + this.isExplicitDisconnect = false; try { createClient(); @@ -261,6 +262,10 @@ class Scratch3MeshV2Blocks { log.info(`Mesh V2: Connection state transition: ${prevState} -> ${state}`); this.connectionState = state; + if (state !== 'disconnected') { + this.isExplicitDisconnect = false; + } + switch (state) { case 'connected': this.runtime.emit(this.runtime.constructor.PERIPHERAL_CONNECTED); @@ -270,6 +275,15 @@ class Scratch3MeshV2Blocks { this.runtime.emit(this.runtime.constructor.PERIPHERAL_REQUEST_ERROR, { extensionId: Scratch3MeshV2Blocks.EXTENSION_ID }); + if (prevState === 'connected' && !this.isExplicitDisconnect) { + this.runtime.emit(this.runtime.constructor.PERIPHERAL_CONNECTION_LOST_ERROR, { + extensionId: Scratch3MeshV2Blocks.EXTENSION_ID + }); + } + // Always emit disconnected to ensure GUI (Blocks.jsx) refreshes its status icon + this.runtime.emit(this.runtime.constructor.PERIPHERAL_DISCONNECTED, { + extensionId: Scratch3MeshV2Blocks.EXTENSION_ID + }); break; case 'disconnected': // Emit error event if we were connecting @@ -278,6 +292,11 @@ class Scratch3MeshV2Blocks { extensionId: Scratch3MeshV2Blocks.EXTENSION_ID }); } + if (prevState === 'connected' && !this.isExplicitDisconnect) { + this.runtime.emit(this.runtime.constructor.PERIPHERAL_CONNECTION_LOST_ERROR, { + extensionId: Scratch3MeshV2Blocks.EXTENSION_ID + }); + } // Always emit disconnected event this.runtime.emit(this.runtime.constructor.PERIPHERAL_DISCONNECTED, { extensionId: Scratch3MeshV2Blocks.EXTENSION_ID @@ -289,6 +308,7 @@ class Scratch3MeshV2Blocks { /* istanbul ignore next */ disconnect () { if (this.meshService) { + this.isExplicitDisconnect = true; this.setConnectionState('disconnected'); this.meshService.leaveGroup(); } diff --git a/test/unit/extension_mesh_v2.js b/test/unit/extension_mesh_v2.js index fb8ca98a9b..5c021fc988 100644 --- a/test/unit/extension_mesh_v2.js +++ b/test/unit/extension_mesh_v2.js @@ -27,6 +27,7 @@ const createMockRuntime = () => { PERIPHERAL_CONNECTED: 'PERIPHERAL_CONNECTED', PERIPHERAL_DISCONNECTED: 'PERIPHERAL_DISCONNECTED', PERIPHERAL_CONNECTION_ERROR_ID: 'PERIPHERAL_CONNECTION_ERROR_ID', + PERIPHERAL_CONNECTION_LOST_ERROR: 'PERIPHERAL_CONNECTION_LOST_ERROR', PERIPHERAL_REQUEST_ERROR: 'PERIPHERAL_REQUEST_ERROR' } }; @@ -167,7 +168,7 @@ test('Mesh V2 Blocks', t => { blocks.connect('meshV2_host'); setImmediate(() => { - st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_REQUEST_ERROR'); + st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_DISCONNECTED'); st.deepEqual(mockRuntime.lastEmittedData, {extensionId: 'meshV2'}); st.equal(blocks.connectionState, 'error'); st.end(); @@ -185,7 +186,7 @@ test('Mesh V2 Blocks', t => { blocks.connect('group1'); setImmediate(() => { - st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_REQUEST_ERROR'); + st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_DISCONNECTED'); st.deepEqual(mockRuntime.lastEmittedData, {extensionId: 'meshV2'}); st.equal(blocks.connectionState, 'error'); st.end(); @@ -202,7 +203,8 @@ test('Mesh V2 Blocks', t => { // Test error state transition blocks.setConnectionState('error'); st.equal(blocks.connectionState, 'error'); - st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_REQUEST_ERROR'); + // Now it emits both PERIPHERAL_REQUEST_ERROR and PERIPHERAL_DISCONNECTED + st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_DISCONNECTED'); st.deepEqual(mockRuntime.lastEmittedData, {extensionId: 'meshV2'}); // Test connected state transition @@ -218,7 +220,7 @@ test('Mesh V2 Blocks', t => { st.end(); }); - t.test('connection state: error does not emit PERIPHERAL_DISCONNECTED', st => { + t.test('connection state: error emits PERIPHERAL_DISCONNECTED', st => { const mockRuntime = createMockRuntime(); const blocks = new MeshV2Blocks(mockRuntime); const events = []; @@ -233,10 +235,93 @@ test('Mesh V2 Blocks', t => { // Transition to error state blocks.setConnectionState('error'); - // Verify only PERIPHERAL_REQUEST_ERROR was emitted - st.equal(events.length, 1); + // Verify both PERIPHERAL_REQUEST_ERROR and PERIPHERAL_DISCONNECTED were emitted + st.equal(events.length, 2); st.equal(events[0].event, 'PERIPHERAL_REQUEST_ERROR'); + st.equal(events[1].event, 'PERIPHERAL_DISCONNECTED'); st.deepEqual(events[0].data, {extensionId: 'meshV2'}); + st.deepEqual(events[1].data, {extensionId: 'meshV2'}); + + st.end(); + }); + + t.test('connection state: connected to error emits PERIPHERAL_CONNECTION_LOST_ERROR', st => { + const mockRuntime = createMockRuntime(); + const blocks = new MeshV2Blocks(mockRuntime); + const events = []; + + // Track all emitted events + const originalEmit = mockRuntime.emit; + mockRuntime.emit = (event, data) => { + events.push({event, data}); + return originalEmit(event, data); + }; + + // Transition to connected state first + blocks.setConnectionState('connected'); + events.length = 0; // Clear events + + // Transition to error state + blocks.setConnectionState('error'); + + // Verify PERIPHERAL_REQUEST_ERROR, PERIPHERAL_CONNECTION_LOST_ERROR, and PERIPHERAL_DISCONNECTED were emitted + st.equal(events.length, 3); + st.equal(events[0].event, 'PERIPHERAL_REQUEST_ERROR'); + st.equal(events[1].event, 'PERIPHERAL_CONNECTION_LOST_ERROR'); + st.equal(events[2].event, 'PERIPHERAL_DISCONNECTED'); + + st.end(); + }); + + t.test('connection state: connected to disconnected (unexpected) emits PERIPHERAL_CONNECTION_LOST_ERROR', st => { + const mockRuntime = createMockRuntime(); + const blocks = new MeshV2Blocks(mockRuntime); + const events = []; + + // Track all emitted events + const originalEmit = mockRuntime.emit; + mockRuntime.emit = (event, data) => { + events.push({event, data}); + return originalEmit(event, data); + }; + + // Transition to connected state first + blocks.setConnectionState('connected'); + events.length = 0; // Clear events + + // Transition to disconnected state unexpectedly (e.g. from service callback) + blocks.setConnectionState('disconnected'); + + // Verify PERIPHERAL_CONNECTION_LOST_ERROR and PERIPHERAL_DISCONNECTED were emitted + st.equal(events.length, 2); + st.equal(events[0].event, 'PERIPHERAL_CONNECTION_LOST_ERROR'); + st.equal(events[1].event, 'PERIPHERAL_DISCONNECTED'); + + st.end(); + }); + + t.test('connection state: connected to disconnected (explicit) NO connection lost error', st => { + const mockRuntime = createMockRuntime(); + const blocks = new MeshV2Blocks(mockRuntime); + const events = []; + + // Track all emitted events + const originalEmit = mockRuntime.emit; + mockRuntime.emit = (event, data) => { + events.push({event, data}); + return originalEmit(event, data); + }; + + // Transition to connected state first + blocks.setConnectionState('connected'); + events.length = 0; // Clear events + + // Explicit disconnect + blocks.disconnect(); + + // Verify ONLY PERIPHERAL_DISCONNECTED was emitted + st.equal(events.length, 1); + st.equal(events[0].event, 'PERIPHERAL_DISCONNECTED'); st.end(); }); diff --git a/test/unit/extension_mesh_v2_issue66.js b/test/unit/extension_mesh_v2_issue66.js index 6b6d7a4a8b..8654f4b2bb 100644 --- a/test/unit/extension_mesh_v2_issue66.js +++ b/test/unit/extension_mesh_v2_issue66.js @@ -29,6 +29,7 @@ const createMockRuntime = () => { PERIPHERAL_CONNECTED: 'PERIPHERAL_CONNECTED', PERIPHERAL_DISCONNECTED: 'PERIPHERAL_DISCONNECTED', PERIPHERAL_CONNECTION_ERROR_ID: 'PERIPHERAL_CONNECTION_ERROR_ID', + PERIPHERAL_CONNECTION_LOST_ERROR: 'PERIPHERAL_CONNECTION_LOST_ERROR', PERIPHERAL_REQUEST_ERROR: 'PERIPHERAL_REQUEST_ERROR' } }; @@ -72,7 +73,7 @@ test('Mesh V2 Issue #66: Improved error handling for expired groups', t => { blocks.connect('expired-id'); st.equal(blocks.connectionState, 'error'); - st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_REQUEST_ERROR'); + st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_DISCONNECTED'); st.deepEqual(mockRuntime.lastEmittedData, {extensionId: 'meshV2'}); st.end(); }); @@ -89,23 +90,36 @@ test('Mesh V2 Issue #66: Improved error handling for expired groups', t => { blocks.meshService.disconnectCallback('GroupNotFound'); st.equal(blocks.connectionState, 'error'); - st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_REQUEST_ERROR'); + st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_DISCONNECTED'); st.end(); }); t.test('disconnect when unauthorized', st => { const mockRuntime = createMockRuntime(); const blocks = new MeshV2Blocks(mockRuntime); + const events = []; + + // Track all emitted events + const originalEmit = mockRuntime.emit; + mockRuntime.emit = (event, data) => { + events.push({event, data}); + return originalEmit(event, data); + }; // Simulate being connected - blocks.connectionState = 'connected'; + blocks.setConnectionState('connected'); blocks.meshService.groupId = 'active-group'; + events.length = 0; // Clear events // Trigger disconnect callback with 'Unauthorized' reason blocks.meshService.disconnectCallback('Unauthorized'); st.equal(blocks.connectionState, 'disconnected'); // Only GroupNotFound/expired currently map to error - st.equal(mockRuntime.lastEmittedEvent, 'PERIPHERAL_DISCONNECTED'); + + // Verify PERIPHERAL_CONNECTION_LOST_ERROR and PERIPHERAL_DISCONNECTED were emitted + st.equal(events.length, 2); + st.equal(events[0].event, 'PERIPHERAL_CONNECTION_LOST_ERROR'); + st.equal(events[1].event, 'PERIPHERAL_DISCONNECTED'); st.end(); });