Initial commit of 001code-html Scratch frontend project.

Includes scratch-gui, scratch-vm, scratch-blocks, scratch-render, scratch-l10n, and deployment config.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 15:37:45 +08:00
commit 6e0a1fbcbb
11350 changed files with 965674 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/virtual-machine');
const RenderedTarget = require('../../src/sprites/rendered-target');
const projectUri = path.resolve(__dirname, '../fixtures/default.sb2');
const project = readFileToBuffer(projectUri);
const vm = new VirtualMachine();
test('spec', t => {
t.type(vm.addSprite, 'function');
t.end();
});
test('default cat', t => {
// Get default cat from .sprite2
const uri = path.resolve(__dirname, '../fixtures/example_sprite.sprite2');
const sprite = readFileToBuffer(uri);
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.ok(threads.length === 0);
vm.quit();
t.end();
});
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
t.doesNotThrow(() => {
vm.loadProject(project).then(() => {
t.equal(vm.runtime.targets.length, 2); // stage and default sprite
// Add another sprite
vm.addSprite(sprite).then(() => {
const targets = vm.runtime.targets;
// Test
t.type(targets, 'object');
t.equal(targets.length, 3);
const newTarget = targets[2];
t.ok(newTarget instanceof RenderedTarget);
t.type(newTarget.id, 'string');
t.type(newTarget.blocks, 'object');
t.type(newTarget.variables, 'object');
const varIds = Object.keys(newTarget.variables);
t.type(varIds.length, 1);
const variable = newTarget.variables[varIds[0]];
t.equal(variable.name, 'foo');
t.equal(variable.value, 0);
t.equal(newTarget.isOriginal, true);
t.equal(newTarget.currentCostume, 0);
t.equal(newTarget.isOriginal, true);
t.equal(newTarget.isStage, false);
t.equal(newTarget.sprite.name, 'Apple');
vm.greenFlag();
setTimeout(() => {
t.equal(variable.value, 10);
vm.getPlaygroundData();
vm.stopAll();
}, 1000);
});
});
});
});

View File

@@ -0,0 +1,54 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const projectUri = path.resolve(__dirname, '../fixtures/block-to-workspace-comments.sb2');
const project = readFileToBuffer(projectUri);
test('importing sb2 project where block comment is converted to workspace comment and block is deleted', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.equal(threads.length, 0);
const target = vm.runtime.targets[1];
// Sprite 1 has 3 Comments, 1 block comment and 2 workspace comments (which were
// originally created via a block comment to workspace comment conversion in Scratch 2.0).
const targetComments = Object.values(target.comments);
t.equal(targetComments.length, 3);
const spriteWorkspaceComments = targetComments.filter(comment => comment.blockId === null);
t.equal(spriteWorkspaceComments.length, 2);
// Test the sprite block comments
const blockComments = targetComments.filter(comment => !!comment.blockId);
t.equal(blockComments.length, 1);
// There should not be any comments where blockId is a number
const invalidComments = targetComments.filter(comment => typeof comment.blockId === 'number');
t.equal(invalidComments.length, 0);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 100);
});
});
});

View File

@@ -0,0 +1,59 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const projectUri = path.resolve(__dirname, '../fixtures/block-to-workspace-comments-without-scripts.sb2');
const project = readFileToBuffer(projectUri);
/* eslint-disable-next-line max-len */
test('importing sb2 project where block comment is converted to workspace comment and block is deleted, and there are no scripts on the workspace', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.equal(threads.length, 0);
const target = vm.runtime.targets[1];
// Sprite 1 has 1 comments, a workspace comment which was
// originally created via a block comment to workspace comment conversion in Scratch 2.0.
// What differentiates this test from above is that there are no scripts in this project.
const targetComments = Object.values(target.comments);
t.equal(targetComments.length, 1);
const spriteWorkspaceComments = targetComments.filter(comment => comment.blockId === null);
t.equal(spriteWorkspaceComments.length, 1);
// Test the sprite block comments
const blockComments = targetComments.filter(comment => !!comment.blockId);
t.equal(blockComments.length, 0);
// There should not be any comments where blockId is a number
const invalidComments = targetComments.filter(comment => typeof comment.blockId === 'number');
t.equal(invalidComments.length, 0);
const targetBlocks = Object.values(target.blocks._blocks);
t.equal(targetBlocks.length, 0);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 100);
});
});
});

View File

@@ -0,0 +1,85 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const Variable = require('../../src/engine/variable');
const StringUtil = require('../../src/util/string-util');
const VariableUtil = require('../../src/util/variable-util');
const projectUri = path.resolve(__dirname, '../fixtures/broadcast_special_chars.sb2');
const project = readFileToBuffer(projectUri);
test('importing sb2 project with special chars in message names', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.equal(threads.length, 0);
t.equal(vm.runtime.targets.length, 2);
const stage = vm.runtime.targets[0];
const cat = vm.runtime.targets[1];
const allBroadcastFields = VariableUtil.getAllVarRefsForTargets(vm.runtime.targets, true);
const abMessageId = Object.keys(stage.variables).filter(k => stage.variables[k].name === 'a&b')[0];
const abMessage = stage.variables[abMessageId];
// Check for unsafe characters, replaceUnsafeChars should just result in the original string
// (e.g. there was nothing to replace)
// Check that the message ID does not have any unsafe characters
t.equal(StringUtil.replaceUnsafeChars(abMessageId), abMessageId);
// Check that the message still has the correct info
t.equal(StringUtil.replaceUnsafeChars(abMessage.id), abMessage.id);
t.equal(abMessage.id, abMessageId);
t.equal(abMessage.type, Variable.BROADCAST_MESSAGE_TYPE);
t.equal(abMessage.value, 'a&b');
const ltPerfectMessageId = Object.keys(stage.variables).filter(k => stage.variables[k].name === '< perfect')[0];
const ltPerfectMessage = stage.variables[ltPerfectMessageId];
// Check for unsafe characters, replaceUnsafeChars should just result in the original string
// (e.g. there was nothing to replace)
// Check that the message ID does not have any unsafe characters
t.equal(StringUtil.replaceUnsafeChars(ltPerfectMessageId), ltPerfectMessageId);
// Check that the message still has the correct info
t.equal(StringUtil.replaceUnsafeChars(ltPerfectMessage.id), ltPerfectMessage.id);
t.equal(ltPerfectMessage.id, ltPerfectMessageId);
t.equal(ltPerfectMessage.type, Variable.BROADCAST_MESSAGE_TYPE);
t.equal(ltPerfectMessage.value, '< perfect');
// Find all the references for these messages, and verify they have the correct ID
t.equal(allBroadcastFields[ltPerfectMessageId].length, 1);
t.equal(allBroadcastFields[abMessageId].length, 1);
const catBlocks = Object.keys(cat.blocks._blocks).map(blockId => cat.blocks._blocks[blockId]);
const catMessageBlocks = catBlocks.filter(
block => Object.prototype.hasOwnProperty.call(block.fields, 'BROADCAST_OPTION')
);
t.equal(catMessageBlocks.length, 2);
t.equal(catMessageBlocks[0].fields.BROADCAST_OPTION.id, ltPerfectMessageId);
t.equal(catMessageBlocks[1].fields.BROADCAST_OPTION.id, abMessageId);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 100);
});
});
});

View File

@@ -0,0 +1,85 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const Variable = require('../../src/engine/variable');
const StringUtil = require('../../src/util/string-util');
const VariableUtil = require('../../src/util/variable-util');
const projectUri = path.resolve(__dirname, '../fixtures/broadcast_special_chars.sb3');
const project = readFileToBuffer(projectUri);
test('importing sb3 project with special chars in message names', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.equal(threads.length, 0);
t.equal(vm.runtime.targets.length, 2);
const stage = vm.runtime.targets[0];
const cat = vm.runtime.targets[1];
const allBroadcastFields = VariableUtil.getAllVarRefsForTargets(vm.runtime.targets, true);
const abMessageId = Object.keys(stage.variables).filter(k => stage.variables[k].name === 'a&b')[0];
const abMessage = stage.variables[abMessageId];
// Check for unsafe characters, replaceUnsafeChars should just result in the original string
// (e.g. there was nothing to replace)
// Check that the message ID does not have any unsafe characters
t.equal(StringUtil.replaceUnsafeChars(abMessageId), abMessageId);
// Check that the message still has the correct info
t.equal(StringUtil.replaceUnsafeChars(abMessage.id), abMessage.id);
t.equal(abMessage.id, abMessageId);
t.equal(abMessage.type, Variable.BROADCAST_MESSAGE_TYPE);
t.equal(abMessage.value, 'a&b');
const ltPerfectMessageId = Object.keys(stage.variables).filter(k => stage.variables[k].name === '< perfect')[0];
const ltPerfectMessage = stage.variables[ltPerfectMessageId];
// Check for unsafe characters, replaceUnsafeChars should just result in the original string
// (e.g. there was nothing to replace)
// Check that the message ID does not have any unsafe characters
t.equal(StringUtil.replaceUnsafeChars(ltPerfectMessageId), ltPerfectMessageId);
// Check that the message still has the correct info
t.equal(StringUtil.replaceUnsafeChars(ltPerfectMessage.id), ltPerfectMessage.id);
t.equal(ltPerfectMessage.id, ltPerfectMessageId);
t.equal(ltPerfectMessage.type, Variable.BROADCAST_MESSAGE_TYPE);
t.equal(ltPerfectMessage.value, '< perfect');
// Find all the references for these messages, and verify they have the correct ID
t.equal(allBroadcastFields[ltPerfectMessageId].length, 1);
t.equal(allBroadcastFields[abMessageId].length, 1);
const catBlocks = Object.keys(cat.blocks._blocks).map(blockId => cat.blocks._blocks[blockId]);
const catMessageBlocks = catBlocks.filter(
block => Object.prototype.hasOwnProperty.call(block.fields, 'BROADCAST_OPTION')
);
t.equal(catMessageBlocks.length, 2);
t.equal(catMessageBlocks[0].fields.BROADCAST_OPTION.id, ltPerfectMessageId);
t.equal(catMessageBlocks[1].fields.BROADCAST_OPTION.id, abMessageId);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 100);
});
});
});

View File

@@ -0,0 +1,92 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const projectUri = path.resolve(__dirname, '../fixtures/clone-cleanup.sb2');
const project = readFileToBuffer(projectUri);
test('clone-cleanup', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
/**
* Track which step of the project is currently under test.
* @type {number}
*/
let testStep = -1;
const verifyCounts = (expectedClones, extraThreads) => {
// stage plus one sprite, plus clones
t.strictEqual(vm.runtime.targets.length, 2 + expectedClones,
`target count at step ${testStep}`);
// the stage should never have any clones
t.strictEqual(vm.runtime.targets[0].sprite.clones.length, 1,
`stage clone count at step ${testStep}`);
// check sprite clone count (+1 for original)
t.strictEqual(vm.runtime.targets[1].sprite.clones.length, 1 + expectedClones,
`sprite clone count at step ${testStep}`);
// thread count isn't directly tied to clone count since threads can end
t.strictEqual(vm.runtime.threads.length, extraThreads + (2 * expectedClones),
`thread count at step ${testStep}`);
};
const testNextStep = () => {
++testStep;
switch (testStep) {
case 0:
// Project has started, main thread running, no clones yet
verifyCounts(0, 1);
break;
case 1:
// 10 clones have been created, main thread still running
verifyCounts(10, 1);
break;
case 2:
// The first batch of clones has deleted themselves; main thread still running
verifyCounts(0, 1);
break;
case 3:
// The second batch of clones has been created and the main thread is about to end
verifyCounts(10, 1);
// After the main thread ends, do one last test step
setTimeout(() => testNextStep(), 1000);
break;
case 4:
// The second batch of clones has deleted themselves; everything is finished
verifyCounts(0, 0);
vm.quit();
t.end();
break;
}
};
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
// Verify initial state: no clones, nothing running ("step -1")
verifyCounts(0, 0);
vm.greenFlag();
// Let the project control the pace of the tests
vm.runtime.on('SAY', () => testNextStep());
});
});
});

View File

@@ -0,0 +1,155 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const cloudVarSimpleUri = path.resolve(__dirname, '../fixtures/cloud_variables_simple.sb2');
const cloudVarLimitUri = path.resolve(__dirname, '../fixtures/cloud_variables_limit.sb2');
const cloudVarExceededLimitUri = path.resolve(__dirname, '../fixtures/cloud_variables_exceeded_limit.sb2');
const cloudVarLocalUri = path.resolve(__dirname, '../fixtures/cloud_variables_local.sb2');
const cloudVarSimple = readFileToBuffer(cloudVarSimpleUri);
const cloudVarLimit = readFileToBuffer(cloudVarLimitUri);
const cloudVarExceededLimit = readFileToBuffer(cloudVarExceededLimitUri);
const cloudVarLocal = readFileToBuffer(cloudVarLocalUri);
test('importing an sb2 project with cloud variables', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(cloudVarSimple).then(() => {
t.equal(vm.runtime.hasCloudData(), true);
const stage = vm.runtime.targets[0];
const stageVars = Object.values(stage.variables);
t.equal(stageVars.length, 1);
const variable = stageVars[0];
t.equal(variable.name, '☁ firstCloud');
t.equal(Number(variable.value), 100); // Though scratch 2 requires
// cloud variables to be numbers, this is something that happens
// when the message is being sent to the server rather than on the client
t.equal(variable.isCloud, true);
vm.quit();
t.end();
});
});
test('importing an sb2 project with cloud variables at the limit for a project', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(cloudVarLimit).then(() => {
t.equal(vm.runtime.hasCloudData(), true);
const stage = vm.runtime.targets[0];
const stageVars = Object.values(stage.variables);
t.equal(stageVars.length, 10);
// All of the 8 stage variables should be cloud variables
t.equal(stageVars.filter(v => v.isCloud).length, 10);
vm.quit();
t.end();
});
});
test('importing an sb2 project with cloud variables exceeding the limit for a project', t => {
// This tests a hacked project where additional cloud variables exceeding
// the project limit have been added.
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(cloudVarExceededLimit).then(() => {
t.equal(vm.runtime.hasCloudData(), true);
const stage = vm.runtime.targets[0];
const stageVars = Object.values(stage.variables);
t.equal(stageVars.length, 15);
// Only 8 of the variables should have the isCloud flag set to true
t.equal(stageVars.filter(v => v.isCloud).length, 10);
vm.quit();
t.end();
});
});
test('importing one project after the other resets cloud variable limit', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(cloudVarExceededLimit).then(() => {
t.equal(vm.runtime.canAddCloudVariable(), false);
vm.loadProject(cloudVarSimple).then(() => {
const stage = vm.runtime.targets[0];
const stageVars = Object.values(stage.variables);
t.equal(stageVars.length, 1);
const variable = stageVars[0];
t.equal(variable.name, '☁ firstCloud');
t.equal(Number(variable.value), 100); // Though scratch 2 requires
// cloud variables to be numbers, this is something that happens
// when the message is being sent to the server rather than on the client
t.equal(variable.isCloud, true);
t.equal(vm.runtime.canAddCloudVariable(), true);
vm.quit();
t.end();
});
});
});
test('local cloud variables get imported as regular variables', t => {
// This tests a hacked project where a sprite-local variable is
// has the cloud variable flag set.
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(cloudVarLocal).then(() => {
t.equal(vm.runtime.hasCloudData(), false);
const stage = vm.runtime.targets[0];
const stageVars = Object.values(stage.variables);
t.equal(stageVars.length, 0);
const sprite = vm.runtime.targets[1];
const spriteVars = Object.values(sprite.variables);
t.equal(spriteVars.length, 1);
t.equal(spriteVars[0].isCloud, false);
vm.quit();
t.end();
});
});

View File

@@ -0,0 +1,151 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const cloudVarSimpleUri = path.resolve(__dirname, '../fixtures/cloud_variables_simple.sb3');
const cloudVarLimitUri = path.resolve(__dirname, '../fixtures/cloud_variables_limit.sb3');
const cloudVarExceededLimitUri = path.resolve(__dirname, '../fixtures/cloud_variables_exceeded_limit.sb3');
const cloudVarLocalUri = path.resolve(__dirname, '../fixtures/cloud_variables_local.sb3');
const cloudVarSimple = readFileToBuffer(cloudVarSimpleUri);
const cloudVarLimit = readFileToBuffer(cloudVarLimitUri);
const cloudVarExceededLimit = readFileToBuffer(cloudVarExceededLimitUri);
const cloudVarLocal = readFileToBuffer(cloudVarLocalUri);
test('importing an sb3 project with cloud variables', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(cloudVarSimple).then(() => {
t.equal(vm.runtime.hasCloudData(), true);
const stage = vm.runtime.targets[0];
const stageVars = Object.values(stage.variables);
t.equal(stageVars.length, 1);
const variable = stageVars[0];
t.equal(variable.name, '☁ firstCloud');
t.equal(Number(variable.value), 100);
t.equal(variable.isCloud, true);
vm.quit();
t.end();
});
});
test('importing an sb3 project with cloud variables at the limit for a project', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(cloudVarLimit).then(() => {
t.equal(vm.runtime.hasCloudData(), true);
const stage = vm.runtime.targets[0];
const stageVars = Object.values(stage.variables);
t.equal(stageVars.length, 10);
// All of the 10 stage variables should be cloud variables
t.equal(stageVars.filter(v => v.isCloud).length, 10);
vm.quit();
t.end();
});
});
test('importing an sb3 project with cloud variables exceeding the limit for a project', t => {
// This tests a hacked project where additional cloud variables exceeding
// the project limit have been added.
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(cloudVarExceededLimit).then(() => {
t.equal(vm.runtime.hasCloudData(), true);
const stage = vm.runtime.targets[0];
const stageVars = Object.values(stage.variables);
t.equal(stageVars.length, 15);
// Only 8 of the variables should have the isCloud flag set to true
t.equal(stageVars.filter(v => v.isCloud).length, 10);
vm.quit();
t.end();
});
});
test('importing one project after the other resets cloud variable limit', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(cloudVarExceededLimit).then(() => {
t.equal(vm.runtime.canAddCloudVariable(), false);
vm.loadProject(cloudVarSimple).then(() => {
const stage = vm.runtime.targets[0];
const stageVars = Object.values(stage.variables);
t.equal(stageVars.length, 1);
const variable = stageVars[0];
t.equal(variable.name, '☁ firstCloud');
t.equal(Number(variable.value), 100);
t.equal(variable.isCloud, true);
t.equal(vm.runtime.canAddCloudVariable(), true);
vm.quit();
t.end();
});
});
});
test('local cloud variables get imported as regular variables', t => {
// This tests a hacked project where a sprite-local variable is
// has the cloud variable flag set.
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(cloudVarLocal).then(() => {
t.equal(vm.runtime.hasCloudData(), false);
const stage = vm.runtime.targets[0];
const stageVars = Object.values(stage.variables);
t.equal(stageVars.length, 0);
const sprite = vm.runtime.targets[1];
const spriteVars = Object.values(sprite.variables);
t.equal(spriteVars.length, 1);
t.equal(spriteVars[0].isCloud, false);
vm.quit();
t.end();
});
});

View File

@@ -0,0 +1,91 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const projectUri = path.resolve(__dirname, '../fixtures/comments.sb2');
const project = readFileToBuffer(projectUri);
test('importing sb2 project with comments', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.equal(threads.length, 0);
const stage = vm.runtime.targets[0];
const target = vm.runtime.targets[1];
const stageComments = Object.values(stage.comments);
// Stage has 1 comment, and it is minimized.
t.equal(stageComments.length, 1);
t.equal(stageComments[0].minimized, true);
t.equal(stageComments[0].text, 'A minimized stage comment.');
// The stage comment is a workspace comment
t.equal(stageComments[0].blockId, null);
// Sprite 1 has 6 Comments, 1 workspace comment, and 5 block comments
const targetComments = Object.values(target.comments);
t.equal(targetComments.length, 6);
const spriteWorkspaceComments = targetComments.filter(comment => comment.blockId === null);
t.equal(spriteWorkspaceComments.length, 1);
t.equal(spriteWorkspaceComments[0].minimized, false);
t.equal(spriteWorkspaceComments[0].text, 'This is a workspace comment.');
// Test the sprite block comments
const blockComments = targetComments.filter(comment => !!comment.blockId);
t.equal(blockComments.length, 5);
t.equal(blockComments[0].minimized, true);
t.equal(blockComments[0].text, '1. Green Flag Comment.');
const greenFlagBlock = target.blocks.getBlock(blockComments[0].blockId);
t.equal(greenFlagBlock.comment, blockComments[0].id);
t.equal(greenFlagBlock.opcode, 'event_whenflagclicked');
t.equal(blockComments[1].minimized, true);
t.equal(blockComments[1].text, '2. Turn 15 Degrees Comment.');
const turnRightBlock = target.blocks.getBlock(blockComments[1].blockId);
t.equal(turnRightBlock.comment, blockComments[1].id);
t.equal(turnRightBlock.opcode, 'motion_turnright');
t.equal(blockComments[2].minimized, false);
t.equal(blockComments[2].text, '3. Comment for a loop.');
const repeatBlock = target.blocks.getBlock(blockComments[2].blockId);
t.equal(repeatBlock.comment, blockComments[2].id);
t.equal(repeatBlock.opcode, 'control_repeat');
t.equal(blockComments[3].minimized, false);
t.equal(blockComments[3].text, '4. Comment for a block nested in a loop.');
const changeColorBlock = target.blocks.getBlock(blockComments[3].blockId);
t.equal(changeColorBlock.comment, blockComments[3].id);
t.equal(changeColorBlock.opcode, 'looks_changeeffectby');
t.equal(blockComments[4].minimized, false);
t.equal(blockComments[4].text, '5. Comment for a block outside of a loop.');
const stopAllBlock = target.blocks.getBlock(blockComments[4].blockId);
t.equal(stopAllBlock.comment, blockComments[4].id);
t.equal(stopAllBlock.opcode, 'control_stop');
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 100);
});
});
});

View File

@@ -0,0 +1,91 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const projectUri = path.resolve(__dirname, '../fixtures/comments.sb3');
const project = readFileToBuffer(projectUri);
test('load an sb3 project with comments', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.equal(threads.length, 0);
const stage = vm.runtime.targets[0];
const target = vm.runtime.targets[1];
const stageComments = Object.values(stage.comments);
// Stage has 1 comment, and it is minimized.
t.equal(stageComments.length, 1);
t.equal(stageComments[0].minimized, true);
t.equal(stageComments[0].text, 'A minimized stage comment.');
// The stage comment is a workspace comment
t.equal(stageComments[0].blockId, null);
// Sprite 1 has 6 Comments, 1 workspace comment, and 5 block comments
const targetComments = Object.values(target.comments);
t.equal(targetComments.length, 6);
const spriteWorkspaceComments = targetComments.filter(comment => comment.blockId === null);
t.equal(spriteWorkspaceComments.length, 1);
t.equal(spriteWorkspaceComments[0].minimized, false);
t.equal(spriteWorkspaceComments[0].text, 'This is a workspace comment.');
// Test the sprite block comments
const blockComments = targetComments.filter(comment => !!comment.blockId);
t.equal(blockComments.length, 5);
t.equal(blockComments[0].minimized, true);
t.equal(blockComments[0].text, '1. Green Flag Comment.');
const greenFlagBlock = target.blocks.getBlock(blockComments[0].blockId);
t.equal(greenFlagBlock.comment, blockComments[0].id);
t.equal(greenFlagBlock.opcode, 'event_whenflagclicked');
t.equal(blockComments[1].minimized, true);
t.equal(blockComments[1].text, '2. Turn 15 Degrees Comment.');
const turnRightBlock = target.blocks.getBlock(blockComments[1].blockId);
t.equal(turnRightBlock.comment, blockComments[1].id);
t.equal(turnRightBlock.opcode, 'motion_turnright');
t.equal(blockComments[2].minimized, false);
t.equal(blockComments[2].text, '3. Comment for a loop.');
const repeatBlock = target.blocks.getBlock(blockComments[2].blockId);
t.equal(repeatBlock.comment, blockComments[2].id);
t.equal(repeatBlock.opcode, 'control_repeat');
t.equal(blockComments[3].minimized, false);
t.equal(blockComments[3].text, '4. Comment for a block nested in a loop.');
const changeColorBlock = target.blocks.getBlock(blockComments[3].blockId);
t.equal(changeColorBlock.comment, blockComments[3].id);
t.equal(changeColorBlock.opcode, 'looks_changeeffectby');
t.equal(blockComments[4].minimized, false);
t.equal(blockComments[4].text, '5. Comment for a block outside of a loop.');
const stopAllBlock = target.blocks.getBlock(blockComments[4].blockId);
t.equal(stopAllBlock.comment, blockComments[4].id);
t.equal(stopAllBlock.opcode, 'control_stop');
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 100);
});
});
});

View File

@@ -0,0 +1,99 @@
const fs = require('fs');
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const projectUri = path.resolve(__dirname, '../fixtures/complex.sb2');
const project = readFileToBuffer(projectUri);
const spriteUri = path.resolve(__dirname, '../fixtures/sprite.json');
const sprite = fs.readFileSync(spriteUri, 'utf8');
test('complex', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.ok(threads.length === 0);
vm.quit();
t.end();
});
// Manipulate each target
vm.on('targetsUpdate', data => {
const targets = data.targetList;
for (const i in targets) {
if (targets[i].isStage === true) continue;
if (targets[i].name.match(/test/)) continue;
vm.setEditingTarget(targets[i].id);
vm.renameSprite(targets[i].id, 'test');
vm.postSpriteInfo({
x: 0,
y: 10,
direction: 90,
draggable: true,
rotationStyle: 'all around',
visible: true
});
vm.addCostume(
'f9a1c175dbe2e5dee472858dd30d16bb.svg',
{
name: 'costume1',
baseLayerID: 0,
baseLayerMD5: 'f9a1c175dbe2e5dee472858dd30d16bb.svg',
bitmapResolution: 1,
rotationCenterX: 47,
rotationCenterY: 55
}
);
}
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
// Post IO data
vm.postIOData('mouse', {
isDown: true,
x: 0,
y: 10,
canvasWidth: 100,
canvasHeight: 100
});
// Add sprite
vm.addSprite(sprite);
// Add backdrop
vm.addBackdrop(
'6b3d87ba2a7f89be703163b6c1d4c964.png',
{
name: 'baseball-field',
baseLayerID: 26,
baseLayerMD5: '6b3d87ba2a7f89be703163b6c1d4c964.png',
bitmapResolution: 2,
rotationCenterX: 480,
rotationCenterY: 360
}
);
// After two seconds, get playground data and stop
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 2000);
});
});
});

View File

@@ -0,0 +1,38 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const uri = path.resolve(__dirname, '../fixtures/control.sb2');
const project = readFileToBuffer(uri);
test('control', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.ok(threads.length > 0);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
});
});
// After two seconds, get playground data and stop
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 2000);
});

View File

@@ -0,0 +1,37 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const uri = path.resolve(__dirname, '../fixtures/data.sb2');
const project = readFileToBuffer(uri);
test('data', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', () => {
// @todo Additional tests
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
// After two seconds, get playground data and stop
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 2000);
});
});
});

View File

@@ -0,0 +1,65 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/virtual-machine');
// const RenderedTarget = require('../../src/sprites/rendered-target');
const projectUri = path.resolve(__dirname, '../fixtures/default.sb2');
const project = readFileToBuffer(projectUri);
const vm = new VirtualMachine();
test('spec', t => {
t.type(vm.deleteSprite, 'function');
t.end();
});
test('default cat', t => {
// Get default cat from .sprite2
// const uri = path.resolve(__dirname, '../fixtures/example_sprite.sprite2');
// const sprite = readFileToBuffer(uri);
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.ok(threads.length === 0);
vm.quit();
t.end();
});
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
t.doesNotThrow(() => {
vm.loadProject(project).then(() => {
t.equal(vm.runtime.targets.length, 2); // stage and default sprite
const defaultSprite = vm.runtime.targets[1];
// Delete the sprite
const addSpriteBack = vm.deleteSprite(vm.runtime.targets[1].id);
t.equal(vm.runtime.targets.length, 1);
t.type(addSpriteBack, 'function');
addSpriteBack().then(() => {
t.equal(vm.runtime.targets.length, 2);
t.equal(vm.runtime.targets[1].getName(), defaultSprite.getName());
vm.greenFlag();
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 1000);
});
});
});
});

View File

@@ -0,0 +1,38 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const uri = path.resolve(__dirname, '../fixtures/event.sb2');
const project = readFileToBuffer(uri);
test('event', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.ok(threads.length > 0);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
// After two seconds, get playground data and stop
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 2000);
});
});
});

View File

@@ -0,0 +1,150 @@
const fs = require('fs');
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
/**
* @fileoverview Transform each sb2 in fixtures/execute into a test.
*
* Test execution of a group of scratch blocks by SAYing if a test did "pass",
* or did "fail". Four keywords can be set at the beginning of a SAY messaage
* to indicate a test primitive.
*
* - "pass MESSAGE" will t.pass(MESSAGE).
* - "fail MESSAGE" will t.fail(MESSAGE).
* - "plan NUMBER_OF_TESTS" will t.plan(Number(NUMBER_OF_TESTS)).
* - "end" will t.end().
*
* A good strategy to follow is to SAY "plan NUMBER_OF_TESTS" first. Then
* "pass" and "fail" depending on expected scratch results in conditions, event
* scripts, or what is best for testing the target block or group of blocks.
* When its done you must SAY "end" so the test and tap know that the end has
* been reached.
*/
const whenThreadsComplete = (t, vm, uri, timeLimit = 5000) =>
// When the number of threads reaches 0 the test is expected to be complete.
new Promise((resolve, reject) => {
const intervalId = setInterval(() => {
let active = 0;
const threads = vm.runtime.threads;
for (let i = 0; i < threads.length; i++) {
if (!threads[i].updateMonitor) {
active += 1;
}
}
if (active === 0) {
resolve();
}
}, 50);
const timeoutId = setTimeout(() => {
t.fail(`Timeout waiting for threads to complete: ${uri}`);
reject(new Error('time limit reached'));
}, timeLimit);
// Clear the interval to allow the process to exit
// naturally.
t.tearDown(() => {
clearInterval(intervalId);
clearTimeout(timeoutId);
});
});
const executeDir = path.resolve(__dirname, '../fixtures/execute');
// Find files which end in ".sb", ".sb2", or ".sb3"
const fileFilter = /\.sb[23]?$/i;
fs.readdirSync(executeDir)
.filter(uri => fileFilter.test(uri))
.forEach(uri => {
const run = (t, enableCompiler) => {
const vm = new VirtualMachine();
// Map string messages to tap reporting methods. This will be used
// with events from scratch's runtime emitted on block instructions.
let didPlan;
let didEnd;
const reporters = {
comment (message) {
t.comment(message);
},
pass (reason) {
t.pass(reason);
},
fail (reason) {
t.fail(reason);
},
plan (count) {
didPlan = true;
t.plan(Number(count));
},
end () {
didEnd = true;
vm.quit();
t.end();
}
};
const reportVmResult = text => {
const command = text.split(/\s+/, 1)[0].toLowerCase();
if (reporters[command]) {
return reporters[command](text.substring(command.length).trim());
}
// Default to a comment with the full text if we didn't match
// any command prefix
return reporters.comment(text);
};
vm.attachStorage(makeTestStorage());
// Start the VM and initialize some vm properties.
// complete.
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.setCompilerOptions({enabled: enableCompiler});
// TW: Script compilation errors should fail.
if (enableCompiler) {
vm.on('COMPILE_ERROR', (target, error) => {
throw new Error(`Could not compile script in ${target.getName()}: ${error}`);
});
}
// Report the text of SAY events as testing instructions.
vm.runtime.on('SAY', (target, type, text) => reportVmResult(text));
const project = readFileToBuffer(path.resolve(executeDir, uri));
// Load the project and once all threads are complete ensure that
// the scratch project sent us a "end" message.
return vm.loadProject(project)
.then(() => vm.greenFlag())
.then(() => whenThreadsComplete(t, vm, uri))
.then(() => {
// Setting a plan is not required but is a good idea.
if (!didPlan) {
t.comment('did not say "plan NUMBER_OF_TESTS"');
}
// End must be called so that tap knows the test is done. If
// the test has an SAY "end" block but that block did not
// execute, this explicit failure will raise that issue so
// it can be resolved.
if (!didEnd) {
t.fail('did not say "end"');
vm.quit();
t.end();
}
});
};
test(`${uri} (interpreted)`, t => run(t, false));
test(`${uri} (compiled)`, t => run(t, true));
});

View File

@@ -0,0 +1,57 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const projectUri = path.resolve(__dirname, '../fixtures/hat-execution-order.sb2');
const project = readFileToBuffer(projectUri);
const compilerAndInterpreter = (name, callback) => {
test(`${name} - interpreted`, t => {
callback(t, {
enabled: false
});
});
test(`${name} - compiled`, t => {
callback(t, {
enabled: true
});
});
};
compilerAndInterpreter('complex', (t, co) => {
const vm = new VirtualMachine();
vm.setCompilerOptions(co);
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.ok(threads.length === 0);
const resultKey = Object.keys(vm.runtime.targets[0].variables)[0];
const results = vm.runtime.targets[0].variables[resultKey].value;
t.deepEqual(results, ['3', '2', '1', 'stage']);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
// After two seconds, get playground data and stop
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 2000);
});
});
});

View File

