const crypto = require('crypto'); const {test} = require('node:test'); const assert = require('node:assert'); const JSZip = require('@turbowarp/jszip'); const VirtualMachine = require('../../src/virtual-machine'); const FakeRenderer = require('../fixtures/fake-renderer'); const makeTestStorage = require('../fixtures/make-test-storage'); const md5 = buffer => crypto.createHash('md5') .update(buffer) .digest('hex'); const SVG = ''; const SVG_MD5 = md5(SVG); const WAV = 'RIFF fake wav data'; const WAV_MD5 = md5(WAV); const makeCostume = name => ({ name, assetId: SVG_MD5, md5ext: `${SVG_MD5}.svg`, dataFormat: 'svg', bitmapResolution: 1, rotationCenterX: 5, rotationCenterY: 5 }); const makeSound = name => ({ name, assetId: WAV_MD5, md5ext: `${WAV_MD5}.wav`, dataFormat: 'wav', format: '', rate: 44100, sampleCount: 100 }); const makeSprite = (name, extra) => Object.assign({ isStage: false, name, variables: {}, lists: {}, broadcasts: {}, blocks: {}, comments: {}, currentCostume: 0, costumes: [], sounds: [], volume: 100, visible: true, x: 0, y: 0, size: 100, direction: 90, draggable: false, rotationStyle: 'all around' }, extra); const makeProjectZip = () => { const projectJson = { targets: [ makeSprite('Stage', { isStage: true, costumes: [makeCostume('backdrop1')], layerOrder: 0 }), makeSprite('Sprite1', { costumes: [makeCostume('costume1'), makeCostume('costume2')], sounds: [makeSound('sound1'), makeSound('sound2')], layerOrder: 1 }) ], monitors: [], extensions: [], meta: { semver: '3.0.0', vm: '0.2.0', agent: '' } }; const zip = new JSZip(); zip.file('project.json', JSON.stringify(projectJson)); zip.file(`${SVG_MD5}.svg`, SVG); zip.file(`${WAV_MD5}.wav`, WAV); return zip.generateAsync({type: 'arraybuffer'}); }; class FakeSoundBank { constructor () { this.soundPlayers = {}; this.removedSounds = []; this.disposed = false; } addSoundPlayer (soundPlayer) { this.soundPlayers[soundPlayer.id] = soundPlayer; } removeSoundPlayer (soundId) { this.removedSounds.push(soundId); delete this.soundPlayers[soundId]; } stopAllSounds () {} setEffects () {} dispose () { this.disposed = true; } } class FakeAudioEngine { constructor () { this.decodedSounds = 0; } createBank () { return new FakeSoundBank(); } decodeSoundPlayer () { this.decodedSounds++; return Promise.resolve({ id: `player${this.decodedSounds}`, buffer: { sampleRate: 44100, length: 100 }, connect: () => {}, stop: () => {}, dispose: () => {} }); } } const makeVM = async () => { const vm = new VirtualMachine(); const renderer = new FakeRenderer(); renderer.destroyedSkins = []; renderer.destroySkin = skinId => renderer.destroyedSkins.push(skinId); renderer.destroyDrawable = () => {}; vm.attachRenderer(renderer); vm.attachAudioEngine(new FakeAudioEngine()); vm.attachStorage(makeTestStorage()); await vm.loadProject(await makeProjectZip()); return vm; }; test('deleting a costume destroys its skin; restoring re-creates it', async () => { const vm = await makeVM(); const target = vm.runtime.getSpriteTargetByName('Sprite1'); const sprite = target.sprite; vm.editingTarget = target; const clone = target.makeClone(); vm.runtime.addTarget(clone); clone.setCostume(1); const costume = sprite.costumes[1]; const skinId = costume.skinId; assert.strictEqual(typeof skinId, 'number', 'costume has a skin'); const restore = vm.deleteCostume(1); assert.deepEqual(sprite.costumes.map(c => c.name), ['costume1'], 'costume removed'); assert.ok(vm.runtime.renderer.destroyedSkins.includes(skinId), 'skin destroyed'); assert.strictEqual(clone.currentCostume, 0, 'clone switched costume'); await restore(); assert.deepEqual(sprite.costumes.map(c => c.name), ['costume1', 'costume2'], 'costume restored'); assert.strictEqual(typeof sprite.costumes[1].skinId, 'number', 'restored costume has a new skin'); }); test('deleting a sound disposes its player; restoring re-decodes it', async () => { const vm = await makeVM(); const target = vm.runtime.getSpriteTargetByName('Sprite1'); const sprite = target.sprite; vm.editingTarget = target; const sound = sprite.sounds[0]; const soundId = sound.soundId; assert.strictEqual(typeof soundId, 'string', 'sound has a player'); const restore = vm.deleteSound(0); assert.deepEqual(sprite.sounds.map(s => s.name), ['sound2'], 'sound removed'); assert.deepEqual(sprite.soundBank.removedSounds, [soundId], 'player removed from the bank'); await restore(); assert.deepEqual(sprite.sounds.map(s => s.name), ['sound2', 'sound1'], 'sound restored'); const restored = sprite.sounds[1]; assert.strictEqual(typeof restored.soundId, 'string', 'restored sound has a player'); assert.ok(sprite.soundBank.soundPlayers[restored.soundId], 'player is in the bank'); }); test('reordering sounds does not dispose their players', async () => { const vm = await makeVM(); const target = vm.runtime.getSpriteTargetByName('Sprite1'); const sprite = target.sprite; target.reorderSound(0, 1); assert.deepEqual(sprite.sounds.map(s => s.name), ['sound2', 'sound1'], 'sounds reordered'); assert.deepEqual(sprite.soundBank.removedSounds, [], 'no players disposed'); }); test('deleting a sprite frees all of its resources, including with clones', async () => { const vm = await makeVM(); const target = vm.runtime.getSpriteTargetByName('Sprite1'); const sprite = target.sprite; vm.runtime.addTarget(target.makeClone()); vm.runtime.addTarget(target.makeClone()); assert.strictEqual(sprite.clones.length, 3, 'sprite has clones'); const skinIds = sprite.costumes.map(c => c.skinId); const soundBank = sprite.soundBank; vm.deleteSprite(target.id); assert.ok(vm.runtime.targets.every(t2 => t2.sprite !== sprite), 'all clones are disposed'); for (const skinId of skinIds) { assert.ok(vm.runtime.renderer.destroyedSkins.includes(skinId), `skin ${skinId} destroyed`); } assert.ok(soundBank.disposed, 'sound bank disposed'); }); test('loading a new project frees the resources of the previous one', async () => { const vm = await makeVM(); const skinIds = []; for (const target of vm.runtime.targets) { for (const costume of target.sprite.costumes) { skinIds.push(costume.skinId); } } const soundBank = vm.runtime.getSpriteTargetByName('Sprite1').sprite.soundBank; assert.ok(skinIds.length >= 3, 'previous project had skins'); await vm.loadProject(await makeProjectZip()); for (const skinId of skinIds) { assert.ok(vm.runtime.renderer.destroyedSkins.includes(skinId), `skin ${skinId} destroyed`); } assert.ok(soundBank.disposed, 'sound bank disposed'); }); test('restoring a costume that has no md5', async () => { const vm = await makeVM(); const target = vm.runtime.getSpriteTargetByName('Sprite1'); const sprite = target.sprite; vm.editingTarget = target; await vm.addCostume(`${SVG_MD5}.svg`, { name: 'library', asset: sprite.costumes[0].asset, bitmapResolution: 1, rotationCenterX: 5, rotationCenterY: 5 }); assert.ok(!sprite.costumes[2].md5, 'costume has no md5'); const restore = vm.deleteCostume(2); await restore(); assert.deepEqual(sprite.costumes.map(c => c.name), ['costume1', 'costume2', 'library'], 'costume restored'); }); test('restoring after the sprite is deleted does not leak', async () => { const vm = await makeVM(); const target = vm.runtime.getSpriteTargetByName('Sprite1'); const sprite = target.sprite; vm.editingTarget = target; const deletedCostume = sprite.costumes[1]; const oldSkinId = deletedCostume.skinId; const restoreCostume = vm.deleteCostume(1); const deletedSound = sprite.sounds[1]; const restoreSound = vm.deleteSound(1); const costumePromise = restoreCostume(); const soundPromise = restoreSound(); const soundBank = sprite.soundBank; vm.deleteSprite(target.id); await costumePromise; await soundPromise; assert.deepEqual(sprite.costumes, [], 'costume not added to deleted sprite'); assert.deepEqual(sprite.sounds, [], 'sound not added to deleted sprite'); assert.notStrictEqual(deletedCostume.skinId, oldSkinId, 'restore created a new skin'); assert.ok(vm.runtime.renderer.destroyedSkins.includes(deletedCostume.skinId), 'new skin destroyed'); assert.ok(soundBank.removedSounds.includes(deletedSound.soundId), 'restored player removed from bank'); }); test('deleting the editing sprite while it has clones', async () => { const vm = await makeVM(); const stage = vm.runtime.getTargetForStage(); const target = vm.runtime.getSpriteTargetByName('Sprite1'); vm.setEditingTarget(target.id); vm.runtime.addTarget(target.makeClone()); vm.deleteSprite(target.id); assert.strictEqual(vm.editingTarget, stage, 'editing target moved to the stage'); }); test('deleting a sprite does not modify previously returned costume or sound lists', async () => { const vm = await makeVM(); const target = vm.runtime.getSpriteTargetByName('Sprite1'); const costumes = target.getCostumes(); const sounds = target.getSounds(); vm.deleteSprite(target.id); assert.strictEqual(costumes.length, 2); assert.strictEqual(sounds.length, 2); });