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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/features.js CHANGED
@@ -12,7 +12,12 @@ import fs from "node:fs";
12
12
  import path from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { parseConfig } from "./config.js";
15
- import { addFeatureEntry, removeFeatureEntry } from "./config-edit.js";
15
+ import {
16
+ addFeatureEntry,
17
+ readFxValue,
18
+ removeFeatureEntry,
19
+ setFxScalar,
20
+ } from "./config-edit.js";
16
21
  import { loadExtensions } from "./extensions.js";
17
22
  import { formatDiff } from "./vendor.js";
18
23
 
@@ -40,15 +45,29 @@ export const FEATURES = {
40
45
  },
41
46
  };
42
47
 
48
+ /**
49
+ * Built-in language/compiler features. These have no extension source and no
50
+ * vendored copy: they live behind a single `fx.<name>` setting, so they are
51
+ * enabled and disabled without touching `features:`.
52
+ */
53
+ export const CORE_FEATURES = {
54
+ tests: {
55
+ name: "tests",
56
+ title: "Testing",
57
+ summary: "Write project tests and run them with `fx test`.",
58
+ core: true,
59
+ },
60
+ };
61
+
43
62
  /** List feature names for help and error messages. */
44
63
  export function featureNames() {
45
- return Object.keys(FEATURES).sort();
64
+ return [...Object.keys(FEATURES), ...Object.keys(CORE_FEATURES)].sort();
46
65
  }
47
66
 
48
67
  /** Resolve one feature by name. */