@@ -0,0 +1,377 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const Thread = require('../../src/engine/thread');
const Runtime = require('../../src/engine/runtime');
const execute = require('../../src/engine/execute.js');
const compilerAndInterpreter = (name, callback) => {
test(`${name} - interpreted`, t => {
callback(t, {
enabled: false
});
});
test(`${name} - compiled`, t => {
callback(t, {
enabled: true
});
});
};
const projectUri = path.resolve(__dirname, '../fixtures/timer-greater-than-hat.sb2');
const project = readFileToBuffer(projectUri);
const checkIsHatThread = (t, vm, hatThread) => {
t.equal(hatThread.stackClick, false);
t.equal(hatThread.updateMonitor, false);
const blockContainer = hatThread.target.blocks;
const opcode = blockContainer.getOpcode(blockContainer.getBlock(hatThread.topBlock));
t.assert(vm.runtime.getIsEdgeActivatedHat(opcode));
};
const checkIsStackClickThread = (t, vm, stackClickThread) => {
t.equal(stackClickThread.stackClick, true);
t.equal(stackClickThread.updateMonitor, false);
};
/**
* timer-greater-than-hat.sb2 contains a single stack
* when timer > -1
* change color effect by 25
* The intention is to make sure that the hat block condition is evaluated
* on each frame.
*/
compilerAndInterpreter('edge activated hat thread runs once every frame', (t, co) => {
const vm = new VirtualMachine();
vm.setCompilerOptions(co);
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
t.doesNotThrow(() => {
// Note: don't run vm.start(), we handle calling _step() manually in this test
vm.runtime.currentStepTime = Runtime.THREAD_STEP_INTERVAL;
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
t.equal(vm.runtime.threads.length, 0);
vm.runtime._step();
let threads = vm.runtime._lastStepDoneThreads;
t.equal(vm.runtime.threads.length, 0);
t.equal(threads.length, 1);
checkIsHatThread(t, vm, threads[0]);
t.assert(threads[0].status === Thread.STATUS_DONE);
// Check that the hat thread is added again when another step is taken
vm.runtime._step();
threads = vm.runtime._lastStepDoneThreads;
t.equal(vm.runtime.threads.length, 0);
t.equal(threads.length, 1);
checkIsHatThread(t, vm, threads[0]);
t.assert(threads[0].status === Thread.STATUS_DONE);
t.end();
});
});
});
/**
* When a hat is added it should run in the next frame. Any block related
* caching should be reset.
*/
// eslint-disable-next-line max-len
compilerAndInterpreter('edge activated hat thread runs after being added to previously executed target', (t, co) => {
const vm = new VirtualMachine();
vm.setCompilerOptions(co);
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
t.doesNotThrow(() => {
// Note: don't run vm.start(), we handle calling _step() manually in this test
vm.runtime.currentStepTime = Runtime.THREAD_STEP_INTERVAL;
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
t.equal(vm.runtime.threads.length, 0);
vm.runtime._step();
let threads = vm.runtime._lastStepDoneThreads;
t.equal(vm.runtime.threads.length, 0);
t.equal(threads.length, 1);
checkIsHatThread(t, vm, threads[0]);
t.assert(threads[0].status === Thread.STATUS_DONE);
// Add a second hat that should create a second thread
const hatBlock = threads[0].target.blocks.getBlock(threads[0].topBlock);
threads[0].target.blocks.createBlock(Object.assign(
{}, hatBlock, {id: 'hatblock2', next: null}
));
// Check that the hat thread is added again when another step is taken
vm.runtime._step();
threads = vm.runtime._lastStepDoneThreads;
t.equal(vm.runtime.threads.length, 0);
t.equal(threads.length, 2);
checkIsHatThread(t, vm, threads[0]);
checkIsHatThread(t, vm, threads[1]);
t.assert(threads[0].status === Thread.STATUS_DONE);
t.assert(threads[1].status === Thread.STATUS_DONE);
t.end();
});
});
});
/**
* If the hat doesn't finish evaluating within one frame, it shouldn't be added again
* on the next frame. (We skip execution by setting the step time to 0)
*/
compilerAndInterpreter('edge activated hat thread not added twice', (t, co) => {
const vm = new VirtualMachine();
vm.setCompilerOptions(co);
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
t.doesNotThrow(() => {
// Note: don't run vm.start(), we handle calling _step() manually in this test
vm.runtime.currentStepTime = 0;
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
t.equal(vm.runtime.threads.length, 0);
vm.runtime._step();
let doneThreads = vm.runtime._lastStepDoneThreads;
t.equal(vm.runtime.threads.length, 1);
t.equal(doneThreads.length, 0);
const prevThread = vm.runtime.threads[0];
checkIsHatThread(t, vm, vm.runtime.threads[0]);
t.assert(vm.runtime.threads[0].status === Thread.STATUS_RUNNING);
// Check that no new threads are added when another step is taken
vm.runtime._step();
doneThreads = vm.runtime._lastStepDoneThreads;
// There should now be one done hat thread and one new hat thread to run
t.equal(vm.runtime.threads.length, 1);
t.equal(doneThreads.length, 0);
checkIsHatThread(t, vm, vm.runtime.threads[0]);
t.assert(vm.runtime.threads[0] === prevThread);
t.end();
});
});
});
/**
* Duplicating a sprite should also track duplicated edge activated hat in
* runtime's _edgeActivatedHatValues map.
*/
compilerAndInterpreter('edge activated hat should trigger for both sprites when sprite is duplicated', (t, co) => {
// Project that is similar to timer-greater-than-hat.sb2, but has code on the sprite so that
// the sprite can be duplicated
const projectWithSpriteUri = path.resolve(__dirname, '../fixtures/edge-triggered-hat.sb3');
const projectWithSprite = readFileToBuffer(projectWithSpriteUri);
const vm = new VirtualMachine();
vm.setCompilerOptions(co);
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
t.doesNotThrow(() => {
// Note: don't run vm.start(), we handle calling _step() manually in this test
vm.runtime.currentStepTime = 0;
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(projectWithSprite).then(() => {
t.equal(vm.runtime.threads.length, 0);
vm.runtime._step();
t.equal(vm.runtime.threads.length, 1);
checkIsHatThread(t, vm, vm.runtime.threads[0]);
t.assert(vm.runtime.threads[0].status === Thread.STATUS_RUNNING);
let numTargetEdgeHats = vm.runtime.targets.reduce((val, target) =>
val + Object.keys(target._edgeActivatedHatValues).length, 0);
t.equal(numTargetEdgeHats, 1);
vm.duplicateSprite(vm.runtime.targets[1].id).then(() => {
vm.runtime._step();
// Check that the runtime's _edgeActivatedHatValues object has two separate keys
// after execute is run on each thread
numTargetEdgeHats = vm.runtime.targets.reduce((val, target) =>
val + Object.keys(target._edgeActivatedHatValues).length, 0);
t.equal(numTargetEdgeHats, 2);
t.end();
});
});
});
});
/**
* Cloning a sprite should also track cloned edge activated hat separately
* runtime's _edgeActivatedHatValues map.
*/
compilerAndInterpreter('edge activated hat should trigger for both sprites when sprite is cloned', (t, co) => {
// Project that is similar to loudness-hat-block.sb2, but has code on the sprite so that
// the sprite can be duplicated
const projectWithSpriteUri = path.resolve(__dirname, '../fixtures/edge-triggered-hat.sb3');
const projectWithSprite = readFileToBuffer(projectWithSpriteUri);
const vm = new VirtualMachine();
vm.setCompilerOptions(co);
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
t.doesNotThrow(() => {
// Note: don't run vm.start(), we handle calling _step() manually in this test
vm.runtime.currentStepTime = 0;
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(projectWithSprite).then(() => {
t.equal(vm.runtime.threads.length, 0);
vm.runtime._step();
t.equal(vm.runtime.threads.length, 1);
checkIsHatThread(t, vm, vm.runtime.threads[0]);
t.assert(vm.runtime.threads[0].status === Thread.STATUS_RUNNING);
// Run execute on the thread to populate the runtime's
// _edgeActivatedHatValues object
execute(vm.runtime.sequencer, vm.runtime.threads[0]);
let numTargetEdgeHats = vm.runtime.targets.reduce((val, target) =>
val + Object.keys(target._edgeActivatedHatValues).length, 0);
t.equal(numTargetEdgeHats, 1);
const cloneTarget = vm.runtime.targets[1].makeClone();
vm.runtime.addTarget(cloneTarget);
vm.runtime._step();
// Check that the runtime's _edgeActivatedHatValues object has two separate keys
// after execute is run on each thread
vm.runtime.threads.forEach(thread => execute(vm.runtime.sequencer, thread));
numTargetEdgeHats = vm.runtime.targets.reduce((val, target) =>
val + Object.keys(target._edgeActivatedHatValues).length, 0);
t.equal(numTargetEdgeHats, 2);
t.end();
});
});
});
/**
* When adding a stack click thread first, make sure that the edge activated hat thread and
* the stack click thread are both pushed and run (despite having the same top block)
*/
compilerAndInterpreter('edge activated hat thread does not interrupt stack click thread', (t, co) => {
const vm = new VirtualMachine();
vm.setCompilerOptions(co);
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
t.doesNotThrow(() => {
// Note: don't run vm.start(), we handle calling _step() manually in this test
vm.runtime.currentStepTime = Runtime.THREAD_STEP_INTERVAL;
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
t.equal(vm.runtime.threads.length, 0);
vm.runtime._step();
let doneThreads = vm.runtime._lastStepDoneThreads;
t.equal(vm.runtime.threads.length, 0);
t.equal(doneThreads.length, 1);
checkIsHatThread(t, vm, doneThreads[0]);
t.assert(doneThreads[0].status === Thread.STATUS_DONE);
// Add stack click thread on this hat
vm.runtime.toggleScript(doneThreads[0].topBlock, {stackClick: true});
// Check that the hat thread is added again when another step is taken
vm.runtime._step();
doneThreads = vm.runtime._lastStepDoneThreads;
t.equal(vm.runtime.threads.length, 0);
t.equal(doneThreads.length, 2);
let hatThread;
let stackClickThread;
if (doneThreads[0].stackClick) {
stackClickThread = doneThreads[0];
hatThread = doneThreads[1];
} else {
stackClickThread = doneThreads[1];
hatThread = doneThreads[0];
}
checkIsHatThread(t, vm, hatThread);
checkIsStackClickThread(t, vm, stackClickThread);
t.assert(doneThreads[0].status === Thread.STATUS_DONE);
t.assert(doneThreads[1].status === Thread.STATUS_DONE);
t.end();
});
});
});
/**
* When adding the hat thread first, make sure that the edge activated hat thread and
* the stack click thread are both pushed and run (despite having the same top block)
*/
compilerAndInterpreter('edge activated hat thread does not interrupt stack click thread', (t, co) => {
const vm = new VirtualMachine();
vm.setCompilerOptions(co);
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
t.doesNotThrow(() => {
// Note: don't run vm.start(), we handle calling _step() manually in this test
vm.runtime.currentStepTime = 0;
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
t.equal(vm.runtime.threads.length, 0);
vm.runtime._step();
let doneThreads = vm.runtime._lastStepDoneThreads;
t.equal(vm.runtime.threads.length, 1);
t.equal(doneThreads.length, 0);
checkIsHatThread(t, vm, vm.runtime.threads[0]);
t.assert(vm.runtime.threads[0].status === Thread.STATUS_RUNNING);
vm.runtime.currentStepTime = Runtime.THREAD_STEP_INTERVAL;
// Add stack click thread on this hat
vm.runtime.toggleScript(vm.runtime.threads[0].topBlock, {stackClick: true});
// Check that the hat thread is added again when another step is taken
vm.runtime._step();
doneThreads = vm.runtime._lastStepDoneThreads;
t.equal(vm.runtime.threads.length, 0);
t.equal(doneThreads.length, 2);
let hatThread;
let stackClickThread;
if (doneThreads[0].stackClick) {
stackClickThread = doneThreads[0];
hatThread = doneThreads[1];
} else {
stackClickThread = doneThreads[1];
hatThread = doneThreads[0];
}
checkIsHatThread(t, vm, hatThread);
checkIsStackClickThread(t, vm, stackClickThread);
t.assert(doneThreads[0].status === Thread.STATUS_DONE);
t.assert(doneThreads[1].status === Thread.STATUS_DONE);
t.end();
});
});
});

View File

@@ -0,0 +1,45 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const uri = path.resolve(__dirname, '../fixtures/single_sound.sb');
const project = readFileToBuffer(uri);
test('default', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.ok(threads.length === 0);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
const stageSounds = vm.runtime.targets[0].sprite.sounds;
const firstSound = stageSounds[0];
// Check that the sound has the correct md5
// This md5 was obtained from the asset server
t.equal(firstSound.md5, 'edb9713dedbe9a2e05c09e0540182ef1.wav');
// After two seconds, get playground data and stop
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 2000);
});
});
});

View File

@@ -0,0 +1,38 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const extractProjectJson = require('../fixtures/readProjectFile').extractProjectJson;
const VirtualMachine = require('../../src/index');
const uri = path.resolve(__dirname, '../fixtures/default.sb2');
const project = extractProjectJson(uri);
test('default', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.ok(threads.length === 0);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
// After two seconds, get playground data and stop
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 2000);
});
});
});

View File

@@ -0,0 +1,52 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const extractProjectJson = require('../fixtures/readProjectFile').extractProjectJson;
const renderedTarget = require('../../src/sprites/rendered-target');
const runtime = require('../../src/engine/runtime');
const sb2 = require('../../src/serialization/sb2');
test('spec', t => {
t.type(sb2.deserialize, 'function');
t.end();
});
test('nested default/*', t => {
// Get SB2 JSON (string)
const uri = path.resolve(__dirname, '../fixtures/default_nested.sb2');
const json = extractProjectJson(uri, 'default');
// Create runtime instance & load SB2 into it
const rt = new runtime();
rt.attachStorage(makeTestStorage());
sb2.deserialize(json, rt).then(({targets}) => {
// Test
t.type(json, 'object');
t.type(rt, 'object');
t.type(targets, 'object');
t.ok(targets[0] instanceof renderedTarget);
t.type(targets[0].id, 'string');
t.type(targets[0].blocks, 'object');
t.type(targets[0].variables, 'object');
t.type(targets[0].comments, 'object');
t.equal(targets[0].isOriginal, true);
t.equal(targets[0].currentCostume, 0);
t.equal(targets[0].isOriginal, true);
t.equal(targets[0].isStage, true);
t.ok(targets[1] instanceof renderedTarget);
t.type(targets[1].id, 'string');
t.type(targets[1].blocks, 'object');
t.type(targets[1].variables, 'object');
t.type(targets[1].comments, 'object');
t.equal(targets[1].isOriginal, true);
t.equal(targets[1].currentCostume, 0);
t.equal(targets[1].isOriginal, true);
t.equal(targets[1].isStage, false);
t.end();
});
});

View File

@@ -0,0 +1,52 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const extractProjectJson = require('../fixtures/readProjectFile').extractProjectJson;
const renderedTarget = require('../../src/sprites/rendered-target');
const runtime = require('../../src/engine/runtime');
const sb2 = require('../../src/serialization/sb2');
test('spec', t => {
t.type(sb2.deserialize, 'function');
t.end();
});
test('default', t => {
// Get SB2 JSON (string)
const uri = path.resolve(__dirname, '../fixtures/default.sb2');
const json = extractProjectJson(uri);
// Create runtime instance & load SB2 into it
const rt = new runtime();
rt.attachStorage(makeTestStorage());
sb2.deserialize(json, rt).then(({targets}) => {
// Test
t.type(json, 'object');
t.type(rt, 'object');
t.type(targets, 'object');
t.ok(targets[0] instanceof renderedTarget);
t.type(targets[0].id, 'string');
t.type(targets[0].blocks, 'object');
t.type(targets[0].variables, 'object');
t.type(targets[0].comments, 'object');
t.equal(targets[0].isOriginal, true);
t.equal(targets[0].currentCostume, 0);
t.equal(targets[0].isOriginal, true);
t.equal(targets[0].isStage, true);
t.ok(targets[1] instanceof renderedTarget);
t.type(targets[1].id, 'string');
t.type(targets[1].blocks, 'object');
t.type(targets[1].variables, 'object');
t.type(targets[1].comments, 'object');
t.equal(targets[1].isOriginal, true);
t.equal(targets[1].currentCostume, 0);
t.equal(targets[1].isOriginal, true);
t.equal(targets[1].isStage, false);
t.end();
});
});

View File

@@ -0,0 +1,127 @@
const test = require('tap').test;
const Worker = require('tiny-worker');
const BlockType = require('../../src/extension-support/block-type');
const dispatch = require('../../src/dispatch/central-dispatch');
const VirtualMachine = require('../../src/virtual-machine');
const Sprite = require('../../src/sprites/sprite');
const RenderedTarget = require('../../src/sprites/rendered-target');
// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead.
dispatch.workerClass = Worker;
class TestInternalExtension {
constructor () {
this.status = {};
this.status.constructorCalled = true;
}
getInfo () {
this.status.getInfoCalled = true;
return {
id: 'testInternalExtension',
name: 'Test Internal Extension',
blocks: [
{
opcode: 'go'
}
],
menus: {
simpleMenu: this._buildAMenu(),
dynamicMenu: '_buildDynamicMenu'
}
};
}
go (args, util, blockInfo) {
this.status.goCalled = true;
return blockInfo;
}
_buildAMenu () {
this.status.buildMenuCalled = true;
return ['abcd', 'efgh', 'ijkl'];
}
_buildDynamicMenu () {
this.status.buildDynamicMenuCalled = true;
return [1, 2, 3, 4, 6];
}
}
test('internal extension', t => {
const vm = new VirtualMachine();
const extension = new TestInternalExtension();
t.ok(extension.status.constructorCalled);
t.notOk(extension.status.getInfoCalled);
vm.extensionManager._registerInternalExtension(extension);
t.ok(extension.status.getInfoCalled);
const func = vm.runtime.getOpcodeFunction('testInternalExtension_go');
t.type(func, 'function');
t.notOk(extension.status.goCalled);
const goBlockInfo = func();
t.ok(extension.status.goCalled);
// The 'go' block returns its own blockInfo. Make sure it matches the expected info.
// Note that the extension parser fills in missing fields so there are more fields here than in `getInfo`.
const expectedBlockInfo = {
arguments: {},
blockAllThreads: false,
blockType: BlockType.COMMAND,
func: goBlockInfo.func, // Cheat since we don't have a good way to ensure we generate the same function
opcode: 'go',
terminal: false,
text: 'go'
};
t.deepEqual(goBlockInfo, expectedBlockInfo);
// There should be 2 menus - one is an array, one is the function to call.
t.equal(vm.runtime._blockInfo[0].menus.length, 2);
// First menu has 3 items.
t.equal(
vm.runtime._blockInfo[0].menus[0].json.args0[0].options.length, 3);
// Second menu is a dynamic menu and therefore should be a function.
t.type(
vm.runtime._blockInfo[0].menus[1].json.args0[0].options, 'function');
t.end();
});
test('load sync', t => {
const vm = new VirtualMachine();
vm.extensionManager.loadExtensionIdSync('coreExample');
t.ok(vm.extensionManager.isExtensionLoaded('coreExample'));
t.equal(vm.runtime._blockInfo.length, 1);
// blocks should be an array of two items: a button pseudo-block and a reporter block.
t.equal(vm.runtime._blockInfo[0].blocks.length, 3);
t.type(vm.runtime._blockInfo[0].blocks[0].info, 'object');
t.type(vm.runtime._blockInfo[0].blocks[0].info.func, 'MAKE_A_VARIABLE');
t.equal(vm.runtime._blockInfo[0].blocks[0].info.blockType, 'button');
t.type(vm.runtime._blockInfo[0].blocks[1].info, 'object');
t.equal(vm.runtime._blockInfo[0].blocks[1].info.opcode, 'exampleOpcode');
t.equal(vm.runtime._blockInfo[0].blocks[1].info.blockType, 'reporter');
t.type(vm.runtime._blockInfo[0].blocks[2].info, 'object');
t.equal(vm.runtime._blockInfo[0].blocks[2].info.opcode, 'exampleWithInlineImage');
t.equal(vm.runtime._blockInfo[0].blocks[2].info.blockType, 'command');
// Test the opcode function
t.equal(vm.runtime._blockInfo[0].blocks[1].info.func(), 'no stage yet');
const sprite = new Sprite(null, vm.runtime);
sprite.name = 'Stage';
const stage = new RenderedTarget(sprite, vm.runtime);
stage.isStage = true;
vm.runtime.targets = [stage];
t.equal(vm.runtime._blockInfo[0].blocks[1].info.func(), 'Stage');
t.end();
});

View File

@@ -0,0 +1,52 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const projectUri = path.resolve(__dirname, '../fixtures/list-monitor-rename.sb3');
const project = readFileToBuffer(projectUri);
test('importing sb3 project with incorrect list monitor name', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', () => {
const stage = vm.runtime.targets[0];
const cat = vm.runtime.targets[1];
for (const {target, renamedListName} of [
{target: stage, renamedListName: 'renamed global'},
{target: cat, renamedListName: 'renamed local'}
]) {
const listId = Object.keys(target.variables).find(k => target.variables[k].name === renamedListName);
const monitorRecord = vm.runtime._monitorState.get(listId);
const monitorBlock = vm.runtime.monitorBlocks.getBlock(listId);
t.equal(monitorRecord.opcode, 'data_listcontents');
// The list name should be properly renamed
t.equal(monitorRecord.params.LIST, renamedListName);
t.equal(monitorBlock.fields.LIST.value, renamedListName);
}
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 100);
});
});
});

View File

@@ -0,0 +1,83 @@
const path = require('path');
const tap = require('tap');
const {test} = tap;
const fs = require('fs');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const dispatch = require('../../src/dispatch/central-dispatch');
const VirtualMachine = require('../../src/index');
/**
* Call _stopLoop() on the Video Sensing extension.
* @param {VirtualMachine} vm - a VM instance which has loaded the 'videoSensing' extension.
*/
const stopVideoLoop = vm => {
// TODO: provide a general way to tell extensions to shut down
// Ideally we'd just dispose of the extension's Worker...
const serviceName = vm.extensionManager._loadedExtensions.get('videoSensing');
dispatch.call(serviceName, '_stopLoop');
};
test('Load external extensions', async t => {
const vm = new VirtualMachine();
const testFiles = fs.readdirSync('./test/fixtures/load-extensions/confirm-load/');
// Test each example extension file
for (const file of testFiles) {
const ext = file.split('-')[0];
const uri = path.resolve(__dirname, `../fixtures/load-extensions/confirm-load/${file}`);
const project = readFileToBuffer(uri);
await t.test('Confirm expected extension is installed in example sb2 and sb3 projects', extTest => {
vm.loadProject(project)
.then(() => {
extTest.ok(vm.extensionManager.isExtensionLoaded(ext));
extTest.end();
});
});
}
stopVideoLoop(vm);
vm.quit();
t.end();
});
test('Load video sensing extension and video properties', async t => {
const vm = new VirtualMachine();
// An array of test projects and their expected video state values
const testProjects = [
{
file: 'videoState-off.sb2',
videoState: 'off',
videoTransparency: 50,
mirror: undefined
},
{
file: 'videoState-on-transparency-0.sb2',
videoState: 'on',
videoTransparency: 0,
mirror: true
}];
for (const project of testProjects) {
const uri = path.resolve(__dirname, `../fixtures/load-extensions/video-state/${project.file}`);
const projectData = readFileToBuffer(uri);
await vm.loadProject(projectData);
const stage = vm.runtime.getTargetForStage();
t.ok(vm.extensionManager.isExtensionLoaded('videoSensing'));
// Check that the stage target has the video state values we expect
// based on the test project files, then check that the video io device
// has the expected state as well
t.equal(stage.videoState, project.videoState);
t.equal(vm.runtime.ioDevices.video.mirror, project.mirror);
t.equal(stage.videoTransparency, project.videoTransparency);
t.equal(vm.runtime.ioDevices.video._ghost, project.videoTransparency);
}
stopVideoLoop(vm);
vm.quit();
t.end();
});

View File

@@ -0,0 +1,41 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/virtual-machine');
const projectUri = path.resolve(__dirname, '../fixtures/sb2-from-sb1-missing-backdrop-image.sb2');
const project = readFileToBuffer(projectUri);
const vm = new VirtualMachine();
test('sb2 project (originally from Scratch 1.4) with missing backdrop image should load', t => {
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.ok(threads.length === 0);
vm.quit();
t.end();
});
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
t.doesNotThrow(() => {
vm.loadProject(project).then(() => {
t.equal(vm.runtime.targets.length, 2); // stage and default sprite
vm.greenFlag();
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 1000);
});
});
});

View File

@@ -0,0 +1,38 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const uri = path.resolve(__dirname, '../fixtures/looks.sb2');
const project = readFileToBuffer(uri);
test('looks', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.ok(threads.length === 0);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
// After two seconds, get playground data and stop
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 2000);
});
});
});

View File

@@ -0,0 +1,95 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const Thread = require('../../src/engine/thread');
const Runtime = require('../../src/engine/runtime');
const projectUri = path.resolve(__dirname, '../fixtures/timer-monitor.sb3');
const project = readFileToBuffer(projectUri);
const checkMonitorThreadPresent = (t, threads) => {
t.equal(threads.length, 1);
const monitorThread = threads[0];
t.equal(monitorThread.stackClick, false);
t.equal(monitorThread.updateMonitor, true);
t.equal(monitorThread.topBlock.toString(), 'timer');
};
/**
* Creates a monitor and then checks if it gets run every frame.
*/
test('monitor thread runs every frame', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
t.doesNotThrow(() => {
// Note: don't run vm.start(), we handle calling _step() manually in this test
vm.runtime.currentStepTime = Runtime.THREAD_STEP_INTERVAL;
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
t.equal(vm.runtime.threads.length, 0);
vm.runtime._step();
let doneThreads = vm.runtime._lastStepDoneThreads;
t.equal(vm.runtime.threads.length, 0);
t.equal(doneThreads.length, 1);
checkMonitorThreadPresent(t, doneThreads);
t.assert(doneThreads[0].status === Thread.STATUS_DONE);
// Check that both are added again when another step is taken
vm.runtime._step();
doneThreads = vm.runtime._lastStepDoneThreads;
t.equal(vm.runtime.threads.length, 0);
t.equal(doneThreads.length, 1);
checkMonitorThreadPresent(t, doneThreads);
t.assert(doneThreads[0].status === Thread.STATUS_DONE);
t.end();
});
});
});
/**
* If the monitor doesn't finish evaluating within one frame, it shouldn't be added again
* on the next frame. (We skip execution by setting the step time to 0)
*/
test('monitor thread not added twice', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
t.doesNotThrow(() => {
// Note: don't run vm.start(), we handle calling _step() manually in this test
vm.runtime.currentStepTime = 0;
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
t.equal(vm.runtime.threads.length, 0);
vm.runtime._step();
let doneThreads = vm.runtime._lastStepDoneThreads;
t.equal(vm.runtime.threads.length, 1);
t.equal(doneThreads.length, 0);
checkMonitorThreadPresent(t, vm.runtime.threads);
t.assert(vm.runtime.threads[0].status === Thread.STATUS_RUNNING);
const prevThread = vm.runtime.threads[0];
// Check that both are added again when another step is taken
vm.runtime._step();
doneThreads = vm.runtime._lastStepDoneThreads;
t.equal(vm.runtime.threads.length, 1);
t.equal(doneThreads.length, 0);
checkMonitorThreadPresent(t, vm.runtime.threads);
t.equal(vm.runtime.threads[0], prevThread);
t.end();
});
});
});

View File

@@ -0,0 +1,143 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const projectUri = path.resolve(__dirname, '../fixtures/monitors.sb2');
const project = readFileToBuffer(projectUri);
test('importing sb2 project with monitors', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
// All monitors should create threads that finish during the step and
// are revoved from runtime.threads.
t.equal(threads.length, 0);
// we care that the last step updated the right number of monitors
// we don't care whether the last step ran other threads or not
const lastStepUpdatedMonitorThreads = vm.runtime._lastStepDoneThreads.filter(thread => thread.updateMonitor);
t.equal(lastStepUpdatedMonitorThreads.length, 8);
// There should be one additional hidden monitor that is in the monitorState but
// does not start a thread.
t.equal(vm.runtime._monitorState.size, 9);
const stage = vm.runtime.targets[0];
const target = vm.runtime.targets[1];
// Global variable named "global" is a slider
let variableId = Object.keys(stage.variables).filter(k => stage.variables[k].name === 'global')[0];
let monitorRecord = vm.runtime._monitorState.get(variableId);
t.equal(monitorRecord.opcode, 'data_variable');
t.equal(monitorRecord.mode, 'slider');
t.equal(monitorRecord.sliderMin, -200); // Make sure these are imported for sliders.
t.equal(monitorRecord.sliderMax, 30);
t.equal(monitorRecord.isDiscrete, false);
t.equal(monitorRecord.x, 5); // These are imported for all monitors, just check once.
t.equal(monitorRecord.y, 59);
t.equal(monitorRecord.visible, true);
// Global variable named "global list" is a list
variableId = Object.keys(stage.variables).filter(k => stage.variables[k].name === 'global list')[0];
monitorRecord = vm.runtime._monitorState.get(variableId);
t.equal(monitorRecord.opcode, 'data_listcontents');
t.equal(monitorRecord.mode, 'list');
t.equal(monitorRecord.visible, true);
// Local variable named "local" is hidden
variableId = Object.keys(target.variables).filter(k => target.variables[k].name === 'local')[0];
monitorRecord = vm.runtime._monitorState.get(variableId);
t.equal(monitorRecord.opcode, 'data_variable');
t.equal(monitorRecord.mode, 'default');
t.equal(monitorRecord.visible, false);
// Local list named "local list" is visible
variableId = Object.keys(target.variables).filter(k => target.variables[k].name === 'local list')[0];
monitorRecord = vm.runtime._monitorState.get(variableId);
t.equal(monitorRecord.opcode, 'data_listcontents');
t.equal(monitorRecord.mode, 'list');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.width, 106); // Make sure these are imported from lists.
t.equal(monitorRecord.height, 206);
// Backdrop name monitor is visible, not sprite specific
// should get imported with id that references the name parameter
// via '_name' at the end since the 3.0 block has a dropdown.
monitorRecord = vm.runtime._monitorState.get('backdropnumbername_name');
t.equal(monitorRecord.opcode, 'looks_backdropnumbername');
t.equal(monitorRecord.mode, 'default');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, null);
t.equal(monitorRecord.targetId, null);
// x position monitor is in large mode, specific to sprite 1
monitorRecord = vm.runtime._monitorState.get(`${target.id}_xposition`);
t.equal(monitorRecord.opcode, 'motion_xposition');
t.equal(monitorRecord.mode, 'large');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, 'Sprite1');
t.equal(monitorRecord.targetId, target.id);
let monitorId;
let monitorBlock;
// The monitor IDs for the sensing_current block should be unique
// to the parameter that is selected on the block being monitored.
// The paramater portion of the id should be lowercase even
// though the field value on the block is uppercase.
monitorId = 'current_date';
monitorRecord = vm.runtime._monitorState.get(monitorId);
t.equal(monitorRecord.opcode, 'sensing_current');
monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId);
t.equal(monitorBlock.fields.CURRENTMENU.value, 'DATE');
t.equal(monitorRecord.mode, 'default');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, null);
t.equal(monitorRecord.targetId, null);
monitorId = 'current_minute';
monitorRecord = vm.runtime._monitorState.get(monitorId);
t.equal(monitorRecord.opcode, 'sensing_current');
monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId);
t.equal(monitorBlock.fields.CURRENTMENU.value, 'MINUTE');
t.equal(monitorRecord.mode, 'default');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, null);
t.equal(monitorRecord.targetId, null);
monitorId = 'current_dayofweek';
monitorRecord = vm.runtime._monitorState.get(monitorId);
t.equal(monitorRecord.opcode, 'sensing_current');
monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId);
t.equal(monitorBlock.fields.CURRENTMENU.value, 'DAYOFWEEK');
t.equal(monitorRecord.mode, 'default');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, null);
t.equal(monitorRecord.targetId, null);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 100);
});
});
});

View File

@@ -0,0 +1,155 @@
const path = require('path');
const tap = require('tap');
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
let vm;
tap.beforeEach(() => {
const projectUri = path.resolve(__dirname, '../fixtures/monitors.sb2');
const project = readFileToBuffer(projectUri);
vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// TODO figure out why running threads doesn't work in this test
// vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
return vm.loadProject(project);
});
const test = tap.test;
test('saving and loading sb2 project with monitors preserves sliderMin and sliderMax', t => {
vm.on('playgroundData', e /* eslint-disable-line no-unused-vars */ => {
// TODO related to above TODO, comment these back in when we figure out
// why running threads doesn't work with this test
// const threads = JSON.parse(e.threads);
// All monitors should create threads that finish during the step and
// are revoved from runtime.threads.
// t.equal(threads.length, 0);
// we care that the last step updated the right number of monitors
// we don't care whether the last step ran other threads or not
// const lastStepUpdatedMonitorThreads = vm.runtime._lastStepDoneThreads.filter(thread => thread.updateMonitor);
// t.equal(lastStepUpdatedMonitorThreads.length, 8);
// There should be one additional hidden monitor that is in the monitorState but
// does not start a thread.
t.equal(vm.runtime._monitorState.size, 9);
const stage = vm.runtime.targets[0];
const target = vm.runtime.targets[1];
// Global variable named "global" is a slider
let variableId = Object.keys(stage.variables).filter(k => stage.variables[k].name === 'global')[0];
// Used later when checking save and load of slider min/max
let monitorRecord = vm.runtime._monitorState.get(variableId);
t.equal(monitorRecord.opcode, 'data_variable');
t.equal(monitorRecord.mode, 'slider');
t.equal(monitorRecord.sliderMin, -200); // Make sure these are imported for sliders.
t.equal(monitorRecord.sliderMax, 30);
t.equal(monitorRecord.isDiscrete, false);
t.equal(monitorRecord.x, 5); // These are imported for all monitors, just check once.
t.equal(monitorRecord.y, 59);
t.equal(monitorRecord.visible, true);
// Global variable named "global list" is a list
variableId = Object.keys(stage.variables).filter(k => stage.variables[k].name === 'global list')[0];
monitorRecord = vm.runtime._monitorState.get(variableId);
t.equal(monitorRecord.opcode, 'data_listcontents');
t.equal(monitorRecord.mode, 'list');
t.equal(monitorRecord.visible, true);
// Local variable named "local" is hidden
variableId = Object.keys(target.variables).filter(k => target.variables[k].name === 'local')[0];
monitorRecord = vm.runtime._monitorState.get(variableId);
t.equal(monitorRecord.opcode, 'data_variable');
t.equal(monitorRecord.mode, 'default');
t.equal(monitorRecord.visible, false);
// Local list named "local list" is visible
variableId = Object.keys(target.variables).filter(k => target.variables[k].name === 'local list')[0];
monitorRecord = vm.runtime._monitorState.get(variableId);
t.equal(monitorRecord.opcode, 'data_listcontents');
t.equal(monitorRecord.mode, 'list');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.width, 106); // Make sure these are imported from lists.
t.equal(monitorRecord.height, 206);
// Backdrop name monitor is visible, not sprite specific
// should get imported with id that references the name parameter
// via '_name' at the end since the 3.0 block has a dropdown.
monitorRecord = vm.runtime._monitorState.get('backdropnumbername_name');
t.equal(monitorRecord.opcode, 'looks_backdropnumbername');
t.equal(monitorRecord.mode, 'default');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, null);
t.equal(monitorRecord.targetId, null);
// x position monitor is in large mode, specific to sprite 1
monitorRecord = vm.runtime._monitorState.get(`${target.id}_xposition`);
t.equal(monitorRecord.opcode, 'motion_xposition');
t.equal(monitorRecord.mode, 'large');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, 'Sprite1');
t.equal(monitorRecord.targetId, target.id);
let monitorId;
let monitorBlock;
// The monitor IDs for the sensing_current block should be unique
// to the parameter that is selected on the block being monitored.
// The paramater portion of the id should be lowercase even
// though the field value on the block is uppercase.
monitorId = 'current_date';
monitorRecord = vm.runtime._monitorState.get(monitorId);
t.equal(monitorRecord.opcode, 'sensing_current');
monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId);
t.equal(monitorBlock.fields.CURRENTMENU.value, 'DATE');
t.equal(monitorRecord.mode, 'default');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, null);
t.equal(monitorRecord.targetId, null);
monitorId = 'current_minute';
monitorRecord = vm.runtime._monitorState.get(monitorId);
t.equal(monitorRecord.opcode, 'sensing_current');
monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId);
t.equal(monitorBlock.fields.CURRENTMENU.value, 'MINUTE');
t.equal(monitorRecord.mode, 'default');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, null);
t.equal(monitorRecord.targetId, null);
monitorId = 'current_dayofweek';
monitorRecord = vm.runtime._monitorState.get(monitorId);
t.equal(monitorRecord.opcode, 'sensing_current');
monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId);
t.equal(monitorBlock.fields.CURRENTMENU.value, 'DAYOFWEEK');
t.equal(monitorRecord.mode, 'default');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, null);
t.equal(monitorRecord.targetId, null);
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
const sb3ProjectJson = vm.toJSON();
return vm.loadProject(sb3ProjectJson).then(() => {
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 100);
});
});
});

View File

