@triflare/fx 2.0.0 → 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/README.md +27 -0
- package/package.json +6 -4
- package/product.yml +1 -1
- package/src/build.js +23 -6
- package/src/catalog.js +103 -0
- package/src/cli/commands/check.js +3 -1
- package/src/cli/commands/feature.js +163 -0
- package/src/cli/commands/fmt.js +173 -0
- package/src/cli/commands/index.js +8 -0
- package/src/cli/commands/lint.js +274 -0
- package/src/cli/commands/test.js +186 -0
- package/src/cli/context.js +16 -1
- package/src/cli/define-command.js +8 -1
- package/src/cli/reporter.js +9 -0
- package/src/compiler.js +133 -0
- package/src/config-edit.js +197 -0
- package/src/config.js +114 -1
- package/src/decompiler.js +45 -0
- package/src/features.js +425 -0
- package/src/formatter.js +519 -0
- package/src/index.js +31 -0
- package/src/lexer.js +45 -4
- package/src/lint/analyze.js +1434 -0
- package/src/lint/config-file.js +111 -0
- package/src/lint/config.js +174 -0
- package/src/lint/rules.js +461 -0
- package/src/linter.js +220 -0
- package/src/merge.js +116 -0
- package/src/parser.js +131 -16
- package/src/project.js +21 -90
- 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/vendor/pm-packager-dist/.fx-build.json +1 -1
- package/vendor/tempvars/fx-tempvars.js +321 -0
|
@@ -0,0 +1,1457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test runner.
|
|
3
|
+
*
|
|
4
|
+
* Discovers test files, builds the project once, then runs each test in a
|
|
5
|
+
* fresh headless PenguinMod VM. Actions drive the runtime; assertions read
|
|
6
|
+
* project state through explicit `state.*` queries. The runner never prints:
|
|
7
|
+
* it emits structured events for the terminal, JSON, JUnit, and editor
|
|
8
|
+
* reporters.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { build } from "../build.js";
|
|
13
|
+
import { loadProject } from "../vm.js";
|
|
14
|
+
import { loadConfigFrom, DEFAULT_TESTS } from "../config.js";
|
|
15
|
+
import { loadExtensions } from "../extensions.js";
|
|
16
|
+
import { loadFeatureExtensions } from "../features.js";
|
|
17
|
+
import { loadVendorExtensions } from "../vendor.js";
|
|
18
|
+
import { productVersion } from "../brand.js";
|
|
19
|
+
import { discoverTests } from "./discover.js";
|
|
20
|
+
import { parseTests } from "./parse.js";
|
|
21
|
+
|
|
22
|
+
/** Exit codes promised by `fx test`. */
|
|
23
|
+
export const TEST_EXIT = {
|
|
24
|
+
OK: 0,
|
|
25
|
+
FAILED: 1,
|
|
26
|
+
ERROR: 2,
|
|
27
|
+
INTERRUPTED: 3,
|
|
28
|
+
USAGE: 4,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** A configuration or test-language problem. */
|
|
32
|
+
export class TestError extends Error {
|
|
33
|
+
constructor(message, options = {}) {
|
|
34
|
+
super(message, options.cause ? { cause: options.cause } : undefined);
|
|
35
|
+
this.name = "TestError";
|
|
36
|
+
this.exitCode = options.exitCode ?? TEST_EXIT.ERROR;
|
|
37
|
+
this.hint = options.hint;
|
|
38
|
+
this.diagnostics = options.diagnostics ?? [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class AssertionFailure extends Error {
|
|
43
|
+
constructor(details) {
|
|
44
|
+
super(details.message);
|
|
45
|
+
this.name = details.name ?? "AssertionError";
|
|
46
|
+
this.details = { name: this.name, ...details };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class TimeoutFailure extends Error {
|
|
51
|
+
constructor(timeout) {
|
|
52
|
+
super(`Test timed out after ${timeout}ms`);
|
|
53
|
+
this.name = "TimeoutError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
class ReturnSignal {
|
|
58
|
+
constructor(value) {
|
|
59
|
+
this.value = value;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Deterministic pseudo-random source for a run.
|
|
65
|
+
* @param {number} seed
|
|
66
|
+
* @returns {() => number} values in `[0, 1)`
|
|
67
|
+
*/
|
|
68
|
+
function mulberry32(seed) {
|
|
69
|
+
let state = seed | 0;
|
|
70
|
+
return () => {
|
|
71
|
+
state = (state + 0x6d2b79f5) | 0;
|
|
72
|
+
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
73
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
74
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Source location of an AST node, defaulting unknown fields to null. */
|
|
79
|
+
function locOf(node) {
|
|
80
|
+
const loc = node?.loc ?? {};
|
|
81
|
+
return {
|
|
82
|
+
file: loc.file ?? null,
|
|
83
|
+
line: loc.line ?? null,
|
|
84
|
+
column: loc.column ?? null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Whether a value is, or is entirely made of, a finite number. */
|
|
89
|
+
function isNumeric(value) {
|
|
90
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
91
|
+
if (typeof value === "string") {
|
|
92
|
+
const trimmed = value.trim();
|
|
93
|
+
if (trimmed === "") return false;
|
|
94
|
+
const number = Number(trimmed);
|
|
95
|
+
return !Number.isNaN(number);
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Scratch-style truthiness: `"0"` and `""` are false. */
|
|
101
|
+
function truthy(value) {
|
|
102
|
+
if (typeof value === "boolean") return value;
|
|
103
|
+
if (typeof value === "number") return value !== 0;
|
|
104
|
+
if (typeof value === "string")
|
|
105
|
+
return isNumeric(value) ? Number(value) !== 0 : value !== "";
|
|
106
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
107
|
+
return Boolean(value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Compare like the VM's `Cast.compare`: numeric when both sides are. */
|
|
111
|
+
function compareValues(left, right) {
|
|
112
|
+
if (isNumeric(left) && isNumeric(right)) {
|
|
113
|
+
const a = Number(left);
|
|
114
|
+
const b = Number(right);
|
|
115
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
116
|
+
}
|
|
117
|
+
const a = String(left).toLowerCase();
|
|
118
|
+
const b = String(right).toLowerCase();
|
|
119
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Render a value for an assertion message. */
|
|
123
|
+
function formatValue(value) {
|
|
124
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
125
|
+
if (value === undefined) return "undefined";
|
|
126
|
+
if (typeof value === "object" && value !== null) return JSON.stringify(value);
|
|
127
|
+
return String(value);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Find a project variable by name and variable type, stage first. */
|
|
131
|
+
function findVariable(vm, name, type) {
|
|
132
|
+
const stage = vm.runtime.getTargetForStage?.();
|
|
133
|
+
const targets = [stage, ...vm.runtime.targets.filter((t) => !t.isStage)];
|
|
134
|
+
for (const target of targets) {
|
|
135
|
+
if (!target) continue;
|
|
136
|
+
for (const variable of Object.values(target.variables ?? {})) {
|
|
137
|
+
if (variable.name === name && (variable.type ?? "") === type)
|
|
138
|
+
return variable;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Capture the mutable state `reset()` restores between tests. */
|
|
145
|
+
function snapshotVm(vm) {
|
|
146
|
+
return vm.runtime.targets.map((target) => ({
|
|
147
|
+
id: target.id,
|
|
148
|
+
variables: Object.fromEntries(
|
|
149
|
+
Object.entries(target.variables ?? {}).map(([id, variable]) => [
|
|
150
|
+
id,
|
|
151
|
+
Array.isArray(variable.value) ? [...variable.value] : variable.value,
|
|
152
|
+
]),
|
|
153
|
+
),
|
|
154
|
+
x: target.x,
|
|
155
|
+
y: target.y,
|
|
156
|
+
direction: target.direction,
|
|
157
|
+
visible: target.visible,
|
|
158
|
+
size: target.size,
|
|
159
|
+
currentCostume: target.currentCostume,
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Restore a snapshot taken by {@link snapshotVm}. */
|
|
164
|
+
function restoreVm(vm, snapshot) {
|
|
165
|
+
vm.stopAll();
|
|
166
|
+
vm.runtime.threads = [];
|
|
167
|
+
for (const saved of snapshot) {
|
|
168
|
+
const target = vm.runtime.getTargetById(saved.id);
|
|
169
|
+
if (!target) continue;
|
|
170
|
+
for (const [id, value] of Object.entries(saved.variables)) {
|
|
171
|
+
if (target.variables?.[id])
|
|
172
|
+
target.variables[id].value = Array.isArray(value) ? [...value] : value;
|
|
173
|
+
}
|
|
174
|
+
target.x = saved.x;
|
|
175
|
+
target.y = saved.y;
|
|
176
|
+
target.direction = saved.direction;
|
|
177
|
+
target.visible = saved.visible;
|
|
178
|
+
target.size = saved.size;
|
|
179
|
+
target.currentCostume = saved.currentCostume;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ------------------------------------------------------------------ interpreter
|
|
184
|
+
|
|
185
|
+
/** Execution state for one test: bindings, VM, clocks and captures. */
|
|
186
|
+
function createScope({ vm, config, seed, capture, deadline, warnings }) {
|
|
187
|
+
return {
|
|
188
|
+
vm,
|
|
189
|
+
config,
|
|
190
|
+
seed,
|
|
191
|
+
capture,
|
|
192
|
+
deadline,
|
|
193
|
+
warnings,
|
|
194
|
+
progress: { ticks: 0 },
|
|
195
|
+
vars: new Map(),
|
|
196
|
+
functions: new Map(),
|
|
197
|
+
broadcasts: new Set(),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Abort the test once its wall-clock budget is spent.
|
|
203
|
+
*
|
|
204
|
+
* This is the only real-time mechanism in a test: `durationMs` is measurement,
|
|
205
|
+
* while `step()` and everything it advances use simulated time.
|
|
206
|
+
*/
|
|
207
|
+
function checkDeadline(scope) {
|
|
208
|
+
if (performance.now() > scope.deadline)
|
|
209
|
+
throw new TimeoutFailure(scope.config.timeout);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Scope for a function call or block: new cells, shared outer bindings. */
|
|
213
|
+
function childScope(scope) {
|
|
214
|
+
// Copy bindings while sharing their cells; declarations and parameters get
|
|
215
|
+
// new cells, while assignments to existing test variables reach the caller.
|
|
216
|
+
return { ...scope, vars: new Map(scope.vars) };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Evaluate a test-language expression. */
|
|
220
|
+
function evaluate(node, scope) {
|
|
221
|
+
switch (node.type) {
|
|
222
|
+
case "Number":
|
|
223
|
+
case "String":
|
|
224
|
+
case "Boolean":
|
|
225
|
+
return node.value;
|
|
226
|
+
case "ArrayLiteral":
|
|
227
|
+
return node.items.map((item) => evaluate(item, scope));
|
|
228
|
+
case "Identifier": {
|
|
229
|
+
if (scope.vars.has(node.name)) return scope.vars.get(node.name).value;
|
|
230
|
+
throw new TestError(
|
|
231
|
+
`Unknown name ${JSON.stringify(node.name)} in test expression`,
|
|
232
|
+
{ diagnostics: [locOf(node)] },
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
case "Member": {
|
|
236
|
+
const object = evaluate(node.object, scope);
|
|
237
|
+
if (object == null || !(node.property in Object(object)))
|
|
238
|
+
throw new TestError(
|
|
239
|
+
`Value has no property ${JSON.stringify(node.property)}`,
|
|
240
|
+
{ diagnostics: [locOf(node)] },
|
|
241
|
+
);
|
|
242
|
+
return object[node.property];
|
|
243
|
+
}
|
|
244
|
+
case "Unary": {
|
|
245
|
+
const value = evaluate(node.operand, scope);
|
|
246
|
+
return node.op === "not" ? !truthy(value) : -Number(value);
|
|
247
|
+
}
|
|
248
|
+
case "Binary":
|
|
249
|
+
return evaluateBinary(node, scope);
|
|
250
|
+
case "Call":
|
|
251
|
+
return evaluateCall(node, scope);
|
|
252
|
+
default:
|
|
253
|
+
throw new TestError(`Unsupported expression ${node.type} in a test`, {
|
|
254
|
+
diagnostics: [locOf(node)],
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Evaluate a binary operator with short-circuiting for `and`/`or`. */
|
|
260
|
+
function evaluateBinary(node, scope) {
|
|
261
|
+
if (node.op === "and")
|
|
262
|
+
return (
|
|
263
|
+
truthy(evaluate(node.left, scope)) && truthy(evaluate(node.right, scope))
|
|
264
|
+
);
|
|
265
|
+
if (node.op === "or")
|
|
266
|
+
return (
|
|
267
|
+
truthy(evaluate(node.left, scope)) || truthy(evaluate(node.right, scope))
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const left = evaluate(node.left, scope);
|
|
271
|
+
const right = evaluate(node.right, scope);
|
|
272
|
+
switch (node.op) {
|
|
273
|
+
case "==":
|
|
274
|
+
return compareValues(left, right) === 0;
|
|
275
|
+
case "!=":
|
|
276
|
+
return compareValues(left, right) !== 0;
|
|
277
|
+
case "<":
|
|
278
|
+
return compareValues(left, right) < 0;
|
|
279
|
+
case "<=":
|
|
280
|
+
return compareValues(left, right) <= 0;
|
|
281
|
+
case ">":
|
|
282
|
+
return compareValues(left, right) > 0;
|
|
283
|
+
case ">=":
|
|
284
|
+
return compareValues(left, right) >= 0;
|
|
285
|
+
case "+":
|
|
286
|
+
return isNumeric(left) && isNumeric(right)
|
|
287
|
+
? Number(left) + Number(right)
|
|
288
|
+
: String(left) + String(right);
|
|
289
|
+
case "-":
|
|
290
|
+
return Number(left) - Number(right);
|
|
291
|
+
case "*":
|
|
292
|
+
return Number(left) * Number(right);
|
|
293
|
+
case "/":
|
|
294
|
+
return Number(right) === 0 ? Infinity : Number(left) / Number(right);
|
|
295
|
+
case "%": {
|
|
296
|
+
const divisor = Number(right);
|
|
297
|
+
if (divisor === 0) return NaN;
|
|
298
|
+
const result = Number(left) % divisor;
|
|
299
|
+
return result / divisor < 0 ? result + divisor : result;
|
|
300
|
+
}
|
|
301
|
+
default:
|
|
302
|
+
throw new TestError(`Unsupported operator ${node.op} in a test`, {
|
|
303
|
+
diagnostics: [locOf(node)],
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Read a required argument from a call, failing with its location. */
|
|
309
|
+
function argument(node, index) {
|
|
310
|
+
const value = node.args[index];
|
|
311
|
+
if (value === undefined)
|
|
312
|
+
throw new TestError(`Missing argument ${index + 1} for ${node.name}`, {
|
|
313
|
+
diagnostics: [locOf(node)],
|
|
314
|
+
});
|
|
315
|
+
return value;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Evaluate a test helper call, `state.*` query or project block call. */
|
|
319
|
+
function evaluateCall(node, scope) {
|
|
320
|
+
const segments = node.segments ?? String(node.name).split(".");
|
|
321
|
+
if (segments[0] === "state")
|
|
322
|
+
return stateQuery(segments.slice(1), node, scope);
|
|
323
|
+
|
|
324
|
+
const fn = scope.functions.get(node.name);
|
|
325
|
+
if (!fn) return callProjectProcedure(node, scope);
|
|
326
|
+
const local = childScope(scope);
|
|
327
|
+
fn.params.forEach((param, index) => {
|
|
328
|
+
local.vars.set(param.name, {
|
|
329
|
+
value: node.args[index] ? evaluate(node.args[index], scope) : 0,
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
try {
|
|
333
|
+
executeStatements(fn.body, local);
|
|
334
|
+
} catch (signal) {
|
|
335
|
+
if (signal instanceof ReturnSignal) return signal.value;
|
|
336
|
+
throw signal;
|
|
337
|
+
}
|
|
338
|
+
return 0;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Stage first, then sprites: where a project block can be declared. */
|
|
342
|
+
function procedureTargets(vm) {
|
|
343
|
+
const stage = vm.runtime.getTargetForStage?.();
|
|
344
|
+
return [
|
|
345
|
+
stage,
|
|
346
|
+
...vm.runtime.targets.filter((target) => !target.isStage),
|
|
347
|
+
].filter(Boolean);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Input block id. Compiled JSON stores `[type, id, ...]`; a VM-loaded project
|
|
352
|
+
* stores `{name, block, shadow}`.
|
|
353
|
+
*/
|
|
354
|
+
function inputBlockId(input) {
|
|
355
|
+
if (Array.isArray(input)) return input[1] ?? null;
|
|
356
|
+
if (input && typeof input === "object")
|
|
357
|
+
return input.block ?? input.shadow ?? null;
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A text primitive block in the shape a VM-loaded project uses. */
|
|
362
|
+
function textBlock(id, value, parent) {
|
|
363
|
+
return {
|
|
364
|
+
id,
|
|
365
|
+
opcode: "text",
|
|
366
|
+
next: null,
|
|
367
|
+
parent,
|
|
368
|
+
inputs: {},
|
|
369
|
+
fields: { TEXT: { name: "TEXT", value: String(value ?? "") } },
|
|
370
|
+
shadow: true,
|
|
371
|
+
topLevel: false,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Find a custom block definition on one target by name and argument count. */
|
|
376
|
+
function findProcedure(target, name, arity) {
|
|
377
|
+
const blocks = target?.blocks?._blocks ?? {};
|
|
378
|
+
for (const block of Object.values(blocks)) {
|
|
379
|
+
if (
|
|
380
|
+
block.opcode !== "procedures_definition" &&
|
|
381
|
+
block.opcode !== "procedures_definition_return"
|
|
382
|
+
)
|
|
383
|
+
continue;
|
|
384
|
+
const protoId = inputBlockId(block.inputs?.custom_block);
|
|
385
|
+
const proto = typeof protoId === "string" ? blocks[protoId] : null;
|
|
386
|
+
const mutation = proto?.mutation;
|
|
387
|
+
if (!mutation) continue;
|
|
388
|
+
let argIds;
|
|
389
|
+
try {
|
|
390
|
+
argIds = JSON.parse(mutation.argumentids ?? "[]");
|
|
391
|
+
} catch {
|
|
392
|
+
argIds = [];
|
|
393
|
+
}
|
|
394
|
+
if (argIds.length !== arity) continue;
|
|
395
|
+
const proccode = String(mutation.proccode ?? "");
|
|
396
|
+
if (proccode !== name && !proccode.startsWith(`${name} `)) continue;
|
|
397
|
+
return {
|
|
398
|
+
proccode,
|
|
399
|
+
argIds,
|
|
400
|
+
returns:
|
|
401
|
+
block.opcode === "procedures_definition_return" ||
|
|
402
|
+
mutation.returns === "true" ||
|
|
403
|
+
mutation.returns === true,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Names of the project's custom blocks, for error messages. */
|
|
410
|
+
function availableProcedures(vm) {
|
|
411
|
+
const names = [];
|
|
412
|
+
for (const target of procedureTargets(vm)) {
|
|
413
|
+
const blocks = target.blocks?._blocks ?? {};
|
|
414
|
+
for (const block of Object.values(blocks)) {
|
|
415
|
+
if (
|
|
416
|
+
block.opcode !== "procedures_definition" &&
|
|
417
|
+
block.opcode !== "procedures_definition_return"
|
|
418
|
+
)
|
|
419
|
+
continue;
|
|
420
|
+
const protoId = inputBlockId(block.inputs?.custom_block);
|
|
421
|
+
const proto = typeof protoId === "string" ? blocks[protoId] : null;
|
|
422
|
+
const proccode = proto?.mutation?.proccode;
|
|
423
|
+
if (!proccode) continue;
|
|
424
|
+
names.push(
|
|
425
|
+
target.isStage ? String(proccode) : `${target.getName()}.${proccode}`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return [...new Set(names)].sort();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Resolve `score()` or `Player.score()` to a target and its definition.
|
|
434
|
+
* A bare name must match exactly one target. Tests do not receive implicit
|
|
435
|
+
* receivers, so ambiguity is an error rather than a guess.
|
|
436
|
+
*/
|
|
437
|
+
function resolveProcedure(node, scope) {
|
|
438
|
+
const segments = node.segments ?? String(node.name).split(".");
|
|
439
|
+
const name = segments.length > 1 ? segments.slice(1).join(".") : segments[0];
|
|
440
|
+
const arity = node.args.length;
|
|
441
|
+
|
|
442
|
+
if (segments.length > 1) {
|
|
443
|
+
const wanted = segments[0];
|
|
444
|
+
const target = procedureTargets(scope.vm).find(
|
|
445
|
+
(candidate) =>
|
|
446
|
+
(candidate.isStage && /^stage$/i.test(wanted)) ||
|
|
447
|
+
candidate.getName?.() === wanted,
|
|
448
|
+
);
|
|
449
|
+
if (!target)
|
|
450
|
+
throw new TestError(
|
|
451
|
+
`No sprite named ${JSON.stringify(wanted)} in the project`,
|
|
452
|
+
{ diagnostics: [locOf(node)] },
|
|
453
|
+
);
|
|
454
|
+
const procedure = findProcedure(target, name, arity);
|
|
455
|
+
if (!procedure)
|
|
456
|
+
throw new TestError(
|
|
457
|
+
`No block named ${JSON.stringify(name)} with ${arity} argument(s) in ${wanted}`,
|
|
458
|
+
{ diagnostics: [locOf(node)] },
|
|
459
|
+
);
|
|
460
|
+
return { target, procedure };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const found = [];
|
|
464
|
+
for (const target of procedureTargets(scope.vm)) {
|
|
465
|
+
const procedure = findProcedure(target, name, arity);
|
|
466
|
+
if (procedure) found.push({ target, procedure });
|
|
467
|
+
}
|
|
468
|
+
if (found.length === 1) return found[0];
|
|
469
|
+
if (found.length > 1) {
|
|
470
|
+
const owners = found.map(({ target }) =>
|
|
471
|
+
target.isStage ? "Stage" : target.getName(),
|
|
472
|
+
);
|
|
473
|
+
throw new TestError(
|
|
474
|
+
`Block ${JSON.stringify(name)} is declared by more than one target (${owners.join(
|
|
475
|
+
", ",
|
|
476
|
+
)}); qualify it with the sprite name, as in ${owners[0]}.${name}(...)`,
|
|
477
|
+
{ diagnostics: [locOf(node)] },
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const available = availableProcedures(scope.vm);
|
|
482
|
+
throw new TestError(
|
|
483
|
+
`Unknown test action or project block ${JSON.stringify(node.name)}` +
|
|
484
|
+
(available.length ? `. Available blocks: ${available.join(", ")}` : ""),
|
|
485
|
+
{ diagnostics: [locOf(node)] },
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Call a project custom block and wait for it to finish.
|
|
491
|
+
*
|
|
492
|
+
* A returning block is wrapped in a temporary `set variable` script so its
|
|
493
|
+
* value can be read from the VM, then the synthetic blocks and variable are
|
|
494
|
+
* removed again.
|
|
495
|
+
*/
|
|
496
|
+
function callProjectProcedure(node, scope) {
|
|
497
|
+
const { target, procedure } = resolveProcedure(node, scope);
|
|
498
|
+
const { vm } = scope;
|
|
499
|
+
const blocks = target.blocks;
|
|
500
|
+
if (!blocks?._blocks)
|
|
501
|
+
throw new TestError(
|
|
502
|
+
"The project target has no blocks to call; rebuild the project",
|
|
503
|
+
{ diagnostics: [locOf(node)] },
|
|
504
|
+
);
|
|
505
|
+
if (typeof vm.runtime._pushThread !== "function")
|
|
506
|
+
throw new TestError(
|
|
507
|
+
"This PenguinMod VM build cannot run project blocks from tests",
|
|
508
|
+
{ diagnostics: [locOf(node)] },
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
const values = node.args.map((arg) => evaluate(arg, scope));
|
|
512
|
+
scope.progress.procedureCalls = (scope.progress.procedureCalls ?? 0) + 1;
|
|
513
|
+
const stamp = `${String(target.id ?? "target").replace(/[^A-Za-z0-9_]/g, "_")}_fxtest${scope.progress.procedureCalls}_${scope.progress.ticks}`;
|
|
514
|
+
const callId = `${stamp}_call`;
|
|
515
|
+
const synthetic = [callId];
|
|
516
|
+
const inputs = {};
|
|
517
|
+
procedure.argIds.forEach((argId, index) => {
|
|
518
|
+
const argBlockId = `${stamp}_arg${index}`;
|
|
519
|
+
synthetic.push(argBlockId);
|
|
520
|
+
blocks._blocks[argBlockId] = textBlock(argBlockId, values[index], callId);
|
|
521
|
+
inputs[argId] = { name: argId, block: argBlockId, shadow: argBlockId };
|
|
522
|
+
});
|
|
523
|
+
blocks._blocks[callId] = {
|
|
524
|
+
id: callId,
|
|
525
|
+
opcode: "procedures_call",
|
|
526
|
+
next: null,
|
|
527
|
+
parent: null,
|
|
528
|
+
inputs,
|
|
529
|
+
fields: {},
|
|
530
|
+
shadow: false,
|
|
531
|
+
topLevel: true,
|
|
532
|
+
x: 0,
|
|
533
|
+
y: 0,
|
|
534
|
+
mutation: {
|
|
535
|
+
tagName: "mutation",
|
|
536
|
+
children: [],
|
|
537
|
+
proccode: procedure.proccode,
|
|
538
|
+
argumentids: JSON.stringify(procedure.argIds),
|
|
539
|
+
warp: "false",
|
|
540
|
+
...(procedure.returns ? { returns: "true" } : {}),
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
let setId = null;
|
|
545
|
+
let varId = null;
|
|
546
|
+
if (procedure.returns) {
|
|
547
|
+
varId = `${stamp}_var`;
|
|
548
|
+
const varName = `__fxTestReturn${scope.progress.procedureCalls}`;
|
|
549
|
+
target.variables[varId] = {
|
|
550
|
+
id: varId,
|
|
551
|
+
name: varName,
|
|
552
|
+
type: "",
|
|
553
|
+
value: 0,
|
|
554
|
+
isCloud: false,
|
|
555
|
+
};
|
|
556
|
+
setId = `${stamp}_set`;
|
|
557
|
+
const valueShadowId = `${stamp}_value`;
|
|
558
|
+
synthetic.push(setId, valueShadowId);
|
|
559
|
+
blocks._blocks[valueShadowId] = textBlock(valueShadowId, "", setId);
|
|
560
|
+
blocks._blocks[setId] = {
|
|
561
|
+
id: setId,
|
|
562
|
+
opcode: "data_setvariableto",
|
|
563
|
+
next: null,
|
|
564
|
+
parent: null,
|
|
565
|
+
inputs: {
|
|
566
|
+
VALUE: { name: "VALUE", block: callId, shadow: valueShadowId },
|
|
567
|
+
},
|
|
568
|
+
fields: {
|
|
569
|
+
VARIABLE: {
|
|
570
|
+
name: "VARIABLE",
|
|
571
|
+
value: varName,
|
|
572
|
+
id: varId,
|
|
573
|
+
variableType: "",
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
shadow: false,
|
|
577
|
+
topLevel: true,
|
|
578
|
+
x: 0,
|
|
579
|
+
y: 0,
|
|
580
|
+
};
|
|
581
|
+
blocks._blocks[callId].parent = setId;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
const thread = vm.runtime._pushThread(setId ?? callId, target);
|
|
586
|
+
if (!thread)
|
|
587
|
+
throw new TestError(
|
|
588
|
+
`Could not start ${JSON.stringify(node.name)} in the VM`,
|
|
589
|
+
{ diagnostics: [locOf(node)] },
|
|
590
|
+
);
|
|
591
|
+
while (vm.runtime.threads.includes(thread)) stepTicks(scope, 1);
|
|
592
|
+
if (!procedure.returns) return 0;
|
|
593
|
+
return target.variables[varId]?.value ?? 0;
|
|
594
|
+
} finally {
|
|
595
|
+
for (const id of synthetic) delete blocks._blocks[id];
|
|
596
|
+
if (varId) delete target.variables[varId];
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Resolve a `state.*` assertion query against the VM. */
|
|
601
|
+
function stateQuery(segments, node, scope) {
|
|
602
|
+
const [query, property] = segments;
|
|
603
|
+
const args = node.args.map((arg) => evaluate(arg, scope));
|
|
604
|
+
const { vm } = scope;
|
|
605
|
+
|
|
606
|
+
if (segments.length > 2)
|
|
607
|
+
throw new TestError(`Unknown state query state.${segments.join(".")}`, {
|
|
608
|
+
diagnostics: [locOf(node)],
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
switch (query) {
|
|
612
|
+
case "variable": {
|
|
613
|
+
const variable = findVariable(vm, String(args[0]), "");
|
|
614
|
+
if (!variable)
|
|
615
|
+
throw new TestError(
|
|
616
|
+
`No variable named ${JSON.stringify(String(args[0]))} in the project`,
|
|
617
|
+
{ diagnostics: [locOf(node)] },
|
|
618
|
+
);
|
|
619
|
+
return variable.value;
|
|
620
|
+
}
|
|
621
|
+
case "list": {
|
|
622
|
+
const variable = findVariable(vm, String(args[0]), "list");
|
|
623
|
+
if (!variable)
|
|
624
|
+
throw new TestError(
|
|
625
|
+
`No list named ${JSON.stringify(String(args[0]))} in the project`,
|
|
626
|
+
{ diagnostics: [locOf(node)] },
|
|
627
|
+
);
|
|
628
|
+
const items = Array.isArray(variable.value) ? [...variable.value] : [];
|
|
629
|
+
if (!property)
|
|
630
|
+
return {
|
|
631
|
+
length: items.length,
|
|
632
|
+
items,
|
|
633
|
+
itemAt: (index) => items[Number(index) - 1],
|
|
634
|
+
};
|
|
635
|
+
if (property === "length") return items.length;
|
|
636
|
+
if (property === "items") return items;
|
|
637
|
+
throw new TestError(`Unknown list property ${JSON.stringify(property)}`, {
|
|
638
|
+
diagnostics: [locOf(node)],
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
case "sprite": {
|
|
642
|
+
const name = String(args[0]);
|
|
643
|
+
const target = vm.runtime.getSpriteTargetByName(name);
|
|
644
|
+
if (!target)
|
|
645
|
+
throw new TestError(
|
|
646
|
+
`No sprite named ${JSON.stringify(name)} in the project`,
|
|
647
|
+
{
|
|
648
|
+
diagnostics: [locOf(node)],
|
|
649
|
+
},
|
|
650
|
+
);
|
|
651
|
+
const sprite = {
|
|
652
|
+
name,
|
|
653
|
+
x: target.x,
|
|
654
|
+
y: target.y,
|
|
655
|
+
direction: target.direction,
|
|
656
|
+
visible: target.visible,
|
|
657
|
+
size: target.size,
|
|
658
|
+
costume: target.getCostumes?.()[target.currentCostume]?.name ?? null,
|
|
659
|
+
};
|
|
660
|
+
if (!property) return sprite;
|
|
661
|
+
if (!(property in sprite))
|
|
662
|
+
throw new TestError(
|
|
663
|
+
`Unknown sprite property ${JSON.stringify(property)}`,
|
|
664
|
+
{
|
|
665
|
+
diagnostics: [locOf(node)],
|
|
666
|
+
},
|
|
667
|
+
);
|
|
668
|
+
return sprite[property];
|
|
669
|
+
}
|
|
670
|
+
case "broadcasted":
|
|
671
|
+
return scope.broadcasts.has(String(args[0]));
|
|
672
|
+
case "errors":
|
|
673
|
+
return [...scope.capture.errors];
|
|
674
|
+
case "ticks":
|
|
675
|
+
return scope.progress.ticks;
|
|
676
|
+
default:
|
|
677
|
+
throw new TestError(`Unknown state query state.${segments.join(".")}`, {
|
|
678
|
+
diagnostics: [locOf(node)],
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Run a list of test statements in order. */
|
|
684
|
+
function executeStatements(statements, scope) {
|
|
685
|
+
for (const statement of statements) {
|
|
686
|
+
checkDeadline(scope);
|
|
687
|
+
executeStatement(statement, scope);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Run one statement node. */
|
|
692
|
+
function executeStatement(node, scope) {
|
|
693
|
+
switch (node.type) {
|
|
694
|
+
case "VarDecl":
|
|
695
|
+
scope.vars.set(node.name, {
|
|
696
|
+
value: node.init ? evaluate(node.init, scope) : 0,
|
|
697
|
+
});
|
|
698
|
+
return;
|
|
699
|
+
case "FunctionDecl":
|
|
700
|
+
scope.functions.set(node.name, node);
|
|
701
|
+
return;
|
|
702
|
+
case "Assign": {
|
|
703
|
+
if (!scope.vars.has(node.name))
|
|
704
|
+
throw new TestError(
|
|
705
|
+
`Unknown variable ${JSON.stringify(node.name)} in test`,
|
|
706
|
+
{ diagnostics: [locOf(node)] },
|
|
707
|
+
);
|
|
708
|
+
const previous = scope.vars.get(node.name).value;
|
|
709
|
+
const value = evaluate(node.value, scope);
|
|
710
|
+
const next =
|
|
711
|
+
node.op === "="
|
|
712
|
+
? value
|
|
713
|
+
: node.op === "+="
|
|
714
|
+
? isNumeric(previous) && isNumeric(value)
|
|
715
|
+
? Number(previous) + Number(value)
|
|
716
|
+
: String(previous) + String(value)
|
|
717
|
+
: node.op === "-="
|
|
718
|
+
? Number(previous) - Number(value)
|
|
719
|
+
: node.op === "*="
|
|
720
|
+
? Number(previous) * Number(value)
|
|
721
|
+
: Number(previous) / Number(value);
|
|
722
|
+
scope.vars.get(node.name).value = next;
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
case "If":
|
|
726
|
+
if (truthy(evaluate(node.cond, scope)))
|
|
727
|
+
executeStatements(node.then, scope);
|
|
728
|
+
else if (node.else) executeStatements(node.else, scope);
|
|
729
|
+
return;
|
|
730
|
+
case "Repeat": {
|
|
731
|
+
const count = Math.round(Number(evaluate(node.count, scope)));
|
|
732
|
+
for (let index = 0; index < count; index += 1) {
|
|
733
|
+
checkDeadline(scope);
|
|
734
|
+
executeStatements(node.body, scope);
|
|
735
|
+
}
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
case "While":
|
|
739
|
+
while (truthy(evaluate(node.cond, scope))) {
|
|
740
|
+
checkDeadline(scope);
|
|
741
|
+
executeStatements(node.body, scope);
|
|
742
|
+
}
|
|
743
|
+
return;
|
|
744
|
+
case "Forever":
|
|
745
|
+
for (;;) {
|
|
746
|
+
checkDeadline(scope);
|
|
747
|
+
executeStatements(node.body, scope);
|
|
748
|
+
}
|
|
749
|
+
case "Return":
|
|
750
|
+
throw new ReturnSignal(node.value ? evaluate(node.value, scope) : 0);
|
|
751
|
+
case "Expect":
|
|
752
|
+
assertExpectation(node, scope);
|
|
753
|
+
return;
|
|
754
|
+
case "BlockCall":
|
|
755
|
+
executeAction(node, scope);
|
|
756
|
+
return;
|
|
757
|
+
default:
|
|
758
|
+
throw new TestError(`Unsupported statement ${node.type} in a test`, {
|
|
759
|
+
diagnostics: [locOf(node)],
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const COMPARISONS = {
|
|
765
|
+
"==": "to equal",
|
|
766
|
+
"!=": "not to equal",
|
|
767
|
+
">": "to be greater than",
|
|
768
|
+
">=": "to be at least",
|
|
769
|
+
"<": "to be less than",
|
|
770
|
+
"<=": "to be at most",
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
/** Throw a detailed `AssertionFailure` when an `expect` is not truthy. */
|
|
774
|
+
function assertExpectation(node, scope) {
|
|
775
|
+
if (truthy(evaluate(node.value, scope))) return;
|
|
776
|
+
|
|
777
|
+
let message = "expected a truthy value";
|
|
778
|
+
let expected;
|
|
779
|
+
let received;
|
|
780
|
+
if (node.value.type === "Binary" && COMPARISONS[node.value.op]) {
|
|
781
|
+
received = evaluate(node.value.left, scope);
|
|
782
|
+
expected = evaluate(node.value.right, scope);
|
|
783
|
+
message = `expected ${formatValue(received)} ${COMPARISONS[node.value.op]} ${formatValue(expected)}`;
|
|
784
|
+
}
|
|
785
|
+
if (node.message) message = `${node.message}: ${message}`;
|
|
786
|
+
|
|
787
|
+
throw new AssertionFailure({
|
|
788
|
+
message,
|
|
789
|
+
expected,
|
|
790
|
+
received,
|
|
791
|
+
...locOf(node),
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Advance simulated time by `count` frames.
|
|
797
|
+
*
|
|
798
|
+
* `runtime._step()` reads `currentMSecs`, so advancing it directly keeps
|
|
799
|
+
* waits, timers and the tick counter deterministic and independent of the
|
|
800
|
+
* real clock. Nothing here sleeps or waits on wall-clock time; the deadline
|
|
801
|
+
* check is the only place real time can stop a run.
|
|
802
|
+
*/
|
|
803
|
+
function stepTicks(scope, count) {
|
|
804
|
+
for (let index = 0; index < count; index += 1) {
|
|
805
|
+
checkDeadline(scope);
|
|
806
|
+
try {
|
|
807
|
+
scope.vm.runtime.currentMSecs += 1000 / scope.config.ticksPerSecond;
|
|
808
|
+
scope.vm.runtime._step();
|
|
809
|
+
} catch (error) {
|
|
810
|
+
scope.capture.errors.push(error?.message ?? String(error));
|
|
811
|
+
if (scope.config.failOnRuntimeError) throw error;
|
|
812
|
+
}
|
|
813
|
+
scope.progress.ticks += 1;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Run a built-in test action, falling back to a project block call. */
|
|
818
|
+
function executeAction(node, scope) {
|
|
819
|
+
const name = (node.segments ?? [node.name]).join(".");
|
|
820
|
+
const arg = (index) => evaluate(argument(node, index), scope);
|
|
821
|
+
const { vm } = scope;
|
|
822
|
+
|
|
823
|
+
switch (name) {
|
|
824
|
+
case "reset": {
|
|
825
|
+
restoreVm(vm, scope.initial);
|
|
826
|
+
scope.progress.ticks = 0;
|
|
827
|
+
scope.broadcasts.clear();
|
|
828
|
+
scope.capture.errors.length = 0;
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
case "start":
|
|
832
|
+
vm.greenFlag();
|
|
833
|
+
return;
|
|
834
|
+
case "step":
|
|
835
|
+
stepTicks(scope, node.args.length ? Math.round(Number(arg(0))) : 1);
|
|
836
|
+
return;
|
|
837
|
+
case "broadcast": {
|
|
838
|
+
const message = String(arg(0));
|
|
839
|
+
scope.broadcasts.add(message);
|
|
840
|
+
vm.runtime.startHats("event_whenbroadcastreceived", {
|
|
841
|
+
BROADCAST_OPTION: message,
|
|
842
|
+
});
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
case "broadcastAndWait": {
|
|
846
|
+
const message = String(arg(0));
|
|
847
|
+
scope.broadcasts.add(message);
|
|
848
|
+
const threads = vm.runtime.startHats("event_whenbroadcastreceived", {
|
|
849
|
+
BROADCAST_OPTION: message,
|
|
850
|
+
});
|
|
851
|
+
// Only wait for the scripts this broadcast started. Other threads, such
|
|
852
|
+
// as a `forever` loop launched by `start()`, keep running independently.
|
|
853
|
+
while (
|
|
854
|
+
Array.isArray(threads) &&
|
|
855
|
+
threads.some((thread) => vm.runtime.threads.includes(thread))
|
|
856
|
+
)
|
|
857
|
+
stepTicks(scope, 1);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
case "click": {
|
|
861
|
+
const target = node.args.length
|
|
862
|
+
? vm.runtime.getSpriteTargetByName(String(arg(0)))
|
|
863
|
+
: null;
|
|
864
|
+
if (node.args.length && !target)
|
|
865
|
+
throw new TestError(
|
|
866
|
+
`No sprite named ${JSON.stringify(String(arg(0)))} to click`,
|
|
867
|
+
{ diagnostics: [locOf(node)] },
|
|
868
|
+
);
|
|
869
|
+
if (!target) {
|
|
870
|
+
vm.runtime.startHats("event_whenstageclicked");
|
|
871
|
+
for (const sprite of vm.runtime.targets.filter((item) => !item.isStage))
|
|
872
|
+
vm.runtime.startHats("event_whenthisspriteclicked", null, sprite);
|
|
873
|
+
} else if (target.isStage) {
|
|
874
|
+
vm.runtime.startHats("event_whenstageclicked", null, target);
|
|
875
|
+
} else {
|
|
876
|
+
vm.runtime.startHats("event_whenthisspriteclicked", null, target);
|
|
877
|
+
}
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
case "keyDown":
|
|
881
|
+
case "keyUp":
|
|
882
|
+
vm.postIOData("keyboard", {
|
|
883
|
+
key: String(arg(0)),
|
|
884
|
+
isDown: name === "keyDown",
|
|
885
|
+
});
|
|
886
|
+
return;
|
|
887
|
+
default:
|
|
888
|
+
// Anything that is not a built-in test action may be a project block.
|
|
889
|
+
callProjectProcedure(node, scope);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// ---------------------------------------------------------------- run one test
|
|
895
|
+
|
|
896
|
+
/** Trim a failure result to the fields a reporter needs. */
|
|
897
|
+
function toError(result) {
|
|
898
|
+
return {
|
|
899
|
+
name: result.name,
|
|
900
|
+
message: result.message,
|
|
901
|
+
file: result.file,
|
|
902
|
+
line: result.line,
|
|
903
|
+
column: result.column,
|
|
904
|
+
expected: result.expected,
|
|
905
|
+
received: result.received,
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** Network APIs tests may not use without an explicit opt-in. */
|
|
910
|
+
const EXTERNAL_IO_APIS = ["fetch", "WebSocket"];
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Make network access fail clearly instead of reaching the host.
|
|
914
|
+
*
|
|
915
|
+
* The VM loads its own extension sources through `fetch`, so URLs the runner
|
|
916
|
+
* already resolved for this project remain readable; every other HTTP request
|
|
917
|
+
* is rejected. Extensions the user explicitly trusts still run with full Node
|
|
918
|
+
* access, so this covers the VM's web APIs rather than containing code the
|
|
919
|
+
* user chose to run. Every replaced property is restored after the test.
|
|
920
|
+
*/
|
|
921
|
+
export function installExternalIOGuard(extensionUrls = []) {
|
|
922
|
+
const allowed = new Set(extensionUrls);
|
|
923
|
+
for (const url of extensionUrls) {
|
|
924
|
+
try {
|
|
925
|
+
allowed.add(new URL(url).href);
|
|
926
|
+
} catch {
|
|
927
|
+
// Keep entries that cannot be parsed usable in their original form.
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
const saved = [];
|
|
931
|
+
const originalFetch = globalThis.fetch;
|
|
932
|
+
const block = (name) => {
|
|
933
|
+
throw new TestError(
|
|
934
|
+
`External network access is blocked during fx test (${name}). ` +
|
|
935
|
+
'Pass "--allow-external-io" to allow it.',
|
|
936
|
+
{ exitCode: TEST_EXIT.ERROR },
|
|
937
|
+
);
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
if (typeof originalFetch === "function") {
|
|
941
|
+
saved.push(["fetch", Object.getOwnPropertyDescriptor(globalThis, "fetch")]);
|
|
942
|
+
Object.defineProperty(globalThis, "fetch", {
|
|
943
|
+
configurable: true,
|
|
944
|
+
writable: true,
|
|
945
|
+
value: async function guardedFetch(input, init) {
|
|
946
|
+
const url =
|
|
947
|
+
typeof input === "string"
|
|
948
|
+
? input
|
|
949
|
+
: input instanceof URL
|
|
950
|
+
? input.href
|
|
951
|
+
: input?.url;
|
|
952
|
+
const text = String(url ?? "");
|
|
953
|
+
if (allowed.has(text) || /^(data|blob|file):/i.test(text))
|
|
954
|
+
return originalFetch.call(globalThis, input, init);
|
|
955
|
+
return block("fetch");
|
|
956
|
+
},
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
for (const name of EXTERNAL_IO_APIS) {
|
|
961
|
+
if (name === "fetch") continue;
|
|
962
|
+
const descriptor = Object.getOwnPropertyDescriptor(globalThis, name);
|
|
963
|
+
if (!descriptor) continue;
|
|
964
|
+
saved.push([name, descriptor]);
|
|
965
|
+
Object.defineProperty(globalThis, name, {
|
|
966
|
+
configurable: true,
|
|
967
|
+
writable: true,
|
|
968
|
+
value: function blockedExternalIO() {
|
|
969
|
+
block(name);
|
|
970
|
+
},
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
return () => {
|
|
974
|
+
for (const [name, descriptor] of saved)
|
|
975
|
+
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** Run one test with external I/O blocked unless the user allowed it. */
|
|
980
|
+
async function runOneTest(options) {
|
|
981
|
+
const restoreExternalIO = options.config.allowExternalIO
|
|
982
|
+
? () => {}
|
|
983
|
+
: installExternalIOGuard(Object.values(options.extensionUrls ?? {}));
|
|
984
|
+
try {
|
|
985
|
+
return await runOneTestInVm(options);
|
|
986
|
+
} finally {
|
|
987
|
+
restoreExternalIO();
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Run one test in a fresh VM and return its outcome.
|
|
993
|
+
* @returns {Promise<object>} status, duration, ticks and any failure
|
|
994
|
+
*/
|
|
995
|
+
async function runOneTestInVm({ test, helpers, sb3, config, seed }) {
|
|
996
|
+
const started = performance.now();
|
|
997
|
+
let vm;
|
|
998
|
+
try {
|
|
999
|
+
vm = await loadProject(sb3, {
|
|
1000
|
+
allowProjectExtensions: config.allowProjectExtensions,
|
|
1001
|
+
});
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
// A VM or extension load failure is an execution error, not a failed
|
|
1004
|
+
// expectation, so it must not be counted as a test failure.
|
|
1005
|
+
return {
|
|
1006
|
+
name: test.name,
|
|
1007
|
+
status: "error",
|
|
1008
|
+
durationMs: Math.round(performance.now() - started),
|
|
1009
|
+
ticks: 0,
|
|
1010
|
+
seed,
|
|
1011
|
+
error: {
|
|
1012
|
+
name: error?.name ?? "ProjectLoadError",
|
|
1013
|
+
message: `could not load the project: ${error?.message ?? String(error)}`,
|
|
1014
|
+
...locOf(test),
|
|
1015
|
+
},
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
vm.runtime.currentStepTime = 1000 / config.ticksPerSecond;
|
|
1019
|
+
// The sequencer refreshes this clock each frame; keep it on simulated time.
|
|
1020
|
+
vm.runtime.updateCurrentMSecs = () => {};
|
|
1021
|
+
|
|
1022
|
+
const capture = { errors: [], warnings: [] };
|
|
1023
|
+
const originalError = console.error;
|
|
1024
|
+
const originalWarn = console.warn;
|
|
1025
|
+
const originalRandom = Math.random;
|
|
1026
|
+
const runtime = vm.runtime;
|
|
1027
|
+
const compileErrorEvent =
|
|
1028
|
+
runtime.constructor?.COMPILE_ERROR ?? "COMPILE_ERROR";
|
|
1029
|
+
const onCompileError = (_target, error) =>
|
|
1030
|
+
capture.errors.push(error?.message ?? String(error));
|
|
1031
|
+
runtime.on?.(compileErrorEvent, onCompileError);
|
|
1032
|
+
|
|
1033
|
+
console.error = (...args) => capture.errors.push(args.map(String).join(" "));
|
|
1034
|
+
console.warn = (...args) => capture.warnings.push(args.map(String).join(" "));
|
|
1035
|
+
Math.random = mulberry32(seed);
|
|
1036
|
+
|
|
1037
|
+
const scope = createScope({
|
|
1038
|
+
vm,
|
|
1039
|
+
config,
|
|
1040
|
+
seed,
|
|
1041
|
+
capture,
|
|
1042
|
+
deadline: started + config.timeout,
|
|
1043
|
+
warnings: capture.warnings,
|
|
1044
|
+
});
|
|
1045
|
+
scope.initial = snapshotVm(vm);
|
|
1046
|
+
|
|
1047
|
+
let status = "passed";
|
|
1048
|
+
let failure = null;
|
|
1049
|
+
try {
|
|
1050
|
+
executeStatements(helpers, scope);
|
|
1051
|
+
executeStatements(test.body, scope);
|
|
1052
|
+
if (config.failOnRuntimeError && capture.errors.length > 0)
|
|
1053
|
+
throw new AssertionFailure({
|
|
1054
|
+
name: "RuntimeError",
|
|
1055
|
+
message: `runtime error: ${capture.errors[0]}`,
|
|
1056
|
+
...locOf(test),
|
|
1057
|
+
});
|
|
1058
|
+
} catch (error) {
|
|
1059
|
+
status = "failed";
|
|
1060
|
+
if (error instanceof AssertionFailure) failure = toError(error.details);
|
|
1061
|
+
else if (error instanceof TimeoutFailure)
|
|
1062
|
+
failure = {
|
|
1063
|
+
name: "TimeoutError",
|
|
1064
|
+
message: error.message,
|
|
1065
|
+
...locOf(test),
|
|
1066
|
+
};
|
|
1067
|
+
else if (error instanceof TestError)
|
|
1068
|
+
failure = {
|
|
1069
|
+
name: "TestLanguageError",
|
|
1070
|
+
message: error.message,
|
|
1071
|
+
...(error.diagnostics[0] ?? locOf(test)),
|
|
1072
|
+
};
|
|
1073
|
+
else
|
|
1074
|
+
failure = {
|
|
1075
|
+
name: error?.name ?? "Error",
|
|
1076
|
+
message: error?.message ?? String(error),
|
|
1077
|
+
...locOf(test),
|
|
1078
|
+
};
|
|
1079
|
+
} finally {
|
|
1080
|
+
runtime.off?.(compileErrorEvent, onCompileError);
|
|
1081
|
+
console.error = originalError;
|
|
1082
|
+
console.warn = originalWarn;
|
|
1083
|
+
Math.random = originalRandom;
|
|
1084
|
+
try {
|
|
1085
|
+
vm.stopAll();
|
|
1086
|
+
} catch {
|
|
1087
|
+
// A VM that failed to load has nothing to stop.
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
return {
|
|
1092
|
+
name: test.name,
|
|
1093
|
+
status,
|
|
1094
|
+
durationMs: Math.round(performance.now() - started),
|
|
1095
|
+
ticks: scope.progress.ticks,
|
|
1096
|
+
seed,
|
|
1097
|
+
error: failure,
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// ---------------------------------------------------------------------- driver
|
|
1102
|
+
|
|
1103
|
+
/** Load vendored, feature and legacy extensions into one catalog. */
|
|
1104
|
+
async function loadCatalog({ configDir, loaded }) {
|
|
1105
|
+
const catalog = {};
|
|
1106
|
+
const extensionUrls = {};
|
|
1107
|
+
const warnings = [];
|
|
1108
|
+
if (loaded.vendor?.length) {
|
|
1109
|
+
const vendored = await loadVendorExtensions({ configDir, config: loaded });
|
|
1110
|
+
Object.assign(catalog, vendored.catalog);
|
|
1111
|
+
Object.assign(extensionUrls, vendored.extensionUrls);
|
|
1112
|
+
warnings.push(...vendored.warnings);
|
|
1113
|
+
}
|
|
1114
|
+
if (loaded.features?.length) {
|
|
1115
|
+
const features = await loadFeatureExtensions({ configDir, config: loaded });
|
|
1116
|
+
Object.assign(catalog, features.catalog);
|
|
1117
|
+
Object.assign(extensionUrls, features.extensionUrls);
|
|
1118
|
+
warnings.push(...features.warnings);
|
|
1119
|
+
}
|
|
1120
|
+
if (loaded.project?.extensions?.length) {
|
|
1121
|
+
const legacy = await loadExtensions(loaded.project.extensions, {
|
|
1122
|
+
baseDir: configDir,
|
|
1123
|
+
expectUnsandboxed: loaded.project.unsandboxed,
|
|
1124
|
+
reportUnmatchedUnsandboxed: false,
|
|
1125
|
+
});
|
|
1126
|
+
Object.assign(catalog, legacy.catalog);
|
|
1127
|
+
Object.assign(extensionUrls, legacy.extensionUrls);
|
|
1128
|
+
warnings.push(...legacy.warnings);
|
|
1129
|
+
}
|
|
1130
|
+
return { catalog, extensionUrls, warnings };
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/** One-line description of a failure, with its source location. */
|
|
1134
|
+
function describeFailure(error) {
|
|
1135
|
+
if (!error) return "";
|
|
1136
|
+
const where =
|
|
1137
|
+
error.file && error.line
|
|
1138
|
+
? `${error.file}:${error.line}:${error.column ?? 0}`
|
|
1139
|
+
: (error.file ?? "");
|
|
1140
|
+
const head = `${error.name}: ${error.message}`;
|
|
1141
|
+
return where ? `${head}\n ${where}` : head;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Run project tests.
|
|
1146
|
+
*
|
|
1147
|
+
* @param {object} [options]
|
|
1148
|
+
* @param {string} [options.dir] project directory
|
|
1149
|
+
* @param {string[]} [options.files] explicit test files, relative to `cwd`
|
|
1150
|
+
* @param {string} [options.cwd] base for explicit files
|
|
1151
|
+
* @param {string} [options.filter] substring matched against file and test names
|
|
1152
|
+
* @param {object} [options.loaded] preloaded project config
|
|
1153
|
+
* @param {Record<string, object>} [options.catalog]
|
|
1154
|
+
* @param {Record<string, string>} [options.extensionUrls]
|
|
1155
|
+
* @param {number} [options.seed]
|
|
1156
|
+
* @param {number} [options.timeout]
|
|
1157
|
+
* @param {number} [options.ticksPerSecond]
|
|
1158
|
+
* @param {boolean} [options.failOnRuntimeError]
|
|
1159
|
+
* @param {boolean} [options.allowProjectExtensions] trust this project's extensions
|
|
1160
|
+
* @param {boolean} [options.allowExternalIO] allow network access during tests
|
|
1161
|
+
* @param {(event: object) => void} [options.onEvent]
|
|
1162
|
+
* @returns {Promise<object>}
|
|
1163
|
+
*/
|
|
1164
|
+
export async function runTests({
|
|
1165
|
+
dir = process.cwd(),
|
|
1166
|
+
files,
|
|
1167
|
+
cwd = process.cwd(),
|
|
1168
|
+
filter = "",
|
|
1169
|
+
loaded: provided,
|
|
1170
|
+
catalog,
|
|
1171
|
+
extensionUrls,
|
|
1172
|
+
seed,
|
|
1173
|
+
timeout,
|
|
1174
|
+
ticksPerSecond,
|
|
1175
|
+
failOnRuntimeError,
|
|
1176
|
+
allowProjectExtensions = false,
|
|
1177
|
+
allowExternalIO = false,
|
|
1178
|
+
onEvent = () => {},
|
|
1179
|
+
} = {}) {
|
|
1180
|
+
const started = performance.now();
|
|
1181
|
+
const startDate = new Date();
|
|
1182
|
+
const root = path.resolve(dir);
|
|
1183
|
+
const loaded = provided ?? loadConfigFrom(root);
|
|
1184
|
+
const configDir = loaded.path ? path.dirname(loaded.path) : root;
|
|
1185
|
+
const settings = loaded.fx?.tests;
|
|
1186
|
+
|
|
1187
|
+
if (!settings)
|
|
1188
|
+
throw new TestError("Testing is disabled in this project.", {
|
|
1189
|
+
exitCode: TEST_EXIT.ERROR,
|
|
1190
|
+
hint: 'run "fx feature tests enable" to turn testing on',
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
const config = {
|
|
1194
|
+
...DEFAULT_TESTS,
|
|
1195
|
+
...settings,
|
|
1196
|
+
seed: seed ?? settings.seed ?? DEFAULT_TESTS.seed,
|
|
1197
|
+
timeout: timeout ?? settings.timeout ?? DEFAULT_TESTS.timeout,
|
|
1198
|
+
ticksPerSecond:
|
|
1199
|
+
ticksPerSecond ?? settings.ticksPerSecond ?? DEFAULT_TESTS.ticksPerSecond,
|
|
1200
|
+
failOnRuntimeError:
|
|
1201
|
+
failOnRuntimeError ??
|
|
1202
|
+
settings.failOnRuntimeError ??
|
|
1203
|
+
DEFAULT_TESTS.failOnRuntimeError,
|
|
1204
|
+
// Trust and network access belong to this invocation, never to the project
|
|
1205
|
+
// configuration, so a cloned project cannot grant them to itself.
|
|
1206
|
+
allowProjectExtensions: Boolean(allowProjectExtensions),
|
|
1207
|
+
allowExternalIO: Boolean(allowExternalIO),
|
|
1208
|
+
};
|
|
1209
|
+
|
|
1210
|
+
const discovered = files?.length
|
|
1211
|
+
? files.map((file) => {
|
|
1212
|
+
const absolute = path.resolve(cwd, file);
|
|
1213
|
+
return {
|
|
1214
|
+
file: absolute,
|
|
1215
|
+
relative: path.relative(root, absolute).split(path.sep).join("/"),
|
|
1216
|
+
};
|
|
1217
|
+
})
|
|
1218
|
+
: discoverTests({ dir: root, pattern: settings.pattern });
|
|
1219
|
+
|
|
1220
|
+
const result = {
|
|
1221
|
+
dir: root,
|
|
1222
|
+
seed: config.seed,
|
|
1223
|
+
version: productVersion(),
|
|
1224
|
+
startTime: startDate.toISOString(),
|
|
1225
|
+
durationMs: 0,
|
|
1226
|
+
files: [],
|
|
1227
|
+
diagnostics: [],
|
|
1228
|
+
counts: {
|
|
1229
|
+
files: discovered.length,
|
|
1230
|
+
passedFiles: 0,
|
|
1231
|
+
failedFiles: 0,
|
|
1232
|
+
errorFiles: 0,
|
|
1233
|
+
tests: 0,
|
|
1234
|
+
passed: 0,
|
|
1235
|
+
failed: 0,
|
|
1236
|
+
errors: 0,
|
|
1237
|
+
pending: 0,
|
|
1238
|
+
},
|
|
1239
|
+
ok: false,
|
|
1240
|
+
exitCode: TEST_EXIT.OK,
|
|
1241
|
+
};
|
|
1242
|
+
|
|
1243
|
+
onEvent({
|
|
1244
|
+
event: "run-start",
|
|
1245
|
+
dir: root,
|
|
1246
|
+
seed: config.seed,
|
|
1247
|
+
version: result.version,
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
if (discovered.length === 0) {
|
|
1251
|
+
result.diagnostics.push({
|
|
1252
|
+
file: null,
|
|
1253
|
+
message: `No test files matched ${JSON.stringify(settings.pattern)} under ${root}`,
|
|
1254
|
+
});
|
|
1255
|
+
result.exitCode = TEST_EXIT.ERROR;
|
|
1256
|
+
onEvent({ event: "diagnostic", ...result.diagnostics[0] });
|
|
1257
|
+
result.durationMs = Math.round(performance.now() - started);
|
|
1258
|
+
onEvent({ event: "run-end", result });
|
|
1259
|
+
return result;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const parsed = parseTests(
|
|
1263
|
+
discovered.map((entry) => ({
|
|
1264
|
+
file: entry.relative,
|
|
1265
|
+
source: fs.readFileSync(entry.file, "utf8"),
|
|
1266
|
+
})),
|
|
1267
|
+
);
|
|
1268
|
+
if (parsed.diagnostics.length > 0) {
|
|
1269
|
+
result.diagnostics.push(...parsed.diagnostics);
|
|
1270
|
+
for (const diagnostic of parsed.diagnostics)
|
|
1271
|
+
onEvent({ event: "diagnostic", ...diagnostic });
|
|
1272
|
+
result.exitCode = TEST_EXIT.ERROR;
|
|
1273
|
+
result.durationMs = Math.round(performance.now() - started);
|
|
1274
|
+
onEvent({ event: "run-end", result });
|
|
1275
|
+
return result;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Discovery happens before the build so reporters can show every selected
|
|
1279
|
+
// test as pending, then running, then completed.
|
|
1280
|
+
const needle = String(filter ?? "").toLowerCase();
|
|
1281
|
+
const wanted = (file, test) =>
|
|
1282
|
+
needle === "" ||
|
|
1283
|
+
file.toLowerCase().includes(needle) ||
|
|
1284
|
+
test.toLowerCase().includes(needle);
|
|
1285
|
+
onEvent({
|
|
1286
|
+
event: "discovered",
|
|
1287
|
+
files: parsed.files
|
|
1288
|
+
.map((file) => ({
|
|
1289
|
+
file: file.file,
|
|
1290
|
+
tests: file.tests
|
|
1291
|
+
.filter((test) => wanted(file.file, test.name))
|
|
1292
|
+
.map((test) => ({ name: test.name, status: "pending" })),
|
|
1293
|
+
}))
|
|
1294
|
+
.filter((file) => file.tests.length > 0),
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
let extensions = { catalog, extensionUrls, warnings: [] };
|
|
1298
|
+
if (!catalog) extensions = await loadCatalog({ configDir, loaded });
|
|
1299
|
+
|
|
1300
|
+
// A project extension is arbitrary JavaScript that would run with Node
|
|
1301
|
+
// process access in the VM, including its top-level code and configured
|
|
1302
|
+
// extensions no test calls. Without an explicit user opt-in, stop before
|
|
1303
|
+
// running anything and say why instead of loading it or silently ignoring
|
|
1304
|
+
// it. The opt-in is an invocation option, not an fx.yml setting.
|
|
1305
|
+
const projectExtensionUrls = extensions.extensionUrls ?? {};
|
|
1306
|
+
if (
|
|
1307
|
+
!config.allowProjectExtensions &&
|
|
1308
|
+
Object.keys(projectExtensionUrls).length > 0
|
|
1309
|
+
) {
|
|
1310
|
+
const ids = Object.keys(projectExtensionUrls).sort();
|
|
1311
|
+
result.diagnostics.push({
|
|
1312
|
+
file: null,
|
|
1313
|
+
message:
|
|
1314
|
+
`Project extensions are blocked during fx test: ${ids
|
|
1315
|
+
.map((id) => JSON.stringify(id))
|
|
1316
|
+
.join(", ")}. ` +
|
|
1317
|
+
'Pass "--allow-project-extensions" to trust and load this project\'s extensions for this run.',
|
|
1318
|
+
});
|
|
1319
|
+
result.exitCode = TEST_EXIT.ERROR;
|
|
1320
|
+
onEvent({ event: "diagnostic", ...result.diagnostics[0] });
|
|
1321
|
+
result.durationMs = Math.round(performance.now() - started);
|
|
1322
|
+
onEvent({ event: "run-end", result });
|
|
1323
|
+
return result;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
let built;
|
|
1327
|
+
try {
|
|
1328
|
+
built = build({
|
|
1329
|
+
dir: root,
|
|
1330
|
+
projectConfig: loaded.project,
|
|
1331
|
+
fxConfig: loaded.fx,
|
|
1332
|
+
catalog: extensions.catalog,
|
|
1333
|
+
extensionUrls: extensions.extensionUrls,
|
|
1334
|
+
useConfigFile: false,
|
|
1335
|
+
testPattern: settings.pattern,
|
|
1336
|
+
});
|
|
1337
|
+
} catch (error) {
|
|
1338
|
+
result.diagnostics.push({
|
|
1339
|
+
file: error.file ?? null,
|
|
1340
|
+
line: error.line ?? null,
|
|
1341
|
+
column: error.column ?? null,
|
|
1342
|
+
message: error.message,
|
|
1343
|
+
});
|
|
1344
|
+
onEvent({ event: "diagnostic", ...result.diagnostics[0] });
|
|
1345
|
+
result.exitCode = TEST_EXIT.ERROR;
|
|
1346
|
+
result.durationMs = Math.round(performance.now() - started);
|
|
1347
|
+
onEvent({ event: "run-end", result });
|
|
1348
|
+
return result;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
for (const file of parsed.files) {
|
|
1352
|
+
const selected = file.tests.filter((test) => wanted(file.file, test.name));
|
|
1353
|
+
if (selected.length === 0) continue;
|
|
1354
|
+
|
|
1355
|
+
const fileResult = {
|
|
1356
|
+
file: file.file,
|
|
1357
|
+
status: "passed",
|
|
1358
|
+
durationMs: 0,
|
|
1359
|
+
tests: [],
|
|
1360
|
+
};
|
|
1361
|
+
const fileStart = performance.now();
|
|
1362
|
+
onEvent({ event: "file-start", file: file.file });
|
|
1363
|
+
|
|
1364
|
+
for (const test of selected) {
|
|
1365
|
+
onEvent({
|
|
1366
|
+
event: "test-start",
|
|
1367
|
+
file: file.file,
|
|
1368
|
+
name: test.name,
|
|
1369
|
+
status: "running",
|
|
1370
|
+
});
|
|
1371
|
+
let outcome;
|
|
1372
|
+
try {
|
|
1373
|
+
outcome = await runOneTest({
|
|
1374
|
+
test,
|
|
1375
|
+
helpers: file.helpers,
|
|
1376
|
+
sb3: built.sb3,
|
|
1377
|
+
config,
|
|
1378
|
+
seed: config.seed,
|
|
1379
|
+
extensionUrls: extensions.extensionUrls,
|
|
1380
|
+
});
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
outcome = {
|
|
1383
|
+
name: test.name,
|
|
1384
|
+
status: "error",
|
|
1385
|
+
durationMs: 0,
|
|
1386
|
+
ticks: 0,
|
|
1387
|
+
seed: config.seed,
|
|
1388
|
+
error: {
|
|
1389
|
+
name: error?.name ?? "Error",
|
|
1390
|
+
message: error?.message ?? String(error),
|
|
1391
|
+
...locOf(test),
|
|
1392
|
+
},
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
fileResult.tests.push(outcome);
|
|
1396
|
+
result.counts.tests += 1;
|
|
1397
|
+
if (outcome.status === "passed") result.counts.passed += 1;
|
|
1398
|
+
else if (outcome.status === "failed") result.counts.failed += 1;
|
|
1399
|
+
else if (outcome.status === "error") {
|
|
1400
|
+
result.counts.errors += 1;
|
|
1401
|
+
const diagnostic = {
|
|
1402
|
+
file: file.file,
|
|
1403
|
+
message: `${test.name}: ${outcome.error?.message ?? "test execution failed"}`,
|
|
1404
|
+
};
|
|
1405
|
+
result.diagnostics.push(diagnostic);
|
|
1406
|
+
onEvent({ event: "diagnostic", ...diagnostic });
|
|
1407
|
+
} else result.counts.pending += 1;
|
|
1408
|
+
onEvent({
|
|
1409
|
+
event: "test-result",
|
|
1410
|
+
file: file.file,
|
|
1411
|
+
...outcome,
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
fileResult.durationMs = Math.round(performance.now() - fileStart);
|
|
1416
|
+
if (fileResult.tests.some((item) => item.status === "error"))
|
|
1417
|
+
fileResult.status = "error";
|
|
1418
|
+
else if (fileResult.tests.some((item) => item.status === "failed"))
|
|
1419
|
+
fileResult.status = "failed";
|
|
1420
|
+
result.files.push(fileResult);
|
|
1421
|
+
if (fileResult.status === "passed") result.counts.passedFiles += 1;
|
|
1422
|
+
else if (fileResult.status === "failed") result.counts.failedFiles += 1;
|
|
1423
|
+
else result.counts.errorFiles += 1;
|
|
1424
|
+
onEvent({
|
|
1425
|
+
event: "file-result",
|
|
1426
|
+
file: file.file,
|
|
1427
|
+
status: fileResult.status,
|
|
1428
|
+
tests: fileResult.tests,
|
|
1429
|
+
durationMs: fileResult.durationMs,
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
result.durationMs = Math.round(performance.now() - started);
|
|
1434
|
+
result.ok =
|
|
1435
|
+
result.counts.failed === 0 &&
|
|
1436
|
+
result.counts.errors === 0 &&
|
|
1437
|
+
result.counts.tests > 0;
|
|
1438
|
+
result.exitCode =
|
|
1439
|
+
result.counts.tests === 0 || result.counts.errors > 0
|
|
1440
|
+
? TEST_EXIT.ERROR
|
|
1441
|
+
: result.counts.failed > 0
|
|
1442
|
+
? TEST_EXIT.FAILED
|
|
1443
|
+
: TEST_EXIT.OK;
|
|
1444
|
+
if (result.counts.tests === 0)
|
|
1445
|
+
result.diagnostics.push({
|
|
1446
|
+
file: null,
|
|
1447
|
+
message: `No tests matched ${JSON.stringify(filter)}`,
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
onEvent({ event: "run-end", result });
|
|
1451
|
+
return result;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/** Format one failure for reporters. */
|
|
1455
|
+
export function formatFailure(error) {
|
|
1456
|
+
return describeFailure(error);
|
|
1457
|
+
}
|