diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 0f0c74a86f3..af1cbe13834 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -50,6 +50,8 @@ const defaultBlockPackages = { const interpolate = require('./tw-interpolate'); const FrameLoop = require('./tw-frame-loop'); +const LazySprite = require('../sprites/tw-lazy-sprite.js'); +const CancellableMutex = require('../util/tw-cancellable-mutex.js'); const defaultExtensionColors = ['#0FBD8C', '#0DA57A', '#0B8E69']; @@ -534,6 +536,18 @@ class Runtime extends EventEmitter { * Total number of finished or errored scratch-storage load() requests since the runtime was created or cleared. */ this.finishedAssetRequests = 0; + + /** + * Sprites with lazy-loading capabilities. + * @type {Array} + */ + this.lazySprites = []; + + /** + * All lazy sprite loading and unloading operations are gated behind this lock. + * @type {CancellableMutex} + */ + this.lazySpritesLock = new CancellableMutex(); } /** @@ -925,6 +939,14 @@ class Runtime extends EventEmitter { return 'PLATFORM_MISMATCH'; } + /** + * Event when lazily loaded sprites are loaded, before they have executed anything. + * Called with array of original targets. + */ + static get LAZY_SPRITES_LOADED () { + return 'LAZY_SPRITES_LOADED'; + } + /** * How rapidly we try to step threads by default, in ms. */ @@ -2276,6 +2298,14 @@ class Runtime extends EventEmitter { if (target.isOriginal) target.deleteMonitors(); }); + this.lazySpritesLock.cancel(); + this.lazySprites.forEach(sprite => { + if (sprite.state === LazySprite.State.LOADED || sprite.state === LazySprite.State.LOADING) { + sprite.unload(); + } + }); + this.lazySprites = []; + this.targets.map(this.disposeTarget, this); this.extensionStorage = {}; // tw: explicitly emit a MONITORS_UPDATE instead of relying on implicit behavior of _step() @@ -3482,6 +3512,75 @@ class Runtime extends EventEmitter { return callback().then(onSuccess, onError); } + + /** + * @param {string[]} spriteNames Assumed to contain no duplicate entries. + * @returns {Promise} Resolves when all sprites have been loaded. + */ + loadLazySprites (spriteNames) { + return this.lazySpritesLock.do(async isCancelled => { + const lazySprites = []; + for (const name of spriteNames) { + const lazySprite = this.lazySprites.find(sprite => sprite.name === name); + if (lazySprite) { + if (lazySprite.state === LazySprite.State.UNLOADED) { + lazySprites.push(lazySprite); + } else if (lazySprite.state === LazySprite.State.ERROR) { + // TODO(lazy) + } else { + // Already loaded or loading. Nothing to do. + } + } else { + throw new Error(`Unknown lazy sprite: ${name}`); + } + } + + const promises = lazySprites.map(sprite => sprite.load()); + const allTargets = await Promise.all(promises); + + if (isCancelled()) { + return; + } + + // Ignore cancelled targets. + const loadedTargets = allTargets.filter(i => i); + for (const target of loadedTargets) { + target.updateAllDrawableProperties(); + this.addTarget(target); + } + + this.emit(Runtime.LAZY_SPRITES_LOADED, loadedTargets); + return loadedTargets; + }); + } + + /** + * @param {string[]} spriteNames Assumed to contain no duplicate entries. + * @returns {Promise} Resolves when all sprites have been unloaded. + */ + unloadLazySprites (spriteNames) { + return this.lazySpritesLock.do(() => { + const lazySprites = []; + for (const name of spriteNames) { + const lazySprite = this.lazySprites.find(sprite => sprite.name === name); + if (lazySprite) { + if (lazySprite.state === LazySprite.State.LOADED || lazySprite.state === LazySprite.State.LOADING) { + lazySprites.push(lazySprite); + } else if (lazySprite.state === LazySprite.State.ERROR) { + // TODO(lazy) + } else { + // Already unloaded. Nothing to do. + } + } else { + throw new Error(`Unknown lazy sprite: ${name}`); + } + } + + for (const lazySprite of lazySprites) { + lazySprite.unload(); + } + }); + } } /** diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js index a092af35482..7a945c3318b 100644 --- a/src/extension-support/extension-manager.js +++ b/src/extension-support/extension-manager.js @@ -25,8 +25,9 @@ const defaultBuiltinExtensions = { makeymakey: () => require('../extensions/scratch3_makeymakey'), boost: () => require('../extensions/scratch3_boost'), gdxfor: () => require('../extensions/scratch3_gdx_for'), - // tw: core extension - tw: () => require('../extensions/tw') + // tw: core extensions + tw: () => require('../extensions/tw'), + twlazy: () => require('../extensions/tw_lazy') }; /** diff --git a/src/extensions/tw_lazy/index.js b/src/extensions/tw_lazy/index.js new file mode 100644 index 00000000000..94930d63a9e --- /dev/null +++ b/src/extensions/tw_lazy/index.js @@ -0,0 +1,134 @@ +const formatMessage = require('format-message'); +const BlockType = require('../../extension-support/block-type'); +const ArgumentType = require('../../extension-support/argument-type'); +const Cast = require('../../util/cast'); +const Runtime = require('../../engine/runtime'); + +class LazySprites { + constructor (runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + + // This is implemented with an event rather than in the blocks below so that it works if + // some other extension loads sprites lazily. + this.runtime.on(Runtime.LAZY_SPRITES_LOADED, targets => { + for (const target of targets) { + this.runtime.startHats('twlazy_whenLoaded', null, target); + } + }); + } + + /** + * @returns {object} metadata for this extension and its blocks. + */ + getInfo () { + return { + id: 'twlazy', + name: 'Lazy Loading', + blocks: [ + { + blockType: BlockType.EVENT, + opcode: 'whenLoaded', + isEdgeActivated: false, + text: formatMessage({ + id: 'tw.lazy.whenLoaded', + default: 'when I load', + description: 'Hat block that runs when a sprite loads' + }) + }, + { + blockType: BlockType.COMMAND, + opcode: 'loadSprite', + text: formatMessage({ + id: 'tw.lazy.loadSprite', + default: 'load sprite [SPRITE]', + description: 'Block that loads a sprite' + }), + arguments: { + SPRITE: { + type: ArgumentType.STRING, + menu: 'lazySprite' + } + } + }, + { + blockType: BlockType.COMMAND, + opcode: 'unloadSprite', + text: formatMessage({ + id: 'tw.lazy.unloadSprite', + default: 'unload sprite [SPRITE]', + description: 'Block that unloads a sprite' + }), + arguments: { + SPRITE: { + type: ArgumentType.STRING, + menu: 'lazySprite' + } + } + } + ], + menus: { + lazySprite: { + acceptReporters: true, + items: 'getLazySpritesMenu' + }, + lazyCostume: { + acceptReporters: true, + items: 'getLazyCostumesMenu' + }, + lazySound: { + acceptReporters: true, + items: 'getLazySoundsMenu' + } + } + }; + } + + getLazySpritesMenu () { + if (this.runtime.lazySprites.length === 0) { + return [ + { + text: formatMessage({ + id: 'tw.lazy.noSprites', + default: 'No sprites', + description: 'Block menu in lazy loading extension when no lazy-loaded sprites exist' + }), + value: '' + } + ]; + } + + return this.runtime.lazySprites.map(i => i.name); + } + + getLazyCostumesMenu () { + // TODO(lazy) + return ['b']; + } + + getLazySoundsMenu () { + // TODO(lazy) + return ['c']; + } + + loadSprite (args) { + const name = Cast.toString(args.SPRITE); + return this.runtime.loadLazySprites([name]) + .catch(() => { + // TODO(lazy): handle this... + }); + } + + unloadSprite (args) { + const name = Cast.toString(args.SPRITE); + return this.runtime.unloadLazySprites([name]) + .catch(() => { + // TODO(lazy): handle this... + }); + } +} + +module.exports = LazySprites; diff --git a/src/serialization/sb2.js b/src/serialization/sb2.js index 1cde8e26e24..e1b0a4b1acb 100644 --- a/src/serialization/sb2.js +++ b/src/serialization/sb2.js @@ -928,6 +928,7 @@ const sb2import = function (json, runtime, optForceSprite, zip) { .then(reorderParsedTargets) .then(targets => ({ targets, + lazySprites: [], // lazy loading not supported in sb2 projects extensions })); }; diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index 815c2837a4d..d27b1737c6e 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -7,6 +7,7 @@ const Runtime = require('../engine/runtime'); const Blocks = require('../engine/blocks'); const Sprite = require('../sprites/sprite'); +const LazySprite = require('../sprites/tw-lazy-sprite.js'); const Variable = require('../engine/variable'); const Comment = require('../engine/comment'); const MonitorRecord = require('../engine/monitor-record'); @@ -628,6 +629,11 @@ const serializeTarget = function (target, extensions) { obj.rotationStyle = target.rotationStyle; } + // Only output anything for lazy sprites so that we match vanilla for non-lazy sprites. + if (target.lazy) { + obj.lazy = true; + } + // Add found extensions to the extensions object targetExtensions.forEach(extensionId => { extensions.add(extensionId); @@ -635,6 +641,24 @@ const serializeTarget = function (target, extensions) { return obj; }; +/** + * @param {LazySprite} lazySprite + * @param {Set} extensions + * @returns {object} + */ +const serializeLazySprite = function (lazySprite, extensions) { + if (lazySprite.state === LazySprite.State.LOADED) { + lazySprite.save(); + } + + const [_blocks, targetExtensions] = serializeBlocks(lazySprite.object.blocks); + targetExtensions.forEach(extensionId => { + extensions.add(extensionId); + }); + + return lazySprite.object; +}; + /** * @param {Record} extensionStorage extensionStorage object * @param {Set} extensions extension IDs @@ -719,7 +743,8 @@ const serialize = function (runtime, targetId, {allowOptimization = true} = {}) const originalTargetsToSerialize = targetId ? [runtime.getTargetById(targetId)] : - runtime.targets.filter(target => target.isOriginal); + runtime.targets.filter(target => target.isOriginal && !target.sprite.isLazy); + const lazySpritesToSerialize = targetId ? [] : runtime.lazySprites; const layerOrdering = getSimplifiedLayerOrdering(originalTargetsToSerialize); @@ -744,10 +769,17 @@ const serialize = function (runtime, targetId, {allowOptimization = true} = {}) return serialized; }); + const serializedLazySprites = lazySpritesToSerialize.map(s => serializeLazySprite(s, extensions)); + const fonts = runtime.fontManager.serializeJSON(); if (targetId) { const target = serializedTargets[0]; + + // Doesn't make sense for an export of a single sprite to be lazy when it gets + // imported again. + delete target.lazy; + if (extensions.size) { // Vanilla Scratch doesn't include extensions in sprites, so don't add this if it's not needed target.extensions = Array.from(extensions); @@ -767,7 +799,10 @@ const serialize = function (runtime, targetId, {allowOptimization = true} = {}) obj.extensionStorage = globalExtensionStorage; } - obj.targets = serializedTargets; + obj.targets = [ + ...serializedTargets, + ...serializedLazySprites + ]; obj.monitors = serializeMonitors(runtime.getMonitorState(), runtime, extensions); @@ -1150,52 +1185,12 @@ const parseScratchAssets = function (object, runtime, zip) { }; /** - * Parse a single "Scratch object" and create all its in-memory VM objects. - * @param {!object} object From-JSON "Scratch object:" sprite, stage, watcher. - * @param {!Runtime} runtime Runtime object to load all structures into. - * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. - * @param {JSZip} zip Sb3 file describing this project (to load assets from) - * @param {object} assets - Promises for assets of this scratch object grouped - * into costumes and sounds - * @return {!Promise.} Promise for the target created (stage or sprite), or null for unsupported objects. + * Loads data from a target's JSON to an actual Target object, in place. + * @param {Runtime} runtime + * @param {Target} target + * @param {object} object */ -const parseScratchObject = function (object, runtime, extensions, zip, assets) { - if (!Object.prototype.hasOwnProperty.call(object, 'name')) { - // Watcher/monitor - skip this object until those are implemented in VM. - // @todo - return Promise.resolve(null); - } - // Blocks container for this object. - const blocks = new Blocks(runtime); - - // @todo: For now, load all Scratch objects (stage/sprites) as a Sprite. - const sprite = new Sprite(blocks, runtime); - - // Sprite/stage name from JSON. - if (Object.prototype.hasOwnProperty.call(object, 'name')) { - sprite.name = object.name; - } - if (Object.prototype.hasOwnProperty.call(object, 'blocks')) { - deserializeBlocks(object.blocks); - // Take a second pass to create objects and add extensions - for (const blockId in object.blocks) { - if (!Object.prototype.hasOwnProperty.call(object.blocks, blockId)) continue; - const blockJSON = object.blocks[blockId]; - blocks.createBlock(blockJSON); - - // If the block is from an extension, record it. - const extensionID = getExtensionIdForOpcode(blockJSON.opcode); - if (extensionID) { - extensions.extensionIDs.add(extensionID); - } - } - } - // Costumes from JSON. - const {costumePromises} = assets; - // Sounds from JSON - const {soundBank, soundPromises} = assets; - // Create the first clone, and load its run-state from JSON. - const target = sprite.createClone(object.isStage ? StageLayering.BACKGROUND_LAYER : StageLayering.SPRITE_LAYER); +const parseTargetStateFromJSON = function (runtime, target, object) { // Load target properties from JSON. if (Object.prototype.hasOwnProperty.call(object, 'tempo')) { target.tempo = object.tempo; @@ -1316,6 +1311,57 @@ const parseScratchObject = function (object, runtime, extensions, zip, assets) { if (Object.prototype.hasOwnProperty.call(object, 'extensionStorage')) { target.extensionStorage = object.extensionStorage; } +}; + +/** + * Parse a single "Scratch object" and create all its in-memory VM objects. + * @param {!object} object From-JSON "Scratch object:" sprite, stage, watcher. + * @param {!Runtime} runtime Runtime object to load all structures into. + * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. + * @param {JSZip} zip Sb3 file describing this project (to load assets from) + * @param {object} assets - Promises for assets of this scratch object grouped + * into costumes and sounds + * @return {!Promise.} Promise for the target created (stage or sprite), or null for unsupported objects. + */ +const parseScratchObject = function (object, runtime, extensions, zip, assets) { + if (!Object.prototype.hasOwnProperty.call(object, 'name')) { + // Watcher/monitor - skip this object until those are implemented in VM. + // @todo + return Promise.resolve(null); + } + // Blocks container for this object. + const blocks = new Blocks(runtime); + + // @todo: For now, load all Scratch objects (stage/sprites) as a Sprite. + const sprite = new Sprite(blocks, runtime); + + // Sprite/stage name from JSON. + if (Object.prototype.hasOwnProperty.call(object, 'name')) { + sprite.name = object.name; + } + if (Object.prototype.hasOwnProperty.call(object, 'blocks')) { + deserializeBlocks(object.blocks); + // Take a second pass to create objects and add extensions + for (const blockId in object.blocks) { + if (!Object.prototype.hasOwnProperty.call(object.blocks, blockId)) continue; + const blockJSON = object.blocks[blockId]; + blocks.createBlock(blockJSON); + + // If the block is from an extension, record it. + const extensionID = getExtensionIdForOpcode(blockJSON.opcode); + if (extensionID) { + extensions.extensionIDs.add(extensionID); + } + } + } + // Costumes from JSON. + const {costumePromises} = assets; + // Sounds from JSON + const {soundBank, soundPromises} = assets; + // Create the first clone, and load its run-state from JSON. + const target = sprite.createClone(object.isStage ? StageLayering.BACKGROUND_LAYER : StageLayering.SPRITE_LAYER); + // Load target properties from JSON. + parseTargetStateFromJSON(runtime, target, object); Promise.all(costumePromises).then(costumes => { sprite.costumes = costumes; }); @@ -1327,6 +1373,37 @@ const parseScratchObject = function (object, runtime, extensions, zip, assets) { return Promise.all(costumePromises.concat(soundPromises)).then(() => target); }; +/** + * @param {object} object Sprite's JSON + * @param {Runtime} runtime + * @param {ImportedExtensionsInfo} extensions Extension information + * @param {JSZip|null} zip Zip file, if any + * @returns {LazySprite} Sprite with lazy-loading capabilities + */ +const parseLazySprite = (object, runtime, extensions, zip) => { + const sprite = new LazySprite(runtime, object, zip); + const blocks = sprite.blocks; + + // See parseScratchObject above + if (Object.prototype.hasOwnProperty.call(object, 'name')) { + sprite.name = object.name; + } + if (Object.prototype.hasOwnProperty.call(object, 'blocks')) { + deserializeBlocks(object.blocks); + for (const blockId in object.blocks) { + if (!Object.prototype.hasOwnProperty.call(object.blocks, blockId)) continue; + const blockJSON = object.blocks[blockId]; + blocks.createBlock(blockJSON); + const extensionID = getExtensionIdForOpcode(blockJSON.opcode); + if (extensionID) { + extensions.extensionIDs.add(extensionID); + } + } + } + + return sprite; +}; + const deserializeMonitor = function (monitorData, runtime, targets, extensions) { // Monitors position is always stored as position from top-left corner in 480x360 stage. const xOffset = (runtime.stageWidth - 480) / 2; @@ -1535,11 +1612,8 @@ const deserialize = async function (json, runtime, zip, isSingleSprite) { } // Extract any custom fonts before loading costumes. - let fontPromise; if (json.customFonts) { - fontPromise = runtime.fontManager.deserialize(json.customFonts, zip, isSingleSprite); - } else { - fontPromise = Promise.resolve(); + await runtime.fontManager.deserialize(json.customFonts, zip, isSingleSprite); } // First keep track of the current target order in the json, @@ -1550,42 +1624,54 @@ const deserialize = async function (json, runtime, zip, isSingleSprite) { .map((t, i) => Object.assign(t, {targetPaneOrder: i})) .sort((a, b) => a.layerOrder - b.layerOrder); + const eagerTargets = targetObjects.filter(i => i.lazy !== true); + const assets = eagerTargets.map(target => parseScratchAssets(target, runtime, zip)); + + // Force to wait for the next loop in the js tick. Let + // storage have some time to send off asset requests. + await Promise.resolve(); + + const unsortedTargets = await Promise.all( + eagerTargets.map((target, index) => parseScratchObject(target, runtime, extensions, zip, assets[index])) + ); + + // Re-sort targets back into original sprite-pane ordering + const sortedTargets = unsortedTargets + .map((t, i) => { + // Add layer order property to deserialized targets. + // This property is used to initialize executable targets in + // the correct order and is deleted in VM's installTargets function + t.layerOrder = i; + return t; + }) + .sort((a, b) => a.targetPaneOrder - b.targetPaneOrder) + .map(t => { + // Delete the temporary properties used for + // sprite pane ordering and stage layer ordering + delete t.targetPaneOrder; + return t; + }); + + const targets = replaceUnsafeCharsInVariableIds(sortedTargets); + const monitorObjects = json.monitors || []; + monitorObjects.map(monitorDesc => deserializeMonitor(monitorDesc, runtime, targets, extensions)); - return fontPromise.then(() => targetObjects.map(target => parseScratchAssets(target, runtime, zip))) - // Force this promise to wait for the next loop in the js tick. Let - // storage have some time to send off asset requests. - .then(assets => Promise.resolve(assets)) - .then(assets => Promise.all(targetObjects - .map((target, index) => - parseScratchObject(target, runtime, extensions, zip, assets[index])))) - .then(targets => targets // Re-sort targets back into original sprite-pane ordering - .map((t, i) => { - // Add layer order property to deserialized targets. - // This property is used to initialize executable targets in - // the correct order and is deleted in VM's installTargets function - t.layerOrder = i; - return t; - }) - .sort((a, b) => a.targetPaneOrder - b.targetPaneOrder) - .map(t => { - // Delete the temporary properties used for - // sprite pane ordering and stage layer ordering - delete t.targetPaneOrder; - return t; - })) - .then(targets => replaceUnsafeCharsInVariableIds(targets)) - .then(targets => { - monitorObjects.map(monitorDesc => deserializeMonitor(monitorDesc, runtime, targets, extensions)); - if (Object.prototype.hasOwnProperty.call(json, 'extensionStorage')) { - runtime.extensionStorage = json.extensionStorage; - } - return targets; - }) - .then(targets => ({ - targets, - extensions - })); + if (Object.prototype.hasOwnProperty.call(json, 'extensionStorage')) { + // eslint-disable-next-line require-atomic-updates + runtime.extensionStorage = json.extensionStorage; + } + + const lazyTargets = targetObjects.filter(i => i.lazy === true); + const lazySprites = await Promise.all(lazyTargets.map(object => ( + parseLazySprite(object, runtime, extensions, zip) + ))); + + return { + targets, + lazySprites, + extensions + }; }; module.exports = { @@ -1593,7 +1679,10 @@ module.exports = { deserialize: deserialize, deserializeBlocks: deserializeBlocks, serializeBlocks: serializeBlocks, + serializeTarget: serializeTarget, deserializeStandaloneBlocks: deserializeStandaloneBlocks, serializeStandaloneBlocks: serializeStandaloneBlocks, - getExtensionIdForOpcode: getExtensionIdForOpcode + getExtensionIdForOpcode: getExtensionIdForOpcode, + parseScratchAssets: parseScratchAssets, + parseTargetStateFromJSON: parseTargetStateFromJSON }; diff --git a/src/sprites/rendered-target.js b/src/sprites/rendered-target.js index eeb029f3874..fa5898c645d 100644 --- a/src/sprites/rendered-target.js +++ b/src/sprites/rendered-target.js @@ -1096,8 +1096,8 @@ class RenderedTarget extends Target { tempo: this.tempo, volume: this.volume, videoTransparency: this.videoTransparency, - videoState: this.videoState - + videoState: this.videoState, + lazy: this.sprite.isLazy }; } diff --git a/src/sprites/sprite.js b/src/sprites/sprite.js index a38fa129c60..1016bf8676f 100644 --- a/src/sprites/sprite.js +++ b/src/sprites/sprite.js @@ -56,6 +56,14 @@ class Sprite { } } + /** + * True if this sprite uses lazy loading. + * @type {boolean} + */ + get isLazy () { + return false; + } + /** * Add an array of costumes, taking care to avoid duplicate names. * @param {!Array} costumes Array of objects representing costumes. diff --git a/src/sprites/tw-lazy-sprite.js b/src/sprites/tw-lazy-sprite.js new file mode 100644 index 00000000000..8c358a68473 --- /dev/null +++ b/src/sprites/tw-lazy-sprite.js @@ -0,0 +1,280 @@ +const Sprite = require('./sprite'); +const JSZip = require('@turbowarp/jszip'); + +/** + * @enum {'unloaded'|'loading'|'loaded'|'error'} + */ +const State = { + UNLOADED: 'unloaded', + LOADING: 'loading', + LOADED: 'loaded', + ERROR: 'error' +}; + +/** + * TODO(lazy): this will generally just leak memory over time in the editor. + */ +class AssetCache { + constructor () { + /** + * Created lazily. + * @type {JSZip|null} + */ + this.internalCache = new JSZip(); + + /** + * @type {JSZip[]} + */ + this.zips = [this.internalCache]; + } + + /** + * @param {JSZip} zip + */ + addZip (zip) { + if (!this.zips.includes(zip)) { + this.zips.push(zip); + } + } + + /** + * Cache a file if it is not already cached. + * @param {string} md5ext md5 and extension + * @param {Uint8Array} data in-memory data + */ + storeIfMissing (md5ext, data) { + if (this.file(md5ext)) { + // Already cached. + return; + } + + // TODO(lazy): we may be able to alleviate memory issues from this if we convert to Blob + // since those can at least theoretically be stored on disk. We might want to do that in + // some sort of background timer so we don't overload everything trying to convert 100MB+ + // of data in one go. + this.internalCache.file(md5ext, data); + } + + /** + * Allows this class to be used as a JSZip zip. + * @param {string} md5ext md5 and extension + * @param {unknown} data Do not provide + * @returns {JSZip.JSZipObject|null} JSZip file if it exists. + */ + file (md5ext, data) { + if (data) { + // There is already a specific method for this. + throw new Error('AssetCache.file() does not support modification'); + } + + for (const zip of this.zips) { + const file = zip.file(md5ext); + // TODO(lazy): check subfolders to match how the other asset operations work + if (file) { + return file; + } + } + + return null; + } +} + +const assetCacheSingleton = new AssetCache(); + +/** + * Sprite with lazy-loading capabilities. + */ +class LazySprite extends Sprite { + /** + * @param {Runtime} runtime + * @param {object} initialJSON JSON from project.json or sprite.json + * @param {JSZip|null} initialZip Zip file provided when loading the project. + */ + constructor (runtime, initialJSON, initialZip) { + // null blocks means Sprite will create it for us + super(null, runtime); + + /** + * @type {State} + */ + this.state = State.UNLOADED; + + /** + * sprite.json or project.json targets[x] entry for the sprite. + * @type {object} + */ + this.object = initialJSON; + + if (initialZip) { + assetCacheSingleton.addZip(initialZip); + } + + /** + * Callback to cancel current load operation. + * @type {() => void} + */ + this._cancelLoadCallback = () => {}; + } + + get isLazy () { + return true; + } + + /** + * Creates an instance of this sprite. + * State must be unloaded. + * Renderer state is not updated. You must call updateAllDrawableProperties() yourself later. + * @returns {Promise} Loaded target, or null if cancelled by unload() call. + */ + load () { + if (this.state !== State.UNLOADED) { + return Promise.reject(new Error(`Unknown state transition ${this.state} -> loading`)); + } + + let cancelled = false; + + const load = async () => { + this.state = State.LOADING; + + // Loaded lazily to avoid circular dependencies + const sb3 = require('../serialization/sb3'); + const { + costumePromises, + soundPromises, + soundBank + } = sb3.parseScratchAssets(this.object, this.runtime, assetCacheSingleton); + + // Wait a bit to give storage a chance to start requests. + await Promise.resolve(); + + // Need to check for cancellation after each async operation. + // At this point the promise is already finished, so our return value won't be seen anywhere. + if (cancelled) { + return null; + } + + const target = this.createClone(); + sb3.parseTargetStateFromJSON(this.runtime, target, this.object); + + const costumes = await Promise.all(costumePromises); + if (cancelled) { + return null; + } + + const sounds = await Promise.all(soundPromises); + if (cancelled) { + return null; + } + + this.costumes = costumes; + this.sounds = sounds; + this.soundBank = soundBank || null; + this.state = State.LOADED; + return target; + }; + + return new Promise((resolve, reject) => { + this._cancelLoadCallback = () => { + cancelled = true; + resolve(null); + }; + + load().then(resolve, reject); + }).catch(err => { + this.state = State.ERROR; + throw err; + }); + } + + /** + * Updates this sprite's stored state based on its original clone. Existing targets are not removed. + * State must be loaded. + * @returns {void} + */ + save () { + if (this.state !== State.LOADED) { + return Promise.reject(new Error(`Cannot save in state ${this.state}`)); + } + + const sb3 = require('../serialization/sb3'); + const serializeAssets = require('../serialization/serialize-assets'); + + const target = this.clones[0]; + const extensions = new Set(); + const serializedJSON = sb3.serializeTarget(target.toJSON(), extensions); + const assets = [ + ...serializeAssets.serializeCostumes(this.runtime, target.id), + ...serializeAssets.serializeSounds(this.runtime, target.id) + ]; + + this.object = serializedJSON; + for (const asset of assets) { + assetCacheSingleton.storeIfMissing(asset.fileName, asset.fileContent); + } + } + + /** + * Updates this sprite's stored state based on its original clone, and removes existing targets. + * State must be LOADED. + * @returns {void} + */ + unload () { + if (this.state !== State.LOADED && this.state !== State.LOADING) { + return Promise.reject(new Error(`Unknown state transition ${this.state} -> unloaded`)); + } + + // Only save if we're in the loaded state. If we're in the loading state, we will have nothing + // to save in the first place. + if (this.state === State.LOADED) { + this.save(); + } + + this.state = State.UNLOADED; + this._cancelLoadCallback(); + + for (const target of this.clones) { + this.runtime.requestTargetsUpdate(target); + this.runtime.disposeTarget(target); + } + } + + /** + * Fetch all assets used in this sprite for serialization. + * @returns {Promise>} + */ + async serializeAssets () { + // Loaded lazily to avoid circular dependencies + const deserializeAssets = require('../serialization/deserialize-assets'); + + const promises = []; + for (const costume of this.object.costumes) { + if (!costume.asset) { + promises.push(deserializeAssets.deserializeCostume(costume, this.runtime, assetCacheSingleton)); + } + } + for (const sound of this.object.sounds) { + if (!sound.asset) { + promises.push(deserializeAssets.deserializeSound(sound, this.runtime, assetCacheSingleton)); + } + } + await Promise.all(promises); + + const allResources = [ + ...this.object.costumes, + ...this.object.sounds + ]; + + return allResources + .map(o => (o.broken ? o.broken.asset : o.asset)) + .filter(asset => asset) + .map(asset => ({ + fileName: `${asset.assetId}.${asset.dataFormat}`, + fileContent: asset.data + })); + } +} + +// Export enums +LazySprite.State = State; + +module.exports = LazySprite; diff --git a/src/util/tw-asset-util.js b/src/util/tw-asset-util.js index 380d9fefb82..e12816611d7 100644 --- a/src/util/tw-asset-util.js +++ b/src/util/tw-asset-util.js @@ -3,7 +3,7 @@ const StringUtil = require('./string-util'); class AssetUtil { /** * @param {Runtime} runtime runtime with storage attached - * @param {JSZip} zip optional JSZip to search for asset in + * @param {JSZip|null} zip optional JSZip to search for asset in * @param {Storage.assetType} assetType scratch-storage asset type * @param {string} md5ext full md5 with file extension * @returns {Promise} scratch-storage asset object diff --git a/src/util/tw-cancellable-mutex.js b/src/util/tw-cancellable-mutex.js new file mode 100644 index 00000000000..df76745183f --- /dev/null +++ b/src/util/tw-cancellable-mutex.js @@ -0,0 +1,83 @@ +/** + * @template T + */ +class CancellableMutex { + constructor () { + /** + * True if the mutex is locked. + * @type {boolean} + * @private + */ + this._locked = false; + + /** + * Queued operations. + * @type {Array<(isCancelled: () => boolean) => Promise>} + */ + this._queue = []; + + /** + * @type {number} + */ + this._cancels = 0; + } + + /** + * Perform async operation using the lock. Will wait until the lock is available. + * @param {(isCancelled: () => boolean) => Promise} callback Async function to run. May resolve or reject. + * @returns {Promise} Resolves or rejects with value or error from callback. + */ + do (callback) { + return new Promise((resolve, reject) => { + const initialCancels = this._cancels; + const isCancelled = () => initialCancels !== this._cancels; + + const startNextOperation = () => { + if (isCancelled()) { + return; + } + + if (this._queue.length) { + const nextCallback = this._queue.shift(); + nextCallback(); + } else { + this._locked = false; + this._cancelCallback = null; + } + }; + + const handleResolve = value => { + resolve(value); + startNextOperation(); + }; + + const handleReject = error => { + reject(error); + startNextOperation(); + }; + + const run = async () => { + try { + handleResolve(await callback(isCancelled)); + } catch (error) { + handleReject(error); + } + }; + + if (this._locked) { + this._queue.push(run); + } else { + this._locked = true; + run(); + } + }); + } + + cancel () { + this._cancels++; + this._locked = false; + this._queue = []; + } +} + +module.exports = CancellableMutex; diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 63872094c2c..08885475715 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -514,9 +514,9 @@ class VirtualMachine extends EventEmitter { } /** - * @returns {JSZip} JSZip zip object representing the sb3. + * @returns {Promise} JSZip zip object representing the sb3. */ - _saveProjectZip () { + async _saveProjectZip () { const projectJson = this.toJSON(); // TODO want to eventually move zip creation out of here, and perhaps @@ -525,7 +525,7 @@ class VirtualMachine extends EventEmitter { // Put everything in a zip file zip.file('project.json', projectJson); - this._addFileDescsToZip(this.serializeAssets(), zip); + this._addFileDescsToZip(await this.serializeAssets(), zip); // Use a fixed modification date for the files in the zip instead of letting JSZip use the // current time to avoid a very small metadata leak and make zipping deterministic. The magic @@ -543,8 +543,9 @@ class VirtualMachine extends EventEmitter { * @param {JSZip.OutputType} [type] JSZip output type. Defaults to 'blob' for Scratch compatibility. * @returns {Promise} Compressed sb3 file in a type determined by the type argument. */ - saveProjectSb3 (type) { - return this._saveProjectZip().generateAsync({ + async saveProjectSb3 (type) { + const zip = await this._saveProjectZip(); + return zip.generateAsync({ type: type || 'blob', mimeType: 'application/x.scratch.sb3', compression: 'DEFLATE' @@ -553,11 +554,12 @@ class VirtualMachine extends EventEmitter { /** * @param {JSZip.OutputType} [type] JSZip output type. Defaults to 'arraybuffer'. - * @returns {StreamHelper} JSZip StreamHelper object generating the compressed sb3. + * @returns {Promise} JSZip StreamHelper object generating the compressed sb3. * See: https://stuk.github.io/jszip/documentation/api_streamhelper.html */ - saveProjectSb3Stream (type) { - return this._saveProjectZip().generateInternalStream({ + async saveProjectSb3Stream (type) { + const zip = await this._saveProjectZip(); + return zip.generateInternalStream({ type: type || 'arraybuffer', mimeType: 'application/x.scratch.sb3', compression: 'DEFLATE' @@ -568,15 +570,15 @@ class VirtualMachine extends EventEmitter { * tw: Serialize the project into a map of files without actually zipping the project. * The buffers returned are the exact same ones used internally, not copies. Avoid directly * manipulating them (except project.json, which is created by this function). - * @returns {Record} Map of file name to the raw data for that file. + * @returns {Promise>} Map of file name to the raw data for that file. */ - saveProjectSb3DontZip () { + async saveProjectSb3DontZip () { const projectJson = this.toJSON(); const files = { 'project.json': new _TextEncoder().encode(projectJson) }; - for (const fileDesc of this.serializeAssets()) { + for (const fileDesc of await this.serializeAssets()) { files[fileDesc.fileName] = fileDesc.fileContent; } @@ -601,19 +603,34 @@ class VirtualMachine extends EventEmitter { /** * @param {string} targetId Optional ID of target to export - * @returns {Array<{fileName: string; fileContent: Uint8Array;}} list of file descs + * @returns {Promise} list of file descs */ - serializeAssets (targetId) { - const costumeDescs = serializeCostumes(this.runtime, targetId); - const soundDescs = serializeSounds(this.runtime, targetId); + async serializeAssets (targetId) { + // This will include non-lazy sprites and loaded lazy sprites. + const loadedCostumeDescs = serializeCostumes(this.runtime, targetId); + const loadedSoundDescs = serializeSounds(this.runtime, targetId); + + // Assume every target needs all fonts. const fontDescs = this.runtime.fontManager.serializeAssets().map(asset => ({ fileName: `${asset.assetId}.${asset.dataFormat}`, fileContent: asset.data })); + + // Fetch assets used by lazy sprites. + const unloadedSprites = this.runtime.lazySprites.filter(i => i.clones.length === 0); + const unloadedSpriteDescs = await Promise.all(unloadedSprites.map(s => s.serializeAssets())); + const flattenedUnloadedSpriteDescs = []; + for (const descs of unloadedSpriteDescs) { + for (const desc of descs) { + flattenedUnloadedSpriteDescs.push(desc); + } + } + return [ - ...costumeDescs, - ...soundDescs, - ...fontDescs + ...loadedCostumeDescs, + ...loadedSoundDescs, + ...fontDescs, + ...flattenedUnloadedSpriteDescs ]; } @@ -637,12 +654,12 @@ class VirtualMachine extends EventEmitter { * @return {object} A generated zip of the sprite and its assets in the format * specified by optZipType or blob by default. */ - exportSprite (targetId, optZipType) { + async exportSprite (targetId, optZipType) { const spriteJson = this.toJSON(targetId); const zip = new JSZip(); zip.file('sprite.json', spriteJson); - this._addFileDescsToZip(this.serializeAssets(targetId), zip); + this._addFileDescsToZip(await this.serializeAssets(targetId), zip); return zip.generateAsync({ type: typeof optZipType === 'string' ? optZipType : 'blob', @@ -706,7 +723,7 @@ class VirtualMachine extends EventEmitter { return Promise.reject('Unable to verify Scratch Project version.'); }; return deserializePromise() - .then(({targets, extensions}) => { + .then(({targets, lazySprites, extensions}) => { if (typeof performance !== 'undefined') { performance.mark('scratch-vm-deserialize-end'); try { @@ -720,7 +737,7 @@ class VirtualMachine extends EventEmitter { log.error(e); } } - return this.installTargets(targets, extensions, true); + return this.installTargets(targets, lazySprites, extensions, true); }); } @@ -756,11 +773,12 @@ class VirtualMachine extends EventEmitter { /** * Install `deserialize` results: zero or more targets after the extensions (if any) used by those targets. * @param {Array.} targets - the targets to be installed + * @param {Array.} lazySprites - sprites that can be loaded lazily * @param {ImportedExtensionsInfo} extensions - metadata about extensions used by these targets * @param {boolean} wholeProject - set to true if installing a whole project, as opposed to a single sprite. * @returns {Promise} resolved once targets have been installed */ - async installTargets (targets, extensions, wholeProject) { + async installTargets (targets, lazySprites, extensions, wholeProject) { await this.extensionManager.allAsyncExtensionsLoaded(); targets = targets.filter(target => !!target); @@ -779,6 +797,12 @@ class VirtualMachine extends EventEmitter { delete target.layerOrder; }); + if (wholeProject) { + this.runtime.lazySprites = lazySprites; + } else { + this.runtime.lazySprites = this.runtime.lazySprites.concat(lazySprites); + } + // Select the first target for editing, e.g., the first sprite. if (wholeProject && (targets.length > 1)) { this.editingTarget = targets[1]; @@ -851,9 +875,7 @@ class VirtualMachine extends EventEmitter { if (Object.prototype.hasOwnProperty.call(error, 'validationError')) { return Promise.reject(JSON.stringify(error)); } - // TODO: reject with an Error (possible breaking API change!) - // eslint-disable-next-line prefer-promise-reject-errors - return Promise.reject(`${errorPrefix} ${error}`); + return Promise.reject(error); }); } @@ -868,8 +890,8 @@ class VirtualMachine extends EventEmitter { const sb2 = require('./serialization/sb2'); return sb2.deserialize(sprite, this.runtime, true, zip) - .then(({targets, extensions}) => - this.installTargets(targets, extensions, false)); + .then(({targets, lazySprites, extensions}) => + this.installTargets(targets, lazySprites, extensions, false)); } /** @@ -883,7 +905,7 @@ class VirtualMachine extends EventEmitter { const sb3 = require('./serialization/sb3'); return sb3 .deserialize(sprite, this.runtime, zip, true) - .then(({targets, extensions}) => this.installTargets(targets, extensions, false)); + .then(({targets, lazySprites, extensions}) => this.installTargets(targets, lazySprites, extensions, false)); } /** diff --git a/test/fixtures/tw-lazy-pen-used-only-in-lazy-sprite.sb3 b/test/fixtures/tw-lazy-pen-used-only-in-lazy-sprite.sb3 new file mode 100644 index 00000000000..570aeea5338 Binary files /dev/null and b/test/fixtures/tw-lazy-pen-used-only-in-lazy-sprite.sb3 differ diff --git a/test/fixtures/tw-lazy-simple.sb3 b/test/fixtures/tw-lazy-simple.sb3 new file mode 100644 index 00000000000..1fcbba1a113 Binary files /dev/null and b/test/fixtures/tw-lazy-simple.sb3 differ diff --git a/test/integration/tw_font_manager.js b/test/integration/tw_font_manager.js index 452fabc687a..3b9f440a142 100644 --- a/test/integration/tw_font_manager.js +++ b/test/integration/tw_font_manager.js @@ -409,7 +409,7 @@ test('deleteFont', t => { t.end(); }); -test('fonts are serialized by VM', t => { +test('fonts are serialized by VM', async t => { const vm = new VirtualMachine(); vm.attachStorage(makeTestStorage()); const {storage, fontManager} = vm.runtime; @@ -427,7 +427,7 @@ test('fonts are serialized by VM', t => { const assets = vm.assets; t.same(assets, [fontAsset], 'font is in vm.assets'); - const serializedAssets = vm.serializeAssets(); + const serializedAssets = await vm.serializeAssets(); t.same(serializedAssets, [ { fileName: '94263e4d553bcec128704e354b659526.ttf', @@ -435,7 +435,7 @@ test('fonts are serialized by VM', t => { } ], 'font is in vm.serializeAssets()'); - const notZippedProject = vm.saveProjectSb3DontZip(); + const notZippedProject = await vm.saveProjectSb3DontZip(); t.equal( notZippedProject['94263e4d553bcec128704e354b659526.ttf'], fontAsset.data, diff --git a/test/integration/tw_lazy.js b/test/integration/tw_lazy.js new file mode 100644 index 00000000000..7bae866588a --- /dev/null +++ b/test/integration/tw_lazy.js @@ -0,0 +1,239 @@ +const {test} = require('tap'); +const path = require('path'); +const fs = require('fs'); +const nodeCrypto = require('crypto'); +const JSZip = require('@turbowarp/jszip'); +const VM = require('../../src/virtual-machine'); +const FakeRenderer = require('../fixtures/fake-renderer'); +const makeTestStorage = require('../fixtures/make-test-storage'); +const LazySprite = require('../../src/sprites/tw-lazy-sprite'); + +test('lazy loaded sprite inside a zip', t => { + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); + + const loadedMd5Sums = new Set(); + const renderer = new FakeRenderer(); + renderer.createSVGSkin = function (svgData) { + const md5sum = nodeCrypto + .createHash('md5') + .update(svgData) + .digest('hex'); + + loadedMd5Sums.add(md5sum); + return this._nextSkinId++; + }; + + vm.attachRenderer(renderer); + vm.attachStorage(makeTestStorage()); + + vm.loadProject(fixture).then(() => { + t.equal(vm.runtime.targets.length, 1); + + t.equal(vm.runtime.lazySprites.length, 1); + const lazySprite = vm.runtime.lazySprites[0]; + t.equal(lazySprite.name, 'Sprite1'); + + t.equal(lazySprite.object.name, 'Sprite1'); + t.not(lazySprite.zip, null); + + t.notOk(loadedMd5Sums.has('927d672925e7b99f7813735c484c6922')); + + lazySprite.load().then(target => { + // Ensure sprite pointer matches + t.equal(target.sprite, lazySprite); + + // Make sure costume got passed to renderer + t.ok(loadedMd5Sums.has('927d672925e7b99f7813735c484c6922')); + + // Make sure various properties from JSON got copied + t.equal(target.getName(), 'Sprite1'); + t.equal(target.x, 10); + t.equal(target.y, 20); + t.equal(target.direction, 95); + t.equal(target.size, 101); + t.equal(target.draggable, true); + + t.end(); + }); + }); +}); + +test('isLazy === true', t => { + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); + vm.loadProject(fixture).then(() => { + const lazySprite = vm.runtime.lazySprites[0]; + t.equal(lazySprite.isLazy, true); + t.end(); + }); +}); + +test('unload before load finishes', t => { + const vm = new VM(); + const renderer = new FakeRenderer(); + vm.attachRenderer(renderer); + vm.attachStorage(makeTestStorage()); + + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); + vm.loadProject(fixture).then(() => { + const lazySprite = vm.runtime.lazySprites[0]; + + lazySprite.load().then(target => { + t.equal(target, null); + t.end(); + }); + lazySprite.unload(); + }); +}); + +test('eagerly imports extensions used only inside lazy sprite', t => { + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-pen-used-only-in-lazy-sprite.sb3')); + vm.loadProject(fixture).then(() => { + // Make sure pen extension got loaded + t.equal(vm.runtime._blockInfo[0].id, 'pen'); + + // And make sure that the sprite actually loads. + const lazySprite = vm.runtime.lazySprites[0]; + lazySprite.load().then(target => { + t.equal(target.getName(), 'Sprite1'); + t.end(); + }); + }); +}); + +test('invalid LazySprite.load() state transitions', t => { + t.plan(4); + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); + vm.loadProject(fixture).then(() => { + const lazySprite = vm.runtime.lazySprites[0]; + + // This load runs first, should succeed + lazySprite.load().then(target => { + t.equal(target.getName(), 'Sprite1'); + + // Third load. Should fail. + lazySprite.load().catch(err => { + t.equal(err.message, 'Unknown state transition loaded -> loading'); + + // Mock the error state. load() should fail. + lazySprite.state = LazySprite.State.ERROR; + lazySprite.load().catch(err2 => { + t.equal(err2.message, 'Unknown state transition error -> loading'); + t.end(); + }); + }); + }); + + // Second load. Should fail. + lazySprite.load().catch(err => { + t.equal(err.message, 'Unknown state transition loading -> loading'); + }); + }); +}); + +test('LazySprite.load() handles error', t => { + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); + vm.loadProject(fixture).then(() => { + const lazySprite = vm.runtime.lazySprites[0]; + lazySprite.createClone = () => { + throw new Error('Simulated error to test error handling'); + }; + + lazySprite.load().catch(err => { + // Make sure it is the expected simulated error, not a real error + t.equal(err.message, 'Simulated error to test error handling'); + + t.equal(lazySprite.state, LazySprite.State.ERROR); + t.end(); + }); + }); +}); + +test('lazy sprites removed on dispose', t => { + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); + vm.loadProject(fixture).then(() => { + t.equal(vm.runtime.lazySprites.length, 1); + vm.runtime.dispose(); + t.equal(vm.runtime.lazySprites.length, 0); + t.end(); + }); +}); + +test('dispose cancels current load operations', t => { + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); + vm.loadProject(fixture).then(() => { + const lazySprite = vm.runtime.lazySprites[0]; + lazySprite.load().then(target => { + t.equal(target, null); + t.end(); + }); + vm.runtime.dispose(); + }); +}); + +test('sb2 has no lazy sprites', t => { + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/default.sb2')); + vm.loadProject(fixture).then(() => { + t.equal(vm.runtime.lazySprites.length, 0); + t.end(); + }); +}); + +for (const load of [true, false]) { + test(`export lazy sprites ${load ? 'after' : 'before'} loading`, t => { + const vm = new VM(); + vm.attachStorage(makeTestStorage()); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); + + vm.loadProject(fixture).then(async () => { + if (load) { + await vm.runtime.loadLazySprites(['Sprite1']); + } + + const buffer = await vm.saveProjectSb3('arraybuffer'); + const zip = await JSZip.loadAsync(buffer); + const json = JSON.parse(await zip.file('project.json').async('text')); + + // Surface-level checks + t.equal(json.targets.length, 2); + t.notOk(Object.prototype.hasOwnProperty.call(json.targets[0], 'lazy')); + t.equal(json.targets[1].name, 'Sprite1'); + t.equal(json.targets[1].lazy, true); + + // Expect exact equality of target JSON + const fixtureZip = await JSZip.loadAsync(fixture); + const fixtureJSON = JSON.parse(await fixtureZip.file('project.json').async('text')); + + delete json.targets[1].targetPaneOrder; + delete fixtureJSON.targets[1].targetPaneOrder; + delete json.targets[1].layerOrder; + delete fixtureJSON.targets[1].layerOrder; + + t.same(json.targets[1], fixtureJSON.targets[1]); + + // Check for lazy loaded sprite's costume existing + t.not(zip.file('927d672925e7b99f7813735c484c6922.svg'), null); + + t.end(); + }); + }); +} + +test('lazy sprite is not lazy when exported individually', t => { + const vm = new VM(); + const fixture = fs.readFileSync(path.join(__dirname, '../fixtures/tw-lazy-simple.sb3')); + vm.loadProject(fixture).then(() => { + vm.runtime.loadLazySprites(['Sprite1']).then(([target]) => { + const json = JSON.parse(vm.toJSON(target.id)); + t.notOk(Object.prototype.hasOwnProperty.call(json, 'lazy')); + t.end(); + }); + }); +}); diff --git a/test/integration/tw_save_project_sb3.js b/test/integration/tw_save_project_sb3.js index 60306e334d7..437bdb7c1d6 100644 --- a/test/integration/tw_save_project_sb3.js +++ b/test/integration/tw_save_project_sb3.js @@ -42,7 +42,7 @@ test('saveProjectSb3Stream', async t => { await vm.loadProject(fixture); let receivedDataEvent = false; - const stream = vm.saveProjectSb3Stream(); + const stream = await vm.saveProjectSb3Stream(); stream.on('data', data => { if (receivedDataEvent) { return; @@ -54,7 +54,7 @@ test('saveProjectSb3Stream', async t => { const buffer = await stream.accumulate(); t.type(buffer, ArrayBuffer); - const stream2 = vm.saveProjectSb3Stream('uint8array'); + const stream2 = await vm.saveProjectSb3Stream('uint8array'); const uint8array = await stream2.accumulate(); t.type(uint8array, Uint8Array); @@ -71,7 +71,7 @@ test('saveProjectSb3DontZip', async t => { vm.attachStorage(makeTestStorage()); await vm.loadProject(fixture); - const map = vm.saveProjectSb3DontZip(); + const map = await vm.saveProjectSb3DontZip(); t.equal(map['project.json'][0], '{'.charCodeAt(0)); t.equal(map['d9c625ae1996b615a146ac2a7dbe74d7.svg'].byteLength, 691); t.equal(map['cd21514d0531fdffb22204e0ec5ed84a.svg'].byteLength, 202); diff --git a/test/integration/tw_serialize_asset_order.js b/test/integration/tw_serialize_asset_order.js index 8d296ac2a85..586a65f4397 100644 --- a/test/integration/tw_serialize_asset_order.js +++ b/test/integration/tw_serialize_asset_order.js @@ -12,17 +12,18 @@ test('serializeAssets serialization order', t => { const vm = new VM(); vm.attachStorage(makeTestStorage()); vm.loadProject(fixture).then(() => { - const assets = vm.serializeAssets(); - for (let i = 0; i < assets.length; i++) { - // won't deduplicate assets, so expecting 8 costumes, 7 sounds - // 8 costumes, 6 sounds - if (i < 8) { - t.ok(assets[i].fileName.endsWith('.svg'), `file ${i + 1} is costume`); - } else { - t.ok(assets[i].fileName.endsWith('.wav'), `file ${i + 1} is sound`); + vm.serializeAssets().then(assets => { + for (let i = 0; i < assets.length; i++) { + // won't deduplicate assets, so expecting 8 costumes, 7 sounds + // 8 costumes, 6 sounds + if (i < 8) { + t.ok(assets[i].fileName.endsWith('.svg'), `file ${i + 1} is costume`); + } else { + t.ok(assets[i].fileName.endsWith('.wav'), `file ${i + 1} is sound`); + } } - } - t.end(); + t.end(); + }); }); }); @@ -79,20 +80,21 @@ test('saveProjectSb3DontZip', t => { const vm = new VM(); vm.attachStorage(makeTestStorage()); vm.loadProject(fixture).then(() => { - const exported = vm.saveProjectSb3DontZip(); - const files = Object.keys(exported); - - for (let i = 0; i < files.length; i++) { - // 6 costumes, 6 sounds - if (i === 0) { - t.equal(files[i], 'project.json', 'first file is project.json'); - } else if (i < 7) { - t.ok(files[i].endsWith('.svg'), `file ${i + 1} is costume`); - } else { - t.ok(files[i].endsWith('.wav'), `file ${i + 1} is sound`); + vm.saveProjectSb3DontZip().then(exported => { + const files = Object.keys(exported); + + for (let i = 0; i < files.length; i++) { + // 6 costumes, 6 sounds + if (i === 0) { + t.equal(files[i], 'project.json', 'first file is project.json'); + } else if (i < 7) { + t.ok(files[i].endsWith('.svg'), `file ${i + 1} is costume`); + } else { + t.ok(files[i].endsWith('.wav'), `file ${i + 1} is sound`); + } } - } - - t.end(); + + t.end(); + }); }); }); diff --git a/test/integration/tw_sprite.js b/test/integration/tw_sprite.js new file mode 100644 index 00000000000..918195602a0 --- /dev/null +++ b/test/integration/tw_sprite.js @@ -0,0 +1,9 @@ +const {test} = require('tap'); +const Sprite = require('../../src/sprites/sprite'); +const Runtime = require('../../src/engine/runtime'); + +test('isLazy === false', t => { + const sprite = new Sprite(null, new Runtime()); + t.equal(sprite.isLazy, false); + t.end(); +}); diff --git a/test/unit/tw_cancellable_mutex.js b/test/unit/tw_cancellable_mutex.js new file mode 100644 index 00000000000..3f3639666cd --- /dev/null +++ b/test/unit/tw_cancellable_mutex.js @@ -0,0 +1,98 @@ +const {test} = require('tap'); +const CancellableMutex = require('../../src/util/tw-cancellable-mutex'); + +test('basic queing', t => { + const mutex = new CancellableMutex(); + const events = []; + + // Tests long resolved promise. + mutex.do(() => new Promise(resolve => { + setTimeout(() => resolve(5), 100); + })).then(value => { + // Make sure resolved value passes through transparently + t.equal(value, 5); + events.push(1); + }); + + // Tests long rejected promise. + mutex.do(() => new Promise((resolve, reject) => { + setTimeout(() => reject(new Error('Test error 1')), 100); + })).catch(error => { + t.equal(error.message, 'Test error 1'); + events.push(2); + }); + + // Tests instantly-resolving resolved promise. + mutex.do(() => new Promise(resolve => { + resolve(10); + })).then(value => { + t.equal(value, 10); + events.push(3); + }); + + // Tests instantly-resolving rejected promise. + mutex.do(() => new Promise((resolve, reject) => { + reject(new Error('Test error 2')); + })).catch(error => { + t.equal(error.message, 'Test error 2'); + events.push(4); + }); + + // Tests instantly-returning sync function. + mutex.do(() => 15).then(value => { + t.equal(value, 15); + events.push(5); + }); + + // Tests instantly-throwing sync function. + mutex.do(() => { + throw new Error('Test error 3'); + }).catch(error => { + t.equal(error.message, 'Test error 3'); + events.push(6); + }); + + mutex.do(() => new Promise(resolve => { + setTimeout(() => { + resolve(); + + // At this point the queue of operations is now empty. Make sure it can + // resume operations from this state. + setTimeout(() => { + mutex.do(() => new Promise(resolve2 => { + resolve2(); + })).then(() => { + events.push(8); + t.same(events, [1, 2, 3, 4, 5, 6, 7, 8]); + t.end(); + }); + }); + }, 50); + })).then(() => { + events.push(7); + }); +}); + +test('cancellation', t => { + const mutex = new CancellableMutex(); + + // Start operation that should never end and then grab its cancel checker. + let isCancelled = null; + mutex.do(_isCancelled => new Promise(() => { + isCancelled = _isCancelled; + })); + t.equal(isCancelled(), false); + + // This operation should never run. + mutex.do(() => new Promise(() => { + t.fail(); + })); + + // After cancelling, existing operation should be able to see that, and queue should be cleared. + mutex.cancel(); + t.equal(isCancelled(), true); + + mutex.do(() => new Promise(() => { + t.end(); + })); +}); diff --git a/test/unit/virtual-machine.js b/test/unit/virtual-machine.js index 36284708856..cf0c7336a8d 100644 --- a/test/unit/virtual-machine.js +++ b/test/unit/virtual-machine.js @@ -62,7 +62,7 @@ test('addSprite throws on invalid string', t => { const vm = new VirtualMachine(); vm.addSprite('this is not a sprite') .catch(e => { - t.equal(e.startsWith('Sprite Upload Error:'), true); + t.equal(e.startsWith('SyntaxError:'), true); t.end(); }); });