diff --git a/package-lock.json b/package-lock.json index 2d77977fa..9ee7409f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 661080408..20d7a9a8c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/import/load-costume.js b/src/import/load-costume.js index d76e8b912..6d0a2d259 100644 --- a/src/import/load-costume.js +++ b/src/import/load-costume.js @@ -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 => { - costume.asset = assetArray[0]; + if (assetArray[0]) { + costume.asset = assetArray[0]; + } else { + return handleCostumeLoadError(costume, runtime); + } + if (assetArray[1]) { costume.textLayerAsset = assetArray[1]; } diff --git a/src/import/load-sound.js b/src/import/load-sound.js index 639fcc2af..c74795391 100644 --- a/src/import/load-sound.js +++ b/src/import/load-sound.js @@ -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,10 +93,21 @@ const loadSound = function (sound, runtime, soundBank) { return ( (sound.asset && Promise.resolve(sound.asset)) || runtime.storage.load(runtime.storage.AssetType.Sound, md5, ext) - ).then(soundAsset => { - sound.asset = soundAsset; - return loadSoundFromAsset(sound, soundAsset, runtime, soundBank); - }); + ) + .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); + }); }; module.exports = { diff --git a/src/serialization/deserialize-assets.js b/src/serialization/deserialize-assets.js index bf59f915f..568614c3d 100644 --- a/src/serialization/deserialize-assets.js +++ b/src/serialization/deserialize-assets.js @@ -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); } diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 6989229c9..b176ec919 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -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; }; diff --git a/src/serialization/serialize-assets.js b/src/serialization/serialize-assets.js index c1d063949..62b28b123 100644 --- a/src/serialization/serialize-assets.js +++ b/src/serialization/serialize-assets.js @@ -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; - assetDescs.push({ - fileName: `${asset.assetId}.${asset.dataFormat}`, - fileContent: asset.data}); + 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 + }); + } } } return assetDescs; diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 131c421c2..a75ff0802 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -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; diff --git a/test/fixtures/corrupt_sound.sb3 b/test/fixtures/corrupt_sound.sb3 new file mode 100644 index 000000000..d2611babc Binary files /dev/null and b/test/fixtures/corrupt_sound.sb3 differ diff --git a/test/fixtures/corrupt_svg.sb3 b/test/fixtures/corrupt_svg.sb3 new file mode 100644 index 000000000..52fb93096 Binary files /dev/null and b/test/fixtures/corrupt_svg.sb3 differ diff --git a/test/fixtures/corrupt_svg.sprite3 b/test/fixtures/corrupt_svg.sprite3 new file mode 100644 index 000000000..1d9dd0b3d Binary files /dev/null and b/test/fixtures/corrupt_svg.sprite3 differ diff --git a/test/fixtures/default.sb3 b/test/fixtures/default.sb3 new file mode 100644 index 000000000..61b718389 Binary files /dev/null and b/test/fixtures/default.sb3 differ diff --git a/test/fixtures/fake-renderer.js b/test/fixtures/fake-renderer.js index dd0868767..f8343692e 100644 --- a/test/fixtures/fake-renderer.js +++ b/test/fixtures/fake-renderer.js @@ -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 () { diff --git a/test/fixtures/make-test-storage.js b/test/fixtures/make-test-storage.js index 0179ca34a..1401a2c1e 100644 --- a/test/fixtures/make-test-storage.js +++ b/test/fixtures/make-test-storage.js @@ -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; }; diff --git a/test/fixtures/missing_sound.sb3 b/test/fixtures/missing_sound.sb3 new file mode 100644 index 000000000..14dea7583 Binary files /dev/null and b/test/fixtures/missing_sound.sb3 differ diff --git a/test/fixtures/missing_svg.sb3 b/test/fixtures/missing_svg.sb3 new file mode 100644 index 000000000..e0956c36f Binary files /dev/null and b/test/fixtures/missing_svg.sb3 differ diff --git a/test/fixtures/missing_svg.sprite3 b/test/fixtures/missing_svg.sprite3 new file mode 100644 index 000000000..5cc098689 Binary files /dev/null and b/test/fixtures/missing_svg.sprite3 differ diff --git a/test/fixtures/readProjectFile.js b/test/fixtures/readProjectFile.js index 8340ba228..36f42797c 100644 --- a/test/fixtures/readProjectFile.js +++ b/test/fixtures/readProjectFile.js @@ -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(); } }; diff --git a/test/integration/sb3_corrupted_sound.js b/test/integration/sb3_corrupted_sound.js new file mode 100644 index 000000000..04f69453b --- /dev/null +++ b/test/integration/sb3_corrupted_sound.js @@ -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); +}); diff --git a/test/integration/sb3_corrupted_svg.js b/test/integration/sb3_corrupted_svg.js new file mode 100644 index 000000000..281c1c604 --- /dev/null +++ b/test/integration/sb3_corrupted_svg.js @@ -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(' { + 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); +}); diff --git a/test/integration/sb3_missing_sound.js b/test/integration/sb3_missing_sound.js new file mode 100644 index 000000000..16927bd10 --- /dev/null +++ b/test/integration/sb3_missing_sound.js @@ -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); +}); diff --git a/test/integration/sb3_missing_svg.js b/test/integration/sb3_missing_svg.js new file mode 100644 index 000000000..523f3459e --- /dev/null +++ b/test/integration/sb3_missing_svg.js @@ -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); +}); diff --git a/test/integration/sprite3_corrupted_svg.js b/test/integration/sprite3_corrupted_svg.js new file mode 100644 index 000000000..9bbe96bc9 --- /dev/null +++ b/test/integration/sprite3_corrupted_svg.js @@ -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(' 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); +}); diff --git a/test/integration/sprite3_missing_svg.js b/test/integration/sprite3_missing_svg.js new file mode 100644 index 000000000..6bd0985f8 --- /dev/null +++ b/test/integration/sprite3_missing_svg.js @@ -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); +});