@isentinel/jest-roblox 0.2.5 → 0.2.6

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 CHANGED
@@ -240,7 +240,7 @@ Create a file named drillbit.toml in your project's directory.
240
240
 
241
241
  ```toml
242
242
  [plugins.jest_roblox]
243
- github = "https://github.com/christopher-buss/jest-roblox-cli/releases/download/v0.2.5/JestRobloxRunner.rbxm"
243
+ github = "https://github.com/christopher-buss/jest-roblox-cli/releases/download/v0.2.4/JestRobloxRunner.rbxm"
244
244
  ```
245
245
 
246
246
  Then run `drillbit` and it will download the plugin and install it in Studio for you.
@@ -10,8 +10,8 @@ register("../loaders/luau-raw.mjs", import.meta.url);
10
10
 
11
11
  if (existsSync(sourceEntry)) {
12
12
  const { main } = await import("../src/cli.ts");
13
- main();
13
+ await main();
14
14
  } else {
15
15
  const { main } = await import("../dist/cli.mjs");
16
- main();
16
+ await main();
17
17
  }
package/dist/cli.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as ResolvedProjectConfig, n as ExecuteResult, v as CliOptions } from "./executor-B2IDh6bH.mjs";
1
+ import { _ as ResolvedProjectConfig, n as ExecuteResult, v as CliOptions } from "./executor-COuwZJJX.mjs";
2
2
 
3
3
  //#region src/cli.d.ts
4
4
  declare function parseArgs(args: Array<string>): CliOptions;
package/dist/cli.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { A as collectPaths, D as formatTypecheckSummary, H as ROOT_ONLY_KEYS, I as createStudioBackend, J as LuauScriptError, K as isValidBackend, M as resolveNestedProjects, N as loadConfig$1, O as formatBanner, R as createOpenCloudBackend, S as formatAgentMultiProject, T as formatResult, U as VALID_BACKENDS, _ as resolveTsconfigDirectories, a as formatAnnotations, c as visitBlock, d as buildProjectJob, f as execute, g as processProjectResult, h as loadCoverageManifest, i as runTypecheck, j as findInTree, k as combineSourceMappers, m as formatExecuteOutput, n as parseGameOutput, o as formatJobSummary, p as executeBackend, q as hashBuffer, r as writeGameOutput, s as resolveGitHubActionsOptions, t as formatGameOutputNotice, v as rojoProjectSchema, w as formatMultiProjectResult, x as writeJsonFile, y as findFormatterOptions } from "./game-output-BtWj32M8.mjs";
1
+ import { A as collectMounts, B as createOpenCloudBackend, D as formatTypecheckSummary, F as loadConfig$1, G as VALID_BACKENDS, J as isValidBackend, M as findInTree, N as pruneAncestors, O as formatBanner, P as resolveNestedProjects, R as createStudioBackend, S as formatAgentMultiProject, T as formatResult, W as ROOT_ONLY_KEYS, X as LuauScriptError, Y as hashBuffer, _ as resolveTsconfigDirectories, a as formatAnnotations, c as visitBlock, d as buildProjectJob, f as execute, g as processProjectResult, h as loadCoverageManifest, i as runTypecheck, j as collectPaths, k as combineSourceMappers, m as formatExecuteOutput, n as parseGameOutput, o as formatJobSummary, p as executeBackend, r as writeGameOutput, s as resolveGitHubActionsOptions, t as formatGameOutputNotice, v as rojoProjectSchema, w as formatMultiProjectResult, x as writeJsonFile, y as findFormatterOptions } from "./game-output-CCPIQMWm.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { type } from "arktype";
4
4
  import assert from "node:assert";
@@ -23,7 +23,7 @@ import istanbulCoverage from "istanbul-lib-coverage";
23
23
  import istanbulReport from "istanbul-lib-report";
24
24
  import istanbulReports from "istanbul-reports";
25
25
  //#region package.json
26
- var version = "0.2.5";
26
+ var version = "0.2.6";
27
27
  //#endregion
28
28
  //#region src/backends/auto.ts
29
29
  var StudioWithFallback = class {
@@ -237,6 +237,19 @@ function extractStaticRoot(pattern) {
237
237
  root: pattern.slice(0, lastSlash)
238
238
  };
239
239
  }