@@ -0,0 +1,268 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const Variable = require('../../src/engine/variable');
const projectUri = path.resolve(__dirname, '../fixtures/monitors.sb3');
const project = readFileToBuffer(projectUri);
test('importing sb3 project with monitors', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
// All monitors should create threads that finish during the step and
// are revoved from runtime.threads.
t.equal(threads.length, 0);
// we care that the last step updated the right number of monitors
// we don't care whether the last step ran other threads or not
const lastStepUpdatedMonitorThreads = vm.runtime._lastStepDoneThreads.filter(thread => thread.updateMonitor);
t.equal(lastStepUpdatedMonitorThreads.length, 17);
// There should be one additional hidden monitor that is in the monitorState but
// does not start a thread.
t.equal(vm.runtime._monitorState.size, 18);
const stage = vm.runtime.targets[0];
const shirtSprite = vm.runtime.targets[1];
const heartSprite = vm.runtime.targets[2];
// Global variable named "my variable" exists
let variableId = Object.keys(stage.variables).filter(k => stage.variables[k].name === 'my variable')[0];
let monitorRecord = vm.runtime._monitorState.get(variableId);
let monitorBlock = vm.runtime.monitorBlocks.getBlock(variableId);
t.equal(monitorRecord.opcode, 'data_variable');
t.equal(monitorRecord.mode, 'default');
// The following few properties are imported for all monitors, just check once.
t.equal(monitorRecord.sliderMin, 0);
t.equal(monitorRecord.sliderMax, 100);
t.equal(monitorRecord.isDiscrete, true); // The default if not present
t.equal(monitorRecord.x, 10);
t.equal(monitorRecord.y, 62);
// Height and width are only used for list monitors and should default to 0
// for all other monitors
t.equal(monitorRecord.width, 0);
t.equal(monitorRecord.height, 0);
t.equal(monitorRecord.visible, true);
t.type(monitorRecord.params, 'object');
// The variable name should be stored in the monitor params
t.equal(monitorRecord.params.VARIABLE, 'my variable');
// Test that the monitor block and its fields were constructed correctly
t.equal(monitorBlock.fields.VARIABLE.value, 'my variable');
t.equal(monitorBlock.fields.VARIABLE.name, 'VARIABLE');
t.equal(monitorBlock.fields.VARIABLE.id, variableId);
t.equal(monitorBlock.fields.VARIABLE.variableType, Variable.SCALAR_TYPE);
// There is a global variable named 'secret_slide' which has a hidden monitor
variableId = Object.keys(stage.variables).filter(k => stage.variables[k].name === 'secret_slide')[0];
monitorRecord = vm.runtime._monitorState.get(variableId);
monitorBlock = vm.runtime.monitorBlocks.getBlock(variableId);
t.equal(monitorRecord.opcode, 'data_variable');
t.equal(monitorRecord.mode, 'slider');
t.equal(monitorRecord.visible, false);
t.equal(monitorRecord.sliderMin, 0);
t.equal(monitorRecord.sliderMax, 100);
t.type(monitorRecord.params, 'object');
t.equal(monitorRecord.params.VARIABLE, 'secret_slide');
// Test that the monitor block and its fields were constructed correctly
t.equal(monitorBlock.fields.VARIABLE.value, 'secret_slide');
t.equal(monitorBlock.fields.VARIABLE.name, 'VARIABLE');
t.equal(monitorBlock.fields.VARIABLE.id, variableId);
t.equal(monitorBlock.fields.VARIABLE.variableType, Variable.SCALAR_TYPE);
// Shirt sprite has a local list named "fashion"
variableId = Object.keys(shirtSprite.variables).filter(k => shirtSprite.variables[k].name === 'fashion')[0];
monitorRecord = vm.runtime._monitorState.get(variableId);
monitorBlock = vm.runtime.monitorBlocks.getBlock(variableId);
t.equal(monitorRecord.opcode, 'data_listcontents');
t.equal(monitorRecord.mode, 'list');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.height, 122);
t.equal(monitorRecord.width, 104);
t.type(monitorRecord.params, 'object');
t.equal(monitorRecord.params.LIST, 'fashion'); // The list name should be stored in the monitor params
// Test that the monitor block and its fields were constructed correctly
t.equal(monitorBlock.fields.LIST.value, 'fashion');
t.equal(monitorBlock.fields.LIST.name, 'LIST');
t.equal(monitorBlock.fields.LIST.id, variableId);
t.equal(monitorBlock.fields.LIST.variableType, Variable.LIST_TYPE);
// Shirt sprite has a local variable named "tee"
variableId = Object.keys(shirtSprite.variables).filter(k => shirtSprite.variables[k].name === 'tee')[0];
monitorRecord = vm.runtime._monitorState.get(variableId);
monitorBlock = vm.runtime.monitorBlocks.getBlock(variableId);
t.equal(monitorRecord.opcode, 'data_variable');
t.equal(monitorRecord.mode, 'slider');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.sliderMin, 0);
t.equal(monitorRecord.sliderMax, 100);
t.type(monitorRecord.params, 'object');
t.equal(monitorRecord.params.VARIABLE, 'tee');
// Test that the monitor block and its fields were constructed correctly
t.equal(monitorBlock.fields.VARIABLE.value, 'tee');
t.equal(monitorBlock.fields.VARIABLE.name, 'VARIABLE');
t.equal(monitorBlock.fields.VARIABLE.id, variableId);
t.equal(monitorBlock.fields.VARIABLE.variableType, Variable.SCALAR_TYPE);
// Heart sprite has a local list named "hearty"
variableId = Object.keys(heartSprite.variables).filter(k => heartSprite.variables[k].name === 'hearty')[0];
monitorRecord = vm.runtime._monitorState.get(variableId);
monitorBlock = vm.runtime.monitorBlocks.getBlock(variableId);
t.equal(monitorRecord.opcode, 'data_variable');
t.equal(monitorRecord.mode, 'default');
t.equal(monitorRecord.visible, true);
t.type(monitorRecord.params, 'object');
t.equal(monitorRecord.params.VARIABLE, 'hearty'); // The variable name should be stored in the monitor params
// Test that the monitor block and its fields were constructed correctly
t.equal(monitorBlock.fields.VARIABLE.value, 'hearty');
t.equal(monitorBlock.fields.VARIABLE.name, 'VARIABLE');
t.equal(monitorBlock.fields.VARIABLE.id, variableId);
t.equal(monitorBlock.fields.VARIABLE.variableType, Variable.SCALAR_TYPE);
// Backdrop name monitor is visible, not sprite specific
// should get imported with id that references the name parameter
// via '_name' at the end since the 3.0 block has a dropdown.
let monitorId = 'backdropnumbername_name';
monitorRecord = vm.runtime._monitorState.get(monitorId);
monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId);
t.equal(monitorRecord.opcode, 'looks_backdropnumbername');
t.equal(monitorRecord.mode, 'default');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, null);
t.equal(monitorRecord.targetId, null);
// Test that the monitor block and its fields were constructed correctly
t.equal(monitorBlock.fields.NUMBER_NAME.value, 'name');
// Backdrop name monitor is visible, not sprite specific
// should get imported with id that references the name parameter
// via '_number' at the end since the 3.0 block has a dropdown.
monitorId = 'backdropnumbername_number';
monitorRecord = vm.runtime._monitorState.get(monitorId);
monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId);
t.equal(monitorRecord.opcode, 'looks_backdropnumbername');
t.equal(monitorRecord.mode, 'default');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, null);
t.equal(monitorRecord.targetId, null);
// Test that the monitor block and its fields were constructed correctly
t.equal(monitorBlock.fields.NUMBER_NAME.value, 'number');
// x position monitor is in large mode, specific to shirt sprite
monitorId = `${shirtSprite.id}_xposition`;
monitorRecord = vm.runtime._monitorState.get(monitorId);
monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId);
t.equal(monitorRecord.opcode, 'motion_xposition');
t.equal(monitorRecord.mode, 'large');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, 'Shirt-T');
t.equal(monitorRecord.targetId, shirtSprite.id);
// y position monitor is in large mode, specific to shirt sprite
monitorId = `${shirtSprite.id}_yposition`;
monitorRecord = vm.runtime._monitorState.get(monitorId);
monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId);
t.equal(monitorRecord.opcode, 'motion_yposition');
t.equal(monitorRecord.mode, 'large');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, 'Shirt-T');
t.equal(monitorRecord.targetId, shirtSprite.id);
// direction monitor is in large mode, specific to shirt sprite
monitorId = `${shirtSprite.id}_direction`;
monitorRecord = vm.runtime._monitorState.get(monitorId);
monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId);
t.equal(monitorRecord.opcode, 'motion_direction');
t.equal(monitorRecord.mode, 'large');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, 'Shirt-T');
t.equal(monitorRecord.targetId, shirtSprite.id);
monitorId = `${shirtSprite.id}_size`;
monitorRecord = vm.runtime._monitorState.get(monitorId);
monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId);
t.equal(monitorRecord.opcode, 'looks_size');
t.equal(monitorRecord.mode, 'large');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, 'Shirt-T');
t.equal(monitorRecord.targetId, shirtSprite.id);
// The monitor IDs for the sensing_current block should be unique
// to the parameter that is selected on the block being monitored.
// The paramater portion of the id should be lowercase even
// though the field value on the block is uppercase.
monitorId = 'current_date';
monitorRecord = vm.runtime._monitorState.get(monitorId);
t.equal(monitorRecord.opcode, 'sensing_current');
monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId);
t.equal(monitorBlock.fields.CURRENTMENU.value, 'DATE');
t.equal(monitorRecord.mode, 'default');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, null);
t.equal(monitorRecord.targetId, null);
monitorId = 'current_year';
monitorRecord = vm.runtime._monitorState.get(monitorId);
t.equal(monitorRecord.opcode, 'sensing_current');
monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId);
t.equal(monitorBlock.fields.CURRENTMENU.value, 'YEAR');
t.equal(monitorRecord.mode, 'default');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, null);
t.equal(monitorRecord.targetId, null);
monitorId = 'current_month';
monitorRecord = vm.runtime._monitorState.get(monitorId);
t.equal(monitorRecord.opcode, 'sensing_current');
monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId);
t.equal(monitorBlock.fields.CURRENTMENU.value, 'MONTH');
t.equal(monitorRecord.mode, 'default');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, null);
t.equal(monitorRecord.targetId, null);
// Extension Monitors
monitorId = 'music_getTempo';
monitorRecord = vm.runtime._monitorState.get(monitorId);
t.equal(monitorRecord.opcode, 'music_getTempo');
monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId);
t.equal(monitorRecord.mode, 'default');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, null);
t.equal(monitorRecord.targetId, null);
t.equal(vm.extensionManager.isExtensionLoaded('music'), true);
monitorId = 'ev3_getDistance';
monitorRecord = vm.runtime._monitorState.get(monitorId);
t.equal(monitorRecord.opcode, 'ev3_getDistance');
monitorBlock = vm.runtime.monitorBlocks.getBlock(monitorId);
t.equal(monitorRecord.mode, 'default');
t.equal(monitorRecord.visible, true);
t.equal(monitorRecord.spriteName, null);
t.equal(monitorRecord.targetId, null);
t.equal(vm.extensionManager.isExtensionLoaded('ev3'), true);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 100);
});
});
});

View File

@@ -0,0 +1,38 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const uri = path.resolve(__dirname, '../fixtures/motion.sb2');
const project = readFileToBuffer(uri);
test('motion', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.ok(threads.length > 0);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
// After two seconds, get playground data and stop
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 2000);
});
});
});

View File

@@ -0,0 +1,77 @@
/**
* @fileoverview
* This integration test ensures that a local upload of a sb2 project pulls
* in assets correctly (from the provided .sb2 file) even if the assets
* are not present on our servers.
*/
const path = require('path');
const fs = require('fs');
const test = require('tap').test;
const AdmZip = require('adm-zip');
const ScratchStorage = require('scratch-storage');
const VirtualMachine = require('../../src/index');
const projectUri = path.resolve(__dirname, '../fixtures/offline-custom-assets.sb2');
const projectZip = AdmZip(projectUri);
const project = Buffer.from(fs.readFileSync(projectUri));
// Custom costume from sb2 file (which was downloaded from offline editor)
// This sound should not be available on our servers
const costume = projectZip.readFile('1.svg');
const costumeData = new Uint8Array(costume);
// Custom sound recording from sb2 file (which was downloaded from offline editor)
// This sound should not be available on our servers
const sound = projectZip.readFile('0.wav');
const soundData = new Uint8Array(sound);
test('offline-custom-assets', t => {
const vm = new VirtualMachine();
// Use a test storage here that does not have any web sources added to it.
const testStorage = new ScratchStorage();
vm.attachStorage(testStorage);
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.ok(threads.length === 0);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
// Verify initial state
t.equals(vm.runtime.targets.length, 2);
const costumes = vm.runtime.targets[1].getCostumes();
t.equals(costumes.length, 1);
const customCostume = costumes[0];
t.equals(customCostume.name, 'A_Test_Costume');
const storedCostume = customCostume.asset;
t.type(storedCostume, 'object');
t.deepEquals(storedCostume.data, costumeData);
const sounds = vm.runtime.targets[1].sprite.sounds;
t.equals(sounds.length, 1);
const customSound = sounds[0];
t.equals(customSound.name, 'A_Test_Recording');
const storedSound = customSound.asset;
t.type(storedSound, 'object');
t.deepEquals(storedSound.data, soundData);
vm.greenFlag();
// After two seconds, get playground data and stop
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 2000);
});
});
});

View File

@@ -0,0 +1,61 @@
const Worker = require('tiny-worker');
const path = require('path');
const test = require('tap').test;
const Scratch3PenBlocks = require('../../src/extensions/scratch3_pen/index.js');
const VirtualMachine = require('../../src/index');
const dispatch = require('../../src/dispatch/central-dispatch');
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const uri = path.resolve(__dirname, '../fixtures/pen.sb2');
const project = readFileToBuffer(uri);
// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead.
dispatch.workerClass = Worker;
test('pen', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', () => {
// @todo Additional tests
const catSprite = vm.runtime.targets[1].sprite;
const [originalCat, cloneCat] = catSprite.clones;
t.notStrictEqual(originalCat, cloneCat);
/** @type {PenState} */
const originalPenState = originalCat.getCustomState(Scratch3PenBlocks.STATE_KEY);
/** @type {PenState} */
const clonePenState = cloneCat.getCustomState(Scratch3PenBlocks.STATE_KEY);
t.notStrictEqual(originalPenState, clonePenState);
t.equal(originalPenState.penAttributes.diameter, 51);
t.equal(clonePenState.penAttributes.diameter, 42);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project)
.then(() => {
vm.greenFlag();
// After two seconds, get playground data and stop
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 2000);
});
});
});

View File

@@ -0,0 +1,38 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const uri = path.resolve(__dirname, '../fixtures/procedure.sb2');
const project = readFileToBuffer(uri);
test('procedure', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.ok(threads.length === 0);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
// After two seconds, get playground data and stop
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 2000);
});
});
});

View File

@@ -0,0 +1,73 @@
const Worker = require('tiny-worker');
const path = require('path');
const test = require('tap').test;
const VirtualMachine = require('../../src/index');
const dispatch = require('../../src/dispatch/central-dispatch');
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
// it doesn't really matter which project we use: we're testing side effects of loading any project
const uri = path.resolve(__dirname, '../fixtures/default.sb3');
const project = readFileToBuffer(uri);
// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead.
dispatch.workerClass = Worker;
test('runId', async t => {
const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const isGuid = data => guidRegex.test(data);
const storage = makeTestStorage();
// add to this list every time the RunId should have changed
const runIdLog = [];
const pushRunId = () => {
const runId = storage.scratchFetch.getMetadata(storage.scratchFetch.RequestMetadata.RunId);
t.ok(isGuid(runId), 'Run IDs should always be a properly-formatted GUID', {runId});
runIdLog.push(runId);
};
const vm = new VirtualMachine();
vm.attachStorage(storage);
pushRunId(); // check that the initial run ID is valid
vm.start(); // starts the VM, not the project, so this doesn't change the run ID
vm.clear();
pushRunId(); // clearing the project conceptually changes the project identity does it DOES change the run ID
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
await vm.loadProject(project);
pushRunId();
vm.greenFlag();
pushRunId();
// Turn the playgroundData event into a Promise that we can await
const playgroundDataPromise = new Promise(resolve => {
vm.on('playgroundData', data => resolve(data));
});
// Let the project run for a bit, then get playground data and stop the project
// This test doesn't need the playground data but it does need to run & stop the project
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
pushRunId();
}, 100);
// wait for the project to run to completion
await playgroundDataPromise;
for (let i = 0; i < runIdLog.length - 1; ++i) {
for (let j = i + 1; j < runIdLog.length; ++j) {
t.notSame(runIdLog[i], runIdLog[j], 'Run IDs should always be unique', {runIdLog});
}
}
vm.quit();
t.end();
});

View File

@@ -0,0 +1,48 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const uri = path.resolve(__dirname, '../fixtures/looks.sb2');
const project = readFileToBuffer(uri);
test('Running project should not emit project changed event', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
let projectChanged = false;
vm.on('PROJECT_CHANGED', () => {
projectChanged = true;
});
// Evaluate playground data and exit
vm.on('playgroundData', () => {
t.equal(projectChanged, false);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
// The test in unit/project_load_changed_state.js tests
// that loading a project does not emit a project changed
// event. This setup tries to be agnostic of whether that
// test is passing or failing.
projectChanged = false;
vm.greenFlag();
// After two seconds, get playground data and stop
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 2000);
});
});
});

View File

@@ -0,0 +1,37 @@
const Worker = require('tiny-worker');
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const dispatch = require('../../src/dispatch/central-dispatch');
const uri = path.resolve(__dirname, '../fixtures/saythink-and-wait.sb2');
const project = readFileToBuffer(uri);
// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead.
dispatch.workerClass = Worker;
test('say/think and wait', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
// After two seconds, stop the project.
// The test will fail if the project throws.
setTimeout(() => {
vm.stopAll();
vm.quit();
t.end();
}, 2000);
});
});
});

View File

@@ -0,0 +1,111 @@
const path = require('path');
const tap = require('tap');
const test = tap.test;
const makeTestStorage = require('../fixtures/make-test-storage');
const {readFileToBuffer, extractProjectJson} = require('../fixtures/readProjectFile');
const VirtualMachine = require('../../src/index');
const sb2 = require('../../src/serialization/sb2');
const invisibleVideoMonitorProjectUri = path.resolve(__dirname, '../fixtures/invisible-video-monitor.sb2');
const invisibleVideoMonitorProject = readFileToBuffer(invisibleVideoMonitorProjectUri);
const visibleVideoMonitorProjectUri = path.resolve(
__dirname, '../fixtures/visible-video-monitor-no-other-video-blocks.sb2');
const visibleVideoMonitorProject = readFileToBuffer(visibleVideoMonitorProjectUri);
const visibleVideoMonitorAndBlocksProjectUri = path.resolve(
__dirname, '../fixtures/visible-video-monitor-and-video-blocks.sb2');
const visibleVideoMonitorAndBlocksProject = extractProjectJson(visibleVideoMonitorAndBlocksProjectUri);
const invisibleTempoMonitorProjectUri = path.resolve(
__dirname, '../fixtures/invisible-tempo-monitor-no-other-music-blocks.sb2');
const invisibleTempoMonitorProject = readFileToBuffer(invisibleTempoMonitorProjectUri);
const visibleTempoMonitorProjectUri = path.resolve(
__dirname, '../fixtures/visible-tempo-monitor-no-other-music-blocks.sb2');
const visibleTempoMonitorProject = readFileToBuffer(visibleTempoMonitorProjectUri);
test('loading sb2 project with invisible video monitor should not load monitor or extension', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(invisibleVideoMonitorProject).then(() => {
t.equal(vm.extensionManager.isExtensionLoaded('videoSensing'), false);
t.equal(vm.runtime._monitorState.size, 0);
vm.quit();
t.end();
});
});
test('loading sb2 project with visible video monitor should not load extension', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(visibleVideoMonitorProject).then(() => {
t.equal(vm.extensionManager.isExtensionLoaded('videoSensing'), false);
t.equal(vm.runtime._monitorState.size, 0);
vm.quit();
t.end();
});
});
// This test looks a little different than the rest because loading a project with
// the video sensing block requires a mock renderer and other setup, so instead
// we are just using deserialize to test what we need instead
test('sb2 project with video sensing blocks and monitor should load extension but not monitor', t => {
const vm = new VirtualMachine();
sb2.deserialize(visibleVideoMonitorAndBlocksProject, vm.runtime).then(project => {
// Extension loads but monitor does not
project.extensions.extensionIDs.has('videoSensing');
// Non-core extension monitors haven't been added to the runtime
t.equal(vm.runtime._monitorState.size, 0);
t.end();
});
});
test('sb2 project with invisible music monitor should not load monitor or extension', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(invisibleTempoMonitorProject).then(() => {
t.equal(vm.extensionManager.isExtensionLoaded('music'), false);
t.equal(vm.runtime._monitorState.size, 0);
vm.quit();
t.end();
});
});
test('sb2 project with visible music monitor should load monitor and extension', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Start VM, load project, and run
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(visibleTempoMonitorProject).then(() => {
t.equal(vm.extensionManager.isExtensionLoaded('music'), true);
t.equal(vm.runtime._monitorState.size, 1);
t.equal(vm.runtime._monitorState.has('music_getTempo'), true);
t.equal(vm.runtime._monitorState.get('music_getTempo').visible, true);
vm.quit();
t.end();
});
});

View File

@@ -0,0 +1,127 @@
/**
* This test mocks render breaking on loading a corrupted bitmap costume.
* The VM should handle this safely by displaying a Gray Question Mark,
* but keeping track of the original costume data and serializing the
* original costume data back out. The saved project.json should not
* reflect that the costume is broken and should therefore re-attempt
* to load the costume if the saved project is re-loaded.
*/
const path = require('path');
const tap = require('tap');
const md5 = require('js-md5');
const makeTestStorage = require('../fixtures/make-test-storage');
const FakeRenderer = require('../fixtures/fake-renderer');
const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
const {extractAsset, readFileToBuffer} = require('../fixtures/readProjectFile');
const VirtualMachine = require('../../src/index');
const {serializeCostumes} = require('../../src/serialization/serialize-assets');
const projectUri = path.resolve(__dirname, '../fixtures/corrupt_png.sb2');
const project = readFileToBuffer(projectUri);
const costumeFileName = '1.png';
const originalCostume = extractAsset(projectUri, costumeFileName);
// We need to get the actual md5 because we hand modified the png to corrupt it
// after we downloaded the project from Scratch
// Loading the project back into the VM will correct the assetId and md5
const brokenCostumeMd5 = md5(originalCostume);
global.Image = function () {
const image = {
width: 1,
height: 1
};
setTimeout(() => {
const base64Image = image.src.split(',')[1];
const decodedText = Buffer.from(base64Image, 'base64').toString();
if (decodedText.includes('Here is some')) {
image.onerror();
} else {
image.onload();
}
}, 100);
return image;
};
global.document = {
createElement: () => ({
// Create mock canvas
getContext: () => ({
drawImage: () => ({})
})
})
};
let vm;
let defaultBitmapAssetId;
tap.setTimeout(30000);
tap.beforeEach(() => {
const storage = makeTestStorage();
vm = new VirtualMachine();
vm.attachStorage(storage);
defaultBitmapAssetId = vm.runtime.storage.defaultAssetId.ImageBitmap;
vm.attachRenderer(new FakeRenderer());
vm.attachV2BitmapAdapter(new FakeBitmapAdapter());
return vm.loadProject(project);
});
const test = tap.test;
test('load sb2 project with corrupted bitmap costume file', t => {
t.equal(vm.runtime.targets.length, 2);
const stage = vm.runtime.targets[0];
t.ok(stage.isStage);
const greenGuySprite = vm.runtime.targets[1];
t.equal(greenGuySprite.getName(), 'GreenGuy');
t.equal(greenGuySprite.getCostumes().length, 1);
const corruptedCostume = greenGuySprite.getCostumes()[0];
t.equal(corruptedCostume.name, 'GreenGuy');
t.equal(corruptedCostume.assetId, defaultBitmapAssetId);
t.equal(corruptedCostume.dataFormat, 'png');
// Runtime should have info about broken asset
t.ok(corruptedCostume.broken);
t.equal(corruptedCostume.broken.assetId, brokenCostumeMd5);
// Verify that we saved the original asset data
t.equal(md5(corruptedCostume.broken.asset.data), brokenCostumeMd5);
t.end();
});
test('load and then save project with corrupted bitmap costume file', t => {
const resavedProject = JSON.parse(vm.toJSON());
t.equal(resavedProject.targets.length, 2);
const stage = resavedProject.targets[0];
t.ok(stage.isStage);
const greenGuySprite = resavedProject.targets[1];
t.equal(greenGuySprite.name, 'GreenGuy');
t.equal(greenGuySprite.costumes.length, 1);
const corruptedCostume = greenGuySprite.costumes[0];
t.equal(corruptedCostume.name, 'GreenGuy');
// Resaved project costume should have the metadata that corresponds to the original broken costume
t.equal(corruptedCostume.assetId, brokenCostumeMd5);
t.equal(corruptedCostume.dataFormat, 'png');
// Test that we didn't save any data about the costume being broken
t.notOk(corruptedCostume.broken);
t.end();
});
test('serializeCostume saves orignal broken costume', t => {
const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[1].id);
t.equal(costumeDescs.length, 1);
const costume = costumeDescs[0];
t.equal(costume.fileName, `${brokenCostumeMd5}.png`);
t.equal(md5(costume.fileContent), brokenCostumeMd5);
t.end();
});

View File

@@ -0,0 +1,127 @@
/**
* This test mocks render breaking on loading a corrupted vector costume.
* The VM should handle this safely by displaying a Gray Question Mark,
* but keeping track of the original costume data and serializing the
* original costume data back out. The saved project.json should not
* reflect that the costume is broken and should therefore re-attempt
* to load the costume if the saved project is re-loaded.
*/
const path = require('path');
const tap = require('tap');
const md5 = require('js-md5');
const makeTestStorage = require('../fixtures/make-test-storage');
const FakeRenderer = require('../fixtures/fake-renderer');
const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
const {extractAsset, readFileToBuffer} = require('../fixtures/readProjectFile');
const VirtualMachine = require('../../src/index');
const {serializeCostumes} = require('../../src/serialization/serialize-assets');
const projectUri = path.resolve(__dirname, '../fixtures/corrupt_svg.sb2');
const project = readFileToBuffer(projectUri);
const costumeFileName = '1.svg';
const originalCostume = extractAsset(projectUri, costumeFileName);
// We need to get the actual md5 because we hand modified the svg to corrupt it
// after we downloaded the project from Scratch
// Loading the project back into the VM will correct the assetId and md5
const brokenCostumeMd5 = md5(originalCostume);
global.Image = function () {
const image = {
width: 1,
height: 1
};
setTimeout(() => image.onload(), 1000);
return image;
};
global.document = {
createElement: () => ({
// Create mock canvas
getContext: () => ({
drawImage: () => ({})
})
})
};
let vm;
let defaultVectorAssetId;
tap.beforeEach(() => {
const storage = makeTestStorage();
vm = new VirtualMachine();
vm.attachStorage(storage);
defaultVectorAssetId = vm.runtime.storage.defaultAssetId.ImageVector;
// Mock renderer breaking on loading a corrupt costume
FakeRenderer.prototype.createSVGSkin = function (svgString) {
// Look for text added to costume to make it a corrupt svg
if (svgString.includes('<here is some')) {
throw new Error('mock createSVGSkin broke');
}
return FakeRenderer._nextSkinId++;
};
vm.attachRenderer(new FakeRenderer());
vm.attachV2BitmapAdapter(new FakeBitmapAdapter());
return vm.loadProject(project);
});
const test = tap.test;
test('load sb2 project with corrupted vector costume file', t => {
t.equal(vm.runtime.targets.length, 2);
const stage = vm.runtime.targets[0];
t.ok(stage.isStage);
const blueGuySprite = vm.runtime.targets[1];
t.equal(blueGuySprite.getName(), 'Blue Guy');
t.equal(blueGuySprite.getCostumes().length, 1);
const corruptedCostume = blueGuySprite.getCostumes()[0];
t.equal(corruptedCostume.name, 'Blue Guy 2');
t.equal(corruptedCostume.assetId, defaultVectorAssetId);
t.equal(corruptedCostume.dataFormat, 'svg');
// Runtime should have info about broken asset
t.ok(corruptedCostume.broken);
t.equal(corruptedCostume.broken.assetId, brokenCostumeMd5);
// Verify that we saved the original asset data
t.equal(md5(corruptedCostume.broken.asset.data), brokenCostumeMd5);
t.end();
});
test('load and then save project with corrupted vector costume file', t => {
const resavedProject = JSON.parse(vm.toJSON());
t.equal(resavedProject.targets.length, 2);
const stage = resavedProject.targets[0];
t.ok(stage.isStage);
const blueGuySprite = resavedProject.targets[1];
t.equal(blueGuySprite.name, 'Blue Guy');
t.equal(blueGuySprite.costumes.length, 1);
const corruptedCostume = blueGuySprite.costumes[0];
t.equal(corruptedCostume.name, 'Blue Guy 2');
// Resaved project costume should have the metadata that corresponds to the original broken costume
t.equal(corruptedCostume.assetId, brokenCostumeMd5);
t.equal(corruptedCostume.dataFormat, 'svg');
// Test that we didn't save any data about the costume being broken
t.notOk(corruptedCostume.broken);
t.end();
});
test('serializeCostume saves orignal broken costume', t => {
const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[1].id);
t.equal(costumeDescs.length, 1);
const costume = costumeDescs[0];
t.equal(costume.fileName, `${brokenCostumeMd5}.svg`);
t.equal(md5(costume.fileContent), brokenCostumeMd5);
t.end();
});

View File

@@ -0,0 +1,112 @@
/**
* This test ensures that the VM gracefully handles an sb2 project with
* a missing bitmap costume. The VM should handle this safely by displaying
* a Gray Question Mark, but keeping track of the original costume data
* and serializing the original costume data back out. The saved project.json
* should not reflect that the costume is broken and should therefore re-attempt
* to load the costume if the saved project is re-loaded.
*/
const path = require('path');
const tap = require('tap');
const makeTestStorage = require('../fixtures/make-test-storage');
const FakeRenderer = require('../fixtures/fake-renderer');
const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const {serializeCostumes} = require('../../src/serialization/serialize-assets');
const projectUri = path.resolve(__dirname, '../fixtures/missing_png.sb2');
const project = readFileToBuffer(projectUri);
const missingCostumeAssetId = 'aadce129bfe4e57f0dd81478f3ed82aa';
global.Image = function () {
const image = {
width: 1,
height: 1
};
setTimeout(() => image.onload(), 100);
return image;
};
global.document = {
createElement: () => ({
// Create mock canvas
getContext: () => ({
drawImage: () => ({})
})
})
};
let vm;
tap.setTimeout(30000);
tap.beforeEach(() => {
const storage = makeTestStorage();
vm = new VirtualMachine();
vm.attachStorage(storage);
vm.attachRenderer(new FakeRenderer());
vm.attachV2BitmapAdapter(new FakeBitmapAdapter());
return vm.loadProject(project);
});
const test = tap.test;
test('loading sb2 project with missing bitmap costume file', t => {
t.equal(vm.runtime.targets.length, 2);
const stage = vm.runtime.targets[0];
t.ok(stage.isStage);
const greenGuySprite = vm.runtime.targets[1];
t.equal(greenGuySprite.getName(), 'GreenGuy');
t.equal(greenGuySprite.getCostumes().length, 1);
const missingCostume = greenGuySprite.getCostumes()[0];
t.equal(missingCostume.name, 'GreenGuy');
// Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
const defaultVectorAssetId = vm.runtime.storage.defaultAssetId.ImageBitmap;
t.equal(missingCostume.assetId, defaultVectorAssetId);
t.equal(missingCostume.dataFormat, 'png');
// Runtime should have info about broken asset
t.ok(missingCostume.broken);
t.equal(missingCostume.broken.assetId, missingCostumeAssetId);
t.end();
});
test('load and then save sb2 project with missing costume file', t => {
const resavedProject = JSON.parse(vm.toJSON());
t.equal(resavedProject.targets.length, 2);
const stage = resavedProject.targets[0];
t.ok(stage.isStage);
const greenGuySprite = resavedProject.targets[1];
t.equal(greenGuySprite.name, 'GreenGuy');
t.equal(greenGuySprite.costumes.length, 1);
const missingCostume = greenGuySprite.costumes[0];
t.equal(missingCostume.name, 'GreenGuy');
// Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
t.equal(missingCostume.assetId, missingCostumeAssetId);
t.equal(missingCostume.dataFormat, 'png');
// Test that we didn't save any data about the costume being broken
t.notOk(missingCostume.broken);
t.end();
});
test('serializeCostume does not save data for missing costume', t => {
const costumeDescs = serializeCostumes(vm.runtime);
t.equal(costumeDescs.length, 1); // Should only have one costume, the backdrop
t.not(costumeDescs[0].fileName, `${missingCostumeAssetId}.png`);
t.end();
});

View File

@@ -0,0 +1,109 @@
/**
* This test ensures that the VM gracefully handles an sb2 project with
* a missing vector costume. The VM should handle this safely by displaying
* a Gray Question Mark, but keeping track of the original costume data
* and serializing the original costume data back out. The saved project.json
* should not reflect that the costume is broken and should therefore re-attempt
* to load the costume if the saved project is re-loaded.
*/
const path = require('path');
const tap = require('tap');
const makeTestStorage = require('../fixtures/make-test-storage');
const FakeRenderer = require('../fixtures/fake-renderer');
const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const {serializeCostumes} = require('../../src/serialization/serialize-assets');
const projectUri = path.resolve(__dirname, '../fixtures/missing_svg.sb2');
const project = readFileToBuffer(projectUri);
const missingCostumeAssetId = 'beca8009621913e2f5b3111eed2d8210';
global.Image = function () {
const image = {
width: 1,
height: 1
};
setTimeout(() => image.onload(), 1000);
return image;
};
global.document = {
createElement: () => ({
// Create mock canvas
getContext: () => ({
drawImage: () => ({})
})
})
};
let vm;
tap.beforeEach(() => {
const storage = makeTestStorage();
vm = new VirtualMachine();
vm.attachStorage(storage);
vm.attachRenderer(new FakeRenderer());
vm.attachV2BitmapAdapter(new FakeBitmapAdapter());
return vm.loadProject(project);
});
const test = tap.test;
test('loading sb2 project with missing vector costume file', t => {
t.equal(vm.runtime.targets.length, 2);
const stage = vm.runtime.targets[0];
t.ok(stage.isStage);
const blueGuySprite = vm.runtime.targets[1];
t.equal(blueGuySprite.getName(), 'Blue Guy');
t.equal(blueGuySprite.getCostumes().length, 1);
const missingCostume = blueGuySprite.getCostumes()[0];
t.equal(missingCostume.name, 'Blue Guy 2');
// Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
const defaultVectorAssetId = vm.runtime.storage.defaultAssetId.ImageVector;
t.equal(missingCostume.assetId, defaultVectorAssetId);
t.equal(missingCostume.dataFormat, 'svg');
// Runtime should have info about broken asset
t.ok(missingCostume.broken);
t.equal(missingCostume.broken.assetId, missingCostumeAssetId);
t.end();
});
test('load and then save sb2 project with missing costume file', t => {
const resavedProject = JSON.parse(vm.toJSON());
t.equal(resavedProject.targets.length, 2);
const stage = resavedProject.targets[0];
t.ok(stage.isStage);
const blueGuySprite = resavedProject.targets[1];
t.equal(blueGuySprite.name, 'Blue Guy');
t.equal(blueGuySprite.costumes.length, 1);
const missingCostume = blueGuySprite.costumes[0];
t.equal(missingCostume.name, 'Blue Guy 2');
// Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
t.equal(missingCostume.assetId, missingCostumeAssetId);
t.equal(missingCostume.dataFormat, 'svg');
// Test that we didn't save any data about the costume being broken
t.notOk(missingCostume.broken);
t.end();
});
test('serializeCostume does not save data for missing costume', t => {
const costumeDescs = serializeCostumes(vm.runtime);
t.equal(costumeDescs.length, 1); // Should only have one costume, the backdrop
t.not(costumeDescs[0].fileName, `${missingCostumeAssetId}.svg`);
t.end();
});

View File

@@ -0,0 +1,110 @@
const test = require('tap').test;
const Blocks = require('../../src/engine/blocks');
const Clone = require('../../src/util/clone');
const {loadCostume} = require('../../src/import/load-costume');
const {loadSound} = require('../../src/import/load-sound');
const makeTestStorage = require('../fixtures/make-test-storage');
const Runtime = require('../../src/engine/runtime');
const sb3 = require('../../src/serialization/sb3');
const Sprite = require('../../src/sprites/sprite');
const defaultCostumeInfo = {
bitmapResolution: 1,
rotationCenterX: 0,
rotationCenterY: 0
};
const defaultSoundInfo = {
};
test('sb3-roundtrip', t => {
const runtime1 = new Runtime();
runtime1.attachStorage(makeTestStorage());
const runtime2 = new Runtime();
runtime2.attachStorage(makeTestStorage());
const testRuntimeState = (label, runtime) => {
t.strictEqual(runtime.targets.length, 2, `${label}: target count`);
const [stageClone, spriteClone] = runtime.targets;
t.strictEqual(stageClone.isOriginal, true);
t.strictEqual(stageClone.isStage, true);
const stage = stageClone.sprite;
t.strictEqual(stage.name, 'Stage');
t.strictEqual(stage.clones.length, 1);
t.strictEqual(stage.clones[0], stageClone);
t.strictEqual(stage.costumes.length, 1);
const [building] = stage.costumes;
t.strictEqual(building.assetId, 'fe5e3566965f9de793beeffce377d054');
t.strictEqual(building.dataFormat, 'jpg');
t.strictEqual(stage.sounds.length, 0);
t.strictEqual(spriteClone.isOriginal, true);
t.strictEqual(spriteClone.isStage, false);
const sprite = spriteClone.sprite;
t.strictEqual(sprite.name, 'Sprite');
t.strictEqual(sprite.clones.length, 1);
t.strictEqual(sprite.clones[0], spriteClone);
t.strictEqual(sprite.costumes.length, 2);
const [cat, squirrel] = sprite.costumes;
t.strictEqual(cat.assetId, 'f88bf1935daea28f8ca098462a31dbb0');
t.strictEqual(cat.dataFormat, 'svg');
t.strictEqual(squirrel.assetId, '7e24c99c1b853e52f8e7f9004416fa34');
t.strictEqual(squirrel.dataFormat, 'png');
t.strictEqual(sprite.sounds.length, 1);
const [meow] = sprite.sounds;
t.strictEqual(meow.md5, '83c36d806dc92327b9e7049a565c6bff.wav');
};
const loadThings = Promise.all([
loadCostume('fe5e3566965f9de793beeffce377d054.jpg', Clone.simple(defaultCostumeInfo), runtime1),
loadCostume('f88bf1935daea28f8ca098462a31dbb0.svg', Clone.simple(defaultCostumeInfo), runtime1),
loadCostume('7e24c99c1b853e52f8e7f9004416fa34.png', Clone.simple(defaultCostumeInfo), runtime1),
loadSound(Object.assign({md5: '83c36d806dc92327b9e7049a565c6bff.wav'}, defaultSoundInfo), runtime1)
]);
const installThings = loadThings.then(results => {
const [building, cat, squirrel, meow] = results;
const stageBlocks = new Blocks(runtime1);
const stage = new Sprite(stageBlocks, runtime1);
stage.name = 'Stage';
stage.costumes = [building];
stage.sounds = [];
const stageClone = stage.createClone();
stageClone.isStage = true;
const spriteBlocks = new Blocks(runtime1);
const sprite = new Sprite(spriteBlocks, runtime1);
sprite.name = 'Sprite';
sprite.costumes = [cat, squirrel];
sprite.sounds = [meow];
const spriteClone = sprite.createClone();
runtime1.targets = [stageClone, spriteClone];
testRuntimeState('original', runtime1);
});
const serializeAndDeserialize = installThings.then(() => {
// Doing a JSON `stringify` and `parse` here more accurately simulate a save/load cycle. In particular:
// 1. it ensures that any non-serializable data is thrown away, and
// 2. `sb3.deserialize` and its helpers do some `hasOwnProperty` checks which fail on the object returned by
// `sb3.serialize` but succeed if that object is "flattened" in this way.
const serializedState = JSON.parse(JSON.stringify(sb3.serialize(runtime1)));
return sb3.deserialize(serializedState, runtime2);
});
return serializeAndDeserialize.then(({targets}) => {
runtime2.targets = targets;
testRuntimeState('copy', runtime2);
});
});

