Merge pull request #3637 from kchadha/gqm-serialize-broken

Handle errors while loading vector costumes or sounds
This commit is contained in:
Karishma Chadha 2022-05-19 18:35:17 -04:00 committed by GitHub
commit 5c68d7fae7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 797 additions and 52 deletions

32
package-lock.json generated
View file

@ -15763,6 +15763,32 @@
"scratch-storage": "^1.0.0",
"scratch-svg-renderer": "0.2.0-prerelease.20210727023023",
"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": {
@ -15785,9 +15811,9 @@
}
},
"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==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/scratch-storage/-/scratch-storage-2.0.1.tgz",
"integrity": "sha512-1Z4sR6jwhpcaFeOY9W5l/u3KKzGKDQcV0WH77OVQ6FFpxXZuKcE/PcXukKnu/PozB/l6QvX2fSADjoXNJ6hbOQ==",
"dev": true,
"requires": {
"arraybuffer-loader": "^1.0.3",

View file

@ -67,6 +67,7 @@
"gh-pages": "1.2.0",
"in-publish": "2.0.1",
"jsdoc": "3.6.6",
"js-md5": "0.7.3",
"json": "^9.0.4",
"lodash.defaultsdeep": "4.6.1",
"pngjs": "3.3.3",
@ -75,7 +76,7 @@
"scratch-l10n": "3.14.20220519031627",
"scratch-render": "0.1.0-prerelease.20211028200436",
"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",
"script-loader": "0.7.2",
"stats.js": "0.17.0",

View file

@ -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.
* 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;
const renderer = runtime.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);
}
const AssetType = runtime.storage.AssetType;
@ -279,17 +320,15 @@ const loadCostumeFromAsset = function (costume, runtime, optVersion) {
if (costume.asset.assetType.runtimeFormat === AssetType.ImageVector.runtimeFormat) {
return loadVector_(costume, runtime, rotationCenter, optVersion)
.catch(error => {
log.warn(`Error loading vector image: ${error.name}: ${error.message}`);
// Use default asset if original fails to load
costume.assetId = runtime.storage.defaultAssetId.ImageVector;
costume.asset = runtime.storage.get(costume.assetId);
costume.md5 = `${costume.assetId}.${AssetType.ImageVector.runtimeFormat}`;
return loadVector_(costume, runtime);
log.warn(`Error loading vector image: ${error}`);
return handleCostumeLoadError(costume, runtime);
});
}
return loadBitmap_(costume, runtime, rotationCenter, optVersion);
};
/**
* Load a costume's asset into memory asynchronously.
* 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.
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);
}
if (!runtime.storage.defaultAssetId) {
log.error(`No default assets found`);
log.warn(`No default assets found`);
return Promise.resolve(costume);
}
@ -330,10 +369,6 @@ const loadCostume = function (md5ext, costume, runtime, optVersion) {
const assetType = (ext === 'svg') ? AssetType.ImageVector : AssetType.ImageBitmap;
const costumePromise = runtime.storage.load(assetType, md5, ext);
if (!costumePromise) {
log.error(`Couldn't fetch costume asset: ${md5ext}`);
return;
}
let textLayerPromise;
if (costume.textLayerMD5) {
@ -343,7 +378,12 @@ const loadCostume = function (md5ext, costume, runtime, optVersion) {
}
return Promise.all([costumePromise, textLayerPromise]).then(assetArray => {
if (assetArray[0]) {
costume.asset = assetArray[0];
} else {
return handleCostumeLoadError(costume, runtime);
}
if (assetArray[1]) {
costume.textLayerAsset = assetArray[1];
}

View file

@ -14,7 +14,7 @@ const log = require('../util/log');
const loadSoundFromAsset = function (sound, soundAsset, runtime, soundBank) {
sound.assetId = soundAsset.assetId;
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 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.
* @param {!object} sound - the Scratch sound object.
@ -49,7 +83,7 @@ const loadSoundFromAsset = function (sound, soundAsset, runtime, soundBank) {
*/
const loadSound = function (sound, runtime, soundBank) {
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);
}
const idParts = StringUtil.splitFirst(sound.md5, '.');
@ -59,9 +93,20 @@ const loadSound = function (sound, runtime, soundBank) {
return (
(sound.asset && Promise.resolve(sound.asset)) ||
runtime.storage.load(runtime.storage.AssetType.Sound, md5, ext)
).then(soundAsset => {
)
.then(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);
})
.catch(e => {
log.warn(`Failed to load sound: ${sound.md5} with error: ${e}`);
return handleSoundLoadError(sound, runtime, soundBank);
});
};

View file

@ -18,7 +18,7 @@ const deserializeSound = function (sound, runtime, zip, assetFileName) {
const fileName = assetFileName ? assetFileName : sound.md5;
const storage = runtime.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);
}
@ -81,7 +81,7 @@ const deserializeCostume = function (costume, runtime, zip, assetFileName, textL
`${assetId}.${costume.dataFormat}`;
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);
}

