@aefree/pi-unity 0.11.0 → 0.12.1
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/CHANGELOG.md +29 -0
- package/README.md +8 -4
- package/index.ts +175 -90
- package/package.json +9 -9
- package/skills/unity-batchmode-tests/SKILL.md +9 -9
- package/skills/unity-pipeline-workflows/SKILL.md +19 -1
- package/src/unity-artifact-inspection.ts +59 -0
- package/src/unity-batchmode.ts +44 -9
- package/src/unity-cli.ts +6 -10
- package/src/unity-core.ts +4 -12
- package/src/unity-editor-fallback.ts +36 -0
- package/src/unity-launch.ts +3 -83
- package/src/unity-projects.ts +2 -3
- package/src/unity-tests.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aefree/pi-unity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./index.ts"
|
|
@@ -18,18 +18,18 @@
|
|
|
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-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",
|
|
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": {
|
|
25
25
|
"typebox": "1.3.8"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
|
+
"@aefree/pi-file-discovery": "^0.1.0",
|
|
29
|
+
"@aefree/pi-project-artifacts": "^0.1.0",
|
|
28
30
|
"@earendil-works/pi-ai": "*",
|
|
29
31
|
"@earendil-works/pi-coding-agent": "*",
|
|
30
|
-
"@earendil-works/pi-tui": "*"
|
|
31
|
-
"@aefree/pi-project-artifacts": "^0.1.0",
|
|
32
|
-
"@aefree/pi-file-discovery": "^0.1.0"
|
|
32
|
+
"@earendil-works/pi-tui": "*"
|
|
33
33
|
},
|
|
34
34
|
"engines": {
|
|
35
35
|
"node": ">=22.19.0"
|
|
@@ -48,11 +48,11 @@
|
|
|
48
48
|
},
|
|
49
49
|
"homepage": "https://github.com/aefreedman/pi-unity#readme",
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@earendil-works/pi-ai": "0.83.0",
|
|
52
|
-
"@earendil-works/pi-coding-agent": "0.83.0",
|
|
53
|
-
"@earendil-works/pi-tui": "0.83.0",
|
|
54
|
-
"@aefree/pi-project-artifacts": "^0.1.0",
|
|
55
51
|
"@aefree/pi-file-discovery": "^0.1.0",
|
|
52
|
+
"@aefree/pi-project-artifacts": "^0.1.0",
|
|
53
|
+
"@earendil-works/pi-ai": "0.85.1",
|
|
54
|
+
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
55
|
+
"@earendil-works/pi-tui": "0.85.1",
|
|
56
56
|
"tsx": "^4.23.5"
|
|
57
57
|
},
|
|
58
58
|
"peerDependenciesMeta": {
|
|
@@ -50,9 +50,9 @@ Use the `pi-unity` tools first instead of forming raw Unity CLI commands on the
|
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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.
|
|
77
|
+
### 3. Resolve the exact-version Unity Editor
|
|
78
78
|
|
|
79
|
-
|
|
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,10 +89,9 @@ 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,
|
|
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
|
|
|
@@ -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
|
-
|
|
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`)
|
|
@@ -41,12 +41,30 @@ Call `unity_run_tests` with:
|
|
|
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.
|
|
43
43
|
|
|
44
|
+
### Two independent fixtures in an open Editor
|
|
45
|
+
|
|
46
|
+
Keep the Editor open. For an authorized request for two known non-overlapping fixtures, use the same explicit exact-copy `path` and platform, one selector per call. For example, replace `./SyntheticGame` with the selected project path and issue this `unity_run_tests` call:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{"path":"./SyntheticGame","testPlatform":"EditMode","execution":"connected","testFilters":["Synthetic.InventoryFixture"]}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Await the completed tool result and inspect its outcome and exact normalized artifact path. Only after a passing result, issue the second call (not in parallel or pre-queued in the same turn):
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{"path":"./SyntheticGame","testPlatform":"EditMode","execution":"connected","testFilters":["Synthetic.DialogueFixture"]}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Stop the remaining sequence on failure, timeout, cancellation, malformed evidence, displaced identity, or any uncertainty; report the first result and the remaining fixture as not run. Do not retry, close the Editor, switch to isolated execution, or broaden to a parent suite/category or empty selectors. Empty/omitted selectors select all tests. Keep each result's artifact separately; do not treat one fixture's evidence as covering the other.
|
|
59
|
+
|
|
60
|
+
For a single category use `testCategories: ["SyntheticCategory"]` and omit `testFilters`. Never combine the two families or join selectors with semicolons. Multiple-selector rejection means no test was dispatched: use the serial recipe only for independent, non-overlapping selections without other isolated-only requirements. If overlap or intended filter/category intersection semantics are unclear, stop for clarification rather than split, duplicate, or broaden tests. Required XML, retries, coverage, or other isolated-only options still need a deliberate isolation decision, not argument stripping.
|
|
61
|
+
|
|
44
62
|
## Bounded raw CLI troubleshooting only
|
|
45
63
|
|
|
46
64
|
Normally call the typed tools, not raw CLI commands. If a typed tool is unavailable in an older installed package and a user specifically authorizes troubleshooting, first use `unity_project_status` and require advertised `recompile_status` or `run_tests` and `test_status`. Use the documented asynchronous form with `--async_tests true`, one fixed deadline, and bounded backoff; parse object and stringified nested JSON. `Total: 0` with `result: running` is a valid nonterminal initiating response. Passing evidence reports successful completion, a known positive executed-test count, and zero failures; nested `success:false`, changed exact-copy identity, or polling timeout is non-passing uncertainty. Do not silently fall back to batchmode after uncertain connected dispatch. Connected work does not guarantee NUnit XML.
|
|
47
65
|
|
|
48
66
|
## When not to use connected tools
|
|
49
67
|
|
|
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.
|
|
68
|
+
Use `unity_run_tests` with `execution: "isolated"` for a closed project, intentional isolation/CI, multiple selectors in one run, retries, sharding, coverage, or required NUnit/JUnit evidence. Multiple independent fixtures alone do not require closing a reachable Editor: use the serial recipe above. 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
69
|
|
|
52
70
|
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.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
2
|
+
import { determineUnityTestOutcome, type NormalizedUnityTestResult } from "./unity-tests";
|
|
3
|
+
|
|
4
|
+
const outcomes = ["passed", "passed_with_flakes", "tests_failed", "empty_selection", "run_error", "timed_out", "cancelled", "uncertain"];
|
|
5
|
+
const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
6
|
+
const nonnegative = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
7
|
+
const count = (value: unknown): value is number => nonnegative(value) && Number.isSafeInteger(value);
|
|
8
|
+
const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every(item => typeof item === "string" && !!item.trim() && !/[\0\r\n;]/.test(item));
|
|
9
|
+
const relativeId = (value: unknown): value is string => typeof value === "string" && !!value.trim()
|
|
10
|
+
&& !isAbsolute(value) && !/^(?:[A-Za-z]:|[\\/])/.test(value) && !value.split(/[\\/]/).includes("..") && !/\0/.test(value);
|
|
11
|
+
|
|
12
|
+
/** Read the durable schema, not a transport response. Missing optional counts remain unknown.
|
|
13
|
+
* Test records may be bounded or absent: never require tests.length === summary.total.
|
|
14
|
+
*/
|
|
15
|
+
export function validateNormalizedUnityTestArtifact(value: unknown): NormalizedUnityTestResult {
|
|
16
|
+
const invalid = (reason: string): never => { throw new Error(`Invalid normalized Unity test artifact: ${reason}.`); };
|
|
17
|
+
if (!record(value) || value.schemaVersion !== 1) invalid("expected schemaVersion 1 object");
|
|
18
|
+
const result = value as Record<string, unknown>;
|
|
19
|
+
if (!["pipeline", "unity-cli", "editor-executable"].includes(result.source as string)) invalid("unsupported source");
|
|
20
|
+
if (!["EditMode", "PlayMode"].includes(result.platform as string)) invalid("unsupported platform");
|
|
21
|
+
if (!outcomes.includes(result.outcome as string)) invalid("unsupported outcome");
|
|
22
|
+
if (!record(result.selection) || !strings(result.selection.testFilters) || !strings(result.selection.testCategories)) invalid("selection must contain testFilters and testCategories arrays");
|
|
23
|
+
if (!record(result.summary)) invalid("summary must be an object");
|
|
24
|
+
const summary = result.summary as Record<string, unknown>;
|
|
25
|
+
for (const key of ["total", "passed", "failed", "skipped", "inconclusive"]) {
|
|
26
|
+
if (summary[key] !== undefined && !count(summary[key])) invalid(`summary.${key} must be a non-negative integer`);
|
|
27
|
+
}
|
|
28
|
+
if (count(summary.total)) {
|
|
29
|
+
const accounted = [summary.passed, summary.failed, summary.skipped, summary.inconclusive].reduce<number>((sum, item) => sum + (count(item) ? item : 0), 0);
|
|
30
|
+
if (accounted > summary.total) invalid("summary counts exceed total");
|
|
31
|
+
}
|
|
32
|
+
if (!Array.isArray(result.tests)) invalid("tests must be an array");
|
|
33
|
+
const tests = result.tests as unknown[];
|
|
34
|
+
for (const test of tests) {
|
|
35
|
+
if (!record(test) || typeof test.name !== "string" || !test.name.trim() || typeof test.status !== "string" || !test.status.trim()) invalid("test records require name and status");
|
|
36
|
+
const item = test as Record<string, unknown>;
|
|
37
|
+
for (const key of ["message", "stackTrace"]) if (item[key] !== undefined && typeof item[key] !== "string") invalid(`test ${key} must be a string`);
|
|
38
|
+
if (item.durationSeconds !== undefined && !nonnegative(item.durationSeconds)) invalid("test durationSeconds must be non-negative");
|
|
39
|
+
if (item.attempts !== undefined && (!count(item.attempts) || item.attempts < 1)) invalid("test attempts must be positive");
|
|
40
|
+
}
|
|
41
|
+
if (count(summary.total) && tests.length > summary.total) invalid("test records exceed total");
|
|
42
|
+
const typed = result as unknown as NormalizedUnityTestResult;
|
|
43
|
+
for (const [status, key] of [["passed", "passed"], ["failed", "failed"], ["skipped", "skipped"], ["inconclusive", "inconclusive"]] as const) {
|
|
44
|
+
const observed = typed.tests.filter(test => test.status.toLowerCase() === status).length;
|
|
45
|
+
if (count(summary[key]) && observed > summary[key]) invalid(`test records conflict with summary.${key}`);
|
|
46
|
+
}
|
|
47
|
+
if (result.projectRelativeId !== undefined && !relativeId(result.projectRelativeId)) invalid("projectRelativeId must be project-relative");
|
|
48
|
+
if (result.backendArtifacts !== undefined && (!record(result.backendArtifacts) || !Object.values(result.backendArtifacts).every(relativeId))) invalid("backendArtifacts must contain project-relative paths");
|
|
49
|
+
for (const key of ["startedAt", "completedAt"]) if (result[key] !== undefined && (typeof result[key] !== "string" || !Number.isFinite(Date.parse(result[key] as string)))) invalid(`${key} must be a timestamp`);
|
|
50
|
+
if (typed.startedAt && typed.completedAt && Date.parse(typed.completedAt) < Date.parse(typed.startedAt)) invalid("completion precedes start");
|
|
51
|
+
if (result.durationSeconds !== undefined && !nonnegative(result.durationSeconds)) invalid("durationSeconds must be non-negative");
|
|
52
|
+
if (result.flakyTests !== undefined && (!Array.isArray(result.flakyTests) || !result.flakyTests.every(item => record(item) && typeof item.name === "string" && !!item.name.trim() && count(item.attempts) && item.attempts > 0))) invalid("invalid flakyTests");
|
|
53
|
+
if (result.outcome === "passed" || result.outcome === "passed_with_flakes") {
|
|
54
|
+
if (determineUnityTestOutcome(typed.summary) !== "passed" || typed.tests.some(test => !["passed", "success"].includes(test.status.toLowerCase()))) invalid("passing outcome lacks consistent positive passing counts/records");
|
|
55
|
+
if (result.outcome === "passed_with_flakes" && !typed.flakyTests?.length) invalid("passed_with_flakes requires flakyTests evidence");
|
|
56
|
+
}
|
|
57
|
+
if (result.outcome === "empty_selection" && ((typed.summary.total ?? 0) > 0 || tests.length > 0)) invalid("empty selection conflicts with executed tests");
|
|
58
|
+
return typed;
|
|
59
|
+
}
|
package/src/unity-batchmode.ts
CHANGED
|
@@ -29,6 +29,8 @@ export type UnityParsedTestResults = {
|
|
|
29
29
|
failedTests: UnityFailedTest[];
|
|
30
30
|
/** Complete bounded per-test evidence for normalized artifacts, never routine tool output. */
|
|
31
31
|
tests: UnityParsedTestCase[];
|
|
32
|
+
/** Observed XML record lower bounds, counted before record output is truncated. */
|
|
33
|
+
testRecordCounts?: { total: number; passed: number; failed: number; skipped: number; inconclusive: number; other: number };
|
|
32
34
|
};
|
|
33
35
|
|
|
34
36
|
export type UnityBatchmodeArtifacts = {
|
|
@@ -82,10 +84,10 @@ function decodeXmlText(value: string | undefined): string | undefined {
|
|
|
82
84
|
|
|
83
85
|
function parseAttributes(tagSource: string): Record<string, string> {
|
|
84
86
|
const attributes: Record<string, string> = {};
|
|
85
|
-
const attributeRegex = /(\w[\w:-]*)\s*=\s*"([^"]*)"/g;
|
|
87
|
+
const attributeRegex = /(\w[\w:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
86
88
|
for (const match of tagSource.matchAll(attributeRegex)) {
|
|
87
89
|
const key = match[1];
|
|
88
|
-
const value = match[2] ?? "";
|
|
90
|
+
const value = match[2] ?? match[3] ?? "";
|
|
89
91
|
attributes[key] = value;
|
|
90
92
|
}
|
|
91
93
|
return attributes;
|
|
@@ -102,6 +104,12 @@ function parseOptionalNumber(value: string | undefined): number | undefined {
|
|
|
102
104
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
function parseTestCount(value: string | undefined): number | undefined {
|
|
108
|
+
// Only omission is unknown. Retain invalid supplied counters as NaN so the
|
|
109
|
+
// inspection validator rejects them instead of silently treating them as absent.
|
|
110
|
+
return value === undefined ? undefined : value.trim() ? Number(value) : Number.NaN;
|
|
111
|
+
}
|
|
112
|
+
|
|
105
113
|
export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults | null {
|
|
106
114
|
const testRunMatch = xml.match(/<test-run\b([^>]*)>/i);
|
|
107
115
|
const testRunCloseIndex = xml.search(/<\/test-run\s*>/i);
|
|
@@ -112,33 +120,43 @@ export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults |
|
|
|
112
120
|
const rootAttributes = parseAttributes(testRunMatch[1] ?? "");
|
|
113
121
|
const failedTests: UnityFailedTest[] = [];
|
|
114
122
|
const tests: UnityParsedTestCase[] = [];
|
|
123
|
+
const testRecordCounts = { total: 0, passed: 0, failed: 0, skipped: 0, inconclusive: 0, other: 0 };
|
|
115
124
|
|
|
116
|
-
|
|
125
|
+
// Match the self-closing alternative first so it cannot consume the body of
|
|
126
|
+
// the next paired record. Both forms carry authoritative failure evidence.
|
|
127
|
+
const testCaseRegex = /<test-case\b([^>]*?)(?:\/\s*>|>([\s\S]*?)<\/test-case\s*>)/gi;
|
|
117
128
|
for (const match of xml.matchAll(testCaseRegex)) {
|
|
118
129
|
const attributes = parseAttributes(match[1] ?? "");
|
|
119
130
|
const body = match[2] ?? "";
|
|
120
131
|
const result = String(attributes.result ?? attributes.label ?? "").toLowerCase();
|
|
121
132
|
const success = String(attributes.success ?? "").toLowerCase();
|
|
122
133
|
const isFailure = result === "failed" || success === "false";
|
|
134
|
+
const status = isFailure ? "Failed" : attributes.result ?? attributes.label ?? "Unknown";
|
|
135
|
+
const statusKey = isFailure ? "failed" : result === "passed" || result === "success" ? "passed" : result === "skipped" ? "skipped" : result === "inconclusive" ? "inconclusive" : "other";
|
|
136
|
+
testRecordCounts.total++;
|
|
137
|
+
testRecordCounts[statusKey]++;
|
|
123
138
|
const failureMessage = body.match(/<message[^>]*>([\s\S]*?)<\/message>/i);
|
|
124
139
|
const stackTrace = body.match(/<stack-trace[^>]*>([\s\S]*?)<\/stack-trace>/i);
|
|
125
140
|
const name = truncateEvidence(attributes.fullname ?? attributes.name ?? "(unknown test)", 1_000) ?? "(unknown test)";
|
|
126
|
-
if (tests.length < 2_000) tests.push({ name, status
|
|
141
|
+
if (tests.length < 2_000) tests.push({ name, status, ...(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
142
|
if (!isFailure) continue;
|
|
128
143
|
if (failedTests.length < 50) failedTests.push({ name, message: truncateEvidence(decodeXmlText(failureMessage?.[1]), 1_000), stackTrace: truncateEvidence(decodeXmlText(stackTrace?.[1]), 4_000) });
|
|
129
144
|
}
|
|
130
145
|
|
|
131
|
-
|
|
146
|
+
// These are separate counters. Synthesizing skipped from inconclusive makes
|
|
147
|
+
// combined-count validation double-count one observed category.
|
|
148
|
+
const skipped = parseTestCount(rootAttributes.skipped);
|
|
132
149
|
|
|
133
150
|
const parsed: UnityParsedTestResults = {
|
|
134
|
-
total:
|
|
135
|
-
passed:
|
|
136
|
-
failed:
|
|
151
|
+
total: parseTestCount(rootAttributes.total) ?? parseTestCount(rootAttributes.testcasecount),
|
|
152
|
+
passed: parseTestCount(rootAttributes.passed),
|
|
153
|
+
failed: parseTestCount(rootAttributes.failed),
|
|
137
154
|
skipped,
|
|
138
|
-
inconclusive:
|
|
155
|
+
inconclusive: parseTestCount(rootAttributes.inconclusive),
|
|
139
156
|
durationSeconds: parseOptionalNumber(rootAttributes.duration),
|
|
140
157
|
failedTests,
|
|
141
158
|
tests,
|
|
159
|
+
testRecordCounts,
|
|
142
160
|
};
|
|
143
161
|
if (parsed.total === undefined && parsed.passed === undefined && parsed.failed === undefined && parsed.failedTests.length === 0) {
|
|
144
162
|
return null;
|
|
@@ -146,6 +164,23 @@ export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults |
|
|
|
146
164
|
return parsed;
|
|
147
165
|
}
|
|
148
166
|
|
|
167
|
+
/** Missing counters and omitted/bounded records are unknown, not zero executions.
|
|
168
|
+
* Supplied counters and observed record lower bounds must nevertheless agree.
|
|
169
|
+
* An optional linked summary can fill missing XML counters, not replace them.
|
|
170
|
+
*/
|
|
171
|
+
export function hasConflictingUnityXmlTestEvidence(
|
|
172
|
+
results: UnityParsedTestResults,
|
|
173
|
+
linkedSummary?: Pick<UnityParsedTestResults, "total" | "passed" | "failed" | "skipped" | "inconclusive">,
|
|
174
|
+
): boolean {
|
|
175
|
+
const keys = ["total", "passed", "failed", "skipped", "inconclusive"] as const;
|
|
176
|
+
const counts = Object.fromEntries(keys.map(key => [key, results[key] ?? linkedSummary?.[key]])) as Pick<UnityParsedTestResults, typeof keys[number]>;
|
|
177
|
+
if (keys.some(key => counts[key] !== undefined && (!Number.isSafeInteger(counts[key]) || counts[key]! < 0))) return true;
|
|
178
|
+
const observed = results.testRecordCounts;
|
|
179
|
+
if (keys.some(key => counts[key] !== undefined && (observed?.[key] ?? 0) > counts[key]!)) return true;
|
|
180
|
+
const accounted = keys.slice(1).reduce((sum, key) => sum + Math.max(counts[key] ?? 0, observed?.[key] ?? 0), 0);
|
|
181
|
+
return counts.total !== undefined && accounted > counts.total;
|
|
182
|
+
}
|
|
183
|
+
|
|
149
184
|
function buildArtifactCandidates(cwd: string, projectRoot: string, rawPath: string): string[] {
|
|
150
185
|
if (path.isAbsolute(rawPath)) {
|
|
151
186
|
return [path.normalize(rawPath)];
|
package/src/unity-cli.ts
CHANGED
|
@@ -13,8 +13,7 @@ export type UnityCliCommand = {
|
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
export type UnityCliLaunchOptions = {
|
|
16
|
-
|
|
17
|
-
editorPath?: string;
|
|
16
|
+
editorVersionOverride?: string;
|
|
18
17
|
timeoutSeconds?: number;
|
|
19
18
|
cliCommand?: string;
|
|
20
19
|
useGraphics?: boolean;
|
|
@@ -84,11 +83,8 @@ function unityCliBaseArgs(): string[] {
|
|
|
84
83
|
}
|
|
85
84
|
|
|
86
85
|
function appendUnityCliEditorOptions(args: string[], options: UnityCliLaunchOptions): void {
|
|
87
|
-
if (options.
|
|
88
|
-
args.push("--editor-version", options.
|
|
89
|
-
}
|
|
90
|
-
if (options.editorPath?.trim()) {
|
|
91
|
-
args.push("--editor-path", options.editorPath.trim());
|
|
86
|
+
if (options.editorVersionOverride?.trim()) {
|
|
87
|
+
args.push("--editor-version", options.editorVersionOverride.trim());
|
|
92
88
|
}
|
|
93
89
|
}
|
|
94
90
|
|
|
@@ -651,11 +647,11 @@ export async function dispatchUnityPlanningInspection(
|
|
|
651
647
|
...(isEval ? [request.evalSnippet!.trim()] : request.args ?? []),
|
|
652
648
|
];
|
|
653
649
|
const execution = await options.execute(command, args, { timeout: options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS, signal: options.signal });
|
|
650
|
+
const raw = [execution.stdout, execution.stderr].filter(Boolean).join("\n");
|
|
651
|
+
const output = summarizeUnityCliText(redactUnityPlanningOutput(raw), 4_000, 40);
|
|
654
652
|
if (execution.error) {
|
|
655
|
-
return { outcome: "rejected", code: isUnityCliTimeout(execution) ? "planning_command_timeout" : "planning_command_failed", message:
|
|
653
|
+
return { outcome: "rejected", code: isUnityCliTimeout(execution) ? "planning_command_timeout" : "planning_command_failed", message: `Connected command did not complete successfully; its effect may be uncertain.${output ? ` ${output}` : ""}` };
|
|
656
654
|
}
|
|
657
|
-
const raw = [execution.stdout, execution.stderr].filter(Boolean).join("\n");
|
|
658
|
-
const output = redactUnityPlanningOutput(summarizeUnityCliText(raw, 4_000, 40));
|
|
659
655
|
const reportedFailure = connectedCommandFailure(execution.stdout, isEval);
|
|
660
656
|
if (reportedFailure) {
|
|
661
657
|
return {
|
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,
|
|
@@ -112,8 +104,8 @@ export function normalizeForCommandSearch(value: string, platform: SupportedPlat
|
|
|
112
104
|
return platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
113
105
|
}
|
|
114
106
|
|
|
115
|
-
function
|
|
116
|
-
if (platform !== "darwin" || process.platform !==
|
|
107
|
+
function realpathMatchesOnNativePlatform(candidatePath: string, projectRoot: string, platform: SupportedPlatform): boolean | null {
|
|
108
|
+
if ((platform !== "darwin" && platform !== "win32") || process.platform !== platform) {
|
|
117
109
|
return null;
|
|
118
110
|
}
|
|
119
111
|
|
|
@@ -121,7 +113,7 @@ function realpathMatchesOnDarwin(candidatePath: string, projectRoot: string, pla
|
|
|
121
113
|
return realpathSync.native(candidatePath) === realpathSync.native(projectRoot);
|
|
122
114
|
} catch {
|
|
123
115
|
// Only use filesystem identity when both paths can be resolved. Falling back
|
|
124
|
-
// to the
|
|
116
|
+
// to the platform-specific textual comparison preserves existing behavior.
|
|
125
117
|
return null;
|
|
126
118
|
}
|
|
127
119
|
}
|
|
@@ -138,7 +130,7 @@ export function projectPathsMatch(candidatePath: string, projectRoot: string, pl
|
|
|
138
130
|
return false;
|
|
139
131
|
}
|
|
140
132
|
|
|
141
|
-
const realpathMatch =
|
|
133
|
+
const realpathMatch = realpathMatchesOnNativePlatform(trimmedCandidatePath, trimmedProjectRoot, comparisonPlatform);
|
|
142
134
|
if (realpathMatch !== null) {
|
|
143
135
|
return realpathMatch;
|
|
144
136
|
}
|
|
@@ -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
|
+
}
|
package/src/unity-launch.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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
|
-
}
|
package/src/unity-projects.ts
CHANGED
|
@@ -5,7 +5,8 @@ import { parseUnityVersionText, resolveAbsolutePath } from "./unity-core";
|
|
|
5
5
|
export type UnityProjectCandidate = {
|
|
6
6
|
projectRoot: string;
|
|
7
7
|
projectName: string;
|
|
8
|
-
|
|
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;
|
package/src/unity-tests.ts
CHANGED
|
@@ -63,6 +63,19 @@ export type NormalizedUnityTestResult = {
|
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
export type UnityTestRouteRequirements = { requiresIsolation: boolean; reasons: string[] };
|
|
66
|
+
|
|
67
|
+
export function resolveUnityCliBackendReportPaths(
|
|
68
|
+
paths: { nunit: string; junit: string; log: string },
|
|
69
|
+
formats: UnityTestReportFormat[],
|
|
70
|
+
): { nunit: string; junit?: string; log: string } {
|
|
71
|
+
// Normalized JSON is derived from Unity CLI's native NUnit XML, so the
|
|
72
|
+
// backend report is required even when callers do not retain NUnit output.
|
|
73
|
+
return {
|
|
74
|
+
nunit: paths.nunit,
|
|
75
|
+
junit: formats.includes("junit") ? paths.junit : undefined,
|
|
76
|
+
log: paths.log,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
66
79
|
export type UnityTestOutcomeEvidence = {
|
|
67
80
|
cancelled?: boolean;
|
|
68
81
|
timedOut?: boolean;
|