View File

@@ -0,0 +1,127 @@
/**
* This test mocks render breaking on loading a corrupted bitmap costume.
* The VM should handle this safely by displaying a Gray Question Mark,
* but keeping track of the original costume data and serializing the
* original costume data back out. The saved project.json should not
* reflect that the costume is broken and should therefore re-attempt
* to load the costume if the saved project is re-loaded.
*/
const path = require('path');
const tap = require('tap');
const md5 = require('js-md5');
const makeTestStorage = require('../fixtures/make-test-storage');
const FakeRenderer = require('../fixtures/fake-renderer');
const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
const {extractAsset, readFileToBuffer} = require('../fixtures/readProjectFile');
const VirtualMachine = require('../../src/index');
const {serializeCostumes} = require('../../src/serialization/serialize-assets');
const projectUri = path.resolve(__dirname, '../fixtures/corrupt_png.sb3');
const project = readFileToBuffer(projectUri);
const costumeFileName = 'e1320c21995dcf6de10119be7f08c26b.png';
const originalCostume = extractAsset(projectUri, costumeFileName);
// We need to get the actual md5 because we hand modified the png to corrupt it
// after we downloaded the project from Scratch
// Loading the project back into the VM will correct the assetId and md5
const brokenCostumeMd5 = md5(originalCostume);
global.Image = function () {
const image = {
width: 1,
height: 1
};
setTimeout(() => {
const base64Image = image.src.split(',')[1];
const decodedText = Buffer.from(base64Image, 'base64').toString();
if (decodedText.includes('Here is some')) {
image.onerror();
} else {
image.onload();
}
}, 100);
return image;
};
global.document = {
createElement: () => ({
// Create mock canvas
getContext: () => ({
drawImage: () => ({})
})
})
};
let vm;
let defaultBitmapAssetId;
tap.setTimeout(30000);
tap.beforeEach(() => {
const storage = makeTestStorage();
vm = new VirtualMachine();
vm.attachStorage(storage);
defaultBitmapAssetId = vm.runtime.storage.defaultAssetId.ImageBitmap;
vm.attachRenderer(new FakeRenderer());
vm.attachV2BitmapAdapter(new FakeBitmapAdapter());
return vm.loadProject(project);
});
const test = tap.test;
test('load sb3 project with corrupted bitmap costume file', t => {
t.equal(vm.runtime.targets.length, 2);
const stage = vm.runtime.targets[0];
t.ok(stage.isStage);
const greenGuySprite = vm.runtime.targets[1];
t.equal(greenGuySprite.getName(), 'Green Guy');
t.equal(greenGuySprite.getCostumes().length, 1);
const corruptedCostume = greenGuySprite.getCostumes()[0];
t.equal(corruptedCostume.name, 'Green Guy');
t.equal(corruptedCostume.assetId, defaultBitmapAssetId);
t.equal(corruptedCostume.dataFormat, 'png');
// Runtime should have info about broken asset
t.ok(corruptedCostume.broken);
t.equal(corruptedCostume.broken.assetId, brokenCostumeMd5);
// Verify that we saved the original asset data
t.equal(md5(corruptedCostume.broken.asset.data), brokenCostumeMd5);
t.end();
});
test('load and then save project with corrupted bitmap costume file', t => {
const resavedProject = JSON.parse(vm.toJSON());
t.equal(resavedProject.targets.length, 2);
const stage = resavedProject.targets[0];
t.ok(stage.isStage);
const greenGuySprite = resavedProject.targets[1];
t.equal(greenGuySprite.name, 'Green Guy');
t.equal(greenGuySprite.costumes.length, 1);
const corruptedCostume = greenGuySprite.costumes[0];
t.equal(corruptedCostume.name, 'Green Guy');
// Resaved project costume should have the metadata that corresponds to the original broken costume
t.equal(corruptedCostume.assetId, brokenCostumeMd5);
t.equal(corruptedCostume.dataFormat, 'png');
// Test that we didn't save any data about the costume being broken
t.notOk(corruptedCostume.broken);
t.end();
});
test('serializeCostume saves orignal broken costume', t => {
const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[1].id);
t.equal(costumeDescs.length, 1);
const costume = costumeDescs[0];
t.equal(costume.fileName, `${brokenCostumeMd5}.png`);
t.equal(md5(costume.fileContent), brokenCostumeMd5);
t.end();
});

View File

@@ -0,0 +1,119 @@
/**
* This test mocks breaking on loading a corrupted sound.
* The VM should handle this safely by replacing the sound data with the default (empty) sound,
* but keeping track of the original sound data and serializing the
* original sound data back out. The saved project.json should not
* reflect that the sound is broken and should therefore re-attempt
* to load the sound if the saved project is re-loaded.
*/
const path = require('path');
const tap = require('tap');
const md5 = require('js-md5');
const makeTestStorage = require('../fixtures/make-test-storage');
const {extractAsset, readFileToBuffer} = require('../fixtures/readProjectFile');
const VirtualMachine = require('../../src/index');
const {serializeSounds} = require('../../src/serialization/serialize-assets');
const projectUri = path.resolve(__dirname, '../fixtures/corrupt_sound.sb3');
const project = readFileToBuffer(projectUri);
const soundFileName = '78618aadd225b1db7bf837fa17dc0568.wav';
const originalSound = extractAsset(projectUri, soundFileName);
// We need to get the actual md5 because we hand modified the sound file to corrupt it
// after we downloaded the project from Scratch
// Loading the project back into the VM will correct the assetId and md5
const brokenSoundMd5 = md5(originalSound);
let fakeId = -1;
const FakeAudioEngine = function () {
return {
decodeSoundPlayer: soundData => {
const soundDataString = soundData.asset.decodeText();
if (soundDataString.includes('here is some')) {
return Promise.reject(new Error('mock audio engine broke'));
}
// Otherwise return fake data
return Promise.resolve({
id: fakeId++,
buffer: {
sampleRate: 1,
length: 1
}
});
},
createBank: () => null
};
};
let vm;
let defaultSoundAssetId;
tap.beforeEach(() => {
const storage = makeTestStorage();
vm = new VirtualMachine();
vm.attachStorage(storage);
defaultSoundAssetId = vm.runtime.storage.defaultAssetId.Sound;
vm.attachAudioEngine(FakeAudioEngine());
return vm.loadProject(project);
});
const test = tap.test;
test('load sb3 project with corrupted sound file', t => {
t.equal(vm.runtime.targets.length, 2);
const stage = vm.runtime.targets[0];
t.ok(stage.isStage);
const catSprite = vm.runtime.targets[1];
t.equal(catSprite.getName(), 'Sprite1');
t.equal(catSprite.getSounds().length, 1);
const corruptedSound = catSprite.getSounds()[0];
t.equal(corruptedSound.name, 'Boop Sound Recording');
t.equal(corruptedSound.assetId, defaultSoundAssetId);
t.equal(corruptedSound.dataFormat, 'wav');
// Runtime should have info about broken asset
t.ok(corruptedSound.broken);
t.equal(corruptedSound.broken.assetId, brokenSoundMd5);
// Verify that we saved the original asset data
t.equal(md5(corruptedSound.broken.asset.data), brokenSoundMd5);
t.end();
});
test('load and then save project with corrupted sound file', t => {
const resavedProject = JSON.parse(vm.toJSON());
t.equal(resavedProject.targets.length, 2);
const stage = resavedProject.targets[0];
t.ok(stage.isStage);
const catSprite = resavedProject.targets[1];
t.equal(catSprite.name, 'Sprite1');
t.equal(catSprite.sounds.length, 1);
const corruptedSound = catSprite.sounds[0];
t.equal(corruptedSound.name, 'Boop Sound Recording');
// Resaved project costume should have the metadata that corresponds to the original broken costume
t.equal(corruptedSound.assetId, brokenSoundMd5);
t.equal(corruptedSound.dataFormat, 'wav');
// Test that we didn't save any data about the costume being broken
t.notOk(corruptedSound.broken);
t.end();
});
test('serializeSounds saves orignal broken sound', t => {
const soundDescs = serializeSounds(vm.runtime, vm.runtime.targets[1].id);
t.equal(soundDescs.length, 1);
const sound = soundDescs[0];
t.equal(sound.fileName, `${brokenSoundMd5}.wav`);
t.equal(md5(sound.fileContent), brokenSoundMd5);
t.end();
});

View File

@@ -0,0 +1,106 @@
/**
* This test mocks render breaking on loading a corrupted vector costume.
* The VM should handle this safely by displaying a Gray Question Mark,
* but keeping track of the original costume data and serializing the
* original costume data back out. The saved project.json should not
* reflect that the costume is broken and should therefore re-attempt
* to load the costume if the saved project is re-loaded.
*/
const path = require('path');
const tap = require('tap');
const md5 = require('js-md5');
const makeTestStorage = require('../fixtures/make-test-storage');
const FakeRenderer = require('../fixtures/fake-renderer');
const {extractAsset, readFileToBuffer} = require('../fixtures/readProjectFile');
const VirtualMachine = require('../../src/index');
const {serializeCostumes} = require('../../src/serialization/serialize-assets');
const projectUri = path.resolve(__dirname, '../fixtures/corrupt_svg.sb3');
const project = readFileToBuffer(projectUri);
const costumeFileName = 'a267f8b97ee9cf8aa9832aa0b4cfd9eb.svg';
const originalCostume = extractAsset(projectUri, costumeFileName);
// We need to get the actual md5 because we hand modified the svg to corrupt it
// after we downloaded the project from Scratch
// Loading the project back into the VM will correct the assetId and md5
const brokenCostumeMd5 = md5(originalCostume);
let vm;
let defaultVectorAssetId;
tap.beforeEach(() => {
const storage = makeTestStorage();
vm = new VirtualMachine();
vm.attachStorage(storage);
defaultVectorAssetId = vm.runtime.storage.defaultAssetId.ImageVector;
// Mock renderer breaking on loading a corrupt costume
FakeRenderer.prototype.createSVGSkin = function (svgString) {
// Look for text added to costume to make it a corrupt svg
if (svgString.includes('<here is some')) {
throw new Error('mock createSVGSkin broke');
}
return FakeRenderer._nextSkinId++;
};
vm.attachRenderer(new FakeRenderer());
return vm.loadProject(project);
});
const test = tap.test;
test('load sb3 project with corrupted vector costume file', t => {
t.equal(vm.runtime.targets.length, 2);
const stage = vm.runtime.targets[0];
t.ok(stage.isStage);
const blueGuySprite = vm.runtime.targets[1];
t.equal(blueGuySprite.getName(), 'Blue Square Guy');
t.equal(blueGuySprite.getCostumes().length, 1);
const corruptedCostume = blueGuySprite.getCostumes()[0];
t.equal(corruptedCostume.name, 'costume1');
t.equal(corruptedCostume.assetId, defaultVectorAssetId);
t.equal(corruptedCostume.dataFormat, 'svg');
// Runtime should have info about broken asset
t.ok(corruptedCostume.broken);
t.equal(corruptedCostume.broken.assetId, brokenCostumeMd5);
// Verify that we saved the original asset data
t.equal(md5(corruptedCostume.broken.asset.data), brokenCostumeMd5);
t.end();
});
test('load and then save project with corrupted vector costume file', t => {
const resavedProject = JSON.parse(vm.toJSON());
t.equal(resavedProject.targets.length, 2);
const stage = resavedProject.targets[0];
t.ok(stage.isStage);
const blueGuySprite = resavedProject.targets[1];
t.equal(blueGuySprite.name, 'Blue Square Guy');
t.equal(blueGuySprite.costumes.length, 1);
const corruptedCostume = blueGuySprite.costumes[0];
t.equal(corruptedCostume.name, 'costume1');
// Resaved project costume should have the metadata that corresponds to the original broken costume
t.equal(corruptedCostume.assetId, brokenCostumeMd5);
t.equal(corruptedCostume.dataFormat, 'svg');
// Test that we didn't save any data about the costume being broken
t.notOk(corruptedCostume.broken);
t.end();
});
test('serializeCostume saves orignal broken costume', t => {
const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[1].id);
t.equal(costumeDescs.length, 1);
const costume = costumeDescs[0];
t.equal(costume.fileName, `${brokenCostumeMd5}.svg`);
t.equal(md5(costume.fileContent), brokenCostumeMd5);
t.end();
});

View File

@@ -0,0 +1,112 @@
/**
* This test ensures that the VM gracefully handles an sb3 project with
* a missing bitmap costume. The VM should handle this safely by displaying
* a Gray Question Mark, but keeping track of the original costume data
* and serializing the original costume data back out. The saved project.json
* should not reflect that the costume is broken and should therefore re-attempt
* to load the costume if the saved project is re-loaded.
*/
const path = require('path');
const tap = require('tap');
const makeTestStorage = require('../fixtures/make-test-storage');
const FakeRenderer = require('../fixtures/fake-renderer');
const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const {serializeCostumes} = require('../../src/serialization/serialize-assets');
const projectUri = path.resolve(__dirname, '../fixtures/missing_png.sb3');
const project = readFileToBuffer(projectUri);
const missingCostumeAssetId = 'e1320c21995dcf6de10119be7f08c26b';
global.Image = function () {
const image = {
width: 1,
height: 1
};
setTimeout(() => image.onload(), 100);
return image;
};
global.document = {
createElement: () => ({
// Create mock canvas
getContext: () => ({
drawImage: () => ({})
})
})
};
let vm;
tap.setTimeout(30000);
tap.beforeEach(() => {
const storage = makeTestStorage();
vm = new VirtualMachine();
vm.attachStorage(storage);
vm.attachRenderer(new FakeRenderer());
vm.attachV2BitmapAdapter(new FakeBitmapAdapter());
return vm.loadProject(project);
});
const test = tap.test;
test('loading sb3 project with missing bitmap costume file', t => {
t.equal(vm.runtime.targets.length, 2);
const stage = vm.runtime.targets[0];
t.ok(stage.isStage);
const greenGuySprite = vm.runtime.targets[1];
t.equal(greenGuySprite.getName(), 'Green Guy');
t.equal(greenGuySprite.getCostumes().length, 1);
const missingCostume = greenGuySprite.getCostumes()[0];
t.equal(missingCostume.name, 'Green Guy');
// Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
const defaultVectorAssetId = vm.runtime.storage.defaultAssetId.ImageBitmap;
t.equal(missingCostume.assetId, defaultVectorAssetId);
t.equal(missingCostume.dataFormat, 'png');
// Runtime should have info about broken asset
t.ok(missingCostume.broken);
t.equal(missingCostume.broken.assetId, missingCostumeAssetId);
t.end();
});
test('load and then save sb3 project with missing costume file', t => {
const resavedProject = JSON.parse(vm.toJSON());
t.equal(resavedProject.targets.length, 2);
const stage = resavedProject.targets[0];
t.ok(stage.isStage);
const greenGuySprite = resavedProject.targets[1];
t.equal(greenGuySprite.name, 'Green Guy');
t.equal(greenGuySprite.costumes.length, 1);
const missingCostume = greenGuySprite.costumes[0];
t.equal(missingCostume.name, 'Green Guy');
// Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
t.equal(missingCostume.assetId, missingCostumeAssetId);
t.equal(missingCostume.dataFormat, 'png');
// Test that we didn't save any data about the costume being broken
t.notOk(missingCostume.broken);
t.end();
});
test('serializeCostume does not save data for missing costume', t => {
const costumeDescs = serializeCostumes(vm.runtime);
t.equal(costumeDescs.length, 1); // Should only have one costume, the backdrop
t.not(costumeDescs[0].fileName, `${missingCostumeAssetId}.png`);
t.end();
});

View File

@@ -0,0 +1,86 @@
/**
* This test ensures that the VM gracefully handles an sb3 project with
* a missing sound. The project should load without error.
* TODO: handle missing or corrupted sounds by replacing the missing sound data
* with the empty sound file but keeping the info about the original missing / corrupted sound
* so that user data does not get overwritten / lost.
*/
const path = require('path');
const tap = require('tap');
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const {serializeSounds} = require('../../src/serialization/serialize-assets');
const projectUri = path.resolve(__dirname, '../fixtures/missing_sound.sb3');
const project = readFileToBuffer(projectUri);
const missingSoundAssetId = '78618aadd225b1db7bf837fa17dc0568';
let vm;
tap.beforeEach(() => {
const storage = makeTestStorage();
vm = new VirtualMachine();
vm.attachStorage(storage);
return vm.loadProject(project);
});
const test = tap.test;
test('loading sb3 project with missing sound file', t => {
t.equal(vm.runtime.targets.length, 2);
const stage = vm.runtime.targets[0];
t.ok(stage.isStage);
const catSprite = vm.runtime.targets[1];
t.equal(catSprite.getSounds().length, 1);
const missingSound = catSprite.getSounds()[0];
t.equal(missingSound.name, 'Boop Sound Recording');
// Sound should have original data but no asset
const defaultSoundAssetId = vm.runtime.storage.defaultAssetId.Sound;
t.equal(missingSound.assetId, defaultSoundAssetId);
t.equal(missingSound.dataFormat, 'wav');
// Runtime should have info about broken asset
t.ok(missingSound.broken);
t.equal(missingSound.broken.assetId, missingSoundAssetId);
t.end();
});
test('load and then save sb3 project with missing sound file', t => {
const resavedProject = JSON.parse(vm.toJSON());
t.equal(resavedProject.targets.length, 2);
const stage = resavedProject.targets[0];
t.ok(stage.isStage);
const catSprite = resavedProject.targets[1];
t.equal(catSprite.name, 'Sprite1');
t.equal(catSprite.sounds.length, 1);
const missingSound = catSprite.sounds[0];
t.equal(missingSound.name, 'Boop Sound Recording');
// Costume should have both default sound data (e.g. "Gray Question Sound" ^_^) and original data
t.equal(missingSound.assetId, missingSoundAssetId);
t.equal(missingSound.dataFormat, 'wav');
// Test that we didn't save any data about the costume being broken
t.notOk(missingSound.broken);
t.end();
});
test('serializeCostume does not save data for missing costume', t => {
const soundDescs = serializeSounds(vm.runtime);
t.equal(soundDescs.length, 1); // Should only have one sound, the pop sound for the stage
t.not(soundDescs[0].fileName, `${missingSoundAssetId}.wav`);
t.end();
});

View File

@@ -0,0 +1,89 @@
/**
* This test ensures that the VM gracefully handles an sb3 project with
* a missing vector costume. The VM should handle this safely by displaying
* a Gray Question Mark, but keeping track of the original costume data
* and serializing the original costume data back out. The saved project.json
* should not reflect that the costume is broken and should therefore re-attempt
* to load the costume if the saved project is re-loaded.
*/
const path = require('path');
const tap = require('tap');
const makeTestStorage = require('../fixtures/make-test-storage');
const FakeRenderer = require('../fixtures/fake-renderer');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const {serializeCostumes} = require('../../src/serialization/serialize-assets');
const projectUri = path.resolve(__dirname, '../fixtures/missing_svg.sb3');
const project = readFileToBuffer(projectUri);
const missingCostumeAssetId = 'a267f8b97ee9cf8aa9832aa0b4cfd9eb';
let vm;
tap.beforeEach(() => {
const storage = makeTestStorage();
vm = new VirtualMachine();
vm.attachStorage(storage);
vm.attachRenderer(new FakeRenderer());
return vm.loadProject(project);
});
const test = tap.test;
test('loading sb3 project with missing vector costume file', t => {
t.equal(vm.runtime.targets.length, 2);
const stage = vm.runtime.targets[0];
t.ok(stage.isStage);
const blueGuySprite = vm.runtime.targets[1];
t.equal(blueGuySprite.getName(), 'Blue Square Guy');
t.equal(blueGuySprite.getCostumes().length, 1);
const missingCostume = blueGuySprite.getCostumes()[0];
t.equal(missingCostume.name, 'costume1');
// Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
const defaultVectorAssetId = vm.runtime.storage.defaultAssetId.ImageVector;
t.equal(missingCostume.assetId, defaultVectorAssetId);
t.equal(missingCostume.dataFormat, 'svg');
// Runtime should have info about broken asset
t.ok(missingCostume.broken);
t.equal(missingCostume.broken.assetId, missingCostumeAssetId);
t.end();
});
test('load and then save sb3 project with missing costume file', t => {
const resavedProject = JSON.parse(vm.toJSON());
t.equal(resavedProject.targets.length, 2);
const stage = resavedProject.targets[0];
t.ok(stage.isStage);
const blueGuySprite = resavedProject.targets[1];
t.equal(blueGuySprite.name, 'Blue Square Guy');
t.equal(blueGuySprite.costumes.length, 1);
const missingCostume = blueGuySprite.costumes[0];
t.equal(missingCostume.name, 'costume1');
// Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
t.equal(missingCostume.assetId, missingCostumeAssetId);
t.equal(missingCostume.dataFormat, 'svg');
// Test that we didn't save any data about the costume being broken
t.notOk(missingCostume.broken);
t.end();
});
test('serializeCostume does not save data for missing costume', t => {
const costumeDescs = serializeCostumes(vm.runtime);
t.equal(costumeDescs.length, 1); // Should only have one costume, the backdrop
t.not(costumeDescs[0].fileName, `${missingCostumeAssetId}.svg`);
t.end();
});

View File

@@ -0,0 +1,38 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const uri = path.resolve(__dirname, '../fixtures/sensing.sb2');
const project = readFileToBuffer(uri);
test('sensing', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.ok(threads.length > 0);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
// After two seconds, get playground data and stop
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 2000);
});
});
});

View File

@@ -0,0 +1,43 @@
const Worker = require('tiny-worker');
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const dispatch = require('../../src/dispatch/central-dispatch');
const uri = path.resolve(__dirname, '../fixtures/sound.sb2');
const project = readFileToBuffer(uri);
// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead.
dispatch.workerClass = Worker;
test('sound', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', e => {
const threads = JSON.parse(e.threads);
t.ok(threads.length > 0);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
// After two seconds, get playground data and stop
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 2000);
});
});
});

View File

@@ -0,0 +1,126 @@
/**
* This test mocks render breaking on loading a sprite2 with a
* corrupted bitmap costume.
* The VM should handle this safely by displaying a Gray Question Mark,
* but keeping track of the original costume data and serializing the
* original costume data back out. The saved project.json should not
* reflect that the costume is broken and should therefore re-attempt
* to load the costume if the saved project is re-loaded.
*/
const path = require('path');
const tap = require('tap');
const md5 = require('js-md5');
const makeTestStorage = require('../fixtures/make-test-storage');
const FakeRenderer = require('../fixtures/fake-renderer');
const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
const {extractAsset, readFileToBuffer} = require('../fixtures/readProjectFile');
const VirtualMachine = require('../../src/index');
const {serializeCostumes} = require('../../src/serialization/serialize-assets');
const projectUri = path.resolve(__dirname, '../fixtures/default.sb3');
const project = readFileToBuffer(projectUri);
const spriteUri = path.resolve(__dirname, '../fixtures/corrupt_png.sprite2');
const sprite = readFileToBuffer(spriteUri);
const costumeFileName = '0.png';
const originalCostume = extractAsset(spriteUri, costumeFileName);
// We need to get the actual md5 because we hand modified the png to corrupt it
// after we downloaded the project from Scratch
// Loading the project back into the VM will correct the assetId and md5
const brokenCostumeMd5 = md5(originalCostume);
global.Image = function () {
const image = {
width: 1,
height: 1
};
setTimeout(() => {
const base64Image = image.src.split(',')[1];
const decodedText = Buffer.from(base64Image, 'base64').toString();
if (decodedText.includes('Here is some')) {
image.onerror();
} else {
image.onload();
}
}, 100);
return image;
};
global.document = {
createElement: () => ({
// Create mock canvas
getContext: () => ({
drawImage: () => ({})
})
})
};
let vm;
let defaultBitmapAssetId;
tap.setTimeout(30000);
tap.beforeEach(() => {
const storage = makeTestStorage();
vm = new VirtualMachine();
vm.attachStorage(storage);
defaultBitmapAssetId = vm.runtime.storage.defaultAssetId.ImageBitmap;
vm.attachRenderer(new FakeRenderer());
vm.attachV2BitmapAdapter(new FakeBitmapAdapter());
return vm.loadProject(project).then(() => vm.addSprite(sprite));
});
const test = tap.test;
test('load sprite2 with corrupted bitmap costume file', t => {
t.equal(vm.runtime.targets.length, 3);
const stage = vm.runtime.targets[0];
t.ok(stage.isStage);
const greenGuySprite = vm.runtime.targets[2];
t.equal(greenGuySprite.getName(), 'GreenGuy');
t.equal(greenGuySprite.getCostumes().length, 1);
const corruptedCostume = greenGuySprite.getCostumes()[0];
t.equal(corruptedCostume.name, 'GreenGuy');
t.equal(corruptedCostume.assetId, defaultBitmapAssetId);
t.equal(corruptedCostume.dataFormat, 'png');
// Runtime should have info about broken asset
t.ok(corruptedCostume.broken);
t.equal(corruptedCostume.broken.assetId, brokenCostumeMd5);
// Verify that we saved the original asset data
t.equal(md5(corruptedCostume.broken.asset.data), brokenCostumeMd5);
t.end();
});
test('load and then save sprite with corrupted costume file', t => {
const resavedSprite = JSON.parse(vm.toJSON(vm.runtime.targets[2].id));
t.equal(resavedSprite.name, 'GreenGuy');
t.equal(resavedSprite.costumes.length, 1);
const corruptedCostume = resavedSprite.costumes[0];
t.equal(corruptedCostume.name, 'GreenGuy');
// Resaved project costume should have the metadata that corresponds to the original broken costume
t.equal(corruptedCostume.assetId, brokenCostumeMd5);
t.equal(corruptedCostume.dataFormat, 'png');
// Test that we didn't save any data about the costume being broken
t.notOk(corruptedCostume.broken);
t.end();
});
test('serializeCostume saves orignal broken costume', t => {
const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[2].id);
t.equal(costumeDescs.length, 1);
const costume = costumeDescs[0];
t.equal(costume.fileName, `${brokenCostumeMd5}.png`);
t.equal(md5(costume.fileContent), brokenCostumeMd5);
t.end();
});

View File

@@ -0,0 +1,125 @@
/**
* This test mocks render breaking on loading a sprite2 with a
* corrupted vector costume.
* The VM should handle this safely by displaying a Gray Question Mark,
* but keeping track of the original costume data and serializing the
* original costume data back out. The saved project.json should not
* reflect that the costume is broken and should therefore re-attempt
* to load the costume if the saved project is re-loaded.
*/
const path = require('path');
const tap = require('tap');
const md5 = require('js-md5');
const makeTestStorage = require('../fixtures/make-test-storage');
const FakeRenderer = require('../fixtures/fake-renderer');
const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
const {extractAsset, readFileToBuffer} = require('../fixtures/readProjectFile');
const VirtualMachine = require('../../src/index');
const {serializeCostumes} = require('../../src/serialization/serialize-assets');
const projectUri = path.resolve(__dirname, '../fixtures/default.sb3');
const project = readFileToBuffer(projectUri);
const spriteUri = path.resolve(__dirname, '../fixtures/corrupt_svg.sprite2');
const sprite = readFileToBuffer(spriteUri);
const costumeFileName = '0.svg';
const originalCostume = extractAsset(spriteUri, costumeFileName);
// We need to get the actual md5 because we hand modified the svg to corrupt it
// after we downloaded the project from Scratch
// Loading the project back into the VM will correct the assetId and md5
const brokenCostumeMd5 = md5(originalCostume);
global.Image = function () {
const image = {
width: 1,
height: 1
};
setTimeout(() => image.onload(), 1000);
return image;
};
global.document = {
createElement: () => ({
// Create mock canvas
getContext: () => ({
drawImage: () => ({})
})
})
};
let vm;
let defaultVectorAssetId;
tap.beforeEach(() => {
const storage = makeTestStorage();
vm = new VirtualMachine();
vm.attachStorage(storage);
defaultVectorAssetId = vm.runtime.storage.defaultAssetId.ImageVector;
// Mock renderer breaking on loading a corrupt costume
FakeRenderer.prototype.createSVGSkin = function (svgString) {
// Look for text added to costume to make it a corrupt svg
if (svgString.includes('<here is some')) {
throw new Error('mock createSVGSkin broke');
}
return FakeRenderer.prototype._nextSkinId++;
};
vm.attachRenderer(new FakeRenderer());
vm.attachV2BitmapAdapter(new FakeBitmapAdapter());
return vm.loadProject(project).then(() => vm.addSprite(sprite));
});
const test = tap.test;
test('load sprite2 with corrupted vector costume file', t => {
t.equal(vm.runtime.targets.length, 3);
const stage = vm.runtime.targets[0];
t.ok(stage.isStage);
const blueGuySprite = vm.runtime.targets[2];
t.equal(blueGuySprite.getName(), 'Blue Guy');
t.equal(blueGuySprite.getCostumes().length, 1);
const corruptedCostume = blueGuySprite.getCostumes()[0];
t.equal(corruptedCostume.name, 'Blue Guy 2');
t.equal(corruptedCostume.assetId, defaultVectorAssetId);
t.equal(corruptedCostume.dataFormat, 'svg');
// Runtime should have info about broken asset
t.ok(corruptedCostume.broken);
t.equal(corruptedCostume.broken.assetId, brokenCostumeMd5);
// Verify that we saved the original asset data
t.equal(md5(corruptedCostume.broken.asset.data), brokenCostumeMd5);
t.end();
});
test('load and then save sprite with corrupted costume file', t => {
const resavedSprite = JSON.parse(vm.toJSON(vm.runtime.targets[2].id));
t.equal(resavedSprite.name, 'Blue Guy');
t.equal(resavedSprite.costumes.length, 1);
const corruptedCostume = resavedSprite.costumes[0];
t.equal(corruptedCostume.name, 'Blue Guy 2');
// Resaved project costume should have the metadata that corresponds to the original broken costume
t.equal(corruptedCostume.assetId, brokenCostumeMd5);
t.equal(corruptedCostume.dataFormat, 'svg');
// Test that we didn't save any data about the costume being broken
t.notOk(corruptedCostume.broken);
t.end();
});
test('serializeCostume saves orignal broken costume', t => {
const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[2].id);
t.equal(costumeDescs.length, 1);
const costume = costumeDescs[0];
t.equal(costume.fileName, `${brokenCostumeMd5}.svg`);
t.equal(md5(costume.fileContent), brokenCostumeMd5);
t.end();
});

View File

@@ -0,0 +1,108 @@
/**
* This test ensures that the VM gracefully handles a sprite2 file with
* a missing bitmap costume. The VM should handle this safely by displaying
* a Gray Question Mark, but keeping track of the original costume data
* and serializing the original costume data back out. The saved project.json
* should not reflect that the costume is broken and should therefore re-attempt
* to load the costume if the saved project is re-loaded.
*/
const path = require('path');
const tap = require('tap');
const makeTestStorage = require('../fixtures/make-test-storage');
const FakeRenderer = require('../fixtures/fake-renderer');
const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const {serializeCostumes} = require('../../src/serialization/serialize-assets');
// The particular project that we're loading doesn't matter for this test
const projectUri = path.resolve(__dirname, '../fixtures/default.sb3');
const project = readFileToBuffer(projectUri);
const spriteUri = path.resolve(__dirname, '../fixtures/missing_png.sprite2');
const sprite = readFileToBuffer(spriteUri);
const missingCostumeAssetId = 'aadce129bfe4e57f0dd81478f3ed82aa';
global.Image = function () {
const image = {
width: 1,
height: 1
};
setTimeout(() => image.onload(), 100);
return image;
};
global.document = {
createElement: () => ({
// Create mock canvas
getContext: () => ({
drawImage: () => ({})
})
})
};
let vm;
tap.setTimeout(30000);
tap.beforeEach(() => {
const storage = makeTestStorage();
vm = new VirtualMachine();
vm.attachStorage(storage);
vm.attachRenderer(new FakeRenderer());
vm.attachV2BitmapAdapter(new FakeBitmapAdapter());
return vm.loadProject(project).then(() => vm.addSprite(sprite));
});
const test = tap.test;
test('loading sprite2 with missing bitmap costume file', t => {
t.equal(vm.runtime.targets.length, 3);
const stage = vm.runtime.targets[0];
t.ok(stage.isStage);
const greenGuySprite = vm.runtime.targets[2];
t.equal(greenGuySprite.getName(), 'GreenGuy');
t.equal(greenGuySprite.getCostumes().length, 1);
const missingCostume = greenGuySprite.getCostumes()[0];
t.equal(missingCostume.name, 'GreenGuy');
// Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
const defaultBitmapAssetId = vm.runtime.storage.defaultAssetId.ImageBitmap;
t.equal(missingCostume.assetId, defaultBitmapAssetId);
t.equal(missingCostume.dataFormat, 'png');
// Runtime should have info about broken asset
t.ok(missingCostume.broken);
t.equal(missingCostume.broken.assetId, missingCostumeAssetId);
t.end();
});
test('load and then save sprite2 with missing bitmap costume file', t => {
const resavedSprite = JSON.parse(vm.toJSON(vm.runtime.targets[2].id));
t.equal(resavedSprite.name, 'GreenGuy');
t.equal(resavedSprite.costumes.length, 1);
const missingCostume = resavedSprite.costumes[0];
t.equal(missingCostume.name, 'GreenGuy');
// Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
t.equal(missingCostume.assetId, missingCostumeAssetId);
t.equal(missingCostume.dataFormat, 'png');
// Test that we didn't save any data about the costume being broken
t.notOk(missingCostume.broken);
t.end();
});
test('serializeCostume does not save data for missing costume', t => {
const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[2].id);
t.equal(costumeDescs.length, 0);
t.end();
});

View File

@@ -0,0 +1,106 @@
/**
* This test ensures that the VM gracefully handles a sprite2 file with
* a missing vector costume. The VM should handle this safely by displaying
* a Gray Question Mark, but keeping track of the original costume data
* and serializing the original costume data back out. The saved project.json
* should not reflect that the costume is broken and should therefore re-attempt
* to load the costume if the saved project is re-loaded.
*/
const path = require('path');
const tap = require('tap');
const makeTestStorage = require('../fixtures/make-test-storage');
const FakeRenderer = require('../fixtures/fake-renderer');
const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const {serializeCostumes} = require('../../src/serialization/serialize-assets');
// The particular project that we're loading doesn't matter for this test
const projectUri = path.resolve(__dirname, '../fixtures/default.sb3');
const project = readFileToBuffer(projectUri);
const spriteUri = path.resolve(__dirname, '../fixtures/missing_svg.sprite2');
const sprite = readFileToBuffer(spriteUri);
const missingCostumeAssetId = 'beca8009621913e2f5b3111eed2d8210';
global.Image = function () {
const image = {
width: 1,
height: 1
};
setTimeout(() => image.onload(), 1000);
return image;
};
global.document = {
createElement: () => ({
// Create mock canvas
getContext: () => ({
drawImage: () => ({})
})
})
};
let vm;
tap.beforeEach(() => {
const storage = makeTestStorage();
vm = new VirtualMachine();
vm.attachStorage(storage);
vm.attachRenderer(new FakeRenderer());
vm.attachV2BitmapAdapter(new FakeBitmapAdapter());
return vm.loadProject(project).then(() => vm.addSprite(sprite));
});
const test = tap.test;
test('loading sprite2 with missing vector costume file', t => {
t.equal(vm.runtime.targets.length, 3);
const stage = vm.runtime.targets[0];
t.ok(stage.isStage);
const blueGuySprite = vm.runtime.targets[2];
t.equal(blueGuySprite.getName(), 'Blue Guy');
t.equal(blueGuySprite.getCostumes().length, 1);
const missingCostume = blueGuySprite.getCostumes()[0];
t.equal(missingCostume.name, 'Blue Guy 2');
// Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
const defaultVectorAssetId = vm.runtime.storage.defaultAssetId.ImageVector;
t.equal(missingCostume.assetId, defaultVectorAssetId);
t.equal(missingCostume.dataFormat, 'svg');
// Runtime should have info about broken asset
t.ok(missingCostume.broken);
t.equal(missingCostume.broken.assetId, missingCostumeAssetId);
t.end();
});
test('load and then save sprite2 with missing vector costume file', t => {
const resavedSprite = JSON.parse(vm.toJSON(vm.runtime.targets[2].id));
t.equal(resavedSprite.name, 'Blue Guy');
t.equal(resavedSprite.costumes.length, 1);
const missingCostume = resavedSprite.costumes[0];
t.equal(missingCostume.name, 'Blue Guy 2');
// Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
t.equal(missingCostume.assetId, missingCostumeAssetId);
t.equal(missingCostume.dataFormat, 'svg');
// Test that we didn't save any data about the costume being broken
t.notOk(missingCostume.broken);
t.end();
});
test('serializeCostume does not save data for missing costume', t => {
const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[2].id);
t.equal(costumeDescs.length, 0);
t.end();
});

