Files
001code-html--cocos/scratch-vm/test/unit/tw_unsandboxed_extensions.js
刘宇飞 6e0a1fbcbb Initial commit of 001code-html Scratch frontend project.
Includes scratch-gui, scratch-vm, scratch-blocks, scratch-render, scratch-l10n, and deployment config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 15:37:45 +08:00

430 lines
14 KiB
JavaScript

const tap = require('tap');
const UnsandboxedExtensionRunner = require('../../src/extension-support/tw-unsandboxed-extension-runner');
const VirtualMachine = require('../../src/virtual-machine');
// Mock enough of the document API for the extension runner to think it works.
// To more accurately test this, we want to make sure that the URLs we pass in are just strings.
// We use a bit of hacky state here to make our document mock know what function to run
// when a script with a given URL "loads"
const scriptCallbacks = new Map();
const setScript = (src, callback) => {
scriptCallbacks.set(src, callback);
};
global.document = {
createElement: tagName => {
if (tagName.toLowerCase() !== 'script') {
throw new Error(`Unknown element: ${tagName}`);
}
return {
tagName: 'SCRIPT',
src: '',
onload: () => {},
onerror: () => {}
};
},
body: {
appendChild: element => {
if (element.tagName === 'SCRIPT') {
setTimeout(() => {
const callback = scriptCallbacks.get(element.src);
if (callback) {
callback();
element.onload();
} else {
element.onerror();
}
}, 50);
}
}
}
};
// Mock various DOM APIs for fetching, window opening, redirecting, etc.
global.Request = class {
constructor (url) {
this.url = url;
}
};
global.fetch = (url, options = {}) => (
Promise.resolve(`[Response ${url instanceof Request ? url.url : url} options=${JSON.stringify(options)}]`)
);
global.window = {
open: (url, target, features) => `[Window ${url} target=${target || ''} features=${features || ''}]`
};
// Remove navigator object from Node 21 and later
delete global.navigator;
tap.beforeEach(() => {
scriptCallbacks.clear();
global.location = {
href: 'https://example.com/'
};
});
const {test} = tap;
test('basic API', async t => {
t.plan(9);
const vm = new VirtualMachine();
class MyExtension {}
setScript('https://turbowarp.org/1.js', () => {
t.equal(global.Scratch.vm, vm);
t.equal(global.Scratch.renderer, vm.runtime.renderer);
t.equal(global.Scratch.extensions.unsandboxed, true);
// These APIs are tested elsewhere, just make sure they're getting exported
t.equal(global.Scratch.ArgumentType.NUMBER, 'number');
t.equal(global.Scratch.BlockType.REPORTER, 'reporter');
t.equal(global.Scratch.TargetType.SPRITE, 'sprite');
t.equal(global.Scratch.Cast.toNumber('3.14'), 3.14);
global.Scratch.extensions.register(new MyExtension());
});
const extensions = await UnsandboxedExtensionRunner.load('https://turbowarp.org/1.js', vm);
t.equal(extensions.length, 1);
t.ok(extensions[0] instanceof MyExtension);
t.end();
});
test('multiple VMs loading extensions', async t => {
const vm1 = new VirtualMachine();
const vm2 = new VirtualMachine();
class Extension1 {}
class Extension2 {}
let api1 = null;
setScript('https://turbowarp.org/1.js', async () => {
// Even if this extension takes a while to register, we should still have our own
// global.Scratch.
await new Promise(resolve => setTimeout(resolve, 100));
if (api1) throw new Error('already ran 1');
api1 = global.Scratch;
global.Scratch.extensions.register(new Extension1());
});
let api2 = null;
setScript('https://turbowarp.org/2.js', () => {
if (api2) throw new Error('already ran 2');
api2 = global.Scratch;
global.Scratch.extensions.register(new Extension2());
});
const extensions = await Promise.all([
UnsandboxedExtensionRunner.load('https://turbowarp.org/1.js', vm1),
UnsandboxedExtensionRunner.load('https://turbowarp.org/2.js', vm2)
]);
t.not(api1, api2);
t.type(api1.extensions.register, 'function');
t.type(api2.extensions.register, 'function');
t.equal(api1.vm, vm1);
t.equal(api2.vm, vm2);
t.equal(extensions.length, 2);
t.equal(extensions[0].length, 1);
t.equal(extensions[1].length, 1);
t.ok(extensions[0][0] instanceof Extension1);
t.ok(extensions[1][0] instanceof Extension2);
t.end();
});
test('register multiple extensions in one script', async t => {
const vm = new VirtualMachine();
class Extension1 {}
class Extension2 {}
setScript('https://turbowarp.org/multiple.js', () => {
global.Scratch.extensions.register(new Extension1());
global.Scratch.extensions.register(new Extension2());
});
const extensions = await UnsandboxedExtensionRunner.load('https://turbowarp.org/multiple.js', vm);
t.equal(extensions.length, 2);
t.ok(extensions[0] instanceof Extension1);
t.ok(extensions[1] instanceof Extension2);
t.end();
});
test('extension error results in rejection', async t => {
const vm = new VirtualMachine();
try {
await UnsandboxedExtensionRunner.load('https://turbowarp.org/404.js', vm);
// Above should throw an error as the script will not load successfully
t.fail();
} catch (e) {
t.pass();
}
t.end();
});
test('ScratchX', async t => {
const vm = new VirtualMachine();
setScript('https://turbowarp.org/scratchx.js', () => {
const ext = {
test: () => 2
};
const descriptor = {
blocks: [
['r', 'test', 'test']
]
};
global.ScratchExtensions.register('Test', descriptor, ext);
});
const extensions = await UnsandboxedExtensionRunner.load('https://turbowarp.org/scratchx.js', vm);
t.equal(extensions.length, 1);
t.equal(extensions[0].test(), 2);
t.end();
});
test('canFetch', async t => {
// see tw_security_manager.js
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
const result = global.Scratch.canFetch('https://example.com/');
t.type(result, Promise);
t.equal(await result, true);
t.end();
});
test('fetch', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
global.Scratch.canFetch = url => url === 'https://example.com/2';
await t.rejects(global.Scratch.fetch('https://example.com/1'), /Permission to fetch https:\/\/example.com\/1 rejected/);
await t.rejects(global.Scratch.fetch(new Request('https://example.com/1')), /Permission to fetch https:\/\/example.com\/1 rejected/);
t.equal(await global.Scratch.fetch('https://example.com/2'), '[Response https://example.com/2 options={}]');
t.equal(await global.Scratch.fetch(new Request('https://example.com/2')), '[Response https://example.com/2 options={}]');
t.equal(await global.Scratch.fetch('https://example.com/2', {
redirect: 'follow',
method: 'POST',
body: 'abc'
}), '[Response https://example.com/2 options={"redirect":"follow","method":"POST","body":"abc"}]');
t.end();
});
test('canOpenWindow', async t => {
// see tw_security_manager.js
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
const result = global.Scratch.canOpenWindow('https://example.com/');
t.type(result, Promise);
t.equal(await result, true);
t.end();
});
test('openWindow', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
global.Scratch.canOpenWindow = url => url === 'https://example.com/2';
await t.rejects(global.Scratch.openWindow('https://example.com/1'), /Permission to open tab https:\/\/example.com\/1 rejected/);
t.equal(await global.Scratch.openWindow('https://example.com/2'), '[Window https://example.com/2 target=_blank features=noreferrer]');
t.equal(await global.Scratch.openWindow('https://example.com/2', 'popup=1'), '[Window https://example.com/2 target=_blank features=noreferrer,popup=1]');
t.end();
});
test('canRedirect', async t => {
// see tw_security_manager.js
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
const result = global.Scratch.canRedirect('https://example.com/');
t.type(result, Promise);
t.equal(await result, true);
t.end();
});
test('redirect', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
global.Scratch.canRedirect = url => url === 'https://example.com/2';
await t.rejects(global.Scratch.redirect('https://example.com/1'), /Permission to redirect to https:\/\/example.com\/1 rejected/);
t.equal(global.location.href, 'https://example.com/');
await global.Scratch.redirect('https://example.com/2');
t.equal(global.location.href, 'https://example.com/2');
t.end();
});
test('translate', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
t.equal(global.Scratch.translate({
id: 'test1',
default: 'Message 1: {var}',
description: 'Description'
}, {
var: 'test'
}), 'Message 1: test');
t.equal(global.Scratch.translate('test1 {var}', {
var: 'ok'
}), 'test1 ok');
t.equal(global.Scratch.translate.language, 'en');
global.Scratch.translate.setup({
en: {
test1: 'EN Message 1: {var}'
},
es: {
test1: 'ES Message 1: {var}'
}
});
t.equal(global.Scratch.translate({
id: 'test1',
default: 'Message 1: {var}',
description: 'Description'
}, {
var: 'test'
}), 'EN Message 1: test');
t.equal(global.Scratch.translate('test1 {var}', {
var: 'ok'
}), 'test1 ok');
t.equal(global.Scratch.translate.language, 'en');
await vm.setLocale('es');
// do not call setup() again; real extensions will not do that.
// need to make sure that the translatiosn are saved after calling setLocale.
t.equal(global.Scratch.translate({
id: 'test1',
default: 'Message 1: {var}',
description: 'Description'
}, {
var: 'test'
}), 'ES Message 1: test');
t.equal(global.Scratch.translate.language, 'es');
t.end();
});
test('canRecordAudio', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
vm.securityManager.canRecordAudio = () => false;
t.equal(await global.Scratch.canRecordAudio(), false);
vm.securityManager.canRecordAudio = () => true;
t.equal(await global.Scratch.canRecordAudio(), true);
t.end();
});
test('canRecordVideo', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
vm.securityManager.canRecordVideo = () => false;
t.equal(await global.Scratch.canRecordVideo(), false);
vm.securityManager.canRecordVideo = () => true;
t.equal(await global.Scratch.canRecordVideo(), true);
t.end();
});
test('canReadClipboard', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
vm.securityManager.canReadClipboard = () => false;
t.equal(await global.Scratch.canReadClipboard(), false);
vm.securityManager.canReadClipboard = () => true;
t.equal(await global.Scratch.canReadClipboard(), true);
t.end();
});
test('canNotify', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
vm.securityManager.canNotify = () => false;
t.equal(await global.Scratch.canNotify(), false);
vm.securityManager.canNotify = () => true;
t.equal(await global.Scratch.canNotify(), true);
t.end();
});
test('canGeolocate', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
vm.securityManager.canGeolocate = () => false;
t.equal(await global.Scratch.canGeolocate(), false);
vm.securityManager.canGeolocate = () => true;
t.equal(await global.Scratch.canGeolocate(), true);
t.end();
});
test('rewriteExtensionURL', async t => {
const vm = new VirtualMachine();
let createdRewrittenExtension = false;
class RewrittenExtension {
getInfo () {
createdRewrittenExtension = true;
return {
id: 'extensionid',
blocks: []
};
}
}
setScript('https://turbowarp.org/rewritten.js', () => {
global.Scratch.extensions.register(new RewrittenExtension());
});
class OriginalExtension {
getInfo () {
t.fail('Should not create original extension');
return {
id: 'extensionid',
blocks: []
};
}
}
setScript('https://turbowarp.org/original.js', () => {
global.Scratch.extensions.register(new OriginalExtension());
});
vm.securityManager.getSandboxMode = () => 'unsandboxed';
vm.securityManager.rewriteExtensionURL = url => {
if (url === 'https://turbowarp.org/original.js') {
return 'https://turbowarp.org/rewritten.js';
}
return url;
};
await vm.extensionManager.loadExtensionURL('https://turbowarp.org/original.js');
t.ok(createdRewrittenExtension, 'used rewritten extension');
t.ok(vm.extensionManager.isExtensionURLLoaded('https://turbowarp.org/original.js'), 'marks original URL as loaded');
t.notOk(vm.extensionManager.isExtensionURLLoaded('https://turbowarp.org/rewritten.js'), 'does not mark new URL as loaded');
t.end();
});
test('canEmbed', async t => {
const vm = new VirtualMachine();
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
vm.securityManager.canEmbed = url => url === 'https://example.com/safe';
t.ok(await global.Scratch.canEmbed('https://example.com/safe'));
t.notOk(await global.Scratch.canEmbed('https://example.com/unsafe'));
t.end();
});
test('CREATE_UNSANDBOXED_EXTENSION_API', t => {
const vm = new VirtualMachine();
vm.on('CREATE_UNSANDBOXED_EXTENSION_API', api => {
api.extraStuff = 'aaaa';
});
UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm);
t.equal(global.Scratch.extraStuff, 'aaaa');
t.end();
});