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
3 changes: 3 additions & 0 deletions packages/scratch-gui/test/unit/setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {TextEncoder, TextDecoder} from 'util';
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
15 changes: 15 additions & 0 deletions packages/scratch-vm/.format-message-lint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"format-message/literal-pattern": 0,
"format-message/literal-locale": 0,
"format-message/no-identical-translation": 1,
"format-message/no-invalid-pattern": 2,
"format-message/no-invalid-translation": 2,
"format-message/no-missing-params": [2, { "allowNonLiteral": true }],
"format-message/no-missing-translation": 1,
"format-message/no-top-scope": 0,
"format-message/translation-match-params": 2,
"format-message/no-empty-jsx-message": 1,
"format-message/no-invalid-translate-attribute": 1,
"format-message/no-invalid-plural-keyword": 1,
"format-message/no-missing-plural-keyword": 0
}
2 changes: 1 addition & 1 deletion packages/scratch-vm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"docs": "jsdoc -c .jsdoc.json",
"i18n:src": "mkdirp translations/core && format-message extract --out-file translations/core/en.json src/extensions/**/index.js",
"i18n:push": "tx-push-src scratch-editor extensions translations/core/en.json",
"lint": "eslint && format-message lint src/**/*.js",
"lint": "eslint . && format-message lint -e customrules -c .format-message-lint.json src/**/*.js",
"prepublish": "in-publish && npm run build || not-in-publish",
"start": "webpack serve",
"tap": "tap ./test/{unit,integration}/*.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const blockIconURI = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABQCAYA
* The url of the translate server.
* @type {string}
*/
const serverURL = 'https://translate-service.scratch.mit.edu/';
const serverURL = 'https://api.smalruby.app/scratch-api-proxy/';