View File

@@ -0,0 +1,126 @@
/**
* This test mocks render breaking on loading a sprite3 with a
* corrupted bitmap costume.
* The VM should handle this safely by displaying a Gray Question Mark,
* but keeping track of the original costume data and serializing the
* original costume data back out. The saved project.json should not
* reflect that the costume is broken and should therefore re-attempt
* to load the costume if the saved project is re-loaded.
*/
const path = require('path');
const tap = require('tap');
const md5 = require('js-md5');
const makeTestStorage = require('../fixtures/make-test-storage');
const FakeRenderer = require('../fixtures/fake-renderer');
const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
const {extractAsset, readFileToBuffer} = require('../fixtures/readProjectFile');
const VirtualMachine = require('../../src/index');
const {serializeCostumes} = require('../../src/serialization/serialize-assets');
const projectUri = path.resolve(__dirname, '../fixtures/default.sb3');
const project = readFileToBuffer(projectUri);
const spriteUri = path.resolve(__dirname, '../fixtures/corrupt_png.sprite3');
const sprite = readFileToBuffer(spriteUri);
const costumeFileName = 'e1320c21995dcf6de10119be7f08c26b.png';
const originalCostume = extractAsset(spriteUri, costumeFileName);
// We need to get the actual md5 because we hand modified the png to corrupt it
// after we downloaded the project from Scratch
// Loading the project back into the VM will correct the assetId and md5
const brokenCostumeMd5 = md5(originalCostume);
global.Image = function () {
const image = {
width: 1,
height: 1
};
setTimeout(() => {
const base64Image = image.src.split(',')[1];
const decodedText = Buffer.from(base64Image, 'base64').toString();
if (decodedText.includes('Here is some')) {
image.onerror();
} else {
image.onload();
}
}, 100);
return image;
};
global.document = {
createElement: () => ({
// Create mock canvas
getContext: () => ({
drawImage: () => ({})
})
})
};
let vm;
let defaultBitmapAssetId;
tap.setTimeout(30000);
tap.beforeEach(() => {
const storage = makeTestStorage();
vm = new VirtualMachine();
vm.attachStorage(storage);
defaultBitmapAssetId = vm.runtime.storage.defaultAssetId.ImageBitmap;
vm.attachRenderer(new FakeRenderer());
vm.attachV2BitmapAdapter(new FakeBitmapAdapter());
return vm.loadProject(project).then(() => vm.addSprite(sprite));
});
const test = tap.test;
test('load sprite3 with corrupted bitmap costume file', t => {
t.equal(vm.runtime.targets.length, 3);
const stage = vm.runtime.targets[0];
t.ok(stage.isStage);
const greenGuySprite = vm.runtime.targets[2];
t.equal(greenGuySprite.getName(), 'Green Guy');
t.equal(greenGuySprite.getCostumes().length, 1);
const corruptedCostume = greenGuySprite.getCostumes()[0];
t.equal(corruptedCostume.name, 'Green Guy');
t.equal(corruptedCostume.assetId, defaultBitmapAssetId);
t.equal(corruptedCostume.dataFormat, 'png');
// Runtime should have info about broken asset
t.ok(corruptedCostume.broken);
t.equal(corruptedCostume.broken.assetId, brokenCostumeMd5);
// Verify that we saved the original asset data
t.equal(md5(corruptedCostume.broken.asset.data), brokenCostumeMd5);
t.end();
});
test('load and then save sprite with corrupted costume file', t => {
const resavedSprite = JSON.parse(vm.toJSON(vm.runtime.targets[2].id));
t.equal(resavedSprite.name, 'Green Guy');
t.equal(resavedSprite.costumes.length, 1);
const corruptedCostume = resavedSprite.costumes[0];
t.equal(corruptedCostume.name, 'Green Guy');
// Resaved project costume should have the metadata that corresponds to the original broken costume
t.equal(corruptedCostume.assetId, brokenCostumeMd5);
t.equal(corruptedCostume.dataFormat, 'png');
// Test that we didn't save any data about the costume being broken
t.notOk(corruptedCostume.broken);
t.end();
});
test('serializeCostume saves orignal broken costume', t => {
const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[2].id);
t.equal(costumeDescs.length, 1);
const costume = costumeDescs[0];
t.equal(costume.fileName, `${brokenCostumeMd5}.png`);
t.equal(md5(costume.fileContent), brokenCostumeMd5);
t.end();
});

View File

@@ -0,0 +1,105 @@
/**
* This test mocks render breaking on loading a sprite with a
* corrupted vector costume.
* The VM should handle this safely by displaying a Gray Question Mark,
* but keeping track of the original costume data and serializing the
* original costume data back out. The saved project.json should not
* reflect that the costume is broken and should therefore re-attempt
* to load the costume if the saved project is re-loaded.
*/
const path = require('path');
const tap = require('tap');
const md5 = require('js-md5');
const makeTestStorage = require('../fixtures/make-test-storage');
const FakeRenderer = require('../fixtures/fake-renderer');
const {extractAsset, readFileToBuffer} = require('../fixtures/readProjectFile');
const VirtualMachine = require('../../src/index');
const {serializeCostumes} = require('../../src/serialization/serialize-assets');
const projectUri = path.resolve(__dirname, '../fixtures/default.sb3');
const project = readFileToBuffer(projectUri);
const spriteUri = path.resolve(__dirname, '../fixtures/corrupt_svg.sprite3');
const sprite = readFileToBuffer(spriteUri);
const costumeFileName = 'a267f8b97ee9cf8aa9832aa0b4cfd9eb.svg';
const originalCostume = extractAsset(spriteUri, costumeFileName);
// We need to get the actual md5 because we hand modified the svg to corrupt it
// after we downloaded the project from Scratch
// Loading the project back into the VM will correct the assetId and md5
const brokenCostumeMd5 = md5(originalCostume);
let vm;
let defaultVectorAssetId;
tap.beforeEach(() => {
const storage = makeTestStorage();
vm = new VirtualMachine();
vm.attachStorage(storage);
defaultVectorAssetId = vm.runtime.storage.defaultAssetId.ImageVector;
// Mock renderer breaking on loading a corrupt costume
FakeRenderer.prototype.createSVGSkin = function (svgString) {
// Look for text added to costume to make it a corrupt svg
if (svgString.includes('<here is some')) {
throw new Error('mock createSVGSkin broke');
}
return FakeRenderer.prototype._nextSkinId++;
};
vm.attachRenderer(new FakeRenderer());
return vm.loadProject(project).then(() => vm.addSprite(sprite));
});
const test = tap.test;
test('load sprite3 with corrupted vector costume file', t => {
t.equal(vm.runtime.targets.length, 3);
const stage = vm.runtime.targets[0];
t.ok(stage.isStage);
const blueGuySprite = vm.runtime.targets[2];
t.equal(blueGuySprite.getName(), 'Blue Square Guy');
t.equal(blueGuySprite.getCostumes().length, 1);
const corruptedCostume = blueGuySprite.getCostumes()[0];
t.equal(corruptedCostume.name, 'costume1');
t.equal(corruptedCostume.assetId, defaultVectorAssetId);
t.equal(corruptedCostume.dataFormat, 'svg');
// Runtime should have info about broken asset
t.ok(corruptedCostume.broken);
t.equal(corruptedCostume.broken.assetId, brokenCostumeMd5);
// Verify that we saved the original asset data
t.equal(md5(corruptedCostume.broken.asset.data), brokenCostumeMd5);
t.end();
});
test('load and then save sprite with corrupted costume file', t => {
const resavedSprite = JSON.parse(vm.toJSON(vm.runtime.targets[2].id));
t.equal(resavedSprite.name, 'Blue Square Guy');
t.equal(resavedSprite.costumes.length, 1);
const corruptedCostume = resavedSprite.costumes[0];
t.equal(corruptedCostume.name, 'costume1');
// Resaved project costume should have the metadata that corresponds to the original broken costume
t.equal(corruptedCostume.assetId, brokenCostumeMd5);
t.equal(corruptedCostume.dataFormat, 'svg');
// Test that we didn't save any data about the costume being broken
t.notOk(corruptedCostume.broken);
t.end();
});
test('serializeCostume saves orignal broken costume', t => {
const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[2].id);
t.equal(costumeDescs.length, 1);
const costume = costumeDescs[0];
t.equal(costume.fileName, `${brokenCostumeMd5}.svg`);
t.equal(md5(costume.fileContent), brokenCostumeMd5);
t.end();
});

View File

@@ -0,0 +1,108 @@
/**
* This test ensures that the VM gracefully handles a sprite3 file with
* a missing bitmap costume. The VM should handle this safely by displaying
* a Gray Question Mark, but keeping track of the original costume data
* and serializing the original costume data back out. The saved project.json
* should not reflect that the costume is broken and should therefore re-attempt
* to load the costume if the saved project is re-loaded.
*/
const path = require('path');
const tap = require('tap');
const makeTestStorage = require('../fixtures/make-test-storage');
const FakeRenderer = require('../fixtures/fake-renderer');
const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const {serializeCostumes} = require('../../src/serialization/serialize-assets');
// The particular project that we're loading doesn't matter for this test
const projectUri = path.resolve(__dirname, '../fixtures/default.sb3');
const project = readFileToBuffer(projectUri);
const spriteUri = path.resolve(__dirname, '../fixtures/missing_png.sprite3');
const sprite = readFileToBuffer(spriteUri);
const missingCostumeAssetId = 'e1320c21995dcf6de10119be7f08c26b';
global.Image = function () {
const image = {
width: 1,
height: 1
};
setTimeout(() => image.onload(), 100);
return image;
};
global.document = {
createElement: () => ({
// Create mock canvas
getContext: () => ({
drawImage: () => ({})
})
})
};
let vm;
tap.setTimeout(30000);
tap.beforeEach(() => {
const storage = makeTestStorage();
vm = new VirtualMachine();
vm.attachStorage(storage);
vm.attachRenderer(new FakeRenderer());
vm.attachV2BitmapAdapter(new FakeBitmapAdapter());
return vm.loadProject(project).then(() => vm.addSprite(sprite));
});
const test = tap.test;
test('loading sprite3 with missing bitmap costume file', t => {
t.equal(vm.runtime.targets.length, 3);
const stage = vm.runtime.targets[0];
t.ok(stage.isStage);
const greenGuySprite = vm.runtime.targets[2];
t.equal(greenGuySprite.getName(), 'Green Guy');
t.equal(greenGuySprite.getCostumes().length, 1);
const missingCostume = greenGuySprite.getCostumes()[0];
t.equal(missingCostume.name, 'Green Guy');
// Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
const defaultBitmapAssetId = vm.runtime.storage.defaultAssetId.ImageBitmap;
t.equal(missingCostume.assetId, defaultBitmapAssetId);
t.equal(missingCostume.dataFormat, 'png');
// Runtime should have info about broken asset
t.ok(missingCostume.broken);
t.equal(missingCostume.broken.assetId, missingCostumeAssetId);
t.end();
});
test('load and then save sprite3 with missing bitmap costume file', t => {
const resavedSprite = JSON.parse(vm.toJSON(vm.runtime.targets[2].id));
t.equal(resavedSprite.name, 'Green Guy');
t.equal(resavedSprite.costumes.length, 1);
const missingCostume = resavedSprite.costumes[0];
t.equal(missingCostume.name, 'Green Guy');
// Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
t.equal(missingCostume.assetId, missingCostumeAssetId);
t.equal(missingCostume.dataFormat, 'png');
// Test that we didn't save any data about the costume being broken
t.notOk(missingCostume.broken);
t.end();
});
test('serializeCostume does not save data for missing costume', t => {
const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[2].id);
t.equal(costumeDescs.length, 0);
t.end();
});

View File

@@ -0,0 +1,86 @@
/**
* This test ensures that the VM gracefully handles a sprite3 file with
* a missing vector costume. The VM should handle this safely by displaying
* a Gray Question Mark, but keeping track of the original costume data
* and serializing the original costume data back out. The saved project.json
* should not reflect that the costume is broken and should therefore re-attempt
* to load the costume if the saved project is re-loaded.
*/
const path = require('path');
const tap = require('tap');
const makeTestStorage = require('../fixtures/make-test-storage');
const FakeRenderer = require('../fixtures/fake-renderer');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const {serializeCostumes} = require('../../src/serialization/serialize-assets');
// The particular project that we're loading doesn't matter for this test
const projectUri = path.resolve(__dirname, '../fixtures/default.sb3');
const project = readFileToBuffer(projectUri);
const spriteUri = path.resolve(__dirname, '../fixtures/missing_svg.sprite3');
const sprite = readFileToBuffer(spriteUri);
const missingCostumeAssetId = 'a267f8b97ee9cf8aa9832aa0b4cfd9eb';
let vm;
tap.beforeEach(() => {
const storage = makeTestStorage();
vm = new VirtualMachine();
vm.attachStorage(storage);
vm.attachRenderer(new FakeRenderer());
return vm.loadProject(project).then(() => vm.addSprite(sprite));
});
const test = tap.test;
test('loading sprite3 with missing vector costume file', t => {
t.equal(vm.runtime.targets.length, 3);
const stage = vm.runtime.targets[0];
t.ok(stage.isStage);
const blueGuySprite = vm.runtime.targets[2];
t.equal(blueGuySprite.getName(), 'Blue Square Guy');
t.equal(blueGuySprite.getCostumes().length, 1);
const missingCostume = blueGuySprite.getCostumes()[0];
t.equal(missingCostume.name, 'costume1');
// Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
const defaultVectorAssetId = vm.runtime.storage.defaultAssetId.ImageVector;
t.equal(missingCostume.assetId, defaultVectorAssetId);
t.equal(missingCostume.dataFormat, 'svg');
// Runtime should have info about broken asset
t.ok(missingCostume.broken);
t.equal(missingCostume.broken.assetId, missingCostumeAssetId);
t.end();
});
test('load and then save sprite3 with missing vector costume file', t => {
const resavedSprite = JSON.parse(vm.toJSON(vm.runtime.targets[2].id));
t.equal(resavedSprite.name, 'Blue Square Guy');
t.equal(resavedSprite.costumes.length, 1);
const missingCostume = resavedSprite.costumes[0];
t.equal(missingCostume.name, 'costume1');
// Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
t.equal(missingCostume.assetId, missingCostumeAssetId);
t.equal(missingCostume.dataFormat, 'svg');
// Test that we didn't save any data about the costume being broken
t.notOk(missingCostume.broken);
t.end();
});
test('serializeCostume does not save data for missing costume', t => {
const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[2].id);
t.equal(costumeDescs.length, 0);
t.end();
});

View File

@@ -0,0 +1,59 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const projectUri = path.resolve(__dirname, '../fixtures/stack-click.sb2');
const project = readFileToBuffer(projectUri);
/**
* stack-click.sb2 contains a sprite at (0, 0) with a single stack
* when timer > 100000000
* move 100 steps
* The intention is to make sure that the stack can be activated by a stack click
* even when the hat predicate is false.
*/
test('stack click activates the stack', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
// Evaluate playground data and exit
vm.on('playgroundData', () => {
// The sprite should have moved 100 to the right
t.equal(vm.editingTarget.x, 100);
vm.quit();
t.end();
});
// Start VM, load project, and run
t.doesNotThrow(() => {
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
const blockContainer = vm.runtime.targets[1].blocks;
const allBlocks = blockContainer._blocks;
// Confirm the editing target is initially at 0
t.equal(vm.editingTarget.x, 0);
// Find hat for greater than and click it
for (const blockId in allBlocks) {
if (allBlocks[blockId].opcode === 'event_whengreaterthan') {
blockContainer.blocklyListen({
blockId: blockId,
element: 'stackclick'
});
}
}
// After two seconds, get playground data and stop
setTimeout(() => {
vm.getPlaygroundData();
vm.stopAll();
}, 2000);
});
});
});

View File

@@ -0,0 +1,198 @@
const {test} = require('tap');
const fs = require('fs');
const path = require('path');
const VM = require('../../src/virtual-machine');
const Thread = require('../../src/engine/thread');
const Clone = require('../../src/util/clone');
const fixturePath = path.join(__dirname, '../fixtures/tw-block-stop-thread.sb3');
const fixtureData = fs.readFileSync(fixturePath);
const stopByRetireThread = thread => {
thread.target.runtime.sequencer.retireThread(thread);
};
const stopBySetStatus = thread => {
thread.status = Thread.STATUS_DONE;
};
test('constants', t => {
t.equal(Thread.STATUS_DONE, 4);
t.end();
});
for (const [enableCompiler, stopFunction] of [
[true, stopByRetireThread],
[true, stopBySetStatus],
[false, stopByRetireThread],
[false, stopBySetStatus]
]) {
const subtestName = `${enableCompiler ? 'compiler' : 'interpreter'} - ${stopFunction.name}`;
test(`${subtestName} - stop in command block`, t => {
const vm = new VM();
vm.setCompilerOptions({enabled: enableCompiler});
t.equal(vm.runtime.compilerOptions.enabled, enableCompiler);
const callOrder = [];
vm.addAddonBlock({
procedureCode: 'first block %s',
arguments: ['number or text'],
callback: (args, util) => {
callOrder.push(['first block', Clone.simple(args)]);
stopFunction(util.thread);
}
});
vm.addAddonBlock({
procedureCode: 'inner block',
return: 1,
callback: args => {
callOrder.push(['input block', Clone.simple(args)]);
return callOrder.length;
}
});
vm.addAddonBlock({
procedureCode: 'second block',
callback: args => {
callOrder.push(['second block', Clone.simple(args)]);
}
});
vm.loadProject(fixtureData).then(() => {
vm.greenFlag();
vm.runtime._step();
t.same(callOrder, [
['input block', {}],
['first block', {'number or text': 1}]
]);
t.end();
});
});
test(`${subtestName} - stop in command block after yielding once`, t => {
const vm = new VM();
vm.setCompilerOptions({enabled: enableCompiler});
t.equal(vm.runtime.compilerOptions.enabled, enableCompiler);
const callOrder = [];
vm.addAddonBlock({
procedureCode: 'first block %s',
arguments: ['number or text'],
callback: (args, util) => {
callOrder.push(['first block', Clone.simple(args)]);
if (callOrder.length === 1) {
util.yield();
} else {
stopFunction(util.thread);
}
}
});
vm.addAddonBlock({
procedureCode: 'inner block',
return: 1,
// Ignore calls because interpreter will re-evaluate on yield but compiler won't
callback: () => 5
});
vm.addAddonBlock({
procedureCode: 'second block',
callback: args => {
callOrder.push(['second block', Clone.simple(args)]);
}
});
vm.loadProject(fixtureData).then(() => {
vm.greenFlag();
vm.runtime._step();
t.same(callOrder, [
['first block', {'number or text': 5}],
['first block', {'number or text': 5}]
]);
t.end();
});
});
test(`${subtestName} - stop in reporter block`, t => {
const vm = new VM();
vm.setCompilerOptions({enabled: enableCompiler});
t.equal(vm.runtime.compilerOptions.enabled, enableCompiler);
const callOrder = [];
vm.addAddonBlock({
procedureCode: 'first block %s',
arguments: ['number or text'],
callback: args => {
callOrder.push(['first block', Clone.simple(args)]);
}
});
vm.addAddonBlock({
procedureCode: 'inner block',
return: 1,
callback: (args, util) => {
callOrder.push(['input block', Clone.simple(args)]);
stopFunction(util.thread);
return callOrder.length;
}
});
vm.addAddonBlock({
procedureCode: 'second block',
callback: args => {
callOrder.push(['second block', Clone.simple(args)]);
}
});
vm.loadProject(fixtureData).then(() => {
vm.greenFlag();
vm.runtime._step();
t.same(callOrder, [
['input block', {}]
]);
t.end();
});
});
test(`${subtestName} - stop in reporter block after yielding once`, t => {
const vm = new VM();
vm.setCompilerOptions({enabled: enableCompiler});
t.equal(vm.runtime.compilerOptions.enabled, enableCompiler);
const callOrder = [];
vm.addAddonBlock({
procedureCode: 'first block %s',
arguments: ['number or text'],
callback: args => {
callOrder.push(['first block', Clone.simple(args)]);
}
});
vm.addAddonBlock({
procedureCode: 'inner block',
return: 1,
callback: (args, util) => {
callOrder.push(['input block', Clone.simple(args)]);
if (callOrder.length === 1) {
util.yield();
// TODO: interpreter bug, should not need to do this...
util.thread.peekStackFrame().waitingReporter = true;
} else {
stopFunction(util.thread);
}
return callOrder.length;
}
});
vm.addAddonBlock({
procedureCode: 'second block',
callback: args => {
callOrder.push(['second block', Clone.simple(args)]);
}
});
vm.loadProject(fixtureData).then(() => {
vm.greenFlag();
vm.runtime._step();
t.same(callOrder, [
['input block', {}],
['input block', {}]
]);
t.end();
});
});
}

View File

@@ -0,0 +1,23 @@
const {test} = require('tap');
const Snapshots = require('../snapshot/lib');
for (const testCase of Snapshots.tests) {
// eslint-disable-next-line no-loop-func
test(testCase.id, async t => {
const expected = Snapshots.getExpectedSnapshot(testCase);
const actual = await Snapshots.generateActualSnapshot(testCase);
const result = Snapshots.compareSnapshots(expected, actual);
if (result === 'VALID') {
t.pass('matches');
} else if (result === 'INPUT_MODIFIED') {
t.fail('input project changed; run: node test/snapshot --update');
} else if (result === 'MISSING_SNAPSHOT') {
t.fail('snapshot is missing; run: node test/snapshot --update');
} else {
// This assertion will always fail, but tap will print out the snapshots
// for easier comparison.
t.equal(expected, actual, 'did not match; you may have to run: node test/snapshot --update');
}
t.end();
});
}

View File

@@ -0,0 +1,60 @@
const fs = require('fs');
const path = require('path');
const {test} = require('tap');
const VM = require('../../src/virtual-machine');
const Scratch = require('../../src/extension-support/tw-extension-api-common');
class TestBuiltinExtension {
getInfo () {
return {
id: 'testbuiltin',
name: 'Test Builtin',
blocks: [
{
opcode: 'test',
blockType: Scratch.BlockType.REPORTER,
text: 'test'
}
]
};
}
test () {
return 'It works! 123';
}
}
test('addBuiltingExtension', t => {
const vm = new VM();
t.equal(vm.extensionManager.isBuiltinExtension('testbuiltin'), false, 'extension is not known');
t.equal(vm.extensionManager.isExtensionLoaded('testbuiltin'), false, 'extension is not loaded');
vm.extensionManager.addBuiltinExtension('testbuiltin', TestBuiltinExtension);
t.equal(vm.extensionManager.isBuiltinExtension('testbuiltin'), true, 'extension is now known');
t.equal(vm.extensionManager.isExtensionLoaded('testbuiltin'), false, 'extension is still not loaded');
const fixture = fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'tw-add-builtin-extension.sb3'));
vm.loadProject(fixture).then(() => {
t.equal(vm.extensionManager.isExtensionLoaded('testbuiltin'), true, 'extension was loaded automatically');
vm.runtime.on('SAY', (target, type, text) => {
t.equal(text, 'It works! 123', 'said value from extension');
t.end();
});
vm.greenFlag();
vm.runtime._step();
});
});
test('each runtime has own set of extensions', t => {
const vm1 = new VM();
const vm2 = new VM();
vm1.extensionManager.addBuiltinExtension('testbuiltin', TestBuiltinExtension);
t.ok(vm1.extensionManager.isBuiltinExtension('testbuiltin'));
t.notOk(vm2.extensionManager.isBuiltinExtension('testbuiltin'));
t.end();
});

View File

@@ -0,0 +1,326 @@
const tap = require('tap');
const fs = require('fs');
const path = require('path');
const VirtualMachine = require('../../src/virtual-machine');
const Thread = require('../../src/engine/thread');
const fixtureData = fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'tw-addon-blocks.sb3'));
const runExecutionTests = compilerEnabled => test => {
const load = async () => {
const vm = new VirtualMachine();
vm.setCompilerOptions({
enabled: compilerEnabled
});
vm.on('COMPILE_ERROR', (target, error) => {
test.fail(`Compile error in ${target.getName()}: ${error}`);
});
await vm.loadProject(fixtureData);
return vm;
};
const getVar = (vm, variableName) => {
const variable = vm.runtime.getTargetForStage().lookupVariableByNameAndType(variableName, '');
return variable.value;
};
test.test('baseline - no addon blocks', t => {
load().then(vm => {
t.equal(getVar(vm, 'block 1'), 'initial');
t.equal(getVar(vm, 'block 2'), 'initial');
t.equal(getVar(vm, 'block 3'), 'initial');
t.equal(getVar(vm, 'block 4'), 'initial');
t.equal(getVar(vm, 'block 4 output'), 'initial');
vm.greenFlag();
vm.runtime._step();
t.equal(getVar(vm, 'block 1'), 'block 1 ran');
t.equal(getVar(vm, 'block 2'), 'block 2: banana');
t.equal(getVar(vm, 'block 3'), 'block 3 ran');
t.equal(getVar(vm, 'block 4'), 'block 4 ran');
t.equal(getVar(vm, 'block 4 output'), 'block 4: apple');
t.end();
});
});
test.test('simple statement blocks', t => {
load().then(vm => {
t.plan(9);
let calledBlock1 = false;
let calledBlock2 = false;
vm.addAddonBlock({
procedureCode: 'block 1',
callback: (args, util) => {
calledBlock1 = true;
t.same(args, {});
t.ok(util.thread instanceof Thread);
// may have to update when project changes
t.equal(util.thread.peekStack(), 'd');
}
});
vm.addAddonBlock({
procedureCode: 'block 2 %s',
arguments: ['number or text'],
callback: (args, util) => {
calledBlock2 = true;
t.same(args, {
'number or text': 'banana'
});
// may have to update when project changes
t.equal(util.thread.peekStack(), 'c');
}
});
vm.greenFlag();
vm.runtime._step();
t.ok(calledBlock1);
t.ok(calledBlock2);
// Overridden blocks should not run
t.equal(getVar(vm, 'block 1'), 'false');
t.equal(getVar(vm, 'block 2'), 'false');
t.end();
});
});
test.test('yield with thread.status = STATUS_PROMISE_WAIT', t => {
load().then(vm => {
t.plan(7);
let threadToResume;
let ranBlock3 = false;
vm.addAddonBlock({
procedureCode: 'block 3',
callback: (args, util) => {
ranBlock3 = true;
util.thread.status = Thread.STATUS_PROMISE_WAIT;
threadToResume = util.thread;
},
arguments: []
});
vm.greenFlag();
vm.runtime._step();
// Make sure we paused it
t.ok(ranBlock3);
t.equal(threadToResume.status, Thread.STATUS_PROMISE_WAIT);
// Should've stopped after block 2
t.equal(getVar(vm, 'block 2'), 'block 2: banana');
t.equal(getVar(vm, 'block 3'), 'false');
t.equal(getVar(vm, 'block 4'), 'false');
threadToResume.status = Thread.STATUS_RUNNING;
vm.runtime._step();
// Should've finished running
t.equal(getVar(vm, 'block 3'), 'false'); // overridden, should not run
t.equal(getVar(vm, 'block 4'), 'block 4 ran');
t.end();
});
});
test.test('yield with util.yield()', t => {
load().then(vm => {
t.plan(10);
let shouldYield = true;
let calledBlock1 = 0;
vm.addAddonBlock({
procedureCode: 'block 1',
callback: (args, util) => {
calledBlock1++;
if (shouldYield) {
util.runtime.requestRedraw();
util.yield();
}
},
arguments: []
});
vm.greenFlag();
for (let i = 0; i < 10; i++) {
vm.runtime._step();
}
t.equal(calledBlock1, 10);
t.equal(getVar(vm, 'block 1'), 'false');
t.equal(getVar(vm, 'block 2'), 'false');
t.equal(getVar(vm, 'block 3'), 'false');
t.equal(getVar(vm, 'block 4'), 'false');
shouldYield = false;
vm.runtime._step();
t.equal(calledBlock1, 11);
t.equal(getVar(vm, 'block 1'), 'false'); // overrridden, should not run
t.equal(getVar(vm, 'block 2'), 'block 2: banana');
t.equal(getVar(vm, 'block 3'), 'block 3 ran');
t.equal(getVar(vm, 'block 4'), 'block 4 ran');
t.end();
});
});
test.test('yield with resolved Promise', t => {
load().then(vm => {
let resolveCallback;
vm.addAddonBlock({
procedureCode: 'block 2 %s',
arguments: ['number or text'],
callback: () => new Promise(resolve => {
resolveCallback = resolve;
})
});
vm.greenFlag();
for (let i = 0; i < 5; i++) {
vm.runtime._step();
}
t.type(resolveCallback, 'function');
t.equal(getVar(vm, 'block 1'), 'block 1 ran');
t.equal(getVar(vm, 'block 2'), 'false');
t.equal(getVar(vm, 'block 3'), 'false');
t.equal(getVar(vm, 'block 4'), 'false');
resolveCallback();
Promise.resolve().then(() => {
vm.runtime._step();
t.equal(getVar(vm, 'block 2'), 'false'); // overridden, should not run
t.equal(getVar(vm, 'block 3'), 'block 3 ran');
t.equal(getVar(vm, 'block 4'), 'block 4 ran');
t.end();
});
});
});
/*
// Doesn't work right now -- not clear whether it should or not
test.skip('yield with rejected Promise', t => {
load().then(vm => {
let rejectCallback;
vm.addAddonBlock({
procedureCode: 'block 2 %s',
arguments: ['number or text'],
callback: () => new Promise((resolve, reject) => {
rejectCallback = reject;
})
});
vm.greenFlag();
for (let i = 0; i < 5; i++) {
vm.runtime._step();
}
t.type(rejectCallback, 'function');
t.equal(getVar(vm, 'block 1'), 'block 1 ran');
t.equal(getVar(vm, 'block 2'), 'false');
t.equal(getVar(vm, 'block 3'), 'false');
t.equal(getVar(vm, 'block 4'), 'false');
rejectCallback(new Error('Intentional error for testing'));
Promise.resolve().then(() => {
vm.runtime._step();
t.equal(getVar(vm, 'block 2'), 'false'); // overridden, should not run
t.equal(getVar(vm, 'block 3'), 'block 3 ran');
t.equal(getVar(vm, 'block 4'), 'block 4 ran');
t.end();
});
});
});
*/
test.test('returning values', t => {
load().then(vm => {
vm.addAddonBlock({
procedureCode: 'block 4 %s',
callback: args => {
t.same(args, {
'number or text': 'apple'
});
return `value from addon block: ${args['number or text']}`;
},
arguments: ['number or text']
});
vm.greenFlag();
vm.runtime._step();
t.equal(getVar(vm, 'block 1'), 'block 1 ran');
t.equal(getVar(vm, 'block 2'), 'block 2: banana');
t.equal(getVar(vm, 'block 3'), 'block 3 ran');
t.equal(getVar(vm, 'block 4'), 'false'); // block 4 itself should not have run, we overrode it
t.equal(getVar(vm, 'block 4 output'), 'value from addon block: apple');
t.end();
});
});
test.end();
};
tap.test('with compiler disabled', runExecutionTests(false));
tap.test('with compiler enabled', runExecutionTests(true));
tap.test('block info', t => {
const vm = new VirtualMachine();
const BLOCK_INFO_ID = 'a-b';
vm.addAddonBlock({
procedureCode: 'hidden %s',
arguments: ['number or text'],
callback: () => {},
hidden: true
});
let blockInfo = vm.runtime._blockInfo.find(i => i.id === BLOCK_INFO_ID);
t.equal(blockInfo, undefined);
vm.addAddonBlock({
procedureCode: 'statement %s',
arguments: ['number or text'],
callback: () => {}
});
vm.addAddonBlock({
procedureCode: 'input %s',
arguments: ['an argument'],
callback: () => {},
return: 1
});
blockInfo = vm.runtime._blockInfo.find(i => i.id === BLOCK_INFO_ID);
t.type(blockInfo.id, 'string');
t.type(blockInfo.name, 'string');
t.type(blockInfo.color1, 'string');
t.type(blockInfo.color2, 'string');
t.type(blockInfo.color3, 'string');
t.same(blockInfo.blocks, [
{
info: {},
// eslint-disable-next-line max-len
xml: '<block type="procedures_call" gap="16"><mutation generateshadows="true" warp="false" proccode="statement %s" argumentnames="[&quot;number or text&quot;]" argumentids="[&quot;arg0&quot;]" argumentdefaults="[&quot;&quot;]"></mutation></block>'
},
{
info: {},
// eslint-disable-next-line max-len
xml: '<block type="procedures_call" gap="16"><mutation generateshadows="true" warp="false" proccode="input %s" argumentnames="[&quot;an argument&quot;]" argumentids="[&quot;arg0&quot;]" argumentdefaults="[&quot;&quot;]" return="1"></mutation></block>'
}
]);
t.end();
});

View File

@@ -0,0 +1,33 @@
const {test} = require('tap');
const Runtime = require('../../src/engine/runtime');
const BlockType = require('../../src/extension-support/block-type');
test('allowDropAnywhere', t => {
t.plan(2);
const rt = new Runtime();
rt.on('EXTENSION_ADDED', json => {
t.equal(json.blocks[0].json.output, 'String');
t.equal(json.blocks[1].json.output, null);
t.end();
});
rt._registerExtensionPrimitives({
id: 'testextension',
name: 'test',
blocks: [
{
opcode: 'drop1',
text: 'drop not anywhere',
blockType: BlockType.REPORTER
},
{
opcode: 'drop2',
text: 'drop anywhere',
blockType: BlockType.REPORTER,
allowDropAnywhere: true
}
]
});
});

View File

@@ -0,0 +1,140 @@
const {test} = require('tap');
const fs = require('fs');
const path = require('path');
const Runtime = require('../../src/engine/runtime');
const VirtualMachine = require('../../src/virtual-machine');
const makeTestStorage = require('../fixtures/make-test-storage');
const AssetUtil = require('../../src/util/tw-asset-util');
test('emitAssetProgress', t => {
const vm = new VirtualMachine();
let runtimeOK = false;
let vmOK = false;
vm.runtime.on('ASSET_PROGRESS', (finished, total) => {
t.equal(finished, 1, 'runtime finished');
t.equal(total, 2, 'runtime total');
runtimeOK = true;
});
vm.on('ASSET_PROGRESS', (finished, total) => {
t.equal(finished, 1, 'vm finished');
t.equal(total, 2, 'vm total');
vmOK = true;
});
vm.runtime.totalAssetRequests = 2;
vm.runtime.finishedAssetRequests = 1;
vm.runtime.emitAssetProgress();
t.ok(runtimeOK, 'runtime');
t.ok(vmOK, 'vm');
t.end();
});
test('resetProgress', t => {
t.plan(4);
const runtime = new Runtime();
runtime.finishedAssetRequests = 10;
runtime.totalAssetRequests = 10;
runtime.on('ASSET_PROGRESS', (finished, total) => {
t.equal(finished, 0, 'event finished');
t.equal(total, 0, 'event total');
});
runtime.resetProgress();
t.equal(runtime.finishedAssetRequests, 0, 'property finishedAssetRequests');
t.equal(runtime.totalAssetRequests, 0, 'property totalAssetRequests');
t.end();
});
test('dispose', t => {
t.plan(1);
const runtime = new Runtime();
runtime.resetProgress = () => {
t.pass();
};
runtime.dispose();
t.end();
});
test('wrapAssetRequest', t => {
const runtime = new Runtime();
const log = [];
runtime.on('ASSET_PROGRESS', (finished, total) => {
log.push([finished, total]);
});
Promise.all([
runtime.wrapAssetRequest(() => Promise.resolve(1)),
runtime.wrapAssetRequest(() => Promise.resolve(2))
]).then(results => {
t.same(results, [1, 2]);
// eslint-disable-next-line prefer-promise-reject-errors
runtime.wrapAssetRequest(() => Promise.reject(3)).catch(error => {
t.equal(error, 3);
t.same(log, [
[0, 1],
[0, 2],
[1, 2],
[2, 2],
[2, 3],
[3, 3]
]);
t.end();
});
});
});
test('asset util emits progress', t => {
const runtime = new Runtime();
const storage = makeTestStorage();
storage.load = (assetType, assetId) => Promise.resolve({
assetId
});
runtime.attachStorage(storage);
const log = [];
runtime.on('ASSET_PROGRESS', (finished, total) => {
log.push([finished, total]);
});
AssetUtil.getByMd5ext(runtime, null, runtime.storage.AssetType.SVG, 'abcdef.svg').then(() => {
t.same(log, [
[0, 1],
[1, 1]
]);
t.end();
});
});
// For the next tests, we have some fixtures that contain 2 assets: 1 sound + 1 costume
// We'll just load them and make sure that each deserializer emits reasonable progress events
for (const format of ['sb', 'sb2', 'sb3']) {
test(format, t => {
const fixture = fs.readFileSync(path.join(__dirname, `../fixtures/tw-asset-progress.${format}`));
const vm = new VirtualMachine();
const log = [];
vm.on('ASSET_PROGRESS', (finished, total) => {
log.push([finished, total]);
});
vm.loadProject(fixture)
.then(() => {
t.same(log, [
[0, 0], // loadProject() implies dispose()
[0, 1],
[0, 2],
[1, 2],
[2, 2]
]);
t.end();
});
});
}