240
+ function mapFsRootToDataModel(outDirectory, rojoTree) {
241
+ const normalized = outDirectory.replace(/\/$/, "");
242
+ const result = findInTree(rojoTree, normalized, "");
243
+ if (result === void 0) {
244
+ const available = [];
245
+ collectPaths(rojoTree, available);
246
+ let message = `No Rojo tree mapping found for path: ${normalized}`;
247
+ if (available.length > 0) message += `\n\nAvailable $path entries: ${available.join(", ")}`;
248
+ const hint = normalized.startsWith("src/") ? "Path starts with \"src/\" — if using roblox-ts, set \"outDir\" in your project config to the compiled output directory (e.g. \"out/client\")" : void 0;
249
+ throw new ConfigError(message, hint);
250
+ }
251
+ return result;
252
+ }
240
253
  function extractProjectRoots(include) {
241
254
  const rootMap = /* @__PURE__ */ new Map();
242
255
  for (const pattern of include) {
@@ -255,23 +268,22 @@ function extractProjectRoots(include) {
255
268
  testMatch
256
269
  }));
257
270
  }
258
- function mapFsRootToDataModel(outDirectory, rojoTree) {
259
- const normalized = outDirectory.replace(/\/$/, "");
260
- const result = findInTree(rojoTree, normalized, "");
261
- if (result === void 0) {
262
- const available = [];
263
- collectPaths(rojoTree, available);
264
- let message = `No Rojo tree mapping found for path: ${normalized}`;
265
- if (available.length > 0) message += `\n\nAvailable $path entries: ${available.join(", ")}`;
266
- const hint = normalized.startsWith("src/") ? "Path starts with \"src/\" — if using roblox-ts, set \"outDir\" in your project config to the compiled output directory (e.g. \"out/client\")" : void 0;
267
- throw new ConfigError(message, hint);
268
- }
269
- return result;
271
+ function applyProjectRoot(include, projectRoot) {
272
+ if (projectRoot === void 0) return include;
273
+ return include.map((pattern) => path$1.posix.join(projectRoot, pattern));
274
+ }
275
+ function createFsClassifier(rootDirectory) {
276
+ return function classify(fsPath) {
277
+ const absolute = path$1.isAbsolute(fsPath) ? fsPath : path$1.resolve(rootDirectory, fsPath);
278
+ const stat = fs.statSync(absolute, { throwIfNoEntry: false });
279
+ if (stat === void 0) return "missing";
280
+ return stat.isDirectory() ? "directory" : "file";
281
+ };
270
282
  }
271
283
  function validateProjects(projects) {
272
284
  const names = /* @__PURE__ */ new Set();
273
285
  for (const project of projects) {
274
- const name = typeof project.displayName === "string" ? project.displayName : project.displayName.name;
286
+ const name = displayNameOf(project);
275
287
  if (name === "") throw new Error("Project must have a non-empty displayName");
276
288
  if (names.has(name)) throw new Error(`Duplicate project displayName: ${name}`);
277
289
  names.add(name);
@@ -285,26 +297,23 @@ const PROJECT_ONLY_KEYS = new Set([
285
297
  "outDir",
286
298
  "root"
287
299
  ]);
288
- function resolveProjectConfig(project, rootConfig, rojoTree) {
289
- const roots = extractProjectRoots(project.include);
300
+ function resolveProjectConfig(project, rootConfig, rojoTree, classify) {
301
+ const rootPrefixedInclude = applyProjectRoot(project.include, project.root);
302
+ const roots = extractProjectRoots(rootPrefixedInclude);
290
303
  const testMatch = roots.flatMap((entry) => entry.testMatch);
291
- const projectRoot = project.root;
292
- if (roots.length > 1 && project.outDir === void 0) {
293
- const name = typeof project.displayName === "string" ? project.displayName : project.displayName.name;
294
- throw new Error(`Project "${name}" has multiple include roots but no outDir. Set outDir or split into separate projects.`);
295
- }
296
- const resolvedOutDirectory = resolveOutDirectory(project.outDir, projectRoot, roots[0]?.root);
297
- const dataModelPath = resolvedOutDirectory !== void 0 ? mapFsRootToDataModel(resolvedOutDirectory, rojoTree) : void 0;
298
- const resolvedInclude = projectRoot === void 0 ? project.include : project.include.map((pattern) => path$1.posix.join(projectRoot, pattern));
304
+ const rojoMounts = resolveMounts(project, roots, rojoTree, classify);
305
+ const projects = rojoMounts.map((mount) => mount.dataModelPath);
306
+ const singleMount = rojoMounts.length === 1 ? rojoMounts[0] : void 0;
299
307
  const config = mergeProjectConfig(rootConfig, project);
300
- const displayName = typeof project.displayName === "string" ? project.displayName : project.displayName.name;
308
+ const displayName = displayNameOf(project);
301
309
  return {
302
310
  config,
303
311
  displayColor: typeof project.displayName === "string" ? void 0 : project.displayName.color,
304
312
  displayName,
305
- include: resolvedInclude,
306
- outDir: resolvedOutDirectory,
307
- projects: dataModelPath !== void 0 ? [dataModelPath] : [],
313
+ include: rootPrefixedInclude,
314
+ outDir: singleMount?.fsPath,
315
+ projects,
316
+ rojoMounts,
308
317
  testMatch
309
318
  };
310
319
  }
@@ -340,19 +349,92 @@ async function resolveAllProjects(entries, rootConfig, rojoTree, cwd) {
340
349
  projects.push(loaded);
341
350
  } else projects.push(entry.test);
342
351
  validateProjects(projects);
343
- return projects.map((project) => resolveProjectConfig(project, rootConfig, rojoTree));
352
+ const classify = createFsClassifier(cwd);
353
+ return projects.map((project) => resolveProjectConfig(project, rootConfig, rojoTree, classify));
344
354
  }
345
- /** When outDir is omitted (pure Luau), falls back to include pattern's static root. */
346
- function resolveOutDirectory(projectOutDirectory, projectRoot, fallbackRoot) {
347
- const base = projectOutDirectory ?? fallbackRoot;
348
- if (base === void 0) return;
349
- return projectRoot !== void 0 ? path$1.posix.join(projectRoot, base) : base;
355
+ function displayNameOf(project) {
356
+ return typeof project.displayName === "string" ? project.displayName : project.displayName.name;
350
357
  }
351
358
  function mergeProjectConfig(rootConfig, project) {
352
359
  const merged = { ...rootConfig };
353
360
  for (const [key, value] of Object.entries(project)) if (!PROJECT_ONLY_KEYS.has(key) && value !== void 0) merged[key] = value;
354
361
  return merged;
355
362
  }
363
+ function dedupeMounts(mounts) {
364
+ const seen = /* @__PURE__ */ new Set();
365
+ const result = [];
366
+ for (const mount of mounts) if (!seen.has(mount.dataModelPath)) {
367
+ seen.add(mount.dataModelPath);
368
+ result.push(mount);
369
+ }
370
+ return result;
371
+ }
372
+ function joinProjectRoot(relativePath, projectRoot) {
373
+ return projectRoot !== void 0 ? path$1.posix.join(projectRoot, relativePath) : relativePath;
374
+ }
375
+ function pruneAncestorMounts(mounts) {
376
+ const dataModelPaths = mounts.map((mount) => mount.dataModelPath);
377
+ const surviving = new Set(pruneAncestors(dataModelPaths));
378
+ return mounts.filter((mount) => surviving.has(mount.dataModelPath));
379
+ }
380
+ function unmappableRootError(project, root, rojoTree) {
381
+ const name = displayNameOf(project);
382
+ const available = [];
383
+ collectPaths(rojoTree, available);
384
+ let message = `Project "${name}": include root "${root}" did not match any Rojo $path entry or subdirectory.`;
385
+ if (available.length > 0) message += `\n\nAvailable $path entries: ${available.join(", ")}`;
386
+ const hint = root.startsWith("src/") ? "Path starts with \"src/\" — if using roblox-ts, set \"outDir\" in your project config to the compiled output directory (e.g. \"out/client\")" : void 0;
387
+ return new ConfigError(message, hint);
388
+ }
389
+ function filterMountsForRoot(allMounts, root) {
390
+ return allMounts.filter((mount) => mount.fsPath === root || mount.fsPath.startsWith(`${root}/`));
391
+ }
392
+ function resolveMounts(project, roots, rojoTree, classify) {
393
+ if (project.outDir !== void 0) {
394
+ const resolvedOutDirectory = joinProjectRoot(project.outDir, project.root);
395
+ return [{
396
+ dataModelPath: mapFsRootToDataModel(resolvedOutDirectory, rojoTree),
397
+ fsPath: resolvedOutDirectory
398
+ }];
399
+ }
400
+ let collectedMounts;
401
+ const allMounts = [];
402
+ for (const { root } of roots) {
403
+ const exact = findInTree(rojoTree, root, "");
404
+ if (exact !== void 0) {
405
+ allMounts.push({
406
+ dataModelPath: exact,
407
+ fsPath: root
408
+ });
409
+ continue;
410
+ }
411
+ collectedMounts ??= collectMounts(rojoTree, "", classify);
412
+ const expanded = filterMountsForRoot(collectedMounts, root);
413
+ if (expanded.length === 0) throw unmappableRootError(project, root, rojoTree);
414
+ allMounts.push(...expanded);
415
+ }
416
+ return pruneAncestorMounts(dedupeMounts(allMounts));
417
+ }
418
+ function copyLuauOptionalFields(raw, config) {
419
+ const record = config;
420
+ for (const key of LUAU_BOOLEAN_KEYS) if (typeof raw[key] === "boolean") record[key] = raw[key];
421
+ for (const key of LUAU_NUMBER_KEYS) if (typeof raw[key] === "number") record[key] = raw[key];
422
+ for (const key of LUAU_STRING_KEYS) if (typeof raw[key] === "string") record[key] = raw[key];
423
+ for (const key of LUAU_STRING_ARRAY_KEYS) if (Array.isArray(raw[key])) record[key] = raw[key];
424
+ }
425
+ function buildProjectConfigFromLuau(luauConfigPath, directoryPath) {
426
+ const raw = loadLuauConfig(luauConfigPath);
427
+ const { displayName } = raw;
428
+ if (typeof displayName !== "string" || displayName === "") throw new Error(`Luau config file "${luauConfigPath}" must have a displayName string`);
429
+ const testMatch = Array.isArray(raw["testMatch"]) ? raw["testMatch"] : void 0;
430
+ const config = {
431
+ displayName,
432
+ include: testMatch !== void 0 ? testMatch.map((pattern) => path$1.posix.join(directoryPath, `${pattern}.luau`)) : [path$1.posix.join(directoryPath, "**/*.spec.luau")]
433
+ };
434
+ if (testMatch !== void 0) config.testMatch = testMatch;
435
+ copyLuauOptionalFields(raw, config);
436
+ return config;
437
+ }
356
438
  /**
357
439
  * When a project config provides `testMatch` but not `include`, derive
358
440
  * `include` by appending `.ts` and `.tsx` extensions. This lets users
@@ -384,26 +466,6 @@ const LUAU_BOOLEAN_KEYS = [
384
466
  const LUAU_NUMBER_KEYS = ["slowTestThreshold", "testTimeout"];
385
467
  const LUAU_STRING_KEYS = ["testEnvironment"];
386
468
  const LUAU_STRING_ARRAY_KEYS = ["setupFiles", "setupFilesAfterEnv"];
387
- function copyLuauOptionalFields(raw, config) {
388
- const record = config;
389
- for (const key of LUAU_BOOLEAN_KEYS) if (typeof raw[key] === "boolean") record[key] = raw[key];
390
- for (const key of LUAU_NUMBER_KEYS) if (typeof raw[key] === "number") record[key] = raw[key];
391
- for (const key of LUAU_STRING_KEYS) if (typeof raw[key] === "string") record[key] = raw[key];
392
- for (const key of LUAU_STRING_ARRAY_KEYS) if (Array.isArray(raw[key])) record[key] = raw[key];
393
- }
394
- function buildProjectConfigFromLuau(luauConfigPath, directoryPath) {
395
- const raw = loadLuauConfig(luauConfigPath);
396
- const { displayName } = raw;
397
- if (typeof displayName !== "string" || displayName === "") throw new Error(`Luau config file "${luauConfigPath}" must have a displayName string`);
398
- const testMatch = Array.isArray(raw["testMatch"]) ? raw["testMatch"] : void 0;
399
- const config = {
400
- displayName,
401
- include: testMatch !== void 0 ? testMatch.map((pattern) => path$1.posix.join(directoryPath, `${pattern}.luau`)) : [path$1.posix.join(directoryPath, "**/*.spec.luau")]
402
- };
403
- if (testMatch !== void 0) config.testMatch = testMatch;
404
- copyLuauOptionalFields(raw, config);
405
- return config;
406
- }
407
469
  //#endregion
408
470
  //#region src/config/setup-resolver.ts
409
471
  const PROBE_EXTENSIONS = [
@@ -445,6 +507,8 @@ function resolvePackageSpecifier(resolve, input) {
445
507
  //#endregion
446
508
  //#region src/config/stubs.ts
447
509
  const HEADER = "-- Auto-generated by jest-roblox (do not edit)\n";
510
+ const STUB_FILENAME = "jest.config.lua";
511
+ const USER_LUAU_FILENAME = "jest.config.luau";
448
512
  const SKIP_FIELDS = new Set(["exclude", "include"]);
449
513
  function serializeToLuau(config) {
450
514
  let output = `${HEADER}return {\n`;
@@ -461,33 +525,52 @@ function serializeToLuau(config) {
461
525
  }
462
526
  function generateProjectConfigs(projects) {
463
527
  for (const project of projects) {
464
- const luauConfigPath = project.outputPath.replace(/\.lua$/, ".luau");
465
- if (fs.existsSync(luauConfigPath)) continue;
466
- const content = serializeToLuau(project.config);
467
528
  const directory = path.dirname(project.outputPath);
529
+ if (hasUserAuthoredConfig(directory)) continue;
530
+ const content = serializeToLuau(project.config);
468
531
  fs.mkdirSync(directory, { recursive: true });
469
532
  fs.writeFileSync(project.outputPath, content, "utf8");
470
533
  }
471
534
  }
535
+ /**
536
+ * Refuse to let any downstream write land outside `rootDirectory`. Mount
537
+ * fsPaths originate from the rojo tree and `resolveNestedProjects` can emit
538
+ * values with `..` segments (nested projects relocate paths); combined with
539
+ * `path.resolve` on Windows, a permissive fsPath could send stub writes to
540
+ * `D:\outside\...`. Guard at every write-site.
541
+ */
542
+ function assertMountContained(project, fsPath, rootDirectory) {
543
+ if (path.isAbsolute(fsPath)) throw new Error(`Project "${project.displayName}" mount fsPath must be relative, got: ${fsPath}`);
544
+ const rootResolved = path.resolve(rootDirectory);
545
+ const mountResolved = path.resolve(rootDirectory, fsPath);
546
+ if (mountResolved !== rootResolved && !mountResolved.startsWith(rootResolved + path.sep)) throw new Error(`Project "${project.displayName}" mount fsPath escapes root directory: ${fsPath}`);
547
+ }
548
+ /**
549
+ * For a multi-mount project, the stubs-per-mount rule requires that a
550
+ * user-authored config at any tracked FS path must appear at every tracked
551
+ * FS path (or none). Throws `ConfigError` when the project has partial
552
+ * coverage — some mounts have a user config and others don't.
553
+ */
554
+ function assertStubCollisionRule(project, rootDirectory) {
555
+ const withUser = [];
556
+ const withoutUser = [];
557
+ for (const mount of project.rojoMounts) {
558
+ assertMountContained(project, mount.fsPath, rootDirectory);
559
+ if (hasUserAuthoredConfig(path.resolve(rootDirectory, mount.fsPath))) withUser.push(mount.fsPath);
560
+ else withoutUser.push(mount.fsPath);
561
+ }
562
+ if (withUser.length === 0 || withoutUser.length === 0) return;
563
+ const withUserList = withUser.join(", ");
564
+ const withoutUserList = withoutUser.join(", ");
565
+ throw new ConfigError(`Project "${project.displayName}": user-authored jest config present at some mounts but not others.\n\nWith user config: ${withUserList}\nWithout user config: ${withoutUserList}\n\nFor multi-mount projects, either every tracked mount must have a user config, or none.`);
566
+ }
472
567
  function syncStubsToShadowDirectory(projects, rootDirectory, shadowDirectory) {
473
568
  let changed = false;
474
569
  const expectedPaths = /* @__PURE__ */ new Set();
475
- for (const project of projects) {
476
- if (project.outDir === void 0) continue;
477
- if (path.isAbsolute(project.outDir)) throw new Error(`Project "${project.displayName}" outDir must be a relative path, got: ${project.outDir}`);
478
- const stubName = "jest.config.lua";
479
- const sourcePath = path.resolve(rootDirectory, project.outDir, stubName);
480
- if (!fs.existsSync(sourcePath)) continue;
481
- const targetPath = path.resolve(shadowDirectory, project.outDir, stubName);
482
- expectedPaths.add(targetPath);
483
- const sourceContent = fs.readFileSync(sourcePath);
484
- if (fs.existsSync(targetPath)) {
485
- const targetContent = fs.readFileSync(targetPath);
486
- if (Buffer.compare(sourceContent, targetContent) === 0) continue;
487
- }
488
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
489
- fs.copyFileSync(sourcePath, targetPath);
490
- changed = true;
570
+ for (const project of projects) for (const mount of project.rojoMounts) {
571
+ const result = syncMountStub(project, mount.fsPath, rootDirectory, shadowDirectory);
572
+ if (result.targetPath !== void 0) expectedPaths.add(result.targetPath);
573
+ changed ||= result.changed;
491
574
  }
492
575
  for (const existing of findShadowStubs(shadowDirectory)) if (!expectedPaths.has(existing)) {
493
576
  fs.unlinkSync(existing);
@@ -497,7 +580,7 @@ function syncStubsToShadowDirectory(projects, rootDirectory, shadowDirectory) {
497
580
  return changed;
498
581
  }
499
582
  function escapeString(value) {
500
- return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
583
+ return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
501
584
  }
502
585
  function serializeLuauValue(value, indent) {
503
586
  if (typeof value === "string") return `"${escapeString(value)}"`;
@@ -510,16 +593,35 @@ function serializeLuauValue(value, indent) {
510
593
  }
511
594
  return String(value);
512
595
  }
513
- function removeEmptyParents(directory, stopAt) {
514
- const resolved = path.resolve(directory);
515
- const resolvedStop = path.resolve(stopAt);
516
- if (resolved === resolvedStop || !resolved.startsWith(resolvedStop)) return;
517
- try {
518
- if (fs.readdirSync(resolved).length === 0) {
519
- fs.rmdirSync(resolved);
520
- removeEmptyParents(path.dirname(resolved), stopAt);
521
- }
522
- } catch {}
596
+ function hasUserAuthoredConfig(directoryPath) {
597
+ const luauPath = path.join(directoryPath, USER_LUAU_FILENAME);
598
+ if (fs.existsSync(luauPath)) return true;
599
+ const luaPath = path.join(directoryPath, STUB_FILENAME);
600
+ if (!fs.existsSync(luaPath)) return false;
601
+ return !fs.readFileSync(luaPath, "utf8").startsWith(HEADER);
602
+ }
603
+ function syncMountStub(project, fsPath, rootDirectory, shadowDirectory) {
604
+ assertMountContained(project, fsPath, rootDirectory);
605
+ const sourcePath = path.resolve(rootDirectory, fsPath, STUB_FILENAME);
606
+ if (!fs.existsSync(sourcePath)) return {
607
+ changed: false,
608
+ targetPath: void 0
609
+ };
610
+ const targetPath = path.resolve(shadowDirectory, fsPath, STUB_FILENAME);
611
+ const sourceContent = fs.readFileSync(sourcePath);
612
+ if (fs.existsSync(targetPath)) {
613
+ const targetContent = fs.readFileSync(targetPath);
614
+ if (Buffer.compare(sourceContent, targetContent) === 0) return {
615
+ changed: false,
616
+ targetPath
617
+ };
618
+ }
619
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
620
+ fs.copyFileSync(sourcePath, targetPath);
621
+ return {
622
+ changed: true,
623
+ targetPath
624
+ };
523
625
  }
524
626
  function findShadowStubs(directory) {
525
627
  const results = [];
@@ -528,10 +630,21 @@ function findShadowStubs(directory) {
528
630
  for (const entry of entries) {
529
631
  const fullPath = path.resolve(directory, entry.name);
530
632
  if (entry.isDirectory()) results.push(...findShadowStubs(fullPath));
531
- else if (entry.name === "jest.config.lua") results.push(fullPath);
633
+ else if (entry.name === STUB_FILENAME) results.push(fullPath);
532
634
  }
533
635
  return results;
534
636
  }
637
+ function removeEmptyParents(directory, stopAt) {
638
+ const resolved = path.resolve(directory);
639
+ const resolvedStop = path.resolve(stopAt);
640
+ if (resolved === resolvedStop || !resolved.startsWith(resolvedStop)) return;
641
+ try {
642
+ if (fs.readdirSync(resolved).length === 0) {
643
+ fs.rmdirSync(resolved);
644
+ removeEmptyParents(path.dirname(resolved), stopAt);
645
+ }
646
+ } catch {}
647
+ }
535
648
  //#endregion
536
649
  //#region src/coverage/derive-coverage-from.ts
537
650
  /**
@@ -2328,8 +2441,7 @@ async function run(args) {
2328
2441
  }
2329
2442
  }
2330
2443
  async function main() {
2331
- const exitCode = await run(process.argv.slice(2));
2332
- process.exit(exitCode);
2444
+ process.exitCode = await run(process.argv.slice(2));
2333
2445
  }
2334
2446
  /**
2335
2447
  * `--parallel` with no value means `"auto"`. Node's `parseArgs` can't express
@@ -2623,18 +2735,20 @@ function buildStubConfig(config) {
2623
2735
  function generateProjectStubs(projects, rootDirectory) {
2624
2736
  const entries = [];
2625
2737
  for (const project of projects) {
2626
- if (project.outDir === void 0) continue;
2627
- const outputPath = path$1.resolve(rootDirectory, project.outDir, "jest.config.lua");
2738
+ assertStubCollisionRule(project, rootDirectory);
2628
2739
  const stubConfig = {
2629
2740
  ...buildStubConfig(project.config),
2630
2741
  displayName: project.displayName,
2631
2742
  include: [],
2632
2743
  testMatch: project.testMatch
2633
2744
  };
2634
- entries.push({
2635
- config: stubConfig,
2636
- outputPath
2637
- });
2745
+ for (const mount of project.rojoMounts) {
2746
+ const outputPath = path$1.resolve(rootDirectory, mount.fsPath, "jest.config.lua");
2747
+ entries.push({
2748
+ config: stubConfig,
2749
+ outputPath
2750
+ });
2751
+ }
2638
2752
  }
2639
2753
  generateProjectConfigs(entries);
2640
2754
  }
@@ -1,5 +1,11 @@
1
1
  import { ReportOptions } from "istanbul-reports";
2
2
 
3
+ //#region packages/rojo-utils/dist/index.d.mts
4
+ interface Mount {
5
+ dataModelPath: string;
6
+ fsPath: string;
7
+ }
8
+ //#endregion
3
9
  //#region node_modules/.pnpm/@rbxts+jest@3.13.3-ts.1/node_modules/@rbxts/jest/src/config.d.ts
4
10
  interface ReporterConfig {
5
11
  reporter: string | ModuleScript;
@@ -987,10 +993,17 @@ interface ResolvedProjectConfig {
987
993
  displayName: string;
988
994
  /** Original include patterns (with TS extensions) for filesystem discovery. */
989
995
  include: Array<string>;
990
- /** Resolved output directory (workspace-relative) for stub generation. */
996
+ /**
997
+ * Single resolved output directory (workspace-relative). Set only when
998
+ * resolution produced exactly one mount; undefined when the project spans
999
+ * multiple rojo mounts. Kept for back-compat; new code should consume
1000
+ * `rojoMounts` instead.
1001
+ */
991
1002
  outDir?: string;
992
- /** DataModel paths for Jest execution. */
1003
+ /** DataModel paths Jest walks up from to discover test configs. */
993
1004
  projects: Array<string>;
1005
+ /** Internal: FS↔DataModel pairs for stub generation and shadow sync. */
1006
+ rojoMounts: Array<Mount>;
994
1007
  /** Luau-side testMatch patterns (extensions stripped). */
995
1008
  testMatch: Array<string>;
996
1009
  }