/**
* How long to wait in ms before timing out requests to translate server.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
const test = require('tap').test;
const MeshV2Service = require('../../../src/extensions/scratch3_mesh_v2/mesh-service');
const {REPORT_DATA} = require('../../../src/extensions/scratch3_mesh_v2/gql-operations');

// Mock MeshClient
const mockClient = {
mutate: null,
subscribe: () => ({
subscribe: () => ({
unsubscribe: () => {}
})
})
};

require('../../../src/extensions/scratch3_mesh_v2/mesh-client').getClient = () => mockClient;

test('MeshV2Service Data Merge Integration', async t => {
let mutateCount = 0;
const mutations = [];

// Custom mock mutate to track calls
mockClient.mutate = async ({mutation, variables}) => {
if (mutation === REPORT_DATA) {
mutateCount++;
mutations.push(JSON.parse(JSON.stringify(variables.data)));
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 50));
}
return {data: {}};
};

const service = new MeshV2Service({
runtime: {
on: () => {},
off: () => {}
}
}, 'node1', 'domain1');

service.groupId = 'group1';
service.client = mockClient;

// Send 1st: starts processing immediately
service.sendData([{key: 'v1', value: 1}]);

// Send 2nd, 3rd, 4th: should be merged into ONE call
service.sendData([{key: 'v1', value: 2}]);
service.sendData([{key: 'v1', value: 3}]);
service.sendData([{key: 'v1', value: 4}]);

await service.dataRateLimiter.waitForCompletion();

t.equal(mutateCount, 2, 'Should only result in 2 API calls (1st + merged 2nd/3rd/4th)');
t.same(mutations[0], [{key: 'v1', value: 1}]);
t.same(mutations[1], [{key: 'v1', value: 4}]);

t.end();
});

test('MeshV2Service Multiple Variables Merge Integration', async t => {
let mutateCount = 0;
const mutations = [];

mockClient.mutate = async ({mutation, variables}) => {
if (mutation === REPORT_DATA) {
mutateCount++;
mutations.push(JSON.parse(JSON.stringify(variables.data)));
await new Promise(resolve => setTimeout(resolve, 50));
}
return {data: {}};
};

const service = new MeshV2Service({
runtime: {
on: () => {},
off: () => {}
}
}, 'node1', 'domain1');
service.groupId = 'group1';
service.client = mockClient;

// First call: starts immediately
service.sendData([{key: 'v1', value: 1}]);

// Subsequent calls: should be merged
service.sendData([{key: 'v1', value: 2}]);
service.sendData([{key: 'v2', value: 10}]);
service.sendData([{key: 'v1', value: 3}]);
service.sendData([{key: 'v2', value: 20}]);

await service.dataRateLimiter.waitForCompletion();

t.equal(mutateCount, 2, 'Should result in 2 API calls');
t.same(mutations[0], [{key: 'v1', value: 1}]);

// The merged payload should contain the latest value for each key.
// Order might depend on implementation, but values must be latest.
const lastMutation = mutations[1];
t.equal(lastMutation.length, 2, 'Merged payload should have 2 unique keys');

const v1Item = lastMutation.find(i => i.key === 'v1');
const v2Item = lastMutation.find(i => i.key === 'v2');

t.equal(v1Item.value, 3, 'v1 should have the latest value');
t.equal(v2Item.value, 20, 'v2 should have the latest value');

t.end();
});

test('MeshV2Service Data Unchanged Detection', async t => {

let mutateCount = 0;

mockClient.mutate = () => {

mutateCount++;

return Promise.resolve({data: {}});

};


const service = new MeshV2Service({
runtime: {
on: () => {},
off: () => {}
}
}, 'node1', 'domain1');
service.groupId = 'group1';
service.client = mockClient;

// First send
await service.sendData([{key: 'v1', value: 100}]);
t.equal(mutateCount, 1);

// Send same data again
await service.sendData([{key: 'v1', value: 100}]);
t.equal(mutateCount, 1, 'Should NOT send if data is unchanged');

// Send changed data
await service.sendData([{key: 'v1', value: 101}]);
t.equal(mutateCount, 2, 'Should send if data is changed');

t.end();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
const test = require('tap').test;
const MeshV2Service = require('../../../src/extensions/scratch3_mesh_v2/mesh-service');
const {
REPORT_DATA,
CREATE_GROUP,
JOIN_GROUP,
LIST_GROUP_STATUSES
} = require('../../../src/extensions/scratch3_mesh_v2/gql-operations');
const Variable = require('../../../src/engine/variable');

// Mock MeshClient
const mockClient = {
mutate: null,
query: null,
subscribe: () => ({
subscribe: () => ({
unsubscribe: () => {}
})
})
};

const createMockBlocks = () => ({
runtime: {
getTargetForStage: () => ({
variables: {
'var1-id': {
name: 'var1',
type: Variable.SCALAR_TYPE,
value: 10
},
'var2-id': {
name: 'var2',
type: Variable.SCALAR_TYPE,
value: 'hello'
}
}
}),
on: () => {},
off: () => {}
}
});

test('MeshV2Service Variable Sync Integration', async t => {
let reportDataPayload = null;

mockClient.mutate = ({mutation, variables}) => {
if (mutation === CREATE_GROUP) {
return Promise.resolve({
data: {
createGroup: {
id: 'group1',
name: variables.name,
domain: variables.domain,
expiresAt: '2025-12-30T12:00:00Z'
}
}
});
}
if (mutation === REPORT_DATA) {
reportDataPayload = variables.data;
}
return Promise.resolve({data: {}});
};

mockClient.query = () => Promise.resolve({data: {listGroupStatuses: []}});

const blocks = createMockBlocks();
const service = new MeshV2Service(blocks, 'node1', 'domain1');
service.client = mockClient;

// Test createGroup
await service.createGroup('my-group');

// Need to wait for RateLimiter to process the queue
await service.dataRateLimiter.waitForCompletion();

t.ok(reportDataPayload, 'REPORT_DATA should be called');
t.equal(reportDataPayload.length, 2);
t.deepEqual(reportDataPayload.find(v => v.key === 'var1'), {key: 'var1', value: '10'});
t.deepEqual(reportDataPayload.find(v => v.key === 'var2'), {key: 'var2', value: 'hello'});

// Cleanup for next test
reportDataPayload = null;
service.cleanup();

// Test joinGroup with a NEW service instance
const service2 = new MeshV2Service(blocks, 'node2', 'domain1');
service2.client = mockClient;

mockClient.mutate = ({mutation, variables}) => {
if (mutation === JOIN_GROUP) {
return Promise.resolve({
data: {
joinGroup: {
domain: variables.domain,
heartbeatIntervalSeconds: 60
}
}
});
}
if (mutation === REPORT_DATA) {
reportDataPayload = variables.data;
}
return Promise.resolve({data: {}});
};

await service2.joinGroup('group2', 'domain1', 'groupName');
await service2.dataRateLimiter.waitForCompletion();

t.ok(reportDataPayload, 'REPORT_DATA should be called on joinGroup');
t.equal(reportDataPayload.length, 2);

service2.cleanup();

t.end();
});

test('MeshV2Service fetch existing nodes data on joinGroup', async t => {
const blocks = {
runtime: {
getTargetForStage: () => ({variables: {}}),
on: () => {},
off: () => {}
}
};

mockClient.mutate = ({mutation}) => {
if (mutation === JOIN_GROUP) {
return Promise.resolve({
data: {
joinGroup: {
domain: 'domain1',
heartbeatIntervalSeconds: 60
}
}
});
}
return Promise.resolve({data: {}});
};

mockClient.query = ({query, variables}) => {
if (query === LIST_GROUP_STATUSES) {
return Promise.resolve({
data: {
listGroupStatuses: [
{
nodeId: 'host-node',
groupId: variables.groupId,
domain: variables.domain,
data: [
{key: 'hostVar', value: '100'}
],
timestamp: '2025-12-30T12:00:00Z'
}
]
}
});
}
return Promise.resolve({data: {}});
};

const service = new MeshV2Service(blocks, 'member-node', 'domain1');
service.client = mockClient;

await service.joinGroup('group1', 'domain1', 'groupName');

t.ok(service.remoteData['host-node'], 'Should have data from host-node');
t.equal(service.remoteData['host-node'].hostVar.value, '100', 'Should have correct variable value from host');

service.cleanup();
t.end();
});
Loading