From deadce3bc6c08a8581b4db8ed4a94ec56fbd4ed5 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Tue, 15 Mar 2022 13:07:50 -0400 Subject: [PATCH 01/15] Let GQM replace broken costume data but explicitly save and serialize broken costume data before finishing loading the costume. --- src/import/load-costume.js | 16 +++++++++++++++- src/serialization/sb3.js | 9 +++++---- src/serialization/serialize-assets.js | 12 ++++++++++-- src/virtual-machine.js | 1 + 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/import/load-costume.js b/src/import/load-costume.js index d76e8b912..4a523449e 100644 --- a/src/import/load-costume.js +++ b/src/import/load-costume.js @@ -280,11 +280,25 @@ const loadCostumeFromAsset = function (costume, runtime, optVersion) { return loadVector_(costume, runtime, rotationCenter, optVersion) .catch(error => { log.warn(`Error loading vector image: ${error.name}: ${error.message}`); + + // Keep track of the old assetId until we're done loading the default costume + const oldAssetId = costume.assetId; + const oldRotationX = costume.rotationCenterX; + const oldRotationY = costume.rotationCenterY; + // 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); + return loadVector_(costume, runtime).then(loadedCostume => { + loadedCostume.broken = {}; + loadedCostume.broken.assetId = oldAssetId; + loadedCostume.broken.md5 = `${oldAssetId}.${AssetType.ImageVector.runtimeFormat}`; + loadedCostume.broken.asset = runtime.storage.get(oldAssetId); + loadedCostume.broken.rotationCenterX = oldRotationX; + loadedCostume.broken.rotationCenterY = oldRotationY; + return loadedCostume; + }); }); } return loadBitmap_(costume, runtime, rotationCenter, optVersion); diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 6989229c9..da31ab807 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -345,7 +345,7 @@ const serializeBlocks = function (blocks) { */ const serializeCostume = function (costume) { const obj = Object.create(null); - obj.assetId = costume.assetId; + obj.assetId = (costume.broken && costume.broken.assetId) || costume.assetId; obj.name = costume.name; obj.bitmapResolution = costume.bitmapResolution; // serialize this property with the name 'md5ext' because that's @@ -353,10 +353,11 @@ const serializeCostume = function (costume) { // 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.md5ext = (costume.broken && costume.broken.md5) || costume.md5; obj.dataFormat = costume.dataFormat.toLowerCase(); - obj.rotationCenterX = costume.rotationCenterX; - obj.rotationCenterY = costume.rotationCenterY; + // TODO: WATCH OUT FOR ZEROs HERE + obj.rotationCenterX = (costume.broken && costume.broken.rotationCenterX) || costume.rotationCenterX; + obj.rotationCenterY = (costume.broken && costume.broken.rotationCenterY) || costume.rotationCenterY; return obj; }; diff --git a/src/serialization/serialize-assets.js b/src/serialization/serialize-assets.js index c1d063949..041f7d0d3 100644 --- a/src/serialization/serialize-assets.js +++ b/src/serialization/serialize-assets.js @@ -16,10 +16,18 @@ 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; + let asset = currAsset.asset; + if (currAsset.broken) { + if (currAsset.broken.asset) { + asset = currAsset.broken.asset; + } else { + continue; + } + } assetDescs.push({ fileName: `${asset.assetId}.${asset.dataFormat}`, - fileContent: asset.data}); + fileContent: asset.data + }); } } return assetDescs; diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 131c421c2..9abbf6791 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -932,6 +932,7 @@ class VirtualMachine extends EventEmitter { */ updateSvg (costumeIndex, svg, rotationCenterX, rotationCenterY) { const costume = this.editingTarget.getCostumes()[costumeIndex]; + if (costume && costume.broken) costume.broken = null; if (costume && this.runtime && this.runtime.renderer) { costume.rotationCenterX = rotationCenterX; costume.rotationCenterY = rotationCenterY; From a8618b378fd052649d79efafd9259fb70a44138c Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Mon, 2 May 2022 16:38:56 -0400 Subject: [PATCH 02/15] Update load costume fixes to handle storage resolving null and put all error handling in one place. Could be used in the future to handle bitmap loading issues as well --- src/import/load-costume.js | 62 ++++++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/src/import/load-costume.js b/src/import/load-costume.js index 4a523449e..b3b73c591 100644 --- a/src/import/load-costume.js +++ b/src/import/load-costume.js @@ -247,6 +247,37 @@ 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 assetId 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; + + // 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}.${costume.dataFormat}`; + + const AssetType = runtime.storage.AssetType; + const defaultCostumePromise = (costume.dataFormat === AssetType.ImageVector.runtimeFormat) ? + loadVector_(costume, runtime) : loadBitmap_(costume, runtime); + + return defaultCostumePromise.then(loadedCostume => { + loadedCostume.broken = {}; + loadedCostume.broken.assetId = oldAssetId; + loadedCostume.broken.md5 = `${oldAssetId}.${costume.dataFormat}`; + // Should be null if we got here because the costume was missing + loadedCostume.broken.asset = runtime.storage.get(oldAssetId); + loadedCostume.broken.rotationCenterX = oldRotationX; + loadedCostume.broken.rotationCenterY = oldRotationY; + return loadedCostume; + }); +}; + /** * Initialize a costume from an asset asynchronously. * Do not call this unless there is a renderer attached. @@ -280,30 +311,14 @@ const loadCostumeFromAsset = function (costume, runtime, optVersion) { return loadVector_(costume, runtime, rotationCenter, optVersion) .catch(error => { log.warn(`Error loading vector image: ${error.name}: ${error.message}`); + return handleCostumeLoadError(costume, runtime); - // Keep track of the old assetId until we're done loading the default costume - const oldAssetId = costume.assetId; - const oldRotationX = costume.rotationCenterX; - const oldRotationY = costume.rotationCenterY; - - // 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).then(loadedCostume => { - loadedCostume.broken = {}; - loadedCostume.broken.assetId = oldAssetId; - loadedCostume.broken.md5 = `${oldAssetId}.${AssetType.ImageVector.runtimeFormat}`; - loadedCostume.broken.asset = runtime.storage.get(oldAssetId); - loadedCostume.broken.rotationCenterX = oldRotationX; - loadedCostume.broken.rotationCenterY = oldRotationY; - return loadedCostume; - }); }); } return loadBitmap_(costume, runtime, rotationCenter, optVersion); }; + /** * Load a costume's asset into memory asynchronously. * Do not call this unless there is a renderer attached. @@ -344,10 +359,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) { @@ -357,7 +368,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]; } From 70a78cf7db250b7bdce8b30f0fab2a5808f01ac6 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Fri, 13 May 2022 18:34:34 -0400 Subject: [PATCH 03/15] Add tests for load costume error handling. Fix issue where asset data was not getting saved out properly. --- package.json | 1 + src/import/load-costume.js | 12 ++- src/virtual-machine.js | 11 +-- test/fixtures/corrupt_svg.sb3 | Bin 0 -> 4856 bytes test/fixtures/corrupt_svg.sprite3 | Bin 0 -> 2713 bytes test/fixtures/default.sb3 | Bin 0 -> 41932 bytes test/fixtures/fake-renderer.js | 13 +++ test/fixtures/make-test-storage.js | 4 +- test/fixtures/missing_svg.sb3 | Bin 0 -> 3446 bytes test/fixtures/missing_svg.sprite3 | Bin 0 -> 1949 bytes test/fixtures/readProjectFile.js | 5 + test/integration/sb3_corrupted_svg.js | 107 ++++++++++++++++++++++ test/integration/sb3_missing_svg.js | 87 ++++++++++++++++++ test/integration/sprite3_corrupted_svg.js | 106 +++++++++++++++++++++ test/integration/sprite3_missing_svg.js | 85 +++++++++++++++++ 15 files changed, 418 insertions(+), 13 deletions(-) create mode 100644 test/fixtures/corrupt_svg.sb3 create mode 100644 test/fixtures/corrupt_svg.sprite3 create mode 100644 test/fixtures/default.sb3 create mode 100644 test/fixtures/missing_svg.sb3 create mode 100644 test/fixtures/missing_svg.sprite3 create mode 100644 test/integration/sb3_corrupted_svg.js create mode 100644 test/integration/sb3_missing_svg.js create mode 100644 test/integration/sprite3_corrupted_svg.js create mode 100644 test/integration/sprite3_missing_svg.js diff --git a/package.json b/package.json index 136318cec..68d1ba16d 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", diff --git a/src/import/load-costume.js b/src/import/load-costume.js index b3b73c591..58f3cacf9 100644 --- a/src/import/load-costume.js +++ b/src/import/load-costume.js @@ -250,9 +250,9 @@ 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 assetId until we're done loading the default costume - // const oldAsset = costume.asset; // could be null +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; @@ -270,8 +270,10 @@ const handleCostumeLoadError = function (costume, runtime) { loadedCostume.broken = {}; loadedCostume.broken.assetId = oldAssetId; loadedCostume.broken.md5 = `${oldAssetId}.${costume.dataFormat}`; + // Should be null if we got here because the costume was missing - loadedCostume.broken.asset = runtime.storage.get(oldAssetId); + loadedCostume.broken.asset = oldAsset; + loadedCostume.broken.rotationCenterX = oldRotationX; loadedCostume.broken.rotationCenterY = oldRotationY; return loadedCostume; @@ -310,7 +312,7 @@ 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}`); + log.warn(`Error loading vector image: ${error}`); return handleCostumeLoadError(costume, runtime); }); diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 9abbf6791..3e67ec91e 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -427,11 +427,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 +446,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 diff --git a/test/fixtures/corrupt_svg.sb3 b/test/fixtures/corrupt_svg.sb3 new file mode 100644 index 0000000000000000000000000000000000000000..52fb93096f082385fc0aec5c32df6fff96e588b7 GIT binary patch literal 4856 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 literal 0 HcmV?d00001 diff --git a/test/fixtures/corrupt_svg.sprite3 b/test/fixtures/corrupt_svg.sprite3 new file mode 100644 index 0000000000000000000000000000000000000000..1d9dd0b3dd08da0ca2ebdfd1c435b295e44d179e GIT binary patch literal 2713 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 literal 0 HcmV?d00001 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_svg.sb3 b/test/fixtures/missing_svg.sb3 new file mode 100644 index 0000000000000000000000000000000000000000..e0956c36f06c05e56a9de97f38882654b9158ee7 GIT binary patch literal 3446 zcmb_e2{=@38$L6G83|L?8Zxqu#9(YCp(4iEP3em*-F|LVGW&UI$axz4%X=XvhuzR!DG;bC0D04Hky@U)w| z_}|Z301Xhlf`h&MJkUnIA?|3Cko{;ymmq&1cR!4(y&XRQkB?JunLk6q_y7nO8v+1x z>sigMivSSctvdj&EeozoA`uL?S=#PaRUs{z;=2q-!Aw77aCH`O^>B7^o+OwPCk2U# zR#DJn;xGjy3c^%E^Drl{NN$O3Mlt#3$5Jo`Z=%n|t&(Qs<>ZxS?zTFXejzjaWDZQ& zjr`Fr{KmL_QDmz;=_r4h=;0iC?@bl<8g!ZucNG5gcl z#3PYj5|%*EW-sk+Sh2Le3{%)bFu1&_wR3O6W34LfD$^Q7Qee-d{?;>e4@Fw@?l!Y zyWW=&vl~Zk7h@k7^2ntX#IJKjpD47s*qSXJ>u4#Cxtvd z9XF51*){957TU3t4|_Xw+e_Tl+mW(Vxmv^Pl|Q!?KO#NReyRN)K5)X>yXsVp@&P6}FBSDY0bB zY%o3-$JP^A!#oGdXKNJe8q~wZnfD zRV?ze1yL_UVr|AdHew#bDw{07)Z0u?1a@ z&nuY&sV0IA44Y|&e@8SaNwohg{7%}jLj!!3a)%BDHcx!u;aB21O1|gH!2fM*z0Tvf zvC9W8nr%dvBKLFU7mbaW#B;%YPI?HZhZ6P%(a+e*VN%;a>9szXAqR8{)l6It@46nC zAxut#!^@p=a!)5gyOc*&4QcI%8qM3S=uJYEeCKBaR~y$gGUGcorr?d%%BNpJoYCVu ztyI`D>bH0GsPaieLVZt})*t1ObCPLjt`4n-0su!Ci%SLzKGk1VGCCS$U2Pq0H=L`B z7DdBFTT4UT1?T2Qad)9;<21CiRsJN0ev=Xvl+hwmf?%N!KHHcY8!Lk8b{P_PQ1(&L zNKkeV@L0700M-)$016-hH!_vX1-{b-z+gAwLHrXUwvu`>*|NjvQqf=@dnT#RnVwwX zRgzZxl4jCmI5Y))E8k&aMvm~830Og9Y#)S;gz|YwmS!e zR_!nJ?AhTVX7<9jDm?8;v`<1{UP`m{r(tY_^sAoqUEy-Ln$QH8ndOES+G|2B}SoC zsXCoG)*xJ}t7y$6;!G_zz_^tA>#E-r3zT%!CJol|=&3~Eg&h=Z9Ca@WNsI+HUAlxh zUtNJ}zdU_*UHH~!>sD)@tr1cqGmjoe*GXNque;b)%T^MP(O?_K87Ql+6xzkg>#DfT zuBW!;JP(J4qYrB(Y<;M+N%l5R+6an~P>*O3d0_q82XRmiy$NAhrIR6J%cD95RA}XsbZO(Ql&!WFq^4>SJhAZpJ}|lH!aK4FT|2ClZsp0BKQ~N1xs^; ze<)!neAMH80aO`DT8orQl5`T?!bklw!fGAM7>RrRY+~kPCltYFhD=-4E1=E02D*PX zcRJ(!AiH};>T3#DCPHk*5rjP#`3vWppT~?Z(**gzq3PlTZUtGiJb_)^)YUZAaBf&l z4K<1zh2o;Fu8zgIW8Gaf-Q9F>WR>7hk8cF#Z10N@cpm{T5Q`QbIB=x<8k}B+yH#jv z*)CLR7H8MgP}<(#y(g1EQOoj2bwh|kg;NzqEpkRdTO8s=Vl@3OS3c^>qB0$%(=Qjq z5ZWH;n8aszdd2O;FsZNfR~9O@+4$ZzDQY>Bo%c27N|_I>rkF2knl8R~Y8aNaZk_-2 zkRgj)ZXOfuFI@V%!{;J-oz2DGe!%~u??eU?k`4mE0l^9nfeOQxwu9g+bJ1YP`CED` z|9@_0KF_>8Txhj8_HaHLqIZLrzr!s4m~o+V;aIr&D2kScEZ5o37G+8Q`wx`!EiQB( z978l8MbV}pFTb_8G^W2`j}|t|@iq%T;T&%>AC=L6gSnSZ`72K6$D9jQ$I;e&R7P(E sHA47mQJI9r@ zu;dt^b3slE1~jfj-g4^i6NjX}`k; z0)M7voe!K6{#eCbt8vFAZjQo;<9;a*j~wj35cKu_#@kaC#XZ<|e&)@M{0e8TC7oaR zGC)f1U`Mn613#;Mx9Tje)O>yLP2gZpl)}_I4#gI33auNxJ+?Ysdt^F&Rc-vEFkWVP z{khjN-)`s5wlyNWvTeqNMQMptoz~R++#}XgI;U*O3GX7??4ZkQ zXZX*lUAcRSqePT_v`FToxh57qlk#oWzF+1!@9(a^`u8jn*spHhuJ>WSKzd?JePPC$ zvVYlc7MwiI@$Ev&)aAnQ(=F9Z^3S(jSG;=Tdqq@?-PNN@*4}N`S};ZX%0UH-I%ZI8 z7BjP@!~oMKD9*6QrU6jK7GG>y7$;hqTbQSqBqy1r87G;W85<>;q@<*!CZ(C17@L{v zl_!?LqZt&%SfZKHfa(0-AWt_p4WMn|j$y88xg`n$z!b&^go4@(3`$`P3``6H3@M2v zi7Y@hR~eX@_p%r9z84LawNaj}@u=|=aXjt@UGy;ZyD z?v+@ZuUx>Dc+TaOtk!4#lUd9EPE_rEH^Xx4i@T3_=32QV6qIePT$FLcHC^26NzT@) zN%tDF+Y0CRolyMq$e=d%e&v&T^TL(B+hkTg_utVojkz;(qW`+DH)D7hYy#rn=Zj~C z`EjWfeu_MrzIfZ@ikUw)W!zfK*=Lp=u9>zdevjK%`4gX*Pkmi`ZjtbDZ;keaensP9k{)@2lT8Ca8 zin_@9%9DMH(sk1dHg8P5l%qJ^-$~ynKh1kO?OOh;jZBjyjkPKaR%zSFe&I;@y#L{j z>))=exi9ekETg7yjQk2^5Bb%?>zRW-7d+c_|MmmpmuJ4Iu-z8^B;zIDB6X6#hVjWK z)91Vo+a6ZENdK~sNk<@FNVPp4i@d*1T$!TX;-t}t item.entryName.match(assetFileName))[0]; + return assetEntry.getData(); } }; 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_svg.js b/test/integration/sb3_missing_svg.js new file mode 100644 index 000000000..9e1445f5a --- /dev/null +++ b/test/integration/sb3_missing_svg.js @@ -0,0 +1,87 @@ +/** + * 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 projectUri = path.resolve(__dirname, '../fixtures/missing_svg.sb3'); +const project = readFileToBuffer(projectUri); + +const missingCostumeAssetId = 'a267f8b97ee9cf8aa9832aa0b4cfd9eb'; + +let vm; + +tap.beforeEach(() => { + const storage = makeTestStorage(); + + // This line removes the webhelper from the list of available helpers. + // W/o the following line, this fails because storage doesn't handle the case + // where none of the tools have isGetSupported: true + // TODO: Remove this line when the related storage bug is resolved so that + // storage gracefully handles non-browser situations where assets are missing. + storage._helpers = [storage._helpers[0]]; + + 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(); + 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..70e3ab046 --- /dev/null +++ b/test/integration/sprite3_missing_svg.js @@ -0,0 +1,85 @@ +/** + * 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'); + +// 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(); + + // This line removes the webhelper from the list of available helpers. + // W/o the following line, this fails because storage doesn't handle the case + // where none of the tools have isGetSupported: true + // TODO: Remove this line when the related storage bug is resolved so that + // storage gracefully handles non-browser situations where assets are missing. + storage._helpers = [storage._helpers[0]]; + + 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(); + process.nextTick(process.exit); +}); From 172409c1e3c6698ffca1221a7c32de4dfb2de4a7 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Fri, 13 May 2022 19:14:25 -0400 Subject: [PATCH 04/15] Handle rotationCenter values that might be 0. --- src/serialization/sb3.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index da31ab807..f39565c0e 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -355,9 +355,13 @@ const serializeCostume = function (costume) { // pervasive obj.md5ext = (costume.broken && costume.broken.md5) || costume.md5; obj.dataFormat = costume.dataFormat.toLowerCase(); - // TODO: WATCH OUT FOR ZEROs HERE - obj.rotationCenterX = (costume.broken && costume.broken.rotationCenterX) || costume.rotationCenterX; - obj.rotationCenterY = (costume.broken && costume.broken.rotationCenterY) || costume.rotationCenterY; + if (costume.broken) { + obj.rotationCenterX = costume.broken.rotationCenterX; + obj.rotationCenterY = costume.broken.rotationCenterY; + } else { + obj.rotationCenterX = costume.rotationCenterX; + obj.rotationCenterY = costume.rotationCenterY; + } return obj; }; From fd31a777e2c66e78f975f099c3086ba6aaa65533 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Mon, 16 May 2022 16:07:08 -0400 Subject: [PATCH 05/15] Code clean up from code review Co-authored-by: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> --- src/serialization/serialize-assets.js | 10 +++------- src/virtual-machine.js | 2 +- test/fixtures/readProjectFile.js | 2 +- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/serialization/serialize-assets.js b/src/serialization/serialize-assets.js index 041f7d0d3..a6c900ec9 100644 --- a/src/serialization/serialize-assets.js +++ b/src/serialization/serialize-assets.js @@ -16,13 +16,9 @@ const serializeAssets = function (runtime, assetType, optTargetId) { const currAssets = currTarget.sprite[assetType]; for (let j = 0; j < currAssets.length; j++) { const currAsset = currAssets[j]; - let asset = currAsset.asset; - if (currAsset.broken) { - if (currAsset.broken.asset) { - asset = currAsset.broken.asset; - } else { - continue; - } + const asset = currAsset.broken ? curAsset.broken.asset : currAsset.asset; + if (!asset) { + continue; } assetDescs.push({ fileName: `${asset.assetId}.${asset.dataFormat}`, diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 3e67ec91e..4d24aa9e1 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -931,7 +931,7 @@ class VirtualMachine extends EventEmitter { */ updateSvg (costumeIndex, svg, rotationCenterX, rotationCenterY) { const costume = this.editingTarget.getCostumes()[costumeIndex]; - if (costume && costume.broken) costume.broken = null; + 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/readProjectFile.js b/test/fixtures/readProjectFile.js index 254862cea..4cb041eb6 100644 --- a/test/fixtures/readProjectFile.js +++ b/test/fixtures/readProjectFile.js @@ -15,7 +15,7 @@ module.exports = { }, extractAsset: function (path, assetFileName) { const zip = new AdmZip(path); - const assetEntry = zip.getEntries().filter(item => item.entryName.match(assetFileName))[0]; + const assetEntry = zip.getEntries().find(item => item.entryName.match(assetFileName)); return assetEntry.getData(); } }; From 1dfaa02c97904807faa7cbac8d9ffb42712b4902 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Mon, 16 May 2022 16:23:41 -0400 Subject: [PATCH 06/15] More code cleanup from PR review --- src/serialization/sb3.js | 21 ++++++++++++++------- test/fixtures/readProjectFile.js | 2 +- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index f39565c0e..df8aad925 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -345,20 +345,27 @@ const serializeBlocks = function (blocks) { */ const serializeCostume = function (costume) { const obj = Object.create(null); - obj.assetId = (costume.broken && costume.broken.assetId) || costume.assetId; obj.name = costume.name; obj.bitmapResolution = costume.bitmapResolution; - // 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.broken && costume.broken.md5) || costume.md5; obj.dataFormat = costume.dataFormat.toLowerCase(); if (costume.broken) { + obj.assetId = costume.broken.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.broken.md5); + obj.rotationCenterX = costume.broken.rotationCenterX; obj.rotationCenterY = costume.broken.rotationCenterY; } else { + obj.assetId = costume.assetId; + + // See related comment above + obj.md5ext = costume.md5; + obj.rotationCenterX = costume.rotationCenterX; obj.rotationCenterY = costume.rotationCenterY; } diff --git a/test/fixtures/readProjectFile.js b/test/fixtures/readProjectFile.js index 4cb041eb6..e7ef5b2bc 100644 --- a/test/fixtures/readProjectFile.js +++ b/test/fixtures/readProjectFile.js @@ -7,7 +7,7 @@ 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/))[0]; if (projectEntry) { return JSON.parse(zip.readAsText(projectEntry.entryName, 'utf8')); } From 05fa6cad3eff865fe2887b5fd57e5cd44dd52760 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Mon, 16 May 2022 16:24:10 -0400 Subject: [PATCH 07/15] Update handleLoadCostumeError to properly handle bitmap costumes --- src/import/load-costume.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/import/load-costume.js b/src/import/load-costume.js index 58f3cacf9..0bf6ba653 100644 --- a/src/import/load-costume.js +++ b/src/import/load-costume.js @@ -256,14 +256,18 @@ const handleCostumeLoadError = function (costume, runtime) { const oldAssetId = costume.assetId; const oldRotationX = costume.rotationCenterX; const oldRotationY = costume.rotationCenterY; - - // 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}.${costume.dataFormat}`; const AssetType = runtime.storage.AssetType; - const defaultCostumePromise = (costume.dataFormat === AssetType.ImageVector.runtimeFormat) ? + 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.dataFormat}`; + + const defaultCostumePromise = (isVector) ? loadVector_(costume, runtime) : loadBitmap_(costume, runtime); return defaultCostumePromise.then(loadedCostume => { From 4ff6288d3fecc2d7e9ee461fbdbc3476760583cd Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Mon, 16 May 2022 16:32:58 -0400 Subject: [PATCH 08/15] Fix typo in suggested code change --- src/serialization/serialize-assets.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serialization/serialize-assets.js b/src/serialization/serialize-assets.js index a6c900ec9..f30c72801 100644 --- a/src/serialization/serialize-assets.js +++ b/src/serialization/serialize-assets.js @@ -16,7 +16,7 @@ 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.broken ? curAsset.broken.asset : currAsset.asset; + const asset = currAsset.broken ? currAsset.broken.asset : currAsset.asset; if (!asset) { continue; } From 8cf390d2b5b99289aa13e61d30b1312eafcab777 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Mon, 16 May 2022 17:11:55 -0400 Subject: [PATCH 09/15] Fix typo --- test/fixtures/readProjectFile.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fixtures/readProjectFile.js b/test/fixtures/readProjectFile.js index e7ef5b2bc..36f42797c 100644 --- a/test/fixtures/readProjectFile.js +++ b/test/fixtures/readProjectFile.js @@ -7,7 +7,7 @@ module.exports = { }, extractProjectJson: function (path) { const zip = new AdmZip(path); - const projectEntry = zip.getEntries().find(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')); } From 8b9ce99762286116f21cd916f58d76821b24e156 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Mon, 16 May 2022 17:33:21 -0400 Subject: [PATCH 10/15] Add tests that serializing a costume does not save any data for assets that were missing when the project was first loaded --- test/integration/sb3_missing_svg.js | 10 ++++++++++ test/integration/sprite3_missing_svg.js | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/test/integration/sb3_missing_svg.js b/test/integration/sb3_missing_svg.js index 9e1445f5a..150f97469 100644 --- a/test/integration/sb3_missing_svg.js +++ b/test/integration/sb3_missing_svg.js @@ -12,6 +12,7 @@ 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); @@ -82,6 +83,15 @@ test('load and then save sb3 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/sprite3_missing_svg.js b/test/integration/sprite3_missing_svg.js index 70e3ab046..1df90dacc 100644 --- a/test/integration/sprite3_missing_svg.js +++ b/test/integration/sprite3_missing_svg.js @@ -12,6 +12,7 @@ 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'); @@ -80,6 +81,13 @@ test('load and then save sprite3 with missing vector 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, vm.runtime.targets[2].id); + t.equal(costumeDescs.length, 0); + t.end(); process.nextTick(process.exit); }); From 2481f28191dec1f2eace446711e351384264d44c Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Tue, 17 May 2022 16:40:33 -0400 Subject: [PATCH 11/15] Rewrite missing asset skip in the affirmative --- src/serialization/serialize-assets.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/serialization/serialize-assets.js b/src/serialization/serialize-assets.js index f30c72801..62b28b123 100644 --- a/src/serialization/serialize-assets.js +++ b/src/serialization/serialize-assets.js @@ -17,13 +17,13 @@ const serializeAssets = function (runtime, assetType, optTargetId) { for (let j = 0; j < currAssets.length; j++) { const currAsset = currAssets[j]; const asset = currAsset.broken ? currAsset.broken.asset : currAsset.asset; - if (!asset) { - continue; + if (asset) { + // Serialize asset if it exists, otherwise skip + assetDescs.push({ + fileName: `${asset.assetId}.${asset.dataFormat}`, + fileContent: asset.data + }); } - assetDescs.push({ - fileName: `${asset.assetId}.${asset.dataFormat}`, - fileContent: asset.data - }); } } return assetDescs; From 11f938f8a9368cbb0a827950aa9fbf86763530ae Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Wed, 18 May 2022 16:55:46 -0400 Subject: [PATCH 12/15] Update to latest version of storage which fixes issue where an HTML 404 page was being returned for missing asset data. Update VM to handle null assets properly. --- package-lock.json | 32 ++++++++++++++++++++++--- package.json | 2 +- src/import/load-costume.js | 6 ++--- src/import/load-sound.js | 10 ++++++-- src/serialization/deserialize-assets.js | 4 ++-- src/virtual-machine.js | 4 +++- test/integration/sb3_missing_svg.js | 9 +------ test/integration/sprite3_missing_svg.js | 8 +------ 8 files changed, 48 insertions(+), 27 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2ddc3c957..10e6f2e05 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.0", + "resolved": "https://registry.npmjs.org/scratch-storage/-/scratch-storage-2.0.0.tgz", + "integrity": "sha512-eLqI5bBWTS1d43BY3zSzJYerBfdwa2l5myLD+IASkGN8eBJtW+/CDsKQC0FtI6xV9Afb7req9eeikHlPYczIuw==", "dev": true, "requires": { "arraybuffer-loader": "^1.0.3", diff --git a/package.json b/package.json index 68d1ba16d..6fb90f499 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "scratch-l10n": "3.14.20220510031559", "scratch-render": "0.1.0-prerelease.20211028200436", "scratch-render-fonts": "1.0.0-prerelease.20210401210003", - "scratch-storage": "1.3.6", + "scratch-storage": "2.0.0", "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 0bf6ba653..6c1db499d 100644 --- a/src/import/load-costume.js +++ b/src/import/load-costume.js @@ -302,7 +302,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; @@ -352,12 +352,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); } diff --git a/src/import/load-sound.js b/src/import/load-sound.js index 639fcc2af..400251f80 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( @@ -49,7 +49,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, '.'); @@ -60,6 +60,12 @@ const loadSound = function (sound, runtime, soundBank) { (sound.asset && Promise.resolve(sound.asset)) || runtime.storage.load(runtime.storage.AssetType.Sound, md5, ext) ).then(soundAsset => { + if (!soundAsset) { + log.warn('Failed to find sound data: ', sound); + // TODO add missing sound error handling that adds the "gray question sound" + return sound; + } + sound.asset = soundAsset; return loadSoundFromAsset(sound, soundAsset, runtime, soundBank); }); 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/virtual-machine.js b/src/virtual-machine.js index 4d24aa9e1..66a9c0fab 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -366,7 +366,9 @@ class VirtualMachine extends EventEmitter { const vm = this; const promise = storage.load(storage.AssetType.Project, id); promise.then(projectAsset => { - vm.loadProject(projectAsset.data); + if (projectAsset) { + return vm.loadProject(projectAsset.data); + } }); } diff --git a/test/integration/sb3_missing_svg.js b/test/integration/sb3_missing_svg.js index 150f97469..523f3459e 100644 --- a/test/integration/sb3_missing_svg.js +++ b/test/integration/sb3_missing_svg.js @@ -23,13 +23,6 @@ let vm; tap.beforeEach(() => { const storage = makeTestStorage(); - - // This line removes the webhelper from the list of available helpers. - // W/o the following line, this fails because storage doesn't handle the case - // where none of the tools have isGetSupported: true - // TODO: Remove this line when the related storage bug is resolved so that - // storage gracefully handles non-browser situations where assets are missing. - storage._helpers = [storage._helpers[0]]; vm = new VirtualMachine(); vm.attachStorage(storage); @@ -88,8 +81,8 @@ test('load and then save sb3 project with missing costume file', t => { 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(); diff --git a/test/integration/sprite3_missing_svg.js b/test/integration/sprite3_missing_svg.js index 1df90dacc..6bd0985f8 100644 --- a/test/integration/sprite3_missing_svg.js +++ b/test/integration/sprite3_missing_svg.js @@ -27,13 +27,6 @@ let vm; tap.beforeEach(() => { const storage = makeTestStorage(); - - // This line removes the webhelper from the list of available helpers. - // W/o the following line, this fails because storage doesn't handle the case - // where none of the tools have isGetSupported: true - // TODO: Remove this line when the related storage bug is resolved so that - // storage gracefully handles non-browser situations where assets are missing. - storage._helpers = [storage._helpers[0]]; vm = new VirtualMachine(); vm.attachStorage(storage); @@ -86,6 +79,7 @@ test('load and then save sprite3 with missing vector costume file', t => { 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(); From 4679d06ac0621a604d17cdf4d3047ddd5fef0980 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Wed, 18 May 2022 21:26:36 -0400 Subject: [PATCH 13/15] Update storage and update 'loadSound' to handle null asset from storage. Track additional metadata for broken costumes: 'bitmapResolution' and 'dataFormat'. --- package-lock.json | 6 +- package.json | 2 +- src/import/load-costume.js | 8 ++- src/import/load-sound.js | 46 ++++++++++++-- src/serialization/sb3.js | 56 ++++++++--------- src/virtual-machine.js | 6 +- test/fixtures/missing_sound.sb3 | Bin 0 -> 8402 bytes test/integration/sb3_missing_sound.js | 87 ++++++++++++++++++++++++++ 8 files changed, 168 insertions(+), 43 deletions(-) create mode 100644 test/fixtures/missing_sound.sb3 create mode 100644 test/integration/sb3_missing_sound.js diff --git a/package-lock.json b/package-lock.json index 10e6f2e05..d67879b2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15811,9 +15811,9 @@ } }, "scratch-storage": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/scratch-storage/-/scratch-storage-2.0.0.tgz", - "integrity": "sha512-eLqI5bBWTS1d43BY3zSzJYerBfdwa2l5myLD+IASkGN8eBJtW+/CDsKQC0FtI6xV9Afb7req9eeikHlPYczIuw==", + "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 6fb90f499..2f0f42769 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "scratch-l10n": "3.14.20220510031559", "scratch-render": "0.1.0-prerelease.20211028200436", "scratch-render-fonts": "1.0.0-prerelease.20210401210003", - "scratch-storage": "2.0.0", + "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 6c1db499d..6d0a2d259 100644 --- a/src/import/load-costume.js +++ b/src/import/load-costume.js @@ -256,6 +256,8 @@ const handleCostumeLoadError = function (costume, runtime) { 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; @@ -265,7 +267,7 @@ const handleCostumeLoadError = function (costume, runtime) { runtime.storage.defaultAssetId.ImageVector : runtime.storage.defaultAssetId.ImageBitmap; costume.asset = runtime.storage.get(costume.assetId); - costume.md5 = `${costume.assetId}.${costume.dataFormat}`; + costume.md5 = `${costume.assetId}.${costume.asset.dataFormat}`; const defaultCostumePromise = (isVector) ? loadVector_(costume, runtime) : loadBitmap_(costume, runtime); @@ -273,13 +275,15 @@ const handleCostumeLoadError = function (costume, runtime) { return defaultCostumePromise.then(loadedCostume => { loadedCostume.broken = {}; loadedCostume.broken.assetId = oldAssetId; - loadedCostume.broken.md5 = `${oldAssetId}.${costume.dataFormat}`; + 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; }); }; diff --git a/src/import/load-sound.js b/src/import/load-sound.js index 400251f80..2c617f37a 100644 --- a/src/import/load-sound.js +++ b/src/import/load-sound.js @@ -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. @@ -60,13 +94,13 @@ const loadSound = function (sound, runtime, soundBank) { (sound.asset && Promise.resolve(sound.asset)) || runtime.storage.load(runtime.storage.AssetType.Sound, md5, ext) ).then(soundAsset => { - if (!soundAsset) { - log.warn('Failed to find sound data: ', sound); - // TODO add missing sound error handling that adds the "gray question sound" - return sound; - } - 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); }); }; diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index df8aad925..b176ec919 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -346,29 +346,24 @@ const serializeBlocks = function (blocks) { const serializeCostume = function (costume) { const obj = Object.create(null); obj.name = costume.name; - obj.bitmapResolution = costume.bitmapResolution; - obj.dataFormat = costume.dataFormat.toLowerCase(); - if (costume.broken) { - obj.assetId = costume.broken.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.broken.md5); - - obj.rotationCenterX = costume.broken.rotationCenterX; - obj.rotationCenterY = costume.broken.rotationCenterY; - } else { - obj.assetId = costume.assetId; - - // See related comment above - obj.md5ext = costume.md5; - - obj.rotationCenterX = costume.rotationCenterX; - obj.rotationCenterY = costume.rotationCenterY; - } + + 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 = costumeToSerialize.md5; + + obj.rotationCenterX = costumeToSerialize.rotationCenterX; + obj.rotationCenterY = costumeToSerialize.rotationCenterY; + return obj; }; @@ -379,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/virtual-machine.js b/src/virtual-machine.js index 66a9c0fab..c05a5f3f5 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -366,9 +366,11 @@ class VirtualMachine extends EventEmitter { const vm = this; const promise = storage.load(storage.AssetType.Project, id); promise.then(projectAsset => { - if (projectAsset) { - return vm.loadProject(projectAsset.data); + if (!projectAsset) { + log.error("Failed to fetch project with id: ", id); + return null; } + return vm.loadProject(projectAsset.data); }); } diff --git a/test/fixtures/missing_sound.sb3 b/test/fixtures/missing_sound.sb3 new file mode 100644 index 0000000000000000000000000000000000000000..14dea75830d86de68144b77fe81a02588092fd9f GIT binary patch literal 8402 zcmds+XEdB$yT?awAtuU1^d2SZj2ROI(R&v{jKLUv^d7xLlqk_fg6N_QB1jNK7bQrv zXh9G)O4P$S@AIA~&&spbn|0nV@7epy%v$%lul3*iy7vBE|65BH51$%9d_JLtReFGb z49%+m00)2tAcR7SBdtWOV4~ts3vrmJs3^<=2^F@qfPpQAkRlKPR}X7_J#qliVR9If z+AIt?b}tO+?nMT`!QaIJ0M6eG0Q4EDJJBm%dA(7-Y$eb@L{ZSxK;G4?Anme+i{mmu z&#oc&?h4PNB&)Nd556O}$*pciiiI<_%zSV&^c-6%S=nlzor>`e@NAGY=^yQwKPhRS z+uEx1{XXRwfW`QK77Vq>=P#fB>{#n`s~v4uM%LfoJCAZVG3zh>W}B^QcKf;j?5;IR z(%x~jqbKc~#gb3&sLAawlMQ{F2Pf+%UrIOF6l8Zf+tYP~{g-@Cf21b%aO&?2E-ohe zQXI9SUOvXVsY-K?aXn?M^~>7*TMXeR4d#1?%$4_J`C-pZpeSs8=1FxU-C|DbY~APAGz7H`--JY) z937pOjw0^TU7aSMn6iZo>le6>HO%8!XTReX&DBeMHN58h>XjsYO4`x$$m_0WkWqJm z^rV|kFVw!-gDayd?LR**^iVbB&<|^gZz0<;A+)3z-cIySu^d;}q~$9ynemEuHpIv! z`-iqq2=u5vx?%FoN1z5lA`4fU73uH)*q%QuKqYf#{iJl?^z+2@b7#af+o*?z124m^ zDI$kn zkYw@SHWlObTZ__eu!0bJs3t2^_c_-Zc zuI7O$7in`sWc*0;b?>UHr&3j4U#SbwLaoa+3AdTdbuyQ4z~ObZEYg0OBCWH){32T} z#Z~>v_(~?@BsNv3YdsP2uk;Q!A~0uT1-9o8RN!Mif4@ zBCXyI6?i~~rbl<6ab4r`X_Ma)ugFhmnTOy(0j{k;W2WnC7b zh7SIsCvSOa^_orCsjjl^q|uslj7v$Y=j49lgg@o%sOgOi12CyuKLQHx%H7L~NePD& zB=aSK^2yny4~piw7Or|=PN6qo2`rG5z=Z3HhU#fp;9%v9X#(GtsosKb(zJ@963SVw zGzz$tywu?JWTU7IeN)IDd6QjJ9G}5tLgsVsdZ#GAVrZg{(#gE*dC?8ZMiangAzsuHsE$pDqgXXhkvhgjZCU5EvM@6%Ld3N12m6jIDgm8P?@Q zL&Ro`M_}kdI8Mk?d&61R1# z=^rbk+;bks;YfrplcKJN8VX;@!Pa(9!Hw^vs27GD;;3JT?l?T@W44tG%ph5Jxi#|Wbk7W5?Y`#U$sR$&GHc%9REPkXsdFm>qsD72J@{>1HI(b_6viHy zTfD7f!C=tZBrV1;&CrgV#qUaH3(!Q)Dpd+y8&X~wB=&5Gm_G~? z#-NItOcDn^E2!Mm(J{}0UxE6Vd{;pDm^dxyygqZ?exuf$&$KZRz)?CrWVa$#kFV|! zp+O)bqwH+Jhm>9Dq?B{UeDyM4t7P?6tMBbv=N~ zx4Z(;j_VWa6s-?~*XTOTC`42zZ4B$E)YU+cNFn^BrUzsE<=N_!R1PIkmwm;K;t&o82OCAJgn)-Op3n0!Q)1 zx+K_^D6?OFESfoLw^7vKQVo9Nv7L59D$72*%zw#$BRd9Q;8jq=o3}@ESx;+$L`~|1 zn8r9LCA%$v=|Q(aS&n|LaVKs>y7b3NA787Jmqx>HV?l7q{dV>K6c67SZk2m5$?~2~ zWI?eoHY4E~vyB-qiE8{SF3lay+hke1S|Rp^93n4=v~mgctU=}DH~GPRSkGi)7f&|htB9FfI#HfYNt zyE!DP*lrdzn{S6iub^gaQH#q9w!IgciHNHd4ay_VRbD9l^%@A_{tpnrM9syaFsLQi z0x5=_($EG4wAg!)@% z(P9Pft4N?y0ZKHPU+}cF1w1X=2crHn-n4KXHD#xr*VVAjc8?Sq} ztUrgjM5JwNrJ1@!{JQp*0a#gs4-cPz;!XWpAw|((WAs!bp@cx7Dzy=}j;VMl6~l^i zds!JzK|Pj!pknVilh3VA?QU(mTmH;TKZZwx-Z0ndzbPGQ+~vI*EV?TbD$Or=iOO(0 zdj#9M+vL`hIq!q(!}35Z{MHZ*&e2MkuteV<-bCC?^IrR_9r1lm7C5m?4J?^mmr!uo z;63l#mAA%&1S`r!No-?~0f{w;3cE3h;yV3D&mQs~)Tq-+C2j(pD6f}LF`oqEcVf$( zrrEl&z0uOolpB3GJX9vkh3rbqB~;~jIy;!f`p*+HqF;OWI0z@Gwi#5IL$7sR_QP4& zhAffKhR^yeTYrCs%TH-`jgmQ<@gA)tncL~ocK1s1Qs~#2jUNXeaEZxOC=-C6D4-;C zxZX!ud&%FYcE^4&pT***5Q9jgi1qQ!PcPCmdn@b(y3M{@$H*F-dLmllaQfa<`|ry_J|; z++GFTDfGu%Z`idx+S4Ud=dtR>vhUWV zGbbD7Ug1r2=~BojwfK(8OYXC+lrV{j9(}yWCHE~ywGtnybGc-3Oa5rxHjmHRMpL9R z31M2PJ@{zx7D%U>rz1xSZtA~1fO=_GbGN=y#J;ut)X`(YBxc>C@69JkaPDe$QvI>F zh;{&Y0tLNU-9WaxKe32u&}%V}Ik}b?_XP^k=2mEOOzA^GCZ=n5Mm)5qYBRpx+f(E6 zo59WgE>TLcx_DSS#k>wB|B4ZR3{1;{9!;$JjP+9VWoq5zZ^$U=;nD0W4m0j6R|x2% z?F+U4$n5d$t_WhnF(cjwYh2FVa0DR-U+$~TdaqcuT36n51Uns6bBB9oO7d3j&)mKdkW3dOG_Jesvit$C*8kj=RtrS}=MPSfFP>8VU-cp&I z^SWge?Hy86H%d4ZwZ6xVdaZ-^p$Y9!9dpv0_s~h#$Qe-P^<#Sw&P#!()>G$*JBfB>PAyxf4Eb9Y_?wjN?Vo{Kd_A zZ#ObrRsWjxK{;1JvU51dp#{O#(`n3l@~fC;Hn1EH*{!k?&XMqT4cFF>Ew!R*OX zL(QvuW60PY-=->siBa0uwOqk>%mm6o2nyOy2mzH`MsyKJLH_FI@A)LJuz2b`5|)kK zW=CjhbKUmMybbcbs@PCg(4zO0MpBf^+*{UQ#NH{Gn^C~Lph03WsYvpJN~Q@xN6REm zjp1CPJN95ySn@!qjyF~uy)r{G7Re!ZCys}hCzC&qIFhH$hI5WiI94(?(UXBujR5~n z*Ptrw4vD6w(!gNaC?~#rpzjp~c{nC=?bB0dH3O;P!^lz)$(Ek)8u<*>xb{1Sv5-;5 zaO~7#hQ)QiA0CP^| zip}Ab(Rd7-1TE$j9ZGM2PD(GpLOP49^mfZ;X{))mXpy*A_`~2ZV_*b{ zH@B4kBj7t;`b-T@7Ll4nD{W^1qD&)cCATnb&YktZXdWRr<`6ZYn-iCi24Sz}LlEI| zb~P%=X7Vp_fR|`?I$1`zU1o4;0D-Mc<&GMD16WqSebXN0nU!K3b#7;;R+tw;;m5mX zcu~iKn#bjq*uYu$g(QA?$!;{Rzc*QzaOt%2?sMBV8k&1o^A6p>>|h*^a?Q&$?i?BW zW+jep-(_djwid!D7_MhCu0=m0=!9rJp|W#fWJwRRl&%Ac>rvgc*1BRFxfyo*sO3Z8 z)Jzc|`k=Epf=DScbUegeR)LE$%V_f@QJR%O$;?b}7E?89pbw^$I;z$DZ1l0P;c-je zhK5oPN-Q8f61UqfJ}~#vSN|uRu$+X`emt}#Gd6TEaP#^UG4#$o8z2(B$ZyLF1w5sn zbO{MoHf2bMleOK*85Wwgtd1r$33k%2Ss6*~KaP?-Zpi@_N%@hYkW zkw|kLBj)VguS&u2NiTvSeAnehwIqR5YHY_6`3h5?^KIJr_-1yGGN$SfcgCc%a(4f8 zsw=Kls48YlMupwmLEnOALqT`^$@nDUppK=^M#aaK($@i`-!XE} zyx^=B{UlYXjbYou=wa+zdLL_FCZ0?~d8D|_A?q_oFBH#>!Bkd)B0dtnGQ@gWb&ZK1 zm7*uS{@=&Fto zz8BYa;!XHxs1fTfl-zSqyVha94;#D|LSx2Y8SWin7O7#mn#vh!&Hz7VZnFzFV~4xP ziJWQ8zS2}eq#XErk<0 z^i5IU!>yB%HJ5)re*o5oQR|`W^KT)*3{LA7o9m|(x0w9w@{a)I4{Fk%b7VSdKTW_$ zsYc7+inbufWu;5-_>(#cismtF!)eQI!9@&(R!xvy*(_%HEs{snRKtNNtg| zGAoYt_FIGBwb^^sJWN!nvJjv!{Y~0HOc}vZ-K!=?$5@l-5mdj*aT3}x+xq&wqsyDx ziK2DmA5$?LV>P4(YCCUIYx<<}d)RVr zvsfhe=KPggJ7#BO7XVe8HV5iC?{zK^;a>)*-{Oe>4*=DD&O#UFuZ5+EFhuy==!J+1 zqbyMt$ABtl2P@mfDeEF>6h;#aajd8w9!YcCGGa}vcowYQGYnGd2JvdU{GOhrtIEt5NFJ*B9<5tVb3LowD?nObR_ z9=aM}Hk2~1)Udf|Zie5FhDo?C_D%vXdxTHNcP-r9>aC_b0mL@ z7|Y#Dm7WXlo!&1sEd@ZcnO~=#c^FJRxpl(sKUtRGuhpp-+Xpp zLG|51$A!wl>2`L6v?f`!UaM!VyB}^?v)&7d0KM~|+^{zt_1cX2Du}01&D_P&^VNTY zTg05kG=L6hP5b&y-0L|ZffAi16k$9R<~G>^syNyKjBB&n}_XXji8|?n;*%;)uJ#aV%Pd= z!lub7$MX#=+4k0-=0!%OK54u8@^oB{NX~tS_V5!jxAFd9?do7WwH>L%Wxv(>sJ{T5z8 z8CI=R+K^{8Dg{!Z!^a!7qSYsA5pH?Sm19Gjt|a4>TkCQfvkcjNwYNMR1w@hUTbMqS zog;MPo5I7AzA?z>{S-F~ZHEb=*!-|h_6_!)x>eSg6~jT*rw&vVm3!pD{oiAtVlH&0 z0A^oMj;BWi)10m6#~bYYZ*|j$V78`f?&;6JZy^Se4`+n>&>v%iwcqhj#cv3`h-Y@O zdINPJ1F=2t`b1NhzyEGZLit|Ai&|w2$3}ivN9bU=n%|B0i3PV=_VK!*F-??>YTogg zuf5&P$ zm|qml-ybQUpMA7nR@Q&o(Sd_YjrYHw5dZ8aUfd#ojOw6^U%zb}{-P<@QvEsNpWV9) zpYo4U9YjtH_;U6B@w31(5!_z< { + 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); +}); From 36849c9f4029f22477079079d6d91615313046e5 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Thu, 19 May 2022 12:55:09 -0400 Subject: [PATCH 14/15] Fix lint error Co-authored-by: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> --- src/virtual-machine.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/virtual-machine.js b/src/virtual-machine.js index c05a5f3f5..a75ff0802 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -367,7 +367,7 @@ class VirtualMachine extends EventEmitter { const promise = storage.load(storage.AssetType.Project, id); promise.then(projectAsset => { if (!projectAsset) { - log.error("Failed to fetch project with id: ", id); + log.error(`Failed to fetch project with id: ${id}`); return null; } return vm.loadProject(projectAsset.data); From 8ab21dd701a6c2b20260c30cd4994a2e4eb75900 Mon Sep 17 00:00:00 2001 From: Karishma Chadha Date: Thu, 19 May 2022 17:45:27 -0400 Subject: [PATCH 15/15] Handle error from sound failing to load (e.g. corrupted sound) --- src/import/load-sound.js | 21 +++-- test/fixtures/corrupt_sound.sb3 | Bin 0 -> 9043 bytes test/integration/sb3_corrupted_sound.js | 120 ++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 test/fixtures/corrupt_sound.sb3 create mode 100644 test/integration/sb3_corrupted_sound.js diff --git a/src/import/load-sound.js b/src/import/load-sound.js index 2c617f37a..c74795391 100644 --- a/src/import/load-sound.js +++ b/src/import/load-sound.js @@ -93,16 +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; + ) + .then(soundAsset => { + sound.asset = soundAsset; - if (!soundAsset) { - log.warn('Failed to find sound data: ', sound.md5); + 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); - } - - return loadSoundFromAsset(sound, soundAsset, runtime, soundBank); - }); + }); }; module.exports = { diff --git a/test/fixtures/corrupt_sound.sb3 b/test/fixtures/corrupt_sound.sb3 new file mode 100644 index 0000000000000000000000000000000000000000..d2611babcb293a365e7de6e2d9ba9753bfaf336e GIT binary patch literal 9043 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 { + 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); +});