View File

@@ -0,0 +1,69 @@
const {test} = require('tap');
const fs = require('fs');
const path = require('path');
const VirtualMachine = require('../../src/virtual-machine');
const Scratch = require('../../src/extension-support/tw-extension-api-common');
// based on https://github.com/TurboWarp/scratch-vm/issues/184
class TestExtension {
getInfo () {
return {
id: 'testextension',
name: 'test',
blocks: [
{
opcode: 'test',
blockType: Scratch.BlockType.COMMAND,
text: 'test block'
}
]
};
}
test () {
// returns a PromiseLike that calls handler immediately instead of in next microtask.
const promise = {
then (callbackFn) {
callbackFn();
return promise;
}
// intentionally omit catch() as that is not part of PromiseLike, so it should not be used
};
return promise;
}
}
const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-block-returning-promise-like.sb3'));
for (const compilerEnabled of [false, true]) {
test(`handles blocks that return a promise-like object - ${compilerEnabled ? 'compiled' : 'interpreted'}`, t => {
const vm = new VirtualMachine();
vm.extensionManager.addBuiltinExtension('testextension', TestExtension);
vm.setCompilerOptions({
enabled: compilerEnabled
});
t.equal(vm.runtime.compilerOptions.enabled, compilerEnabled, 'sanity check');
vm.runtime.on('COMPILE_ERROR', () => {
t.fail('Compile error');
});
vm.loadProject(fixture).then(() => {
let ended = 0;
vm.runtime.on('SAY', (target, type, text) => {
if (text === 'end') {
ended++;
} else {
t.fail('said something unknown');
}
});
vm.greenFlag();
vm.runtime._step();
t.equal(ended, 1, 'script ran once immediately');
t.end();
});
});
}

View File

@@ -0,0 +1,15 @@
const {test} = require('tap');
const fs = require('fs');
const path = require('path');
const VM = require('../../src/virtual-machine');
const Timer = require('../../src/util/timer');
test('compatibility stack frame is exposed on thread', t => {
const vm = new VM();
vm.loadProject(fs.readFileSync(path.join(__dirname, '../fixtures/tw-glide.sb3'))).then(() => {
vm.greenFlag();
vm.runtime._step();
t.ok(vm.runtime.threads[0].compatibilityStackFrame.timer instanceof Timer);
t.end();
});
});

View File

@@ -0,0 +1,224 @@
const fs = require('fs');
const path = require('path');
const {test} = require('tap');
const VirtualMachine = require('../../src/virtual-machine');
const Scratch = require('../../src/extension-support/tw-extension-api-common');
// Based on https://github.com/TurboWarp/scratch-vm/pull/141
class TestExtensionUsingReturn {
getInfo () {
return {
id: 'loopsAndThings',
name: 'Loops and things test - return',
blocks: [
{
opcode: 'conditional',
blockType: Scratch.BlockType.CONDITIONAL,
text: 'run branch [BRANCH] of',
arguments: {
BRANCH: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 1
}
},
branchCount: 3
},
{
opcode: 'loop',
blockType: Scratch.BlockType.LOOP,
text: 'my repeat [TIMES]',
arguments: {
TIMES: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 10
}
}
},
'---',
{
opcode: 'testPromise',
blockType: Scratch.BlockType.REPORTER,
text: 'return [VALUE] in a Promise',
arguments: {
VALUE: {
type: Scratch.ArgumentType.STRING,
defaultValue: ''
}
}
}
]
};
}
conditional ({BRANCH}) {
return Scratch.Cast.toNumber(BRANCH);
}
loop ({TIMES}, util) {
const times = Math.round(Scratch.Cast.toNumber(TIMES));
if (typeof util.stackFrame.loopCounter === 'undefined') {
util.stackFrame.loopCounter = times;
}
util.stackFrame.loopCounter--;
if (util.stackFrame.loopCounter >= 0) {
return true;
}
}
testPromise ({VALUE}) {
return Promise.resolve(VALUE);
}
}
class TestExtensionUsingStartBranch {
getInfo () {
return {
id: 'loopsAndThings',
name: 'Loops and things test - startBranch',
blocks: [
{
opcode: 'conditional',
blockType: Scratch.BlockType.CONDITIONAL,
text: 'run branch [BRANCH] of',
arguments: {
BRANCH: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 1
}
},
branchCount: 3
},
{
opcode: 'loop',
blockType: Scratch.BlockType.LOOP,
text: 'my repeat [TIMES]',
arguments: {
TIMES: {
type: Scratch.ArgumentType.NUMBER,
defaultValue: 10
}
}
},
'---',
{
opcode: 'testPromise',
blockType: Scratch.BlockType.REPORTER,
text: 'return [VALUE] in a Promise',
arguments: {
VALUE: {
type: Scratch.ArgumentType.STRING,
defaultValue: ''
}
}
}
]
};
}
conditional ({BRANCH}, util) {
util.startBranch(Scratch.Cast.toNumber(BRANCH), false);
}
loop ({TIMES}, util) {
const times = Math.round(Scratch.Cast.toNumber(TIMES));
if (typeof util.stackFrame.loopCounter === 'undefined') {
util.stackFrame.loopCounter = times;
}
util.stackFrame.loopCounter--;
if (util.stackFrame.loopCounter >= 0) {
util.startBranch(1, true);
}
}
testPromise ({VALUE}) {
return Promise.resolve(VALUE);
}
}
/* eslint-disable no-loop-func */
for (const Extension of [TestExtensionUsingReturn, TestExtensionUsingStartBranch]) {
for (const compilerEnabled of [false, true]) {
test(`CONDITIONAL - ${Extension.name} - ${compilerEnabled ? 'compiled' : 'interpreted'}`, t => {
t.plan(1);
const vm = new VirtualMachine();
vm.setCompilerOptions({
enabled: compilerEnabled
});
vm.extensionManager.addBuiltinExtension('loopsAndThings', Extension);
vm.runtime.on('COMPILE_ERROR', () => {
t.fail('Compile error');
});
vm.loadProject(fs.readFileSync(path.join(__dirname, '../fixtures/tw-conditional.sb3'))).then(() => {
let okayCount = 0;
vm.runtime.on('SAY', (target, type, text) => {
if (text === 'OK!') {
okayCount++;
} else if (text === 'end') {
vm.quit();
t.equal(okayCount, 5);
t.end();
} else {
t.fail(`Unexpected text: ${text}`);
}
});
vm.greenFlag();
vm.start();
});
});
test(`LOOP - ${Extension.name} - ${compilerEnabled ? 'compiled' : 'interpreted'}`, t => {
t.plan(1);
const vm = new VirtualMachine();
vm.setCompilerOptions({
enabled: compilerEnabled
});
vm.extensionManager.addBuiltinExtension('loopsAndThings', Extension);
vm.runtime.on('COMPILE_ERROR', () => {
t.fail('Compile error');
});
vm.loadProject(fs.readFileSync(path.join(__dirname, '../fixtures/tw-loop.sb3'))).then(() => {
vm.runtime.on('SAY', (target, type, text) => {
vm.quit();
t.equal(text, 'a 3 b 12 c 48 frames 64');
t.end();
});
vm.greenFlag();
vm.start();
});
});
test(`beyond branchCount - ${Extension.name} - ${compilerEnabled ? 'compiled' : 'interpreted'}`, t => {
t.plan(1);
const vm = new VirtualMachine();
vm.setCompilerOptions({
enabled: compilerEnabled
});
vm.extensionManager.addBuiltinExtension('loopsAndThings', Extension);
vm.runtime.on('COMPILE_ERROR', () => {
t.fail('Compile error');
});
vm.loadProject(fs.readFileSync(path.join(__dirname, '../fixtures/tw-beyond-branchCount.sb3'))).then(() => {
vm.runtime.on('SAY', (target, type, text) => {
if (text === 'BeyondBranchCount') {
t.pass('BeyondBranchCount');
} else if (text === 'end') {
vm.quit();
t.end();
}
});
vm.greenFlag();
vm.start();
});
});
}
}

View File

@@ -0,0 +1,104 @@
const {test} = require('tap');
const VirtualMachine = require('../../src/virtual-machine');
test('Loading project uses default extension URLs', t => {
t.plan(1);
const vm = new VirtualMachine();
const events = [];
vm.securityManager.canLoadExtensionFromProject = url => {
events.push(`canLoadExtensionFromProject ${url}`);
return true;
};
vm.extensionManager.loadExtensionURL = url => {
events.push(`loadExtensionURL ${url}`);
return Promise.resolve();
};
vm.loadProject({
targets: [
{
isStage: true,
name: 'Stage',
variables: {},
lists: {},
broadcasts: {},
blocks: {
a: {
opcode: 'text_clearText',
next: null,
parent: null,
inputs: {},
fields: {},
shadow: false,
topLevel: true,
x: 203,
y: 250
},
b: {
opcode: 'pen_clear',
next: null,
parent: null,
inputs: {},
fields: {},
shadow: false,
topLevel: true,
x: 203,
y: 250
},
c: {
opcode: 'griffpatch_doTick',
next: null,
parent: null,
inputs: {},
fields: {},
shadow: false,
topLevel: true,
x: 203,
y: 250
}
},
comments: {},
currentCostume: 0,
costumes: [
{
assetId: 'cd21514d0531fdffb22204e0ec5ed84a',
dataFormat: 'svg',
md5ext: 'cd21514d0531fdffb22204e0ec5ed84a.svg',
name: 'backdrop1',
rotationCenterX: 240,
rotationCenterY: 180
}
],
sounds: [],
volume: 100,
layerOrder: 0,
tempo: 60,
videoTransparency: 50,
videoState: 'on',
textToSpeechLanguage: null
}
],
monitors: [],
extensions: [
// this list intentionally wrong to make sure we don't rely on its contents
],
extensionURLs: {
griffpatch: 'https://example.com/box2d.js'
},
meta: {
semver: '3.0.0',
vm: '0.2.0',
agent: ''
}
}).then(() => {
t.same(events, [
'canLoadExtensionFromProject https://extensions.turbowarp.org/lab/text.js',
'loadExtensionURL https://extensions.turbowarp.org/lab/text.js',
'canLoadExtensionFromProject https://example.com/box2d.js',
'loadExtensionURL https://example.com/box2d.js'
]);
t.end();
});
});

View File

@@ -0,0 +1,14 @@
const {test} = require('tap');
const VM = require('../../src/virtual-machine');
test('saveProjectSb3 is deterministic over time', t => {
const vm = new VM();
Promise.all([
vm.saveProjectSb3('nodebuffer'),
// Zip modification time is only accurate to the second
new Promise(resolve => setTimeout(resolve, 1000)).then(() => vm.saveProjectSb3('nodebuffer'))
]).then(([a, b]) => {
t.same(a, b);
t.end();
});
});

View File

@@ -0,0 +1,71 @@
const VM = require('../../src/virtual-machine');
const {test} = require('tap');
const fs = require('fs');
const path = require('path');
const compilerAndInterpreter = (name, callback) => {
test(`${name} - interpreted`, t => {
callback(t, {
enabled: false
});
});
test(`${name} - compiled`, t => {
callback(t, {
enabled: true
});
});
};
compilerAndInterpreter('edge activated hats returning promises work properly', async (t, co) => {
const vm = new VM();
vm.setCompilerOptions(co);
// Modify event_whengreaterthan to return a Promise (like a custom extension would) and allow us
// to replace the value. This is a bit of a hack.
let hatValue = false;
vm.runtime._primitives.event_whengreaterthan = () => Promise.resolve(hatValue);
// Track how many times the script was executed.
let sayCounter = 0;
vm.runtime.on('SAY', () => {
sayCounter++;
});
const projectPath = path.join(__dirname, '..', 'fixtures', 'tw-edge-activated-hat-returns-promise.sb3');
await vm.loadProject(fs.readFileSync(projectPath));
const step = async (count = 1) => {
for (let i = 0; i < count; i++) {
vm.runtime._step();
// Give promises returned by blocks a chance to resolve.
await Promise.resolve();
}
};
hatValue = false;
await step(10);
t.equal(sayCounter, 0);
hatValue = true;
await step();
// promise can't resolve in this tick, so block shouldn't run yet
t.equal(sayCounter, 0);
await step();
t.equal(sayCounter, 1);
await step(10);
t.equal(sayCounter, 1);
hatValue = false;
await step(10);
t.equal(sayCounter, 1);
hatValue = true;
await step();
t.equal(sayCounter, 1);
await step();
t.equal(sayCounter, 2);
await step(10);
t.equal(sayCounter, 2);
t.end();
});

View File

@@ -0,0 +1,332 @@
const fs = require('fs');
const pathUtil = require('path');
const htmlparser = require('htmlparser2');
const {test} = require('tap');
const VirtualMachine = require('../../src/virtual-machine');
const Runtime = require('../../src/engine/runtime');
const ArgumentType = require('../../src/extension-support/argument-type');
const BlockType = require('../../src/extension-support/block-type');
const baseExtensionInfo = {
id: 'xmltest',
name: `<>"'&& Name`,
docsURI: `https://example.com/&''""<<>>`,
menuIconURI: `data:<>&"' category icon`,
blocks: [
{
blockType: `block type <>&"'`,
opcode: `opcode <>&"'`,
text: `<>&"' [string argument <>&"'] [inputMenu <"'&>] [fieldMenu <"'&>] [image <"'&>]`,
blockIconURI: `'data:<>&"' block icon`,
arguments: {
[`string argument <>&"'`]: {
type: ArgumentType.STRING,
defaultValue: `default string <>&"'`
},
[`inputMenu <"'&>`]: {
type: ArgumentType.STRING,
menu: `input <>&"'`,
defaultValue: `default input <>&"'`
},
[`fieldMenu <"'&>`]: {
type: `argument type <>&"'`,
menu: `field <>&"'`,
defaultValue: `default field <>&"'`
},
[`image <"'&>`]: {
type: ArgumentType.IMAGE,
dataURI: `data:<>&"' image input`
}
}
},
{
opcode: 'button',
blockType: BlockType.BUTTON,
text: `'"><& button text`,
func: `'"><& func`
}
],
menus: {
[`input <>&"'`]: {
acceptReporters: true,
items: [
`1 <>&"`,
`2 <>&"`,
`3 <>&"`
]
},
[`field <>&"'`]: {
acceptReporters: false,
items: [
`1 <>&"`,
`2 <>&"`,
`3 <>&"`
]
}
}
};
test('XML escaped in Runtime.getBlocksXML()', t => {
// While these changes will make the extension unusable in a real editor environment, we still
// want to make sure that these fields are actually being escaped.
const mangledExtension = JSON.parse(JSON.stringify(baseExtensionInfo));
mangledExtension.color1 = `<"'&amp;amp;color1>`;
mangledExtension.color2 = `<"'&amp;amp;color2>`;
mangledExtension.color3 = `<"'&amp;amp;color3>`;
const vm = new VirtualMachine();
vm.extensionManager._registerInternalExtension({
getInfo: () => mangledExtension
});
const xmlList = vm.runtime.getBlocksXML();
t.type(xmlList, Array, 'getBlocksXML returns array');
t.equal(xmlList.length, 1, 'array has 1 item');
const xmlEntry = xmlList[0];
t.equal(xmlEntry.id, `xmltest`, 'id worked');
const parsedXml = htmlparser.parseDOM(xmlEntry.xml);
t.equal(parsedXml.length, 1, 'xml has 1 root node');
/*
Expected XML structure:
<category name="..." colour="..." secondaryColour="..." iconURI="...">
<button ... web-class="..."></button>
<block type="...">
<value name="...">
<shadow type="text">
<field name="TEXT">default value</field>
</shadow>
</value>
<value name="...">
<shadow type="...">
<field name="...">default value</field>
</shadow>
</value>
<field name="...">default value</field>
</block>
<button text="..." callbackKey="..."></button>
</category>
*/
const category = parsedXml[0];
t.equal(category.name, 'category', 'has <category>');
t.equal(category.attribs.name, '&lt;&gt;&quot;&apos;&amp;&amp; Name', 'escaped category name');
t.equal(category.attribs.id, 'xmltest', 'category id');
t.equal(category.attribs.colour, '&lt;&quot;&apos;&amp;amp;amp;color1&gt;', 'escaped category color');
t.equal(category.attribs.secondarycolour, '&lt;&quot;&apos;&amp;amp;amp;color2&gt;', 'escaped category color 2');
t.equal(category.attribs.iconuri, 'data:&lt;&gt;&amp;&quot;&apos; category icon', 'escaped category icon');
t.equal(category.children.length, 3, 'category has 3 children');
// Check docsURI
const docsButton = category.children[0];
t.equal(docsButton.name, 'button', 'has docs <button>');
t.equal(docsButton.attribs.callbackkey, 'OPEN_EXTENSION_DOCS');
t.equal(
docsButton.attribs.callbackdata,
'https://example.com/&amp;&apos;&apos;&quot;&quot;&lt;&lt;&gt;&gt;',
'escaped docs callback data'
);
t.equal(docsButton.children.length, 0, 'docs button has 0 children');
// Check the block
const block = category.children[1];
t.equal(block.name, 'block', 'has <block>');
t.equal(
block.attribs.type,
'xmltest_opcode &lt;&gt;&amp;&quot;&apos;',
'escaped block id'
);
t.equal(block.children.length, 3, 'block has 3 children');
// Check the block's string input
const stringInput = block.children[0];
t.equal(stringInput.name, 'value', 'string input is <value>');
t.equal(stringInput.attribs.name, 'string argument &lt;&gt;&amp;&quot;&apos;', 'escaped string input id');
t.equal(stringInput.children.length, 1, 'string input has 1 child');
const stringInputShadow = stringInput.children[0];
t.equal(stringInputShadow.name, 'shadow', 'string input shadow is <shadow>');
t.equal(stringInputShadow.attribs.type, 'text', 'string input shadow is of type text');
t.equal(stringInputShadow.children.length, 1, 'string input shadow has 1 child');
const stringInputField = stringInputShadow.children[0];
t.equal(stringInputField.name, 'field', 'string input field is <field>');
t.equal(stringInputField.children.length, 1, 'field input has 1 child');
const stringInputFieldContent = stringInputField.children[0];
t.equal(
stringInputFieldContent.data,
'default string &lt;&gt;&amp;&quot;&apos;',
'escaped string input default value'
);
// Check the block's menu input
const menuInput = block.children[1];
t.equal(menuInput.name, 'value', 'menu input is <value>');
t.equal(menuInput.attribs.name, 'inputMenu &lt;&quot;&apos;&amp;&gt;', 'escaped menu input id');
t.equal(menuInput.children.length, 1, 'menu input has 1 child');
const inputShadow = menuInput.children[0];
t.equal(inputShadow.name, 'shadow', 'input shadow is <shadow>');
t.equal(
inputShadow.attribs.type,
'xmltest_menu_input &lt;&gt;&amp;&quot;&apos;',
'escaped menu id'
);
t.equal(inputShadow.children.length, 1, 'input shadow has 1 child');
const inputField = inputShadow.children[0];
t.equal(inputField.name, 'field', 'input field is <field>');
t.equal(inputField.children.length, 1, 'input field has 1 child');
const inputFieldContent = inputField.children[0];
t.equal(inputFieldContent.data, 'default input &lt;&gt;&amp;&quot;&apos;', 'escaped input default value');
// Check the block's menu field
const menuField = block.children[2];
t.equal(menuField.name, 'field', 'menu field is <field>');
t.equal(menuField.attribs.name, 'fieldMenu &lt;&quot;&apos;&amp;&gt;', 'escaped field menu id');
t.equal(menuField.children.length, 1, 'menu field has 1 child');
const menuFieldContent = menuField.children[0];
t.equal(menuFieldContent.data, 'default field &lt;&gt;&amp;&quot;&apos;', 'escaped field default value');
// Check the button block
const button = category.children[2];
t.equal(button.name, 'button', 'button is <button>');
t.equal(button.attribs.text, '&apos;&quot;&gt;&lt;&amp; button text', 'escaped button text');
t.end();
});
test('ID escaped in Runtime.getBlocksXML()', t => {
// Previous test needs to use an actually valid extension ID. For this test we will
// register an invalid extension just to make sure that the ID ends up being escaped.
const rt = new Runtime();
rt._registerExtensionPrimitives({
id: `id <>&"'`,
name: 'name',
blocks: []
});
const xmlList = rt.getBlocksXML();
const xmlEntry = xmlList[0];
t.equal(xmlEntry.id, `id <>&"'`, 'extension id outside of xml unchanged');
const parsedXML = htmlparser.parseDOM(xmlEntry.xml);
t.equal(parsedXML.length, 1, 'XML has 1 root node');
const category = parsedXML[0];
t.equal(category.name, 'category', 'category is <category>');
t.equal(category.attribs.id, 'id &lt;&gt;&amp;&quot;&apos;', 'escaped extension id');
t.equal(category.children.length, 0, 'category has no children');
t.end();
});
test('XML escaped in Blocks.toXML()', async t => {
const vm = new VirtualMachine();
const serviceName = vm.extensionManager._registerInternalExtension({
getInfo: () => baseExtensionInfo
});
vm.extensionManager._loadedExtensions.set(baseExtensionInfo.id, serviceName);
const fixturePath = pathUtil.join(__dirname, '..', 'fixtures', 'tw-project-using-xml-extension.sb3');
const checkVM = () => {
const generatedXML = vm.runtime.targets[0].blocks.toXML();
const parsedXML = htmlparser.parseDOM(generatedXML);
/*
Example expected XML:
<block id="..." type="xmltest_opcode" x="..." y="...">
<value name="string argument">
<shadow id="..." type="text">
<field name="TEXT">default string</field>
</shadow>
</value>
<value name="inputMenu">
<shadow id="..." type="xmltest_menu_input">
<field name="input">default input</field>
</shadow>
</value>
<field name="fieldMenu">default field</field>
</block>
*/
t.equal(parsedXML.length, 1, 'XML has 1 root');
// Check the block itself
const block = parsedXML[0];
t.equal(block.name, 'block', 'block is <block>');
t.equal(
block.attribs.type,
'xmltest_opcode &lt;&gt;&amp;&quot;&apos;',
'escaped block opcode'
);
t.equal(block.children.length, 3, 'block has 3 children');
// Check the string input
const stringInputValue = block.children[0];
t.equal(stringInputValue.name, 'value', 'string input is <value>');
t.equal(
stringInputValue.attribs.name,
'string argument &lt;&gt;&amp;&quot;&apos;',
'escaped string input name'
);
t.equal(stringInputValue.children.length, 1, 'string input has 1 child');
const stringInputShadow = stringInputValue.children[0];
t.equal(stringInputShadow.name, 'shadow', 'string input shadow is <shadow>');
t.equal(stringInputValue.children.length, 1, 'string input shadow has 1 child');
const stringInputField = stringInputShadow.children[0];
t.equal(stringInputField.name, 'field', 'string input field is <field>');
t.equal(stringInputField.children.length, 1, 'string input field has 1 child');
const stringInputFieldContent = stringInputField.children[0];
t.equal(stringInputFieldContent.data, `default string &lt;&gt;&amp;&quot;&apos;`, 'escaped string input value');
// Check the input menu
const inputMenuValue = block.children[1];
t.equal(inputMenuValue.name, 'value', 'input menu is <value>');
t.equal(inputMenuValue.attribs.name, 'inputMenu &lt;&quot;&apos;&amp;&gt;', 'escaped input menu name');
t.equal(inputMenuValue.children.length, 1, 'input menu has 1 child');
const inputMenuShadow = inputMenuValue.children[0];
t.equal(inputMenuShadow.name, 'shadow', 'input menu shadow is <shadow>');
t.equal(inputMenuValue.children.length, 1, 'input menu shadow has 1 child');
const inputMenuField = inputMenuShadow.children[0];
t.equal(inputMenuField.name, 'field', 'input menu field is <field>');
t.equal(inputMenuField.children.length, 1, 'input menu field has 1 child');
const inputMenuFieldContent = inputMenuField.children[0];
t.equal(inputMenuFieldContent.data, `default input &lt;&gt;&amp;&quot;&apos;`, 'escaped input menu value');
// Check the field menu
const fieldMenu = block.children[2];
t.equal(fieldMenu.name, 'field', 'field menu is <field>');
t.equal(fieldMenu.attribs.name, 'fieldMenu &lt;&quot;&apos;&amp;&gt;', 'escaped field menu name');
t.equal(fieldMenu.children.length, 1, 'field menu has 1 child');
const fieldMenuContent = fieldMenu.children[0];
t.equal(fieldMenuContent.data, `default field &lt;&gt;&amp;&quot;&apos;`, 'escaped input menu value');
};
// Check that we can deserialize a project using this extension.
await vm.loadProject(fs.readFileSync(fixturePath));
checkVM();
// Check that it still works after serialization and deserialization.
const serialized = await vm.saveProjectSb3('uint8array');
await vm.loadProject(serialized);
checkVM();
t.end();
});

View File

@@ -0,0 +1,83 @@
const {test} = require('tap');
const htmlparser = require('htmlparser2');
const VM = require('../../src/virtual-machine');
const BlockType = require('../../src/extension-support/block-type');
test('buttons', t => {
const vm = new VM();
let buttonRunCount = 0;
vm.extensionManager._registerInternalExtension({
'getInfo': () => ({
id: 'test',
name: 'Test',
blocks: [
{
blockType: BlockType.BUTTON,
text: 'button text <>',
func: 'callback &"\'<>'
},
{
blockType: BlockType.BUTTON,
text: 'make variable <>',
func: 'MAKE_A_VARIABLE'
},
{
blockType: BlockType.BUTTON,
text: 'make procedure ""',
func: 'MAKE_A_PROCEDURE'
},
{
blockType: BlockType.BUTTON,
text: 'make list &&',
func: 'MAKE_A_LIST'
}
]
}),
'callback &"\'<>': () => {
buttonRunCount++;
}
});
const xml = vm.runtime.getBlocksXML();
t.equal(xml.length, 1);
const parsed = htmlparser.parseDOM(xml[0].xml, {
decodeEntities: true
});
t.equal(parsed.length, 1);
const category = parsed[0];
t.equal(category.children.length, 4);
const customButton = category.children[0];
t.equal(customButton.name, 'button');
t.equal(customButton.attribs.text, 'button text <>');
t.equal(customButton.attribs.callbackkey, 'EXTENSION_CALLBACK');
t.equal(customButton.attribs.callbackdata, 'test_callback &"\'<>');
const makeVariable = category.children[1];
t.equal(makeVariable.attribs.text, 'make variable <>');
t.equal(makeVariable.attribs.callbackkey, 'MAKE_A_VARIABLE');
const makeProcedure = category.children[2];
t.equal(makeProcedure.attribs.text, 'make procedure ""');
t.equal(makeProcedure.attribs.callbackkey, 'MAKE_A_PROCEDURE');
const makeList = category.children[3];
t.equal(makeList.attribs.text, 'make list &&');
t.equal(makeList.attribs.callbackkey, 'MAKE_A_LIST');
for (let i = 1; i <= 3; i++) {
const builtinButton = category.children[i];
t.equal(builtinButton.name, 'button');
t.equal(builtinButton.attribs.callbackdata, undefined);
}
t.equal(buttonRunCount, 0);
vm.handleExtensionButtonPress(customButton.attribs.callbackdata);
t.equal(buttonRunCount, 1);
vm.handleExtensionButtonPress(customButton.attribs.callbackdata);
t.equal(buttonRunCount, 2);
t.end();
});

View File

@@ -0,0 +1,178 @@
const {test} = require('tap');
const fs = require('fs');
const path = require('path');
const VirtualMachine = require('../../src/virtual-machine');
const Sprite = require('../../src/sprites/sprite');
const RenderedTarget = require('../../src/sprites/rendered-target');
const sb3 = require('../../src/serialization/sb3');
const Runtime = require('../../src/engine/runtime');
test('serialize data', t => {
const vm = new VirtualMachine();
const rt = vm.runtime;
const target1 = new RenderedTarget(new Sprite(null, rt), rt);
const target2 = new RenderedTarget(new Sprite(null, rt), rt);
rt.addTarget(target1);
rt.addTarget(target2);
t.same(sb3.serialize(rt).extensionStorage, undefined, 'global - nothing when no extensions');
t.same(
sb3.serialize(rt).targets.map(i => i.extensionStorage),
[undefined, undefined],
'sprites - nothing when no extensions'
);
vm.extensionManager._registerInternalExtension({
getInfo: () => ({
id: 'test1',
blocks: []
})
});
vm.extensionManager._registerInternalExtension({
getInfo: () => ({
id: 'test2',
blocks: []
})
});
vm.extensionManager._registerInternalExtension({
getInfo: () => ({
id: 'test3',
blocks: []
})
});
t.same(sb3.serialize(rt).extensionStorage, undefined, 'global - nothing when no storage');
t.same(
sb3.serialize(rt).targets.map(i => i.extensionStorage),
[undefined, undefined],
'sprites - nothing when no storage'
);
const topLevelBlockBase = {
// this is not interesting for this test
inputs: {},
fields: {},
topLevel: true,
next: null,
parent: null
};
target1.blocks.createBlock({
...topLevelBlockBase,
id: 'block1',
opcode: 'test1_whatever'
});
target2.blocks.createBlock({
...topLevelBlockBase,
id: 'block2',
opcode: 'test2_whatever'
});
target1.extensionStorage.test1 = 1234321;
t.same(sb3.serialize(rt, target1.id).extensionStorage, {
test1: 1234321
}, 'target1 alone has test1');
t.same(sb3.serialize(rt, target2.id).extensionStorage, undefined, 'target2 alone does not have test1');
target1.extensionStorage.test1 = null;
t.same(sb3.serialize(rt, target1.id).extensionStorage, undefined, 'null is not serialized');
target1.extensionStorage.test1 = undefined;
t.same(sb3.serialize(rt, target1.id).extensionStorage, undefined, 'undefined is not serialized');
target1.extensionStorage.test1 = {it: 'works'};
target1.extensionStorage.test2 = true;
target1.extensionStorage.test3 = {should_not: 'be_saved'};
target2.extensionStorage.test1 = ['ok'];
delete target2.extensionStorage.test2;
delete target2.extensionStorage.test3;
rt.extensionStorage.test1 = 'global ok';
delete rt.extensionStorage.test2;
rt.extensionStorage.test3 = ['dont save this'];
const json = sb3.serialize(rt);
t.same(json.extensionStorage, {
test1: 'global ok'
}, 'final - global has test1');
t.same(json.targets.map(i => i.extensionStorage), [
{
test1: {
it: 'works'
},
test2: true
},
{
test1: ['ok']
}
], 'final - targets ok');
t.end();
});
test('deserialize project with data', t => {
const vm = new VirtualMachine();
vm.extensionManager._registerInternalExtension({
getInfo: () => ({
id: 'test1',
blocks: []
})
});
vm.extensionManager._registerInternalExtension({
getInfo: () => ({
id: 'test2',
blocks: []
})
});
vm.extensionManager._registerInternalExtension({
getInfo: () => ({
id: 'test3',
blocks: []
})
});
// trick it into thinking the extensions are real and loaded...
vm.extensionManager._loadedExtensions.set('test1', 'invalid');
vm.extensionManager._loadedExtensions.set('test2', 'invalid');
vm.extensionManager._loadedExtensions.set('test3', 'invalid');
const fixture = fs.readFileSync(path.resolve(__dirname, '../fixtures/tw-extension-storage.sb3'));
vm.loadProject(fixture).then(() => {
t.same(vm.runtime.extensionStorage, {
test1: 'global ok'
}, 'deserialized global');
t.same(vm.runtime.targets[0].extensionStorage, {
test1: {
it: 'works'
},
test2: true
}, 'deserialized target 0');
t.same(vm.runtime.targets[1].extensionStorage, {
test1: ['ok']
}, 'deserialized target 1');
t.same(vm.runtime.targets[2].extensionStorage, {}, 'deserialized target 2');
t.end();
});
});
test('deserialize project with no data', t => {
const vm = new VirtualMachine();
const fixture = fs.readFileSync(path.resolve(__dirname, '../fixtures/tw-extension-storage-no-data.sb3'));
vm.loadProject(fixture).then(() => {
t.same(vm.runtime.extensionStorage, {}, 'deserialized global');
t.same(vm.runtime.targets[0].extensionStorage, {}, 'deserialized target 0');
t.end();
});
});
test('dispose resets storage', t => {
const runtime = new Runtime();
runtime.extensionStorage.something = 3;
runtime.dispose();
t.same(runtime.extensionStorage, {});
t.end();
});

View File

