@isentinel/jest-roblox 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -444,6 +444,7 @@ root, and `workspace.outputFile: true` writes a per-package result file per
444
444
  | `--outputFile <path>` | Write results to a file |
445
445
  | `--gameOutput <path>` | Write game print/warn/error to a file |
446
446
  | `--coverage` | Collect coverage |
447
+ | `--no-coverage` | Disable coverage for this run, even when enabled in config |
447
448
  | `--coverageDirectory <path>` | Where to put coverage reports |
448
449
  | `--coverageReporters <r...>` | Which report formats to use |
449
450
  | `--collectCoverageFrom <glob>` | Globs for files to include in coverage (repeatable) |
package/dist/cli.mjs CHANGED
@@ -1,31 +1,8 @@
1
- import { A as mergeCliWithConfig, D as LuauScriptError, E as formatBanner, H as ConfigError, R as VALID_BACKENDS, U as version, V as isValidBackend, f as outputMultiResult, j as loadConfig, p as outputSingleResult, t as runJestRoblox, y as parseGameOutput } from "./run-Cl5gYSQr.mjs";
1
+ import { B as VALID_BACKENDS, G as version, M as mergeCliWithConfig, N as loadConfig, O as formatBanner, U as isValidBackend, W as ConfigError, d as walkErrorChain, h as outputSingleResult, k as LuauScriptError, m as outputMultiResult, t as runJestRoblox, u as formatMissingScopes, x as parseGameOutput } from "./run-CyHhajiY.mjs";
2
2
  import { OpenCloudError } from "@bedrock-rbx/ocale";
3
3
  import process from "node:process";
4
4
  import { parseArgs as parseArgs$1 } from "node:util";
5
5
  import color from "tinyrainbow";
6
- //#region src/utils/error-chain.ts
7
- const MAX_DEPTH = 5;
8
- function walkErrorChain(err) {
9
- const entries = [];
10
- let current = err;
11
- while (current instanceof Error && entries.length < MAX_DEPTH) {
12
- entries.push({
13
- name: current.constructor.name,
14
- code: readStringProperty(current, "code"),
15
- errno: readStringProperty(current, "errno"),
16
- message: current.message,
17
- syscall: readStringProperty(current, "syscall")
18
- });
19
- current = current.cause;
20
- }
21
- return entries;
22
- }
23
- function readStringProperty(err, key) {
24
- const value = Reflect.get(err, key);
25
- if (value === void 0 || value === null) return;
26
- return String(value);
27
- }
28
- //#endregion
29
6
  //#region src/cli.ts
30
7
  const VERSION = version;
31
8
  const HELP_TEXT = `
@@ -47,6 +24,7 @@ Options:
47
24
  --no-color Disable colored output
48
25
  -u, --updateSnapshot Update snapshot files
49
26
  --coverage Enable coverage collection
27
+ --no-coverage Disable coverage for this run (overrides config)
50
28
  --collectCoverageFrom <glob> Globs for files to include in coverage (repeatable)
51
29
  --coverageDirectory <path> Directory for coverage output (default: coverage)
52
30
  --coverageReporters <r...> Coverage reporters (default: text, lcov)
@@ -90,6 +68,7 @@ Examples:
90
68
  jest-roblox -t "should spawn" Run tests matching pattern
91
69
  jest-roblox --formatters json Output JSON to file
92
70
  jest-roblox --coverage Run tests with coverage instrumentation
71
+ jest-roblox --no-coverage Skip coverage instrumentation for this run
93
72
  `;