49
68
  export function getFeature(name) {
50
69
  const key = String(name ?? "");
51
- const feature = FEATURES[key];
70
+ const feature = FEATURES[key] ?? CORE_FEATURES[key];
52
71
  if (!feature)
53
72
  throw new FeatureError(
54
73
  `Unknown feature ${JSON.stringify(key)}. Available features: ${featureNames().join(", ")}.`,
@@ -158,6 +177,39 @@ function readFeatureEntries(configPath) {
158
177
  * @param {boolean} [options.force] replace a modified copy
159
178
  * @returns {{feature: object, name: string, file: string, path: string, changed: boolean, configChanged: boolean}}
160
179
  */
180
+ /** True when a feature's setting is enabled in the project config. */
181
+ function coreFeatureEnabled(configPath, feature) {
182
+ if (!configPath || !fs.existsSync(configPath)) return false;
183
+ return Boolean(readFxValue(configPath, feature.name));
184
+ }
185
+
186
+ /**
187
+ * Whether a feature is currently enabled in a project.
188
+ * @param {{configPath?: string, name: string}} options
189
+ */
190
+ export function featureEnabled({ configPath, name } = {}) {
191
+ const feature = getFeature(name);
192
+ if (feature.core) return coreFeatureEnabled(configPath, feature);
193
+ if (!configPath || !fs.existsSync(configPath)) return false;
194
+ return Boolean(
195
+ readFeatureEntries(configPath).find((entry) => entry.name === feature.name),
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Enable a feature in a project.
201
+ *
202
+ * Extension features copy their source into the project; core features only
203
+ * record `fx.<name>: true` in `fx.yml`.
204
+ *
205
+ * @param {object} options
206
+ * @param {string} options.configPath
207
+ * @param {string} options.configDir
208
+ * @param {string} options.name
209
+ * @param {string} [options.vendorDir]
210
+ * @param {boolean} [options.force] replace a modified copy
211
+ * @returns {{feature: object, name: string, file: string|null, path: string|null, changed: boolean, configChanged: boolean}}
212
+ */
161
213
  export function enableFeature({
162
214
  configPath,
163
215
  configDir,
@@ -166,6 +218,18 @@ export function enableFeature({
166
218
  force = false,
167
219
  } = {}) {
168
220
  const feature = getFeature(name);
221
+ if (feature.core) {
222
+ const changed = !coreFeatureEnabled(configPath, feature);
223
+ if (changed) setFxScalar(configPath, feature.name, true);
224
+ return {
225
+ feature,
226
+ name: feature.name,
227
+ file: null,
228
+ path: null,
229
+ changed,
230
+ configChanged: changed,
231
+ };
232
+ }
169
233
  const dir = assertInside(
170
234
  configDir,
171
235
  path.resolve(configDir, String(vendorDir || "extensions")),
@@ -212,6 +276,24 @@ export function enableFeature({
212
276
  */
213
277
  export function disableFeature({ configPath, configDir, name } = {}) {
214
278
  const feature = getFeature(name);
279
+ if (feature.core) {
280
+ if (!coreFeatureEnabled(configPath, feature))
281
+ return {
282
+ feature,
283
+ name: feature.name,
284
+ file: null,
285
+ changed: false,
286
+ warning: null,
287
+ };
288
+ setFxScalar(configPath, feature.name, false);
289
+ return {
290
+ feature,
291
+ name: feature.name,
292
+ file: null,
293
+ changed: true,
294
+ warning: `${feature.title} will become a core feature in a future version.`,
295
+ };
296
+ }
215
297
  const entry = readFeatureEntries(configPath).find(
216
298
  (candidate) => candidate.name === feature.name,
217
299
  );
@@ -259,6 +341,10 @@ export function updateFeature({
259
341
  dryRun = false,
260
342
  } = {}) {
261
343
  const feature = getFeature(name);
344
+ if (feature.core)
345
+ throw new FeatureError(
346
+ `${feature.title} is built into Fx, so there is no vendored source or lock entry to update.`,
347
+ );
262
348
  const entry = readFeatureEntries(configPath).find(
263
349
  (candidate) => candidate.name === feature.name,
264
350
  );
package/src/formatter.js CHANGED
@@ -81,6 +81,10 @@ function renderExpression(node, minimum = 0, parentOperator) {
81
81
  return `${node.name}(${node.args
82
82
  .map((arg) => renderExpression(arg))
83
83
  .join(", ")})`;
84
+ case "Member":
85
+ // Member access binds tighter than every operator, so its object is
86
+ // rendered without parentheses, as in `state.list("items").length`.
87
+ return `${renderExpression(node.object)}.${node.property}`;
84
88
  case "ArrayLiteral":
85
89
  return `[${node.items.map((item) => renderExpression(item)).join(", ")}]`;
86
90
  case "Unary": {
@@ -131,6 +135,7 @@ function category(node) {
131
135
  case "FunctionDecl":
132
136
  return "function";
133
137
  case "HatDecl":
138
+ case "TestDecl":
134
139
  return "script";
135
140
  default:
136
141
  return "statement";
@@ -353,6 +358,17 @@ class Formatter {
353
358
  this.renderNodeBody(node, indent);
354
359
  this.out.push(`${pad}}`);
355
360
  return;
361
+ case "TestDecl":
362
+ this.out.push(`${pad}test ${stringLiteral(node.name)} {`);
363
+ this.renderNodeBody(node, indent);
364
+ this.out.push(`${pad}}`);
365
+ return;
366
+ case "Expect":
367
+ this.out.push(
368
+ `${pad}expect ${renderExpression(node.value)}` +
369
+ `${node.message ? `, ${stringLiteral(node.message)}` : ""};`,
370
+ );
371
+ return;
356
372
  case "If":
357
373
  this.renderIf(node, indent);
358
374
  return;
@@ -475,7 +491,11 @@ class Formatter {
475
491
  format() {
476
492
  const units = [];
477
493
  if (this.program.stage) units.push(this.program.stage);
478
- units.push(...this.program.sprites, ...this.program.topItems);
494
+ units.push(
495
+ ...this.program.sprites,
496
+ ...this.program.topItems,
497
+ ...this.program.tests,
498
+ );
479
499
  // Preserve the order declarations had in this file; only separate them.
480
500
  units.sort((a, b) => this.tokenIndexOf(a.loc) - this.tokenIndexOf(b.loc));
481
501
  this.renderBody(units, 0, this.tokens.length - 1);
package/src/index.js CHANGED
@@ -61,6 +61,27 @@ export {
61
61
  writeLock,
62
62
  } from "./vendor.js";
63
63
  export { formatDevUrls, injectDevClient, startDevServer } from "./dev.js";
64
+ export {
65
+ DEFAULT_TEST_PATTERN,
66
+ discoverTests,
67
+ globToRegExp,
68
+ isTestFile,
69
+ matchesTestPattern,
70
+ } from "./test/discover.js";
71
+ export { parseTestFile, parseTests } from "./test/parse.js";
72
+ export {
73
+ TEST_EXIT,
74
+ TestError,
75
+ formatFailure,
76
+ runTests,
77
+ } from "./test/runner.js";
78
+ export {
79
+ REPORTER_NAMES,
80
+ createTestReporter,
81
+ jsonReport,
82
+ junitReport,
83
+ } from "./test/reporters.js";
84
+ export { createVm, loadProject, loadVmModules } from "./vm.js";
64
85
  export * from "./catalog.js";
65
86
  export * from "./config.js";
66
87
  export { FxError, LexError, ParseError, CompileError } from "./errors.js";
@@ -14,6 +14,7 @@ import { ParseError } from "../errors.js";
14
14
  import { tokenizeWithComments } from "../lexer.js";
15
15
  import { mergePrograms } from "../merge.js";
16
16
  import { parse } from "../parser.js";
17
+ import { DEFAULT_TEST_PATTERN, isTestPath } from "../test/pattern.js";
17
18
  import { DEFAULT_LINT, resolveRuleSeverities } from "./config.js";
18
19
  import { RULES, ruleById } from "./rules.js";
19
20
 
@@ -1193,6 +1194,7 @@ export function sortDiagnostics(diagnostics) {
1193
1194
  * configPath?: string|null,
1194
1195
  * configDir?: string|null,
1195
1196
  * rootDir?: string|null,
1197
+ * testPattern?: string|null,
1196
1198
  * extensionCatalog?: Record<string, object>,
1197
1199
  * declaredExtensions?: string[],
1198
1200
  * lintConfig?: object,
@@ -1211,6 +1213,9 @@ export function lintSources({
1211
1213
  sources = [],
1212
1214
  config = null,
1213
1215
  configPath = null,
1216
+ configDir = null,
1217
+ rootDir = null,
1218
+ testPattern = DEFAULT_TEST_PATTERN,
1214
1219
  extensionCatalog = {},
1215
1220
  declaredExtensions = [],
1216
1221
  lintConfig = DEFAULT_LINT,
@@ -1252,12 +1257,27 @@ export function lintSources({
1252
1257
  }
1253
1258
  }
1254
1259
 
1260
+ // A source that declares tests, or whose path matches the configured
1261
+ // test-file pattern, is a test file. Test files are never part of the
1262
+ // project program — `mergePrograms` rejects them by design — so they are
1263
+ // left out of the cross-file merge and the project rules. Editors lint every
1264
+ // `.tfx` file in a project, test files included, and must not report the
1265
+ // merge error on each test declaration. Pattern matching also keeps a
1266
+ // helper-only test file (no `test` declarations) from leaking its top-level
1267
+ // variables into project scope.
1268
+ const testRoot = rootDir ?? configDir ?? null;
1269
+ const projectParsed = parsed.filter(
1270
+ ({ file, ast }) =>
1271
+ (ast.tests ?? []).length === 0 &&
1272
+ !isTestPath(file, testRoot, testPattern),
1273
+ );
1274
+
1255
1275
  // Partial sprites are resolved by the project merge, which throws when the
1256
1276
  // feature is off or a base declaration is missing. Report those first.
1257
1277
  const partialSprites = Boolean(config?.fx?.partialSprites);
1258
1278
  let partialBlocked = false;
1259
1279
  if (!partialSprites) {
1260
- for (const { ast } of parsed) {
1280
+ for (const { ast } of projectParsed) {
1261
1281
  for (const sprite of ast.sprites) {
1262
1282
  if (!sprite.partial) continue;
1263
1283
  partialBlocked = true;
@@ -1271,7 +1291,7 @@ export function lintSources({
1271
1291
  }
1272
1292
  }
1273
1293
  const groups = new Map();
1274
- for (const { ast } of parsed) {
1294
+ for (const { ast } of projectParsed) {
1275
1295
  for (const sprite of ast.sprites) {
1276
1296
  const group = groups.get(sprite.name) ?? { base: false, fragment: null };
1277
1297
  if (sprite.partial) group.fragment ??= sprite;
@@ -1290,10 +1310,10 @@ export function lintSources({
1290
1310
  }
1291
1311
 
1292
1312
  let program = null;
1293
- if (!partialBlocked) {
1313
+ if (!partialBlocked && projectParsed.length > 0) {
1294
1314
  try {
1295
1315
  program = mergePrograms(
1296
- parsed.map(({ file, ast }) => ({ ast, file })),
1316
+ projectParsed.map(({ file, ast }) => ({ ast, file })),
1297
1317
  { partialSprites },
1298
1318
  );
1299
1319
  } catch (error) {
@@ -1305,7 +1325,7 @@ export function lintSources({
1305
1325
  }
1306
1326
  }
1307
1327
  if (program) analyzer.analyzeProgram(program);
1308
- else for (const { ast } of parsed) analyzer.analyzeProgram(ast);
1328
+ else for (const { ast } of projectParsed) analyzer.analyzeProgram(ast);
1309
1329
 
1310
1330
  // Diagnostics from filesystem-backed project checks (assets, fx.lock).
1311
1331
  for (const diagnostic of extraDiagnostics) {
@@ -22,6 +22,7 @@ function extensionName(source) {
22
22
  * config: {fx: {partialSprites: boolean}},
23
23
  * lintConfig: object,
24
24
  * declaredExtensions: string[],
25
+ * testPattern: string|null,
25
26
  * projectName: string|null,
26
27
  * error?: string,
27
28
  * }}
@@ -35,6 +36,7 @@ export function parseProjectConfigText(text) {
35
36
  config: null,
36
37
  lintConfig: null,
37
38
  declaredExtensions: [],
39
+ testPattern: null,
38
40
  projectName: null,
39
41
  error: error instanceof Error ? error.message : String(error),
40
42
  };
@@ -44,6 +46,7 @@ export function parseProjectConfigText(text) {
44
46
  config: null,
45
47
  lintConfig: null,
46
48
  declaredExtensions: [],
49
+ testPattern: null,
47
50
  projectName: null,
48
51
  error: "fx.yml must contain a mapping at the top level",
49
52
  };
@@ -68,6 +71,7 @@ export function parseProjectConfigText(text) {
68
71
  config: null,
69
72
  lintConfig: null,
70
73
  declaredExtensions: [],
74
+ testPattern: null,
71
75
  projectName: typeof project.name === "string" ? project.name : null,
72
76
  error: error instanceof Error ? error.message : String(error),
73
77
  };
@@ -88,10 +92,20 @@ export function parseProjectConfigText(text) {
88
92
  }
89
93
  }
90
94
 
95
+ const testPattern =
96
+ fx.tests &&
97
+ typeof fx.tests === "object" &&
98
+ !Array.isArray(fx.tests) &&
99
+ typeof fx.tests.pattern === "string" &&
100
+ fx.tests.pattern.trim()
101
+ ? fx.tests.pattern.trim()
102
+ : null;
103
+
91
104
  return {
92
105
  config: { fx: { partialSprites: Boolean(fx.partialSprites) } },
93
106
  lintConfig,
94
107
  declaredExtensions: [...declaredExtensions],
108
+ testPattern,
95
109
  projectName: typeof project.name === "string" ? project.name : null,
96
110
  };
97
111
  }
package/src/merge.js CHANGED
@@ -43,6 +43,13 @@ export function mergePrograms(
43
43
  const groups = new Map();
44
44
 
45
45
  for (const { ast, file } of entries) {
46
+ if (ast.tests && ast.tests.length > 0)
47
+ throw new ParseError(
48
+ `Test ${JSON.stringify(ast.tests[0].name)} is declared in a project source. ` +
49
+ `Move test declarations to a test file (for example ` +
50
+ `test/score.test.tfx); ordinary builds never compile test files.`,
51
+ errorInfo(ast.tests[0].loc),
52
+ );
46
53
  if (ast.stage) {
47
54
  if (stage) {
48
55
  throw new ParseError(
package/src/parser.js CHANGED
@@ -97,6 +97,7 @@ class Parser {
97
97
  const sprites = [];
98
98
  let stage = null;
99
99
  const topItems = [];
100
+ const tests = [];
100
101
 
101
102
  while (this.peek().type !== "eof") {
102
103
  if (
@@ -116,12 +117,38 @@ class Parser {
116
117
  ) {
117
118
  if (stage) this.fail("A project may only declare one stage");
118
119
  stage = this.parseStage();
120
+ } else if (
121
+ this.atIdent("test") &&
122
+ this.peek(1).type === "string" &&
123
+ this.peek(2).type === "punct" &&
124
+ this.peek(2).value === "{"
125
+ ) {
126
+ tests.push(this.parseTestDecl());
119
127
  } else {
120
128
  topItems.push(this.parseBodyItem());
121
129
  }
122
130
  }
123
131
 
124
- return { type: "Program", stage, sprites, topItems };
132
+ return { type: "Program", stage, sprites, topItems, tests };
133
+ }
134
+
135
+ /** `test "name" { ... }` — only valid in test files. */
136
+ parseTestDecl() {
137
+ const tok = this.expect("test");
138
+ const name = this.expectType("string", "a test name string").value;
139
+ const body = this.parseBlockBody();
140
+ return { type: "TestDecl", name, body, loc: tok };
141
+ }
142
+
143
+ /** `expect <expression>[, "message"];` */
144
+ parseExpect() {
145
+ const tok = this.expect("expect");
146
+ const value = this.parseExpression();
147
+ let message = null;
148
+ if (this.eat(","))
149
+ message = this.expectType("string", "an assertion message string").value;
150
+ this.expect(";");
151
+ return { type: "Expect", value, message, loc: tok };
125
152
  }
126
153
 
127
154
  parseSprite(partial = false, startTok = null) {
@@ -207,6 +234,18 @@ class Parser {
207
234
  }
208
235
  case "when":
209
236
  return this.parseHat();
237
+ case "expect":
238
+ // A complete call or assignment stays a normal statement. An
239
+ // expression continuing after the closing parenthesis is an assertion.
240
+ if (
241
+ !(
242
+ this.peek(1).type === "punct" &&
243
+ ASSIGN_OPS.has(this.peek(1).value)
244
+ ) &&
245
+ !this.isExpectCall()
246
+ )
247
+ return this.parseExpect();
248
+ break;
210
249
  case "if":
211
250
  return this.parseIf();
212
251
  case "repeat":
@@ -233,6 +272,22 @@ class Parser {
233
272
  return this.parseSimpleStatement();
234
273
  }
235
274
 
275
+ isExpectCall() {
276
+ if (this.peek(1).type !== "punct" || this.peek(1).value !== "(")
277
+ return false;
278
+ let depth = 0;
279
+ for (let offset = 1; this.peek(offset).type !== "eof"; offset++) {
280
+ const token = this.peek(offset);
281
+ if (token.type !== "punct") continue;
282
+ if (token.value === "(") depth++;
283
+ if (token.value === ")" && --depth === 0) {
284
+ const next = this.peek(offset + 1);
285
+ return next.type === "punct" && [";", "{"].includes(next.value);
286
+ }
287
+ }
288
+ return false;
289
+ }
290
+
236
291
  parseVarDecl(cloud) {
237
292
  const tok = cloud ? this.expect("cloud") : this.expect("let");
238
293
  if (cloud) this.expect("let");
@@ -474,6 +529,26 @@ class Parser {
474
529
  return { name: segments.join("."), segments, loc: tok };
475
530
  }
476
531
 
532
+ /**
533
+ * Postfix member access on a call result, as in
534
+ * `state.list("items").length`. Qualified identifiers already absorb their
535
+ * segments while they are parsed.
536
+ */
537
+ parseMemberAccess(expression) {
538
+ let node = expression;
539
+ while (this.atPunct(".") && this.peek(1).type === "ident") {
540
+ this.next();
541
+ const property = this.next();
542
+ node = {
543
+ type: "Member",
544
+ object: node,
545
+ property: property.value,
546
+ loc: property,
547
+ };
548
+ }
549
+ return node;
550
+ }
551
+
477
552
  parseExpressionList(closing) {
478
553
  const list = [];
479
554
  if (this.atPunct(closing)) return list;
@@ -607,13 +682,13 @@ class Parser {
607
682
  if (this.eat("(")) {
608
683
  const args = this.parseExpressionList(")");
609
684
  this.expect(")");
610
- return {
685
+ return this.parseMemberAccess({
611
686
  type: "Call",
612
687
  name: name.name,
613
688
  segments: name.segments,
614
689
  args,
615
690
  loc: tok,
616
- };
691
+ });
617
692
  }
618
693
  return {
619
694
  type: "Identifier",
package/src/project.js CHANGED
@@ -9,6 +9,7 @@ import fs from "node:fs";
9
9
  import path from "node:path";
10
10
  import { configFilename, sourceExtension } from "./brand.js";
11
11
  import { mergePrograms as mergeParsed } from "./merge.js";
12
+ import { DEFAULT_TEST_PATTERN, matchesTestPattern } from "./test/discover.js";
12
13
 
13
14
  /** Directories never scanned for `.tfx` sources. */
14
15
  const IGNORED_DIRS = new Set([
@@ -21,12 +22,18 @@ const IGNORED_DIRS = new Set([
21
22
 
22
23
  /**
23
24
  * Recursively find every `.tfx` file under `dir`, skipping hidden entries and
24
- * well-known build/dependency directories. Results are deterministic (sorted by
25
- * path) so builds are reproducible.
25
+ * well-known build/dependency directories. Test files are skipped so they
26
+ * never become part of the project target.
27
+ *
28
+ * Results are deterministic (sorted by path) so builds are reproducible.
26
29
  * @param {string} dir
30
+ * @param {{testPattern?: string|null}} [options] `null` keeps test files
27
31
  * @returns {string[]} absolute file paths
28
32
  */
29
- export function discoverSources(dir) {
33
+ export function discoverSources(
34
+ dir,
35
+ { testPattern = DEFAULT_TEST_PATTERN } = {},
36
+ ) {
30
37
  const root = path.resolve(dir);
31
38
  const found = [];
32
39
  const extension = sourceExtension().toLowerCase();
@@ -49,6 +56,10 @@ export function discoverSources(dir) {
49
56
  entry.isFile() &&
50
57
  entry.name.toLowerCase().endsWith(extension)
51
58
  ) {
59
+ if (testPattern) {
60
+ const relative = path.relative(root, full).split(path.sep).join("/");
61
+ if (matchesTestPattern(relative, testPattern)) continue;
62
+ }
52
63
  found.push(full);
53
64
  }
54
65
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Test-file discovery.
3
+ *
4
+ * Test files are ordinary `.tfx` sources that match a project glob. They are
5
+ * never part of the project target, so both `fx test` and the normal build
6
+ * path use the same matcher.
7
+ */
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { DEFAULT_TEST_PATTERN, matchesTestPattern } from "./pattern.js";
11
+
12
+ export {
13
+ DEFAULT_TEST_PATTERN,
14
+ globToRegExp,
15
+ matchesTestPattern,
16
+ } from "./pattern.js";
17
+
18
+ const IGNORED_DIRS = new Set([
19
+ "node_modules",
20
+ "build",
21
+ "dist",
22
+ "coverage",
23
+ ".cache",
24
+ ]);
25
+
26
+ /** Recursively collect test files below `current`, skipping ignored dirs. */
27
+ function walk(root, current, found, pattern, extension) {
28
+ let entries;
29
+ try {
30
+ entries = fs.readdirSync(current, { withFileTypes: true });
31
+ } catch {
32
+ return;
33
+ }
34
+ entries.sort((a, b) => a.name.localeCompare(b.name));
35
+ for (const entry of entries) {
36
+ if (entry.name.startsWith(".")) continue;
37
+ const full = path.join(current, entry.name);
38
+ if (entry.isDirectory()) {
39
+ if (IGNORED_DIRS.has(entry.name)) continue;
40
+ walk(root, full, found, pattern, extension);
41
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith(extension)) {
42
+ const relative = path.relative(root, full).split(path.sep).join("/");
43
+ if (matchesTestPattern(relative, pattern)) found.push(full);
44
+ }
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Find every test file under a directory.
50
+ *
51
+ * @param {{dir: string, pattern?: string, extension?: string}} options
52
+ * @returns {Array<{file: string, relative: string}>} sorted by path
53
+ */
54
+ export function discoverTests({
55
+ dir,
56
+ pattern = DEFAULT_TEST_PATTERN,
57
+ extension = ".tfx",
58
+ } = {}) {
59
+ if (!dir) throw new Error("discoverTests needs a directory");
60
+ const root = path.resolve(dir);
61
+ const found = [];
62
+ walk(root, root, found, pattern, String(extension).toLowerCase());
63
+ found.sort();
64
+ return found.map((file) => ({
65
+ file,
66
+ relative: path.relative(root, file).split(path.sep).join("/"),
67
+ }));
68
+ }
69
+
70
+ /**
71
+ * Whether one explicit path is a test file for a project directory.
72
+ * @param {string} file
73
+ * @param {string} dir
74
+ * @param {string} [pattern]
75
+ */
76
+ export function isTestFile(file, dir, pattern = DEFAULT_TEST_PATTERN) {
77
+ const relative = path
78
+ .relative(path.resolve(dir), path.resolve(file))
79
+ .split(path.sep)
80
+ .join("/");
81
+ return matchesTestPattern(relative, pattern);
82
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Test-file parsing.
3
+ *
4
+ * A test file is a normal `.tfx` source with `test` declarations. Project
5
+ * declarations (sprites, the stage, hats, project variables) are rejected:
6
+ * tests reuse the language, not the project structure.
7
+ */
8
+ import { parse } from "../parser.js";
9
+ import { ParseError } from "../errors.js";
10
+
11
+ const ALLOWED_TOP_LEVEL = new Set(["VarDecl", "FunctionDecl"]);
12
+
13
+ /**
14
+ * @param {string} source
15
+ * @param {string} file
16
+ * @returns {{file: string, tests: object[], helpers: object[]}}
17
+ */
18
+ export function parseTestFile(source, file = "<test>") {
19
+ const ast = parse(source, file);
20
+
21
+ if (ast.stage)
22
+ throw new ParseError(
23
+ "Test files cannot declare a stage; tests run against the project build",
24
+ locationOf(ast.stage),
25
+ );
26
+ if (ast.sprites.length > 0)
27
+ throw new ParseError(
28
+ `Test files cannot declare sprites (found ${JSON.stringify(ast.sprites[0].name)})`,
29
+ locationOf(ast.sprites[0]),
30
+ );
31
+
32
+ for (const item of ast.topItems) {
33
+ if (!ALLOWED_TOP_LEVEL.has(item.type))
34
+ throw new ParseError(
35
+ "Test files may only declare tests, shared variables and shared functions",
36
+ locationOf(item),
37
+ );
38
+ }
39
+
40
+ return { file, tests: ast.tests, helpers: ast.topItems };
41
+ }
42
+
43
+ /** Source location of an AST node, defaulting unknown fields to null. */
44
+ function locationOf(node) {
45
+ const loc = node?.loc ?? {};
46
+ return { file: loc.file, line: loc.line, column: loc.column };
47
+ }
48
+
49
+ /**
50
+ * Parse several test files. Errors are collected so a run can report every
51
+ * broken file before failing with the language-error exit code.
52
+ *
53
+ * @param {Array<{file: string, source: string}>} entries
54
+ * @returns {{files: Array<{file: string, tests: object[], helpers: object[]}>, diagnostics: object[]}}
55
+ */
56
+ export function parseTests(entries) {
57
+ const files = [];
58
+ const diagnostics = [];
59
+ for (const entry of entries) {
60
+ try {
61
+ files.push(parseTestFile(entry.source, entry.file));
62
+ } catch (error) {
63
+ diagnostics.push({
64
+ file: entry.file,
65
+ message: error.message,
66
+ line: error.line ?? null,
67
+ column: error.column ?? null,
68
+ });
69
+ }
70
+ }
71
+ return { files, diagnostics };
72
+ }