diff --git a/src/import/load-costume.js b/src/import/load-costume.js index 6d0a2d259..4852399bd 100644 --- a/src/import/load-costume.js +++ b/src/import/load-costume.js @@ -99,7 +99,7 @@ const canvasPool = (function () { * assetMatchesBase is true if the asset matches the base layer; false if it required adjustment */ const fetchBitmapCanvas_ = function (costume, runtime, rotationCenter) { - if (!costume || !costume.asset) { + if (!costume || !costume.asset) { // TODO: We can probably remove this check... return Promise.reject('Costume load failed. Assets were missing.'); } if (!runtime.v2BitmapAdapter) { @@ -176,7 +176,7 @@ const fetchBitmapCanvas_ = function (costume, runtime, rotationCenter) { assetMatchesBase: scale === 1 && !textImageElement }; }) - .catch(() => { + .finally(() => { // Clean up the text layer properties if it fails to load delete costume.textLayerMD5; delete costume.textLayerAsset; @@ -325,7 +325,11 @@ const loadCostumeFromAsset = function (costume, runtime, optVersion) { }); } - return loadBitmap_(costume, runtime, rotationCenter, optVersion); + return loadBitmap_(costume, runtime, rotationCenter, optVersion) + .catch(error => { + log.warn(`Error loading bitmap image: ${error}`); + return handleCostumeLoadError(costume, runtime); + }); }; @@ -377,18 +381,25 @@ const loadCostume = function (md5ext, costume, runtime, optVersion) { textLayerPromise = Promise.resolve(null); } - return Promise.all([costumePromise, textLayerPromise]).then(assetArray => { - if (assetArray[0]) { - costume.asset = assetArray[0]; - } else { - return handleCostumeLoadError(costume, runtime); - } + 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]; - } - return loadCostumeFromAsset(costume, runtime, optVersion); - }); + if (assetArray[1]) { + costume.textLayerAsset = assetArray[1]; + } + return loadCostumeFromAsset(costume, runtime, optVersion); + }) + .catch(error => { + // Handle case where storage.load rejects with errors + // instead of resolving null + log.warn('Error loading costume: ', error); + return handleCostumeLoadError(costume, runtime); + }); }; module.exports = { diff --git a/src/virtual-machine.js b/src/virtual-machine.js index a75ff0802..0d2cab346 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -877,6 +877,7 @@ class VirtualMachine extends EventEmitter { updateBitmap (costumeIndex, bitmap, rotationCenterX, rotationCenterY, bitmapResolution) { const costume = this.editingTarget.getCostumes()[costumeIndex]; if (!(costume && this.runtime && this.runtime.renderer)) return; + if (costume && costume.broken) delete costume.broken; costume.rotationCenterX = rotationCenterX; costume.rotationCenterY = rotationCenterY; diff --git a/test/fixtures/corrupt_png.sb2 b/test/fixtures/corrupt_png.sb2 new file mode 100644 index 000000000..9e19272bd Binary files /dev/null and b/test/fixtures/corrupt_png.sb2 differ diff --git a/test/fixtures/corrupt_png.sprite2 b/test/fixtures/corrupt_png.sprite2 new file mode 100644 index 000000000..b292e438a Binary files /dev/null and b/test/fixtures/corrupt_png.sprite2 differ diff --git a/test/fixtures/missing_png.sb2 b/test/fixtures/missing_png.sb2 new file mode 100644 index 000000000..1539fed21 Binary files /dev/null and b/test/fixtures/missing_png.sb2 differ diff --git a/test/fixtures/missing_png.sprite2 b/test/fixtures/missing_png.sprite2 new file mode 100644 index 000000000..514119934 Binary files /dev/null and b/test/fixtures/missing_png.sprite2 differ diff --git a/test/integration/sb2_corrupted_png.js b/test/integration/sb2_corrupted_png.js new file mode 100644 index 000000000..78584ec2c --- /dev/null +++ b/test/integration/sb2_corrupted_png.js @@ -0,0 +1,126 @@ +/** + * This test mocks render breaking on loading a corrupted bitmap costume. + * The VM should handle this safely by displaying a Gray Question Mark, + * but keeping track of the original costume data and serializing the + * original costume data back out. The saved project.json should not + * reflect that the costume is broken and should therefore re-attempt + * to load the costume if the saved project is re-loaded. + */ +const path = require('path'); +const tap = require('tap'); +const md5 = require('js-md5'); +const makeTestStorage = require('../fixtures/make-test-storage'); +const FakeRenderer = require('../fixtures/fake-renderer'); +const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter'); +const {extractAsset, readFileToBuffer} = require('../fixtures/readProjectFile'); +const VirtualMachine = require('../../src/index'); +const {serializeCostumes} = require('../../src/serialization/serialize-assets'); + +const projectUri = path.resolve(__dirname, '../fixtures/corrupt_png.sb2'); +const project = readFileToBuffer(projectUri); +const costumeFileName = '1.png'; +const originalCostume = extractAsset(projectUri, costumeFileName); +// We need to get the actual md5 because we hand modified the png to corrupt it +// after we downloaded the project from Scratch +// Loading the project back into the VM will correct the assetId and md5 +const brokenCostumeMd5 = md5(originalCostume); + +global.Image = function () { + const image = { + width: 1, + height: 1 + }; + setTimeout(() => { + const base64Image = image.src.split(',')[1]; + const decodedText = Buffer.from(base64Image, 'base64').toString(); + if (decodedText.includes('Here is some')) { + image.onerror(); + } else { + image.onload(); + } + }, 1000); + return image; +}; + +global.document = { + createElement: () => ({ + // Create mock canvas + getContext: () => ({ + drawImage: () => ({}) + }) + }) +}; + +let vm; +let defaultBitmapAssetId; + +tap.beforeEach(() => { + const storage = makeTestStorage(); + + vm = new VirtualMachine(); + vm.attachStorage(storage); + defaultBitmapAssetId = vm.runtime.storage.defaultAssetId.ImageBitmap; + + vm.attachRenderer(new FakeRenderer()); + vm.attachV2BitmapAdapter(new FakeBitmapAdapter()); + + return vm.loadProject(project); +}); + +const test = tap.test; + +test('load sb2 project with corrupted bitmap costume file', t => { + t.equal(vm.runtime.targets.length, 2); + + const stage = vm.runtime.targets[0]; + t.ok(stage.isStage); + + const greenGuySprite = vm.runtime.targets[1]; + t.equal(greenGuySprite.getName(), 'GreenGuy'); + t.equal(greenGuySprite.getCostumes().length, 1); + + const corruptedCostume = greenGuySprite.getCostumes()[0]; + t.equal(corruptedCostume.name, 'GreenGuy'); + t.equal(corruptedCostume.assetId, defaultBitmapAssetId); + t.equal(corruptedCostume.dataFormat, 'png'); + // Runtime should have info about broken asset + t.ok(corruptedCostume.broken); + t.equal(corruptedCostume.broken.assetId, brokenCostumeMd5); + // Verify that we saved the original asset data + t.equal(md5(corruptedCostume.broken.asset.data), brokenCostumeMd5); + + t.end(); +}); + +test('load and then save project with corrupted bitmap costume file', t => { + const resavedProject = JSON.parse(vm.toJSON()); + + t.equal(resavedProject.targets.length, 2); + + const stage = resavedProject.targets[0]; + t.ok(stage.isStage); + + const greenGuySprite = resavedProject.targets[1]; + t.equal(greenGuySprite.name, 'GreenGuy'); + t.equal(greenGuySprite.costumes.length, 1); + + const corruptedCostume = greenGuySprite.costumes[0]; + t.equal(corruptedCostume.name, 'GreenGuy'); + // Resaved project costume should have the metadata that corresponds to the original broken costume + t.equal(corruptedCostume.assetId, brokenCostumeMd5); + t.equal(corruptedCostume.dataFormat, 'png'); + // Test that we didn't save any data about the costume being broken + t.notOk(corruptedCostume.broken); + + t.end(); +}); + +test('serializeCostume saves orignal broken costume', t => { + const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[1].id); + t.equal(costumeDescs.length, 1); + const costume = costumeDescs[0]; + t.equal(costume.fileName, `${brokenCostumeMd5}.png`); + t.equal(md5(costume.fileContent), brokenCostumeMd5); + t.end(); + process.nextTick(process.exit); +}); diff --git a/test/integration/sb2_missing_png.js b/test/integration/sb2_missing_png.js new file mode 100644 index 000000000..0d633f225 --- /dev/null +++ b/test/integration/sb2_missing_png.js @@ -0,0 +1,111 @@ +/** + * This test ensures that the VM gracefully handles an sb2 project with + * a missing bitmap costume. The VM should handle this safely by displaying + * a Gray Question Mark, but keeping track of the original costume data + * and serializing the original costume data back out. The saved project.json + * should not reflect that the costume is broken and should therefore re-attempt + * to load the costume if the saved project is re-loaded. + */ +const path = require('path'); +const tap = require('tap'); +const makeTestStorage = require('../fixtures/make-test-storage'); +const FakeRenderer = require('../fixtures/fake-renderer'); +const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter'); +const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer; +const VirtualMachine = require('../../src/index'); +const {serializeCostumes} = require('../../src/serialization/serialize-assets'); + +const projectUri = path.resolve(__dirname, '../fixtures/missing_png.sb2'); +const project = readFileToBuffer(projectUri); + + +const missingCostumeAssetId = 'aadce129bfe4e57f0dd81478f3ed82aa'; + +global.Image = function () { + const image = { + width: 1, + height: 1 + }; + setTimeout(() => image.onload(), 1000); + return image; +}; + +global.document = { + createElement: () => ({ + // Create mock canvas + getContext: () => ({ + drawImage: () => ({}) + }) + }) +}; + +let vm; + +tap.beforeEach(() => { + const storage = makeTestStorage(); + + vm = new VirtualMachine(); + vm.attachStorage(storage); + vm.attachRenderer(new FakeRenderer()); + vm.attachV2BitmapAdapter(new FakeBitmapAdapter()); + + return vm.loadProject(project); +}); + +const test = tap.test; + +test('loading sb2 project with missing bitmap costume file', t => { + t.equal(vm.runtime.targets.length, 2); + + const stage = vm.runtime.targets[0]; + t.ok(stage.isStage); + + const greenGuySprite = vm.runtime.targets[1]; + t.equal(greenGuySprite.getName(), 'GreenGuy'); + t.equal(greenGuySprite.getCostumes().length, 1); + + const missingCostume = greenGuySprite.getCostumes()[0]; + t.equal(missingCostume.name, 'GreenGuy'); + // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data + const defaultVectorAssetId = vm.runtime.storage.defaultAssetId.ImageBitmap; + t.equal(missingCostume.assetId, defaultVectorAssetId); + t.equal(missingCostume.dataFormat, 'png'); + // Runtime should have info about broken asset + t.ok(missingCostume.broken); + t.equal(missingCostume.broken.assetId, missingCostumeAssetId); + + t.end(); +}); + +test('load and then save sb2 project with missing costume file', t => { + const resavedProject = JSON.parse(vm.toJSON()); + + t.equal(resavedProject.targets.length, 2); + + const stage = resavedProject.targets[0]; + t.ok(stage.isStage); + + const greenGuySprite = resavedProject.targets[1]; + t.equal(greenGuySprite.name, 'GreenGuy'); + t.equal(greenGuySprite.costumes.length, 1); + + const missingCostume = greenGuySprite.costumes[0]; + t.equal(missingCostume.name, 'GreenGuy'); + // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data + t.equal(missingCostume.assetId, missingCostumeAssetId); + t.equal(missingCostume.dataFormat, 'png'); + // Test that we didn't save any data about the costume being broken + t.notOk(missingCostume.broken); + + t.end(); +}); + +test('serializeCostume does not save data for missing costume', t => { + const costumeDescs = serializeCostumes(vm.runtime); + + t.equal(costumeDescs.length, 1); // Should only have one costume, the backdrop + t.not(costumeDescs[0].fileName, `${missingCostumeAssetId}.png`); + + t.end(); + process.nextTick(process.exit); +}); diff --git a/test/integration/sb2_missing_svg.js b/test/integration/sb2_missing_svg.js index 46bbb676b..f8a9da30d 100644 --- a/test/integration/sb2_missing_svg.js +++ b/test/integration/sb2_missing_svg.js @@ -13,6 +13,7 @@ const FakeRenderer = require('../fixtures/fake-renderer'); const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter'); const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer; const VirtualMachine = require('../../src/index'); +const {serializeCostumes} = require('../../src/serialization/serialize-assets'); const projectUri = path.resolve(__dirname, '../fixtures/missing_svg.sb2'); const project = readFileToBuffer(projectUri); @@ -95,6 +96,15 @@ test('load and then save sb2 project with missing costume file', t => { // 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/sprite2_corrupted_png.js b/test/integration/sprite2_corrupted_png.js new file mode 100644 index 000000000..d579e5310 --- /dev/null +++ b/test/integration/sprite2_corrupted_png.js @@ -0,0 +1,125 @@ +/** + * This test mocks render breaking on loading a sprite2 with a + * corrupted bitmap costume. + * The VM should handle this safely by displaying a Gray Question Mark, + * but keeping track of the original costume data and serializing the + * original costume data back out. The saved project.json should not + * reflect that the costume is broken and should therefore re-attempt + * to load the costume if the saved project is re-loaded. + */ +const path = require('path'); +const tap = require('tap'); +const md5 = require('js-md5'); +const makeTestStorage = require('../fixtures/make-test-storage'); +const FakeRenderer = require('../fixtures/fake-renderer'); +const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter'); +const {extractAsset, readFileToBuffer} = require('../fixtures/readProjectFile'); +const VirtualMachine = require('../../src/index'); +const {serializeCostumes} = require('../../src/serialization/serialize-assets'); + +const projectUri = path.resolve(__dirname, '../fixtures/default.sb3'); +const project = readFileToBuffer(projectUri); + +const spriteUri = path.resolve(__dirname, '../fixtures/corrupt_png.sprite2'); +const sprite = readFileToBuffer(spriteUri); + +const costumeFileName = '0.png'; +const originalCostume = extractAsset(spriteUri, costumeFileName); +// We need to get the actual md5 because we hand modified the png to corrupt it +// after we downloaded the project from Scratch +// Loading the project back into the VM will correct the assetId and md5 +const brokenCostumeMd5 = md5(originalCostume); + +global.Image = function () { + const image = { + width: 1, + height: 1 + }; + setTimeout(() => { + const base64Image = image.src.split(',')[1]; + const decodedText = Buffer.from(base64Image, 'base64').toString(); + if (decodedText.includes('Here is some')) { + image.onerror(); + } else { + image.onload(); + } + }, 1000); + return image; +}; + +global.document = { + createElement: () => ({ + // Create mock canvas + getContext: () => ({ + drawImage: () => ({}) + }) + }) +}; + +let vm; +let defaultBitmapAssetId; + +tap.beforeEach(() => { + const storage = makeTestStorage(); + + vm = new VirtualMachine(); + vm.attachStorage(storage); + defaultBitmapAssetId = vm.runtime.storage.defaultAssetId.ImageBitmap; + + vm.attachRenderer(new FakeRenderer()); + vm.attachV2BitmapAdapter(new FakeBitmapAdapter()); + + return vm.loadProject(project).then(() => vm.addSprite(sprite)); +}); + +const test = tap.test; + +test('load sprite2 with corrupted bitmap costume file', t => { + t.equal(vm.runtime.targets.length, 3); + + const stage = vm.runtime.targets[0]; + t.ok(stage.isStage); + + const greenGuySprite = vm.runtime.targets[2]; + t.equal(greenGuySprite.getName(), 'GreenGuy'); + t.equal(greenGuySprite.getCostumes().length, 1); + + const corruptedCostume = greenGuySprite.getCostumes()[0]; + t.equal(corruptedCostume.name, 'GreenGuy'); + t.equal(corruptedCostume.assetId, defaultBitmapAssetId); + t.equal(corruptedCostume.dataFormat, 'png'); + // Runtime should have info about broken asset + t.ok(corruptedCostume.broken); + t.equal(corruptedCostume.broken.assetId, brokenCostumeMd5); + // Verify that we saved the original asset data + t.equal(md5(corruptedCostume.broken.asset.data), brokenCostumeMd5); + + t.end(); +}); + +test('load and then save sprite with corrupted costume file', t => { + const resavedSprite = JSON.parse(vm.toJSON(vm.runtime.targets[2].id)); + + t.equal(resavedSprite.name, 'GreenGuy'); + t.equal(resavedSprite.costumes.length, 1); + + const corruptedCostume = resavedSprite.costumes[0]; + t.equal(corruptedCostume.name, 'GreenGuy'); + // Resaved project costume should have the metadata that corresponds to the original broken costume + t.equal(corruptedCostume.assetId, brokenCostumeMd5); + t.equal(corruptedCostume.dataFormat, 'png'); + // Test that we didn't save any data about the costume being broken + t.notOk(corruptedCostume.broken); + + t.end(); +}); + +test('serializeCostume saves orignal broken costume', t => { + const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[2].id); + t.equal(costumeDescs.length, 1); + const costume = costumeDescs[0]; + t.equal(costume.fileName, `${brokenCostumeMd5}.png`); + t.equal(md5(costume.fileContent), brokenCostumeMd5); + t.end(); + process.nextTick(process.exit); +}); diff --git a/test/integration/sprite2_missing_png.js b/test/integration/sprite2_missing_png.js new file mode 100644 index 000000000..dad0d5314 --- /dev/null +++ b/test/integration/sprite2_missing_png.js @@ -0,0 +1,107 @@ +/** + * This test ensures that the VM gracefully handles a sprite2 file with + * a missing bitmap costume. The VM should handle this safely by displaying + * a Gray Question Mark, but keeping track of the original costume data + * and serializing the original costume data back out. The saved project.json + * should not reflect that the costume is broken and should therefore re-attempt + * to load the costume if the saved project is re-loaded. + */ +const path = require('path'); +const tap = require('tap'); +const makeTestStorage = require('../fixtures/make-test-storage'); +const FakeRenderer = require('../fixtures/fake-renderer'); +const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter'); +const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer; +const VirtualMachine = require('../../src/index'); +const {serializeCostumes} = require('../../src/serialization/serialize-assets'); + +// The particular project that we're loading doesn't matter for this test +const projectUri = path.resolve(__dirname, '../fixtures/default.sb3'); +const project = readFileToBuffer(projectUri); + +const spriteUri = path.resolve(__dirname, '../fixtures/missing_png.sprite2'); +const sprite = readFileToBuffer(spriteUri); + +const missingCostumeAssetId = 'aadce129bfe4e57f0dd81478f3ed82aa'; + +global.Image = function () { + const image = { + width: 1, + height: 1 + }; + setTimeout(() => image.onload(), 1000); + return image; +}; + +global.document = { + createElement: () => ({ + // Create mock canvas + getContext: () => ({ + drawImage: () => ({}) + }) + }) +}; + +let vm; + +tap.beforeEach(() => { + const storage = makeTestStorage(); + + vm = new VirtualMachine(); + vm.attachStorage(storage); + vm.attachRenderer(new FakeRenderer()); + vm.attachV2BitmapAdapter(new FakeBitmapAdapter()); + + return vm.loadProject(project).then(() => vm.addSprite(sprite)); +}); + +const test = tap.test; + +test('loading sprite2 with missing bitmap costume file', t => { + t.equal(vm.runtime.targets.length, 3); + + const stage = vm.runtime.targets[0]; + t.ok(stage.isStage); + + const greenGuySprite = vm.runtime.targets[2]; + t.equal(greenGuySprite.getName(), 'GreenGuy'); + t.equal(greenGuySprite.getCostumes().length, 1); + + const missingCostume = greenGuySprite.getCostumes()[0]; + t.equal(missingCostume.name, 'GreenGuy'); + // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data + const defaultBitmapAssetId = vm.runtime.storage.defaultAssetId.ImageBitmap; + t.equal(missingCostume.assetId, defaultBitmapAssetId); + t.equal(missingCostume.dataFormat, 'png'); + // Runtime should have info about broken asset + t.ok(missingCostume.broken); + t.equal(missingCostume.broken.assetId, missingCostumeAssetId); + + t.end(); +}); + +test('load and then save sprite2 with missing bitmap costume file', t => { + const resavedSprite = JSON.parse(vm.toJSON(vm.runtime.targets[2].id)); + + t.equal(resavedSprite.name, 'GreenGuy'); + t.equal(resavedSprite.costumes.length, 1); + + const missingCostume = resavedSprite.costumes[0]; + t.equal(missingCostume.name, 'GreenGuy'); + // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data + t.equal(missingCostume.assetId, missingCostumeAssetId); + t.equal(missingCostume.dataFormat, 'png'); + // Test that we didn't save any data about the costume being broken + t.notOk(missingCostume.broken); + + t.end(); +}); + +test('serializeCostume does not save data for missing costume', t => { + const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[2].id); + + t.equal(costumeDescs.length, 0); + + t.end(); + process.nextTick(process.exit); +});