94
73
  function parseArgs(args) {
95
74
  const { positionals, values } = parseArgs$1({
@@ -122,6 +101,7 @@ function parseArgs(args) {
122
101
  type: "boolean"
123
102
  },
124
103
  "no-color": { type: "boolean" },
104
+ "no-coverage": { type: "boolean" },
125
105
  "no-coverage-cache": { type: "boolean" },
126
106
  "no-show-luau": { type: "boolean" },
127
107
  "outputFile": { type: "string" },
@@ -176,7 +156,7 @@ function parseArgs(args) {
176
156
  affectedSince: values["affected-since"],
177
157
  apiKey: values.apiKey,
178
158
  backend: validateBackend(values.backend),
179
- collectCoverage: values.coverage,
159
+ collectCoverage: values["no-coverage"] === true ? false : values.coverage,
180
160
  collectCoverageFrom: values.collectCoverageFrom,
181
161
  color: values["no-color"] === true ? false : values.color,
182
162
  config: values.config,
@@ -288,6 +268,7 @@ function formatBackendErrorBanner(err) {
288
268
  const extras = formatChainExtras(entry);
289
269
  const label = color.dim(`[${index.toString()}]`);
290
270
  body.push(` ${label} ${entry.name}: ${entry.message}${extras}`);
271
+ if (entry.requiredScopes !== void 0) body.push(` ${color.yellow(formatMissingScopes(entry.requiredScopes))}`);
291
272
  }
292
273
  return formatBanner({
293
274
  body,
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { B as defineProject, C as formatFailure, F as JEST_ARGV_EXCLUDED_KEYS, I as ROOT_CLI_KEYS, L as SHARED_TEST_KEYS, M as resolveConfig, N as DEFAULT_CONFIG, O as extractJsonFromOutput, P as GLOBAL_TEST_KEYS, S as writeJsonFile, T as formatTestSummary, _ as runProjects, a as visitStatement, b as writeGameOutput, c as OpenCloudBackend, d as generateTestScript, g as formatExecuteOutput, h as formatJobSummary, i as visitExpression, j as loadConfig, k as parseJestOutput, l as createOpenCloudBackend, m as formatAnnotations, n as runTypecheck, o as StudioBackend, r as visitBlock, s as createStudioBackend, t as runJestRoblox, u as buildJestArgv, v as formatGameOutputNotice, w as formatResult, x as formatJson, y as parseGameOutput, z as defineConfig } from "./run-Cl5gYSQr.mjs";
1
+ import { A as extractJsonFromOutput, C as formatJson, D as formatTestSummary, E as formatResult, F as DEFAULT_CONFIG, H as defineProject, I as GLOBAL_TEST_KEYS, L as JEST_ARGV_EXCLUDED_KEYS, N as loadConfig, P as resolveConfig, R as ROOT_CLI_KEYS, S as writeGameOutput, T as formatFailure, V as defineConfig, _ as formatJobSummary, a as visitStatement, b as formatGameOutputNotice, c as OpenCloudBackend, f as buildJestArgv, g as formatAnnotations, i as visitExpression, j as parseJestOutput, l as createOpenCloudBackend, n as runTypecheck, o as StudioBackend, p as generateTestScript, r as visitBlock, s as createStudioBackend, t as runJestRoblox, v as formatExecuteOutput, w as writeJsonFile, x as parseGameOutput, y as runProjects, z as SHARED_TEST_KEYS } from "./run-CyHhajiY.mjs";
2
2
  export { DEFAULT_CONFIG, GLOBAL_TEST_KEYS, JEST_ARGV_EXCLUDED_KEYS, OpenCloudBackend, ROOT_CLI_KEYS, SHARED_TEST_KEYS, StudioBackend, buildJestArgv, createOpenCloudBackend, createStudioBackend, defineConfig, defineProject, extractJsonFromOutput, formatAnnotations, formatExecuteOutput, formatFailure, formatGameOutputNotice, formatJobSummary, formatJson, formatResult, formatTestSummary, generateTestScript, loadConfig, parseGameOutput, parseJestOutput, resolveConfig, runJestRoblox, runProjects, runTypecheck, visitBlock, visitExpression, visitStatement, writeGameOutput, writeJsonFile };
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { PermissionError, PollTimeoutError } from "@bedrock-rbx/ocale";
2
+ import { PermissionError, PollTimeoutError, TRANSIENT_TRANSPORT_CODES } from "@bedrock-rbx/ocale";
3
3
  import process from "node:process";
4
4
  import { isDeepStrictEqual } from "node:util";
5
5
  import color from "tinyrainbow";
@@ -33,7 +33,7 @@ import { Buffer } from "node:buffer";
33
33
  import { parseJSONC, parseYAML } from "confbox";
34
34
  import { Visitor, parseSync } from "oxc-parser";
35
35
  //#region package.json
36
- var version = "0.3.1";
36
+ var version = "0.3.2";
37
37
  //#endregion
38
38
  //#region src/config/errors.ts
39
39
  var ConfigError = class extends Error {
@@ -933,12 +933,35 @@ function mapBranchArmLocations(traceMap, locations, sourceMapDirectory) {
933
933
  tsPath
934
934
  };
935
935
  }
936
+ /**
937
+ * Detects a phantom branch arm produced by a source-less synthetic statement
938
+ * `if` (e.g. a roblox-ts Array polyfill like `.filter`/`.includes`). The
939
+ * synthetic `if` has no source map entry, so trace-mapping's greatest-lower-
940
+ * bound bias snaps both arms onto the nearest preceding segment — the then-
941
+ * arm's own start — yielding a zero-width arm that coincides with another
942
+ * arm's start and can never be covered.
943
+ *
944
+ * A genuine statement `if` is safe: roblox-ts always renders it multi-line, so
945
+ * the then-body (generated line `if+1`) and the implicit-else arm (generated
946
+ * line `if`) carry distinct source-map segments and never collapse. This is
947
+ * gated to `type === "if"` by the caller: a single-line `expr-if` (ternary)
948
+ * legitimately collapses to one column-0 segment and must NOT be dropped.
949
+ */
950
+ function hasCollapsedPhantomArm(locations) {
951
+ return locations.some((arm, index) => {
952
+ if (!(arm.start.line === arm.end.line && arm.start.column === arm.end.column)) return false;
953
+ return locations.some((other, otherIndex) => {
954
+ return otherIndex !== index && other.start.line === arm.start.line && other.start.column === arm.start.column;
955
+ });
956
+ });
957
+ }
936
958
  function mapFileBranches(resources, fileCoverage, pendingBranches) {
937
959
  if (resources.coverageMap.branchMap === void 0) return;
938
960
  for (const [branchId, entry] of Object.entries(resources.coverageMap.branchMap)) {
939
961
  const armHitCounts = fileCoverage.b?.[branchId] ?? [];
940
962
  const result = mapBranchArmLocations(resources.traceMap, entry.locations, resources.sourceMapDirectory);
941
963
  if (result === void 0) continue;
964
+ if (entry.type === "if" && hasCollapsedPhantomArm(result.locations)) continue;
942
965
  let fileBranches = pendingBranches.get(result.tsPath);
943
966
  if (fileBranches === void 0) {
944
967
  fileBranches = [];
@@ -2081,6 +2104,7 @@ function findMapping(filePath, mappings, key = "outDir") {
2081
2104
  }
2082
2105
  function replacePrefix(filePath, from, to) {
2083
2106
  if (filePath === from) return to;
2107
+ if (from === ".") return `${to}/${filePath.startsWith("./") ? filePath.slice(2) : filePath}`;
2084
2108
  if (filePath.startsWith(`${from}/`)) return `${to}${filePath.slice(from.length)}`;
2085
2109
  return filePath;
2086
2110
  }
@@ -2717,7 +2741,7 @@ function formatTestSummary(result, timing, styles, options) {
2717
2741
  }
2718
2742
  function formatResult(result, timing, options) {
2719
2743
  const styles = createStyles(options.color, options.slowTestThreshold);
2720
- const lines = [formatRunHeader(options, styles)];
2744
+ const lines = [""];
2721
2745
  for (const file of result.testResults) {
2722
2746
  if (options.failuresOnly === true && file.numFailingTests === 0 && !hasExecError(file)) continue;
2723
2747
  lines.push(formatFileSummary(file, options, styles));
@@ -2891,7 +2915,7 @@ function formatMultiProjectResult(projects, timing, options) {
2891
2915
  result,
2892
2916
  styles
2893
2917
  }));
2894
- const lines = [formatRunHeader(options, styles), sections.join("\n\n")];
2918
+ const lines = ["", sections.join("\n\n")];
2895
2919
  const mergedResult = mergeJestResults(projects.map((project) => project.result));
2896
2920
  lines.push("", formatTestSummary(mergedResult, timing, styles, {
2897
2921
  snapshotWriteFailures: options.snapshotWriteFailures,
@@ -3698,6 +3722,15 @@ function hasFormatter(formatters, name) {
3698
3722
  function usesAgentFormatter(formatters, verbose = false) {
3699
3723
  return hasFormatter(formatters, "agent") && !verbose;
3700
3724
  }
3725
+ /**
3726
+ * Whether human-facing progress output (the run header, the "Running X of Y"
3727
+ * notice, the workspace streaming lines) should be written: not silent and not
3728
+ * a machine-readable formatter (json, or non-verbose agent). The single source
3729
+ * of truth so these sinks can't drift apart.
3730
+ */
3731
+ function isDefaultHumanFormatter(options) {
3732
+ return options.silent !== true && !usesAgentFormatter(options.formatters, options.verbose) && !hasFormatter(options.formatters, "json");
3733
+ }
3701
3734
  //#endregion
3702
3735
  //#region src/snapshot/path-resolver.ts
3703
3736
  function createSnapshotPathResolver(config) {
@@ -4896,7 +4929,10 @@ var OcaleRunner = class {
4896
4929
  script,
4897
4930
  timeoutSeconds,
4898
4931
  universeId: this.credentials.universeId
4899
- }, { timeoutMs: timeout });
4932
+ }, {
4933
+ retryableTransportCodes: TRANSIENT_TRANSPORT_CODES,
4934
+ timeoutMs: timeout
4935
+ });
4900
4936
  if (!result.success) {
4901
4937
  if (result.err instanceof PollTimeoutError) throw new Error("Execution timed out", { cause: result.err });
4902
4938
  throw new Error(result.err.message, { cause: result.err });
@@ -4918,7 +4954,7 @@ var OcaleRunner = class {
4918
4954
  placeId: this.credentials.placeId,
4919
4955
  universeId: this.credentials.universeId
4920
4956
  };
4921
- const result = await this.places.save(parameters);
4957
+ const result = await this.places.save(parameters, { retryableTransportCodes: TRANSIENT_TRANSPORT_CODES });
4922
4958
  if (!result.success) throw new Error(`Failed to upload place: ${result.err.message}`, { cause: result.err });
4923
4959
  return {
4924
4960
  uploadMs: Date.now() - uploadStart,
@@ -5036,6 +5072,35 @@ function generateTestScript(options) {
5036
5072
  return test_runner_bundled_default.replace("__CONFIG_JSON__", () => JSON.stringify({ configs }));
5037
5073
  }
5038
5074
  //#endregion
5075
+ //#region src/utils/error-chain.ts
5076
+ const MAX_DEPTH = 5;
5077
+ function walkErrorChain(err) {
5078
+ const entries = [];
5079
+ let current = err;
5080
+ while (current instanceof Error && entries.length < MAX_DEPTH) {
5081
+ entries.push({
5082
+ name: current.constructor.name,
5083
+ code: readStringProperty(current, "code"),
5084
+ errno: readStringProperty(current, "errno"),
5085
+ message: current.message,
5086
+ requiredScopes: current instanceof PermissionError ? current.requiredScopes : void 0,
5087
+ syscall: readStringProperty(current, "syscall")
5088
+ });
5089
+ current = current.cause;
5090
+ }
5091
+ return entries;
5092
+ }
5093
+ function formatMissingScopes(scopes) {
5094
+ if (scopes.length === 0) return "API key has insufficient scopes. Add via Creator Dashboard.";
5095
+ const joined = scopes.join(", ");
5096
+ return `API key missing scope${scopes.length === 1 ? "" : "s"} ${joined}. Add via Creator Dashboard.`;
5097
+ }
5098
+ function readStringProperty(err, key) {
5099
+ const value = Reflect.get(err, key);
5100
+ if (value === void 0 || value === null) return;
5101
+ return String(value);
5102
+ }
5103
+ //#endregion
5039
5104
  //#region src/backends/open-cloud.ts
5040
5105
  const PARALLEL_AUTO_CAP = 3;
5041
5106
  const BASE_URL_ENV = "JEST_ROBLOX_OPEN_CLOUD_BASE_URL";
@@ -5171,10 +5236,7 @@ function createOpenCloudBackend(credentials) {
5171
5236
  }
5172
5237
  function describeError(err) {
5173
5238
  const cause = err instanceof Error ? err.cause : void 0;
5174
- if (cause instanceof PermissionError) {
5175
- const scopes = cause.requiredScopes.join(", ");
5176
- return `API key missing scope${cause.requiredScopes.length === 1 ? "" : "s"} ${scopes}. Add via Creator Dashboard.`;
5177
- }
5239
+ if (cause instanceof PermissionError) return formatMissingScopes(cause.requiredScopes);
5178
5240
  return err instanceof Error ? err.message : String(err);
5179
5241
  }
5180
5242
  function warnStreamingDisabled(err, state) {
@@ -5947,6 +6009,27 @@ function narrowConfigByFiles(config, files) {
5947
6009
  testPathPattern: `(${branches.join("|")})`
5948
6010
  };
5949
6011
  }
6012
+ /**
6013
+ * Forward an Instance-namespace `testPathPattern` to the Luau runner.
6014
+ *
6015
+ * Node-side discovery is the source of truth: the FS-namespace filter
6016
+ * (positional args or `--testPathPattern`) has already resolved to a concrete
6017
+ * file set against real paths. Drop the raw FS-shaped pattern and re-narrow by
6018
+ * the discovered files so Jest-on-Roblox matches the same files — its paths are
6019
+ * Roblox Instance names (e.g. `ServerScriptService/...`) with no `src/` prefix,
6020
+ * so a raw FS pattern like `src/server/foo.spec` matches zero files there.
6021
+ *
6022
+ * `filterActive` gates the rewrite: a bare run (no positionals, no
6023
+ * `testPathPattern`) leaves the config untouched so the Luau side runs every
6024
+ * `testMatch` file rather than a giant basename alternation.
6025
+ */
6026
+ function narrowForLuauRun(config, runtimeFiles, filterActive) {
6027
+ if (!filterActive) return config;
6028
+ return narrowConfigByFiles({
6029
+ ...config,
6030
+ testPathPattern: void 0
6031
+ }, runtimeFiles);
6032
+ }
5950
6033
  function toBasenamePattern(file) {
5951
6034
  const posix = file.replaceAll("\\", "/");
5952
6035
  const lastSlash = posix.lastIndexOf("/");
@@ -7972,6 +8055,27 @@ function resolveSetupFilePaths(config) {
7972
8055
  resolveAllSetupFilePaths([config]);
7973
8056
  }
7974
8057
  //#endregion
8058
+ //#region src/run/run-header.ts
8059
+ /**
8060
+ * Print the ` RUN vX.Y <rootDir>` header to stdout at the moment a run begins
8061
+ * (right before the backend uploads), so the CLI doesn't look stalled while it
8062
+ * waits for remote results. The end-of-run formatters no longer emit it.
8063
+ *
8064
+ * Self-gates to the default human formatter: nothing is written under
8065
+ * `--silent`, `--formatters json`, or `--formatters agent` (without
8066
+ * `--verbose`), which produce machine-readable output that must stay clean.
8067
+ */
8068
+ function emitRunHeader(input) {
8069
+ if (!isDefaultHumanFormatter(input)) return;
8070
+ process.stdout.write(formatRunHeader({
8071
+ collectCoverage: input.collectCoverage,
8072
+ color: input.color,
8073
+ rootDir: input.rootDir,
8074
+ verbose: input.verbose ?? false,
8075
+ version: input.version
8076
+ }));
8077
+ }
8078
+ //#endregion
7975
8079
  //#region src/run/multi.ts
7976
8080
  const DEFAULT_ROJO_PROJECT = "default.project.json";
7977
8081
  const VERSION$2 = version;
@@ -8015,6 +8119,15 @@ async function runMultiProject(options) {
8015
8119
  rootConfig
8016
8120
  });
8017
8121
  });
8122
+ if (pendingJobs.length > 0) emitRunHeader({
8123
+ collectCoverage: rootConfig.collectCoverage,
8124
+ color: rootConfig.color,
8125
+ formatters: rootConfig.formatters,
8126
+ rootDir: rootConfig.rootDir,
8127
+ silent: rootConfig.silent,
8128
+ verbose: rootConfig.verbose,
8129
+ version: VERSION$2
8130
+ });
8018
8131
  const projectResults = await runJobs(backend, pendingJobs, parallel, timing);
8019
8132
  const uniqueTypeTestFiles = [...new Set(allTypeTestFiles)];
8020
8133
  const typecheckResult = uniqueTypeTestFiles.length > 0 ? timing.profile("runTypecheck", () => {
@@ -8086,10 +8199,11 @@ function collectPendingJobs(arguments_) {
8086
8199
  testMatch: project.include
8087
8200
  };
8088
8201
  const { runtimeFiles, typeTestFiles } = classifyTestFiles(discoverTestFiles(discoveryConfig, projectCliFiles).files, rootConfig);
8089
- const projConfig = narrowConfigByFiles({
8202
+ const filterActive = (projectCliFiles?.length ?? 0) > 0 || discoveryConfig.testPathPattern !== void 0;
8203
+ const projConfig = narrowForLuauRun({
8090
8204
  ...discoveryConfig,
8091
8205
  testMatch: project.testMatch
8092
- }, projectCliFiles ?? []);
8206
+ }, runtimeFiles, filterActive);
8093
8207
  allTypeTestFiles.push(...typeTestFiles);
8094
8208
  if (runtimeFiles.length === 0) continue;
8095
8209
  const runtimeInjectionPaths = [];
@@ -8212,15 +8326,15 @@ const VERSION$1 = version;
8212
8326
  async function runSingleProject(options) {
8213
8327
  const { cli } = options;
8214
8328
  const timing = options.timing ?? NOOP_TIMING_COLLECTOR;
8215
- const config = timing.profile("narrowConfigByFiles", () => {
8216
- return narrowConfigByFiles(options.config, cli.files ?? []);
8217
- });
8329
+ const baseConfig = { ...options.config };
8218
8330
  timing.profile("resolveSetupFilePaths", () => {
8219
- resolveSetupFilePaths(config);
8331
+ resolveSetupFilePaths(baseConfig);
8332
+ });
8333
+ const discovery = timing.profile("discoverTestFiles", () => {
8334
+ return discoverTestFiles(baseConfig, cli.files);
8220
8335
  });
8221
- const discovery = timing.profile("discoverTestFiles", () => discoverTestFiles(config, cli.files));
8222
8336
  if (discovery.files.length === 0) {
8223
- if (config.passWithNoTests) return {
8337
+ if (baseConfig.passWithNoTests) return {
8224
8338
  mode: "single",
8225
8339
  preCoverageMs: 0
8226
8340
  };
@@ -8232,7 +8346,11 @@ async function runSingleProject(options) {
8232
8346
  };
8233
8347
  }
8234
8348
  const { runtimeFiles, typeTestFiles } = timing.profile("classifyTestFiles", () => {
8235
- return classifyTestFiles(discovery.files, config);
8349
+ return classifyTestFiles(discovery.files, baseConfig);
8350
+ });
8351
+ const filterActive = (cli.files?.length ?? 0) > 0 || baseConfig.testPathPattern !== void 0;
8352
+ const config = timing.profile("narrowForLuauRun", () => {
8353
+ return narrowForLuauRun(baseConfig, runtimeFiles, filterActive);
8236
8354
  });
8237
8355
  if (typeTestFiles.length === 0 && runtimeFiles.length === 0) {
8238
8356
  if (config.passWithNoTests) return {
@@ -8280,7 +8398,17 @@ async function runSingleProject(options) {
8280
8398
  }
8281
8399
  async function executeRuntimeTests(options) {
8282
8400
  const { cli, config, testFiles, timing, totalFiles } = options;
8283
- if (!config.silent && !usesAgentFormatter(config.formatters, config.verbose) && !hasFormatter(config.formatters, "json") && testFiles.length !== totalFiles) process.stderr.write(`Running ${String(testFiles.length)} of ${String(totalFiles)} test files\n`);
8401
+ const useDefaultFormatter = isDefaultHumanFormatter(config);
8402
+ emitRunHeader({
8403
+ collectCoverage: config.collectCoverage,
8404
+ color: config.color,
8405
+ formatters: config.formatters,
8406
+ rootDir: config.rootDir,
8407
+ silent: config.silent,
8408
+ verbose: config.verbose,
8409
+ version: VERSION$1
8410
+ });
8411
+ if (useDefaultFormatter && testFiles.length !== totalFiles) process.stderr.write(`Running ${String(testFiles.length)} of ${String(totalFiles)} test files\n`);
8284
8412
  const backend = await timing.profileAsync("resolveBackend", async () => {
8285
8413
  return resolveBackend(cli, config);
8286
8414
  });
@@ -9213,13 +9341,33 @@ async function prepareWorkspaceDispatch(input) {
9213
9341
  }
9214
9342
  return { scriptOverride: generateMaterializerScript(inputs) };
9215
9343
  }
9344
+ /**
9345
+ * Resolve a `--testPathPattern` against this package's files Node-side, then
9346
+ * forward an Instance-namespace basename pattern (see {@link narrowForLuauRun}).
9347
+ *
9348
+ * A pattern that matches no file in this package simply targets a different
9349
+ * package: keep the (zero-matching) raw pattern so Jest-on-Roblox runs nothing,
9350
+ * and set `passWithNoTests` so it doesn't `exit(1)`. The raw pattern is
9351
+ * load-bearing here — clearing it would drop the filter entirely and make the
9352
+ * Luau side fall back to `testMatch`, running the whole package.
9353
+ */
9354
+ function narrowPackageTestPathPattern(packageConfig) {
9355
+ if (packageConfig.testPathPattern === void 0) return packageConfig;
9356
+ const { files } = discoverTestFiles(packageConfig);
9357
+ const { runtimeFiles } = classifyTestFiles(files, packageConfig);
9358
+ if (runtimeFiles.length === 0) return {
9359
+ ...packageConfig,
9360
+ passWithNoTests: true
9361
+ };
9362
+ return narrowForLuauRun(packageConfig, runtimeFiles, true);
9363
+ }
9216
9364
  async function loadPackages(input) {
9217
9365
  const { cli, packageInfos, timing } = input;
9218
9366
  const loaded = [];
9219
9367
  for (const info of packageInfos) {
9220
- const packageConfig = mergeCliWithConfig(cli, await timing.profileAsync(`load-config:${info.name}`, async () => {
9368
+ const packageConfig = narrowPackageTestPathPattern(mergeCliWithConfig(cli, await timing.profileAsync(`load-config:${info.name}`, async () => {
9221
9369
  return loadConfig$1(void 0, info.packageDirectory);
9222
- }));
9370
+ })));
9223
9371
  const rojoProject = packageConfig.rojoProject ?? ROJO_PROJECT_DEFAULT;
9224
9372
  const hasExplicitIgnore = packageConfig.coveragePathIgnorePatterns !== DEFAULT_CONFIG.coveragePathIgnorePatterns;
9225
9373
  const hasExplicitCoverageCache = packageConfig.coverageCache !== DEFAULT_CONFIG.coverageCache;
@@ -9811,6 +9959,14 @@ async function runWorkspaceMode(cli, workspace, timing) {
9811
9959
  }
9812
9960
  let runtimeResults;
9813
9961
  try {
9962
+ emitRunHeader({
9963
+ color: runOptions.color,
9964
+ formatters: runOptions.formatters,
9965
+ rootDir: workspaceRoot,
9966
+ silent: runOptions.silent,
9967
+ verbose: cli.verbose,
9968
+ version: VERSION
9969
+ });
9814
9970
  const onStreamingResult = resolveStreamingProgressSink(runOptions, cli);
9815
9971
  runtimeResults = await runWorkspace({
9816
9972
  backend,
@@ -9908,8 +10064,11 @@ function composeWorkspaceDisplayName(package_, project) {
9908
10064
  * either break the structured output or be silenced anyway.
9909
10065
  */
9910
10066
  function resolveStreamingProgressSink(runOptions, cli) {
9911
- if (runOptions.silent) return;
9912
- if (hasFormatter(runOptions.formatters, "json") || usesAgentFormatter(runOptions.formatters, cli.verbose)) return;
10067
+ if (!isDefaultHumanFormatter({
10068
+ formatters: runOptions.formatters,
10069
+ silent: runOptions.silent,
10070
+ verbose: cli.verbose
10071
+ })) return;
9913
10072
  return (entry) => {
9914
10073
  const line = formatStreamingProgressLine(entry, { color: runOptions.color });
9915
10074
  process.stdout.write(`${line}\n`);
@@ -9942,4 +10101,4 @@ async function runJestRoblox(cli, config) {
9942
10101
  }
9943
10102
  }
9944
10103
  //#endregion
9945
- export { mergeCliWithConfig as A, defineProject as B, formatFailure as C, LuauScriptError as D, formatBanner as E, JEST_ARGV_EXCLUDED_KEYS as F, ConfigError as H, ROOT_CLI_KEYS as I, SHARED_TEST_KEYS as L, resolveConfig as M, DEFAULT_CONFIG as N, extractJsonFromOutput as O, GLOBAL_TEST_KEYS as P, VALID_BACKENDS as R, writeJsonFile$1 as S, formatTestSummary as T, version as U, isValidBackend as V, runProjects as _, visitStatement as a, writeGameOutput as b, OpenCloudBackend as c, generateTestScript as d, outputMultiResult as f, formatExecuteOutput as g, formatJobSummary as h, visitExpression as i, loadConfig$1 as j, parseJestOutput as k, createOpenCloudBackend as l, formatAnnotations as m, runTypecheck as n, StudioBackend as o, outputSingleResult as p, visitBlock as r, createStudioBackend as s, runJestRoblox as t, buildJestArgv as u, formatGameOutputNotice as v, formatResult as w, formatJson as x, parseGameOutput as y, defineConfig as z };
10104
+ export { extractJsonFromOutput as A, VALID_BACKENDS as B, formatJson as C, formatTestSummary as D, formatResult as E, DEFAULT_CONFIG as F, version as G, defineProject as H, GLOBAL_TEST_KEYS as I, JEST_ARGV_EXCLUDED_KEYS as L, mergeCliWithConfig as M, loadConfig$1 as N, formatBanner as O, resolveConfig as P, ROOT_CLI_KEYS as R, writeGameOutput as S, formatFailure as T, isValidBackend as U, defineConfig as V, ConfigError as W, formatJobSummary as _, visitStatement as a, formatGameOutputNotice as b, OpenCloudBackend as c, walkErrorChain as d, buildJestArgv as f, formatAnnotations as g, outputSingleResult as h, visitExpression as i, parseJestOutput as j, LuauScriptError as k, createOpenCloudBackend as l, outputMultiResult as m, runTypecheck as n, StudioBackend as o, generateTestScript as p, visitBlock as r, createStudioBackend as s, runJestRoblox as t, formatMissingScopes as u, formatExecuteOutput as v, writeJsonFile$1 as w, parseGameOutput as x, runProjects as y, SHARED_TEST_KEYS as z };
@@ -686,7 +686,7 @@ function C$4({ force: e } = {}) {
686
686
  var y$3 = C$4();
687
687
  //#endregion
688
688
  //#region package.json
689
- var version = "0.3.1";
689
+ var version = "0.3.2";
690
690
  //#endregion
691
691
  //#region src/config/errors.ts
692
692
  var ConfigError = class extends Error {
@@ -20150,12 +20150,35 @@ function mapBranchArmLocations(traceMap, locations, sourceMapDirectory) {
20150
20150
  tsPath
20151
20151
  };
20152
20152
  }
20153
+ /**
20154
+ * Detects a phantom branch arm produced by a source-less synthetic statement
20155
+ * `if` (e.g. a roblox-ts Array polyfill like `.filter`/`.includes`). The
20156
+ * synthetic `if` has no source map entry, so trace-mapping's greatest-lower-
20157
+ * bound bias snaps both arms onto the nearest preceding segment — the then-
20158
+ * arm's own start — yielding a zero-width arm that coincides with another
20159
+ * arm's start and can never be covered.
20160
+ *
20161
+ * A genuine statement `if` is safe: roblox-ts always renders it multi-line, so
20162
+ * the then-body (generated line `if+1`) and the implicit-else arm (generated
20163
+ * line `if`) carry distinct source-map segments and never collapse. This is
20164
+ * gated to `type === "if"` by the caller: a single-line `expr-if` (ternary)
20165
+ * legitimately collapses to one column-0 segment and must NOT be dropped.
20166
+ */
20167
+ function hasCollapsedPhantomArm(locations) {
20168
+ return locations.some((arm, index) => {
20169
+ if (!(arm.start.line === arm.end.line && arm.start.column === arm.end.column)) return false;
20170
+ return locations.some((other, otherIndex) => {
20171
+ return otherIndex !== index && other.start.line === arm.start.line && other.start.column === arm.start.column;
20172
+ });
20173
+ });
20174
+ }
20153
20175
  function mapFileBranches(resources, fileCoverage, pendingBranches) {
20154
20176
  if (resources.coverageMap.branchMap === void 0) return;
20155
20177
  for (const [branchId, entry] of Object.entries(resources.coverageMap.branchMap)) {
20156
20178
  const armHitCounts = fileCoverage.b?.[branchId] ?? [];
20157
20179
  const result = mapBranchArmLocations(resources.traceMap, entry.locations, resources.sourceMapDirectory);
20158
20180
  if (result === void 0) continue;
20181
+ if (entry.type === "if" && hasCollapsedPhantomArm(result.locations)) continue;
20159
20182
  let fileBranches = pendingBranches.get(result.tsPath);
20160
20183
  if (fileBranches === void 0) {
20161
20184
  fileBranches = [];
@@ -25919,6 +25942,7 @@ function findMapping(filePath, mappings, key = "outDir") {
25919
25942
  }
25920
25943
  function replacePrefix(filePath, from, to) {
25921
25944
  if (filePath === from) return to;
25945
+ if (from === ".") return `${to}/${filePath.startsWith("./") ? filePath.slice(2) : filePath}`;
25922
25946
  if (filePath.startsWith(`${from}/`)) return `${to}${filePath.slice(from.length)}`;
25923
25947
  return filePath;
25924
25948
  }
@@ -26758,7 +26782,7 @@ var core_default = (/* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exp
26758
26782
  }
26759
26783
  });
26760
26784
  };
26761
- var MODES = /* @__PURE__ */ Object.freeze({
26785
+ var MODES = /*#__PURE__*/ Object.freeze({
26762
26786
  __proto__: null,
26763
26787
  APOS_STRING_MODE,
26764
26788
  BACKSLASH_ESCAPE,
@@ -29055,7 +29079,7 @@ function formatTestSummary(result, timing, styles, options) {
29055
29079
  }
29056
29080
  function formatResult(result, timing, options) {
29057
29081
  const styles = createStyles(options.color, options.slowTestThreshold);
29058
- const lines = [formatRunHeader(options, styles)];
29082
+ const lines = [""];
29059
29083
  for (const file of result.testResults) {
29060
29084
  if (options.failuresOnly === true && file.numFailingTests === 0 && !hasExecError(file)) continue;
29061
29085
  lines.push(formatFileSummary(file, options, styles));
@@ -29229,7 +29253,7 @@ function formatMultiProjectResult(projects, timing, options) {
29229
29253
  result,
29230
29254
  styles
29231
29255
  }));
29232
- const lines = [formatRunHeader(options, styles), sections.join("\n\n")];
29256
+ const lines = ["", sections.join("\n\n")];
29233
29257
  const mergedResult = mergeJestResults(projects.map((project) => project.result));
29234
29258
  lines.push("", formatTestSummary(mergedResult, timing, styles, {
29235
29259
  snapshotWriteFailures: options.snapshotWriteFailures,
@@ -30036,6 +30060,15 @@ function hasFormatter(formatters, name) {
30036
30060
  function usesAgentFormatter(formatters, verbose = false) {
30037
30061
  return hasFormatter(formatters, "agent") && !verbose;
30038
30062
  }
30063
+ /**
30064
+ * Whether human-facing progress output (the run header, the "Running X of Y"
30065
+ * notice, the workspace streaming lines) should be written: not silent and not
30066
+ * a machine-readable formatter (json, or non-verbose agent). The single source
30067
+ * of truth so these sinks can't drift apart.
30068
+ */
30069
+ function isDefaultHumanFormatter(options) {
30070
+ return options.silent !== true && !usesAgentFormatter(options.formatters, options.verbose) && !hasFormatter(options.formatters, "json");
30071
+ }
30039
30072
  //#endregion
30040
30073
  //#region src/snapshot/path-resolver.ts
30041
30074
  function createSnapshotPathResolver(config) {
@@ -37469,7 +37502,10 @@ var OcaleRunner = class {
37469
37502
  script,
37470
37503
  timeoutSeconds,
37471
37504
  universeId: this.credentials.universeId
37472
- }, { timeoutMs: timeout });
37505
+ }, {
37506
+ retryableTransportCodes: TRANSIENT_TRANSPORT_CODES,
37507
+ timeoutMs: timeout
37508
+ });
37473
37509
  if (!result.success) {
37474
37510
  if (result.err instanceof PollTimeoutError) throw new Error("Execution timed out", { cause: result.err });
37475
37511
  throw new Error(result.err.message, { cause: result.err });
@@ -37491,7 +37527,7 @@ var OcaleRunner = class {
37491
37527
  placeId: this.credentials.placeId,
37492
37528
  universeId: this.credentials.universeId
37493
37529
  };
37494
- const result = await this.places.save(parameters);
37530
+ const result = await this.places.save(parameters, { retryableTransportCodes: TRANSIENT_TRANSPORT_CODES });
37495
37531
  if (!result.success) throw new Error(`Failed to upload place: ${result.err.message}`, { cause: result.err });
37496
37532
  return {
37497
37533
  uploadMs: Date.now() - uploadStart,
@@ -37609,6 +37645,35 @@ function generateTestScript(options) {
37609
37645
  return test_runner_bundled_default.replace("__CONFIG_JSON__", () => JSON.stringify({ configs }));
37610
37646
  }
37611
37647
  //#endregion
37648
+ //#region src/utils/error-chain.ts
37649
+ const MAX_DEPTH = 5;
37650
+ function walkErrorChain(err) {
37651
+ const entries = [];
37652
+ let current = err;
37653
+ while (current instanceof Error && entries.length < MAX_DEPTH) {
37654
+ entries.push({
37655
+ name: current.constructor.name,
37656
+ code: readStringProperty(current, "code"),
37657
+ errno: readStringProperty(current, "errno"),
37658
+ message: current.message,
37659
+ requiredScopes: current instanceof PermissionError ? current.requiredScopes : void 0,
37660
+ syscall: readStringProperty(current, "syscall")
37661
+ });
37662
+ current = current.cause;
37663
+ }
37664
+ return entries;
37665
+ }
37666
+ function formatMissingScopes(scopes) {
37667
+ if (scopes.length === 0) return "API key has insufficient scopes. Add via Creator Dashboard.";
37668
+ const joined = scopes.join(", ");
37669
+ return `API key missing scope${scopes.length === 1 ? "" : "s"} ${joined}. Add via Creator Dashboard.`;
37670
+ }
37671
+ function readStringProperty(err, key) {
37672
+ const value = Reflect.get(err, key);
37673
+ if (value === void 0 || value === null) return;
37674
+ return String(value);
37675
+ }
37676
+ //#endregion
37612
37677
  //#region src/backends/open-cloud.ts
37613
37678
  const PARALLEL_AUTO_CAP = 3;
37614
37679
  const BASE_URL_ENV = "JEST_ROBLOX_OPEN_CLOUD_BASE_URL";
@@ -37744,10 +37809,7 @@ function createOpenCloudBackend(credentials) {
37744
37809
  }
37745
37810
  function describeError(err) {
37746
37811
  const cause = err instanceof Error ? err.cause : void 0;
37747
- if (cause instanceof PermissionError) {
37748
- const scopes = cause.requiredScopes.join(", ");
37749
- return `API key missing scope${cause.requiredScopes.length === 1 ? "" : "s"} ${scopes}. Add via Creator Dashboard.`;
37750
- }
37812
+ if (cause instanceof PermissionError) return formatMissingScopes(cause.requiredScopes);
37751
37813
  return err instanceof Error ? err.message : String(err);
37752
37814
  }
37753
37815
  function warnStreamingDisabled(err, state) {
@@ -38520,6 +38582,27 @@ function narrowConfigByFiles(config, files) {
38520
38582
  testPathPattern: `(${branches.join("|")})`
38521
38583
  };
38522
38584
  }
38585
+ /**
38586
+ * Forward an Instance-namespace `testPathPattern` to the Luau runner.
38587
+ *
38588
+ * Node-side discovery is the source of truth: the FS-namespace filter
38589
+ * (positional args or `--testPathPattern`) has already resolved to a concrete
38590
+ * file set against real paths. Drop the raw FS-shaped pattern and re-narrow by
38591
+ * the discovered files so Jest-on-Roblox matches the same files — its paths are
38592
+ * Roblox Instance names (e.g. `ServerScriptService/...`) with no `src/` prefix,
38593
+ * so a raw FS pattern like `src/server/foo.spec` matches zero files there.
38594
+ *
38595
+ * `filterActive` gates the rewrite: a bare run (no positionals, no
38596
+ * `testPathPattern`) leaves the config untouched so the Luau side runs every
38597
+ * `testMatch` file rather than a giant basename alternation.
38598
+ */
38599
+ function narrowForLuauRun(config, runtimeFiles, filterActive) {
38600
+ if (!filterActive) return config;
38601
+ return narrowConfigByFiles({
38602
+ ...config,
38603
+ testPathPattern: void 0
38604
+ }, runtimeFiles);
38605
+ }
38523
38606
  function toBasenamePattern(file) {
38524
38607
  const posix = file.replaceAll("\\", "/");
38525
38608
  const lastSlash = posix.lastIndexOf("/");
@@ -40549,6 +40632,27 @@ function resolveSetupFilePaths(config) {
40549
40632
  resolveAllSetupFilePaths([config]);
40550
40633
  }
40551
40634
  //#endregion
40635
+ //#region src/run/run-header.ts
40636
+ /**
40637
+ * Print the ` RUN vX.Y <rootDir>` header to stdout at the moment a run begins
40638
+ * (right before the backend uploads), so the CLI doesn't look stalled while it
40639
+ * waits for remote results. The end-of-run formatters no longer emit it.
40640
+ *
40641
+ * Self-gates to the default human formatter: nothing is written under
40642
+ * `--silent`, `--formatters json`, or `--formatters agent` (without
40643
+ * `--verbose`), which produce machine-readable output that must stay clean.
40644
+ */
40645
+ function emitRunHeader(input) {
40646
+ if (!isDefaultHumanFormatter(input)) return;
40647
+ node_process.default.stdout.write(formatRunHeader({
40648
+ collectCoverage: input.collectCoverage,
40649
+ color: input.color,
40650
+ rootDir: input.rootDir,
40651
+ verbose: input.verbose ?? false,
40652
+ version: input.version
40653
+ }));
40654
+ }
40655
+ //#endregion
40552
40656
  //#region src/run/multi.ts
40553
40657
  const DEFAULT_ROJO_PROJECT = "default.project.json";
40554
40658
  const VERSION$3 = version;
@@ -40592,6 +40696,15 @@ async function runMultiProject(options) {
40592
40696
  rootConfig
40593
40697
  });
40594
40698
  });
40699
+ if (pendingJobs.length > 0) emitRunHeader({
40700
+ collectCoverage: rootConfig.collectCoverage,
40701
+ color: rootConfig.color,
40702
+ formatters: rootConfig.formatters,
40703
+ rootDir: rootConfig.rootDir,
40704
+ silent: rootConfig.silent,
40705
+ verbose: rootConfig.verbose,
40706
+ version: VERSION$3
40707
+ });
40595
40708
  const projectResults = await runJobs(backend, pendingJobs, parallel, timing);
40596
40709
  const uniqueTypeTestFiles = [...new Set(allTypeTestFiles)];
40597
40710
  const typecheckResult = uniqueTypeTestFiles.length > 0 ? timing.profile("runTypecheck", () => {
@@ -40663,10 +40776,11 @@ function collectPendingJobs(arguments_) {
40663
40776
  testMatch: project.include
40664
40777
  };
40665
40778
  const { runtimeFiles, typeTestFiles } = classifyTestFiles(discoverTestFiles(discoveryConfig, projectCliFiles).files, rootConfig);
40666
- const projConfig = narrowConfigByFiles({
40779
+ const filterActive = (projectCliFiles?.length ?? 0) > 0 || discoveryConfig.testPathPattern !== void 0;
40780
+ const projConfig = narrowForLuauRun({
40667
40781
  ...discoveryConfig,
40668
40782
  testMatch: project.testMatch
40669
- }, projectCliFiles ?? []);
40783
+ }, runtimeFiles, filterActive);
40670
40784
  allTypeTestFiles.push(...typeTestFiles);
40671
40785
  if (runtimeFiles.length === 0) continue;
40672
40786
  const runtimeInjectionPaths = [];
@@ -40789,15 +40903,15 @@ const VERSION$2 = version;
40789
40903
  async function runSingleProject(options) {
40790
40904
  const { cli } = options;
40791
40905
  const timing = options.timing ?? NOOP_TIMING_COLLECTOR;
40792
- const config = timing.profile("narrowConfigByFiles", () => {
40793
- return narrowConfigByFiles(options.config, cli.files ?? []);
40794
- });
40906
+ const baseConfig = { ...options.config };
40795
40907
  timing.profile("resolveSetupFilePaths", () => {
40796
- resolveSetupFilePaths(config);
40908
+ resolveSetupFilePaths(baseConfig);
40909
+ });
40910
+ const discovery = timing.profile("discoverTestFiles", () => {
40911
+ return discoverTestFiles(baseConfig, cli.files);
40797
40912
  });
40798
- const discovery = timing.profile("discoverTestFiles", () => discoverTestFiles(config, cli.files));
40799
40913
  if (discovery.files.length === 0) {
40800
- if (config.passWithNoTests) return {
40914
+ if (baseConfig.passWithNoTests) return {
40801
40915
  mode: "single",
40802
40916
  preCoverageMs: 0
40803
40917
  };
@@ -40809,7 +40923,11 @@ async function runSingleProject(options) {
40809
40923
  };
40810
40924
  }
40811
40925
  const { runtimeFiles, typeTestFiles } = timing.profile("classifyTestFiles", () => {
40812
- return classifyTestFiles(discovery.files, config);
40926
+ return classifyTestFiles(discovery.files, baseConfig);
40927
+ });
40928
+ const filterActive = (cli.files?.length ?? 0) > 0 || baseConfig.testPathPattern !== void 0;
40929
+ const config = timing.profile("narrowForLuauRun", () => {
40930
+ return narrowForLuauRun(baseConfig, runtimeFiles, filterActive);
40813
40931
  });
40814
40932
  if (typeTestFiles.length === 0 && runtimeFiles.length === 0) {
40815
40933
  if (config.passWithNoTests) return {
@@ -40857,7 +40975,17 @@ async function runSingleProject(options) {
40857
40975
  }
40858
40976
  async function executeRuntimeTests(options) {
40859
40977
  const { cli, config, testFiles, timing, totalFiles } = options;
40860
- if (!config.silent && !usesAgentFormatter(config.formatters, config.verbose) && !hasFormatter(config.formatters, "json") && testFiles.length !== totalFiles) node_process.default.stderr.write(`Running ${String(testFiles.length)} of ${String(totalFiles)} test files\n`);
40978
+ const useDefaultFormatter = isDefaultHumanFormatter(config);
40979
+ emitRunHeader({
40980
+ collectCoverage: config.collectCoverage,
40981
+ color: config.color,
40982
+ formatters: config.formatters,
40983
+ rootDir: config.rootDir,
40984
+ silent: config.silent,
40985
+ verbose: config.verbose,
40986
+ version: VERSION$2
40987
+ });
40988
+ if (useDefaultFormatter && testFiles.length !== totalFiles) node_process.default.stderr.write(`Running ${String(testFiles.length)} of ${String(totalFiles)} test files\n`);
40861
40989
  const backend = await timing.profileAsync("resolveBackend", async () => {
40862
40990
  return resolveBackend(cli, config);
40863
40991
  });
@@ -41790,13 +41918,33 @@ async function prepareWorkspaceDispatch(input) {
41790
41918
  }
41791
41919
  return { scriptOverride: generateMaterializerScript(inputs) };
41792
41920
  }
41921
+ /**
41922
+ * Resolve a `--testPathPattern` against this package's files Node-side, then
41923
+ * forward an Instance-namespace basename pattern (see {@link narrowForLuauRun}).
41924
+ *
41925
+ * A pattern that matches no file in this package simply targets a different
41926
+ * package: keep the (zero-matching) raw pattern so Jest-on-Roblox runs nothing,
41927
+ * and set `passWithNoTests` so it doesn't `exit(1)`. The raw pattern is
41928
+ * load-bearing here — clearing it would drop the filter entirely and make the
41929
+ * Luau side fall back to `testMatch`, running the whole package.
41930
+ */
41931
+ function narrowPackageTestPathPattern(packageConfig) {
41932
+ if (packageConfig.testPathPattern === void 0) return packageConfig;
41933
+ const { files } = discoverTestFiles(packageConfig);
41934
+ const { runtimeFiles } = classifyTestFiles(files, packageConfig);
41935
+ if (runtimeFiles.length === 0) return {
41936
+ ...packageConfig,
41937
+ passWithNoTests: true
41938
+ };
41939
+ return narrowForLuauRun(packageConfig, runtimeFiles, true);
41940
+ }
41793
41941
  async function loadPackages(input) {
41794
41942
  const { cli, packageInfos, timing } = input;
41795
41943
  const loaded = [];
41796
41944
  for (const info of packageInfos) {
41797
- const packageConfig = mergeCliWithConfig(cli, await timing.profileAsync(`load-config:${info.name}`, async () => {
41945
+ const packageConfig = narrowPackageTestPathPattern(mergeCliWithConfig(cli, await timing.profileAsync(`load-config:${info.name}`, async () => {
41798
41946
  return loadConfig(void 0, info.packageDirectory);
41799
- }));
41947
+ })));
41800
41948
  const rojoProject = packageConfig.rojoProject ?? ROJO_PROJECT_DEFAULT;
41801
41949
  const hasExplicitIgnore = packageConfig.coveragePathIgnorePatterns !== DEFAULT_CONFIG.coveragePathIgnorePatterns;
41802
41950
  const hasExplicitCoverageCache = packageConfig.coverageCache !== DEFAULT_CONFIG.coverageCache;
@@ -42389,6 +42537,14 @@ async function runWorkspaceMode(cli, workspace, timing) {
42389
42537
  }
42390
42538
  let runtimeResults;
42391
42539
  try {
42540
+ emitRunHeader({
42541
+ color: runOptions.color,
42542
+ formatters: runOptions.formatters,
42543
+ rootDir: workspaceRoot,
42544
+ silent: runOptions.silent,
42545
+ verbose: cli.verbose,
42546
+ version: VERSION$1
42547
+ });
42392
42548
  const onStreamingResult = resolveStreamingProgressSink(runOptions, cli);
42393
42549
  runtimeResults = await runWorkspace({
42394
42550
  backend,
@@ -42486,8 +42642,11 @@ function composeWorkspaceDisplayName(package_, project) {
42486
42642
  * either break the structured output or be silenced anyway.
42487
42643
  */
42488
42644
  function resolveStreamingProgressSink(runOptions, cli) {
42489
- if (runOptions.silent) return;
42490
- if (hasFormatter(runOptions.formatters, "json") || usesAgentFormatter(runOptions.formatters, cli.verbose)) return;
42645
+ if (!isDefaultHumanFormatter({
42646
+ formatters: runOptions.formatters,
42647
+ silent: runOptions.silent,
42648
+ verbose: cli.verbose
42649
+ })) return;
42491
42650
  return (entry) => {
42492
42651
  const line = formatStreamingProgressLine(entry, { color: runOptions.color });
42493
42652
  node_process.default.stdout.write(`${line}\n`);
@@ -42520,29 +42679,6 @@ async function runJestRoblox(cli, config) {
42520
42679
  }
42521
42680
  }
42522
42681
  //#endregion
42523
- //#region src/utils/error-chain.ts
42524
- const MAX_DEPTH = 5;
42525
- function walkErrorChain(err) {
42526
- const entries = [];
42527
- let current = err;
42528
- while (current instanceof Error && entries.length < MAX_DEPTH) {
42529
- entries.push({
42530
- name: current.constructor.name,
42531
- code: readStringProperty(current, "code"),
42532
- errno: readStringProperty(current, "errno"),
42533
- message: current.message,
42534
- syscall: readStringProperty(current, "syscall")
42535
- });
42536
- current = current.cause;
42537
- }
42538
- return entries;
42539
- }
42540
- function readStringProperty(err, key) {
42541
- const value = Reflect.get(err, key);
42542
- if (value === void 0 || value === null) return;
42543
- return String(value);
42544
- }
42545
- //#endregion
42546
42682
  //#region src/cli.ts
42547
42683
  const VERSION = version;
42548
42684
  const HELP_TEXT = `
@@ -42564,6 +42700,7 @@ Options:
42564
42700
  --no-color Disable colored output
42565
42701
  -u, --updateSnapshot Update snapshot files
42566
42702
  --coverage Enable coverage collection
42703
+ --no-coverage Disable coverage for this run (overrides config)
42567
42704
  --collectCoverageFrom <glob> Globs for files to include in coverage (repeatable)
42568
42705
  --coverageDirectory <path> Directory for coverage output (default: coverage)
42569
42706
  --coverageReporters <r...> Coverage reporters (default: text, lcov)
@@ -42607,6 +42744,7 @@ Examples:
42607
42744
  jest-roblox -t "should spawn" Run tests matching pattern
42608
42745
  jest-roblox --formatters json Output JSON to file
42609
42746
  jest-roblox --coverage Run tests with coverage instrumentation
42747
+ jest-roblox --no-coverage Skip coverage instrumentation for this run
42610
42748
  `;
42611
42749
  function parseArgs(args) {
42612
42750
  const { positionals, values } = (0, node_util.parseArgs)({
@@ -42639,6 +42777,7 @@ function parseArgs(args) {
42639
42777
  type: "boolean"
42640
42778
  },
42641
42779
  "no-color": { type: "boolean" },
42780
+ "no-coverage": { type: "boolean" },
42642
42781
  "no-coverage-cache": { type: "boolean" },
42643
42782
  "no-show-luau": { type: "boolean" },
42644
42783
  "outputFile": { type: "string" },
@@ -42693,7 +42832,7 @@ function parseArgs(args) {
42693
42832
  affectedSince: values["affected-since"],
42694
42833
  apiKey: values.apiKey,
42695
42834
  backend: validateBackend(values.backend),
42696
- collectCoverage: values.coverage,
42835
+ collectCoverage: values["no-coverage"] === true ? false : values.coverage,
42697
42836
  collectCoverageFrom: values.collectCoverageFrom,
42698
42837
  color: values["no-color"] === true ? false : values.color,
42699
42838
  config: values.config,
@@ -42805,6 +42944,7 @@ function formatBackendErrorBanner(err) {
42805
42944
  const extras = formatChainExtras(entry);
42806
42945
  const label = y$3.dim(`[${index.toString()}]`);
42807
42946
  body.push(` ${label} ${entry.name}: ${entry.message}${extras}`);
42947
+ if (entry.requiredScopes !== void 0) body.push(` ${y$3.yellow(formatMissingScopes(entry.requiredScopes))}`);
42808
42948
  }
42809
42949
  return formatBanner({
42810
42950
  body,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isentinel/jest-roblox",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Jest-compatible CLI for running roblox-ts tests via Roblox Open Cloud",
5
5
  "keywords": [
6
6
  "jest",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "devDependencies": {
61
61
  "@isentinel/eslint-config": "5.0.0-beta.11",
62
- "@isentinel/roblox-ts": "4.0.5",
62
+ "@isentinel/roblox-ts": "4.0.6",
63
63
  "@isentinel/tsconfig": "1.2.0",
64
64
  "@isentinel/weld": "0.2.0",
65
65
  "@oxc-project/types": "0.123.0",