@thigasdevelopment/luam 0.19.2 → 0.19.3

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.
Files changed (2) hide show
  1. package/luam.mjs +733 -18
  2. package/package.json +1 -1
package/luam.mjs CHANGED
@@ -3253,7 +3253,7 @@ var EXIT_USAGE = 2;
3253
3253
  // src/config/manifest-context.ts
3254
3254
  var DEVELOPMENT_MODE = "development";
3255
3255
  var PRODUCTION_MODE = "production";
3256
- var DEVELOPMENT_COMMANDS = ["dev", "ensure", "server"];
3256
+ var DEVELOPMENT_COMMANDS = ["dev", "ensure", "server", "test"];
3257
3257
  var PRODUCTION_COMMANDS = ["build"];
3258
3258
  function manifestMode(command) {
3259
3259
  if (DEVELOPMENT_COMMANDS.includes(command)) {
@@ -6762,7 +6762,7 @@ import { tmpdir } from "node:os";
6762
6762
  import { join as join2 } from "node:path";
6763
6763
 
6764
6764
  // src/cli/version.ts
6765
- var VERSION = true ? "0.19.2" : "0.0.0-dev";
6765
+ var VERSION = true ? "0.19.3" : "0.0.0-dev";
6766
6766
  var PROGRAM_NAME = "luam";
6767
6767
  var PROGRAM_DESCRIPTION = "luam \u2014 the Luam compiler for Multi Theft Auto resources.";
6768
6768
 
@@ -8313,7 +8313,7 @@ import { resolve as resolve8 } from "node:path";
8313
8313
  function createSourceResolver(mapping) {
8314
8314
  const matchers = new Map(SOURCE_SIDES.map((environment) => [environment, createPatternMatcher(mapping[environment])]));
8315
8315
  const patterns = SOURCE_SIDES.flatMap((environment) => [...matchers.get(environment)?.patterns ?? []]);
8316
- function resolve19(path) {
8316
+ function resolve21(path) {
8317
8317
  const normalized = normalizePattern(path);
8318
8318
  const matches = [];
8319
8319
  for (const environment of SOURCE_SIDES) {
@@ -8327,8 +8327,8 @@ function createSourceResolver(mapping) {
8327
8327
  return {
8328
8328
  patterns,
8329
8329
  roots: watchRoots(patterns),
8330
- resolve: resolve19,
8331
- side: (path) => resolve19(path).environment
8330
+ resolve: resolve21,
8331
+ side: (path) => resolve21(path).environment
8332
8332
  };
8333
8333
  }
8334
8334
  function describeMatches(matches) {
@@ -8338,17 +8338,28 @@ function describeMatches(matches) {
8338
8338
  // ../compiler/src/project/source-kind.ts
8339
8339
  var SOURCE_EXTENSION = ".luam";
8340
8340
  var DECLARATION_EXTENSION = ".d.luam";
8341
+ var TEST_EXTENSION = ".test.luam";
8341
8342
  function isDeclarationPath(path) {
8342
8343
  return normalizePath(path).endsWith(DECLARATION_EXTENSION);
8343
8344
  }
8345
+ function isTestPath(path) {
8346
+ return normalizePath(path).endsWith(TEST_EXTENSION);
8347
+ }
8344
8348
 
8345
8349
  // src/build/source-discovery.ts
8346
8350
  var MISSING_SOURCE = "config-missing-source";
8347
8351
  var NO_SOURCES = "config-no-sources";
8348
8352
  var SIDE_CONFLICT = "config-source-side-conflict";
8353
+ var TEST_SOURCE = "config-test-source";
8349
8354
  var UNREADABLE_SOURCE = "build-source-unreadable";
8350
8355
  function checkLiterals(root, resolver, found, diagnostics) {
8351
8356
  for (const pattern of resolver.patterns.filter(isLiteralPattern)) {
8357
+ if (isTestPath(pattern)) {
8358
+ diagnostics.push(
8359
+ cliError(TEST_SOURCE, `"${pattern}" is listed in "sources" but "${TEST_EXTENSION}" files are run by "luam test" and never built into the resource.`)
8360
+ );
8361
+ continue;
8362
+ }
8352
8363
  if (!pattern.endsWith(SOURCE_EXTENSION)) {
8353
8364
  diagnostics.push(cliError(MISSING_SOURCE, `"${pattern}" is listed in "sources" but does not end in "${SOURCE_EXTENSION}".`));
8354
8365
  continue;
@@ -8372,7 +8383,7 @@ function discoverSources(root, sources, excluded = []) {
8372
8383
  const diagnostics = tree.errors.map((message) => cliError(UNREADABLE_SOURCE, message));
8373
8384
  const files = [];
8374
8385
  const matched = /* @__PURE__ */ new Set();
8375
- for (const path of tree.files.filter((entry) => entry.endsWith(SOURCE_EXTENSION))) {
8386
+ for (const path of tree.files.filter((entry) => entry.endsWith(SOURCE_EXTENSION) && !isTestPath(entry))) {
8376
8387
  const resolution = resolver.resolve(path);
8377
8388
  if (resolution.matches.length === 0) {
8378
8389
  continue;
@@ -8458,6 +8469,37 @@ function projectDeclarations(entries3, origin) {
8458
8469
  return { globals: [envDeclaration(entries3, origin)] };
8459
8470
  }
8460
8471
 
8472
+ // ../compiler/src/checker/test-declarations.ts
8473
+ var TEST_ORIGIN = "luam:test";
8474
+ var CALLBACK = fn([], VOID);
8475
+ var EXPECTATION_MEMBERS = [
8476
+ { name: "toBe", type: fn([ANY], VOID, 1, false, ["expected"]) },
8477
+ { name: "toBeFalsy", type: fn([], VOID) },
8478
+ { name: "toBeNil", type: fn([], VOID) },
8479
+ { name: "toBeTruthy", type: fn([], VOID) },
8480
+ { name: "toContain", type: fn([ANY], VOID, 1, false, ["value"]) },
8481
+ { name: "toEqual", type: fn([ANY], VOID, 1, false, ["expected"]) },
8482
+ { name: "toNotBe", type: fn([ANY], VOID, 1, false, ["expected"]) },
8483
+ { name: "toNotEqual", type: fn([ANY], VOID, 1, false, ["expected"]) },
8484
+ { name: "toThrow", type: fn([optionalOf(STRING)], VOID, 0, false, ["message"]) }
8485
+ ];
8486
+ var STUB_MEMBERS = [
8487
+ { name: "calls", type: fn([STRING], arrayOf(TABLE), 1, false, ["name"]) },
8488
+ { name: "reset", type: fn([], VOID) },
8489
+ { name: "returns", type: fn([STRING, ANY], VOID, 2, false, ["name", "value"]) },
8490
+ { name: "stub", type: fn([STRING, ANY], VOID, 2, false, ["name", "implementation"]) }
8491
+ ];
8492
+ var EXPECTATION_TYPE = record("Expectation", EXPECTATION_MEMBERS, TEST_ORIGIN);
8493
+ var MTA_STUBS_TYPE = record("MtaStubs", STUB_MEMBERS, TEST_ORIGIN);
8494
+ var TEST_DECLARATIONS = [
8495
+ { name: "afterEach", environment: "shared", source: "extension", type: fn([CALLBACK], VOID, 1, false, ["body"]) },
8496
+ { name: "beforeEach", environment: "shared", source: "extension", type: fn([CALLBACK], VOID, 1, false, ["body"]) },
8497
+ { name: "describe", environment: "shared", source: "extension", type: fn([STRING, CALLBACK], VOID, 2, false, ["name", "body"]) },
8498
+ { name: "expect", environment: "shared", source: "extension", type: fn([ANY], EXPECTATION_TYPE, 1, false, ["value"]) },
8499
+ { name: "mta", environment: "shared", source: "extension", type: MTA_STUBS_TYPE },
8500
+ { name: "test", environment: "shared", source: "extension", type: fn([STRING, CALLBACK], VOID, 2, false, ["name", "body"]) }
8501
+ ];
8502
+
8461
8503
  // ../compiler/src/checker/ambient.ts
8462
8504
  var EVENT_NAME_PREFIX = "event:";
8463
8505
  var EMPTY_AMBIENT = { classes: [], interfaces: [], enums: [], globals: [], events: [] };
@@ -20517,8 +20559,8 @@ function typeOf(state, expression) {
20517
20559
  function indentLine(state, text) {
20518
20560
  return `${INDENT2.repeat(state.indent)}${text}`;
20519
20561
  }
20520
- function markSource(state, sourceLine, symbol = state.symbol) {
20521
- const marker = { sourceLine };
20562
+ function markSource(state, sourceLine2, symbol = state.symbol) {
20563
+ const marker = { sourceLine: sourceLine2 };
20522
20564
  if (symbol !== void 0) {
20523
20565
  marker.symbol = symbol;
20524
20566
  }
@@ -22185,7 +22227,7 @@ function compileModule(file, ambient, context) {
22185
22227
  ...file.environment === void 0 ? {} : { environment: file.environment },
22186
22228
  ambient,
22187
22229
  contracts: context.contracts,
22188
- project: context.project,
22230
+ project: isTestPath(file.path) ? context.testProject : context.project,
22189
22231
  projectReferences: context.projectReferences,
22190
22232
  compilerOptions: context.compilerOptions,
22191
22233
  development: context.development
@@ -22273,6 +22315,7 @@ function createProjectCache() {
22273
22315
  scope: createAmbientScope(collected, { project, references: projectReferences, options: compilerOptions, development, contracts }),
22274
22316
  contracts,
22275
22317
  project,
22318
+ testProject: { globals: [...project.globals, ...TEST_DECLARATIONS] },
22276
22319
  projectReferences,
22277
22320
  compilerOptions,
22278
22321
  development
@@ -22363,6 +22406,7 @@ function runCompile(root, config, options = {}) {
22363
22406
  const excluded = [config.outDir, config.contracts];
22364
22407
  const sources = discoverSources(root, config.sources, excluded);
22365
22408
  const inputs = readProjectInputs(root, { assets: config.assets, environment: config.environment, excluded });
22409
+ const files = [...sources.files, ...options.additionalFiles ?? []].sort((left, right) => left.path.localeCompare(right.path));
22366
22410
  const contracts = readDependencyContracts(root, config);
22367
22411
  const diagnostics = [...sources.diagnostics, ...inputs.diagnostics, ...contracts.diagnostics];
22368
22412
  if (hasCliErrors(diagnostics)) {
@@ -22381,9 +22425,9 @@ function runCompile(root, config, options = {}) {
22381
22425
  contract: null
22382
22426
  };
22383
22427
  }
22384
- tracker.begin("compile", sources.files.length);
22428
+ tracker.begin("compile", files.length);
22385
22429
  const declarations = projectDeclarations(inputs.declared?.entries ?? null, config.environment.file);
22386
- const project = cache3.compile(sources.files, {
22430
+ const project = cache3.compile(files, {
22387
22431
  project: declarations,
22388
22432
  contracts: contracts.contracts,
22389
22433
  compilerOptions: config.compilerOptions,
@@ -22406,12 +22450,12 @@ function runCompile(root, config, options = {}) {
22406
22450
  build,
22407
22451
  diagnostics,
22408
22452
  fileDiagnostics: assembly.diagnostics,
22409
- fileCount: sources.files.length,
22453
+ fileCount: files.length,
22410
22454
  durationMs: performance.now() - started,
22411
22455
  stats: project.stats,
22412
22456
  environmentTemplate: inputs.deployed === null ? null : renderEnvironmentTemplate(inputs.deployed),
22413
22457
  phases: tracker.durations(),
22414
- sources: diagnosticSources(sources.files, assembly.diagnostics),
22458
+ sources: diagnosticSources(files, assembly.diagnostics),
22415
22459
  map: build?.map ?? null,
22416
22460
  contract: build === null ? null : buildResourceAbi(config.name, project.modules.flatMap((module) => module.contributions))
22417
22461
  };
@@ -23783,14 +23827,14 @@ function parseMtaLogLine(line2, activeResource, fallback = /* @__PURE__ */ new D
23783
23827
  const level = severity?.[1] === void 0 ? "info" : logLevel(severity[1]) ?? "info";
23784
23828
  const message = stamped.rest.replace(resource[0], "").replace(severity?.[0] ?? "", "").replace(/^\s*[:|-]?\s*/, "");
23785
23829
  const sourcePath = resource[2];
23786
- const sourceLine = resource[3];
23830
+ const sourceLine2 = resource[3];
23787
23831
  return {
23788
23832
  timestamp: stamped.value,
23789
23833
  environment: "server",
23790
23834
  level,
23791
23835
  message,
23792
23836
  resource: activeResource,
23793
- ...sourcePath !== void 0 && sourceLine !== void 0 ? { source: { path: sourcePath, line: Number(sourceLine) } } : {}
23837
+ ...sourcePath !== void 0 && sourceLine2 !== void 0 ? { source: { path: sourcePath, line: Number(sourceLine2) } } : {}
23794
23838
  };
23795
23839
  }
23796
23840
  return { timestamp: stamped.value, environment: "server", level: "info", message: stamped.rest, resource: activeResource };
@@ -24235,9 +24279,52 @@ function registerDevCommand(program2, runtime) {
24235
24279
  });
24236
24280
  }
24237
24281
 
24282
+ // src/testing/lua-interpreter.ts
24283
+ import { spawnSync as spawnSync2 } from "node:child_process";
24284
+ var REQUIRED_LUA_VERSION = "Lua 5.1";
24285
+ var LUA_CANDIDATES = ["lua5.1", "lua51", "lua", "luajit"];
24286
+ var LUA_ENV_VARIABLE = "LUAM_LUA";
24287
+ var INSTALL_HINT = 'Install a Lua 5.1 interpreter and put it on PATH, or point "--lua" or the LUAM_LUA variable at one. LuaJIT reports "Lua 5.1" and is accepted.';
24288
+ function probeInterpreter(executable) {
24289
+ const result = spawnSync2(executable, ["-e", "io.write(_VERSION)"], { encoding: "utf8", shell: false, windowsHide: true });
24290
+ if (result.error !== void 0 || result.status !== 0) {
24291
+ return null;
24292
+ }
24293
+ return result.stdout.trim();
24294
+ }
24295
+ function findLuaInterpreter(request = {}) {
24296
+ const probe = request.probe ?? probeInterpreter;
24297
+ const pinned = request.explicit ?? request.env ?? null;
24298
+ const candidates3 = pinned === null ? LUA_CANDIDATES : [pinned];
24299
+ for (const executable of candidates3) {
24300
+ const version = probe(executable);
24301
+ if (version === REQUIRED_LUA_VERSION) {
24302
+ return { executable, version };
24303
+ }
24304
+ }
24305
+ return null;
24306
+ }
24307
+ function describeMissingInterpreter(request = {}) {
24308
+ const pinned = request.explicit ?? request.env ?? null;
24309
+ if (pinned !== null) {
24310
+ return `"${pinned}" is not a ${REQUIRED_LUA_VERSION} interpreter.`;
24311
+ }
24312
+ return `No ${REQUIRED_LUA_VERSION} interpreter was found on PATH (tried ${LUA_CANDIDATES.map((name) => `"${name}"`).join(", ")}).`;
24313
+ }
24314
+
24238
24315
  // src/commands/doctor-command.ts
24239
- function runDoctorCommand(reporter, editorService) {
24316
+ function reportInterpreter(reporter, probe) {
24317
+ const interpreter = findLuaInterpreter(probe === void 0 ? {} : { probe });
24318
+ if (interpreter === null) {
24319
+ reporter.warn(`No ${REQUIRED_LUA_VERSION} interpreter was found on PATH, so "luam test" cannot run.`);
24320
+ reporter.detail(INSTALL_HINT);
24321
+ return;
24322
+ }
24323
+ reporter.success(`"${interpreter.executable}" reports ${interpreter.version} and can run "luam test".`);
24324
+ }
24325
+ function runDoctorCommand(reporter, editorService, probe) {
24240
24326
  reporter.success(`Luam CLI ${VERSION} is running on Node.js ${process.versions.node}.`);
24327
+ reportInterpreter(reporter, probe);
24241
24328
  const editors = editorService.detect();
24242
24329
  if (editors.length === 0) {
24243
24330
  reporter.warn("No supported editor command was found on PATH.");
@@ -24565,8 +24652,635 @@ function registerServerCommand(program2, runtime) {
24565
24652
  });
24566
24653
  }
24567
24654
 
24655
+ // src/testing/test-report.ts
24656
+ import { readFileSync as readFileSync11 } from "node:fs";
24657
+ import { resolve as resolve19 } from "node:path";
24658
+ function sourceLine(root, path, line2, cache3) {
24659
+ let lines = cache3.get(path);
24660
+ if (lines === void 0) {
24661
+ try {
24662
+ lines = readFileSync11(resolve19(root, path), "utf8").split(/\r?\n/);
24663
+ } catch {
24664
+ lines = [];
24665
+ }
24666
+ cache3.set(path, lines);
24667
+ }
24668
+ return lines[line2 - 1] ?? null;
24669
+ }
24670
+ function columnOf(text, symbol) {
24671
+ if (text === null) {
24672
+ return 1;
24673
+ }
24674
+ const symbolIndex = symbol === void 0 ? -1 : text.indexOf(symbol);
24675
+ if (symbolIndex >= 0) {
24676
+ return symbolIndex + 1;
24677
+ }
24678
+ const indent = /^\s*/.exec(text)?.[0].length ?? 0;
24679
+ return indent + 1;
24680
+ }
24681
+ function resolveTestPosition(root, map, result, cache3) {
24682
+ if (map === null || result.file === null || result.line === null) {
24683
+ return null;
24684
+ }
24685
+ const resolution = resolveResourcePosition(map, result.file, result.line);
24686
+ if (resolution.status !== "resolved") {
24687
+ return null;
24688
+ }
24689
+ const { file, line: line2, symbol } = resolution.position;
24690
+ return { path: file, line: line2, column: columnOf(sourceLine(root, file, line2, cache3), symbol) };
24691
+ }
24692
+ function formatLocation(root, map, result, cache3) {
24693
+ const position2 = resolveTestPosition(root, map, result, cache3);
24694
+ if (position2 !== null) {
24695
+ return `${position2.path}:${position2.line}:${position2.column}`;
24696
+ }
24697
+ return result.file === null || result.line === null ? "unknown position" : `${result.file}:${result.line}`;
24698
+ }
24699
+ function countResults(runs) {
24700
+ const results = runs.flatMap((run) => run.results);
24701
+ const passed = results.filter((result) => result.passed).length;
24702
+ return { passed, failed: results.length - passed };
24703
+ }
24704
+ function reportTestResults(reporter, runs, root, map, durationMs) {
24705
+ const cache3 = /* @__PURE__ */ new Map();
24706
+ const marker = (name) => reporter.style.marker(name);
24707
+ for (const run of runs) {
24708
+ for (const line2 of run.output) {
24709
+ reporter.detail(` ${line2}`);
24710
+ }
24711
+ for (const result of run.results) {
24712
+ const tone = result.passed ? "success" : "error";
24713
+ const label2 = `${result.environment} \xB7 ${result.name}`;
24714
+ reporter.raw(` ${reporter.style.paint(tone, marker(result.passed ? "success" : "failure"))} ${label2}`);
24715
+ if (!result.passed) {
24716
+ reporter.rawError(` ${formatLocation(root, map, result, cache3)} ${result.message}`);
24717
+ }
24718
+ }
24719
+ if (run.failure !== null) {
24720
+ reporter.error(`The ${run.environment} test run did not complete: ${run.failure}`);
24721
+ }
24722
+ }
24723
+ const totals = countResults(runs);
24724
+ const summary = `${pluralize(totals.passed, "test")} passed, ${totals.failed} failed in ${formatDuration(durationMs)}.`;
24725
+ const crashed = runs.some((run) => run.failure !== null);
24726
+ if (totals.failed === 0 && !crashed) {
24727
+ reporter.success(`Tests passed: ${summary}`);
24728
+ return true;
24729
+ }
24730
+ reporter.error(`Tests failed: ${summary}`);
24731
+ return false;
24732
+ }
24733
+
24734
+ // src/testing/test-discovery.ts
24735
+ import { readFileSync as readFileSync12 } from "node:fs";
24736
+ import { resolve as resolve20 } from "node:path";
24737
+ var DEFAULT_TEST_ENVIRONMENT = "shared";
24738
+ var SIDE_CONFLICT2 = "test-source-side-conflict";
24739
+ var UNREADABLE_TEST = "test-source-unreadable";
24740
+ function readTest(root, path, diagnostics) {
24741
+ try {
24742
+ return readFileSync12(resolve20(root, path), "utf8");
24743
+ } catch (error) {
24744
+ diagnostics.push(cliError(UNREADABLE_TEST, `The test file "${path}" could not be read: ${error instanceof Error ? error.message : String(error)}`));
24745
+ return null;
24746
+ }
24747
+ }
24748
+ function discoverTests(root, sources, excluded = []) {
24749
+ const resolver = createSourceResolver(sources);
24750
+ const tree = listProjectFiles(root, ["."], excluded);
24751
+ const diagnostics = tree.errors.map((message) => cliError(UNREADABLE_TEST, message));
24752
+ const files = [];
24753
+ for (const path of tree.files.filter(isTestPath)) {
24754
+ const resolution = resolver.resolve(path);
24755
+ if (resolution.matches.length > 1) {
24756
+ diagnostics.push(cliError(SIDE_CONFLICT2, `"${path}" is matched by more than one side: ${describeMatches(resolution.matches)}. Narrow the patterns.`));
24757
+ continue;
24758
+ }
24759
+ const source = readTest(root, path, diagnostics);
24760
+ if (source !== null) {
24761
+ files.push({ path, source, environment: resolution.environment ?? DEFAULT_TEST_ENVIRONMENT });
24762
+ }
24763
+ }
24764
+ return { files: files.sort((left, right) => left.path.localeCompare(right.path)), diagnostics };
24765
+ }
24766
+
24767
+ // src/testing/test-runner.ts
24768
+ import { spawnSync as spawnSync3 } from "node:child_process";
24769
+ import { mkdirSync as mkdirSync7, mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync8 } from "node:fs";
24770
+ import { tmpdir as tmpdir2 } from "node:os";
24771
+ import { dirname as dirname7, join as join6 } from "node:path";
24772
+
24773
+ // src/testing/harness-assertions.ts
24774
+ var ASSERTIONS_SOURCE = String.raw`local SENTINEL = '##luam:test'
24775
+ local HARNESS = debug.getinfo(1, 'S').short_src
24776
+
24777
+ local tests = {}
24778
+ local groups = {}
24779
+ local beforeHooks = {}
24780
+ local afterHooks = {}
24781
+ local callLog = {}
24782
+ local behaviour = {}
24783
+ local stubNames = rawget(_G, '__luamTestNames') or {}
24784
+
24785
+ local function escape(value)
24786
+ local text = tostring(value)
24787
+ text = text:gsub('\\', '\\\\')
24788
+ text = text:gsub('\t', '\\t')
24789
+ text = text:gsub('\r', '\\r')
24790
+ text = text:gsub('\n', '\\n')
24791
+ return text
24792
+ end
24793
+
24794
+ local function emit(kind, ...)
24795
+ local parts = { SENTINEL, kind }
24796
+ for index = 1, select('#', ...) do
24797
+ parts[#parts + 1] = escape(select(index, ...))
24798
+ end
24799
+ io.write(table.concat(parts, '\t'))
24800
+ io.write('\n')
24801
+ io.stdout:flush()
24802
+ end
24803
+
24804
+ local function origin()
24805
+ local index = 1
24806
+ while true do
24807
+ local info = debug.getinfo(index, 'Sl')
24808
+ if info == nil then
24809
+ return '', ''
24810
+ end
24811
+ if info.what ~= 'C' and info.short_src ~= HARNESS then
24812
+ return info.short_src, info.currentline
24813
+ end
24814
+ index = index + 1
24815
+ end
24816
+ end
24817
+
24818
+ local function failure(message)
24819
+ local file, line = origin()
24820
+ return { luamTestFailure = true, message = message, file = file, line = line }
24821
+ end
24822
+
24823
+ local function fail(message)
24824
+ error(failure(message), 0)
24825
+ end
24826
+
24827
+ local function handler(err)
24828
+ if type(err) == 'table' and err.luamTestFailure then
24829
+ return err
24830
+ end
24831
+ return failure(tostring(err))
24832
+ end
24833
+
24834
+ local function attempt(body)
24835
+ local ok, err = xpcall(body, handler)
24836
+ if ok then
24837
+ return nil
24838
+ end
24839
+ return err
24840
+ end
24841
+
24842
+ local function describeValue(value)
24843
+ if type(value) == 'string' then
24844
+ return string.format('%q', value)
24845
+ end
24846
+ return tostring(value)
24847
+ end
24848
+
24849
+ local function deepEqual(left, right)
24850
+ if left == right then
24851
+ return true
24852
+ end
24853
+ if type(left) ~= 'table' or type(right) ~= 'table' then
24854
+ return false
24855
+ end
24856
+ for key, value in pairs(left) do
24857
+ if not deepEqual(value, right[key]) then
24858
+ return false
24859
+ end
24860
+ end
24861
+ for key in pairs(right) do
24862
+ if left[key] == nil then
24863
+ return false
24864
+ end
24865
+ end
24866
+ return true
24867
+ end
24868
+
24869
+ local function expect(value)
24870
+ local matchers = {}
24871
+
24872
+ function matchers.toBe(expected)
24873
+ if value ~= expected then
24874
+ fail('expected ' .. describeValue(expected) .. ', got ' .. describeValue(value))
24875
+ end
24876
+ end
24877
+
24878
+ function matchers.toNotBe(expected)
24879
+ if value == expected then
24880
+ fail('expected a value other than ' .. describeValue(expected))
24881
+ end
24882
+ end
24883
+
24884
+ function matchers.toEqual(expected)
24885
+ if not deepEqual(value, expected) then
24886
+ fail('expected ' .. describeValue(expected) .. ', got ' .. describeValue(value))
24887
+ end
24888
+ end
24889
+
24890
+ function matchers.toNotEqual(expected)
24891
+ if deepEqual(value, expected) then
24892
+ fail('expected a value other than ' .. describeValue(expected))
24893
+ end
24894
+ end
24895
+
24896
+ function matchers.toBeNil()
24897
+ if value ~= nil then
24898
+ fail('expected nil, got ' .. describeValue(value))
24899
+ end
24900
+ end
24901
+
24902
+ function matchers.toBeTruthy()
24903
+ if not value then
24904
+ fail('expected a truthy value, got ' .. describeValue(value))
24905
+ end
24906
+ end
24907
+
24908
+ function matchers.toBeFalsy()
24909
+ if value then
24910
+ fail('expected a falsy value, got ' .. describeValue(value))
24911
+ end
24912
+ end
24913
+
24914
+ function matchers.toContain(entry)
24915
+ if type(value) == 'string' then
24916
+ if string.find(value, tostring(entry), 1, true) == nil then
24917
+ fail('expected ' .. describeValue(value) .. ' to contain ' .. describeValue(entry))
24918
+ end
24919
+ return
24920
+ end
24921
+ if type(value) ~= 'table' then
24922
+ fail('expected a string or a table, got ' .. type(value))
24923
+ return
24924
+ end
24925
+ for _, candidate in pairs(value) do
24926
+ if deepEqual(candidate, entry) then
24927
+ return
24928
+ end
24929
+ end
24930
+ fail('expected the table to contain ' .. describeValue(entry))
24931
+ end
24932
+
24933
+ function matchers.toThrow(message)
24934
+ if type(value) ~= 'function' then
24935
+ fail('expected a function, got ' .. type(value))
24936
+ return
24937
+ end
24938
+ local ok, err = pcall(value)
24939
+ if ok then
24940
+ fail('expected the function to throw')
24941
+ return
24942
+ end
24943
+ if message ~= nil and string.find(tostring(err), message, 1, true) == nil then
24944
+ fail('expected the error to contain ' .. describeValue(message) .. ', got ' .. describeValue(tostring(err)))
24945
+ end
24946
+ end
24947
+
24948
+ return matchers
24949
+ end
24950
+ `;
24951
+
24952
+ // src/testing/harness-runner.ts
24953
+ var RUNNER_SOURCE = String.raw`local function fullName(name)
24954
+ if #groups == 0 then
24955
+ return name
24956
+ end
24957
+ return table.concat(groups, ' > ') .. ' > ' .. name
24958
+ end
24959
+
24960
+ local function snapshot(list)
24961
+ local copy = {}
24962
+ for index = 1, #list do
24963
+ copy[index] = list[index]
24964
+ end
24965
+ return copy
24966
+ end
24967
+
24968
+ local function describe(name, body)
24969
+ groups[#groups + 1] = name
24970
+ local beforeDepth = #beforeHooks
24971
+ local afterDepth = #afterHooks
24972
+ body()
24973
+ for index = #beforeHooks, beforeDepth + 1, -1 do
24974
+ beforeHooks[index] = nil
24975
+ end
24976
+ for index = #afterHooks, afterDepth + 1, -1 do
24977
+ afterHooks[index] = nil
24978
+ end
24979
+ groups[#groups] = nil
24980
+ end
24981
+
24982
+ local function test(name, body)
24983
+ tests[#tests + 1] = { name = fullName(name), body = body, before = snapshot(beforeHooks), after = snapshot(afterHooks) }
24984
+ end
24985
+
24986
+ local function beforeEach(body)
24987
+ beforeHooks[#beforeHooks + 1] = body
24988
+ end
24989
+
24990
+ local function afterEach(body)
24991
+ afterHooks[#afterHooks + 1] = body
24992
+ end
24993
+
24994
+ local function resetStubs()
24995
+ callLog = {}
24996
+ behaviour = {}
24997
+ end
24998
+
24999
+ local function record(name, ...)
25000
+ local entries = callLog[name]
25001
+ if entries == nil then
25002
+ entries = {}
25003
+ callLog[name] = entries
25004
+ end
25005
+ entries[#entries + 1] = { n = select('#', ...), ... }
25006
+ end
25007
+
25008
+ local function makeStub(name)
25009
+ return function(...)
25010
+ record(name, ...)
25011
+ local configured = behaviour[name]
25012
+ if configured == nil then
25013
+ return nil
25014
+ end
25015
+ if configured.kind == 'function' then
25016
+ return configured.body(...)
25017
+ end
25018
+ return configured.value
25019
+ end
25020
+ end
25021
+
25022
+ local mta = {}
25023
+
25024
+ function mta.stub(name, implementation)
25025
+ behaviour[name] = { kind = 'function', body = implementation }
25026
+ end
25027
+
25028
+ function mta.returns(name, value)
25029
+ behaviour[name] = { kind = 'value', value = value }
25030
+ end
25031
+
25032
+ function mta.calls(name)
25033
+ return callLog[name] or {}
25034
+ end
25035
+
25036
+ function mta.reset()
25037
+ resetStubs()
25038
+ end
25039
+
25040
+ local function runAll()
25041
+ local passed = 0
25042
+ local failed = 0
25043
+ for _, entry in ipairs(tests) do
25044
+ resetStubs()
25045
+ local result = nil
25046
+ for _, hook in ipairs(entry.before) do
25047
+ if result == nil then
25048
+ result = attempt(hook)
25049
+ end
25050
+ end
25051
+ if result == nil then
25052
+ result = attempt(entry.body)
25053
+ end
25054
+ for _, hook in ipairs(entry.after) do
25055
+ local hookResult = attempt(hook)
25056
+ if result == nil then
25057
+ result = hookResult
25058
+ end
25059
+ end
25060
+ if result == nil then
25061
+ passed = passed + 1
25062
+ emit('pass', entry.name)
25063
+ else
25064
+ failed = failed + 1
25065
+ emit('fail', entry.name, result.file, result.line, result.message)
25066
+ end
25067
+ end
25068
+ emit('done', passed, failed)
25069
+ return failed
25070
+ end
25071
+
25072
+ rawset(_G, 'describe', describe)
25073
+ rawset(_G, 'test', test)
25074
+ rawset(_G, 'beforeEach', beforeEach)
25075
+ rawset(_G, 'afterEach', afterEach)
25076
+ rawset(_G, 'expect', expect)
25077
+ rawset(_G, 'mta', mta)
25078
+
25079
+ setmetatable(_G, {
25080
+ __index = function(_, key)
25081
+ if stubNames[key] == nil then
25082
+ return nil
25083
+ end
25084
+ local stub = makeStub(key)
25085
+ rawset(_G, key, stub)
25086
+ return stub
25087
+ end,
25088
+ })
25089
+
25090
+ rawset(_G, '__luamTest', {
25091
+ discard = function()
25092
+ tests = {}
25093
+ groups = {}
25094
+ beforeHooks = {}
25095
+ afterHooks = {}
25096
+ end,
25097
+ run = runAll,
25098
+ })
25099
+ `;
25100
+
25101
+ // src/testing/harness-source.ts
25102
+ var HARNESS_FILE = "harness.lua";
25103
+ var ENTRY_PREFIX = "main-";
25104
+ var SENTINEL = "##luam:test";
25105
+ var HARNESS_SOURCE = `${ASSERTIONS_SOURCE}
25106
+ ${RUNNER_SOURCE}`;
25107
+ function entryFile(environment) {
25108
+ return `${ENTRY_PREFIX}${environment}.lua`;
25109
+ }
25110
+ function stubbedGlobals(environment) {
25111
+ return globalsFor(environment).filter((declaration) => declaration.source === "mta" && declaration.type.kind === "function").map((declaration) => declaration.name).sort();
25112
+ }
25113
+ function entrySource(environment, preload, target) {
25114
+ const names = stubbedGlobals(environment).map((name) => ` ['${name}'] = true,`);
25115
+ const discard = preload.length === 0 ? [] : ["__luamTest.discard()"];
25116
+ const lines = [
25117
+ "__luamTestNames = {",
25118
+ ...names,
25119
+ "}",
25120
+ `dofile('${HARNESS_FILE}')`,
25121
+ ...preload.map((path) => `dofile('${path}')`),
25122
+ ...discard,
25123
+ ...target.map((path) => `dofile('${path}')`),
25124
+ "local failed = __luamTest.run()",
25125
+ "os.exit(failed == 0 and 0 or 1)"
25126
+ ];
25127
+ return `${lines.join("\n")}
25128
+ `;
25129
+ }
25130
+
25131
+ // src/testing/test-runner.ts
25132
+ function runLua(executable, args, cwd) {
25133
+ const result = spawnSync3(executable, [...args], { cwd, encoding: "utf8", shell: false, windowsHide: true });
25134
+ return {
25135
+ status: result.status,
25136
+ stdout: result.stdout ?? "",
25137
+ stderr: result.stderr ?? "",
25138
+ failure: result.error === void 0 ? null : result.error.message
25139
+ };
25140
+ }
25141
+ function unescape(value) {
25142
+ return value.replace(/\\(.)/g, (match, character) => {
25143
+ if (character === "n") {
25144
+ return "\n";
25145
+ }
25146
+ if (character === "r") {
25147
+ return "\r";
25148
+ }
25149
+ return character === "t" ? " " : character;
25150
+ });
25151
+ }
25152
+ function parseLine(environment, line2) {
25153
+ const parts = line2.split(" ");
25154
+ if (parts[0] !== SENTINEL || parts[1] !== "pass" && parts[1] !== "fail") {
25155
+ return null;
25156
+ }
25157
+ const passed = parts[1] === "pass";
25158
+ const file = unescape(parts[3] ?? "");
25159
+ const parsedLine = Number(parts[4] ?? "");
25160
+ return {
25161
+ environment,
25162
+ name: unescape(parts[2] ?? ""),
25163
+ passed,
25164
+ file: file.length === 0 ? null : file,
25165
+ line: Number.isSafeInteger(parsedLine) && parsedLine > 0 ? parsedLine : null,
25166
+ message: unescape(parts[5] ?? "")
25167
+ };
25168
+ }
25169
+ function parseRunOutput(environment, stdout) {
25170
+ const results = [];
25171
+ const output = [];
25172
+ for (const line2 of stdout.split(/\r?\n/)) {
25173
+ const result = parseLine(environment, line2);
25174
+ if (result !== null) {
25175
+ results.push(result);
25176
+ continue;
25177
+ }
25178
+ if (line2.startsWith(SENTINEL) || line2.length === 0) {
25179
+ continue;
25180
+ }
25181
+ output.push(line2);
25182
+ }
25183
+ return { results, output };
25184
+ }
25185
+ function writeWorkspace(directory, scripts) {
25186
+ writeFileSync8(join6(directory, HARNESS_FILE), HARNESS_SOURCE, "utf8");
25187
+ for (const script of scripts) {
25188
+ const target = join6(directory, script.path);
25189
+ mkdirSync7(dirname7(target), { recursive: true });
25190
+ writeFileSync8(target, script.content, "utf8");
25191
+ }
25192
+ }
25193
+ function loadOrder(available, environment) {
25194
+ const shared = bundlePath("shared");
25195
+ const own = bundlePath(environment);
25196
+ const target = available.has(own) ? [own] : [];
25197
+ if (environment === "shared") {
25198
+ return { preload: [], target };
25199
+ }
25200
+ return { preload: available.has(shared) ? [shared] : [], target };
25201
+ }
25202
+ function runTests(request) {
25203
+ const spawn2 = request.spawn ?? runLua;
25204
+ const directory = mkdtempSync2(join6(tmpdir2(), "luam-test-"));
25205
+ const available = new Set(request.scripts.map((script) => script.path));
25206
+ try {
25207
+ writeWorkspace(directory, request.scripts);
25208
+ return ALL_ENVIRONMENTS.filter((environment) => request.environments.includes(environment)).map((environment) => {
25209
+ const { preload, target } = loadOrder(available, environment);
25210
+ const entry = entryFile(environment);
25211
+ writeFileSync8(join6(directory, entry), entrySource(environment, preload, target), "utf8");
25212
+ const execution = spawn2(request.executable, [entry], directory);
25213
+ const parsed = parseRunOutput(environment, execution.stdout);
25214
+ const failure3 = execution.failure ?? (parsed.results.length === 0 && execution.stderr.length > 0 ? execution.stderr.trim() : null);
25215
+ return { environment, results: parsed.results, output: parsed.output, failure: failure3 };
25216
+ });
25217
+ } finally {
25218
+ rmSync2(directory, { recursive: true, force: true });
25219
+ }
25220
+ }
25221
+
25222
+ // src/commands/test-command.ts
25223
+ function testEnvironments(build, paths) {
25224
+ const found = build.bundles.flatMap(
25225
+ (bundle) => bundle.members.flatMap((member) => member.kind === "module" && paths.has(member.module.source) ? [member.module.environment] : [])
25226
+ );
25227
+ return [...new Set(found)];
25228
+ }
25229
+ function runTestCommand(context, options = {}) {
25230
+ const reporter = commandReporter(context);
25231
+ const excluded = [context.config.outDir, context.config.contracts];
25232
+ const discovered = discoverTests(context.root, context.config.sources, excluded);
25233
+ reportCliDiagnostics(reporter, discovered.diagnostics);
25234
+ if (hasCliErrors(discovered.diagnostics)) {
25235
+ return EXIT_DIAGNOSTICS;
25236
+ }
25237
+ if (discovered.files.length === 0) {
25238
+ reporter.warn(`No "${TEST_EXTENSION}" files were found. Write one next to the module it covers.`);
25239
+ return EXIT_OK;
25240
+ }
25241
+ const request = { explicit: options.lua ?? null, env: options.env ?? null, ...options.probe === void 0 ? {} : { probe: options.probe } };
25242
+ const interpreter = findLuaInterpreter(request);
25243
+ if (interpreter === null) {
25244
+ reporter.error(describeMissingInterpreter(request));
25245
+ reporter.detail(INSTALL_HINT);
25246
+ return EXIT_USAGE;
25247
+ }
25248
+ const started = performance.now();
25249
+ const outcome = runCompile(context.root, context.config, { additionalFiles: discovered.files, development: true, layout: "bundle", map: true });
25250
+ reportCliDiagnostics(reporter, outcome.diagnostics);
25251
+ reportFileDiagnostics(reporter, outcome.fileDiagnostics, outcome.sources);
25252
+ if (outcome.build === null) {
25253
+ reporter.error("Tests failed: the project did not compile.");
25254
+ return EXIT_DIAGNOSTICS;
25255
+ }
25256
+ const paths = new Set(discovered.files.map((file) => file.path));
25257
+ const environments = testEnvironments(outcome.build, paths);
25258
+ const runs = runTests({
25259
+ executable: interpreter.executable,
25260
+ scripts: outcome.build.scripts,
25261
+ environments,
25262
+ ...options.spawn === void 0 ? {} : { spawn: options.spawn }
25263
+ });
25264
+ return reportTestResults(reporter, runs, context.root, outcome.map, performance.now() - started) ? EXIT_OK : EXIT_DIAGNOSTICS;
25265
+ }
25266
+
25267
+ // src/cli/registry/test-registration.ts
25268
+ function registerTestCommand(program2, runtime) {
25269
+ const command = program2.command("test").description("Compile the project with its test files and run them on a Lua 5.1 interpreter.");
25270
+ addProjectOptions(command);
25271
+ command.option("--lua <path>", "Path to the Lua 5.1 interpreter that runs the tests.");
25272
+ command.action((options) => {
25273
+ const project = createProjectContext(runtime, "test", options);
25274
+ if (project.context === null) {
25275
+ runtime.exitCode = project.error;
25276
+ return;
25277
+ }
25278
+ runtime.exitCode = runTestCommand(project.context, { lua: options.lua ?? null, env: runtime.env[LUA_ENV_VARIABLE] ?? null });
25279
+ });
25280
+ }
25281
+
24568
25282
  // src/commands/trace-command.ts
24569
- import { existsSync as existsSync13, readFileSync as readFileSync11 } from "node:fs";
25283
+ import { existsSync as existsSync13, readFileSync as readFileSync13 } from "node:fs";
24570
25284
  function defaultMapPath(root, options, reporter) {
24571
25285
  const loaded = loadManifest(root, { path: options.manifestPath, mode: manifestMode("trace"), env: options.env });
24572
25286
  if (loaded.config !== null) {
@@ -24591,7 +25305,7 @@ function readStandardInput() {
24591
25305
  return "";
24592
25306
  }
24593
25307
  try {
24594
- return readFileSync11(0, "utf8");
25308
+ return readFileSync13(0, "utf8");
24595
25309
  } catch {
24596
25310
  return "";
24597
25311
  }
@@ -24676,6 +25390,7 @@ var COMMAND_REGISTRARS = [
24676
25390
  registerInitCommand,
24677
25391
  registerServerCommand,
24678
25392
  registerSetupCommand,
25393
+ registerTestCommand,
24679
25394
  registerTraceCommand
24680
25395
  ];
24681
25396
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thigasdevelopment/luam",
3
- "version": "0.19.2",
3
+ "version": "0.19.3",
4
4
  "description": "The Luam compiler for Multi Theft Auto resources.",
5
5
  "keywords": [
6
6
  "mta",