@@ -0,0 +1,589 @@
const fs = require('fs');
const path = require('path');
const {test} = require('tap');
const _makeTestStorage = require('../fixtures/make-test-storage');
const Runtime = require('../../src/engine/runtime');
const VirtualMachine = require('../../src/virtual-machine');
const emptyProjectFixture = path.join(__dirname, '..', 'fixtures', 'tw-empty-project.sb3');
const makeTestStorage = () => {
const storage = _makeTestStorage();
storage.DataFormat.TTF = 'ttf';
storage.AssetType.Font = {
contentType: 'font/ttf',
name: 'Font',
runtimeFormat: storage.DataFormat.TTF,
immutable: true
};
return storage;
};
test('isValidFamily', t => {
const {fontManager} = new Runtime();
t.ok(fontManager.isValidFamily('Roboto'));
t.ok(fontManager.isValidFamily('sans-serif'));
t.ok(fontManager.isValidFamily('helvetica neue'));
t.notOk(fontManager.isValidFamily('Roboto;Bold'));
t.notOk(fontManager.isValidFamily('Arial, sans-serif'));
t.end();
});
test('getSafeName', t => {
const {fontManager} = new Runtime();
t.equal(fontManager.getSafeName('Arial'), 'Arial');
fontManager.addSystemFont('Arial', 'sans-serif');
t.equal(fontManager.getSafeName('Arial'), 'Arial2');
t.equal(fontManager.getSafeName('Weird123!@"<>?'), 'Weird123');
t.end();
});
test('system font', t => {
const mockRenderer = {
setLayerGroupOrdering: () => {},
setCustomFonts: () => {
t.fail('Should not call renderer.setCustomFonts()');
}
};
const rt = new Runtime();
rt.attachRenderer(mockRenderer);
const {fontManager} = rt;
let changed = false;
fontManager.on('change', () => {
changed = true;
});
fontManager.addSystemFont('Noto Sans Mono', 'monospace');
t.ok(changed, 'addSystemFont() emits change');
t.ok(fontManager.hasFont('Noto Sans Mono'), 'updated hasFont()');
t.same(fontManager.getFonts(), [
{
system: true,
name: 'Noto Sans Mono',
family: '"Noto Sans Mono", monospace',
data: null,
format: null
}
]);
t.same(fontManager.serializeJSON(), [
{
system: true,
family: 'Noto Sans Mono',
fallback: 'monospace'
}
]);
t.same(fontManager.serializeAssets(), []);
changed = false;
fontManager.addSystemFont('Lobster', 'fantasy, sans-serif');
t.ok(changed, 'addSystemFont() emits change');
t.ok(fontManager.hasFont('Lobster'), 'updated hasFont()');
t.same(fontManager.getFonts(), [
{
system: true,
name: 'Noto Sans Mono',
family: '"Noto Sans Mono", monospace',
data: null,
format: null
},
{
system: true,
name: 'Lobster',
family: '"Lobster", fantasy, sans-serif',
data: null,
format: null
}
]);
t.same(fontManager.serializeJSON(), [
{
system: true,
family: 'Noto Sans Mono',
fallback: 'monospace'
},
{
system: true,
family: 'Lobster',
fallback: 'fantasy, sans-serif'
}
]);
t.same(fontManager.serializeAssets(), []);
t.end();
});
test('system font validation', t => {
const {fontManager} = new Runtime();
t.throws(() => {
fontManager.addCustomFont(';', 'monospace');
});
t.end();
});
test('clear', t => {
const setCustomFontsCalls = [];
const mockRenderer = {
setLayerGroupOrdering: () => {},
setCustomFonts: fonts => {
setCustomFontsCalls.push(fonts);
}
};
const rt = new Runtime();
rt.attachStorage(makeTestStorage());
rt.attachRenderer(mockRenderer);
const {fontManager, storage} = rt;
fontManager.addSystemFont('Arial', 'sans-serif');
t.equal(fontManager.getFonts().length, 1);
let changed = false;
fontManager.on('change', () => {
changed = true;
});
fontManager.clear();
t.ok(changed, 'clear() emits change');
t.equal(fontManager.getFonts().length, 0, 'removed font');
t.same(setCustomFontsCalls, [], 'clear() does not call setCustomFonts() if only system fonts');
fontManager.addCustomFont('Wingdings', 'monospace', storage.createAsset(
storage.AssetType.Font,
'ttf',
new Uint8Array([11, 12, 13]),
null,
true
));
changed = false;
setCustomFontsCalls.length = 0;
fontManager.clear();
t.ok(changed, 'clear() emits change');
t.same(setCustomFontsCalls, [{}], 'clear() clears setCustomFonts()');
t.end();
});
test('custom fonts', t => {
const setCustomFontsCalls = [];
const mockRenderer = {
setLayerGroupOrdering: () => {},
setCustomFonts: customFonts => {
setCustomFontsCalls.push(customFonts);
}
};
const rt = new Runtime();
rt.attachRenderer(mockRenderer);
rt.attachStorage(makeTestStorage());
const {fontManager, storage} = rt;
let changed = false;
fontManager.on('change', () => {
changed = true;
});
fontManager.addCustomFont('Arial', 'sans-serif', storage.createAsset(
storage.AssetType.Font,
storage.DataFormat.TTF,
new Uint8Array([1, 2, 3]),
null,
true
));
t.ok(changed, 'addCustomFont() emits change');
t.ok(fontManager.hasFont('Arial'), 'updated hasFont()');
t.same(fontManager.getFonts(), [
{
system: false,
name: 'Arial',
family: '"Arial", sans-serif',
data: new Uint8Array([1, 2, 3]),
format: 'ttf'
}
]);
t.same(fontManager.serializeJSON(), [
{
system: false,
family: 'Arial',
fallback: 'sans-serif',
md5ext: '5289df737df57326fcdd22597afb1fac.ttf'
}
]);
t.same(setCustomFontsCalls, [
{
// eslint-disable-next-line max-len
'"Arial", sans-serif': '@font-face { font-family: "Arial"; src: url("data:font/ttf;base64,AQID"); }'
}
]);
const assets = fontManager.serializeAssets();
t.equal(assets.length, 1);
t.same(assets[0].data, new Uint8Array([1, 2, 3]));
changed = false;
setCustomFontsCalls.length = 0;
const asset = storage.createAsset(
storage.AssetType.Font,
'woff2',
new Uint8Array([4, 5, 6]),
null,
true
);
fontManager.addCustomFont('Comic Sans MS', 'serif', asset);
t.ok(changed, 'addCustomFont() emits change');
t.ok(fontManager.hasFont('Comic Sans MS'), 'updated hasFont()');
t.same(fontManager.getFonts(), [
{
system: false,
name: 'Arial',
family: '"Arial", sans-serif',
data: new Uint8Array([1, 2, 3]),
format: 'ttf'
},
{
system: false,
name: 'Comic Sans MS',
family: '"Comic Sans MS", serif',
data: new Uint8Array([4, 5, 6]),
format: 'woff2'
}
]);
t.same(fontManager.serializeJSON(), [
{
system: false,
family: 'Arial',
fallback: 'sans-serif',
md5ext: '5289df737df57326fcdd22597afb1fac.ttf'
},
{
system: false,
family: 'Comic Sans MS',
fallback: 'serif',
md5ext: 'b4a3ba90641372b4e4eaa841a5a400ec.woff2'
}
]);
t.same(setCustomFontsCalls, [
{
// eslint-disable-next-line max-len
'"Arial", sans-serif': '@font-face { font-family: "Arial"; src: url("data:font/ttf;base64,AQID"); }',
// eslint-disable-next-line max-len
'"Comic Sans MS", serif': '@font-face { font-family: "Comic Sans MS"; src: url("data:font/ttf;base64,BAUG"); }'
}
]);
const assets2 = fontManager.serializeAssets();
t.equal(assets2.length, 2);
t.equal(assets2[0], assets[0]);
t.equal(assets2[1], asset);
t.end();
});
test('custom font validation', t => {
const rt = new Runtime();
rt.attachStorage(makeTestStorage());
const {fontManager, storage} = rt;
t.throws(() => {
fontManager.addCustomFont('family;', 'sans-serif', storage.createAsset(
storage.DataFormat.Font,
storage.DataFormat.TTF,
new Uint8Array([1]),
null,
true
));
});
t.end();
});
test('deleteFont', t => {
const rt = new Runtime();
rt.attachStorage(makeTestStorage());
const {fontManager, storage} = rt;
fontManager.addSystemFont('Liberation Mono', 'monospace');
fontManager.addCustomFont('Noto Sans Mono', 'monospace', storage.createAsset(
storage.AssetType.Font,
storage.DataFormat.TTF,
new Uint8Array([17, 18, 19]),
null,
true
));
t.ok(fontManager.hasFont('Liberation Mono'), 'has font initially');
t.ok(fontManager.hasFont('Noto Sans Mono'), 'has font initially');
let changed = false;
fontManager.on('change', () => {
changed = true;
});
const setCustomFontsCalls = [];
const mockRenderer = {
setLayerGroupOrdering: () => {},
setCustomFonts: customFonts => {
setCustomFontsCalls.push(customFonts);
}
};
rt.attachRenderer(mockRenderer);
fontManager.deleteFont(1);
t.ok(changed, 'deleteFont() emits change');
t.ok(fontManager.hasFont('Liberation Mono'), 'kept font');
t.notOk(fontManager.hasFont('Noto Sans Mono'), 'deleted font');
t.same(setCustomFontsCalls, [{}], 'called setCustomFonts() after deleting non-system font');
t.same(fontManager.getFonts(), [
{
system: true,
name: 'Liberation Mono',
family: '"Liberation Mono", monospace',
data: null,
format: null
}
], 'updated getFonts() after deleting');
changed = false;
fontManager.deleteFont(0);
t.ok(changed, 'deleteFont() emits change');
t.same(setCustomFontsCalls, [{}], 'did not call setCustomFonts() again after deleting system font');
t.same(fontManager.getFonts(), [], 'updated getFonts() after deleting');
t.end();
});
test('fonts are serialized by VM', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
const {storage, fontManager} = vm.runtime;
fontManager.addSystemFont('DejaVu Sans', 'sans-serif');
const fontAsset = storage.createAsset(
storage.AssetType.Font,
storage.DataFormat.TTF,
new Uint8Array([10, 11, 12]),
null,
true
);
fontManager.addCustomFont('Noto Color Emoji', 'emoji', fontAsset);
const assets = vm.assets;
t.same(assets, [fontAsset], 'font is in vm.assets');
const serializedAssets = vm.serializeAssets();
t.same(serializedAssets, [
{
fileName: '94263e4d553bcec128704e354b659526.ttf',
fileContent: new Uint8Array([10, 11, 12])
}
], 'font is in vm.serializeAssets()');
const notZippedProject = vm.saveProjectSb3DontZip();
t.equal(
notZippedProject['94263e4d553bcec128704e354b659526.ttf'],
fontAsset.data,
'font is in saveProjectSb3DontZip()'
);
const projectJSON = JSON.parse(vm.toJSON());
t.same(projectJSON.customFonts, [
{
system: true,
family: 'DejaVu Sans',
fallback: 'sans-serif'
},
{
system: false,
family: 'Noto Color Emoji',
fallback: 'emoji',
md5ext: '94263e4d553bcec128704e354b659526.ttf'
}
], 'font is in vm.toJSON()');
t.end();
});
test('does not serialize fonts if there are none', t => {
const vm = new VirtualMachine();
const json = JSON.parse(vm.toJSON());
t.not('customFonts' in json);
t.end();
});
test('serialization and deserialization roundtrip - project', t => {
const originalVM = new VirtualMachine();
originalVM.attachStorage(makeTestStorage());
const {storage, fontManager} = originalVM.runtime;
originalVM.loadProject(fs.readFileSync(emptyProjectFixture)).then(() => {
// Add our custom fonts here
fontManager.addSystemFont('Ubuntu Mono', 'monospace');
const fontAsset = storage.createAsset(
storage.AssetType.Font,
storage.DataFormat.TTF,
new Uint8Array([20, 21, 22, 23, 24]),
null,
true
);
fontManager.addCustomFont('Inter', 'sans-serif', fontAsset);
originalVM.saveProjectSb3('arraybuffer').then(projectSb3 => {
const newVM = new VirtualMachine();
newVM.attachStorage(makeTestStorage());
const newFontManager = newVM.runtime.fontManager;
newFontManager.addSystemFont('ShouldBeRemoved', 'sans-serif');
let changed = false;
newFontManager.on('change', () => {
changed = true;
});
newVM.loadProject(projectSb3).then(() => {
t.ok(changed, 'loadProject() emits change');
t.same(newFontManager.getFonts(), [
{
system: true,
name: 'Ubuntu Mono',
family: '"Ubuntu Mono", monospace',
data: null,
format: null
},
{
system: false,
name: 'Inter',
family: '"Inter", sans-serif',
data: new Uint8Array([20, 21, 22, 23, 24]),
format: 'ttf'
}
], 'preserved in getFonts()');
t.same(newFontManager.serializeJSON(), [
{
system: true,
family: 'Ubuntu Mono',
fallback: 'monospace'
},
{
system: false,
family: 'Inter',
fallback: 'sans-serif',
md5ext: '316f84429ec778137b2f5c6f893c7e41.ttf'
}
], 'preserved in serializeJSON()');
const assets = newFontManager.serializeAssets();
t.equal(assets.length, 1);
t.same(assets[0].data, new Uint8Array([20, 21, 22, 23, 24]), 'preserved in serializeAssets()');
t.end();
});
});
});
});
test('serialization and deserialization roundtrip - target', t => {
const originalVM = new VirtualMachine();
originalVM.attachStorage(makeTestStorage());
const {fontManager, storage} = originalVM.runtime;
originalVM.loadProject(fs.readFileSync(emptyProjectFixture)).then(() => {
// The fixture we use only contains a stage. We'll convert it to a sprite so we can
// addSprite() it later.
const sprite = originalVM.runtime.targets[0];
sprite.isStage = false;
const noFontsJSON = JSON.parse(originalVM.toJSON(sprite.id));
t.notOk('customFonts' in noFontsJSON, 'does not serialize fonts in target if no fonts');
fontManager.addCustomFont('Noto Sans Traditional Chinese', 'sans-serif', storage.createAsset(
storage.AssetType.Font,
storage.DataFormat.TTF,
new Uint8Array([97, 98, 99]),
null,
true
));
fontManager.addSystemFont('FreeSans', 'sans-serif');
const spriteJSON = JSON.parse(originalVM.toJSON(sprite.id));
t.same(spriteJSON.customFonts, [
{
system: false,
family: 'Noto Sans Traditional Chinese',
fallback: 'sans-serif',
md5ext: '900150983cd24fb0d6963f7d28e17f72.ttf'
},
{
system: true,
family: 'FreeSans',
fallback: 'sans-serif'
}
], 'serializes custom fonts to target');
originalVM.exportSprite(sprite.id, 'uint8array').then(exportedSprite => {
const newVM = new VirtualMachine();
newVM.attachStorage(makeTestStorage());
const newFontManager = newVM.runtime.fontManager;
newVM.loadProject(fs.readFileSync(emptyProjectFixture)).then(() => {
// The existing fonts should not be removed or overwritten
newFontManager.addSystemFont('Liberation Sans', 'sans-serif');
newFontManager.addSystemFont('FreeSans', 'monospace');
let changed = false;
newFontManager.on('change', () => {
changed = true;
});
newVM.addSprite(exportedSprite).then(() => {
t.ok(changed, 'addSprite() emits change');
t.same(newFontManager.getFonts(), [
// Importing a sprite should not overwrite old fonts.
{
system: true,
name: 'Liberation Sans',
family: '"Liberation Sans", sans-serif',
data: null,
format: null
},
{
system: true,
name: 'FreeSans',
family: '"FreeSans", monospace',
data: null,
format: null
},
{
system: false,
name: 'Noto Sans Traditional Chinese',
family: '"Noto Sans Traditional Chinese", sans-serif',
data: new Uint8Array([97, 98, 99]),
format: 'ttf'
}
], 'imported fonts from sprite');
t.end();
});
});
});
});
});
test('deserializes ignores invalid fonts', t => {
const {fontManager} = new Runtime();
fontManager.deserialize([
{
system: true,
family: ';} body { display: none; }',
fallback: 'sans-serif'
},
{
system: true,
family: 'Source Code Pro',
fallback: 'monospace'
}
], null, false).then(() => {
t.equal(fontManager.getFonts().length, 1);
t.equal(fontManager.getFonts()[0].name, 'Source Code Pro');
t.end();
});
});

View File

@@ -0,0 +1,35 @@
const VM = require('../../src/virtual-machine');
const {test} = require('tap');
// The actual logic of the costume exporting and importing is tested elsewhere.
// This is just to make sure that the VM's shims are going to the right place.
test('getExportedCostume', t => {
const vm = new VM();
t.same(
vm.getExportedCostume({
asset: {
data: new Uint8Array([97, 98, 99])
},
dataFormat: 'png'
}),
new Uint8Array([97, 98, 99])
);
t.end();
});
test('getExportedCostumeBase64', t => {
// We'll just make sure that the output is being base64 encoded.
const vm = new VM();
t.same(
vm.getExportedCostumeBase64({
asset: {
data: new Uint8Array([97, 98, 99])
},
dataFormat: 'png'
}),
// btoa("abc")
'YWJj'
);
t.end();
});

View File

@@ -0,0 +1,226 @@
const fs = require('fs');
const path = require('path');
const {test} = require('tap');
const VM = require('../../src/virtual-machine');
const BlockType = require('../../src/extension-support/block-type');
const ArgumentType = require('../../src/extension-support/argument-type');
const compilerAndInterpreter = (name, callback) => {
test(`${name} - interpreted`, t => {
callback(t, {
enabled: false
});
});
test(`${name} - compiled`, t => {
callback(t, {
enabled: true
});
});
};
const fixture = fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'tw-hats-and-events.sb3'));
compilerAndInterpreter('hats and events', (t, co) => {
const vm = new VM();
vm.setCompilerOptions(co);
let log = [];
let hatReturns = false;
class TestExtension {
getInfo () {
return {
id: 'testpredicate',
name: 'Test Predicate',
blocks: [
{
opcode: 'event',
blockType: BlockType.EVENT,
text: 'event block',
isEdgeActivated: false
},
{
opcode: 'hat',
blockType: BlockType.HAT,
text: 'hat block',
isEdgeActivated: false
},
{
opcode: 'complexhat',
blockType: BlockType.HAT,
text: 'complex hat [MENU] if [INPUT]',
isEdgeActivated: false,
arguments: {
MENU: {
menu: 'test'
},
INPUT: {
type: ArgumentType.STRING,
defaultValue: 'default'
}
}
},
{
opcode: 'clickme',
blockType: BlockType.HAT,
text: 'stack click test [INPUT]',
isEdgeActivated: false,
arguments: {
INPUT: {
type: ArgumentType.STRING,
defaultValue: 'default'
}
}
}
],
menus: {
test: {
acceptReporters: false,
items: ['a', 'b', 'c']
}
}
};
}
event () {
log.push('this should never run');
}
hat () {
log.push(`hat ${hatReturns}`);
return hatReturns;
}
complexhat ({INPUT}) {
log.push(`complex hat ${INPUT}`);
return !!INPUT;
}
clickme () {
log.push('clickme');
return Promise.resolve(false);
}
}
vm.extensionManager.addBuiltinExtension('testpredicate', TestExtension);
vm.on('COMPILE_ERROR', () => {
t.fail('Compile error');
});
vm.loadProject(fixture).then(async () => {
log = vm.runtime.getTargetForStage().lookupVariableByNameAndType('log', 'list').value;
t.same(log, [], 'sanity check - log starts empty');
// Let it run for a bit. Nothing should happen on its own.
for (let i = 0; i < 5; i++) {
vm.runtime._step();
}
t.same(log, [], 'nothing happens initially');
// See if events work
vm.runtime.startHats('testpredicate_event');
t.same(log, [], 'event function does not get called, even if it exists');
vm.runtime._step();
t.same(log, ['event'], 'ran event script');
log.length = 0;
// Test hat that returns false
hatReturns = false;
vm.runtime.startHats('testpredicate_hat');
t.same(log, ['hat false'], 'ran hat function');
vm.runtime._step();
t.same(log, ['hat false'], 'did not run hat script');
// Test hat that returns true
hatReturns = true;
vm.runtime.startHats('testpredicate_hat');
t.same(log, [
'hat false',
'hat true'
], 'ran hat function');
vm.runtime._step();
t.same(log, [
'hat false',
'hat true',
'hat'
], 'ran hat script');
log.length = 0;
// Test hat that returns false in a Promise
hatReturns = Promise.resolve(false);
vm.runtime.startHats('testpredicate_hat');
t.same(log, ['hat [object Promise]'], 'ran hat function');
vm.runtime._step();
t.same(log, ['hat [object Promise]'], 'hat script does not run before promise finishes');
await Promise.resolve(); // Allow promise to be processed
vm.runtime._step();
t.same(log, ['hat [object Promise]'], 'hat script still does not run');
log.length = 0;
// Test hat that returns true in a Promise
hatReturns = Promise.resolve(true);
vm.runtime.startHats('testpredicate_hat');
t.same(log, ['hat [object Promise]'], 'ran hat function');
vm.runtime._step();
t.same(log, ['hat [object Promise]'], 'hat script does not run before promise finishes');
await Promise.resolve();
vm.runtime._step();
t.same(log, ['hat [object Promise]', 'hat'], 'hat script runs after promise finishes');
log.length = 0;
// Test complex hat
vm.runtime.startHats('testpredicate_complexhat', {
MENU: 'a'
});
t.same(log, [
'complex hat ',
'complex hat 1'
], 'ran complex hat functions');
vm.runtime._step();
t.same(log, [
'complex hat ',
'complex hat 1',
'complex hat a 2'
], 'ran complex hat script');
log.length = 0;
// Test complex hat with a complex input
vm.runtime.startHats('testpredicate_complexhat', {
MENU: 'b'
});
t.same(log, [], 'control flow in complex inputs is not run immediately');
vm.runtime._step();
t.same(log, [
'evaluated block ',
'evaluated block 1'
], 'evaluated complex inputs but not hat function');
vm.runtime._step();
t.same(log, [
'evaluated block ',
'evaluated block 1',
'complex hat ',
'complex hat 1',
'complex hat b 2'
], 'evaluated complex hat functions and scripts');
log.length = 0;
// Test that in stackClick mode, the hat block still gets run, but the result is ignored
const sprite = vm.runtime.targets[1];
const allBlocks = Object.values(sprite.sprite.blocks._blocks);
const clickBlockId = allBlocks.find(i => i.opcode === 'testpredicate_clickme').id;
vm.runtime._pushThread(clickBlockId, sprite, {
stackClick: true
});
t.same(log, [], 'stackClick does not run anything immediately');
vm.runtime._step();
t.same(log, ['evaluated block something'], 'stackClick hat ran input');
vm.runtime._step();
t.same(log, ['evaluated block something', 'clickme'], 'stackClick hat input evaluated');
await Promise.resolve(); // Allow promise to be processed
vm.runtime._step();
t.same(log, ['evaluated block something', 'clickme', 'stack click'], 'stackClick hat script ran');
t.end();
});
});

View File

@@ -0,0 +1,79 @@
const Runtime = require('../../src/engine/runtime');
const sb2 = require('../../src/serialization/sb2');
const {test} = require('tap');
test('importing ScratchX/.sbx project', async t => {
const rt = new Runtime();
const deserialized = await sb2.deserialize(
{
objName: 'Stage',
scripts: [
[0, 0, [['Text to Speech.speak_text', 'Hello!']]],
[788, 33, [['Spotify\u001feveryBar']]],
[100, 10, [['Weather extension\u001fgetWeather', 'temperature', 'Cambridge, MA']]],
[60, 40, [['Synth Extension.setEffect', 'glide', 10]]]
],
sounds: [],
costumes: [],
children: [],
info: {
savedExtensions: [
{
menus: {
// not important for this test
},
extensionName: 'Spotify',
javascriptURL: 'https://ericrosenbaum.github.io/spotify-extension/extension.js',
blockSpecs: [
// not important for this test
]
},
{
extensionName: 'Weather extension',
javascriptURL: 'http://khanning.github.io/scratch-weather-extension/weather_extension.js'
}
]
}
},
rt
);
const extensionIDs = deserialized.extensions.extensionIDs;
t.equal(extensionIDs.size, 4);
t.ok(extensionIDs.has('sbxtexttospeech'));
t.ok(extensionIDs.has('sbxspotify'));
t.ok(extensionIDs.has('sbxweatherextension'));
t.ok(extensionIDs.has('sbxsynthextension'));
const extensionURLs = deserialized.extensions.extensionURLs;
t.equal(extensionURLs.size, 2);
t.equal(extensionURLs.get('sbxspotify'), 'https://ericrosenbaum.github.io/spotify-extension/extension.js');
t.equal(extensionURLs.get('sbxweatherextension'), 'http://khanning.github.io/scratch-weather-extension/weather_extension.js');
const stage = deserialized.targets[0];
const blocks = Object.values(stage.blocks._blocks);
const textToSpeech = blocks.find(i => i.opcode === 'sbxtexttospeech_speak_text');
t.type(textToSpeech, 'object');
t.type(textToSpeech.inputs['0'], 'object');
t.type(textToSpeech.inputs['1'], 'undefined');
const spotify = blocks.find(i => i.opcode === 'sbxspotify_everyBar');
t.type(spotify, 'object');
t.type(spotify.inputs['0'], 'undefined');
const weather = blocks.find(i => i.opcode === 'sbxweatherextension_getWeather');
t.type(weather, 'object');
t.type(weather.inputs['0'], 'object');
t.type(weather.inputs['1'], 'object');
t.type(weather.inputs['2'], 'undefined');
const synth = blocks.find(i => i.opcode === 'sbxsynthextension_setEffect');
t.type(synth, 'object');
t.type(synth.inputs['0'], 'object');
t.type(synth.inputs['1'], 'object');
t.type(synth.inputs['2'], 'undefined');
t.end();
});

View File

@@ -0,0 +1,41 @@
const htmlparser = require('htmlparser2');
const {test} = require('tap');
const VirtualMachine = require('../../src/virtual-machine');
const BlockType = require('../../src/extension-support/block-type');
test('Label blocks', t => {
const vm = new VirtualMachine();
vm.extensionManager._registerInternalExtension({
getInfo: () => ({
id: 'testlabel',
name: `Label Test`,
blocks: [
{
blockType: BlockType.LABEL,
text: 'test <>&"\''
}
]
})
});
const xmlList = vm.runtime.getBlocksXML();
t.equal(xmlList.length, 1);
const parsedXML = htmlparser.parseDOM(xmlList[0].xml);
// Expecting something like this:
// <category name="Label Test" id="testlabel" colour="#0FBD8C" secondaryColour="#0DA57A">
// <label text="&lt;&gt;&amp;&quot;&apos;"></label>
// </category>
t.equal(parsedXML.length, 1);
const category = parsedXML[0];
t.equal(category.name, 'category');
t.equal(category.children.length, 1);
const label = category.children[0];
t.equal(label.name, 'label');
t.equal(label.children.length, 0);
t.equal(label.attribs.text, 'test &lt;&gt;&amp;&quot;&apos;');
t.end();
});

View File

@@ -0,0 +1,54 @@
const fs = require('fs');
const path = require('path');
const {test} = require('tap');
const VM = require('../../src/virtual-machine');
const compilerAndInterpreter = (name, callback) => {
test(`${name} - interpreted`, t => {
callback(t, {
enabled: false
});
});
test(`${name} - compiled`, t => {
callback(t, {
enabled: true
});
});
};
global.document = {
hidden: true
};
compilerAndInterpreter('last block in loop returns Promise', (t, co) => {
t.plan(1);
const vm = new VM();
vm.setCompilerOptions(co);
const fixturePath = path.join(__dirname, '../fixtures/tw-last-block-in-loop-returns-promise.sb3');
vm.loadProject(fs.readFileSync(fixturePath)).then(async () => {
// This is a stand-in for a block like "move 10 steps"
// This is just easier than attaching a real mock renderer and everything that ends up requiring
vm.addAddonBlock({
procedureCode: 'something to simulate a redraw request',
callback: () => {
vm.runtime.requestRedraw();
},
arguments: []
});
vm.greenFlag();
let iterations = 0;
while (vm.runtime.threads.length > 0) {
iterations++;
vm.runtime._step();
// Give Promises a chance to resolve
await Promise.resolve();
}
t.equal(iterations, 16);
t.end();
});
});

View File

@@ -0,0 +1,30 @@
const {loadCostume} = require('../../src/import/load-costume');
const Runtime = require('../../src/engine/runtime');
const makeTestStorage = require('../fixtures/make-test-storage');
const FakeRenderer = require('../fixtures/fake-renderer');
const {test} = require('tap');
test('importing SVG with stored rotation center', async t => {
t.plan(3);
const runtime = new Runtime();
const storage = makeTestStorage();
runtime.attachStorage(storage);
const renderer = new FakeRenderer();
renderer.createSVGSkin = (svgText, rotationCenter) => {
// Make sure that the rotation center given to the renderer is correct
t.same(rotationCenter, [106.62300344745225, -11.822572945859918]);
// just need to return a valid skin ID, doesn't matter
return 1;
};
runtime.attachRenderer(renderer);
const svg = new TextEncoder().encode(
`<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"></svg><!--rotationCenter:106.62300344745225:-11.822572945859918-->`
);
const asset = storage.createAsset(storage.AssetType.ImageVector, storage.DataFormat.SVG, svg, null, true);
const costume = await loadCostume(`${asset.assetId}.svg`, {
asset
}, runtime);
t.equal(costume.rotationCenterX, 106.62300344745225);
t.equal(costume.rotationCenterY, -11.822572945859918);
t.end();
});

View File

@@ -0,0 +1,121 @@
const {loadCostume} = require('../../src/import/load-costume');
const {loadSound} = require('../../src/import/load-sound');
const Runtime = require('../../src/engine/runtime');
const makeTestStorage = require('../fixtures/make-test-storage');
const FakeRenderer = require('../fixtures/fake-renderer');
const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
const {test} = require('tap');
global.Image = function () {
const image = {
width: 10,
height: 10
};
setTimeout(() => {
if (image.onload) {
image.onload();
}
});
return image;
};
class FakeAudioEngine {
decodeSoundPlayer () {
return Promise.resolve({
id: 0,
buffer: {
sampleRate: 1,
length: 1
}
});
}
}
test('load bitmap in packaged runtime', async t => {
const rt = new Runtime();
rt.convertToPackagedRuntime();
rt.attachRenderer(new FakeRenderer());
rt.attachV2BitmapAdapter(new FakeBitmapAdapter());
const storage = makeTestStorage();
rt.attachStorage(storage);
const asset = storage.createAsset(
storage.AssetType.ImageBitmap,
storage.DataFormat.PNG,
new ArrayBuffer(10),
null,
true
);
const costume = await loadCostume(`${asset.assetId}.png`, {asset}, rt);
t.equal(costume.asset, null);
t.end();
});
test('load vector in packaged runtime', async t => {
const rt = new Runtime();
rt.convertToPackagedRuntime();
rt.attachRenderer(new FakeRenderer());
const storage = makeTestStorage();
rt.attachStorage(storage);
const asset = storage.createAsset(
storage.AssetType.ImageVector,
storage.DataFormat.SVG,
new ArrayBuffer(10),
null,
true
);
const costume = await loadCostume(`${asset.assetId}.svg`, {asset}, rt);
t.equal(costume.asset, null);
t.end();
});
test('load sound in packaged runtime', async t => {
const rt = new Runtime();
rt.convertToPackagedRuntime();
const storage = makeTestStorage();
rt.attachStorage(storage);
rt.attachAudioEngine(new FakeAudioEngine());
const asset = storage.createAsset(
storage.AssetType.Sound,
storage.DataFormat.MP3,
new ArrayBuffer(10),
null,
true
);
const costume = await loadSound({
asset,
md5: `${asset.assetId}.mp3`
}, rt, null);
t.equal(costume.asset, null);
t.end();
});
test('storage.createAsset never generates real asset IDs', t => {
const rt = new Runtime();
rt.convertToPackagedRuntime();
const storage = makeTestStorage();
rt.attachStorage(storage);
const shouldUseGivenID = storage.createAsset(
storage.AssetType.ImageBitmap,
storage.DataFormat.PNG,
new ArrayBuffer(10),
'a'.repeat(32)
);
t.equal(shouldUseGivenID.assetId, 'a'.repeat(32));
const shouldUseFakeID = storage.createAsset(
storage.AssetType.ImageBitmap,
storage.DataFormat.PNG,
new ArrayBuffer(11),
null,
true
);
t.equal(shouldUseFakeID.assetId, '1');
const shouldUseDifferentFakeID = storage.createAsset(
storage.AssetType.ImageBitmap,
storage.DataFormat.PNG,
new ArrayBuffer(12),
null,
true
);
t.equal(shouldUseDifferentFakeID.assetId, '2');
t.end();
});

View File

@@ -0,0 +1,161 @@
const {test} = require('tap');
const VM = require('../../src/virtual-machine');
const platform = require('../../src/engine/tw-platform');
const Clone = require('../../src/util/clone');
test('the internal object', t => {
// the idea with this test is to make it harder for forks to screw up modifying the file
t.type(platform.name, 'string');
t.type(platform.url, 'string');
t.end();
});
test('vm property', t => {
const vm = new VM();
t.same(vm.runtime.platform, platform, 'copy of tw-platform.js');
t.not(vm.runtime.platform, platform, 'not the same object as tw-platform.js');
t.end();
});
test('sanitize', t => {
const vm = new VM();
vm.runtime.platform.name += ' - test';
const json = JSON.parse(vm.toJSON());
t.same(json.meta.platform, vm.runtime.platform, 'copy of runtime.platform');
t.not(json.meta.platform, vm.runtime.platform, 'not the same object as runtime.platform');
t.end();
});
const vanillaProject = {
targets: [
{
isStage: true,
name: 'Stage',
variables: {},
lists: {},
broadcasts: {},
blocks: {},
comments: {},
currentCostume: 0,
costumes: [
{
name: 'backdrop1',
dataFormat: 'svg',
assetId: 'cd21514d0531fdffb22204e0ec5ed84a',
md5ext: 'cd21514d0531fdffb22204e0ec5ed84a.svg',
rotationCenterX: 240,
rotationCenterY: 180
}
],
sounds: [],
volume: 100,
layerOrder: 0,
tempo: 60,
videoTransparency: 50,
videoState: 'on',
textToSpeechLanguage: null
}
],
monitors: [],
extensions: [],
meta: {
semver: '3.0.0',
vm: '0.2.0',
agent: ''
}
};
test('deserialize no platform', t => {
const vm = new VM();
vm.runtime.on('PLATFORM_MISMATCH', () => {
t.fail('Called PLATFORM_MISMATCH');
});
vm.loadProject(vanillaProject).then(() => {
t.end();
});
});
test('deserialize matching platform', t => {
const vm = new VM();
vm.runtime.on('PLATFORM_MISMATCH', () => {
t.fail('Called PLATFORM_MISMATCH');
});
const project = Clone.simple(vanillaProject);
project.meta.platform = Object.assign({}, platform);
vm.loadProject(project).then(() => {
t.end();
});
});
test('deserialize mismatching platform with no listener', t => {
const vm = new VM();
const project = Clone.simple(vanillaProject);
project.meta.platform = {
name: '3tw4ergo980uitegr5hoijuk;'
};
vm.loadProject(project).then(() => {
t.end();
});
});
test('deserialize mismatching platform with 1 listener', t => {
t.plan(2);
const vm = new VM();
vm.runtime.on('PLATFORM_MISMATCH', (pl, callback) => {
t.same(pl, {
name: 'aa',
url: '...'
});
t.ok('called PLATFORM_MISMATCH');
callback();
});
const project = Clone.simple(vanillaProject);
project.meta.platform = {
name: 'aa',
url: '...'
};
vm.loadProject(project).then(() => {
t.end();
});
});
test('deserialize mismatching platform with 3 listeners', t => {
t.plan(2);
const calls = [];
let expectedToLoad = false;
const vm = new VM();
vm.runtime.on('PLATFORM_MISMATCH', (_, callback) => {
calls.push([1, callback]);
});
vm.runtime.on('PLATFORM_MISMATCH', (_, callback) => {
calls.push([2, callback]);
});
vm.runtime.on('PLATFORM_MISMATCH', (_, callback) => {
calls.push([3, callback]);
});
const project = Clone.simple(vanillaProject);
project.meta.platform = {
name: ''
};
vm.loadProject(project).then(() => {
t.ok(expectedToLoad);
t.end();
});
// loadProject is async, may need to wait a bit
setTimeout(async () => {
t.same(calls.map(i => i[0]), [1, 2, 3], 'listeners called in correct order');
// loadProject should not finish until we call all of the listeners' callbacks
calls[0][1]();
await new Promise(resolve => setTimeout(resolve, 100));
calls[1][1]();
await new Promise(resolve => setTimeout(resolve, 100));
expectedToLoad = true;
calls[2][1]();
}, 0);
});

View File

@@ -0,0 +1,116 @@
const {test} = require('tap');
const Runtime = require('../../src/engine/runtime');
const VM = require('../../src/virtual-machine');
const mockRenderer = () => ({
setLayerGroupOrdering: () => {
// not relevant to this test
},
privateSkinAccess: true,
setPrivateSkinAccess (enabled) {
this.privateSkinAccess = enabled;
}
});
test('baseline: no external communication methods', t => {
const rt = new Runtime();
rt.attachRenderer(mockRenderer());
t.equal(rt.renderer.privateSkinAccess, true);
t.end();
});
test('throws errors for unknown method', t => {
t.plan(1);
const rt = new Runtime();
try {
rt.setExternalCommunicationMethod('something fake', true);
} catch (e) {
t.equal(e.message, 'Unknown method: something fake');
}
t.end();
});
test('communication method enabled after attaching renderer', t => {
const rt = new Runtime();
rt.attachRenderer(mockRenderer());
rt.setExternalCommunicationMethod('cloudVariables', true);
t.equal(rt.renderer.privateSkinAccess, false);
t.end();
});
test('communication method enabled before attaching renderer', t => {
const rt = new Runtime();
rt.setExternalCommunicationMethod('cloudVariables', true);
rt.attachRenderer(mockRenderer());
t.equal(rt.renderer.privateSkinAccess, false);
t.end();
});
test('disable enforcement', t => {
const rt = new Runtime();
rt.attachRenderer(mockRenderer());
rt.setEnforcePrivacy(false);
rt.setExternalCommunicationMethod('cloudVariables', true);
t.equal(rt.renderer.privateSkinAccess, true);
t.end();
});
test('multiple features toggled', t => {
const rt = new Runtime();
rt.attachRenderer(mockRenderer());
rt.setExternalCommunicationMethod('cloudVariables', true);
t.equal(rt.renderer.privateSkinAccess, false);
rt.setExternalCommunicationMethod('customExtensions', true);
t.equal(rt.renderer.privateSkinAccess, false);
rt.setExternalCommunicationMethod('cloudVariables', false);
t.equal(rt.renderer.privateSkinAccess, false);
rt.setExternalCommunicationMethod('customExtensions', false);
t.equal(rt.renderer.privateSkinAccess, true);
t.end();
});
test('cloud variables', t => {
const rt = new Runtime();
rt.attachRenderer(mockRenderer());
rt.addCloudVariable();
t.equal(rt.renderer.privateSkinAccess, false);
rt.addCloudVariable();
t.equal(rt.renderer.privateSkinAccess, false);
rt.removeCloudVariable();
t.equal(rt.renderer.privateSkinAccess, false);
rt.removeCloudVariable();
t.equal(rt.renderer.privateSkinAccess, true);
t.end();
});
test('custom extensions', async t => {
const vm = new VM();
vm.attachRenderer(mockRenderer());
vm.extensionManager.securityManager.getSandboxMode = () => 'unsandboxed';
global.document = {
createElement: () => {
const element = {};
setTimeout(() => {
global.Scratch.extensions.register({
getInfo: () => ({})
});
});
return element;
},
body: {
appendChild: () => {}
}
};
t.equal(vm.renderer.privateSkinAccess, true);
await vm.extensionManager.loadExtensionURL('data:application/javascript;,');
t.equal(vm.renderer.privateSkinAccess, false);
t.end();
});

