@aefree/pi-unity 0.11.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/CHANGELOG.md +14 -0
- package/README.md +2 -2
- package/index.ts +94 -73
- package/package.json +2 -2
- package/skills/unity-batchmode-tests/SKILL.md +9 -9
- package/src/unity-cli.ts +3 -7
- package/src/unity-core.ts +0 -8
- 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/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ and this project follows semantic versioning for public package releases.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.12.0 - 2026-08-27
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- Made Unity CLI authoritative for ordinary project launches: `open`, `run`, and `test` now rely on the selected project without passing an Editor-version override; direct executable launch remains an exact-version fallback.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Kept isolated Unity CLI test results discoverable for JSON-only reports by always writing the native NUnit evidence to the project's `Logs` directory.
|
|
18
|
+
- Made direct Unity Editor fallback selection project-version-driven by probing only exact-version standard installations without Editor-path overrides.
|
|
19
|
+
|
|
20
|
+
### Removed
|
|
21
|
+
|
|
22
|
+
- Removed the `UNITY_EDITOR_PATH`, `UNITY_PATH`, and `unityEditorPath` Editor-selection overrides.
|
|
23
|
+
|
|
10
24
|
## [0.11.0] - 2026-08-26
|
|
11
25
|
|
|
12
26
|
### Added
|
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ pi install <path-to-pi-unity>
|
|
|
23
23
|
pi install -l <path-to-pi-unity> # project-local
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
Pi discovers the extension from `index.ts` and packaged skills from `skills/`.
|
|
26
|
+
Pi discovers the extension from `index.ts` and packaged skills from `skills/`. In UI sessions, pi-unity warns once per Pi runtime when Unity CLI is unavailable or `UNITY_CLI_PATH` is invalid. The warning never displays configured paths; install Unity CLI, then restart or reload Pi.
|
|
27
27
|
|
|
28
28
|
## Included tools
|
|
29
29
|
|
|
@@ -113,7 +113,7 @@ Use `unity_pipeline_inspect` when a purpose-built structured command fits. Use e
|
|
|
113
113
|
|
|
114
114
|
## Launch and process safeguards
|
|
115
115
|
|
|
116
|
-
`unity_open_editor` and batchmode tools
|
|
116
|
+
`unity_open_editor` and batchmode tools treat Unity CLI as the authoritative launcher: `unity open`, `unity run`, and `unity test` receive only the selected project and let Unity CLI read `ProjectVersion.txt`. The direct Editor executable is an exceptional compatibility fallback used only when Unity CLI is unavailable. Set `launcher` to `auto`, `unity-cli`, or `editor-executable` when explicit routing is needed.
|
|
117
117
|
|
|
118
118
|
Before launching, pi-unity checks:
|
|
119
119
|
|
package/index.ts
CHANGED
|
@@ -21,13 +21,14 @@ import {
|
|
|
21
21
|
} from "./src/unity-batchmode";
|
|
22
22
|
import { formatPathForUser, hasUnityCommandLineFlag } from "./src/unity-core";
|
|
23
23
|
import { createUnityCliBatchmodeReportArgs, createUnityCliEditorExitCommand, createUnityCliRunCommand, createUnityCliTestCommand, dispatchUnityPlanningInspection, haveSameKnownProcessIds, inspectUnityCliProjectCapabilities, listRunningUnityCliEditorsForProject, resolveUnityCliCommand, UNITY_PLANNING_READ_COMMANDS, type UnityCliProjectCapabilities } from "./src/unity-cli";
|
|
24
|
-
import {
|
|
24
|
+
import { launchUnityCliOpenDetached } from "./src/unity-launch";
|
|
25
|
+
import { createUnityBatchmodeCommand, launchUnityEditorDetached, resolveUnityEditorPath } from "./src/unity-editor-fallback";
|
|
25
26
|
import { loadPiUnitySettings, type PiUnitySettings } from "./src/pi-unity-settings";
|
|
26
27
|
import { dedupeRunningUnityProcesses, listRunningUnityProcessesForProject, redactUnityProcessCommandLine, terminateRunningUnityProcesses, verifyUnityProcessIdentity, type RunningUnityProcess } from "./src/unity-processes";
|
|
27
28
|
import { assertUnityProjectNotBusy, evaluateUnityLaunchSafety, getUnityNativeLockfilePath, inspectUnityProjectBusyState, withUnityProjectLaunchMutex } from "./src/unity-project-lock";
|
|
28
|
-
import { resolveUnityProjectCandidates, type UnityProjectCandidate } from "./src/unity-projects";
|
|
29
|
+
import { readUnityVersion, resolveUnityProjectCandidates, type UnityProjectCandidate } from "./src/unity-projects";
|
|
29
30
|
import { createUnityTestBatchPlan, type UnityTestBatchPlan, type UnityTestPlatform } from "./src/unity-test-batch";
|
|
30
|
-
import { applyUnityCliRetrySummary, compactUnityTestSummary, defaultUnityTestReportFormats, deriveUnityCliEffectiveReportPath, determineUnityTestOutcome, getUnityTestRouteRequirements, normalizeUnityRunTestsRequest, parseUnityCliRetrySummary, writeNormalizedUnityTestArtifact, type NormalizedUnityTestResult, type UnityRunTestsRequest } from "./src/unity-tests";
|
|
31
|
+
import { applyUnityCliRetrySummary, compactUnityTestSummary, defaultUnityTestReportFormats, deriveUnityCliEffectiveReportPath, determineUnityTestOutcome, getUnityTestRouteRequirements, normalizeUnityRunTestsRequest, parseUnityCliRetrySummary, resolveUnityCliBackendReportPaths, writeNormalizedUnityTestArtifact, type NormalizedUnityTestResult, type UnityRunTestsRequest } from "./src/unity-tests";
|
|
31
32
|
import { auditUnityGuidance, type UnityGuidanceAuditResult } from "./src/unity-guidance-audit";
|
|
32
33
|
import { runUnityPipelineRecompile, runUnityPipelineTests, type UnityPipelineOperationDetails } from "./src/unity-pipeline";
|
|
33
34
|
import {
|
|
@@ -103,20 +104,19 @@ type UnityLauncherPreference = "auto" | "unity-cli" | "editor-executable";
|
|
|
103
104
|
|
|
104
105
|
const OPEN_EDITOR_PARAMS = Type.Object({
|
|
105
106
|
path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
106
|
-
unityEditorPath: Type.Optional(Type.String({ description: "Optional explicit Unity executable path override." })),
|
|
107
107
|
automated: Type.Optional(Type.Boolean({ default: false, description: "Pass Unity Editor's -automated flag when opening the project. Defaults to false." })),
|
|
108
108
|
launcher: LAUNCHER_SCHEMA,
|
|
109
|
-
});
|
|
109
|
+
}, { additionalProperties: false });
|
|
110
110
|
|
|
111
111
|
const LAUNCH_BATCHMODE_PARAMS = Type.Object({
|
|
112
112
|
path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
113
|
-
|
|
113
|
+
|
|
114
114
|
args: Type.Optional(Type.Array(Type.String(), { description: "Additional Unity command-line arguments appended after -batchmode -projectPath <project> for direct editor launch, or forwarded after `unity run <project> --` for Unity CLI launch. pi-unity adds -nographics by default unless useGraphics=true." })),
|
|
115
115
|
useGraphics: Type.Optional(Type.Boolean({ default: false, description: "Set true only when the requested Unity batchmode work requires an active graphics device, such as screenshots, rendering, or visual PlayMode tests. Defaults to false, which adds -nographics." })),
|
|
116
116
|
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 14400, default: 3600, description: "Timeout in seconds for the batchmode process." })),
|
|
117
117
|
launcher: LAUNCHER_SCHEMA,
|
|
118
118
|
closeBlockingUnityProcess: Type.Optional(Type.Boolean({ default: false, description: "When true, pi-unity may close a running Unity process for the resolved project before launch, but only if piUnity.allowCloseRunningUnityProcess is enabled in Pi settings. The process is selected by project matching, not by model-supplied PID." })),
|
|
119
|
-
});
|
|
119
|
+
}, { additionalProperties: false });
|
|
120
120
|
|
|
121
121
|
const RUN_TESTS_PARAMS = Type.Object({
|
|
122
122
|
path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
@@ -137,18 +137,6 @@ const RUN_TESTS_PARAMS = Type.Object({
|
|
|
137
137
|
closeBlockingUnityProcess: Type.Optional(Type.Boolean({ default: false })),
|
|
138
138
|
}, { additionalProperties: false });
|
|
139
139
|
|
|
140
|
-
const RUN_TEST_BATCH_PARAMS = Type.Object({
|
|
141
|
-
path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
142
|
-
unityEditorPath: Type.Optional(Type.String({ description: "Optional explicit Unity executable path override." })),
|
|
143
|
-
testPlatform: StringEnum(["EditMode", "PlayMode"] as const, { description: "Unity Test Framework platform. One batch runs exactly one test platform." }),
|
|
144
|
-
testFilters: Type.Optional(Type.Array(Type.String(), { maxItems: 50, description: "Full test names or regex filters. Values are normalized into one semicolon-separated -testFilter argument." })),
|
|
145
|
-
testCategories: Type.Optional(Type.Array(Type.String(), { maxItems: 50, description: "Categories or category regex/negations. Values are normalized into one semicolon-separated -testCategory argument." })),
|
|
146
|
-
useGraphics: Type.Optional(Type.Boolean({ default: false, description: "Set true only for graphics-dependent PlayMode tests or visual capture. Defaults to headless -nographics." })),
|
|
147
|
-
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 14400, default: 3600 })),
|
|
148
|
-
launcher: LAUNCHER_SCHEMA,
|
|
149
|
-
closeBlockingUnityProcess: Type.Optional(Type.Boolean({ default: false, description: "Use guarded same-project Unity process closure only when piUnity.allowCloseRunningUnityProcess is enabled." })),
|
|
150
|
-
});
|
|
151
|
-
|
|
152
140
|
const PROJECT_STATUS_PARAMS = Type.Object({
|
|
153
141
|
path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
154
142
|
});
|
|
@@ -196,7 +184,7 @@ const INSPECT_ARTIFACTS_PARAMS = Type.Object({
|
|
|
196
184
|
});
|
|
197
185
|
|
|
198
186
|
function buildProjectChoiceLabel(cwd: string, candidate: UnityProjectCandidate): string {
|
|
199
|
-
return `${candidate.projectName}
|
|
187
|
+
return `${candidate.projectName} — ${formatPathForUser(cwd, candidate.projectRoot)}`;
|
|
200
188
|
}
|
|
201
189
|
|
|
202
190
|
async function chooseProjectCandidateWithWrappingNavigation(
|
|
@@ -267,7 +255,7 @@ async function chooseProjectCandidateWithWrappingNavigation(
|
|
|
267
255
|
|
|
268
256
|
function formatCandidateList(cwd: string, candidates: UnityProjectCandidate[]): string {
|
|
269
257
|
return candidates
|
|
270
|
-
.map((candidate) => `- ${candidate.projectName}
|
|
258
|
+
.map((candidate) => `- ${candidate.projectName} — ${formatPathForUser(cwd, candidate.projectRoot)}`)
|
|
271
259
|
.join("\n");
|
|
272
260
|
}
|
|
273
261
|
|
|
@@ -331,6 +319,13 @@ async function resolveProjectCandidate(
|
|
|
331
319
|
return { candidate, discoveryWarning };
|
|
332
320
|
}
|
|
333
321
|
|
|
322
|
+
async function requireManualUnityVersion(candidate: UnityProjectCandidate): Promise<string> {
|
|
323
|
+
if (candidate.unityVersion) return candidate.unityVersion;
|
|
324
|
+
const unityVersion = await readUnityVersion(candidate.projectRoot);
|
|
325
|
+
candidate.unityVersion = unityVersion;
|
|
326
|
+
return unityVersion;
|
|
327
|
+
}
|
|
328
|
+
|
|
334
329
|
function joinWarnings(...warnings: Array<string | undefined>): string | undefined {
|
|
335
330
|
const present = warnings.filter((warning): warning is string => Boolean(warning && warning.trim().length > 0));
|
|
336
331
|
return present.length > 0 ? present.join("\n") : undefined;
|
|
@@ -446,11 +441,12 @@ async function closeBlockingUnityProcessesForBatchmode(
|
|
|
446
441
|
);
|
|
447
442
|
}
|
|
448
443
|
|
|
449
|
-
const
|
|
444
|
+
const unityVersion = await requireManualUnityVersion(candidate);
|
|
445
|
+
const cliCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, unityVersion, { signal });
|
|
450
446
|
const canRequestGracefulExit = cliCapabilities.commandDiscoverySucceeded && cliCapabilities.advertisedCommands.includes("eval");
|
|
451
447
|
if (canRequestGracefulExit) {
|
|
452
448
|
const refreshedRunning = await listBlockingUnityProcesses(candidate.projectRoot);
|
|
453
|
-
const refreshedCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot,
|
|
449
|
+
const refreshedCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, unityVersion, { signal });
|
|
454
450
|
const samePids = haveSameKnownProcessIds(running.processes, refreshedRunning.processes);
|
|
455
451
|
const samePipelinePids = haveSameKnownProcessIds(cliCapabilities.matchingInstances, refreshedCapabilities.matchingInstances);
|
|
456
452
|
if (refreshedRunning.warning || !samePids || !samePipelinePids || !refreshedCapabilities.advertisedCommands.includes("eval")) {
|
|
@@ -599,7 +595,8 @@ async function buildProjectStatusReport(
|
|
|
599
595
|
const lockState = await inspectUnityProjectBusyState(candidate.projectRoot);
|
|
600
596
|
const cliStatus = await listRunningUnityCliEditorsForProject(candidate.projectRoot);
|
|
601
597
|
const processStatus = await listRunningUnityProcessesForProject(candidate.projectRoot);
|
|
602
|
-
const
|
|
598
|
+
const unityVersion = await requireManualUnityVersion(candidate);
|
|
599
|
+
const cliCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, unityVersion, { signal });
|
|
603
600
|
const runningProcesses = dedupeRunningUnityProcesses([...cliStatus.processes, ...processStatus.processes]);
|
|
604
601
|
const isBusy = runningProcesses.length > 0 || cliCapabilities.matchingInstances.length > 0;
|
|
605
602
|
const staleLockSuspected = lockState.nativeLockfileExists && !isBusy && !processStatus.warning;
|
|
@@ -607,7 +604,7 @@ async function buildProjectStatusReport(
|
|
|
607
604
|
const piUnitySettings = await loadPiUnitySettings(ctx);
|
|
608
605
|
|
|
609
606
|
const lines = [
|
|
610
|
-
`Unity project status for ${formatPathForUser(ctx.cwd, candidate.projectRoot)} using Unity ${
|
|
607
|
+
`Unity project status for ${formatPathForUser(ctx.cwd, candidate.projectRoot)} using declared Unity ${unityVersion}.`,
|
|
611
608
|
`- Native lockfile: ${lockState.nativeLockfileExists ? "present" : "absent"}`,
|
|
612
609
|
`- Lockfile path: ${lockState.nativeLockfilePath}`,
|
|
613
610
|
`- Running Unity processes targeting project: ${runningProcesses.length}`,
|
|
@@ -656,7 +653,7 @@ async function buildProjectStatusReport(
|
|
|
656
653
|
details: {
|
|
657
654
|
mode: "status",
|
|
658
655
|
projectRoot: candidate.projectRoot,
|
|
659
|
-
unityVersion: candidate
|
|
656
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
660
657
|
editorPath: "",
|
|
661
658
|
warning: combinedWarning,
|
|
662
659
|
status: "passed",
|
|
@@ -737,7 +734,7 @@ async function buildArtifactInspectionReport(
|
|
|
737
734
|
const hasLoadedArtifacts = Boolean(artifacts.testResultsPath || artifacts.logFilePath);
|
|
738
735
|
const status = deriveUnityArtifactInspectionStatus(hasLoadedArtifacts, invocation, parsedTestResults);
|
|
739
736
|
const lines = [
|
|
740
|
-
`Unity artifacts inspected for ${formatPathForUser(ctx.cwd, candidate.projectRoot)}
|
|
737
|
+
`Unity artifacts inspected for ${formatPathForUser(ctx.cwd, candidate.projectRoot)}; Unity CLI selects the project's declared Editor version when launching.`,
|
|
741
738
|
testResultsPath ? `Requested test results: ${testResultsPath}` : "Requested test results: (none found)",
|
|
742
739
|
logFilePath ? `Requested log file: ${logFilePath}` : "Requested log file: (none found)",
|
|
743
740
|
normalizedResultPath ? `Requested normalized result: ${normalizedResultPath}` : "Requested normalized result: (none found)",
|
|
@@ -764,7 +761,7 @@ async function buildArtifactInspectionReport(
|
|
|
764
761
|
details: {
|
|
765
762
|
mode: "artifacts",
|
|
766
763
|
projectRoot: candidate.projectRoot,
|
|
767
|
-
unityVersion: candidate
|
|
764
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
768
765
|
editorPath: "",
|
|
769
766
|
invocation,
|
|
770
767
|
artifacts: compactUnityArtifacts(artifacts),
|
|
@@ -782,7 +779,9 @@ function buildEditorLaunchSummary(
|
|
|
782
779
|
launcher: "unity-cli" | "editor-executable" = "editor-executable",
|
|
783
780
|
): string {
|
|
784
781
|
return [
|
|
785
|
-
|
|
782
|
+
launcher === "unity-cli"
|
|
783
|
+
? `Launched Unity Editor GUI for ${formatPathForUser(cwd, candidate.projectRoot)} with the project-declared Editor version selected by Unity CLI.`
|
|
784
|
+
: `Launched Unity Editor GUI for ${formatPathForUser(cwd, candidate.projectRoot)} using the project's declared Unity version through the direct Editor fallback.`,
|
|
786
785
|
launcher === "unity-cli" ? `Launcher: unity open (${editorPath})` : `Editor: ${editorPath}`,
|
|
787
786
|
GUI_WARNING,
|
|
788
787
|
SINGLE_PROCESS_WARNING,
|
|
@@ -809,7 +808,7 @@ async function buildBatchmodeReport(
|
|
|
809
808
|
const status = deriveUnityBatchmodeStatus(result.code, Boolean(result.killed), invocation, parsedTestResults);
|
|
810
809
|
const text = buildUnityBatchmodeAgentText({
|
|
811
810
|
displayProjectPath: formatPathForUser(ctx.cwd, candidate.projectRoot),
|
|
812
|
-
unityVersion: candidate
|
|
811
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
813
812
|
editorPath,
|
|
814
813
|
exitCode: result.code,
|
|
815
814
|
killed: Boolean(result.killed),
|
|
@@ -827,7 +826,7 @@ async function buildBatchmodeReport(
|
|
|
827
826
|
details: {
|
|
828
827
|
mode: "batchmode",
|
|
829
828
|
projectRoot: candidate.projectRoot,
|
|
830
|
-
unityVersion: candidate
|
|
829
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
831
830
|
editorPath,
|
|
832
831
|
command: editorPath,
|
|
833
832
|
args,
|
|
@@ -936,16 +935,42 @@ function throwIfAborted(signal?: AbortSignal): void {
|
|
|
936
935
|
}
|
|
937
936
|
}
|
|
938
937
|
|
|
939
|
-
|
|
938
|
+
const UNITY_CLI_WARNING_SYMBOL = Symbol.for("@aefree/pi-unity/unity-cli-warning/v1");
|
|
939
|
+
const unityCliRuntimeState = globalThis as Record<PropertyKey, unknown>;
|
|
940
|
+
const UNITY_CLI_WARNING = "pi-unity capability warning: Unity CLI is unavailable or UNITY_CLI_PATH is invalid. Install Unity CLI, then restart or reload Pi to restore pi-unity's primary project-launch workflow.";
|
|
941
|
+
|
|
942
|
+
async function warnWhenUnityCliUnavailable(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
943
|
+
if (!ctx.hasUI || unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] === true) return;
|
|
944
|
+
try {
|
|
945
|
+
const result = await pi.exec(resolveUnityCliCommand(), ["--version"], { timeout: 5000 });
|
|
946
|
+
// Timeouts are uncertain and must neither warn nor select another backend.
|
|
947
|
+
if (result.killed || result.code === 0 || unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] === true) return;
|
|
948
|
+
unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] = true;
|
|
949
|
+
ctx.ui.notify(UNITY_CLI_WARNING, "warning");
|
|
950
|
+
} catch (error) {
|
|
951
|
+
if ((error instanceof Error && error.name === "AbortError") || isUnityCliProbeTimeout(error)) return;
|
|
952
|
+
if (unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] === true) return;
|
|
953
|
+
unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] = true;
|
|
954
|
+
ctx.ui.notify(UNITY_CLI_WARNING, "warning");
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
type UnityCliAvailability = "available" | "unavailable" | "uncertain";
|
|
958
|
+
|
|
959
|
+
function isUnityCliProbeTimeout(error: unknown): boolean {
|
|
960
|
+
return Boolean(error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ETIMEDOUT");
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
async function getUnityCliAvailability(pi: ExtensionAPI, signal?: AbortSignal): Promise<UnityCliAvailability> {
|
|
940
964
|
try {
|
|
941
965
|
throwIfAborted(signal);
|
|
942
966
|
const command = resolveUnityCliCommand();
|
|
943
967
|
const result = await pi.exec(command, ["--version"], { signal, timeout: 5000 });
|
|
944
968
|
throwIfAborted(signal);
|
|
945
|
-
|
|
969
|
+
if (result.killed) return "uncertain";
|
|
970
|
+
return result.code === 0 ? "available" : "unavailable";
|
|
946
971
|
} catch (error) {
|
|
947
972
|
if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) throw error;
|
|
948
|
-
return
|
|
973
|
+
return isUnityCliProbeTimeout(error) ? "uncertain" : "unavailable";
|
|
949
974
|
}
|
|
950
975
|
}
|
|
951
976
|
|
|
@@ -995,16 +1020,18 @@ async function shouldUseUnityCli(
|
|
|
995
1020
|
return false;
|
|
996
1021
|
}
|
|
997
1022
|
|
|
998
|
-
const
|
|
999
|
-
if (
|
|
1023
|
+
const availability = await getUnityCliAvailability(pi, signal);
|
|
1024
|
+
if (availability === "uncertain") {
|
|
1025
|
+
throw new Error("Unity CLI availability could not be confirmed because its bounded probe timed out. Retry the request; pi-unity will not select the direct Editor fallback after an uncertain Unity CLI probe.");
|
|
1026
|
+
}
|
|
1027
|
+
if (preference === "unity-cli" && availability === "unavailable") {
|
|
1000
1028
|
throw new Error("Unity CLI launcher was requested, but the `unity` command is not available. Set UNITY_CLI_PATH or use launcher='editor-executable'.");
|
|
1001
1029
|
}
|
|
1002
1030
|
|
|
1003
|
-
return available;
|
|
1031
|
+
return availability === "available";
|
|
1004
1032
|
}
|
|
1005
1033
|
|
|
1006
1034
|
type GuardedBatchmodeParams = {
|
|
1007
|
-
unityEditorPath?: string;
|
|
1008
1035
|
args?: string[];
|
|
1009
1036
|
useGraphics?: boolean;
|
|
1010
1037
|
timeoutSeconds?: number;
|
|
@@ -1036,17 +1063,14 @@ async function runGuardedUnityBatchmode(
|
|
|
1036
1063
|
const invocation = parseUnityBatchmodeInvocation(createUnityCliBatchmodeReportArgs(candidate.projectRoot, extraArgs, { useGraphics }));
|
|
1037
1064
|
const useUnityCli = await shouldUseUnityCli(pi, params.launcher, signal);
|
|
1038
1065
|
throwIfAborted(signal);
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
useGraphics,
|
|
1048
|
-
})
|
|
1049
|
-
: createUnityBatchmodeCommand(editorPath, candidate.projectRoot, extraArgs, { useGraphics });
|
|
1066
|
+
let editorPath = "Unity CLI";
|
|
1067
|
+
let command: { command: string; args: string[] };
|
|
1068
|
+
if (useUnityCli) {
|
|
1069
|
+
command = createUnityCliRunCommand(candidate.projectRoot, extraArgs, { timeoutSeconds, useGraphics });
|
|
1070
|
+
} else {
|
|
1071
|
+
editorPath = await resolveUnityEditorPath(await requireManualUnityVersion(candidate));
|
|
1072
|
+
command = createUnityBatchmodeCommand(editorPath, candidate.projectRoot, extraArgs, { useGraphics });
|
|
1073
|
+
}
|
|
1050
1074
|
const closeReport = await closeBlockingUnityProcessesForBatchmode(
|
|
1051
1075
|
pi,
|
|
1052
1076
|
ctx,
|
|
@@ -1123,7 +1147,7 @@ async function runUnifiedUnityTests(
|
|
|
1123
1147
|
): Promise<{ content: Array<{ type: "text"; text: string }>; details: UnityToolDetails & { testResult: NormalizedUnityTestResult; artifactPath: string; route: "connected" | "isolated" } }> {
|
|
1124
1148
|
const request = normalizeUnityRunTestsRequest(raw);
|
|
1125
1149
|
const requirements = getUnityTestRouteRequirements(request);
|
|
1126
|
-
const capabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, candidate
|
|
1150
|
+
const capabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, await requireManualUnityVersion(candidate), { signal, execute: createPipelineUnityCliExecutor(pi) });
|
|
1127
1151
|
const reachable = capabilities.matchingInstances.some(instance => instance.reachable === true);
|
|
1128
1152
|
const busy = (await listBlockingUnityProcesses(candidate.projectRoot)).processes.length > 0 || capabilities.matchingInstances.length > 0;
|
|
1129
1153
|
let route: "connected" | "isolated";
|
|
@@ -1144,7 +1168,7 @@ async function runUnifiedUnityTests(
|
|
|
1144
1168
|
}
|
|
1145
1169
|
const formats = request.reportFormats ?? defaultUnityTestReportFormats(route);
|
|
1146
1170
|
if (route === "connected") {
|
|
1147
|
-
const result = await runUnityPipelineTests({ projectRoot: candidate.projectRoot, unityVersion: candidate
|
|
1171
|
+
const result = await runUnityPipelineTests({ projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), testPlatform: request.testPlatform, testFilter: request.testFilters[0], testCategory: request.testCategories[0], timeoutSeconds: request.timeoutSeconds, allowAutonomousExitPlayMode }, createPipelineDependencies(pi), { signal, onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }] }) });
|
|
1148
1172
|
const counts = result.details.counts!;
|
|
1149
1173
|
const normalized: NormalizedUnityTestResult = {
|
|
1150
1174
|
schemaVersion: 1, source: "pipeline", platform: request.testPlatform,
|
|
@@ -1153,11 +1177,11 @@ async function runUnifiedUnityTests(
|
|
|
1153
1177
|
};
|
|
1154
1178
|
const artifactPath = await writeNormalizedUnityTestArtifact(candidate.projectRoot, normalized);
|
|
1155
1179
|
const text = `${compactUnityTestSummary(normalized)}\nRoute: connected Pipeline. Normalized artifact: ${artifactPath}`;
|
|
1156
|
-
return { content: [{ type: "text", text }], details: { mode: "tests", projectRoot: candidate.projectRoot, unityVersion: candidate
|
|
1180
|
+
return { content: [{ type: "text", text }], details: { mode: "tests", projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), editorPath: "", status: normalized.outcome === "passed" ? "passed" : "failed", pipeline: result.details, testResult: { ...normalized, tests: [] }, artifactPath, route } };
|
|
1157
1181
|
}
|
|
1158
1182
|
if (request.isolatedLauncher === "editor-executable" && (request.retries || request.rerunFailed || request.shard || request.coverage || formats.includes("junit"))) throw new Error("The direct Editor fallback cannot honor CLI-only retry, rerun, shard, coverage, or JUnit options.");
|
|
1159
1183
|
const plan = createUnityTestBatchPlan({ projectRoot: candidate.projectRoot, testPlatform: request.testPlatform, testFilters: request.testFilters, testCategories: request.testCategories });
|
|
1160
|
-
const cliAvailable =
|
|
1184
|
+
const cliAvailable = await shouldUseUnityCli(pi, request.isolatedLauncher, signal);
|
|
1161
1185
|
if (!cliAvailable && request.isolatedLauncher === "unity-cli") throw new Error("Unity CLI was requested but is unavailable.");
|
|
1162
1186
|
if (!cliAvailable && (request.retries || request.rerunFailed || request.shard || request.coverage || formats.includes("junit"))) throw new Error("Unity CLI is unavailable and the requested options have no direct Editor fallback.");
|
|
1163
1187
|
if (!cliAvailable) {
|
|
@@ -1173,7 +1197,7 @@ async function runUnifiedUnityTests(
|
|
|
1173
1197
|
const closeReport = await closeBlockingUnityProcessesForBatchmode(pi, ctx, candidate, invocation, request.closeBlockingUnityProcess, signal);
|
|
1174
1198
|
await removeStaleLockfileAfterGuardedClose(candidate, closeReport);
|
|
1175
1199
|
await enforceLaunchRouteSafety(candidate.projectRoot, "unity-cli");
|
|
1176
|
-
const command = createUnityCliTestCommand(candidate.projectRoot, { testPlatform: request.testPlatform, testFilters: request.testFilters, testCategories: request.testCategories, retries: request.retries, rerunFailed: request.rerunFailed, shard: request.shard, shardInventoryPath: request.shardInventoryPath, reportPaths: { nunit:
|
|
1200
|
+
const command = createUnityCliTestCommand(candidate.projectRoot, { testPlatform: request.testPlatform, testFilters: request.testFilters, testCategories: request.testCategories, retries: request.retries, rerunFailed: request.rerunFailed, shard: request.shard, shardInventoryPath: request.shardInventoryPath, reportPaths: resolveUnityCliBackendReportPaths({ nunit: plan.testResultsPath, junit: plan.junitResultsPath, log: plan.logFilePath }, formats), coverage: request.coverage, coverageOptions: request.coverageOptions, useGraphics: request.useGraphics, timeoutSeconds: request.timeoutSeconds });
|
|
1177
1201
|
const execution = await pi.exec(command.command, command.args, { signal, timeout: (request.timeoutSeconds ?? 3600) * 1000 + 30_000 });
|
|
1178
1202
|
const effectiveResultsPath = deriveUnityCliEffectiveReportPath(plan.testResultsPath, { rerunFailed: request.rerunFailed, shard: request.shard });
|
|
1179
1203
|
let effectiveResultsXml: string | undefined;
|
|
@@ -1197,7 +1221,7 @@ async function runUnifiedUnityTests(
|
|
|
1197
1221
|
}
|
|
1198
1222
|
const artifactPath = await writeNormalizedUnityTestArtifact(candidate.projectRoot, normalized);
|
|
1199
1223
|
const text = `${compactUnityTestSummary(normalized)}\nRoute: isolated Unity CLI. Normalized artifact: ${artifactPath}`;
|
|
1200
|
-
return { content: [{ type: "text", text }], details: { mode: "tests", projectRoot: candidate.projectRoot, unityVersion: candidate
|
|
1224
|
+
return { content: [{ type: "text", text }], details: { mode: "tests", projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), editorPath: "Unity CLI", status: outcome === "passed" || outcome === "passed_with_flakes" || outcome === "empty_selection" ? "passed" : "failed", command: command.command, cliArgs: command.args, testBatch: plan, testResult: { ...normalized, tests: [] }, artifactPath, route } };
|
|
1201
1225
|
});
|
|
1202
1226
|
}
|
|
1203
1227
|
|
|
@@ -1338,6 +1362,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1338
1362
|
|
|
1339
1363
|
pi.on("session_start", async (_event, ctx) => {
|
|
1340
1364
|
restoreSessionSettings(ctx);
|
|
1365
|
+
await warnWhenUnityCliUnavailable(pi, ctx);
|
|
1341
1366
|
const scope = ctx.sessionManager;
|
|
1342
1367
|
unregisterScope(registrations.get(scope));
|
|
1343
1368
|
// Optional package integrations resolve independently. The Unity extension and
|
|
@@ -1415,14 +1440,14 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1415
1440
|
await withUnityProjectLaunchMutex(candidate.projectRoot, { mode: "gui", toolName: "unity-open" }, async () => {
|
|
1416
1441
|
await enforceSingleProcessRule(candidate.projectRoot);
|
|
1417
1442
|
let launcher: "unity-cli" | "editor-executable" = "editor-executable";
|
|
1418
|
-
let editorPath =
|
|
1443
|
+
let editorPath = "Unity CLI";
|
|
1419
1444
|
let launch: { pid: number | undefined; args: string[]; command: string };
|
|
1420
|
-
if (await
|
|
1445
|
+
if (await shouldUseUnityCli(pi, undefined)) {
|
|
1421
1446
|
launcher = "unity-cli";
|
|
1422
|
-
launch = launchUnityCliOpenDetached(candidate.projectRoot
|
|
1447
|
+
launch = launchUnityCliOpenDetached(candidate.projectRoot);
|
|
1423
1448
|
} else {
|
|
1424
1449
|
await assertUnityProjectNotBusy(candidate.projectRoot);
|
|
1425
|
-
editorPath = await resolveUnityEditorPath(candidate
|
|
1450
|
+
editorPath = await resolveUnityEditorPath(await requireManualUnityVersion(candidate));
|
|
1426
1451
|
launch = launchUnityEditorDetached(editorPath, candidate.projectRoot);
|
|
1427
1452
|
}
|
|
1428
1453
|
const summary = buildEditorLaunchSummary(ctx.cwd, candidate, editorPath, discoveryWarning, launcher);
|
|
@@ -1549,13 +1574,13 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1549
1574
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1550
1575
|
throwIfAborted(signal);
|
|
1551
1576
|
const { candidate } = await resolveProjectCandidate(ctx, params.path);
|
|
1552
|
-
const result = await runUnityPipelineRecompile({ projectRoot: candidate.projectRoot, unityVersion: candidate
|
|
1577
|
+
const result = await runUnityPipelineRecompile({ projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), timeoutSeconds: params.timeoutSeconds, allowAutonomousExitPlayMode: sessionAllowsAutonomousPlayModeExit(ctx) }, createPipelineDependencies(pi), {
|
|
1553
1578
|
signal,
|
|
1554
1579
|
onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }] }),
|
|
1555
1580
|
});
|
|
1556
1581
|
return {
|
|
1557
1582
|
content: [{ type: "text", text: result.text }],
|
|
1558
|
-
details: { mode: "pipeline", projectRoot: result.details.projectRoot, unityVersion: candidate
|
|
1583
|
+
details: { mode: "pipeline", projectRoot: result.details.projectRoot, unityVersion: await requireManualUnityVersion(candidate), editorPath: "", status: "passed", pipeline: result.details } satisfies UnityToolDetails,
|
|
1559
1584
|
};
|
|
1560
1585
|
},
|
|
1561
1586
|
renderCall(args, theme, context) { return renderUnityPipelineCall("unity_pipeline_recompile", args, theme, context); },
|
|
@@ -1580,7 +1605,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1580
1605
|
throwIfAborted(signal);
|
|
1581
1606
|
const result = await dispatchUnityPlanningInspection({
|
|
1582
1607
|
projectRoot: candidate.projectRoot,
|
|
1583
|
-
unityVersion: candidate
|
|
1608
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
1584
1609
|
command: "eval",
|
|
1585
1610
|
evalSnippet: params.code,
|
|
1586
1611
|
}, {
|
|
@@ -1597,7 +1622,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1597
1622
|
details: {
|
|
1598
1623
|
mode: "pipeline_eval",
|
|
1599
1624
|
projectRoot: candidate.projectRoot,
|
|
1600
|
-
unityVersion: candidate
|
|
1625
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
1601
1626
|
editorPath: "",
|
|
1602
1627
|
status: result.outcome === "dispatched" ? "passed" : "failed",
|
|
1603
1628
|
pipelineEval: result,
|
|
@@ -1629,7 +1654,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1629
1654
|
throwIfAborted(signal);
|
|
1630
1655
|
const result = await dispatchUnityPlanningInspection({
|
|
1631
1656
|
projectRoot: candidate.projectRoot,
|
|
1632
|
-
unityVersion: candidate
|
|
1657
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
1633
1658
|
command: params.command,
|
|
1634
1659
|
args: params.args,
|
|
1635
1660
|
}, {
|
|
@@ -1646,7 +1671,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1646
1671
|
details: {
|
|
1647
1672
|
mode: "pipeline_inspection",
|
|
1648
1673
|
projectRoot: candidate.projectRoot,
|
|
1649
|
-
unityVersion: candidate
|
|
1674
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
1650
1675
|
editorPath: "",
|
|
1651
1676
|
status: result.outcome === "dispatched" ? "passed" : "failed",
|
|
1652
1677
|
pipelineInspection: result,
|
|
@@ -1715,18 +1740,14 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1715
1740
|
const useUnityCli = await shouldUseUnityCli(pi, params.launcher as UnityLauncherPreference | undefined, signal);
|
|
1716
1741
|
throwIfAborted(signal);
|
|
1717
1742
|
let launcher: "unity-cli" | "editor-executable" = "editor-executable";
|
|
1718
|
-
let editorPath =
|
|
1743
|
+
let editorPath = "Unity CLI";
|
|
1719
1744
|
let launch: { pid: number | undefined; args: string[]; command: string };
|
|
1720
1745
|
if (useUnityCli) {
|
|
1721
1746
|
launcher = "unity-cli";
|
|
1722
|
-
launch = launchUnityCliOpenDetached(candidate.projectRoot, {
|
|
1723
|
-
editorVersion: candidate.unityVersion,
|
|
1724
|
-
editorPath: params.unityEditorPath,
|
|
1725
|
-
automated: params.automated,
|
|
1726
|
-
});
|
|
1747
|
+
launch = launchUnityCliOpenDetached(candidate.projectRoot, { automated: params.automated });
|
|
1727
1748
|
} else {
|
|
1728
1749
|
await assertUnityProjectNotBusy(candidate.projectRoot);
|
|
1729
|
-
editorPath = await resolveUnityEditorPath(candidate
|
|
1750
|
+
editorPath = await resolveUnityEditorPath(await requireManualUnityVersion(candidate));
|
|
1730
1751
|
launch = launchUnityEditorDetached(editorPath, candidate.projectRoot, { automated: params.automated });
|
|
1731
1752
|
}
|
|
1732
1753
|
const text = buildEditorLaunchSummary(ctx.cwd, candidate, editorPath, discoveryWarning, launcher);
|
|
@@ -1736,7 +1757,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1736
1757
|
details: {
|
|
1737
1758
|
mode: "gui",
|
|
1738
1759
|
projectRoot: candidate.projectRoot,
|
|
1739
|
-
unityVersion: candidate
|
|
1760
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
1740
1761
|
editorPath,
|
|
1741
1762
|
pid: launch.pid,
|
|
1742
1763
|
command: launch.command,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aefree/pi-unity",
|
|
3
|
-
"version": "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-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": {
|
|
@@ -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`)
|
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
|
|
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
|
+
}
|
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;
|