@aefree/pi-unity 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-unity",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./index.ts"
@@ -18,7 +18,7 @@
18
18
  ]
19
19
  },
20
20
  "scripts": {
21
- "test": "tsx tests/unity-core.test.ts && tsx tests/unity-pipeline.test.ts && tsx tests/unity-processes.test.ts && tsx tests/pi-unity-settings.test.ts && tsx tests/unity-projects.test.ts && tsx tests/unity-guidance-audit.test.ts && tsx tests/unity-batchmode.test.ts && tsx tests/unity-test-batch.test.ts && tsx tests/unity-cli.test.ts && tsx tests/unity-project-lock.test.ts && tsx tests/unity-artifact-profile.test.ts && tsx tests/unity-file-discovery-filter.test.ts && tsx tests/unity-registration.test.ts && tsx tests/unity-optional-integrations.test.ts && tsx tests/unity-package-validation.test.ts",
21
+ "test": "tsx tests/unity-core.test.ts && tsx tests/unity-launch.test.ts && tsx tests/unity-pipeline.test.ts && tsx tests/unity-processes.test.ts && tsx tests/pi-unity-settings.test.ts && tsx tests/unity-projects.test.ts && tsx tests/unity-guidance-audit.test.ts && tsx tests/unity-batchmode.test.ts && tsx tests/unity-test-batch.test.ts && tsx tests/unity-tests.test.ts && tsx tests/unity-cli.test.ts && tsx tests/unity-project-lock.test.ts && tsx tests/unity-artifact-profile.test.ts && tsx tests/unity-file-discovery-filter.test.ts && tsx tests/unity-registration.test.ts && tsx tests/unity-optional-integrations.test.ts && tsx tests/unity-package-validation.test.ts",
22
22
  "eval:guidance-skill": "tsx evals/auditing-unity-agent-guidance/run-eval.ts"
23
23
  },
