@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/package.json +3 -3
- package/product.yml +1 -1
- package/src/build.js +23 -6
- package/src/cli/commands/check.js +3 -1
- package/src/cli/commands/feature.js +54 -5
- package/src/cli/commands/index.js +2 -0
- package/src/cli/commands/lint.js +4 -0
- package/src/cli/commands/test.js +186 -0
- package/src/cli/context.js +3 -1
- package/src/config-edit.js +78 -0
- package/src/config.js +77 -0
- package/src/features.js +89 -3
- package/src/formatter.js +21 -1
- package/src/index.js +21 -0
- package/src/lint/analyze.js +25 -5
- package/src/lint/config-file.js +14 -0
- package/src/merge.js +7 -0
- package/src/parser.js +78 -3
- package/src/project.js +14 -3
- package/src/test/discover.js +82 -0
- package/src/test/parse.js +72 -0
- package/src/test/pattern.js +84 -0
- package/src/test/reporters.js +332 -0
- package/src/test/runner.js +1457 -0
- package/src/vm.js +325 -0
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
|
+
};
|