@triflare/fx 2.1.0-beta.1 → 2.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/vm.js ADDED
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Headless PenguinMod VM adapter.
3
+ *
4
+ * `fx test` runs projects against the real PenguinMod runtime. The VM is a
5
+ * browser module that touches a handful of DOM globals at import time, so this
6
+ * module provides minimal in-memory stand-ins for storage and rendering and
7
+ * requires the VM lazily. Loading a project never draws anything, so the
8
+ * no-op renderer is enough to deserialize targets, costumes, and blocks.
9
+ *
10
+ * The VM is shipped as a runtime dependency: `fx test` reports a clear
11
+ * error when it is not installed instead of failing at import time.
12
+ */
13
+ import crypto from "node:crypto";
14
+ import { createRequire } from "node:module";
15
+ import { runInThisContext } from "node:vm";
16
+
17
+ let modules = null;
18
+
19
+ /** Blob URLs the VM created for project extension sources, keyed by URL. */
20
+ const extensionBlobs = new Map();
21
+
22
+ /** Install the DOM globals the VM reads while it is being imported. */
23
+ function installGlobals() {
24
+ globalThis.location ||= {
25
+ search: "",
26
+ href: "http://localhost/",
27
+ origin: "http://localhost",
28
+ protocol: "http:",
29
+ host: "localhost",
30
+ hostname: "localhost",
31
+ pathname: "/",
32
+ hash: "",
33
+ };
34
+ globalThis.DOMParser ||= class {
35
+ parseFromString() {
36
+ const empty = () => [];
37
+ return {
38
+ querySelectorAll: empty,
39
+ getElementsByTagName: empty,
40
+ querySelector: () => null,
41
+ };
42
+ }
43
+ };
44
+ globalThis.window ||= globalThis;
45
+ globalThis.self ||= globalThis;
46
+
47
+ // The VM loads a project extension like a page does: fetch its URL, wrap the
48
+ // result in a Blob, read that Blob with FileReader, then append a script.
49
+ // These shims keep that flow working without a DOM.
50
+ globalThis.FileReader ||= class {
51
+ readAsText(blob) {
52
+ Promise.resolve(blob.text()).then(
53
+ (result) => this.onload?.({ target: { result } }),
54
+ (error) => this.onerror?.(error),
55
+ );
56
+ }
57
+ };
58
+ if (!globalThis.URL.createObjectURL.__fxHeadless) {
59
+ const createObjectURL = globalThis.URL.createObjectURL.bind(globalThis.URL);
60
+ globalThis.URL.createObjectURL = (blob) => {
61
+ const url = createObjectURL(blob);
62
+ extensionBlobs.set(url, blob);
63
+ return url;
64
+ };
65
+ globalThis.URL.createObjectURL.__fxHeadless = true;
66
+ }
67
+
68
+ /** Run an appended `<script>` whose `src` is one of the VM's Blob URLs. */
69
+ const runScript = (node) => {
70
+ const blob = extensionBlobs.get(node.src);
71
+ if (!blob) {
72
+ node.onerror?.(new Error(`Cannot read extension script ${node.src}`));
73
+ return;
74
+ }
75
+ Promise.resolve(blob.text()).then(
76
+ (source) => {
77
+ try {
78
+ // The unsandboxed loader installs `global.Scratch` before appending
79
+ // the script, so evaluating here matches a browser script tag.
80
+ runInThisContext(source, { filename: node.src });
81
+ node.onload?.();
82
+ } catch (error) {
83
+ node.onerror?.(error);
84
+ }
85
+ },
86
+ (error) => node.onerror?.(error),
87
+ );
88
+ };
89
+
90
+ globalThis.document ||= {
91
+ createElement: (tagName) => ({
92
+ tagName: String(tagName).toUpperCase(),
93
+ style: {},
94
+ getContext: () => null,
95
+ appendChild() {},
96
+ setAttribute() {},
97
+ addEventListener() {},
98
+ remove() {},
99
+ }),
100
+ body: {
101
+ appendChild(node) {
102
+ if (node?.tagName === "SCRIPT") runScript(node);
103
+ },
104
+ },
105
+ querySelector: () => null,
106
+ addEventListener() {},
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Load `scratch-vm` on first use.
112
+ * @returns {{VirtualMachine: any, centralDispatch: any}}
113
+ */
114
+ export function loadVmModules() {
115
+ if (modules) return modules;
116
+ installGlobals();
117
+ const require = createRequire(import.meta.url);
118
+ let VirtualMachine;
119
+ let centralDispatch;
120
+ try {
121
+ VirtualMachine = require("scratch-vm");
122
+ centralDispatch = require("scratch-vm/src/dispatch/central-dispatch");
123
+ } catch (error) {
124
+ throw new Error(
125
+ "Running tests needs the PenguinMod VM, which could not be loaded. " +
126
+ "Reinstall @triflare/fx with its runtime dependencies and try again.",
127
+ { cause: error },
128
+ );
129
+ }
130
+ modules = { VirtualMachine, centralDispatch };
131
+ return modules;
132
+ }
133
+
134
+ const AssetType = {
135
+ ImageVector: { name: "ImageVector", runtimeFormat: "svg" },
136
+ ImageBitmap: { name: "ImageBitmap", runtimeFormat: "png" },
137
+ Sound: { name: "Sound", runtimeFormat: "wav" },
138
+ };
139
+
140
+ const DataFormat = {
141
+ SVG: "svg",
142
+ PNG: "png",
143
+ JPG: "jpg",
144
+ BMP: "bmp",
145
+ GIF: "gif",
146
+ WAV: "wav",
147
+ MP3: "mp3",
148
+ OGG: "ogg",
149
+ FLAC: "flac",
150
+ };
151
+
152
+ const md5 = (data) =>
153
+ crypto.createHash("md5").update(Buffer.from(data)).digest("hex");
154
+
155
+ /** In-memory storage module so the VM can load assets headlessly. */
156
+ export function createStorage() {
157
+ return {
158
+ AssetType,
159
+ DataFormat,
160
+ createAsset(assetType, dataFormat, data, assetId, generateId) {
161
+ const bytes =
162
+ data instanceof Uint8Array ? data : new Uint8Array(data ?? []);
163
+ return {
164
+ assetType,
165
+ dataFormat,
166
+ data: bytes,
167
+ assetId: assetId ?? (generateId ? md5(bytes) : null),
168
+ clean: true,
169
+ encodeDataURI() {
170
+ const base64 = Buffer.from(this.data).toString("base64");
171
+ return `data:${dataFormat};base64,${base64}`;
172
+ },
173
+ decodeText() {
174
+ return Buffer.from(this.data).toString("utf8");
175
+ },
176
+ encodeTextData(text) {
177
+ this.data = new Uint8Array(Buffer.from(text, "utf8"));
178
+ this.assetId = md5(this.data);
179
+ },
180
+ };
181
+ },
182
+ };
183
+ }
184
+
185
+ /** No-op renderer: enough state for blocks that inspect or create skins. */
186
+ export function createRenderer() {
187
+ let nextId = 0;
188
+ const skins = {};
189
+ const createSkin = (size = [0, 0]) => {
190
+ const skinId = nextId++;
191
+ skins[skinId] = {
192
+ id: skinId,
193
+ renderQuality: 1,
194
+ _size: size,
195
+ _setTexture() {},
196
+ };
197
+ return skinId;
198
+ };
199
+ return {
200
+ _allSkins: skins,
201
+ _groupOrdering: [],
202
+ useHighQualityRender: true,
203
+ setLayerGroupOrdering() {},
204
+ setPrivateSkinAccess() {},
205
+ setUseHighQualityRender() {},
206
+ setRenderOffscreen() {},
207
+ setStageSize() {},
208
+ draw() {},
209
+ markDrawableAsNoninteractive() {},
210
+ createDrawable() {
211
+ return nextId++;
212
+ },
213
+ destroyDrawable() {},
214
+ updateDrawableSkinId() {},
215
+ updateDrawablePosition() {},
216
+ updateDrawableDirectionScale() {},
217
+ updateDrawableVisible() {},
218
+ updateDrawableEffect() {},
219
+ setDrawableOrder() {},
220
+ getDrawableOrder() {
221
+ return 0;
222
+ },
223
+ getFencedPositionOfDrawable(id, position) {
224
+ return position;
225
+ },
226
+ getBounds() {
227
+ return { left: 0, right: 0, top: 0, bottom: 0 };
228
+ },
229
+ getBoundsForBubble() {
230
+ return { left: 0, right: 0, top: 0, bottom: 0 };
231
+ },
232
+ isTouchingDrawables() {
233
+ return false;
234
+ },
235
+ drawableTouching() {
236
+ return false;
237
+ },
238
+ getTouchingDrawablesPoint() {
239
+ return [];
240
+ },
241
+ isTouchingColor() {
242
+ return false;
243
+ },
244
+ createSVGSkin() {
245
+ return createSkin();
246
+ },
247
+ createBitmapSkin() {
248
+ return createSkin();
249
+ },
250
+ createPenSkin() {
251
+ return createSkin();
252
+ },
253
+ getSkinSize(skinId) {
254
+ return skins[skinId] ? skins[skinId]._size : [0, 0];
255
+ },
256
+ getSkinRotationCenter() {
257
+ return [0, 0];
258
+ },
259
+ penClear() {},
260
+ penLine() {},
261
+ penPoint() {},
262
+ penStamp() {},
263
+ penTranslate() {},
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Create a headless VM with in-memory storage and a no-op renderer.
269
+ *
270
+ * Project extensions are plain JavaScript, and the only way to run them here is
271
+ * unsandboxed in this Node process: loading one is equivalent to running the
272
+ * project's code with full filesystem and network access. Nothing is loaded
273
+ * unless `allowProjectExtensions` is set. Callers opt in explicitly (for `fx
274
+ * test` that means the user passes `--allow-project-extensions`) to state that
275
+ * the project's extensions are trusted for this run.
276
+ *
277
+ * The dispatch broker is a process-wide singleton; dropping its services keeps
278
+ * one VM's extensions from logging a provider conflict for the next.
279
+ *
280
+ * @param {{allowProjectExtensions?: boolean}} [options]
281
+ */
282
+ export function createVm({ allowProjectExtensions = false } = {}) {
283
+ const { VirtualMachine, centralDispatch } = loadVmModules();
284
+ centralDispatch.services = {};
285
+ const vm = new VirtualMachine();
286
+ vm.attachStorage(createStorage());
287
+ vm.attachRenderer(createRenderer());
288
+ // Denying the load keeps both the extension source and its top-level code
289
+ // from running, even when the project only configured it and no block uses
290
+ // it. The VM reports "Permission to load extension denied" for the project.
291
+ vm.securityManager.canLoadExtensionFromProject = () =>
292
+ Promise.resolve(allowProjectExtensions);
293
+ // Node has neither the web worker nor the iframe sandbox the VM would use, so
294
+ // a permitted extension runs in this process, exactly like PenguinMod's
295
+ // unsandboxed mode. Denied extensions never reach the sandbox selector.
296
+ vm.securityManager.getSandboxMode = () =>
297
+ Promise.resolve(allowProjectExtensions ? "unsandboxed" : "iframe");
298
+ return vm;
299
+ }
300
+
301
+ /**
302
+ * Build a fresh VM and load an sb3/pmp buffer into it.
303
+ * @param {Buffer} sb3
304
+ * @param {{allowProjectExtensions?: boolean}} [options]
305
+ */
306
+ export const loadProject = async (sb3, options = {}) => {
307
+ const vm = createVm(options);
308
+ // Loading an extension logs its whole source to the console; keep reporter
309
+ // output (especially JSON and JUnit) parseable.
310
+ const log = console.log;
311
+ console.log = () => {};
312
+ try {
313
+ await vm.loadProject(sb3);
314
+ } catch (error) {
315
+ try {
316
+ vm.stopAll();
317
+ } catch {
318
+ // Preserve the original load error if cleanup also fails.
319
+ }
320
+ throw error;
321
+ } finally {
322
+ console.log = log;
323
+ }
324
+ return vm;
325
+ };