24
24
  "dependencies": {
@@ -11,7 +11,7 @@ Always resolve and pass the exact Unity project-copy path. Do not route by proje
11
11
 
12
12
  - Inspect: `unity_project_status`
13
13
  - Connected compile/test: use the package's typed connected tools when available
14
- - Isolated tests: `unity_run_test_batch`
14
+ - Isolated tests: `unity_run_tests`
15
15
  - Custom isolated Editor arguments: `unity_launch_batchmode`
16
16
  - Existing evidence: `unity_inspect_artifacts`
17
17
 
@@ -18,7 +18,7 @@
18
18
 
19
19
  - Connected EditMode: `run_tests --mode editor`; asynchronous execution plus `test_status` is safest for uniform wrappers.
20
20
  - Connected PlayMode: require `--async_tests true`, then poll `test_status` because domain reload can drop the initiating request.
21
- - Isolated/report-producing: use `unity test` or the packaged `unity_run_test_batch` when NUnit XML/log artifacts are required.
21
+ - Isolated/report-producing: use `unity test` or the packaged `unity_run_tests` when NUnit XML/log artifacts are required.
22
22
  - Preserve graphics requirements and reject zero-test, malformed, incomplete, or nested `success:false` results.
23
23
 
24
24
  ## Build and ExecuteMethod
@@ -14,7 +14,7 @@ Run Unity Test Framework tests from the command line without opening the Editor
14
14
  - **Use absolute paths** for `-testResults` and `-logFile` to ensure logs are easy to find.
15
15
  - **Unity allows only one process per project folder** - GUI Editor and batchmode/headless both count as that one process.
16
16
  - **Do not open the GUI editor for the same project before or during batchmode runs** - `/unity-open` and `unity_open_editor` launch the full Unity Editor GUI and are not equivalent to headless batchmode.
17
- - **Do not close a reachable Pipeline Editor merely to run tests** - an already-open exact copy can run supported tests through `run_tests` plus `test_status`; use the `unity-pipeline-workflows` skill.
17
+ - **Do not close a reachable Pipeline Editor merely to run tests** - call `unity_run_tests`; it selects connected Pipeline for supported requests and rejects isolated-only options rather than closing the Editor.
18
18
  - **Only close a blocking Unity Editor through `unity_launch_batchmode` safeguards after deliberately choosing isolated execution** - this is limited to cases such as required NUnit XML, unsupported connected filters/commands, or explicit isolation. Use `closeBlockingUnityProcess: true` only when `unity_project_status` shows `piUnity.allowCloseRunningUnityProcess` is enabled or the user explicitly says it is enabled; pi-unity re-scans the resolved project and never accepts arbitrary PIDs.
19
19
  - **Use `unity_launch_batchmode` when you want to run headless Unity directly** - keep test-specific flags deliberate, especially around `-runTests` and `-quit`.
20
20
  - **Bundle tests into one Unity batchmode turn whenever practical** - starting/stopping Unity, importing assets, and domain reloads dominate runtime. A broader single run is usually faster than many sequential one-test Unity launches, and same-project runs cannot use useful parallelism.
@@ -40,19 +40,19 @@ Run Unity Test Framework tests from the command line without opening the Editor
40
40
  Use the `pi-unity` tools first instead of forming raw Unity CLI commands on the fly:
41
41
  - `unity_project_status` to inspect lockfile/process state without launching Unity
42
42
  - `unity_inspect_artifacts` to summarize existing Unity logs/test XML without launching Unity
43
- - `unity_run_test_batch` for isolated/report-producing Unity Test Framework runs with one platform and bundled filters/categories
43
+ - `unity_run_tests` for isolated/report-producing Unity Test Framework runs with one platform and bundled filters/categories
44
44
  - `unity_launch_batchmode` for custom headless Unity execution that needs raw Editor arguments
45
45
  - `unity_open_editor` only when the user explicitly wants the GUI Editor
46
46
  - `/unity-open` as the user-facing GUI launcher helper
47
47
 
48
- `unity_run_test_batch` should be the default for isolated or report-producing Unity Test Framework work. It generates unique absolute XML/log paths under the project `Logs` directory, normalizes filter/category arrays into one launch, omits `-quit`, and uses the same guarded executor as `unity_launch_batchmode`. For an already-open reachable exact-copy Pipeline Editor, use the `unity-pipeline-workflows` skill instead.
48
+ `unity_run_tests` is the one ordinary Unity Test Framework tool. It routes supported requests to a reachable exact-copy Pipeline Editor or a closed-project isolated `unity test` run, writes a durable normalized JSON artifact, and retains native NUnit/JUnit reports when requested. It never silently switches after uncertain dispatch.
49
49
 
50
50
  `unity_launch_batchmode` remains the default for custom agent-run headless Unity work because it already:
51
51
  - resolves the Unity project from a direct project root, a coordination root, or another nearby folder
52
52
  - reads `ProjectSettings/ProjectVersion.txt`
53
- - prefers the installed `unity run` CLI when available, falling back to OS-aware direct editor launch
53
+ - treats installed `unity run` as authoritative when available: it receives the project path without `--editor-version` and reads the declared version itself, falling back only when Unity CLI is unavailable
54
54
  - uses OS-aware standard install probing
55
- - supports explicit `UNITY_EDITOR_PATH` / `unityEditorPath` overrides
55
+ - resolves only exact project-version standard installation candidates when direct Editor fallback is required
56
56
  - strips direct-Editor flags managed by `unity run` (`-batchmode`, `-projectPath`, `-quit`) before forwarding args in Unity CLI mode
57
57
  - supports `launcher: "editor-executable"` when Unity CLI argument forwarding differs from direct Editor executable behavior
58
58
  - checks Unity CLI status and running Unity processes before launch
@@ -68,15 +68,15 @@ If a launch is blocked by a native Unity lockfile, call `unity_project_status` b
68
68
 
69
69
  ### 2. Get the Project's Unity Version
70
70
 
71
- If you are in direct CLI fallback mode, read `ProjectSettings/ProjectVersion.txt` in the Unity project root:
71
+ Before direct Editor fallback, read `ProjectSettings/ProjectVersion.txt` in the Unity project root:
72
72
 
73
73
  ```
74
74
  m_EditorVersion: ####.#.#f#
75
75
  ```
76
76
 
77
- ### 3. Find the Unity Executable
77
+ ### 3. Resolve the exact-version Unity Editor
78
78
 
79
- If you are in direct CLI fallback mode, check these locations based on platform and version:
79
+ For direct Editor fallback, use only the standard locations constructed from the version declared in `ProjectVersion.txt`:
80
80
 
81
81
  **Windows:**
82
82
  ```
@@ -89,17 +89,16 @@ C:\UnityInstalls\<version>\Editor\Unity.exe
89
89
  ```
90
90
  /Applications/Unity/Hub/Editor/<version>/Unity.app/Contents/MacOS/Unity
91
91
  /Applications/Unity/<version>/Unity.app/Contents/MacOS/Unity
92
- /Applications/Unity*
93
92
  ```
94
93
 
95
- If not found, ask the user for their Unity install path.
94
+ If the exact declared version is not found, do not use an arbitrary Editor path or a nearby version. Ask the user to install the declared version through their normal Unity installation workflow, or use Unity CLI after it can resolve that exact version.
96
95
 
97
96
  ### 4. Choose the execution route, then run tests
98
97
 
99
98
  Route before planning a batch:
100
99
  1. Call `unity_project_status` for the exact project copy.
101
- 2. If that copy is already open, Pipeline is reachable, and `run_tests` plus `test_status` are advertised, use `unity-pipeline-workflows`; do not close the Editor or invoke `unity_run_test_batch` merely because the test tool is more convenient.
102
- 3. Choose isolated `unity_run_test_batch` only when the Editor is closed, connected testing is unavailable, CI/isolation is intentional, required filters are unsupported, or NUnit XML/log artifacts are required. State the reason.
100
+ 2. If that copy is already open, Pipeline is reachable, and `run_tests` plus `test_status` are advertised, use `unity-pipeline-workflows`; do not close the Editor or invoke `unity_run_tests` merely because the test tool is more convenient.
101
+ 3. Choose isolated `unity_run_tests` only when the Editor is closed, connected testing is unavailable, CI/isolation is intentional, required filters are unsupported, or NUnit XML/log artifacts are required. State the reason.
103
102
  4. If project state is uncertain, stop rather than closing the Editor or starting batchmode.
104
103
 
105
104
  After choosing the isolated route:
@@ -108,7 +107,7 @@ After choosing the isolated route:
108
107
  - when several specific tests are relevant, prefer a broader class/namespace/suite/category filter, or no `-testFilter` for the affected platform, over separate Unity launches
109
108
  - use a single-test launch only for a quick smoke check or to isolate/rerun a known failure; do not use one-test launches as the default validation strategy
110
109
  - do not queue multiple `unity_launch_batchmode` calls back-to-back in one agent turn; wait for the structured summary, inspect failures, and only then decide whether another Unity launch is necessary
111
- - call `unity_run_test_batch` for isolated/report-producing tests; use `unity_launch_batchmode` only when custom raw Unity arguments are required
110
+ - call `unity_run_tests` for isolated/report-producing tests; use `unity_launch_batchmode` only when custom raw Unity arguments are required
112
111
  - pass one `testPlatform` (`EditMode` or `PlayMode`) and bundle applicable `testFilters`/`testCategories`; empty arrays mean all tests on that platform
113
112
  - use the generated exact result/log paths from the tool report for any follow-up artifact inspection
114
113
  - pass `closeBlockingUnityProcess: true` only after connected testing was ruled out or isolated evidence was explicitly required, a same-project Unity process is blocking the chosen run, and Pi settings enable `piUnity.allowCloseRunningUnityProcess`
@@ -124,12 +123,13 @@ After choosing the isolated route:
124
123
  - exclude graphics-required screenshot/visual-capture tests from no-graphics runs
125
124
  - prefer project test categories such as `RequiresGraphics` / `VisualCapture` when the project exposes them
126
125
 
127
- Direct CLI fallback templates:
126
+ Unity CLI project-authoritative template:
128
127
  ```
129
128
  unity test "<ProjectPath>" --mode <EditMode|PlayMode> --filter "<Full.Test.Name>" --output "<ResultsPath>" -- -logFile "<LogPath>"
130
- "<UnityEditorPath>" -batchmode -nographics -projectPath "<ProjectPath>" -runTests -testPlatform <EditMode|PlayMode> -testFilter "<Full.Test.Name>" -testResults "<ResultsPath>" -logFile "<LogPath>"
131
129
  ```
132
130
 
131
+ For direct Editor fallback, use `unity_launch_batchmode` with its project path and test parameters; it resolves only the declared project's exact-version standard candidates.
132
+
133
133
  Fallback parameters:
134
134
  - `-testPlatform`: `EditMode` or `PlayMode`
135
135
  - `-testFilter`: Full test name (e.g., `MyNamespace.MyTests.TestMethodName`)
@@ -12,19 +12,19 @@ Use this workflow only for an already-running exact project copy with `com.unity
12
12
  Use one typed tool call for each supported connected operation:
13
13
 
14
14
  - `unity_pipeline_recompile` for connected script compilation.
15
- - `unity_pipeline_run_tests` for one focused `EditMode` or `PlayMode` test-name selection.
15
+ - `unity_run_tests` for one compatible `EditMode` or `PlayMode` test-name or category selection.
16
16
 
17
17
  These tools resolve the exact copy, require advertised commands, inspect lifecycle state, dispatch once, validate identity, and poll internally with a fixed deadline. Do not recreate their wait loops with `bash`, `unity recompile_status`, or `unity test_status` calls.
18
18
 
19
- A timeout or malformed response is uncertain: the Unity operation may still be running. Do not cancel, retry, launch batchmode, close the Editor, or claim a result without a new user-authorized decision. The only automatic retry is Pipeline 0.5's explicit initial-settling `Server Busy` response for `unity_pipeline_recompile` or `unity_pipeline_run_tests`, which confirms that a main-thread command was rejected before dispatch.
19
+ A timeout or malformed response is uncertain: the Unity operation may still be running. Do not cancel, retry, launch batchmode, close the Editor, or claim a result without a new user-authorized decision. The only automatic retry is Pipeline 0.5's explicit initial-settling `Server Busy` response for `unity_pipeline_recompile` or `unity_run_tests`, which confirms that a main-thread command was rejected before dispatch.
20
20
 
21
21
  ## Preconditions and boundaries
22
22
 
23
23
  1. Pass an explicit `path` when multiple project copies may be found; paths identify copies, not display names.
24
24
  2. The typed tools require a reachable exact-copy Pipeline and advertised `editor_status` plus operation commands. A different connected client is not itself a project lock.
25
- 3. `unity_pipeline_recompile` never sends `editor_stop` or overrides Unity's Script Changes While Playing preference. Known recompile-and-continue, stop-and-recompile, and defer policies proceed according to Unity's configured behavior. Pipeline 0.4 does not currently expose that preference, so the tool reports the unavailable policy while allowing recompilation to proceed. `unity_pipeline_run_tests` may dispatch advertised `editor_stop` when needed, then verifies Edit Mode before running tests. The tools never enter Play Mode, pause, save, launch, or close Unity; recompilation may perform Unity's normal asset refresh/import and script-change behavior.
25
+ 3. `unity_pipeline_recompile` never sends `editor_stop` or overrides Unity's Script Changes While Playing preference. Known recompile-and-continue, stop-and-recompile, and defer policies proceed according to Unity's configured behavior. Pipeline 0.4 does not currently expose that preference, so the tool reports the unavailable policy while allowing recompilation to proceed. `unity_run_tests` may dispatch advertised `editor_stop` when needed, then verifies Edit Mode before running tests. The tools never enter Play Mode, pause, save, launch, or close Unity; recompilation may perform Unity's normal asset refresh/import and script-change behavior.
26
26
  4. Test success requires a well-formed terminal result, a known positive executed count, and zero failures. An asynchronous initiation with `Total: 0` and `running` is nonterminal.
27
- 5. Passing test records are intentionally discarded. Failures retain only a bounded set of failed/inconclusive names, messages, and stack excerpts.
27
+ 5. Routine tool output remains compact. Complete bounded terminal test records are persisted immediately in the durable normalized JSON artifact before Pipeline status can be displaced.
28
28
 
29
29
  ## Compile
30
30
 
@@ -32,11 +32,11 @@ Call `unity_pipeline_recompile` with optional `path` and `timeoutSeconds` (defau
32
32
 
33
33
  ## Focused tests
34
34
 
35
- Call `unity_pipeline_run_tests` with:
35
+ Call `unity_run_tests` with:
36
36
 
37
37
  - required `testPlatform`: `EditMode` or `PlayMode`;
38
- - optional `testFilter`: one test-name filter only;
39
- - optional `path` and `timeoutSeconds` (default 600, maximum 3600).
38
+ - optional `testFilters` or `testCategories`: at most one selector family and one selector for connected execution;
39
+ - optional `execution`, `path`, and `timeoutSeconds` (default 600, maximum 3600).
40
40
  - before running PlayMode tests, check the Game View focus setting. Set it to Play Unfocused for the test run, then restore the previous setting afterward.
41
41
 
42
42
  The tool treats `no_tests`, idle, and not-started statuses as safe inactivity, detects a pre-existing active connected test before dispatch, and stops rather than claiming or replacing active work. It captures returned mode/filter/run identity fields when available and stops as uncertain if status is clearly displaced by another run.
@@ -47,6 +47,6 @@ Normally call the typed tools, not raw CLI commands. If a typed tool is unavaila
47
47
 
48
48
  ## When not to use connected tools
49
49
 
50
- Use `unity_run_test_batch` for a closed project, intentional isolation/CI, category or multiple filters, or required NUnit XML/log evidence. State the reason for that route. Do not use batchmode as an automatic fallback after an uncertain connected dispatch.
50
+ Use `unity_run_tests` with `execution: "isolated"` for a closed project, intentional isolation/CI, multiple selectors, retries, sharding, coverage, or required NUnit/JUnit evidence. A single category is supported connected when Pipeline advertises the command; do not claim broader connected selector support. Do not use batchmode as an automatic fallback after an uncertain connected dispatch.
51
51
 
52
52
  Use the typed compile/test tools when their polling and terminal evidence fit the task. Advertised Pipeline `eval` remains available through `unity_pipeline_eval` for bounded project-specific inspection or operations outside those typed workflows; its `timeoutSeconds` range is 1–86,400 seconds, and a timeout remains uncertain without cancellation or retry. It is an assistance surface, not a forbidden fallback or a substitute for the typed tools' completion protocol. Eval compiles arbitrary C# with Roslyn on the Editor main thread, so ordinary properties and local-variable snippets are valid; it is not expression-only or reliably statically read-only. Prefer typed tools for their stronger evidence, but let user intent and project guidance govern mutations. Lifecycle, persistent-setting, destructive, asset, scene-save, package, build, and test mutations require explicit authorization.
@@ -18,6 +18,7 @@ export type UnityFailedTest = {
18
18
  stackTrace?: string;
19
19
  };
20
20
 
21
+ export type UnityParsedTestCase = { name: string; status: string; durationSeconds?: number; message?: string; stackTrace?: string };
21
22
  export type UnityParsedTestResults = {
22
23
  total?: number;
23
24
  passed?: number;
@@ -26,6 +27,8 @@ export type UnityParsedTestResults = {
26
27
  inconclusive?: number;
27
28
  durationSeconds?: number;
28
29
  failedTests: UnityFailedTest[];
30
+ /** Complete bounded per-test evidence for normalized artifacts, never routine tool output. */
31
+ tests: UnityParsedTestCase[];
29
32
  };
30
33
 
31
34
  export type UnityBatchmodeArtifacts = {
@@ -108,6 +111,7 @@ export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults |
108
111
 
109
112
  const rootAttributes = parseAttributes(testRunMatch[1] ?? "");
110
113
  const failedTests: UnityFailedTest[] = [];
114
+ const tests: UnityParsedTestCase[] = [];
111
115
 
112
116
  const testCaseRegex = /<test-case\b([^>]*)>([\s\S]*?)<\/test-case>/gi;
113
117
  for (const match of xml.matchAll(testCaseRegex)) {
@@ -116,17 +120,12 @@ export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults |
116
120
  const result = String(attributes.result ?? attributes.label ?? "").toLowerCase();
117
121
  const success = String(attributes.success ?? "").toLowerCase();
118
122
  const isFailure = result === "failed" || success === "false";
119
- if (!isFailure) continue;
120
-
121
123
  const failureMessage = body.match(/<message[^>]*>([\s\S]*?)<\/message>/i);
122
124
  const stackTrace = body.match(/<stack-trace[^>]*>([\s\S]*?)<\/stack-trace>/i);
123
- if (failedTests.length < 50) {
124
- failedTests.push({
125
- name: truncateEvidence(attributes.fullname ?? attributes.name ?? "(unknown test)", 500) ?? "(unknown test)",
126
- message: truncateEvidence(decodeXmlText(failureMessage?.[1]), 1_000),
127
- stackTrace: truncateEvidence(decodeXmlText(stackTrace?.[1]), 4_000),
128
- });
129
- }
125
+ const name = truncateEvidence(attributes.fullname ?? attributes.name ?? "(unknown test)", 1_000) ?? "(unknown test)";
126
+ if (tests.length < 2_000) tests.push({ name, status: attributes.result ?? attributes.label ?? "Unknown", ...(parseOptionalNumber(attributes.duration) === undefined ? {} : { durationSeconds: parseOptionalNumber(attributes.duration) }), ...(truncateEvidence(decodeXmlText(failureMessage?.[1]), 4_000) ? { message: truncateEvidence(decodeXmlText(failureMessage?.[1]), 4_000) } : {}), ...(truncateEvidence(decodeXmlText(stackTrace?.[1]), 8_000) ? { stackTrace: truncateEvidence(decodeXmlText(stackTrace?.[1]), 8_000) } : {}) });
127
+ if (!isFailure) continue;
128
+ if (failedTests.length < 50) failedTests.push({ name, message: truncateEvidence(decodeXmlText(failureMessage?.[1]), 1_000), stackTrace: truncateEvidence(decodeXmlText(stackTrace?.[1]), 4_000) });
130
129
  }
131
130
 
132
131
  const skipped = parseOptionalNumber(rootAttributes.skipped) ?? parseOptionalNumber(rootAttributes.inconclusive);
@@ -139,6 +138,7 @@ export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults |
139
138
  inconclusive: parseOptionalNumber(rootAttributes.inconclusive),
140
139
  durationSeconds: parseOptionalNumber(rootAttributes.duration),
141
140
  failedTests,
141
+ tests,
142
142
  };
143
143
  if (parsed.total === undefined && parsed.passed === undefined && parsed.failed === undefined && parsed.failedTests.length === 0) {
144
144
  return null;
package/src/unity-cli.ts CHANGED
@@ -13,8 +13,7 @@ export type UnityCliCommand = {
13
13
  };
14
14
 
15
15
  export type UnityCliLaunchOptions = {
16
- editorVersion?: string;
17
- editorPath?: string;
16
+ editorVersionOverride?: string;
18
17
  timeoutSeconds?: number;
19
18
  cliCommand?: string;
20
19
  useGraphics?: boolean;
@@ -22,6 +21,19 @@ export type UnityCliLaunchOptions = {
22
21
  automated?: boolean;
23
22
  };
24
23
 
24
+ export type UnityCliTestOptions = UnityCliLaunchOptions & {
25
+ testPlatform: "EditMode" | "PlayMode";
26
+ testFilters?: string[];
27
+ testCategories?: string[];
28
+ retries?: number;
29
+ rerunFailed?: boolean;
30
+ shard?: string;
31
+ shardInventoryPath?: string;
32
+ reportPaths?: { nunit?: string; junit?: string; log?: string };
33
+ coverage?: boolean;
34
+ coverageOptions?: string;
35
+ };
36
+
25
37
  export type UnityCliPipelineInstance = {
26
38
  projectPath: string;
27
39
  pid: number | null;
@@ -71,11 +83,8 @@ function unityCliBaseArgs(): string[] {
71
83
  }
72
84
 
73
85
  function appendUnityCliEditorOptions(args: string[], options: UnityCliLaunchOptions): void {
74
- if (options.editorVersion?.trim()) {
75
- args.push("--editor-version", options.editorVersion.trim());
76
- }
77
- if (options.editorPath?.trim()) {
78
- args.push("--editor-path", options.editorPath.trim());
86
+ if (options.editorVersionOverride?.trim()) {
87
+ args.push("--editor-version", options.editorVersionOverride.trim());
79
88
  }
80
89
  }
81
90
 
@@ -116,6 +125,30 @@ export function normalizeUnityCliForwardedArgs(extraEditorArgs: string[] = []):
116
125
  return normalized;
117
126
  }
118
127
 
128
+ export function createUnityCliTestCommand(projectRoot: string, options: UnityCliTestOptions): UnityCliCommand {
129
+ const args = [...unityCliBaseArgs(), "test", projectRoot, "--mode", options.testPlatform];
130
+ appendUnityCliEditorOptions(args, options);
131
+ if (options.timeoutSeconds !== undefined) args.push("--timeout", String(options.timeoutSeconds));
132
+ if (options.testFilters?.length) args.push("--filter", options.testFilters.join(";"));
133
+ if (options.retries) args.push("--retries", String(options.retries));
134
+ if (options.rerunFailed) args.push("--rerun-failed");
135
+ if (options.shard) args.push("--shard", options.shard);
136
+ if (options.shardInventoryPath) args.push("--shard-inventory", options.shardInventoryPath);
137
+ const nunit = options.reportPaths?.nunit;
138
+ const junit = options.reportPaths?.junit;
139
+ if (nunit && junit) args.push("--output", nunit, "--report-format", "nunit,junit", "--junit-output", junit);
140
+ else if (junit) args.push("--output", junit, "--report-format", "junit");
141
+ else if (nunit) args.push("--output", nunit);
142
+ if (options.coverage) args.push("--coverage");
143
+ if (options.coverageOptions) args.push("--coverage-options", options.coverageOptions);
144
+ const editorArgs: string[] = [];
145
+ if (!options.useGraphics) editorArgs.push("-nographics");
146
+ if (options.testCategories?.length) editorArgs.push("-testCategory", options.testCategories.join(";"));
147
+ if (options.reportPaths?.log) editorArgs.push("-logFile", options.reportPaths.log);
148
+ if (editorArgs.length > 0) args.push("--", ...editorArgs);
149
+ return { command: resolveUnityCliCommand(options), args };
150
+ }
151
+
119
152
  export function createUnityCliRunCommand(projectRoot: string, extraEditorArgs: string[] = [], options: UnityCliLaunchOptions = {}): UnityCliCommand {
120
153
  const args = [...unityCliBaseArgs(), "run", projectRoot];
121
154
  const forwardedArgs = normalizeUnityCliForwardedArgs(applyDefaultUnityBatchmodeArgs(extraEditorArgs, { useGraphics: options.useGraphics }));
package/src/unity-core.ts CHANGED
@@ -27,14 +27,6 @@ export function parseUnityVersionText(contents: string): string | null {
27
27
  return match ? match[1] : null;
28
28
  }
29
29
 
30
- export function normalizeUnityEditorOverride(editorPath: string, platform: SupportedPlatform): string {
31
- const normalized = path.normalize(editorPath.trim());
32
- if (platform === "darwin" && normalized.toLowerCase().endsWith(".app")) {
33
- return path.join(normalized, "Contents", "MacOS", "Unity");
34
- }
35
- return normalized;
36
- }
37
-
38
30
  export function buildUnityEditorCandidates(
39
31
  version: string,
40
32
  platform: SupportedPlatform = process.platform,
@@ -0,0 +1,36 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import {
4
+ buildUnityBatchmodeArgs,
5
+ buildUnityEditorCandidates,
6
+ buildUnityOpenEditorArgs,
7
+ type SupportedPlatform,
8
+ type UnityOpenEditorArgsOptions,
9
+ } from "./unity-core";
10
+
11
+ /** Direct-executable compatibility fallback. Unity CLI routes must not import this module. */
12
+ export async function resolveUnityEditorPath(
13
+ unityVersion: string,
14
+ options: { platform?: SupportedPlatform; homeDir?: string; access?: (candidate: string) => Promise<void> } = {},
15
+ ): Promise<string> {
16
+ const autoCandidates = buildUnityEditorCandidates(unityVersion, options.platform ?? process.platform, options.homeDir);
17
+ const access = options.access ?? ((candidate: string) => fs.access(candidate, fs.constants.X_OK));
18
+ for (const candidate of autoCandidates) {
19
+ try { await access(candidate); return path.normalize(candidate); } catch { /* Try next exact-version candidate. */ }
20
+ }
21
+ throw new Error(`Could not find a Unity Editor executable for Unity ${unityVersion}. Install that exact Editor version through your normal Unity installation workflow, or install Unity CLI so it can resolve the project version.`);
22
+ }
23
+
24
+ export function launchUnityEditorDetached(editorPath: string, projectRoot: string, options: UnityOpenEditorArgsOptions = {}): { pid: number | undefined; args: string[]; command: string } {
25
+ const args = buildUnityOpenEditorArgs(projectRoot, options);
26
+ const child = (awaitableSpawn)(editorPath, args, { detached: true, stdio: "ignore", windowsHide: false });
27
+ child.unref();
28
+ return { pid: child.pid, args, command: editorPath };
29
+ }
30
+
31
+ // Kept local so this module owns direct process creation entirely.
32
+ import { spawn as awaitableSpawn } from "node:child_process";
33
+
34
+ export function createUnityBatchmodeCommand(editorPath: string, projectRoot: string, extraArgs: string[] = [], options: { useGraphics?: boolean } = {}): { command: string; args: string[] } {
35
+ return { command: editorPath, args: buildUnityBatchmodeArgs(projectRoot, extraArgs, options) };
36
+ }
@@ -1,90 +1,10 @@
1
- import * as fs from "node:fs/promises";
2
- import * as path from "node:path";
3
1
  import { spawn } from "node:child_process";
4
- import {
5
- buildUnityBatchmodeArgs,
6
- buildUnityEditorCandidates,
7
- buildUnityOpenEditorArgs,
8
- normalizeUnityEditorOverride,
9
- type SupportedPlatform,
10
- type UnityOpenEditorArgsOptions,
11
- } from "./unity-core";
12
2
  import { createUnityCliOpenCommand, type UnityCliLaunchOptions } from "./unity-cli";
13
3
 
14
- export async function resolveUnityEditorPath(
15
- unityVersion: string,
16
- options: {
17
- overridePath?: string;
18
- env?: NodeJS.ProcessEnv;
19
- platform?: SupportedPlatform;
20
- homeDir?: string;
21
- } = {},
22
- ): Promise<string> {
23
- const platform = options.platform ?? process.platform;
24
- const env = options.env ?? process.env;
25
- const homeDir = options.homeDir;
26
-
27
- const overrideCandidates = [options.overridePath, env.UNITY_EDITOR_PATH, env.UNITY_PATH]
28
- .map((value) => value?.trim())
29
- .filter((value): value is string => Boolean(value))
30
- .map((value) => normalizeUnityEditorOverride(value, platform));
31
-
32
- const autoCandidates = buildUnityEditorCandidates(unityVersion, platform, homeDir);
33
-
34
- for (const candidate of [...overrideCandidates, ...autoCandidates]) {
35
- try {
36
- await fs.access(candidate, fs.constants.X_OK);
37
- return path.normalize(candidate);
38
- } catch {
39
- // Try next candidate.
40
- }
41
- }
42
-
43
- throw new Error(
44
- [
45
- `Could not find a Unity Editor executable for Unity ${unityVersion}.`,
46
- "Set UNITY_EDITOR_PATH to an explicit executable path or pass unityEditorPath.",
47
- ].join(" "),
48
- );
49
- }
50
-
51
- export function launchUnityEditorDetached(
52
- editorPath: string,
53
- projectRoot: string,
54
- options: UnityOpenEditorArgsOptions = {},
55
- ): { pid: number | undefined; args: string[]; command: string } {
56
- const args = buildUnityOpenEditorArgs(projectRoot, options);
57
- const child = spawn(editorPath, args, {
58
- detached: true,
59
- stdio: "ignore",
60
- windowsHide: false,
61
- });
62
- child.unref();
63
- return { pid: child.pid, args, command: editorPath };
64
- }
65
-
66
- export function launchUnityCliOpenDetached(
67
- projectRoot: string,
68
- options: UnityCliLaunchOptions = {},
69
- ): { pid: number | undefined; args: string[]; command: string } {
4
+ /** Unity CLI GUI launch path. Direct executable fallback lives in unity-editor-fallback.ts. */
5
+ export function launchUnityCliOpenDetached(projectRoot: string, options: UnityCliLaunchOptions = {}): { pid: number | undefined; args: string[]; command: string } {
70
6
  const cli = createUnityCliOpenCommand(projectRoot, options);
71
- const child = spawn(cli.command, cli.args, {
72
- detached: true,
73
- stdio: "ignore",
74
- windowsHide: false,
75
- });
7
+ const child = spawn(cli.command, cli.args, { detached: true, stdio: "ignore", windowsHide: false });
76
8
  child.unref();
77
9
  return { pid: child.pid, args: cli.args, command: cli.command };
78
10
  }
79
-
80
- export function createUnityBatchmodeCommand(
81
- editorPath: string,
82
- projectRoot: string,
83
- extraArgs: string[] = [],
84
- options: { useGraphics?: boolean } = {},
85
- ): { command: string; args: string[] } {
86
- return {
87
- command: editorPath,
88
- args: buildUnityBatchmodeArgs(projectRoot, extraArgs, options),
89
- };
90
- }
@@ -11,7 +11,7 @@ export const UNITY_PIPELINE_MAX_DIAGNOSTICS = 8;
11
11
  export const UNITY_PIPELINE_MAX_STACK_CHARS = 600;
12
12
 
13
13
  export type UnityPipelineCompileRequest = { projectRoot: string; unityVersion: string; timeoutSeconds?: number; allowAutonomousExitPlayMode?: boolean };
14
- export type UnityPipelineTestRequest = { projectRoot: string; unityVersion: string; testPlatform: "EditMode" | "PlayMode"; testFilter?: string; timeoutSeconds?: number; allowAutonomousExitPlayMode?: boolean };
14
+ export type UnityPipelineTestRequest = { projectRoot: string; unityVersion: string; testPlatform: "EditMode" | "PlayMode"; testFilter?: string; testCategory?: string; timeoutSeconds?: number; allowAutonomousExitPlayMode?: boolean };
15
15
  export type UnityPipelineProgress = (message: string) => void;
16
16
  /** Unity's EditorSettings.ScriptChangesWhilePlaying values when a future editor_status payload supplies one. */
17
17
  export type UnityScriptChangesWhilePlayingPolicy = "recompile_and_continue" | "stop_and_recompile" | "defer" | "unknown";
@@ -32,7 +32,9 @@ export type UnityPipelineOperationDetails = {
32
32
  testFilter?: string;
33
33
  counts?: { total: number; passed?: number; failed: number; inconclusive?: number };
34
34
  };
35
- export type UnityPipelineOperationResult = { text: string; details: UnityPipelineOperationDetails };
35
+ export type UnityPipelineTestRecord = { name: string; status: string; durationSeconds?: number; message?: string; stackTrace?: string };
36
+ /** testRecords are terminal evidence for the caller's durable artifact only; do not expose them in tool details. */
37
+ export type UnityPipelineOperationResult = { text: string; details: UnityPipelineOperationDetails; testRecords?: UnityPipelineTestRecord[] };
36
38
 
37
39
  type RecordValue = Record<string, unknown>;
38
40
  type ParsedEnvelope = { result: RecordValue; outerSuccess: boolean; malformed?: string };
@@ -41,6 +43,7 @@ type NormalizedTest = {
41
43
  state: "inactive" | "starting" | "running" | "completed" | "failed" | "cancelled" | "uncertain";
42
44
  total?: number; passed?: number; failed?: number; inconclusive?: number; failures: string[];
43
45
  correlation: Record<string, string>;
46
+ testRecords?: UnityPipelineTestRecord[];
44
47
  };
45
48
 
46
49
  type PipelineDependencies = {
@@ -172,6 +175,24 @@ function summary(result: RecordValue): RecordValue | undefined {
172
175
  walk(result, item => { if (!found && record(field(item, "summary"))) found = record(field(item, "summary")); });
173
176
  return found;
174
177
  }
178
+ function testRecords(result: RecordValue): UnityPipelineTestRecord[] {
179
+ const values: UnityPipelineTestRecord[] = [];
180
+ walk(result, item => {
181
+ for (const key of ["tests", "results", "testresults"]) {
182
+ const entries = field(item, key);
183
+ if (!Array.isArray(entries)) continue;
184
+ for (const entry of entries.slice(0, 2_000)) {
185
+ const test = record(entry); if (!test) continue;
186
+ const name = string(field(test, "name", "fullname", "testname"));
187
+ const status = string(field(test, "result", "status", "outcome"));
188
+ if (!name || !status) continue;
189
+ const durationSeconds = number(field(test, "duration", "durationseconds", "time"));
190
+ values.push({ name: bounded(name, 1_000), status: bounded(status, 100), ...(durationSeconds === undefined ? {} : { durationSeconds }), ...(string(field(test, "message", "error", "failuremessage")) ? { message: bounded(string(field(test, "message", "error", "failuremessage"))!, 4_000) } : {}), ...(string(field(test, "stacktrace", "stack", "trace")) ? { stackTrace: bounded(string(field(test, "stacktrace", "stack", "trace"))!, 8_000) } : {}) });
191
+ }
192
+ }
193
+ });
194
+ return values.slice(0, 2_000);
195
+ }
175
196
  function testFailures(result: RecordValue): string[] {
176
197
  const values: string[] = [];
177
198
  walk(result, item => {
@@ -223,7 +244,7 @@ export function normalizeUnityPipelineTest(output: string): NormalizedTest {
223
244
  : raw === "no_tests" || raw === "idle" || raw === "not_started" || raw === "not_running" ? "inactive"
224
245
  : raw === "running" ? "running" : raw === "starting" || raw === "queued" ? "starting"
225
246
  : raw === "completed" || raw === "complete" || raw === "success" ? "completed" : "uncertain";
226
- return { state, total, passed, failed: failedCount, inconclusive, failures: testFailures(parsed.result), correlation: correlation(parsed.result) };
247
+ return { state, total, passed, failed: failedCount, inconclusive, failures: testFailures(parsed.result), correlation: correlation(parsed.result), testRecords: testRecords(parsed.result) };
227
248
  }
228
249
 
229
250
  function editorStopSucceeded(output: string): boolean {
@@ -471,7 +492,10 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
471
492
  const observed = statusOf(parseUnityPipelineEnvelope(before.stdout).result) ?? "unknown";
472
493
  throw new Error(`Unity Pipeline returned unsupported preflight test status '${bounded(observed, 80)}'; test run not started.`);
473
494
  }
474
- const args = ["--mode", request.testPlatform === "EditMode" ? "editor" : "playmode", ...(request.testFilter ? ["--filter", request.testFilter, "--filter_type", "testName"] : []), "--async_tests", "true"];
495
+ if (request.testFilter && request.testCategory) throw new Error("Connected Pipeline cannot combine a test-name filter and category in one run; operation not started.");
496
+ const selectorArgs = request.testFilter ? ["--filter", request.testFilter, "--filter_type", "testName"]
497
+ : request.testCategory ? ["--filter", request.testCategory, "--filter_type", "category"] : [];
498
+ const args = ["--mode", request.testPlatform === "EditMode" ? "editor" : "playmode", ...selectorArgs, "--async_tests", "true"];
475
499
  ensureBeforeDeadline(deadline, now, "tests before dispatch"); throwIfAborted(signal);
476
500
  const dispatched = await dispatchMainThreadCommand(deps, projectRoot, "run_tests", args, "tests", signal, deadline, now, sleep);
477
501
  if (dispatched.error) throw new Error("Unity Pipeline test dispatch failed; test run may not have started.");
@@ -485,7 +509,7 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
485
509
  if (state.state === "completed") {
486
510
  const counts = passingCounts(state);
487
511
  if (!counts) throw new Error("Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).");
488
- return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter, counts } };
512
+ return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter ?? request.testCategory, counts }, testRecords: state.testRecords };
489
513
  }
490
514
  for (let poll = 0; now() < deadline; poll += 1) {
491
515
  options.onUpdate?.(`Unity ${request.testPlatform} tests ${state.state}; ${elapsed(start, now).toFixed(1)}s elapsed.`);
@@ -507,7 +531,7 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
507
531
  if (state.state !== "completed") continue;
508
532
  const counts = passingCounts(state);
509
533
  if (!counts) throw new Error("Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).");
510
- return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter, counts } };
534
+ return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter ?? request.testCategory, counts }, testRecords: state.testRecords };
511
535
  }
512
536
  throw timeoutMessage("tests");
513
537
  }
@@ -5,7 +5,8 @@ import { parseUnityVersionText, resolveAbsolutePath } from "./unity-core";
5
5
  export type UnityProjectCandidate = {
6
6
  projectRoot: string;
7
7
  projectName: string;
8
- unityVersion: string;
8
+ /** Manual ProjectVersion.txt evidence, loaded only by direct/Pipeline routes. */
9
+ unityVersion?: string;
9
10
  };
10
11
 
11
12
  export type UnityProjectDiscoveryResult = {
@@ -81,7 +82,6 @@ export async function findAncestorUnityProject(startDir: string): Promise<UnityP
81
82
  return {
82
83
  projectRoot: current,
83
84
  projectName: path.basename(current),
84
- unityVersion: await readUnityVersion(current),
85
85
  };
86
86
  }
87
87
 
@@ -123,7 +123,6 @@ export async function discoverUnityProjects(
123
123
  candidates.push({
124
124
  projectRoot: next.dir,
125
125
  projectName: path.basename(next.dir),
126
- unityVersion: await readUnityVersion(next.dir),
127
126
  });
128
127
  if (candidates.length >= maxCandidates) {
129
128
  truncated = true;
@@ -18,6 +18,7 @@ export type UnityTestBatchPlan = {
18
18
  testFilters: string[];
19
19
  testCategories: string[];
20
20
  testResultsPath: string;
21
+ junitResultsPath: string;
21
22
  logFilePath: string;
22
23
  args: string[];
23
24
  };
@@ -59,6 +60,7 @@ export function createUnityTestBatchPlan(input: UnityTestBatchPlanInput): UnityT
59
60
  const platformSlug = input.testPlatform.toLowerCase();
60
61
  const basename = `unity-tests-${platformSlug}-${safeTimestamp(input.now ?? new Date())}-${token}`;
61
62
  const testResultsPath = pathApi.join(logsRoot, `${basename}.xml`);
63
+ const junitResultsPath = pathApi.join(logsRoot, `${basename}.junit.xml`);
62
64
  const logFilePath = pathApi.join(logsRoot, `${basename}.log`);
63
65
  const args = ["-runTests", "-testPlatform", input.testPlatform];
64
66
  if (testFilters.length > 0) args.push("-testFilter", testFilters.join(";"));
@@ -78,5 +80,5 @@ export function createUnityTestBatchPlan(input: UnityTestBatchPlanInput): UnityT
78
80
  throw new Error("Unity test batch arguments must not contain -quit.");
79
81
  }
80
82
 
81
- return { testPlatform: input.testPlatform, testFilters, testCategories, testResultsPath, logFilePath, args };
83
+ return { testPlatform: input.testPlatform, testFilters, testCategories, testResultsPath, junitResultsPath, logFilePath, args };
82
84
  }