Hook up error handling to bitmap loading flow and add tests for loading missing and corrupt bitmpas from sb2 and sprite2 files

This commit is contained in:
Karishma Chadha 2022-05-23 17:00:19 -04:00
parent 0d3a72ec9b
commit 3e193565ee
11 changed files with 505 additions and 14 deletions

View file

@ -99,7 +99,7 @@ const canvasPool = (function () {
* assetMatchesBase is true if the asset matches the base layer; false if it required adjustment * assetMatchesBase is true if the asset matches the base layer; false if it required adjustment
*/ */
const fetchBitmapCanvas_ = function (costume, runtime, rotationCenter) { 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.'); return Promise.reject('Costume load failed. Assets were missing.');
} }
if (!runtime.v2BitmapAdapter) { if (!runtime.v2BitmapAdapter) {
@ -176,7 +176,7 @@ const fetchBitmapCanvas_ = function (costume, runtime, rotationCenter) {
assetMatchesBase: scale === 1 && !textImageElement assetMatchesBase: scale === 1 && !textImageElement
}; };
}) })
.catch(() => { .finally(() => {
// Clean up the text layer properties if it fails to load // Clean up the text layer properties if it fails to load
delete costume.textLayerMD5; delete costume.textLayerMD5;
delete costume.textLayerAsset; 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); textLayerPromise = Promise.resolve(null);
} }
return Promise.all([costumePromise, textLayerPromise]).then(assetArray => { return Promise.all([costumePromise, textLayerPromise])
if (assetArray[0]) { .then(assetArray => {
costume.asset = assetArray[0]; if (assetArray[0]) {
} else { costume.asset = assetArray[0];
return handleCostumeLoadError(costume, runtime); } else {
} return handleCostumeLoadError(costume, runtime);
}
if (assetArray[1]) { if (assetArray[1]) {
costume.textLayerAsset = assetArray[1]; costume.textLayerAsset = assetArray[1];
} }
return loadCostumeFromAsset(costume, runtime, optVersion); 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 = { module.exports = {

View file

@ -877,6 +877,7 @@ class VirtualMachine extends EventEmitter {
updateBitmap (costumeIndex, bitmap, rotationCenterX, rotationCenterY, bitmapResolution) { updateBitmap (costumeIndex, bitmap, rotationCenterX, rotationCenterY, bitmapResolution) {
const costume = this.editingTarget.getCostumes()[costumeIndex]; const costume = this.editingTarget.getCostumes()[costumeIndex];
if (!(costume && this.runtime && this.runtime.renderer)) return; if (!(costume && this.runtime && this.runtime.renderer)) return;
if (costume && costume.broken) delete costume.broken;
costume.rotationCenterX = rotationCenterX; costume.rotationCenterX = rotationCenterX;
costume.rotationCenterY = rotationCenterY; costume.rotationCenterY = rotationCenterY;

BIN
test/fixtures/corrupt_png.sb2 vendored Normal file

Binary file not shown.

BIN
test/fixtures/corrupt_png.sprite2 vendored Normal file

Binary file not shown.

BIN
test/fixtures/missing_png.sb2 vendored Normal file

Binary file not shown.

BIN
test/fixtures/missing_png.sprite2 vendored Normal file

Binary file not shown.

View file

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

View file

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

View file

@ -13,6 +13,7 @@ const FakeRenderer = require('../fixtures/fake-renderer');
const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter'); const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer; const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
const VirtualMachine = require('../../src/index'); const VirtualMachine = require('../../src/index');
const {serializeCostumes} = require('../../src/serialization/serialize-assets');
const projectUri = path.resolve(__dirname, '../fixtures/missing_svg.sb2'); const projectUri = path.resolve(__dirname, '../fixtures/missing_svg.sb2');
const project = readFileToBuffer(projectUri); 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 // Test that we didn't save any data about the costume being broken
t.notOk(missingCostume.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(); t.end();
process.nextTick(process.exit); process.nextTick(process.exit);
}); });

View file

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

View file

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