Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/extensions/scratch3_mesh_v2/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class Scratch3MeshV2Blocks {
this.domain = getDomainFromUrl();
this.nodeId = uuidv4().replaceAll('-', '');
this.connectionState = 'disconnected';
this.isExplicitDisconnect = false;

try {
createClient();
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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
Expand All @@ -289,6 +308,7 @@ class Scratch3MeshV2Blocks {
/* istanbul ignore next */
disconnect () {
if (this.meshService) {
this.isExplicitDisconnect = true;
this.setConnectionState('disconnected');
this.meshService.leaveGroup();
}
Expand Down
97 changes: 91 additions & 6 deletions test/unit/extension_mesh_v2.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
};
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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
Expand All @@ -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 = [];
Expand All @@ -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();
});
Expand Down
22 changes: 18 additions & 4 deletions test/unit/extension_mesh_v2_issue66.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
};
Expand Down Expand Up @@ -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();
});
Expand All @@ -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();
});

Expand Down
Loading