mirror of
https://github.com/scratchfoundation/scratch-vm.git
synced 2024-12-24 06:52:40 -05:00
Merge pull request #3637 from kchadha/gqm-serialize-broken
Handle errors while loading vector costumes or sounds
This commit is contained in:
commit
5c68d7fae7
24 changed files with 797 additions and 52 deletions
32
package-lock.json
generated
32
package-lock.json
generated
|
@ -15763,6 +15763,32 @@
|
||||||
"scratch-storage": "^1.0.0",
|
"scratch-storage": "^1.0.0",
|
||||||
"scratch-svg-renderer": "0.2.0-prerelease.20210727023023",
|
"scratch-svg-renderer": "0.2.0-prerelease.20210727023023",
|
||||||
"twgl.js": "4.4.0"
|
"twgl.js": "4.4.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"scratch-storage": {
|
||||||
|
"version": "1.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/scratch-storage/-/scratch-storage-1.3.6.tgz",
|
||||||
|
"integrity": "sha512-L/7z7SB7cGANsgjyiE+qZNaPEqFHK1yPbNomizkgN3WHGcKRogLvmheR57kOxHNpQzodUTbG+pVVH6fR2ZY1Sg==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"arraybuffer-loader": "^1.0.3",
|
||||||
|
"base64-js": "1.3.0",
|
||||||
|
"fastestsmallesttextencoderdecoder": "^1.0.7",
|
||||||
|
"js-md5": "0.7.3",
|
||||||
|
"minilog": "3.1.0",
|
||||||
|
"worker-loader": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"worker-loader": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-tnvNp4K3KQOpfRnD20m8xltE3eWh89Ye+5oj7wXEEHKac1P4oZ6p9oTj8/8ExqoSBnk9nu5Pr4nKfQ1hn2APJw==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"loader-utils": "^1.0.0",
|
||||||
|
"schema-utils": "^0.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scratch-render-fonts": {
|
"scratch-render-fonts": {
|
||||||
|
@ -15785,9 +15811,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scratch-storage": {
|
"scratch-storage": {
|
||||||
"version": "1.3.6",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/scratch-storage/-/scratch-storage-1.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/scratch-storage/-/scratch-storage-2.0.1.tgz",
|
||||||
"integrity": "sha512-L/7z7SB7cGANsgjyiE+qZNaPEqFHK1yPbNomizkgN3WHGcKRogLvmheR57kOxHNpQzodUTbG+pVVH6fR2ZY1Sg==",
|
"integrity": "sha512-1Z4sR6jwhpcaFeOY9W5l/u3KKzGKDQcV0WH77OVQ6FFpxXZuKcE/PcXukKnu/PozB/l6QvX2fSADjoXNJ6hbOQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"arraybuffer-loader": "^1.0.3",
|
"arraybuffer-loader": "^1.0.3",
|
||||||
|
|
|
@ -67,6 +67,7 @@
|
||||||
"gh-pages": "1.2.0",
|
"gh-pages": "1.2.0",
|
||||||
"in-publish": "2.0.1",
|
"in-publish": "2.0.1",
|
||||||
"jsdoc": "3.6.6",
|
"jsdoc": "3.6.6",
|
||||||
|
"js-md5": "0.7.3",
|
||||||
"json": "^9.0.4",
|
"json": "^9.0.4",
|
||||||
"lodash.defaultsdeep": "4.6.1",
|
"lodash.defaultsdeep": "4.6.1",
|
||||||
"pngjs": "3.3.3",
|
"pngjs": "3.3.3",
|
||||||
|
@ -75,7 +76,7 @@
|
||||||
"scratch-l10n": "3.14.20220519031627",
|
"scratch-l10n": "3.14.20220519031627",
|
||||||
"scratch-render": "0.1.0-prerelease.20211028200436",
|
"scratch-render": "0.1.0-prerelease.20211028200436",
|
||||||
"scratch-render-fonts": "1.0.0-prerelease.20210401210003",
|
"scratch-render-fonts": "1.0.0-prerelease.20210401210003",
|
||||||
"scratch-storage": "1.3.6",
|
"scratch-storage": "2.0.1",
|
||||||
"scratch-svg-renderer": "0.2.0-prerelease.20210727023023",
|
"scratch-svg-renderer": "0.2.0-prerelease.20210727023023",
|
||||||
"script-loader": "0.7.2",
|
"script-loader": "0.7.2",
|
||||||
"stats.js": "0.17.0",
|
"stats.js": "0.17.0",
|
||||||
|
|
|
@ -247,6 +247,47 @@ const loadBitmap_ = function (costume, runtime, _rotationCenter) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle all manner of costume errors with a Gray Question Mark (default costume)
|
||||||
|
// and preserve as much of the original costume data as possible
|
||||||
|
// Returns a promise of a costume
|
||||||
|
const handleCostumeLoadError = function (costume, runtime) {
|
||||||
|
// Keep track of the old asset information until we're done loading the default costume
|
||||||
|
const oldAsset = costume.asset; // could be null
|
||||||
|
const oldAssetId = costume.assetId;
|
||||||
|
const oldRotationX = costume.rotationCenterX;
|
||||||
|
const oldRotationY = costume.rotationCenterY;
|
||||||
|
const oldBitmapResolution = costume.bitmapResolution;
|
||||||
|
const oldDataFormat = costume.dataFormat;
|
||||||
|
|
||||||
|
const AssetType = runtime.storage.AssetType;
|
||||||
|
const isVector = costume.dataFormat === AssetType.ImageVector.runtimeFormat;
|
||||||
|
|
||||||
|
// Use default asset if original fails to load
|
||||||
|
costume.assetId = isVector ?
|
||||||
|
runtime.storage.defaultAssetId.ImageVector :
|
||||||
|
runtime.storage.defaultAssetId.ImageBitmap;
|
||||||
|
costume.asset = runtime.storage.get(costume.assetId);
|
||||||
|
costume.md5 = `${costume.assetId}.${costume.asset.dataFormat}`;
|
||||||
|
|
||||||
|
const defaultCostumePromise = (isVector) ?
|
||||||
|
loadVector_(costume, runtime) : loadBitmap_(costume, runtime);
|
||||||
|
|
||||||
|
return defaultCostumePromise.then(loadedCostume => {
|
||||||
|
loadedCostume.broken = {};
|
||||||
|
loadedCostume.broken.assetId = oldAssetId;
|
||||||
|
loadedCostume.broken.md5 = `${oldAssetId}.${oldDataFormat}`;
|
||||||
|
|
||||||
|
// Should be null if we got here because the costume was missing
|
||||||
|
loadedCostume.broken.asset = oldAsset;
|
||||||
|
loadedCostume.broken.dataFormat = oldDataFormat;
|
||||||
|
|
||||||
|
loadedCostume.broken.rotationCenterX = oldRotationX;
|
||||||
|
loadedCostume.broken.rotationCenterY = oldRotationY;
|
||||||
|
loadedCostume.broken.bitmapResolution = oldBitmapResolution;
|
||||||
|
return loadedCostume;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize a costume from an asset asynchronously.
|
* Initialize a costume from an asset asynchronously.
|
||||||
* Do not call this unless there is a renderer attached.
|
* Do not call this unless there is a renderer attached.
|
||||||
|
@ -265,7 +306,7 @@ const loadCostumeFromAsset = function (costume, runtime, optVersion) {
|
||||||
costume.assetId = costume.asset.assetId;
|
costume.assetId = costume.asset.assetId;
|
||||||
const renderer = runtime.renderer;
|
const renderer = runtime.renderer;
|
||||||
if (!renderer) {
|
if (!renderer) {
|
||||||
log.error('No rendering module present; cannot load costume: ', costume.name);
|
log.warn('No rendering module present; cannot load costume: ', costume.name);
|
||||||
return Promise.resolve(costume);
|
return Promise.resolve(costume);
|
||||||
}
|
}
|
||||||
const AssetType = runtime.storage.AssetType;
|
const AssetType = runtime.storage.AssetType;
|
||||||
|
@ -279,17 +320,15 @@ const loadCostumeFromAsset = function (costume, runtime, optVersion) {
|
||||||
if (costume.asset.assetType.runtimeFormat === AssetType.ImageVector.runtimeFormat) {
|
if (costume.asset.assetType.runtimeFormat === AssetType.ImageVector.runtimeFormat) {
|
||||||
return loadVector_(costume, runtime, rotationCenter, optVersion)
|
return loadVector_(costume, runtime, rotationCenter, optVersion)
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
log.warn(`Error loading vector image: ${error.name}: ${error.message}`);
|
log.warn(`Error loading vector image: ${error}`);
|
||||||
// Use default asset if original fails to load
|
return handleCostumeLoadError(costume, runtime);
|
||||||
costume.assetId = runtime.storage.defaultAssetId.ImageVector;
|
|
||||||
costume.asset = runtime.storage.get(costume.assetId);
|
|
||||||
costume.md5 = `${costume.assetId}.${AssetType.ImageVector.runtimeFormat}`;
|
|
||||||
return loadVector_(costume, runtime);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return loadBitmap_(costume, runtime, rotationCenter, optVersion);
|
return loadBitmap_(costume, runtime, rotationCenter, optVersion);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a costume's asset into memory asynchronously.
|
* Load a costume's asset into memory asynchronously.
|
||||||
* Do not call this unless there is a renderer attached.
|
* Do not call this unless there is a renderer attached.
|
||||||
|
@ -317,12 +356,12 @@ const loadCostume = function (md5ext, costume, runtime, optVersion) {
|
||||||
|
|
||||||
// Need to load the costume from storage. The server should have a reference to this md5.
|
// Need to load the costume from storage. The server should have a reference to this md5.
|
||||||
if (!runtime.storage) {
|
if (!runtime.storage) {
|
||||||
log.error('No storage module present; cannot load costume asset: ', md5ext);
|
log.warn('No storage module present; cannot load costume asset: ', md5ext);
|
||||||
return Promise.resolve(costume);
|
return Promise.resolve(costume);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!runtime.storage.defaultAssetId) {
|
if (!runtime.storage.defaultAssetId) {
|
||||||
log.error(`No default assets found`);
|
log.warn(`No default assets found`);
|
||||||
return Promise.resolve(costume);
|
return Promise.resolve(costume);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -330,10 +369,6 @@ const loadCostume = function (md5ext, costume, runtime, optVersion) {
|
||||||
const assetType = (ext === 'svg') ? AssetType.ImageVector : AssetType.ImageBitmap;
|
const assetType = (ext === 'svg') ? AssetType.ImageVector : AssetType.ImageBitmap;
|
||||||
|
|
||||||
const costumePromise = runtime.storage.load(assetType, md5, ext);
|
const costumePromise = runtime.storage.load(assetType, md5, ext);
|
||||||
if (!costumePromise) {
|
|
||||||
log.error(`Couldn't fetch costume asset: ${md5ext}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let textLayerPromise;
|
let textLayerPromise;
|
||||||
if (costume.textLayerMD5) {
|
if (costume.textLayerMD5) {
|
||||||
|
@ -343,7 +378,12 @@ const loadCostume = function (md5ext, costume, runtime, optVersion) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.all([costumePromise, textLayerPromise]).then(assetArray => {
|
return Promise.all([costumePromise, textLayerPromise]).then(assetArray => {
|
||||||
|
if (assetArray[0]) {
|
||||||
costume.asset = assetArray[0];
|
costume.asset = assetArray[0];
|
||||||
|
} else {
|
||||||
|
return handleCostumeLoadError(costume, runtime);
|
||||||
|
}
|
||||||
|
|
||||||
if (assetArray[1]) {
|
if (assetArray[1]) {
|
||||||
costume.textLayerAsset = assetArray[1];
|
costume.textLayerAsset = assetArray[1];
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ const log = require('../util/log');
|
||||||
const loadSoundFromAsset = function (sound, soundAsset, runtime, soundBank) {
|
const loadSoundFromAsset = function (sound, soundAsset, runtime, soundBank) {
|
||||||
sound.assetId = soundAsset.assetId;
|
sound.assetId = soundAsset.assetId;
|
||||||
if (!runtime.audioEngine) {
|
if (!runtime.audioEngine) {
|
||||||
log.error('No audio engine present; cannot load sound asset: ', sound.md5);
|
log.warn('No audio engine present; cannot load sound asset: ', sound.md5);
|
||||||
return Promise.resolve(sound);
|
return Promise.resolve(sound);
|
||||||
}
|
}
|
||||||
return runtime.audioEngine.decodeSoundPlayer(Object.assign(
|
return runtime.audioEngine.decodeSoundPlayer(Object.assign(
|
||||||
|
@ -38,6 +38,40 @@ const loadSoundFromAsset = function (sound, soundAsset, runtime, soundBank) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle sound loading errors by replacing the runtime sound with the
|
||||||
|
// default sound from storage, but keeping track of the original sound metadata
|
||||||
|
// in a `broken` field
|
||||||
|
const handleSoundLoadError = function (sound, runtime, soundBank) {
|
||||||
|
// Keep track of the old asset information until we're done loading the default sound
|
||||||
|
const oldAsset = sound.asset; // could be null
|
||||||
|
const oldAssetId = sound.assetId;
|
||||||
|
const oldSample = sound.sampleCount;
|
||||||
|
const oldRate = sound.rate;
|
||||||
|
const oldFormat = sound.format;
|
||||||
|
const oldDataFormat = sound.dataFormat;
|
||||||
|
|
||||||
|
// Use default asset if original fails to load
|
||||||
|
sound.assetId = runtime.storage.defaultAssetId.Sound;
|
||||||
|
sound.asset = runtime.storage.get(sound.assetId);
|
||||||
|
sound.md5 = `${sound.assetId}.${sound.asset.dataFormat}`;
|
||||||
|
|
||||||
|
return loadSoundFromAsset(sound, sound.asset, runtime, soundBank).then(loadedSound => {
|
||||||
|
loadedSound.broken = {};
|
||||||
|
loadedSound.broken.assetId = oldAssetId;
|
||||||
|
loadedSound.broken.md5 = `${oldAssetId}.${oldDataFormat}`;
|
||||||
|
|
||||||
|
// Should be null if we got here because the sound was missing
|
||||||
|
loadedSound.broken.asset = oldAsset;
|
||||||
|
|
||||||
|
loadedSound.broken.sampleCount = oldSample;
|
||||||
|
loadedSound.broken.rate = oldRate;
|
||||||
|
loadedSound.broken.format = oldFormat;
|
||||||
|
loadedSound.broken.dataFormat = oldDataFormat;
|
||||||
|
|
||||||
|
return loadedSound;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a sound's asset into memory asynchronously.
|
* Load a sound's asset into memory asynchronously.
|
||||||
* @param {!object} sound - the Scratch sound object.
|
* @param {!object} sound - the Scratch sound object.
|
||||||
|
@ -49,7 +83,7 @@ const loadSoundFromAsset = function (sound, soundAsset, runtime, soundBank) {
|
||||||
*/
|
*/
|
||||||
const loadSound = function (sound, runtime, soundBank) {
|
const loadSound = function (sound, runtime, soundBank) {
|
||||||
if (!runtime.storage) {
|
if (!runtime.storage) {
|
||||||
log.error('No storage module present; cannot load sound asset: ', sound.md5);
|
log.warn('No storage module present; cannot load sound asset: ', sound.md5);
|
||||||
return Promise.resolve(sound);
|
return Promise.resolve(sound);
|
||||||
}
|
}
|
||||||
const idParts = StringUtil.splitFirst(sound.md5, '.');
|
const idParts = StringUtil.splitFirst(sound.md5, '.');
|
||||||
|
@ -59,9 +93,20 @@ const loadSound = function (sound, runtime, soundBank) {
|
||||||
return (
|
return (
|
||||||
(sound.asset && Promise.resolve(sound.asset)) ||
|
(sound.asset && Promise.resolve(sound.asset)) ||
|
||||||
runtime.storage.load(runtime.storage.AssetType.Sound, md5, ext)
|
runtime.storage.load(runtime.storage.AssetType.Sound, md5, ext)
|
||||||
).then(soundAsset => {
|
)
|
||||||
|
.then(soundAsset => {
|
||||||
sound.asset = soundAsset;
|
sound.asset = soundAsset;
|
||||||
|
|
||||||
|
if (!soundAsset) {
|
||||||
|
log.warn('Failed to find sound data: ', sound.md5);
|
||||||
|
return handleSoundLoadError(sound, runtime, soundBank);
|
||||||
|
}
|
||||||
|
|
||||||
return loadSoundFromAsset(sound, soundAsset, runtime, soundBank);
|
return loadSoundFromAsset(sound, soundAsset, runtime, soundBank);
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
log.warn(`Failed to load sound: ${sound.md5} with error: ${e}`);
|
||||||
|
return handleSoundLoadError(sound, runtime, soundBank);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,7 @@ const deserializeSound = function (sound, runtime, zip, assetFileName) {
|
||||||
const fileName = assetFileName ? assetFileName : sound.md5;
|
const fileName = assetFileName ? assetFileName : sound.md5;
|
||||||
const storage = runtime.storage;
|
const storage = runtime.storage;
|
||||||
if (!storage) {
|
if (!storage) {
|
||||||
log.error('No storage module present; cannot load sound asset: ', fileName);
|
log.warn('No storage module present; cannot load sound asset: ', fileName);
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,7 +81,7 @@ const deserializeCostume = function (costume, runtime, zip, assetFileName, textL
|
||||||
`${assetId}.${costume.dataFormat}`;
|
`${assetId}.${costume.dataFormat}`;
|
||||||
|
|
||||||
if (!storage) {
|
if (!storage) {
|
||||||
log.error('No storage module present; cannot load costume asset: ', fileName);
|
log.warn('No storage module present; cannot load costume asset: ', fileName);
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -345,18 +345,25 @@ const serializeBlocks = function (blocks) {
|
||||||
*/
|
*/
|
||||||
const serializeCostume = function (costume) {
|
const serializeCostume = function (costume) {
|
||||||
const obj = Object.create(null);
|
const obj = Object.create(null);
|
||||||
obj.assetId = costume.assetId;
|
|
||||||
obj.name = costume.name;
|
obj.name = costume.name;
|
||||||
obj.bitmapResolution = costume.bitmapResolution;
|
|
||||||
|
const costumeToSerialize = costume.broken || costume;
|
||||||
|
|
||||||
|
obj.bitmapResolution = costumeToSerialize.bitmapResolution;
|
||||||
|
obj.dataFormat = costumeToSerialize.dataFormat.toLowerCase();
|
||||||
|
|
||||||
|
obj.assetId = costumeToSerialize.assetId;
|
||||||
|
|
||||||
// serialize this property with the name 'md5ext' because that's
|
// serialize this property with the name 'md5ext' because that's
|
||||||
// what it's actually referring to. TODO runtime objects need to be
|
// what it's actually referring to. TODO runtime objects need to be
|
||||||
// updated to actually refer to this as 'md5ext' instead of 'md5'
|
// updated to actually refer to this as 'md5ext' instead of 'md5'
|
||||||
// but that change should be made carefully since it is very
|
// but that change should be made carefully since it is very
|
||||||
// pervasive
|
// pervasive
|
||||||
obj.md5ext = costume.md5;
|
obj.md5ext = costumeToSerialize.md5;
|
||||||
obj.dataFormat = costume.dataFormat.toLowerCase();
|
|
||||||
obj.rotationCenterX = costume.rotationCenterX;
|
obj.rotationCenterX = costumeToSerialize.rotationCenterX;
|
||||||
obj.rotationCenterY = costume.rotationCenterY;
|
obj.rotationCenterY = costumeToSerialize.rotationCenterY;
|
||||||
|
|
||||||
return obj;
|
return obj;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -367,18 +374,21 @@ const serializeCostume = function (costume) {
|
||||||
*/
|
*/
|
||||||
const serializeSound = function (sound) {
|
const serializeSound = function (sound) {
|
||||||
const obj = Object.create(null);
|
const obj = Object.create(null);
|
||||||
obj.assetId = sound.assetId;
|
|
||||||
obj.name = sound.name;
|
obj.name = sound.name;
|
||||||
obj.dataFormat = sound.dataFormat.toLowerCase();
|
|
||||||
obj.format = sound.format;
|
const soundToSerialize = sound.broken || sound;
|
||||||
obj.rate = sound.rate;
|
|
||||||
obj.sampleCount = sound.sampleCount;
|
obj.assetId = soundToSerialize.assetId;
|
||||||
|
obj.dataFormat = soundToSerialize.dataFormat.toLowerCase();
|
||||||
|
obj.format = soundToSerialize.format;
|
||||||
|
obj.rate = soundToSerialize.rate;
|
||||||
|
obj.sampleCount = soundToSerialize.sampleCount;
|
||||||
// serialize this property with the name 'md5ext' because that's
|
// serialize this property with the name 'md5ext' because that's
|
||||||
// what it's actually referring to. TODO runtime objects need to be
|
// what it's actually referring to. TODO runtime objects need to be
|
||||||
// updated to actually refer to this as 'md5ext' instead of 'md5'
|
// updated to actually refer to this as 'md5ext' instead of 'md5'
|
||||||
// but that change should be made carefully since it is very
|
// but that change should be made carefully since it is very
|
||||||
// pervasive
|
// pervasive
|
||||||
obj.md5ext = sound.md5;
|
obj.md5ext = soundToSerialize.md5;
|
||||||
return obj;
|
return obj;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -16,10 +16,14 @@ const serializeAssets = function (runtime, assetType, optTargetId) {
|
||||||
const currAssets = currTarget.sprite[assetType];
|
const currAssets = currTarget.sprite[assetType];
|
||||||
for (let j = 0; j < currAssets.length; j++) {
|
for (let j = 0; j < currAssets.length; j++) {
|
||||||
const currAsset = currAssets[j];
|
const currAsset = currAssets[j];
|
||||||
const asset = currAsset.asset;
|
const asset = currAsset.broken ? currAsset.broken.asset : currAsset.asset;
|
||||||
|
if (asset) {
|
||||||
|
// Serialize asset if it exists, otherwise skip
|
||||||
assetDescs.push({
|
assetDescs.push({
|
||||||
fileName: `${asset.assetId}.${asset.dataFormat}`,
|
fileName: `${asset.assetId}.${asset.dataFormat}`,
|
||||||
fileContent: asset.data});
|
fileContent: asset.data
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return assetDescs;
|
return assetDescs;
|
||||||
|
|
|
@ -366,7 +366,11 @@ class VirtualMachine extends EventEmitter {
|
||||||
const vm = this;
|
const vm = this;
|
||||||
const promise = storage.load(storage.AssetType.Project, id);
|
const promise = storage.load(storage.AssetType.Project, id);
|
||||||
promise.then(projectAsset => {
|
promise.then(projectAsset => {
|
||||||
vm.loadProject(projectAsset.data);
|
if (!projectAsset) {
|
||||||
|
log.error(`Failed to fetch project with id: ${id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return vm.loadProject(projectAsset.data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -427,11 +431,9 @@ class VirtualMachine extends EventEmitter {
|
||||||
* specified by optZipType or blob by default.
|
* specified by optZipType or blob by default.
|
||||||
*/
|
*/
|
||||||
exportSprite (targetId, optZipType) {
|
exportSprite (targetId, optZipType) {
|
||||||
const sb3 = require('./serialization/sb3');
|
|
||||||
|
|
||||||
const soundDescs = serializeSounds(this.runtime, targetId);
|
const soundDescs = serializeSounds(this.runtime, targetId);
|
||||||
const costumeDescs = serializeCostumes(this.runtime, targetId);
|
const costumeDescs = serializeCostumes(this.runtime, targetId);
|
||||||
const spriteJson = StringUtil.stringify(sb3.serialize(this.runtime, targetId));
|
const spriteJson = this.toJSON(targetId);
|
||||||
|
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
zip.file('sprite.json', spriteJson);
|
zip.file('sprite.json', spriteJson);
|
||||||
|
@ -448,12 +450,13 @@ class VirtualMachine extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export project as a Scratch 3.0 JSON representation.
|
* Export project or sprite as a Scratch 3.0 JSON representation.
|
||||||
|
* @param {string=} optTargetId - Optional id of a sprite to serialize
|
||||||
* @return {string} Serialized state of the runtime.
|
* @return {string} Serialized state of the runtime.
|
||||||
*/
|
*/
|
||||||
toJSON () {
|
toJSON (optTargetId) {
|
||||||
const sb3 = require('./serialization/sb3');
|
const sb3 = require('./serialization/sb3');
|
||||||
return StringUtil.stringify(sb3.serialize(this.runtime));
|
return StringUtil.stringify(sb3.serialize(this.runtime, optTargetId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO do we still need this function? Keeping it here so as not to introduce
|
// TODO do we still need this function? Keeping it here so as not to introduce
|
||||||
|
@ -932,6 +935,7 @@ class VirtualMachine extends EventEmitter {
|
||||||
*/
|
*/
|
||||||
updateSvg (costumeIndex, svg, rotationCenterX, rotationCenterY) {
|
updateSvg (costumeIndex, svg, rotationCenterX, rotationCenterY) {
|
||||||
const costume = this.editingTarget.getCostumes()[costumeIndex];
|
const costume = this.editingTarget.getCostumes()[costumeIndex];
|
||||||
|
if (costume && costume.broken) delete costume.broken;
|
||||||
if (costume && this.runtime && this.runtime.renderer) {
|
if (costume && this.runtime && this.runtime.renderer) {
|
||||||
costume.rotationCenterX = rotationCenterX;
|
costume.rotationCenterX = rotationCenterX;
|
||||||
costume.rotationCenterY = rotationCenterY;
|
costume.rotationCenterY = rotationCenterY;
|
||||||
|
|
BIN
test/fixtures/corrupt_sound.sb3
vendored
Normal file
BIN
test/fixtures/corrupt_sound.sb3
vendored
Normal file
Binary file not shown.
BIN
test/fixtures/corrupt_svg.sb3
vendored
Normal file
BIN
test/fixtures/corrupt_svg.sb3
vendored
Normal file
Binary file not shown.
BIN
test/fixtures/corrupt_svg.sprite3
vendored
Normal file
BIN
test/fixtures/corrupt_svg.sprite3
vendored
Normal file
Binary file not shown.
BIN
test/fixtures/default.sb3
vendored
Normal file
BIN
test/fixtures/default.sb3
vendored
Normal file
Binary file not shown.
13
test/fixtures/fake-renderer.js
vendored
13
test/fixtures/fake-renderer.js
vendored
|
@ -4,6 +4,19 @@ const FakeRenderer = function () {
|
||||||
this.y = 0;
|
this.y = 0;
|
||||||
this.order = 0;
|
this.order = 0;
|
||||||
this.spriteCount = 5;
|
this.spriteCount = 5;
|
||||||
|
this._nextSkinId = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
FakeRenderer.prototype.createSVGSkin = function () {
|
||||||
|
return this._nextSkinId++;
|
||||||
|
};
|
||||||
|
|
||||||
|
FakeRenderer.prototype.getSkinSize = function (d) { // eslint-disable-line no-unused-vars
|
||||||
|
return [0, 0];
|
||||||
|
};
|
||||||
|
|
||||||
|
FakeRenderer.prototype.getSkinRotationCenter = function (d) { // eslint-disable-line no-unused-vars
|
||||||
|
return [0, 0];
|
||||||
};
|
};
|
||||||
|
|
||||||
FakeRenderer.prototype.createDrawable = function () {
|
FakeRenderer.prototype.createDrawable = function () {
|
||||||
|
|
4
test/fixtures/make-test-storage.js
vendored
4
test/fixtures/make-test-storage.js
vendored
|
@ -39,8 +39,8 @@ const getAssetUrl = function (asset) {
|
||||||
const makeTestStorage = function () {
|
const makeTestStorage = function () {
|
||||||
const storage = new ScratchStorage();
|
const storage = new ScratchStorage();
|
||||||
const AssetType = storage.AssetType;
|
const AssetType = storage.AssetType;
|
||||||
storage.addWebSource([AssetType.Project], getProjectUrl);
|
storage.addWebStore([AssetType.Project], getProjectUrl);
|
||||||
storage.addWebSource([AssetType.ImageVector, AssetType.ImageBitmap, AssetType.Sound], getAssetUrl);
|
storage.addWebStore([AssetType.ImageVector, AssetType.ImageBitmap, AssetType.Sound], getAssetUrl);
|
||||||
return storage;
|
return storage;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
BIN
test/fixtures/missing_sound.sb3
vendored
Normal file
BIN
test/fixtures/missing_sound.sb3
vendored
Normal file
Binary file not shown.
BIN
test/fixtures/missing_svg.sb3
vendored
Normal file
BIN
test/fixtures/missing_svg.sb3
vendored
Normal file
Binary file not shown.
BIN
test/fixtures/missing_svg.sprite3
vendored
Normal file
BIN
test/fixtures/missing_svg.sprite3
vendored
Normal file
Binary file not shown.
7
test/fixtures/readProjectFile.js
vendored
7
test/fixtures/readProjectFile.js
vendored
|
@ -7,10 +7,15 @@ module.exports = {
|
||||||
},
|
},
|
||||||
extractProjectJson: function (path) {
|
extractProjectJson: function (path) {
|
||||||
const zip = new AdmZip(path);
|
const zip = new AdmZip(path);
|
||||||
const projectEntry = zip.getEntries().filter(item => item.entryName.match(/project\.json/))[0];
|
const projectEntry = zip.getEntries().find(item => item.entryName.match(/project\.json/));
|
||||||
if (projectEntry) {
|
if (projectEntry) {
|
||||||
return JSON.parse(zip.readAsText(projectEntry.entryName, 'utf8'));
|
return JSON.parse(zip.readAsText(projectEntry.entryName, 'utf8'));
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
},
|
||||||
|
extractAsset: function (path, assetFileName) {
|
||||||
|
const zip = new AdmZip(path);
|
||||||
|
const assetEntry = zip.getEntries().find(item => item.entryName.match(assetFileName));
|
||||||
|
return assetEntry.getData();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
120
test/integration/sb3_corrupted_sound.js
Normal file
120
test/integration/sb3_corrupted_sound.js
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
process.nextTick(process.exit);
|
||||||
|
});
|
107
test/integration/sb3_corrupted_svg.js
Normal file
107
test/integration/sb3_corrupted_svg.js
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
process.nextTick(process.exit);
|
||||||
|
});
|
87
test/integration/sb3_missing_sound.js
Normal file
87
test/integration/sb3_missing_sound.js
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
process.nextTick(process.exit);
|
||||||
|
});
|
90
test/integration/sb3_missing_svg.js
Normal file
90
test/integration/sb3_missing_svg.js
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
process.nextTick(process.exit);
|
||||||
|
});
|
106
test/integration/sprite3_corrupted_svg.js
Normal file
106
test/integration/sprite3_corrupted_svg.js
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
process.nextTick(process.exit);
|
||||||
|
});
|
87
test/integration/sprite3_missing_svg.js
Normal file
87
test/integration/sprite3_missing_svg.js
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
process.nextTick(process.exit);
|
||||||
|
});
|
Loading…
Reference in a new issue