const fs = require('fs'); const path = require('path'); const {test} = require('node:test'); const assert = require('node:assert'); const _makeTestStorage = require('../fixtures/make-test-storage'); const Runtime = require('../../src/engine/runtime'); const VirtualMachine = require('../../src/virtual-machine'); const JSZip = require('@turbowarp/jszip'); const emptyProjectFixture = path.join(__dirname, '..', 'fixtures', 'tw-empty-project.sb3'); const makeTestStorage = () => { const storage = _makeTestStorage(); storage.DataFormat.TTF = 'ttf'; storage.AssetType.Font = { contentType: 'font/ttf', name: 'Font', runtimeFormat: storage.DataFormat.TTF, immutable: true }; return storage; }; test('isValidSystemFont', () => { const {fontManager} = new Runtime(); assert.ok(fontManager.isValidSystemFont('Roboto')); assert.ok(fontManager.isValidSystemFont('sans-serif')); assert.ok(fontManager.isValidSystemFont('helvetica neue')); assert.ok(!fontManager.isValidSystemFont('Roboto;Bold')); assert.ok(!fontManager.isValidSystemFont('Arial, sans-serif')); fontManager.restrictFont('Roboto'); assert.ok(fontManager.isValidSystemFont('Roboto')); }); test('isValidCustomFont', () => { const {fontManager} = new Runtime(); assert.ok(fontManager.isValidCustomFont('Roboto')); assert.ok(fontManager.isValidCustomFont('sans-serif')); assert.ok(fontManager.isValidCustomFont('helvetica neue')); assert.ok(!fontManager.isValidCustomFont('Roboto;Bold')); assert.ok(!fontManager.isValidCustomFont('Arial, sans-serif')); fontManager.restrictFont('Roboto'); assert.ok(!fontManager.isValidCustomFont('Roboto')); assert.ok(!fontManager.isValidCustomFont('roboto')); assert.ok(!fontManager.isValidCustomFont('ROBOTO')); assert.ok(fontManager.isValidCustomFont('Roboto ')); assert.ok(fontManager.isValidCustomFont('Roboto2')); assert.ok(fontManager.isValidCustomFont('sans-serif')); assert.ok(fontManager.isValidCustomFont('helvetica neue')); assert.ok(!fontManager.isValidCustomFont('Roboto;Bold')); assert.ok(!fontManager.isValidCustomFont('Arial, sans-serif')); }); test('getSafeSystemFont', () => { const {fontManager} = new Runtime(); assert.strictEqual(fontManager.getUnusedSystemFont('Arial'), 'Arial'); fontManager.addSystemFont('Arial', 'sans-serif'); assert.strictEqual(fontManager.getUnusedSystemFont('Arial'), 'Arial2'); assert.strictEqual(fontManager.getUnusedSystemFont('Weird123!@"<>?'), 'Weird123'); fontManager.restrictFont('Restricted'); assert.strictEqual(fontManager.getUnusedSystemFont('Restricted'), 'Restricted'); }); test('getSafeCustomFont', () => { const {fontManager} = new Runtime(); assert.strictEqual(fontManager.getUnusedCustomFont('Arial'), 'Arial'); fontManager.addSystemFont('Arial', 'sans-serif'); assert.strictEqual(fontManager.getUnusedCustomFont('Arial'), 'Arial2'); assert.strictEqual(fontManager.getUnusedCustomFont('Weird123!@"<>?'), 'Weird123'); fontManager.restrictFont('Restricted'); assert.strictEqual(fontManager.getUnusedCustomFont('Restricted'), 'Restricted2'); assert.strictEqual(fontManager.getUnusedCustomFont('restricted'), 'restricted2'); assert.strictEqual(fontManager.getUnusedCustomFont(' restricted'), ' restricted'); fontManager.restrictFont('Restricted2'); assert.strictEqual(fontManager.getUnusedCustomFont('Restricted'), 'Restricted3'); assert.strictEqual(fontManager.getUnusedCustomFont('restricted'), 'restricted3'); fontManager.addSystemFont('Restricted3'); assert.strictEqual(fontManager.getUnusedCustomFont('Restricted'), 'Restricted4'); assert.strictEqual(fontManager.getUnusedCustomFont('restricted'), 'restricted4'); }); test('system font', () => { const mockRenderer = { setLayerGroupOrdering: () => {}, setCustomFonts: () => { assert.fail('Should not call renderer.setCustomFonts()'); } }; const rt = new Runtime(); rt.attachRenderer(mockRenderer); const {fontManager} = rt; let changed = false; fontManager.on('change', () => { changed = true; }); fontManager.addSystemFont('Noto Sans Mono', 'monospace'); assert.ok(changed, 'addSystemFont() emits change'); assert.ok(fontManager.hasFont('Noto Sans Mono'), 'updated hasFont()'); assert.ok(fontManager.hasFont('noto sans mono'), 'updated hasFont() case insensitively'); assert.deepEqual(fontManager.getFonts(), [ { system: true, name: 'Noto Sans Mono', family: '"Noto Sans Mono", monospace', data: null, format: null } ]); assert.deepEqual(fontManager.serializeJSON(), [ { system: true, family: 'Noto Sans Mono', fallback: 'monospace' } ]); assert.deepEqual(fontManager.serializeAssets(), []); changed = false; fontManager.addSystemFont('Lobster', 'fantasy, sans-serif'); assert.ok(changed, 'addSystemFont() emits change'); assert.ok(fontManager.hasFont('Lobster'), 'updated hasFont()'); assert.deepEqual(fontManager.getFonts(), [ { system: true, name: 'Noto Sans Mono', family: '"Noto Sans Mono", monospace', data: null, format: null }, { system: true, name: 'Lobster', family: '"Lobster", fantasy, sans-serif', data: null, format: null } ]); assert.deepEqual(fontManager.serializeJSON(), [ { system: true, family: 'Noto Sans Mono', fallback: 'monospace' }, { system: true, family: 'Lobster', fallback: 'fantasy, sans-serif' } ]); assert.deepEqual(fontManager.serializeAssets(), []); }); test('system font validation', () => { const {fontManager} = new Runtime(); fontManager.restrictFont('Restricted'); assert.throws(() => { fontManager.addCustomFont(';', 'monospace'); }); assert.throws(() => { fontManager.addCustomFont('Restricted', 'monospace'); }); }); test('clear', () => { const setCustomFontsCalls = []; const mockRenderer = { setLayerGroupOrdering: () => {}, setCustomFonts: fonts => { setCustomFontsCalls.push(fonts); } }; const rt = new Runtime(); rt.attachStorage(makeTestStorage()); rt.attachRenderer(mockRenderer); const {fontManager, storage} = rt; fontManager.addSystemFont('Arial', 'sans-serif'); assert.strictEqual(fontManager.getFonts().length, 1); let changed = false; fontManager.on('change', () => { changed = true; }); fontManager.clear(); assert.ok(changed, 'clear() emits change'); assert.strictEqual(fontManager.getFonts().length, 0, 'removed font'); assert.deepEqual(setCustomFontsCalls, [], 'clear() does not call setCustomFonts() if only system fonts'); fontManager.addCustomFont('Wingdings', 'monospace', storage.createAsset( storage.AssetType.Font, 'ttf', new Uint8Array([11, 12, 13]), null, true )); changed = false; setCustomFontsCalls.length = 0; fontManager.clear(); assert.ok(changed, 'clear() emits change'); assert.deepEqual(setCustomFontsCalls, [{}], 'clear() clears setCustomFonts()'); }); test('custom fonts', () => { const setCustomFontsCalls = []; const mockRenderer = { setLayerGroupOrdering: () => {}, setCustomFonts: customFonts => { setCustomFontsCalls.push(customFonts); } }; const rt = new Runtime(); rt.attachRenderer(mockRenderer); rt.attachStorage(makeTestStorage()); const {fontManager, storage} = rt; let changed = false; fontManager.on('change', () => { changed = true; }); fontManager.addCustomFont('Arial', 'sans-serif', storage.createAsset( storage.AssetType.Font, storage.DataFormat.TTF, new Uint8Array([1, 2, 3]), null, true )); assert.ok(changed, 'addCustomFont() emits change'); assert.ok(fontManager.hasFont('Arial'), 'updated hasFont()'); assert.deepEqual(fontManager.getFonts(), [ { system: false, name: 'Arial', family: '"Arial", sans-serif', data: new Uint8Array([1, 2, 3]), format: 'ttf' } ]); assert.deepEqual(fontManager.serializeJSON(), [ { system: false, family: 'Arial', fallback: 'sans-serif', md5ext: '5289df737df57326fcdd22597afb1fac.ttf' } ]); assert.deepEqual(setCustomFontsCalls, [ { // eslint-disable-next-line max-len '"Arial", sans-serif': '@font-face { font-family: "Arial"; src: url("data:font/ttf;base64,AQID"); }' } ]); const assets = fontManager.serializeAssets(); assert.strictEqual(assets.length, 1); assert.deepEqual(assets[0].data, new Uint8Array([1, 2, 3])); changed = false; setCustomFontsCalls.length = 0; const asset = storage.createAsset( storage.AssetType.Font, 'woff2', new Uint8Array([4, 5, 6]), null, true ); fontManager.addCustomFont('Comic Sans MS', 'serif', asset); assert.ok(changed, 'addCustomFont() emits change'); assert.ok(fontManager.hasFont('Comic Sans MS'), 'updated hasFont()'); assert.deepEqual(fontManager.getFonts(), [ { system: false, name: 'Arial', family: '"Arial", sans-serif', data: new Uint8Array([1, 2, 3]), format: 'ttf' }, { system: false, name: 'Comic Sans MS', family: '"Comic Sans MS", serif', data: new Uint8Array([4, 5, 6]), format: 'woff2' } ]); assert.deepEqual(fontManager.serializeJSON(), [ { system: false, family: 'Arial', fallback: 'sans-serif', md5ext: '5289df737df57326fcdd22597afb1fac.ttf' }, { system: false, family: 'Comic Sans MS', fallback: 'serif', md5ext: 'b4a3ba90641372b4e4eaa841a5a400ec.woff2' } ]); assert.deepEqual(setCustomFontsCalls, [ { // eslint-disable-next-line max-len '"Arial", sans-serif': '@font-face { font-family: "Arial"; src: url("data:font/ttf;base64,AQID"); }', // eslint-disable-next-line max-len '"Comic Sans MS", serif': '@font-face { font-family: "Comic Sans MS"; src: url("data:font/ttf;base64,BAUG"); }' } ]); const assets2 = fontManager.serializeAssets(); assert.strictEqual(assets2.length, 2); assert.strictEqual(assets2[0], assets[0]); assert.strictEqual(assets2[1], asset); }); test('custom font validation', () => { const rt = new Runtime(); rt.attachStorage(makeTestStorage()); const {fontManager, storage} = rt; assert.throws(() => { fontManager.addCustomFont('family;', 'sans-serif', storage.createAsset( storage.DataFormat.Font, storage.DataFormat.TTF, new Uint8Array([1]), null, true )); }); }); test('deleteFont', () => { const rt = new Runtime(); rt.attachStorage(makeTestStorage()); const {fontManager, storage} = rt; fontManager.addSystemFont('Liberation Mono', 'monospace'); fontManager.addCustomFont('Noto Sans Mono', 'monospace', storage.createAsset( storage.AssetType.Font, storage.DataFormat.TTF, new Uint8Array([17, 18, 19]), null, true )); assert.ok(fontManager.hasFont('Liberation Mono'), 'has font initially'); assert.ok(fontManager.hasFont('Noto Sans Mono'), 'has font initially'); let changed = false; fontManager.on('change', () => { changed = true; }); const setCustomFontsCalls = []; const mockRenderer = { setLayerGroupOrdering: () => {}, setCustomFonts: customFonts => { setCustomFontsCalls.push(customFonts); } }; rt.attachRenderer(mockRenderer); fontManager.deleteFont(1); assert.ok(changed, 'deleteFont() emits change'); assert.ok(fontManager.hasFont('Liberation Mono'), 'kept font'); assert.ok(!fontManager.hasFont('Noto Sans Mono'), 'deleted font'); assert.deepEqual(setCustomFontsCalls, [{}], 'called setCustomFonts() after deleting non-system font'); assert.deepEqual(fontManager.getFonts(), [ { system: true, name: 'Liberation Mono', family: '"Liberation Mono", monospace', data: null, format: null } ], 'updated getFonts() after deleting'); changed = false; fontManager.deleteFont(0); assert.ok(changed, 'deleteFont() emits change'); assert.deepEqual(setCustomFontsCalls, [{}], 'did not call setCustomFonts() again after deleting system font'); assert.deepEqual(fontManager.getFonts(), [], 'updated getFonts() after deleting'); }); test('fonts are serialized by VM', () => { const vm = new VirtualMachine(); vm.attachStorage(makeTestStorage()); const {storage, fontManager} = vm.runtime; fontManager.addSystemFont('DejaVu Sans', 'sans-serif'); const fontAsset = storage.createAsset( storage.AssetType.Font, storage.DataFormat.TTF, new Uint8Array([10, 11, 12]), null, true ); fontManager.addCustomFont('Noto Color Emoji', 'emoji', fontAsset); const assets = vm.assets; assert.deepEqual(assets, [fontAsset], 'font is in vm.assets'); const serializedAssets = vm.serializeAssets(); assert.deepEqual(serializedAssets, [ { fileName: '94263e4d553bcec128704e354b659526.ttf', fileContent: new Uint8Array([10, 11, 12]) } ], 'font is in vm.serializeAssets()'); const notZippedProject = vm.saveProjectSb3DontZip(); assert.strictEqual( notZippedProject['94263e4d553bcec128704e354b659526.ttf'], fontAsset.data, 'font is in saveProjectSb3DontZip()' ); const projectJSON = JSON.parse(vm.toJSON()); assert.deepEqual(projectJSON.customFonts, [ { system: true, family: 'DejaVu Sans', fallback: 'sans-serif' }, { system: false, family: 'Noto Color Emoji', fallback: 'emoji', md5ext: '94263e4d553bcec128704e354b659526.ttf' } ], 'font is in vm.toJSON()'); }); test('does not serialize fonts if there are none', () => { const vm = new VirtualMachine(); const json = JSON.parse(vm.toJSON()); assert.ok(!('customFonts' in json)); }); test('serialization and deserialization roundtrip - project', async () => { const originalVM = new VirtualMachine(); originalVM.attachStorage(makeTestStorage()); const {storage, fontManager} = originalVM.runtime; await originalVM.loadProject(fs.readFileSync(emptyProjectFixture)); // Add our custom fonts here fontManager.addSystemFont('Ubuntu Mono', 'monospace'); const fontAsset = storage.createAsset( storage.AssetType.Font, storage.DataFormat.TTF, new Uint8Array([20, 21, 22, 23, 24]), null, true ); fontManager.addCustomFont('Inter', 'sans-serif', fontAsset); const projectSb3 = await originalVM.saveProjectSb3('arraybuffer'); const newVM = new VirtualMachine(); newVM.attachStorage(makeTestStorage()); const newFontManager = newVM.runtime.fontManager; newFontManager.addSystemFont('ShouldBeRemoved', 'sans-serif'); let changed = false; newFontManager.on('change', () => { changed = true; }); await newVM.loadProject(projectSb3); assert.ok(changed, 'loadProject() emits change'); assert.deepEqual(newFontManager.getFonts(), [ { system: true, name: 'Ubuntu Mono', family: '"Ubuntu Mono", monospace', data: null, format: null }, { system: false, name: 'Inter', family: '"Inter", sans-serif', data: new Uint8Array([20, 21, 22, 23, 24]), format: 'ttf' } ], 'preserved in getFonts()'); assert.deepEqual(newFontManager.serializeJSON(), [ { system: true, family: 'Ubuntu Mono', fallback: 'monospace' }, { system: false, family: 'Inter', fallback: 'sans-serif', md5ext: '316f84429ec778137b2f5c6f893c7e41.ttf' } ], 'preserved in serializeJSON()'); const assets = newFontManager.serializeAssets(); assert.strictEqual(assets.length, 1); assert.deepEqual( assets[0].data, new Uint8Array([20, 21, 22, 23, 24]), 'preserved in serializeAssets()' ); }); test('serialization and deserialization roundtrip - target', async () => { const originalVM = new VirtualMachine(); originalVM.attachStorage(makeTestStorage()); const {fontManager, storage} = originalVM.runtime; await originalVM.loadProject(fs.readFileSync(emptyProjectFixture)); // The fixture we use only contains a stage. We'll convert it to a sprite so we can // addSprite() it later. const sprite = originalVM.runtime.targets[0]; sprite.isStage = false; const noFontsJSON = JSON.parse(originalVM.toJSON(sprite.id)); assert.ok(!('customFonts' in noFontsJSON), 'does not serialize fonts in target if no fonts'); fontManager.addCustomFont('Noto Sans Traditional Chinese', 'sans-serif', storage.createAsset( storage.AssetType.Font, storage.DataFormat.TTF, new Uint8Array([97, 98, 99]), null, true )); fontManager.addSystemFont('FreeSans', 'sans-serif'); const spriteJSON = JSON.parse(originalVM.toJSON(sprite.id)); assert.deepEqual(spriteJSON.customFonts, [ { system: false, family: 'Noto Sans Traditional Chinese', fallback: 'sans-serif', md5ext: '900150983cd24fb0d6963f7d28e17f72.ttf' }, { system: true, family: 'FreeSans', fallback: 'sans-serif' } ], 'serializes custom fonts to target'); const exportedSprite = await originalVM.exportSprite(sprite.id, 'uint8array'); const newVM = new VirtualMachine(); newVM.attachStorage(makeTestStorage()); const newFontManager = newVM.runtime.fontManager; await newVM.loadProject(fs.readFileSync(emptyProjectFixture)); // The existing fonts should not be removed or overwritten newFontManager.addSystemFont('Liberation Sans', 'sans-serif'); newFontManager.addSystemFont('FreeSans', 'monospace'); let changed = false; newFontManager.on('change', () => { changed = true; }); await newVM.addSprite(exportedSprite); assert.ok(changed, 'addSprite() emits change'); assert.deepEqual(newFontManager.getFonts(), [ // Importing a sprite should not overwrite old fonts. { system: true, name: 'Liberation Sans', family: '"Liberation Sans", sans-serif', data: null, format: null }, { system: true, name: 'FreeSans', family: '"FreeSans", monospace', data: null, format: null }, { system: false, name: 'Noto Sans Traditional Chinese', family: '"Noto Sans Traditional Chinese", sans-serif', data: new Uint8Array([97, 98, 99]), format: 'ttf' } ], 'imported fonts from sprite'); }); test('deserializes ignores invalid fonts', async () => { const {fontManager} = new Runtime(); await fontManager.deserialize([ { system: true, family: ';} body { display: none; }', fallback: 'sans-serif' }, { system: true, family: 'Source Code Pro', fallback: 'monospace' } ], null, false); assert.strictEqual(fontManager.getFonts().length, 1); assert.strictEqual(fontManager.getFonts()[0].name, 'Source Code Pro'); }); test('restrict throws on invalid input', () => { const {fontManager} = new Runtime(); assert.throws(() => { fontManager.restrictFont('(#@*$'); }, { message: 'Invalid font' }); }); test('restrict removes existing fonts', () => { let setCustomFontsCalls = 0; const mockRenderer = { setLayerGroupOrdering: () => {}, setCustomFonts: () => { setCustomFontsCalls++; } }; const rt = new Runtime(); rt.attachRenderer(mockRenderer); rt.attachStorage(makeTestStorage()); const {fontManager, storage} = rt; let changeEvents = 0; fontManager.on('change', () => { changeEvents++; }); fontManager.addSystemFont('System Font', 'sans-serif'); fontManager.addCustomFont('Important Font', 'sans-serif', storage.createAsset( storage.AssetType.Font, 'ttf', new Uint8Array([11, 12, 13]), null, true )); fontManager.addCustomFont('Not Important Font', 'sans-serif', storage.createAsset( storage.AssetType.Font, 'ttf', new Uint8Array([11, 12, 13]), null, true )); assert.strictEqual(changeEvents, 3, 'sanity check'); assert.strictEqual(setCustomFontsCalls, 2, 'sanity check'); fontManager.restrictFont('Not Used'); assert.strictEqual(changeEvents, 3, 'does not emit change when unused font restricted'); assert.strictEqual(setCustomFontsCalls, 2, 'does not emit change when unused font restricted'); fontManager.restrictFont('System Font'); assert.strictEqual(changeEvents, 3, 'does not emit change when system font restricted'); assert.strictEqual(setCustomFontsCalls, 2, 'does not emit change when system font restricted'); fontManager.restrictFont('important font'); assert.strictEqual(changeEvents, 4, 'emits change when custom font restricted'); assert.strictEqual(setCustomFontsCalls, 3, 'emits change when custom font restricted'); assert.deepEqual(fontManager.getFonts().map(i => i.name), [ 'System Font', 'Not Important Font' ]); fontManager.restrictFont('Important Font'); assert.strictEqual(changeEvents, 4, 'does not emit change when restricted font restricted again'); assert.strictEqual(setCustomFontsCalls, 3, 'does not emit change when restricted font restricted again'); }); test('overriding existing fonts', () => { let setCustomFontsCalls = 0; const mockRenderer = { setLayerGroupOrdering: () => {}, setCustomFonts: () => { setCustomFontsCalls++; } }; const rt = new Runtime(); rt.attachRenderer(mockRenderer); rt.attachStorage(makeTestStorage()); const {fontManager, storage} = rt; let changeEvents = 0; fontManager.on('change', () => { changeEvents++; }); const asset = storage.createAsset( storage.AssetType.Font, 'ttf', new Uint8Array([11, 12, 13]), null, true ); fontManager.addCustomFont('TestFont', 'sans-serif', asset); assert.strictEqual(changeEvents, 1); assert.strictEqual(setCustomFontsCalls, 1); assert.deepEqual(fontManager.getFonts().map(i => i.name), ['TestFont']); fontManager.addSystemFont('TestFonT', 'sans-serif'); assert.strictEqual(changeEvents, 2); assert.strictEqual(setCustomFontsCalls, 2); assert.deepEqual(fontManager.getFonts().map(i => i.name), ['TestFonT']); fontManager.addSystemFont('TestFONT', 'sans-serif'); assert.strictEqual(changeEvents, 3); assert.strictEqual(setCustomFontsCalls, 2); assert.deepEqual(fontManager.getFonts().map(i => i.name), ['TestFONT']); fontManager.addCustomFont('TESTFONT', 'sans-serif', asset); assert.strictEqual(changeEvents, 4); assert.strictEqual(setCustomFontsCalls, 3); assert.deepEqual(fontManager.getFonts().map(i => i.name), ['TESTFONT']); }); test('deserialize waits for renderer to load fonts', async () => { const zip = new JSZip(); zip.file('8e0b5e4ad4e3b2b9a8b4e9bd0ec4de2c.ttf', new Uint8Array([1, 2, 3])); let resolveSetCustomFontsCalled; const setCustomFontsCalled = new Promise(resolve => { resolveSetCustomFontsCalled = resolve; }); let resolveFontsLoaded; const mockRenderer = { setLayerGroupOrdering: () => {}, setCustomFonts: () => { resolveSetCustomFontsCalled(); return new Promise(resolve => { resolveFontsLoaded = resolve; }); } }; const rt = new Runtime(); rt.attachRenderer(mockRenderer); rt.attachStorage(makeTestStorage()); let deserialized = false; const promise = rt.fontManager.deserialize([ { system: false, family: 'TestFont', fallback: 'sans-serif', md5ext: '8e0b5e4ad4e3b2b9a8b4e9bd0ec4de2c.ttf' } ], zip, false).then(() => { deserialized = true; }); await setCustomFontsCalled; await new Promise(resolve => setTimeout(resolve)); assert.strictEqual(deserialized, false, 'still waiting for fonts'); resolveFontsLoaded(); await promise; assert.strictEqual(deserialized, true); assert.deepEqual(rt.fontManager.getFonts().map(i => i.name), ['TestFont']); });