@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@triflare/fx",
|
|
3
|
-
"version": "2.1.0-beta.
|
|
3
|
+
"version": "2.1.0-beta.2",
|
|
4
4
|
"description": "Code PenguinMod projects with text.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"js-yaml": "^5.4.3",
|
|
51
51
|
"jszip": "^3.10.1",
|
|
52
52
|
"quickjs-emscripten": "^0.32.0",
|
|
53
|
+
"scratch-vm": "github:PenguinMod/PenguinMod-Vm#75ef6994e55270b9c2a2f3cb2edf6a5bf31cdf2a",
|
|
53
54
|
"sha.js": "^2.4.12"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
@@ -57,8 +58,7 @@
|
|
|
57
58
|
"eslint": "^10.14.0",
|
|
58
59
|
"eslint-config-prettier": "^10.1.8",
|
|
59
60
|
"globals": "^17.13.0",
|
|
60
|
-
"prettier": "^3.9.10"
|
|
61
|
-
"scratch-vm": "github:PenguinMod/PenguinMod-Vm#75ef6994e55270b9c2a2f3cb2edf6a5bf31cdf2a"
|
|
61
|
+
"prettier": "^3.9.10"
|
|
62
62
|
},
|
|
63
63
|
"overrides": {
|
|
64
64
|
"minimatch": "^11.0.4",
|
package/product.yml
CHANGED
package/src/build.js
CHANGED
|
@@ -9,6 +9,7 @@ import { Compiler } from "./compiler.js";
|
|
|
9
9
|
import { zipSync } from "./sb3.js";
|
|
10
10
|
import { loadConfigFrom, DEFAULT_PROJECT, DEFAULT_FX } from "./config.js";
|
|
11
11
|
import { sourceExtension } from "./brand.js";
|
|
12
|
+
import { DEFAULT_TEST_PATTERN } from "./test/discover.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* @typedef {object} BuildOptions
|
|
@@ -30,7 +31,7 @@ import { sourceExtension } from "./brand.js";
|
|
|
30
31
|
* @param {BuildOptions} options
|
|
31
32
|
* @returns {{dir: string, entries: Array<{file: string, source: string}>}}
|
|
32
33
|
*/
|
|
33
|
-
function resolveSources(options) {
|
|
34
|
+
function resolveSources(options, { testPattern = DEFAULT_TEST_PATTERN } = {}) {
|
|
34
35
|
const cwd = process.cwd();
|
|
35
36
|
|
|
36
37
|
if (Array.isArray(options.sources)) {
|
|
@@ -84,7 +85,7 @@ function resolveSources(options) {
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
const dir = path.resolve(options.dir || cwd);
|
|
87
|
-
const files = discoverSources(dir);
|
|
88
|
+
const files = discoverSources(dir, { testPattern });
|
|
88
89
|
if (files.length === 0)
|
|
89
90
|
throw new Error(`No ${sourceExtension()} files found under ${dir}`);
|
|
90
91
|
return {
|
|
@@ -101,15 +102,23 @@ function resolveSources(options) {
|
|
|
101
102
|
* @returns {{project: object, assets: Map<string, Buffer>, sb3: Buffer, config: object, fx: object, ast: object, files: string[], warnings: string[]}}
|
|
102
103
|
*/
|
|
103
104
|
export function build(options = {}) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
// Load the config before scanning, so a project's test glob decides which
|
|
106
|
+
// files are test files (and therefore excluded from the build).
|
|
107
|
+
const searchDir =
|
|
108
|
+
options.configDir ||
|
|
109
|
+
(options.dir
|
|
110
|
+
? path.resolve(options.dir)
|
|
111
|
+
: options.file
|
|
112
|
+
? path.dirname(path.resolve(options.file))
|
|
113
|
+
: Array.isArray(options.files) && options.files.length > 0
|
|
114
|
+
? path.dirname(path.resolve(options.files[0]))
|
|
115
|
+
: process.cwd());
|
|
107
116
|
|
|
108
117
|
let projectConfig = options.projectConfig || null;
|
|
109
118
|
let fxConfig = options.fxConfig || null;
|
|
110
119
|
let configPath = null;
|
|
111
120
|
if (!projectConfig && options.useConfigFile !== false) {
|
|
112
|
-
const loaded = loadConfigFrom(
|
|
121
|
+
const loaded = loadConfigFrom(searchDir);
|
|
113
122
|
projectConfig = loaded.project;
|
|
114
123
|
fxConfig = loaded.fx;
|
|
115
124
|
configPath = loaded.path;
|
|
@@ -117,6 +126,14 @@ export function build(options = {}) {
|
|
|
117
126
|
if (!projectConfig) projectConfig = { ...DEFAULT_PROJECT };
|
|
118
127
|
if (!fxConfig) fxConfig = { ...DEFAULT_FX };
|
|
119
128
|
|
|
129
|
+
const { dir, entries } = resolveSources(options, {
|
|
130
|
+
testPattern:
|
|
131
|
+
options.testPattern ??
|
|
132
|
+
(fxConfig.tests ? fxConfig.tests.pattern : DEFAULT_TEST_PATTERN),
|
|
133
|
+
});
|
|
134
|
+
if (entries.length === 0)
|
|
135
|
+
throw new Error("No Triflare Fx sources to compile");
|
|
136
|
+
|
|
120
137
|
const asts = entries.map(({ file, source }) => ({
|
|
121
138
|
ast: parse(source, file),
|
|
122
139
|
file,
|
|
@@ -26,7 +26,9 @@ building an archive.`,
|
|
|
26
26
|
migrateLegacy: false,
|
|
27
27
|
});
|
|
28
28
|
const { target, loaded } = state;
|
|
29
|
-
const files = target.file
|
|
29
|
+
const files = target.file
|
|
30
|
+
? [target.file]
|
|
31
|
+
: discoverSources(target.dir, { testPattern: loaded.fx?.tests?.pattern });
|
|
30
32
|
if (files.length === 0)
|
|
31
33
|
throw new UsageError(`No ${EXTENSION} files found under ${target.dir}`);
|
|
32
34
|
|
|
@@ -2,6 +2,8 @@ import path from "node:path";
|
|
|
2
2
|
import {
|
|
3
3
|
disableFeature,
|
|
4
4
|
enableFeature,
|
|
5
|
+
featureEnabled,
|
|
6
|
+
getFeature,
|
|
5
7
|
updateFeature,
|
|
6
8
|
} from "../../features.js";
|
|
7
9
|
import { loadVendorConfig, requireConfigPath } from "./vendor/helpers.js";
|
|
@@ -14,7 +16,7 @@ export const featureCommand = {
|
|
|
14
16
|
group: "Core Commands:",
|
|
15
17
|
summary: "Install, update or remove a built-in language feature",
|
|
16
18
|
description: `Features are extension-backed language additions. "enable" copies the\nfeature's file into the project and records it under "features:" in fx.yml;\n"update" replaces that copy with the one bundled in this Fx version; and\n"disable" removes both. The file is committed, so Fx upgrades never change it.`,
|
|
17
|
-
arguments: ["<name>", "
|
|
19
|
+
arguments: ["<name>", "[action]"],
|
|
18
20
|
options: [
|
|
19
21
|
{
|
|
20
22
|
flags: "--force",
|
|
@@ -29,14 +31,37 @@ export const featureCommand = {
|
|
|
29
31
|
],
|
|
30
32
|
async run({ args, options, cwd, reporter, prompts, yes, product }) {
|
|
31
33
|
const [name, action] = args;
|
|
34
|
+
const configPath = requireConfigPath(cwd, product);
|
|
35
|
+
const configDir = path.dirname(configPath);
|
|
36
|
+
const feature = getFeature(name);
|
|
37
|
+
|
|
38
|
+
if (action == null) {
|
|
39
|
+
if (!feature.core)
|
|
40
|
+
throw new UsageError(`Missing an action for ${JSON.stringify(name)}.`, {
|
|
41
|
+
hint: `use one of: ${ACTIONS.join(", ")}`,
|
|
42
|
+
});
|
|
43
|
+
const enabled = featureEnabled({ configPath, name: feature.name });
|
|
44
|
+
reporter.detail("Feature", feature.title);
|
|
45
|
+
reporter.detail("Status", enabled ? "enabled" : "disabled");
|
|
46
|
+
reporter.detail("Setting", `fx.${feature.name}`);
|
|
47
|
+
reporter.info(
|
|
48
|
+
enabled
|
|
49
|
+
? `Run "fx test" to run the project's tests.`
|
|
50
|
+
: `Run "fx feature ${feature.name} enable" to enable it.`,
|
|
51
|
+
);
|
|
52
|
+
return {
|
|
53
|
+
feature: feature.name,
|
|
54
|
+
action: "status",
|
|
55
|
+
enabled,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
32
59
|
if (!ACTIONS.includes(action))
|
|
33
60
|
throw new UsageError(
|
|
34
61
|
`Unknown feature action ${JSON.stringify(action)} for ${JSON.stringify(name)}.`,
|
|
35
62
|
{ hint: `use one of: ${ACTIONS.join(", ")}` },
|
|
36
63
|
);
|
|
37
64
|
|
|
38
|
-
const configPath = requireConfigPath(cwd, product);
|
|
39
|
-
const configDir = path.dirname(configPath);
|
|
40
65
|
if (action === "enable") {
|
|
41
66
|
const loaded = loadVendorConfig(configPath);
|
|
42
67
|
const result = enableFeature({
|
|
@@ -47,8 +72,12 @@ export const featureCommand = {
|
|
|
47
72
|
force: Boolean(options.force),
|
|
48
73
|
});
|
|
49
74
|
if (result.changed)
|
|
50
|
-
reporter.success(
|
|
51
|
-
|
|
75
|
+
reporter.success(
|
|
76
|
+
result.file
|
|
77
|
+
? `${result.feature.title} enabled (${result.file})`
|
|
78
|
+
: `${result.feature.title} enabled`,
|
|
79
|
+
);
|
|
80
|
+
else reporter.info(`${result.feature.title} is already enabled`);
|
|
52
81
|
if (result.configChanged)
|
|
53
82
|
reporter.info(`Recorded the feature in ${path.basename(configPath)}`);
|
|
54
83
|
return {
|
|
@@ -60,6 +89,26 @@ export const featureCommand = {
|
|
|
60
89
|
}
|
|
61
90
|
|
|
62
91
|
if (action === "disable") {
|
|
92
|
+
if (feature.core) {
|
|
93
|
+
const result = disableFeature({ configPath, configDir, name });
|
|
94
|
+
if (!result.changed) {
|
|
95
|
+
reporter.info(`${result.feature.title} is already disabled`);
|
|
96
|
+
return {
|
|
97
|
+
feature: result.name,
|
|
98
|
+
action,
|
|
99
|
+
changed: false,
|
|
100
|
+
warning: null,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
reporter.success(`Disabled ${result.feature.title}`);
|
|
104
|
+
if (result.warning) reporter.warn(result.warning);
|
|
105
|
+
return {
|
|
106
|
+
feature: result.name,
|
|
107
|
+
action,
|
|
108
|
+
changed: true,
|
|
109
|
+
warning: result.warning,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
63
112
|
await prompts.confirmDestructive({
|
|
64
113
|
message: `Disable ${name} and delete its installed file?`,
|
|
65
114
|
yes,
|
|
@@ -11,6 +11,7 @@ import { initCommand } from "./init.js";
|
|
|
11
11
|
import { lintCommand } from "./lint.js";
|
|
12
12
|
import { packageCommand } from "./package.js";
|
|
13
13
|
import { registerSprite } from "./sprite/index.js";
|
|
14
|
+
import { testCommand } from "./test.js";
|
|
14
15
|
import { registerVendor } from "./vendor/index.js";
|
|
15
16
|
import { defineCommand } from "../define-command.js";
|
|
16
17
|
|
|
@@ -24,6 +25,7 @@ const TOP_LEVEL = [
|
|
|
24
25
|
fmtCommand,
|
|
25
26
|
catalogCommand,
|
|
26
27
|
featureCommand,
|
|
28
|
+
testCommand,
|
|
27
29
|
initCommand,
|
|
28
30
|
completionCommand,
|
|
29
31
|
];
|
package/src/cli/commands/lint.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { sourceExtension } from "../../brand.js";
|
|
4
4
|
import { discoverSources } from "../../project.js";
|
|
5
|
+
import { DEFAULT_TEST_PATTERN } from "../../test/discover.js";
|
|
5
6
|
import { applyFixes, lintSources } from "../../linter.js";
|
|
6
7
|
import {
|
|
7
8
|
effectivePreset,
|
|
@@ -178,6 +179,9 @@ Exit status is 1 when errors are reported, or when warnings are reported with
|
|
|
178
179
|
configPath: state.loaded.path,
|
|
179
180
|
configDir: state.configDir,
|
|
180
181
|
rootDir: state.configDir ?? cwd,
|
|
182
|
+
testPattern: state.loaded.fx?.tests
|
|
183
|
+
? state.loaded.fx.tests.pattern
|
|
184
|
+
: DEFAULT_TEST_PATTERN,
|
|
181
185
|
extensionCatalog: state.catalog,
|
|
182
186
|
lintConfig,
|
|
183
187
|
strict: Boolean(options.strict),
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { loadProjectState } from "../context.js";
|
|
3
|
+
import { CliError } from "../errors.js";
|
|
4
|
+
import { outcome } from "../define-command.js";
|
|
5
|
+
import { createTestReporter, REPORTER_NAMES } from "../../test/reporters.js";
|
|
6
|
+
import { runTests, TestError, TEST_EXIT } from "../../test/runner.js";
|
|
7
|
+
|
|
8
|
+
const WATCH_IGNORED = /(^|\/)(node_modules|build|dist|\.git|doc_build)(\/|$)/;
|
|
9
|
+
|
|
10
|
+
/** Resolve a `--reporter` value, mapping the global `--json` flag onto it. */
|
|
11
|
+
function reporterName(options) {
|
|
12
|
+
const name = String(options.reporter ?? "default");
|
|
13
|
+
if (options.json && name === "default") return "json";
|
|
14
|
+
if (!REPORTER_NAMES.includes(name))
|
|
15
|
+
throw new CliError(`Unknown reporter ${JSON.stringify(name)}.`, {
|
|
16
|
+
code: "TEST_USAGE",
|
|
17
|
+
exitCode: TEST_EXIT.USAGE,
|
|
18
|
+
hint: `use one of: ${REPORTER_NAMES.join(", ")}`,
|
|
19
|
+
});
|
|
20
|
+
return name;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Parse a positive numeric option, reporting usage errors with its label. */
|
|
24
|
+
function validateNumber(value, label) {
|
|
25
|
+
if (value === undefined) return undefined;
|
|
26
|
+
const number = Number(value);
|
|
27
|
+
if (!Number.isFinite(number) || number <= 0)
|
|
28
|
+
throw new CliError(`${label} must be a positive number`, {
|
|
29
|
+
code: "TEST_USAGE",
|
|
30
|
+
exitCode: TEST_EXIT.USAGE,
|
|
31
|
+
});
|
|
32
|
+
return number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Wait for the next source or config change. */
|
|
36
|
+
function nextChange(dir, signal) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
let watcher;
|
|
39
|
+
let timer = null;
|
|
40
|
+
const done = () => {
|
|
41
|
+
if (timer) clearTimeout(timer);
|
|
42
|
+
watcher?.close();
|
|
43
|
+
signal.removeEventListener("abort", done);
|
|
44
|
+
resolve();
|
|
45
|
+
};
|
|
46
|
+
if (signal.aborted) return done();
|
|
47
|
+
signal.addEventListener("abort", done, { once: true });
|
|
48
|
+
try {
|
|
49
|
+
watcher = fs.watch(dir, { recursive: true }, (event, filename) => {
|
|
50
|
+
if (!filename) return;
|
|
51
|
+
const name = String(filename).split("\\").join("/");
|
|
52
|
+
if (WATCH_IGNORED.test(name)) return;
|
|
53
|
+
if (!/\.(tfx|ya?ml)$/i.test(name)) return;
|
|
54
|
+
if (timer) clearTimeout(timer);
|
|
55
|
+
timer = setTimeout(done, 80);
|
|
56
|
+
});
|
|
57
|
+
} catch (error) {
|
|
58
|
+
signal.removeEventListener("abort", done);
|
|
59
|
+
reject(error);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const testCommand = {
|
|
65
|
+
name: "test",
|
|
66
|
+
group: "Core Commands:",
|
|
67
|
+
summary: "Run project tests in a headless PenguinMod VM",
|
|
68
|
+
description: `Discover test files (test/**/*.test.tfx by default), build the project
|
|
69
|
+
once, and run every test in a fresh VM. Testing is a language feature: enable
|
|
70
|
+
it with "fx feature tests enable".`,
|
|
71
|
+
arguments: ["[files...]"],
|
|
72
|
+
options: [
|
|
73
|
+
{
|
|
74
|
+
flags: "-t, --filter <text>",
|
|
75
|
+
description: "Only run tests whose name or file contains <text>",
|
|
76
|
+
},
|
|
77
|
+
{ flags: "-w, --watch", description: "Re-run when sources change" },
|
|
78
|
+
{ flags: "--run", description: "Run once and exit (the default)" },
|
|
79
|
+
{
|
|
80
|
+
flags: "--reporter <name>",
|
|
81
|
+
description: `Output format: ${REPORTER_NAMES.join(", ")}`,
|
|
82
|
+
defaultValue: "default",
|
|
83
|
+
},
|
|
84
|
+
{ flags: "--seed <number>", description: "Seed the random generator" },
|
|
85
|
+
{
|
|
86
|
+
flags: "--timeout <ms>",
|
|
87
|
+
description: "Per-test timeout in milliseconds",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
flags: "--allow-project-extensions",
|
|
91
|
+
description: "Trust and load this project's extensions for this run",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
flags: "--allow-external-io",
|
|
95
|
+
description: "Allow network access (fetch and WebSocket) during tests",
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
examples: [
|
|
99
|
+
"fx test",
|
|
100
|
+
"fx test test/score.test.tfx",
|
|
101
|
+
"fx test --filter score --reporter verbose",
|
|
102
|
+
"fx test --reporter junit",
|
|
103
|
+
"fx test --allow-project-extensions",
|
|
104
|
+
],
|
|
105
|
+
jsonEvents: true,
|
|
106
|
+
async run({ args, options, cwd, reporter, prompts }) {
|
|
107
|
+
// Commander hands a variadic argument back as one array.
|
|
108
|
+
const files = (Array.isArray(args[0]) ? args[0] : args).filter(Boolean);
|
|
109
|
+
const name = reporterName(options);
|
|
110
|
+
const color =
|
|
111
|
+
options.color === "always" ||
|
|
112
|
+
(options.color !== "never" &&
|
|
113
|
+
options.color !== false &&
|
|
114
|
+
Boolean(process.stdout.isTTY) &&
|
|
115
|
+
!process.env.NO_COLOR);
|
|
116
|
+
const testReporter = createTestReporter({
|
|
117
|
+
name,
|
|
118
|
+
stream: process.stdout,
|
|
119
|
+
color,
|
|
120
|
+
tty: Boolean(process.stdout.isTTY),
|
|
121
|
+
dir: cwd,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const runOnce = async () => {
|
|
125
|
+
const state = await loadProjectState({
|
|
126
|
+
input: undefined,
|
|
127
|
+
cwd,
|
|
128
|
+
options,
|
|
129
|
+
reporter,
|
|
130
|
+
prompts,
|
|
131
|
+
});
|
|
132
|
+
return runTests({
|
|
133
|
+
dir: state.configDir,
|
|
134
|
+
files,
|
|
135
|
+
cwd,
|
|
136
|
+
filter: options.filter ?? "",
|
|
137
|
+
loaded: state.loaded,
|
|
138
|
+
catalog: state.catalog,
|
|
139
|
+
extensionUrls: state.extensionUrls,
|
|
140
|
+
seed: validateNumber(options.seed, "--seed"),
|
|
141
|
+
timeout: validateNumber(options.timeout, "--timeout"),
|
|
142
|
+
allowProjectExtensions: Boolean(options.allowProjectExtensions),
|
|
143
|
+
allowExternalIO: Boolean(options.allowExternalIO),
|
|
144
|
+
onEvent: testReporter.onEvent,
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (!options.watch) {
|
|
149
|
+
try {
|
|
150
|
+
const result = await runOnce();
|
|
151
|
+
return outcome({ data: result, exitCode: result.exitCode });
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error instanceof TestError)
|
|
154
|
+
throw new CliError(error.message, {
|
|
155
|
+
code: "TEST_ERROR",
|
|
156
|
+
exitCode: error.exitCode,
|
|
157
|
+
hint: error.hint,
|
|
158
|
+
cause: error,
|
|
159
|
+
});
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
let interrupted = false;
|
|
166
|
+
const onInterrupt = () => {
|
|
167
|
+
interrupted = true;
|
|
168
|
+
controller.abort();
|
|
169
|
+
};
|
|
170
|
+
process.once("SIGINT", onInterrupt);
|
|
171
|
+
try {
|
|
172
|
+
for (;;) {
|
|
173
|
+
const result = await runOnce();
|
|
174
|
+
if (interrupted)
|
|
175
|
+
return outcome({ data: result, exitCode: TEST_EXIT.INTERRUPTED });
|
|
176
|
+
reporter.info("Watching for changes... (Ctrl+C to stop)");
|
|
177
|
+
await nextChange(result.dir, controller.signal);
|
|
178
|
+
if (interrupted)
|
|
179
|
+
return outcome({ data: result, exitCode: TEST_EXIT.INTERRUPTED });
|
|
180
|
+
}
|
|
181
|
+
} finally {
|
|
182
|
+
controller.abort();
|
|
183
|
+
process.off("SIGINT", onInterrupt);
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
};
|
package/src/cli/context.js
CHANGED
|
@@ -26,8 +26,10 @@ export function resolveInput(input, cwd = process.cwd()) {
|
|
|
26
26
|
try {
|
|
27
27
|
stat = fs.statSync(resolved);
|
|
28
28
|
} catch {
|
|
29
|
+
// Name the absolute path that was checked: relative inputs are resolved
|
|
30
|
+
// from the working directory, not from the project root.
|
|
29
31
|
throw new UsageError(`input not found: ${input}`, {
|
|
30
|
-
hint: `check the path, or run from the project directory`,
|
|
32
|
+
hint: `looked for ${resolved}; check the path, or run from the project directory`,
|
|
31
33
|
});
|
|
32
34
|
}
|
|
33
35
|
return stat.isDirectory() ? { dir: resolved } : { file: resolved };
|
package/src/config-edit.js
CHANGED
|
@@ -673,6 +673,84 @@ export function addFeatureEntry(configPath, name, file) {
|
|
|
673
673
|
return { added, updated };
|
|
674
674
|
}
|
|
675
675
|
|
|
676
|
+
/** Read a raw `fx.<key>` value without normalising the rest of the config. */
|
|
677
|
+
export function readFxValue(configPath, key) {
|
|
678
|
+
const raw = yaml.load(fs.readFileSync(configPath, "utf8")) ?? {};
|
|
679
|
+
if (typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
680
|
+
const fx = raw.fx;
|
|
681
|
+
if (!fx || typeof fx !== "object" || Array.isArray(fx)) return undefined;
|
|
682
|
+
return fx[key];
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Set a scalar `fx.<key>` value while preserving the rest of the file.
|
|
687
|
+
*
|
|
688
|
+
* A nested block under the key (for example a hand-written `fx.tests:`
|
|
689
|
+
* mapping) is replaced by the scalar, so enabling and disabling a feature
|
|
690
|
+
* always leaves one canonical value behind.
|
|
691
|
+
*
|
|
692
|
+
* @param {string} configPath
|
|
693
|
+
* @param {string} key
|
|
694
|
+
* @param {string|number|boolean} value
|
|
695
|
+
* @returns {{changed: boolean}}
|
|
696
|
+
*/
|
|
697
|
+
export function setFxScalar(configPath, key, value) {
|
|
698
|
+
const text = fs.readFileSync(configPath, "utf8");
|
|
699
|
+
const lines = splitLines(text);
|
|
700
|
+
const eol = eolOf(text);
|
|
701
|
+
const scalar = typeof value === "string" ? quote(value) : String(value);
|
|
702
|
+
const fx = topLevel(lines, "fx");
|
|
703
|
+
|
|
704
|
+
if (!fx) {
|
|
705
|
+
if (lines.length > 0 && !lines[lines.length - 1].endsWith("\n"))
|
|
706
|
+
lines[lines.length - 1] += eol;
|
|
707
|
+
lines.push(`fx:${eol}`, ` ${key}: ${scalar}${eol}`);
|
|
708
|
+
writeDocument(configPath, lines.join(""));
|
|
709
|
+
return { changed: true };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const { value: rest, comment } = splitComment(fx.rest);
|
|
713
|
+
if (rest !== "") {
|
|
714
|
+
if (!rest.startsWith("{") || !rest.endsWith("}"))
|
|
715
|
+
throw new Error("fx must be a block mapping to edit it");
|
|
716
|
+
const mapping = parseValue(rest);
|
|
717
|
+
if (!mapping || typeof mapping !== "object" || Array.isArray(mapping))
|
|
718
|
+
throw new Error("fx must be a mapping to edit it");
|
|
719
|
+
if (mapping[key] === value) return { changed: false };
|
|
720
|
+
mapping[key] = value;
|
|
721
|
+
const dumped = yaml
|
|
722
|
+
.dump({ fx: mapping }, { lineWidth: -1, noRefs: true })
|
|
723
|
+
.split("\n")
|
|
724
|
+
.filter((line, index, all) => !(index === all.length - 1 && line === ""))
|
|
725
|
+
.map((line) => `${line}${eol}`);
|
|
726
|
+
if (comment) dumped[0] = `fx: ${comment}${eol}`;
|
|
727
|
+
replaceRange(lines, fx.index, fx.index + 1, dumped);
|
|
728
|
+
writeDocument(configPath, lines.join(""));
|
|
729
|
+
return { changed: true };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const end = sectionEnd(lines, fx.index);
|
|
733
|
+
const existing = mappingEntrySpans(lines, fx.index, end).find(
|
|
734
|
+
(span) => span.key === key,
|
|
735
|
+
);
|
|
736
|
+
if (existing) {
|
|
737
|
+
let entryEnd = existing.end;
|
|
738
|
+
while (
|
|
739
|
+
entryEnd > existing.index + 1 &&
|
|
740
|
+
/^(?:[ \t]*#.*|[ \t]*)$/.test(content(lines[entryEnd - 1]))
|
|
741
|
+
)
|
|
742
|
+
entryEnd--;
|
|
743
|
+
replaceRange(lines, existing.index, entryEnd, [
|
|
744
|
+
`${existing.indent}${quoteKey(key)}: ${scalar}${eol}`,
|
|
745
|
+
]);
|
|
746
|
+
} else {
|
|
747
|
+
const indent = childIndent(lines, fx.index, end);
|
|
748
|
+
insertAt(lines, end, [`${indent}${quoteKey(key)}: ${scalar}${eol}`], eol);
|
|
749
|
+
}
|
|
750
|
+
writeDocument(configPath, lines.join(""));
|
|
751
|
+
return { changed: true };
|
|
752
|
+
}
|
|
753
|
+
|
|
676
754
|
/**
|
|
677
755
|
* Remove a `features:` entry.
|
|
678
756
|
* @param {string} configPath
|
package/src/config.js
CHANGED
|
@@ -26,6 +26,19 @@ export const DEFAULT_PROJECT = {
|
|
|
26
26
|
unsandboxed: [],
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Deterministic defaults for `fx test`. Virtual time is derived from
|
|
31
|
+
* `ticksPerSecond`; `seed` seeds the pseudo-random generator for the run.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_TESTS = {
|
|
34
|
+
pattern: "test/**/*.test.tfx",
|
|
35
|
+
seed: 12345,
|
|
36
|
+
timeout: 1000,
|
|
37
|
+
ticksPerSecond: 30,
|
|
38
|
+
failOnRuntimeError: true,
|
|
39
|
+
parallel: false,
|
|
40
|
+
};
|
|
41
|
+
|
|
29
42
|
/**
|
|
30
43
|
* Fx-only settings that never reach PenguinMod's project runtime config.
|
|
31
44
|
* Kept in a separate top-level `fx:` block in fx.yml.
|
|
@@ -33,9 +46,72 @@ export const DEFAULT_PROJECT = {
|
|
|
33
46
|
export const DEFAULT_FX = {
|
|
34
47
|
partialSprites: false,
|
|
35
48
|
vendorDir: "extensions",
|
|
49
|
+
tests: false,
|
|
36
50
|
lint: { ...DEFAULT_LINT, rules: { ...DEFAULT_LINT.rules } },
|
|
37
51
|
};
|
|
38
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Normalize the `fx.tests` setting.
|
|
55
|
+
*
|
|
56
|
+
* `true` enables testing with the defaults, `false` (or an absent key)
|
|
57
|
+
* disables it, and a mapping may override any default. The mapping form is
|
|
58
|
+
* what `fx feature tests enable` writes and later edits by hand.
|
|
59
|
+
*
|
|
60
|
+
* @param {unknown} value
|
|
61
|
+
* @returns {false | (typeof DEFAULT_TESTS & {enabled: true})}
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeTestConfig(value) {
|
|
64
|
+
if (value == null || value === false) return false;
|
|
65
|
+
if (value === true) return { enabled: true, ...DEFAULT_TESTS };
|
|
66
|
+
if (!isPlainObject(value))
|
|
67
|
+
throw new Error(
|
|
68
|
+
`${configFilename()}: fx.tests must be true, false, or a mapping`,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const unknown = Object.keys(value).filter(
|
|
72
|
+
(key) => !Object.hasOwn(DEFAULT_TESTS, key),
|
|
73
|
+
);
|
|
74
|
+
if (unknown.length)
|
|
75
|
+
throw new Error(
|
|
76
|
+
`${configFilename()}: unknown fx.tests setting(s): ${unknown
|
|
77
|
+
.map((key) => `"${key}"`)
|
|
78
|
+
.join(", ")}`,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const tests = { enabled: true, ...DEFAULT_TESTS };
|
|
82
|
+
if (value.pattern != null) {
|
|
83
|
+
if (typeof value.pattern !== "string" || value.pattern.trim() === "")
|
|
84
|
+
throw new Error(`${configFilename()}: fx.tests.pattern must be a glob`);
|
|
85
|
+
tests.pattern = value.pattern.trim();
|
|
86
|
+
}
|
|
87
|
+
if (value.seed != null) {
|
|
88
|
+
const seed = Number(value.seed);
|
|
89
|
+
if (!Number.isFinite(seed))
|
|
90
|
+
throw new Error(`${configFilename()}: fx.tests.seed must be a number`);
|
|
91
|
+
tests.seed = seed;
|
|
92
|
+
}
|
|
93
|
+
if (value.timeout != null) {
|
|
94
|
+
const timeout = Number(value.timeout);
|
|
95
|
+
if (!Number.isFinite(timeout) || timeout <= 0)
|
|
96
|
+
throw new Error(
|
|
97
|
+
`${configFilename()}: fx.tests.timeout must be a positive number of milliseconds`,
|
|
98
|
+
);
|
|
99
|
+
tests.timeout = timeout;
|
|
100
|
+
}
|
|
101
|
+
if (value.ticksPerSecond != null) {
|
|
102
|
+
const ticks = Number(value.ticksPerSecond);
|
|
103
|
+
if (!Number.isFinite(ticks) || ticks <= 0)
|
|
104
|
+
throw new Error(
|
|
105
|
+
`${configFilename()}: fx.tests.ticksPerSecond must be positive`,
|
|
106
|
+
);
|
|
107
|
+
tests.ticksPerSecond = ticks;
|
|
108
|
+
}
|
|
109
|
+
if (value.failOnRuntimeError != null)
|
|
110
|
+
tests.failOnRuntimeError = Boolean(value.failOnRuntimeError);
|
|
111
|
+
if (value.parallel != null) tests.parallel = Boolean(value.parallel);
|
|
112
|
+
return tests;
|
|
113
|
+
}
|
|
114
|
+
|
|
39
115
|
/** Default directory that receives vendored extension sources. */
|
|
40
116
|
export const DEFAULT_VENDOR_DIR = "extensions";
|
|
41
117
|
|
|
@@ -418,6 +494,7 @@ export function normalizeFx(raw = {}) {
|
|
|
418
494
|
throw new Error(`${configFilename()}: fx.vendorDir must be a path`);
|
|
419
495
|
fx.vendorDir = raw.vendorDir.trim();
|
|
420
496
|
}
|
|
497
|
+
if (raw.tests !== undefined) fx.tests = normalizeTestConfig(raw.tests);
|
|
421
498
|
fx.lint = normalizeLintConfig(
|
|
422
499
|
raw.lint,
|
|
423
500
|
(message) => new Error(`${configFilename()}: ${message}`),
|