View File

@@ -0,0 +1,102 @@
const {test} = require('tap');
const fs = require('fs');
const path = require('path');
const VM = require('../../src/virtual-machine');
const Scratch = require('../../src/extension-support/tw-extension-api-common');
const commandFixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-rejected-promise-command.sb3'));
const reporterFixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-rejected-promise-reporter.sb3'));
class TestExtension {
getInfo () {
return {
id: 'test123',
name: 'test123',
blocks: [
{
blockType: Scratch.BlockType.COMMAND,
opcode: 'command',
text: 'return rejected promise'
},
{
blockType: Scratch.BlockType.REPORTER,
opcode: 'reporter',
text: 'return rejected promise'
}
]
};
}
command () {
return Promise.reject(new Error('Test error 1'));
}
reporter () {
return Promise.reject(new Error('Test error 2'));
}
}
for (const enableCompiler of [true, false]) {
test(`COMMAND returns rejected promise - ${enableCompiler ? 'compiler' : 'interpreter'}`, t => {
const vm = new VM();
vm.extensionManager.addBuiltinExtension('test123', TestExtension);
vm.setCompilerOptions({
enabled: enableCompiler
});
t.equal(vm.runtime.compilerOptions.enabled, enableCompiler);
vm.loadProject(commandFixture).then(async () => {
vm.greenFlag();
for (let i = 0; i < 12; i++) {
vm.runtime._step();
// wait for promise rejection to be handled
await Promise.resolve();
}
const stage = vm.runtime.getTargetForStage();
t.equal(stage.lookupVariableByNameAndType('before', '').value, 10);
t.equal(stage.lookupVariableByNameAndType('after', '').value, 10);
t.end();
});
});
test(`REPORTER returns rejected promise - ${enableCompiler ? 'compiler' : 'interpreter'}`, t => {
const vm = new VM();
vm.extensionManager.addBuiltinExtension('test123', TestExtension);
vm.setCompilerOptions({
enabled: enableCompiler
});
t.equal(vm.runtime.compilerOptions.enabled, enableCompiler);
vm.loadProject(reporterFixture).then(async () => {
vm.greenFlag();
for (let i = 0; i < 12; i++) {
vm.runtime._step();
// wait for promise rejection to be handled
await Promise.resolve();
}
const stage = vm.runtime.getTargetForStage();
t.equal(stage.lookupVariableByNameAndType('before', '').value, 10);
t.equal(stage.lookupVariableByNameAndType('after', '').value, 10);
t.same(stage.lookupVariableByNameAndType('values', 'list').value, [
'Error: Test error 2',
'Error: Test error 2',
'Error: Test error 2',
'Error: Test error 2',
'Error: Test error 2',
'Error: Test error 2',
'Error: Test error 2',
'Error: Test error 2',
'Error: Test error 2',
'Error: Test error 2',
'Error: Test error 2'
]);
t.end();
});
});
}

View File

@@ -0,0 +1,84 @@
const {test} = require('tap');
require('../fixtures/tw_mock_blob'); // must load Blob before VM so JSZip thinks Blob is supported
const fs = require('fs');
const pathUtil = require('path');
const VirtualMachine = require('../../src/virtual-machine');
const makeTestStorage = require('../fixtures/make-test-storage');
const JSZip = require('@turbowarp/jszip');
const fixture = fs.readFileSync(pathUtil.join(__dirname, '..', 'fixtures', 'tw-save-project-sb3.sb3'));
test('saveProjectSb3', async t => {
t.plan(6);
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
await vm.loadProject(fixture);
// Test that it defaults to Blob
// Note: we use a mock implementation of Blob in tests
const blob = await vm.saveProjectSb3();
t.type(blob, Blob);
const buffer = await vm.saveProjectSb3('arraybuffer');
t.type(buffer, ArrayBuffer);
const base64 = await vm.saveProjectSb3('base64');
t.type(base64, 'string');
const zip = await JSZip.loadAsync(buffer);
t.equal((await zip.file('project.json').async('string'))[0], '{');
t.equal((await zip.file('d9c625ae1996b615a146ac2a7dbe74d7.svg').async('uint8array')).byteLength, 691);
t.equal((await zip.file('cd21514d0531fdffb22204e0ec5ed84a.svg').async('uint8array')).byteLength, 202);
t.end();
});
test('saveProjectSb3Stream', async t => {
t.plan(6);
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
await vm.loadProject(fixture);
let receivedDataEvent = false;
const stream = vm.saveProjectSb3Stream();
stream.on('data', data => {
if (receivedDataEvent) {
return;
}
receivedDataEvent = true;
t.type(data, Uint8Array);
});
stream.resume();
const buffer = await stream.accumulate();
t.type(buffer, ArrayBuffer);
const stream2 = vm.saveProjectSb3Stream('uint8array');
const uint8array = await stream2.accumulate();
t.type(uint8array, Uint8Array);
const zip = await JSZip.loadAsync(buffer);
t.equal((await zip.file('project.json').async('string'))[0], '{');
t.equal((await zip.file('d9c625ae1996b615a146ac2a7dbe74d7.svg').async('uint8array')).byteLength, 691);
t.equal((await zip.file('cd21514d0531fdffb22204e0ec5ed84a.svg').async('uint8array')).byteLength, 202);
t.end();
});
test('saveProjectSb3DontZip', async t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
await vm.loadProject(fixture);
const map = vm.saveProjectSb3DontZip();
t.equal(map['project.json'][0], '{'.charCodeAt(0));
t.equal(map['d9c625ae1996b615a146ac2a7dbe74d7.svg'].byteLength, 691);
t.equal(map['cd21514d0531fdffb22204e0ec5ed84a.svg'].byteLength, 202);
// Make sure that the asset buffers returned are the exact same as the ones used internally, not copies.
const costume = vm.runtime.targets[0].getCostumes()[0];
t.equal(map['cd21514d0531fdffb22204e0ec5ed84a.svg'], costume.asset.data);
t.end();
});

View File

@@ -0,0 +1,268 @@
const {test} = require('tap');
const fs = require('fs');
const path = require('path');
const VirtualMachine = require('../../src/virtual-machine');
const {setupUnsandboxedExtensionAPI} = require('../../src/extension-support/tw-unsandboxed-extension-runner');
const testProject = fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'tw-project-with-extensions.sb3'));
// The test project contains two extensions: a fetch one and a bitwise one.
const FETCH_EXTENSION = 'https://extensions.turbowarp.org/fetch.js';
const BITWISE_EXTENSION = 'https://extensions.turbowarp.org/bitwise.js';
/* eslint-disable no-script-url */
/* eslint-disable require-await */
test('Deny both extensions', async t => {
const vm = new VirtualMachine();
vm.extensionManager.loadExtensionURL = () => {
t.fail();
};
vm.securityManager.canLoadExtensionFromProject = () => false;
try {
await vm.loadProject(testProject);
// loadProject() should fail because extensions were denied
t.fail();
} catch (e) {
t.pass();
}
t.end();
});
test('Deny 1 of 2 extensions', async t => {
const vm = new VirtualMachine();
vm.extensionManager.loadExtensionURL = () => {
t.fail();
};
vm.securityManager.canLoadExtensionFromProject = url => Promise.resolve(url === FETCH_EXTENSION);
try {
await vm.loadProject(testProject);
// loadProject() should fail because extensions were denied
t.fail();
} catch (e) {
t.pass();
}
t.end();
});
test('Allow both extensions', async t => {
const vm = new VirtualMachine();
const loadedExtensions = [];
vm.extensionManager.loadExtensionURL = url => {
loadedExtensions.push(url);
return Promise.resolve();
};
vm.securityManager.canLoadExtensionFromProject = url => {
if (url === FETCH_EXTENSION) {
return true;
}
if (url === BITWISE_EXTENSION) {
return Promise.resolve(true);
}
t.fail('unknown extension');
};
await vm.loadProject(testProject);
t.same(new Set(loadedExtensions), new Set([FETCH_EXTENSION, BITWISE_EXTENSION]));
t.end();
});
test('canFetch', async t => {
const vm = new VirtualMachine();
setupUnsandboxedExtensionAPI(vm);
global.location = {
href: 'https://example.com/'
};
// data: and blob: are always allowed, shouldn't call security manager
vm.securityManager.canFetch = () => t.fail('security manager should be ignored for these protocols');
t.equal(await global.Scratch.canFetch('data:text/html,test'), true);
t.equal(await global.Scratch.canFetch('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), true);
vm.securityManager.canFetch = () => false;
t.equal(await global.Scratch.canFetch('file:///etc/hosts'), false);
t.equal(await global.Scratch.canFetch('http://example.com/'), false);
t.equal(await global.Scratch.canFetch('https://example.com/'), false);
t.equal(await global.Scratch.canFetch('null'), false);
t.equal(await global.Scratch.canFetch(null), false);
vm.securityManager.canFetch = () => Promise.resolve(false);
t.equal(await global.Scratch.canFetch('file:///etc/hosts'), false);
t.equal(await global.Scratch.canFetch('http://example.com/'), false);
t.equal(await global.Scratch.canFetch('https://example.com/'), false);
t.equal(await global.Scratch.canFetch('boring.html'), false);
t.equal(await global.Scratch.canFetch('null'), false);
t.equal(await global.Scratch.canFetch(null), false);
vm.securityManager.canFetch = () => true;
t.equal(await global.Scratch.canFetch('file:///etc/hosts'), true);
t.equal(await global.Scratch.canFetch('http://example.com/'), true);
t.equal(await global.Scratch.canFetch('https://example.com/'), true);
t.equal(await global.Scratch.canFetch('boring.html'), true);
t.equal(await global.Scratch.canFetch('null'), true);
t.equal(await global.Scratch.canFetch(null), true);
const calledWithURLs = [];
vm.securityManager.canFetch = async url => {
calledWithURLs.push(url);
return url === 'https://example.com/null';
};
t.equal(await global.Scratch.canFetch('file:///etc/hosts'), false);
t.equal(await global.Scratch.canFetch('http://example.com/'), false);
t.equal(await global.Scratch.canFetch('https://example.com/null'), true);
t.equal(await global.Scratch.canFetch('null'), true);
t.equal(await global.Scratch.canFetch(null), true);
t.same(calledWithURLs, [
'file:///etc/hosts',
'http://example.com/',
'https://example.com/null',
'https://example.com/null',
'https://example.com/null'
]);
t.end();
});
test('canOpenWindow', async t => {
const vm = new VirtualMachine();
setupUnsandboxedExtensionAPI(vm);
global.location = {
href: 'https://example.com/'
};
// javascript: should never be allowed, shouldn't call security manager
vm.securityManager.canOpenWindow = () => t.fail('should not call security manager for javascript:');
t.equal(await global.Scratch.canOpenWindow('javascript:alert(1)'), false);
vm.securityManager.canOpenWindow = () => false;
t.equal(await global.Scratch.canOpenWindow('data:text/html,test'), false);
t.equal(await global.Scratch.canOpenWindow('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), false);
t.equal(await global.Scratch.canOpenWindow('file:///etc/hosts'), false);
t.equal(await global.Scratch.canOpenWindow('https://example.com/'), false);
t.equal(await global.Scratch.canOpenWindow('index.html'), false);
t.equal(await global.Scratch.canOpenWindow(null), false);
vm.securityManager.canOpenWindow = () => Promise.resolve(false);
t.equal(await global.Scratch.canOpenWindow('data:text/html,test'), false);
t.equal(await global.Scratch.canOpenWindow('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), false);
t.equal(await global.Scratch.canOpenWindow('file:///etc/hosts'), false);
t.equal(await global.Scratch.canOpenWindow('https://example.com/'), false);
t.equal(await global.Scratch.canOpenWindow('index.html'), false);
t.equal(await global.Scratch.canOpenWindow(null), false);
vm.securityManager.canOpenWindow = () => true;
t.equal(await global.Scratch.canOpenWindow('data:text/html,test'), true);
t.equal(await global.Scratch.canOpenWindow('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), true);
t.equal(await global.Scratch.canOpenWindow('file:///etc/hosts'), true);
t.equal(await global.Scratch.canOpenWindow('https://example.com/'), true);
t.equal(await global.Scratch.canOpenWindow('index.html'), true);
t.equal(await global.Scratch.canOpenWindow(null), true);
const calledWithURLs = [];
vm.securityManager.canOpenWindow = async url => {
calledWithURLs.push(url);
return url === 'file:///etc/hosts';
};
t.equal(await global.Scratch.canOpenWindow('data:text/html,test'), false);
t.equal(await global.Scratch.canOpenWindow('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), false);
t.equal(await global.Scratch.canOpenWindow('file:///etc/hosts'), true);
t.equal(await global.Scratch.canOpenWindow('https://example.com/'), false);
t.equal(await global.Scratch.canOpenWindow('index.html'), false);
t.equal(await global.Scratch.canOpenWindow(null), false);
t.same(calledWithURLs, [
'data:text/html,test',
'blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd',
'file:///etc/hosts',
'https://example.com/',
'https://example.com/index.html',
'https://example.com/null'
]);
t.end();
});
test('canRedirect', async t => {
const vm = new VirtualMachine();
setupUnsandboxedExtensionAPI(vm);
global.location = {
href: 'https://example.com/'
};
// javascript: should never be allowed, shouldn't call security manager
vm.securityManager.canRedirect = () => t.fail('should not call security manager for javascript:');
t.equal(await global.Scratch.canRedirect('javascript:alert(1)'), false);
vm.securityManager.canRedirect = () => false;
t.equal(await global.Scratch.canRedirect('data:text/html,test'), false);
t.equal(await global.Scratch.canRedirect('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), false);
t.equal(await global.Scratch.canRedirect('file:///etc/hosts'), false);
t.equal(await global.Scratch.canRedirect('https://example.com/'), false);
t.equal(await global.Scratch.canRedirect('index.html'), false);
t.equal(await global.Scratch.canRedirect(null), false);
vm.securityManager.canRedirect = () => Promise.resolve(false);
t.equal(await global.Scratch.canRedirect('data:text/html,test'), false);
t.equal(await global.Scratch.canRedirect('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), false);
t.equal(await global.Scratch.canRedirect('file:///etc/hosts'), false);
t.equal(await global.Scratch.canRedirect('https://example.com/'), false);
t.equal(await global.Scratch.canRedirect('index.html'), false);
t.equal(await global.Scratch.canRedirect(null), false);
vm.securityManager.canRedirect = () => true;
t.equal(await global.Scratch.canRedirect('data:text/html,test'), true);
t.equal(await global.Scratch.canRedirect('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), true);
t.equal(await global.Scratch.canRedirect('file:///etc/hosts'), true);
t.equal(await global.Scratch.canRedirect('https://example.com/'), true);
t.equal(await global.Scratch.canRedirect('index.html'), true);
t.equal(await global.Scratch.canRedirect(null), true);
const calledWithURLs = [];
vm.securityManager.canRedirect = async url => {
calledWithURLs.push(url);
return url === 'file:///etc/hosts';
};
t.equal(await global.Scratch.canRedirect('data:text/html,test'), false);
t.equal(await global.Scratch.canRedirect('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), false);
t.equal(await global.Scratch.canRedirect('file:///etc/hosts'), true);
t.equal(await global.Scratch.canRedirect('https://example.com/'), false);
t.equal(await global.Scratch.canRedirect('index.html'), false);
t.equal(await global.Scratch.canRedirect(null), false);
t.same(calledWithURLs, [
'data:text/html,test',
'blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd',
'file:///etc/hosts',
'https://example.com/',
'https://example.com/index.html',
'https://example.com/null'
]);
t.end();
});
test('canEmbed', async t => {
const vm = new VirtualMachine();
setupUnsandboxedExtensionAPI(vm);
global.location = {
href: 'https://example.com/'
};
const calledWithURLs = [];
vm.securityManager.canEmbed = async url => {
calledWithURLs.push(url);
return url === 'https://example.com/ok';
};
t.equal(await global.Scratch.canEmbed('https://example.com/ok'), true);
t.equal(await global.Scratch.canEmbed('https://example.com/bad'), false);
t.equal(await global.Scratch.canEmbed('file:///etc/hosts'), false);
t.equal(await global.Scratch.canEmbed('data:text/html;,<h1>test</h1>'), false);
t.equal(await global.Scratch.canEmbed('ok'), true);
t.same(calledWithURLs, [
'https://example.com/ok',
'https://example.com/bad',
'file:///etc/hosts',
'data:text/html;,<h1>test</h1>',
'https://example.com/ok'
]);
t.end();
});

View File

@@ -0,0 +1,98 @@
const fs = require('fs');
const path = require('path');
const {test} = require('tap');
const JSZip = require('@turbowarp/jszip');
const VM = require('../../src/virtual-machine');
const makeTestStorage = require('../fixtures/make-test-storage');
const fixture = fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'tw-serialize-asset-order.sb3'));
test('serializeAssets serialization order', t => {
t.plan(15);
const vm = new VM();
vm.attachStorage(makeTestStorage());
vm.loadProject(fixture).then(() => {
const assets = vm.serializeAssets();
for (let i = 0; i < assets.length; i++) {
// won't deduplicate assets, so expecting 8 costumes, 7 sounds
// 8 costumes, 6 sounds
if (i < 8) {
t.ok(assets[i].fileName.endsWith('.svg'), `file ${i + 1} is costume`);
} else {
t.ok(assets[i].fileName.endsWith('.wav'), `file ${i + 1} is sound`);
}
}
t.end();
});
});
test('saveProjectSb3 serialization order', t => {
t.plan(13);
const vm = new VM();
vm.attachStorage(makeTestStorage());
vm.loadProject(fixture).then(() => {
vm.saveProjectSb3('arraybuffer').then(serialized => {
JSZip.loadAsync(serialized).then(zip => {
const files = Object.keys(zip.files);
for (let i = 0; i < files.length; i++) {
// 6 costumes, 6 sounds
if (i === 0) {
t.equal(files[i], 'project.json', 'first file is project.json');
} else if (i < 7) {
t.ok(files[i].endsWith('.svg'), `file ${i + 1} is costume`);
} else {
t.ok(files[i].endsWith('.wav'), `file ${i + 1} is sound`);
}
}
t.end();
});
});
});
});
test('exportSprite serialization order', t => {
t.plan(9);
const vm = new VM();
vm.attachStorage(makeTestStorage());
vm.loadProject(fixture).then(() => {
vm.exportSprite(vm.runtime.targets[1].id, 'arraybuffer').then(serialized => {
JSZip.loadAsync(serialized).then(zip => {
const files = Object.keys(zip.files);
for (let i = 0; i < files.length; i++) {
// 4 costumes, 4 sounds
if (i === 0) {
t.equal(files[i], 'sprite.json', 'first file is sprite.json');
} else if (i < 5) {
t.ok(files[i].endsWith('.svg'), `file ${i + 1} is costume`);
} else {
t.ok(files[i].endsWith('.wav'), `file ${i + 1} is sound`);
}
}
t.end();
});
});
});
});
test('saveProjectSb3DontZip', t => {
t.plan(13);
const vm = new VM();
vm.attachStorage(makeTestStorage());
vm.loadProject(fixture).then(() => {
const exported = vm.saveProjectSb3DontZip();
const files = Object.keys(exported);
for (let i = 0; i < files.length; i++) {
// 6 costumes, 6 sounds
if (i === 0) {
t.equal(files[i], 'project.json', 'first file is project.json');
} else if (i < 7) {
t.ok(files[i].endsWith('.svg'), `file ${i + 1} is costume`);
} else {
t.ok(files[i].endsWith('.wav'), `file ${i + 1} is sound`);
}
}
t.end();
});
});

View File

@@ -0,0 +1,45 @@
const {test} = require('tap');
const VM = require('../../src/virtual-machine');
const MonitorRecord = require('../../src/engine/monitor-record');
test('Correctly serializes native extension only used by monitors', t => {
const vm = new VM();
vm.runtime.requestAddMonitor(MonitorRecord({
id: 'fakeblock1',
opcode: 'pen_fakeblock',
visiblle: true
}));
vm.runtime.requestAddMonitor(MonitorRecord({
id: 'fakeblock2',
opcode: 'translate_fakeblock',
visiblle: false
}));
const json = JSON.parse(vm.toJSON());
t.same(json.extensions, ['pen', 'translate']);
t.not('customExtensions' in json);
t.end();
});
test('Correctly serializes custom extension only used by monitors', t => {
const vm = new VM();
vm.runtime.requestAddMonitor(MonitorRecord({
id: 'fakeblock1',
opcode: 'fetch_fakeblock',
visible: true
}));
vm.runtime.requestAddMonitor(MonitorRecord({
// should not be serialized at all
id: 'fakeblock2',
opcode: 'bitwise_fakeblock',
visible: false
}));
vm.extensionManager.getExtensionURLs = () => ({
fetch: 'https://extensions.turbowarp.org/fetch.js'
});
const json = JSON.parse(vm.toJSON());
t.same(json.extensions, ['fetch']);
t.same(json.extensionURLs, {
fetch: 'https://extensions.turbowarp.org/fetch.js'
});
t.end();
});

View File

@@ -0,0 +1,48 @@
const {test} = require('tap');
const VirtualMachine = require('../../src/virtual-machine');
const RenderedTarget = require('../../src/sprites/rendered-target');
const Sprite = require('../../src/sprites/sprite');
test('Serializes custom extensions', t => {
t.plan(6);
const vm = new VirtualMachine();
// Trick the extension manager into thinking a couple extensions are loaded.
vm.extensionManager.workerURLs[0] = 'https://example.com/test1.js';
vm.extensionManager.workerURLs[1] = 'https://example.com/test2.js';
// First number in the service names corresponds to index in workerURLs
vm.extensionManager._loadedExtensions.set('test1', 'test.0.0');
vm.extensionManager._loadedExtensions.set('test2', 'test.1.0');
const targetUsingBlock = new RenderedTarget(new Sprite(null, vm.runtime), vm.runtime);
vm.runtime.addTarget(targetUsingBlock);
targetUsingBlock.blocks.createBlock({
id: 'a',
opcode: 'test1_something'
});
const targetNotUsingBlock = new RenderedTarget(new Sprite(null, vm.runtime), vm.runtime);
vm.runtime.addTarget(targetNotUsingBlock);
// test2 isn't used, so it shouldn't be included in the JSON
const serializedProject = JSON.parse(vm.toJSON());
t.same(serializedProject.extensions, ['test1'], 'save extension IDs for project');
t.same(serializedProject.extensionURLs, {
test1: 'https://example.com/test1.js'
}, 'save extension URLs for project');
const serializedTargetWithBlock = JSON.parse(vm.toJSON(targetUsingBlock.id));
t.same(serializedTargetWithBlock.extensions, ['test1'], 'save extension IDs for sprite');
t.same(serializedTargetWithBlock.extensionURLs, {
test1: 'https://example.com/test1.js'
}, 'save extension URLs for sprite');
// other sprite uses no extensions, so don't want extension stuff in the JSON
const serializedTargetWithoutBlock = JSON.parse(vm.toJSON(targetNotUsingBlock.id));
t.notOk('extensions' in serializedTargetWithoutBlock, 'dont save extension IDs for empty sprite');
t.notOk('extensionURLs' in serializedTargetWithoutBlock, 'dont save extension URLs for empty sprite');
t.end();
});

View File

@@ -0,0 +1,31 @@
const Runtime = require('../../src/engine/runtime');
const {test} = require('tap');
const {serialize} = require('../../src/serialization/sb3');
const MonitorRecord = require('../../src/engine/monitor-record');
test('does not serialize hidden monitors from extensions', t => {
const rt = new Runtime();
rt.requestAddMonitor(MonitorRecord({
id: 'timer',
opcode: 'sensing_timer',
visible: true
}));
rt.requestAddMonitor(MonitorRecord({
id: 'other_monitor',
opcode: 'tw_someOpcodeThatIsntPartOfACoreExtension',
visible: true
}));
const monitorsWhenVisible = serialize(rt).monitors;
t.ok(monitorsWhenVisible[0].id === 'timer');
t.ok(monitorsWhenVisible[1].id === 'other_monitor');
t.equal(monitorsWhenVisible.length, 2);
rt.requestHideMonitor('timer');
rt.requestHideMonitor('other_monitor');
const monitorsWhenHidden = serialize(rt).monitors;
t.ok(monitorsWhenHidden[0].id === 'timer');
t.equal(monitorsWhenHidden.length, 1);
t.end();
});

View File

@@ -0,0 +1,29 @@
const fs = require('fs');
const path = require('path');
const {test} = require('tap');
const VirtualMachine = require('../../src/virtual-machine');
const projectPath = path.join(__dirname, '..', 'fixtures', 'tw-stackframe-op.sb3');
test('util.thread.peekStackFrame().op', t => {
const vm = new VirtualMachine();
vm.runtime.setCompilerOptions({
enabled: false
});
// Easiest way to test this is to overwrite an existing block
vm.runtime._primitives.operator_add = function (args, util) {
const op = util.thread.peekStackFrame().op;
t.equal(op.id, 'c');
t.end();
// return value not used
return 4;
};
vm.loadProject(fs.readFileSync(projectPath)).then(() => {
vm.greenFlag();
vm.runtime._step();
});
});

View File

@@ -0,0 +1,116 @@
const {test} = require('tap');
const VirtualMachine = require('../../src/virtual-machine');
const RenderedTarget = require('../../src/sprites/rendered-target');
const Sprite = require('../../src/sprites/sprite');
test('Serializes standalone blocks', t => {
t.plan(4);
const vm = new VirtualMachine();
vm.extensionManager.workerURLs[0] = 'https://example.com/test1.js';
vm.extensionManager.workerURLs[1] = 'https://example.com/test2.js';
vm.extensionManager._loadedExtensions.set('test1', 'test.0.0');
vm.extensionManager._loadedExtensions.set('test2', 'test.1.0');
const primitiveBlock = {
id: 'donotcompress1',
opcode: 'control_if'
};
const extensionBlock1 = {
id: 'donotcompress2',
opcode: 'test1_something'
};
const extensionBlock2 = {
id: 'donotcompress3',
opcode: 'test2_something'
};
t.same(vm.exportStandaloneBlocks([]), []);
t.same(vm.exportStandaloneBlocks([primitiveBlock]), [primitiveBlock]);
t.same(vm.exportStandaloneBlocks([extensionBlock1, extensionBlock2]), {
blocks: [extensionBlock1, extensionBlock2],
extensionURLs: {
test1: 'https://example.com/test1.js',
test2: 'https://example.com/test2.js'
}
});
t.same(vm.exportStandaloneBlocks([primitiveBlock, extensionBlock2]), {
blocks: [primitiveBlock, extensionBlock2],
extensionURLs: {
test2: 'https://example.com/test2.js'
}
});
t.end();
});
test('Deserializes vanilla standalone blocks', t => {
t.plan(2);
const vm = new VirtualMachine();
const target = new RenderedTarget(new Sprite(null, vm.runtime), vm.runtime);
vm.runtime.addTarget(target);
vm.shareBlocksToTarget([
{
id: 'abcdef',
opcode: 'control_if'
}
], target.id).then(() => {
const createdBlock = Object.values(target.sprite.blocks._blocks)[0];
t.equal(createdBlock.opcode, 'control_if');
t.not(createdBlock.id, 'abcdef', 'opcode changed');
t.end();
});
});
test('Deserializes standalone blocks with extensions', t => {
t.plan(3);
const vm = new VirtualMachine();
const target = new RenderedTarget(new Sprite(null, vm.runtime), vm.runtime);
vm.runtime.addTarget(target);
const events = [];
vm.securityManager.canLoadExtensionFromProject = url => {
events.push(`canLoadExtensionFromProject ${url}`);
return true;
};
vm.extensionManager.loadExtensionURL = url => {
events.push(`loadExtensionURL ${url}`);
return Promise.resolve();
};
vm.shareBlocksToTarget({
blocks: [
{
id: 'fruit',
opcode: 'pen_clear'
},
{
id: 'vegetable',
opcode: 'test1_something'
}
],
extensionURLs: {
test1: 'https://example.com/test1.js',
test2: 'https://example.com/should.be.discarded.js',
pen: 'https://example.com/should.also.be.discarded.js'
}
}, target.id).then(() => {
t.same(events, [
'canLoadExtensionFromProject https://example.com/test1.js',
'loadExtensionURL https://example.com/test1.js'
]);
const penBlock = Object.values(target.sprite.blocks._blocks).find(i => i.opcode === 'pen_clear');
t.not(penBlock.id, 'fruit', 'changed pen block id');
const extensionBlock = Object.values(target.sprite.blocks._blocks).find(i => i.opcode === 'test1_something');
t.not(extensionBlock.id, 'vegetable', 'changed extension block id');
t.end();
});
});

View File

@@ -0,0 +1,24 @@
const Runtime = require('../../src/engine/runtime');
const {test} = require('tap');
test('step events', t => {
const events = [];
const rt = new Runtime();
rt.sequencer.stepThreads = () => {
events.push('sequencer.stepThreads()');
return [];
};
rt.on('BEFORE_EXECUTE', () => {
events.push('BEFORE_EXECUTE');
});
rt.on('AFTER_EXECUTE', () => {
events.push('AFTER_EXECUTE');
});
rt._step();
t.same(events, [
'BEFORE_EXECUTE',
'sequencer.stepThreads()',
'AFTER_EXECUTE'
]);
t.end();
});

View File

@@ -0,0 +1,82 @@
const {test} = require('tap');
const fs = require('fs');
const VirtualMachine = require('../../src/virtual-machine');
const Runtime = require('../../src/engine/runtime');
const sb3 = require('../../src/serialization/sb3');
const RenderedTarget = require('../../src/sprites/rendered-target');
const Sprite = require('../../src/sprites/sprite');
const path = require('path');
test('serializes very long comments', t => {
const rt = new Runtime();
const sprite = new Sprite();
const target = new RenderedTarget(sprite);
rt.addTarget(target);
target.createComment('id_short', null, 'short comment', 0, 0, 20, 20, false);
target.createComment('id_max_length', null, `start${'0'.repeat(8000 - 5)}`, 0, 0, 20, 20, false);
target.createComment('id_max_length_plus_1', null, `start${'0'.repeat(8000 - 5)}a`, 0, 0, 20, 20, false);
target.createComment(
'id_way_too_long',
null,
`start${'0'.repeat(8000 - 5 - 3)}endthis should be the truncated part${'0'.repeat(25000)}`,
0,
0,
20,
20,
false
);
const serialized = sb3.serialize(rt, target.id);
const common = {
// same for every block, already tested elsewhere, not interesting for here
blockId: null,
x: 0,
y: 0,
width: 20,
height: 20,
minimized: false
};
t.same(serialized.comments, {
id_short: {
...common,
text: 'short comment'
},
id_max_length: {
...common,
text: `start${'0'.repeat(8000 - 5)}`
},
id_max_length_plus_1: {
...common,
text: `start${'0'.repeat(8000 - 5)}`,
extraText: 'a'
},
id_way_too_long: {
...common,
text: `start${'0'.repeat(8000 - 5 - 3)}end`,
extraText: `this should be the truncated part${'0'.repeat(25000)}`
}
});
t.end();
});
test('deserializes very long comments', t => {
const vm = new VirtualMachine();
const fixture = fs.readFileSync(path.resolve(__dirname, '../fixtures/tw-very-long-comments.sb3'));
vm.loadProject(fixture).then(() => {
const comments = vm.runtime.targets[0].comments;
// note that comment IDs may change each time the test project is saved
t.equal(comments.b.text, '');
t.equal(comments.a.text, 'short');
t.equal(comments.c.text, `exactly length limit${'0'.repeat(8000 - 'exactly length limit'.length)}`);
t.equal(comments.d.text, `length limit + 1${':'.repeat(8000 - 'length limit + 1'.length)}1`);
t.equal(
comments.e.text,
`unreasonably long${'!'.repeat(8000 - 'unreasonably long'.length)}${'123456789'.repeat(2000)}`
);
t.end();
});
});

View File

@@ -0,0 +1,38 @@
const htmlparser = require('htmlparser2');
const {test} = require('tap');
const VirtualMachine = require('../../src/virtual-machine');
const BlockType = require('../../src/extension-support/block-type');
test('XML blocks', t => {
const vm = new VirtualMachine();
vm.extensionManager._registerInternalExtension({
getInfo: () => ({
id: 'testxml',
name: 'XML Test',
blocks: [
{
blockType: BlockType.XML,
xml: '<test it="works">!</test>'
}
]
})
});
const xmlList = vm.runtime.getBlocksXML();
t.equal(xmlList.length, 1);
const parsedXML = htmlparser.parseDOM(xmlList[0].xml);
t.equal(parsedXML.length, 1);
const category = parsedXML[0];
t.equal(category.name, 'category');
t.equal(category.children.length, 1);
const label = category.children[0];
t.equal(label.name, 'test');
t.equal(label.attribs.it, 'works');
t.equal(label.children.length, 1);
t.equal(label.children[0].data, '!');
t.end();
});

View File

@@ -0,0 +1,54 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const uri = path.resolve(__dirname, '../fixtures/unknown-opcode-as-reporter-block.sb2');
const project = readFileToBuffer(uri);
test('unknown opcode', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
// The project has 4 blocks in a single stack:
// when green flag
// if "unknown block"
// set volume to "unknown block"
// play sound "unknown block"
// the "unknown block" has unknown opcode and was created by
// dragging a discontinued extension.
// It should be parsed in without error and a shadow block
// should be created where appropriate.
const blocks = vm.runtime.targets[0].blocks;
const topBlockId = blocks.getScripts()[0];
const secondBlockId = blocks.getNextBlock(topBlockId);
const thirdBlockId = blocks.getNextBlock(secondBlockId);
const fourthBlockId = blocks.getNextBlock(thirdBlockId);
t.equal(blocks.getBlock(topBlockId).opcode, 'event_whenflagclicked');
t.equal(blocks.getBlock(secondBlockId).opcode, 'control_wait_until');
t.equal(blocks.getBlock(thirdBlockId).opcode, 'sound_setvolumeto');
t.equal(blocks.getBlock(fourthBlockId).opcode, 'sound_play');
const secondBlockInputId = blocks.getBlock(secondBlockId).inputs.CONDITION.block;
const thirdBlockInputId = blocks.getBlock(thirdBlockId).inputs.VOLUME.block;
const fourthBlockInputId = blocks.getBlock(fourthBlockId).inputs.SOUND_MENU.block;
t.equal(secondBlockInputId, null);
t.true(blocks.getBlock(thirdBlockInputId).shadow);
t.equal(blocks.getBlock(thirdBlockInputId).opcode, 'math_number');
t.true(blocks.getBlock(fourthBlockInputId).shadow);
t.equal(blocks.getBlock(fourthBlockInputId).opcode, 'sound_sounds_menu');
vm.quit();
t.end();
});
});

View File

@@ -0,0 +1,39 @@
const path = require('path');
const test = require('tap').test;
const makeTestStorage = require('../fixtures/make-test-storage');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index');
const uri = path.resolve(__dirname, '../fixtures/unknown-opcode-in-c-block.sb2');
const project = readFileToBuffer(uri);
test('unknown opcode', t => {
const vm = new VirtualMachine();
vm.attachStorage(makeTestStorage());
vm.start();
vm.clear();
vm.setCompatibilityMode(false);
vm.setTurboMode(false);
vm.loadProject(project).then(() => {
vm.greenFlag();
// The project has 3 blocks in a single stack:
// when green flag => forever [ => "undefined"]
// the "undefined" block has opcode "foo" and was created by dragging
// a custom procedure caller named foo from the backpack into a project.
// It should be parsed in without error and it should
// leave the forever block empty.
const blocks = vm.runtime.targets[1].blocks;
const topBlockId = blocks.getScripts()[0];
const secondBlockId = blocks.getNextBlock(topBlockId);
const innerBlockId = blocks.getBranch(secondBlockId, 0);
t.equal(blocks.getBlock(topBlockId).opcode, 'event_whenflagclicked');
t.equal(blocks.getBlock(secondBlockId).opcode, 'control_forever');
t.equal(innerBlockId, null);
vm.quit();
t.end();
});
});

Some files were not shown because too many files have changed in this diff Show More