View file

@ -345,18 +345,25 @@ const serializeBlocks = function (blocks) {
*/
const serializeCostume = function (costume) {
const obj = Object.create(null);
obj.assetId = costume.assetId;
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
// what it's actually referring to. TODO runtime objects need to be
// updated to actually refer to this as 'md5ext' instead of 'md5'
// but that change should be made carefully since it is very
// pervasive
obj.md5ext = costume.md5;
obj.dataFormat = costume.dataFormat.toLowerCase();
obj.rotationCenterX = costume.rotationCenterX;
obj.rotationCenterY = costume.rotationCenterY;
obj.md5ext = costumeToSerialize.md5;
obj.rotationCenterX = costumeToSerialize.rotationCenterX;
obj.rotationCenterY = costumeToSerialize.rotationCenterY;
return obj;
};
@ -367,18 +374,21 @@ const serializeCostume = function (costume) {
*/
const serializeSound = function (sound) {
const obj = Object.create(null);
obj.assetId = sound.assetId;
obj.name = sound.name;
obj.dataFormat = sound.dataFormat.toLowerCase();
obj.format = sound.format;
obj.rate = sound.rate;
obj.sampleCount = sound.sampleCount;
const soundToSerialize = sound.broken || sound;
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
// what it's actually referring to. TODO runtime objects need to be
// updated to actually refer to this as 'md5ext' instead of 'md5'
// but that change should be made carefully since it is very
// pervasive
obj.md5ext = sound.md5;
obj.md5ext = soundToSerialize.md5;
return obj;
};

View file

@ -16,10 +16,14 @@ const serializeAssets = function (runtime, assetType, optTargetId) {
const currAssets = currTarget.sprite[assetType];
for (let j = 0; j < currAssets.length; 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({
fileName: `${asset.assetId}.${asset.dataFormat}`,
fileContent: asset.data});
fileContent: asset.data
});
}
}
}
return assetDescs;

View file

@ -366,7 +366,11 @@ class VirtualMachine extends EventEmitter {
const vm = this;
const promise = storage.load(storage.AssetType.Project, id);
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.
*/
exportSprite (targetId, optZipType) {
const sb3 = require('./serialization/sb3');
const soundDescs = serializeSounds(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();
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.
*/
toJSON () {
toJSON (optTargetId) {
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
@ -932,6 +935,7 @@ class VirtualMachine extends EventEmitter {
*/
updateSvg (costumeIndex, svg, rotationCenterX, rotationCenterY) {
const costume = this.editingTarget.getCostumes()[costumeIndex];
if (costume && costume.broken) delete costume.broken;
if (costume && this.runtime && this.runtime.renderer) {
costume.rotationCenterX = rotationCenterX;
costume.rotationCenterY = rotationCenterY;

BIN
test/fixtures/corrupt_sound.sb3 vendored Normal file

Binary file not shown.

BIN
test/fixtures/corrupt_svg.sb3 vendored Normal file

Binary file not shown.

BIN
test/fixtures/corrupt_svg.sprite3 vendored Normal file

Binary file not shown.

BIN
test/fixtures/default.sb3 vendored Normal file

Binary file not shown.

View file

@ -4,6 +4,19 @@ const FakeRenderer = function () {
this.y = 0;
this.order = 0;
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 () {

View file

@ -39,8 +39,8 @@ const getAssetUrl = function (asset) {
const makeTestStorage = function () {
const storage = new ScratchStorage();
const AssetType = storage.AssetType;
storage.addWebSource([AssetType.Project], getProjectUrl);
storage.addWebSource([AssetType.ImageVector, AssetType.ImageBitmap, AssetType.Sound], getAssetUrl);
storage.addWebStore([AssetType.Project], getProjectUrl);
storage.addWebStore([AssetType.ImageVector, AssetType.ImageBitmap, AssetType.Sound], getAssetUrl);
return storage;
};

BIN
test/fixtures/missing_sound.sb3 vendored Normal file

Binary file not shown.

BIN
test/fixtures/missing_svg.sb3 vendored Normal file

Binary file not shown.

BIN
test/fixtures/missing_svg.sprite3 vendored Normal file

Binary file not shown.

View file

@ -7,10 +7,15 @@ module.exports = {
},
extractProjectJson: function (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) {
return JSON.parse(zip.readAsText(projectEntry.entryName, 'utf8'));
}
return null;
},
extractAsset: function (path, assetFileName) {
const zip = new AdmZip(path);
const assetEntry = zip.getEntries().find(item => item.entryName.match(assetFileName));
return assetEntry.getData();
}
};

View 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);
});

View 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);
});

View 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);
});

View 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);
});

View 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);
});

View 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);
});