@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/CHANGELOG.md +33 -0
- package/README.md +7 -8
- package/index.ts +231 -141
- package/package.json +2 -2
- package/skills/auditing-unity-agent-guidance/assets/mixed-workflow-template.md +1 -1
- package/skills/auditing-unity-agent-guidance/references/migration-policy.md +1 -1
- package/skills/unity-batchmode-tests/SKILL.md +15 -15
- package/skills/unity-pipeline-workflows/SKILL.md +8 -8
- package/src/unity-batchmode.ts +9 -9
- package/src/unity-cli.ts +40 -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-pipeline.ts +30 -6
- package/src/unity-projects.ts +2 -3
- package/src/unity-test-batch.ts +3 -1
- package/src/unity-tests.ts +285 -0
package/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { keyHint, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
-
import { mkdir, readdir, stat, unlink } from "node:fs/promises";
|
|
4
|
+
import { mkdir, readFile, readdir, stat, unlink } from "node:fs/promises";
|
|
5
5
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
6
|
import { setTimeout as delay } from "node:timers/promises";
|
|
7
7
|
import { getKeybindings, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
@@ -20,13 +20,15 @@ import {
|
|
|
20
20
|
type UnityParsedTestResults,
|
|
21
21
|
} from "./src/unity-batchmode";
|
|
22
22
|
import { formatPathForUser, hasUnityCommandLineFlag } from "./src/unity-core";
|
|
23
|
-
import { createUnityCliBatchmodeReportArgs, createUnityCliEditorExitCommand, createUnityCliRunCommand, dispatchUnityPlanningInspection, haveSameKnownProcessIds, inspectUnityCliProjectCapabilities, listRunningUnityCliEditorsForProject, resolveUnityCliCommand, UNITY_PLANNING_READ_COMMANDS, type UnityCliProjectCapabilities } from "./src/unity-cli";
|
|
24
|
-
import {
|
|
23
|
+
import { createUnityCliBatchmodeReportArgs, createUnityCliEditorExitCommand, createUnityCliRunCommand, createUnityCliTestCommand, dispatchUnityPlanningInspection, haveSameKnownProcessIds, inspectUnityCliProjectCapabilities, listRunningUnityCliEditorsForProject, resolveUnityCliCommand, UNITY_PLANNING_READ_COMMANDS, type UnityCliProjectCapabilities } from "./src/unity-cli";
|
|
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";
|
|
31
|
+
import { applyUnityCliRetrySummary, compactUnityTestSummary, defaultUnityTestReportFormats, deriveUnityCliEffectiveReportPath, determineUnityTestOutcome, getUnityTestRouteRequirements, normalizeUnityRunTestsRequest, parseUnityCliRetrySummary, resolveUnityCliBackendReportPaths, writeNormalizedUnityTestArtifact, type NormalizedUnityTestResult, type UnityRunTestsRequest } from "./src/unity-tests";
|
|
30
32
|
import { auditUnityGuidance, type UnityGuidanceAuditResult } from "./src/unity-guidance-audit";
|
|
31
33
|
import { runUnityPipelineRecompile, runUnityPipelineTests, type UnityPipelineOperationDetails } from "./src/unity-pipeline";
|
|
32
34
|
import {
|
|
@@ -66,7 +68,7 @@ const GUI_WARNING = "This launches the full Unity Editor GUI and is not the same
|
|
|
66
68
|
const SINGLE_PROCESS_WARNING = "Unity allows only one process per project folder. GUI Editor and batchmode/headless both count as that one process.";
|
|
67
69
|
|
|
68
70
|
type UnityToolDetails = {
|
|
69
|
-
mode: "gui" | "batchmode" | "status" | "artifacts" | "pipeline_inspection" | "pipeline_eval" | "pipeline";
|
|
71
|
+
mode: "gui" | "batchmode" | "status" | "artifacts" | "pipeline_inspection" | "pipeline_eval" | "pipeline" | "tests";
|
|
70
72
|
projectRoot: string;
|
|
71
73
|
unityVersion: string;
|
|
72
74
|
editorPath: string;
|
|
@@ -102,32 +104,38 @@ type UnityLauncherPreference = "auto" | "unity-cli" | "editor-executable";
|
|
|
102
104
|
|
|
103
105
|
const OPEN_EDITOR_PARAMS = Type.Object({
|
|
104
106
|
path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
105
|
-
unityEditorPath: Type.Optional(Type.String({ description: "Optional explicit Unity executable path override." })),
|
|
106
107
|
automated: Type.Optional(Type.Boolean({ default: false, description: "Pass Unity Editor's -automated flag when opening the project. Defaults to false." })),
|
|
107
108
|
launcher: LAUNCHER_SCHEMA,
|
|
108
|
-
});
|
|
109
|
+
}, { additionalProperties: false });
|
|
109
110
|
|
|
110
111
|
const LAUNCH_BATCHMODE_PARAMS = Type.Object({
|
|
111
112
|
path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
112
|
-
|
|
113
|
+
|
|
113
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." })),
|
|
114
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." })),
|
|
115
116
|
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 14400, default: 3600, description: "Timeout in seconds for the batchmode process." })),
|
|
116
117
|
launcher: LAUNCHER_SCHEMA,
|
|
117
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." })),
|
|
118
|
-
});
|
|
119
|
+
}, { additionalProperties: false });
|
|
119
120
|
|
|
120
|
-
const
|
|
121
|
+
const RUN_TESTS_PARAMS = Type.Object({
|
|
121
122
|
path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
123
|
+
testPlatform: StringEnum(["EditMode", "PlayMode"] as const),
|
|
124
|
+
testFilters: Type.Optional(Type.Array(Type.String(), { maxItems: 50 })),
|
|
125
|
+
testCategories: Type.Optional(Type.Array(Type.String(), { maxItems: 50 })),
|
|
126
|
+
execution: Type.Optional(StringEnum(["auto", "connected", "isolated"] as const, { default: "auto" })),
|
|
127
|
+
isolatedLauncher: Type.Optional(StringEnum(["auto", "unity-cli", "editor-executable"] as const, { default: "auto" })),
|
|
128
|
+
retries: Type.Optional(Type.Integer({ minimum: 0, maximum: 20, default: 0 })),
|
|
129
|
+
rerunFailed: Type.Optional(Type.Boolean({ default: false })),
|
|
130
|
+
shard: Type.Optional(Type.String({ maxLength: 500 })),
|
|
131
|
+
shardInventoryPath: Type.Optional(Type.String({ maxLength: 1000 })),
|
|
132
|
+
reportFormats: Type.Optional(Type.Array(StringEnum(["json", "nunit", "junit"] as const), { maxItems: 3 })),
|
|
133
|
+
coverage: Type.Optional(Type.Boolean({ default: false })),
|
|
134
|
+
coverageOptions: Type.Optional(Type.String({ maxLength: 1000 })),
|
|
135
|
+
useGraphics: Type.Optional(Type.Boolean({ default: false })),
|
|
127
136
|
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 14400, default: 3600 })),
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
});
|
|
137
|
+
closeBlockingUnityProcess: Type.Optional(Type.Boolean({ default: false })),
|
|
138
|
+
}, { additionalProperties: false });
|
|
131
139
|
|
|
132
140
|
const PROJECT_STATUS_PARAMS = Type.Object({
|
|
133
141
|
path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
@@ -168,6 +176,7 @@ const GUIDANCE_AUDIT_PARAMS = Type.Object({
|
|
|
168
176
|
const INSPECT_ARTIFACTS_PARAMS = Type.Object({
|
|
169
177
|
path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
170
178
|
testResultsPath: Type.Optional(Type.String({ description: "Unity Test Framework XML results path. Relative paths are resolved against cwd and the Unity project root." })),
|
|
179
|
+
normalizedResultPath: Type.Optional(Type.String({ description: "pi-unity normalized JSON test artifact path." })),
|
|
171
180
|
logFilePath: Type.Optional(Type.String({ description: "Unity log file path. Relative paths are resolved against cwd and the Unity project root." })),
|
|
172
181
|
latestFromLogs: Type.Optional(Type.Boolean({ default: true, description: "When paths are omitted, inspect the newest .xml and .log files under the project's Logs folder." })),
|
|
173
182
|
maxLines: Type.Optional(Type.Integer({ minimum: 1, maximum: 500, default: 60, description: "Maximum log/output lines to include." })),
|
|
@@ -175,7 +184,7 @@ const INSPECT_ARTIFACTS_PARAMS = Type.Object({
|
|
|
175
184
|
});
|
|
176
185
|
|
|
177
186
|
function buildProjectChoiceLabel(cwd: string, candidate: UnityProjectCandidate): string {
|
|
178
|
-
return `${candidate.projectName}
|
|
187
|
+
return `${candidate.projectName} — ${formatPathForUser(cwd, candidate.projectRoot)}`;
|
|
179
188
|
}
|
|
180
189
|
|
|
181
190
|
async function chooseProjectCandidateWithWrappingNavigation(
|
|
@@ -246,7 +255,7 @@ async function chooseProjectCandidateWithWrappingNavigation(
|
|
|
246
255
|
|
|
247
256
|
function formatCandidateList(cwd: string, candidates: UnityProjectCandidate[]): string {
|
|
248
257
|
return candidates
|
|
249
|
-
.map((candidate) => `- ${candidate.projectName}
|
|
258
|
+
.map((candidate) => `- ${candidate.projectName} — ${formatPathForUser(cwd, candidate.projectRoot)}`)
|
|
250
259
|
.join("\n");
|
|
251
260
|
}
|
|
252
261
|
|
|
@@ -310,6 +319,13 @@ async function resolveProjectCandidate(
|
|
|
310
319
|
return { candidate, discoveryWarning };
|
|
311
320
|
}
|
|
312
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
|
+
|
|
313
329
|
function joinWarnings(...warnings: Array<string | undefined>): string | undefined {
|
|
314
330
|
const present = warnings.filter((warning): warning is string => Boolean(warning && warning.trim().length > 0));
|
|
315
331
|
return present.length > 0 ? present.join("\n") : undefined;
|
|
@@ -425,11 +441,12 @@ async function closeBlockingUnityProcessesForBatchmode(
|
|
|
425
441
|
);
|
|
426
442
|
}
|
|
427
443
|
|
|
428
|
-
const
|
|
444
|
+
const unityVersion = await requireManualUnityVersion(candidate);
|
|
445
|
+
const cliCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, unityVersion, { signal });
|
|
429
446
|
const canRequestGracefulExit = cliCapabilities.commandDiscoverySucceeded && cliCapabilities.advertisedCommands.includes("eval");
|
|
430
447
|
if (canRequestGracefulExit) {
|
|
431
448
|
const refreshedRunning = await listBlockingUnityProcesses(candidate.projectRoot);
|
|
432
|
-
const refreshedCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot,
|
|
449
|
+
const refreshedCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, unityVersion, { signal });
|
|
433
450
|
const samePids = haveSameKnownProcessIds(running.processes, refreshedRunning.processes);
|
|
434
451
|
const samePipelinePids = haveSameKnownProcessIds(cliCapabilities.matchingInstances, refreshedCapabilities.matchingInstances);
|
|
435
452
|
if (refreshedRunning.warning || !samePids || !samePipelinePids || !refreshedCapabilities.advertisedCommands.includes("eval")) {
|
|
@@ -578,7 +595,8 @@ async function buildProjectStatusReport(
|
|
|
578
595
|
const lockState = await inspectUnityProjectBusyState(candidate.projectRoot);
|
|
579
596
|
const cliStatus = await listRunningUnityCliEditorsForProject(candidate.projectRoot);
|
|
580
597
|
const processStatus = await listRunningUnityProcessesForProject(candidate.projectRoot);
|
|
581
|
-
const
|
|
598
|
+
const unityVersion = await requireManualUnityVersion(candidate);
|
|
599
|
+
const cliCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, unityVersion, { signal });
|
|
582
600
|
const runningProcesses = dedupeRunningUnityProcesses([...cliStatus.processes, ...processStatus.processes]);
|
|
583
601
|
const isBusy = runningProcesses.length > 0 || cliCapabilities.matchingInstances.length > 0;
|
|
584
602
|
const staleLockSuspected = lockState.nativeLockfileExists && !isBusy && !processStatus.warning;
|
|
@@ -586,7 +604,7 @@ async function buildProjectStatusReport(
|
|
|
586
604
|
const piUnitySettings = await loadPiUnitySettings(ctx);
|
|
587
605
|
|
|
588
606
|
const lines = [
|
|
589
|
-
`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}.`,
|
|
590
608
|
`- Native lockfile: ${lockState.nativeLockfileExists ? "present" : "absent"}`,
|
|
591
609
|
`- Lockfile path: ${lockState.nativeLockfilePath}`,
|
|
592
610
|
`- Running Unity processes targeting project: ${runningProcesses.length}`,
|
|
@@ -635,7 +653,7 @@ async function buildProjectStatusReport(
|
|
|
635
653
|
details: {
|
|
636
654
|
mode: "status",
|
|
637
655
|
projectRoot: candidate.projectRoot,
|
|
638
|
-
unityVersion: candidate
|
|
656
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
639
657
|
editorPath: "",
|
|
640
658
|
warning: combinedWarning,
|
|
641
659
|
status: "passed",
|
|
@@ -685,7 +703,7 @@ function compactUnityArtifacts(artifacts: UnityBatchmodeArtifacts): UnityBatchmo
|
|
|
685
703
|
async function buildArtifactInspectionReport(
|
|
686
704
|
ctx: ExtensionContext,
|
|
687
705
|
candidate: UnityProjectCandidate,
|
|
688
|
-
params: { testResultsPath?: string; logFilePath?: string; latestFromLogs?: boolean; maxLines?: number; maxChars?: number },
|
|
706
|
+
params: { testResultsPath?: string; normalizedResultPath?: string; logFilePath?: string; latestFromLogs?: boolean; maxLines?: number; maxChars?: number },
|
|
689
707
|
): Promise<{ text: string; details: UnityToolDetails }> {
|
|
690
708
|
const useLatest = params.latestFromLogs !== false;
|
|
691
709
|
const logsRoot = join(candidate.projectRoot, "Logs");
|
|
@@ -693,6 +711,15 @@ async function buildArtifactInspectionReport(
|
|
|
693
711
|
?? (useLatest ? await findNewestFile(logsRoot, [".xml"]) : undefined);
|
|
694
712
|
const logFilePath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.logFilePath)
|
|
695
713
|
?? (useLatest ? await findNewestFile(logsRoot, [".log", ".txt"]) : undefined);
|
|
714
|
+
const normalizedResultPath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.normalizedResultPath)
|
|
715
|
+
?? (useLatest ? await findNewestFile(logsRoot, [".json"]) : undefined);
|
|
716
|
+
let normalizedSummary: string | undefined;
|
|
717
|
+
if (normalizedResultPath) {
|
|
718
|
+
try {
|
|
719
|
+
const normalized = JSON.parse(await readFile(normalizedResultPath, "utf8")) as Partial<NormalizedUnityTestResult>;
|
|
720
|
+
if (normalized.schemaVersion === 1 && typeof normalized.outcome === "string") normalizedSummary = `Normalized test result: ${normalized.platform ?? "Unity"} ${normalized.outcome}; ${normalized.summary?.total ?? "unknown"} total.`;
|
|
721
|
+
} catch { normalizedSummary = `Normalized test result JSON could not be parsed: ${normalizedResultPath}`; }
|
|
722
|
+
}
|
|
696
723
|
const invocation: UnityBatchmodeInvocation = {
|
|
697
724
|
isTestRun: Boolean(testResultsPath),
|
|
698
725
|
usesNoGraphics: false,
|
|
@@ -707,9 +734,11 @@ async function buildArtifactInspectionReport(
|
|
|
707
734
|
const hasLoadedArtifacts = Boolean(artifacts.testResultsPath || artifacts.logFilePath);
|
|
708
735
|
const status = deriveUnityArtifactInspectionStatus(hasLoadedArtifacts, invocation, parsedTestResults);
|
|
709
736
|
const lines = [
|
|
710
|
-
`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.`,
|
|
711
738
|
testResultsPath ? `Requested test results: ${testResultsPath}` : "Requested test results: (none found)",
|
|
712
739
|
logFilePath ? `Requested log file: ${logFilePath}` : "Requested log file: (none found)",
|
|
740
|
+
normalizedResultPath ? `Requested normalized result: ${normalizedResultPath}` : "Requested normalized result: (none found)",
|
|
741
|
+
...(normalizedSummary ? [normalizedSummary] : []),
|
|
713
742
|
];
|
|
714
743
|
|
|
715
744
|
if (parsedTestResults) {
|
|
@@ -732,7 +761,7 @@ async function buildArtifactInspectionReport(
|
|
|
732
761
|
details: {
|
|
733
762
|
mode: "artifacts",
|
|
734
763
|
projectRoot: candidate.projectRoot,
|
|
735
|
-
unityVersion: candidate
|
|
764
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
736
765
|
editorPath: "",
|
|
737
766
|
invocation,
|
|
738
767
|
artifacts: compactUnityArtifacts(artifacts),
|
|
@@ -750,7 +779,9 @@ function buildEditorLaunchSummary(
|
|
|
750
779
|
launcher: "unity-cli" | "editor-executable" = "editor-executable",
|
|
751
780
|
): string {
|
|
752
781
|
return [
|
|
753
|
-
|
|
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.`,
|
|
754
785
|
launcher === "unity-cli" ? `Launcher: unity open (${editorPath})` : `Editor: ${editorPath}`,
|
|
755
786
|
GUI_WARNING,
|
|
756
787
|
SINGLE_PROCESS_WARNING,
|
|
@@ -777,7 +808,7 @@ async function buildBatchmodeReport(
|
|
|
777
808
|
const status = deriveUnityBatchmodeStatus(result.code, Boolean(result.killed), invocation, parsedTestResults);
|
|
778
809
|
const text = buildUnityBatchmodeAgentText({
|
|
779
810
|
displayProjectPath: formatPathForUser(ctx.cwd, candidate.projectRoot),
|
|
780
|
-
unityVersion: candidate
|
|
811
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
781
812
|
editorPath,
|
|
782
813
|
exitCode: result.code,
|
|
783
814
|
killed: Boolean(result.killed),
|
|
@@ -795,7 +826,7 @@ async function buildBatchmodeReport(
|
|
|
795
826
|
details: {
|
|
796
827
|
mode: "batchmode",
|
|
797
828
|
projectRoot: candidate.projectRoot,
|
|
798
|
-
unityVersion: candidate
|
|
829
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
799
830
|
editorPath,
|
|
800
831
|
command: editorPath,
|
|
801
832
|
args,
|
|
@@ -904,16 +935,42 @@ function throwIfAborted(signal?: AbortSignal): void {
|
|
|
904
935
|
}
|
|
905
936
|
}
|
|
906
937
|
|
|
907
|
-
|
|
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> {
|
|
908
964
|
try {
|
|
909
965
|
throwIfAborted(signal);
|
|
910
966
|
const command = resolveUnityCliCommand();
|
|
911
967
|
const result = await pi.exec(command, ["--version"], { signal, timeout: 5000 });
|
|
912
968
|
throwIfAborted(signal);
|
|
913
|
-
|
|
969
|
+
if (result.killed) return "uncertain";
|
|
970
|
+
return result.code === 0 ? "available" : "unavailable";
|
|
914
971
|
} catch (error) {
|
|
915
972
|
if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) throw error;
|
|
916
|
-
return
|
|
973
|
+
return isUnityCliProbeTimeout(error) ? "uncertain" : "unavailable";
|
|
917
974
|
}
|
|
918
975
|
}
|
|
919
976
|
|
|
@@ -963,16 +1020,18 @@ async function shouldUseUnityCli(
|
|
|
963
1020
|
return false;
|
|
964
1021
|
}
|
|
965
1022
|
|
|
966
|
-
const
|
|
967
|
-
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") {
|
|
968
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'.");
|
|
969
1029
|
}
|
|
970
1030
|
|
|
971
|
-
return available;
|
|
1031
|
+
return availability === "available";
|
|
972
1032
|
}
|
|
973
1033
|
|
|
974
1034
|
type GuardedBatchmodeParams = {
|
|
975
|
-
unityEditorPath?: string;
|
|
976
1035
|
args?: string[];
|
|
977
1036
|
useGraphics?: boolean;
|
|
978
1037
|
timeoutSeconds?: number;
|
|
@@ -1004,17 +1063,14 @@ async function runGuardedUnityBatchmode(
|
|
|
1004
1063
|
const invocation = parseUnityBatchmodeInvocation(createUnityCliBatchmodeReportArgs(candidate.projectRoot, extraArgs, { useGraphics }));
|
|
1005
1064
|
const useUnityCli = await shouldUseUnityCli(pi, params.launcher, signal);
|
|
1006
1065
|
throwIfAborted(signal);
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
useGraphics,
|
|
1016
|
-
})
|
|
1017
|
-
: 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
|
+
}
|
|
1018
1074
|
const closeReport = await closeBlockingUnityProcessesForBatchmode(
|
|
1019
1075
|
pi,
|
|
1020
1076
|
ctx,
|
|
@@ -1079,6 +1135,96 @@ async function runGuardedUnityBatchmode(
|
|
|
1079
1135
|
);
|
|
1080
1136
|
}
|
|
1081
1137
|
|
|
1138
|
+
async function runUnifiedUnityTests(
|
|
1139
|
+
pi: ExtensionAPI,
|
|
1140
|
+
ctx: ExtensionContext,
|
|
1141
|
+
candidate: UnityProjectCandidate,
|
|
1142
|
+
discoveryWarning: string | undefined,
|
|
1143
|
+
raw: UnityRunTestsRequest,
|
|
1144
|
+
signal: AbortSignal | undefined,
|
|
1145
|
+
onUpdate?: (update: { content: Array<{ type: "text"; text: string }> }) => void,
|
|
1146
|
+
allowAutonomousExitPlayMode = true,
|
|
1147
|
+
): Promise<{ content: Array<{ type: "text"; text: string }>; details: UnityToolDetails & { testResult: NormalizedUnityTestResult; artifactPath: string; route: "connected" | "isolated" } }> {
|
|
1148
|
+
const request = normalizeUnityRunTestsRequest(raw);
|
|
1149
|
+
const requirements = getUnityTestRouteRequirements(request);
|
|
1150
|
+
const capabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, await requireManualUnityVersion(candidate), { signal, execute: createPipelineUnityCliExecutor(pi) });
|
|
1151
|
+
const reachable = capabilities.matchingInstances.some(instance => instance.reachable === true);
|
|
1152
|
+
const busy = (await listBlockingUnityProcesses(candidate.projectRoot)).processes.length > 0 || capabilities.matchingInstances.length > 0;
|
|
1153
|
+
let route: "connected" | "isolated";
|
|
1154
|
+
if (request.execution === "connected") {
|
|
1155
|
+
if (requirements.requiresIsolation) throw new Error(`Connected execution cannot honor this request: ${requirements.reasons.join("; ")}.`);
|
|
1156
|
+
if (!reachable) throw new Error("Connected execution requires an already-open exact-copy reachable Pipeline Editor; no Unity was launched.");
|
|
1157
|
+
route = "connected";
|
|
1158
|
+
} else if (request.execution === "isolated") {
|
|
1159
|
+
if (reachable && !request.closeBlockingUnityProcess) throw new Error("Isolated execution will not close a reachable Pipeline Editor automatically. Close it first or use the explicitly guarded close option.");
|
|
1160
|
+
route = "isolated";
|
|
1161
|
+
} else if (reachable) {
|
|
1162
|
+
if (requirements.requiresIsolation) throw new Error(`This request requires isolated execution (${requirements.reasons.join("; ")}), but the exact project copy is open in reachable Pipeline. pi-unity will not close it automatically.`);
|
|
1163
|
+
route = "connected";
|
|
1164
|
+
} else {
|
|
1165
|
+
if (capabilities.pipelineDiscovery === "timeout" || (capabilities.projectSupportsPipeline && capabilities.pipelineDiscovery !== "absent" && capabilities.pipelineDiscovery !== "available")) throw new Error("Pipeline discovery is uncertain for this exact project copy; refusing to start an isolated Editor until project state is known.");
|
|
1166
|
+
if (busy) throw new Error("A Unity process is already open for this exact project copy without reachable Pipeline; refusing to launch a second process.");
|
|
1167
|
+
route = "isolated";
|
|
1168
|
+
}
|
|
1169
|
+
const formats = request.reportFormats ?? defaultUnityTestReportFormats(route);
|
|
1170
|
+
if (route === "connected") {
|
|
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 }] }) });
|
|
1172
|
+
const counts = result.details.counts!;
|
|
1173
|
+
const normalized: NormalizedUnityTestResult = {
|
|
1174
|
+
schemaVersion: 1, source: "pipeline", platform: request.testPlatform,
|
|
1175
|
+
selection: { testFilters: request.testFilters, testCategories: request.testCategories },
|
|
1176
|
+
durationSeconds: result.details.elapsedSeconds, outcome: determineUnityTestOutcome(counts), summary: counts, tests: result.testRecords ?? [],
|
|
1177
|
+
};
|
|
1178
|
+
const artifactPath = await writeNormalizedUnityTestArtifact(candidate.projectRoot, normalized);
|
|
1179
|
+
const text = `${compactUnityTestSummary(normalized)}\nRoute: connected Pipeline. Normalized artifact: ${artifactPath}`;
|
|
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 } };
|
|
1181
|
+
}
|
|
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.");
|
|
1183
|
+
const plan = createUnityTestBatchPlan({ projectRoot: candidate.projectRoot, testPlatform: request.testPlatform, testFilters: request.testFilters, testCategories: request.testCategories });
|
|
1184
|
+
const cliAvailable = await shouldUseUnityCli(pi, request.isolatedLauncher, signal);
|
|
1185
|
+
if (!cliAvailable && request.isolatedLauncher === "unity-cli") throw new Error("Unity CLI was requested but is unavailable.");
|
|
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.");
|
|
1187
|
+
if (!cliAvailable) {
|
|
1188
|
+
const fallback = await runGuardedUnityBatchmode(pi, ctx, candidate, discoveryWarning, { args: plan.args, useGraphics: request.useGraphics, timeoutSeconds: request.timeoutSeconds, launcher: "editor-executable", closeBlockingUnityProcess: request.closeBlockingUnityProcess }, signal, "unity_run_test_batch");
|
|
1189
|
+
const parsed = fallback.details.parsedTestResults;
|
|
1190
|
+
const summary = { total: parsed?.total, passed: parsed?.passed, failed: parsed?.failed, skipped: parsed?.skipped };
|
|
1191
|
+
const normalized: NormalizedUnityTestResult = { schemaVersion: 1, source: "editor-executable", platform: request.testPlatform, selection: { testFilters: request.testFilters, testCategories: request.testCategories }, outcome: determineUnityTestOutcome(summary), summary, tests: parsed?.tests ?? [] };
|
|
1192
|
+
const artifactPath = await writeNormalizedUnityTestArtifact(candidate.projectRoot, normalized);
|
|
1193
|
+
return { content: [{ type: "text", text: `${compactUnityTestSummary(normalized)}\nRoute: isolated direct Editor. Normalized artifact: ${artifactPath}` }], details: { ...fallback.details, mode: "tests", testResult: { ...normalized, tests: [] }, artifactPath, route } };
|
|
1194
|
+
}
|
|
1195
|
+
return await withUnityProjectLaunchMutex(candidate.projectRoot, { mode: "batchmode", toolName: "unity_run_tests" }, async () => {
|
|
1196
|
+
const invocation = parseUnityBatchmodeInvocation(plan.args);
|
|
1197
|
+
const closeReport = await closeBlockingUnityProcessesForBatchmode(pi, ctx, candidate, invocation, request.closeBlockingUnityProcess, signal);
|
|
1198
|
+
await removeStaleLockfileAfterGuardedClose(candidate, closeReport);
|
|
1199
|
+
await enforceLaunchRouteSafety(candidate.projectRoot, "unity-cli");
|
|
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 });
|
|
1201
|
+
const execution = await pi.exec(command.command, command.args, { signal, timeout: (request.timeoutSeconds ?? 3600) * 1000 + 30_000 });
|
|
1202
|
+
const effectiveResultsPath = deriveUnityCliEffectiveReportPath(plan.testResultsPath, { rerunFailed: request.rerunFailed, shard: request.shard });
|
|
1203
|
+
let effectiveResultsXml: string | undefined;
|
|
1204
|
+
try { effectiveResultsXml = await readFile(effectiveResultsPath, "utf8"); } catch { /* Missing current-run evidence is handled below. */ }
|
|
1205
|
+
const parsed = effectiveResultsXml ? parseUnityTestResultsXml(effectiveResultsXml) : null;
|
|
1206
|
+
const summary = { total: parsed?.total, passed: parsed?.passed, failed: parsed?.failed, skipped: parsed?.skipped };
|
|
1207
|
+
const outcome = execution.killed ? "timed_out" : execution.code === 8 ? "tests_failed" : execution.code === 6 ? "run_error" : determineUnityTestOutcome(summary);
|
|
1208
|
+
let normalized: NormalizedUnityTestResult = { schemaVersion: 1, source: "unity-cli", platform: request.testPlatform, selection: { testFilters: request.testFilters, testCategories: request.testCategories }, outcome: (request.rerunFailed || request.shard) && execution.code === 0 && !parsed ? "empty_selection" : outcome, summary, tests: parsed?.tests ?? [], backendArtifacts: { ...(parsed && formats.includes("nunit") ? { nunit: `Logs/${effectiveResultsPath.split(/[\\/]/).pop()}` } : {}), ...(parsed && formats.includes("junit") ? { junit: `Logs/${deriveUnityCliEffectiveReportPath(plan.junitResultsPath, { rerunFailed: request.rerunFailed, shard: request.shard }).split(/[\\/]/).pop()}` } : {}), log: `Logs/${plan.logFilePath.split(/[\\/]/).pop()}` } };
|
|
1209
|
+
if (request.retries > 0 && formats.includes("nunit")) {
|
|
1210
|
+
const retryPath = effectiveResultsPath.replace(/\.[^.\\/]+$/, ".retries.json");
|
|
1211
|
+
try {
|
|
1212
|
+
const retry = parseUnityCliRetrySummary(JSON.parse(await readFile(retryPath, "utf8")));
|
|
1213
|
+
if (!retry) normalized = { ...normalized, outcome: "uncertain" };
|
|
1214
|
+
else {
|
|
1215
|
+
normalized = applyUnityCliRetrySummary(normalized, retry);
|
|
1216
|
+
normalized.backendArtifacts = { ...normalized.backendArtifacts, retrySummary: `Logs/${retryPath.split(/[\\/]/).pop()}` };
|
|
1217
|
+
}
|
|
1218
|
+
} catch {
|
|
1219
|
+
normalized = { ...normalized, outcome: "uncertain" };
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
const artifactPath = await writeNormalizedUnityTestArtifact(candidate.projectRoot, normalized);
|
|
1223
|
+
const text = `${compactUnityTestSummary(normalized)}\nRoute: isolated Unity CLI. Normalized artifact: ${artifactPath}`;
|
|
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 } };
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1082
1228
|
function renderUnityPipelineResult(result: any, options: { expanded: boolean; isPartial: boolean }, theme: any, context: { lastComponent?: unknown }): Text {
|
|
1083
1229
|
const details = result.details as UnityToolDetails | undefined;
|
|
1084
1230
|
const primaryText = getToolTextContent(result);
|
|
@@ -1216,6 +1362,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1216
1362
|
|
|
1217
1363
|
pi.on("session_start", async (_event, ctx) => {
|
|
1218
1364
|
restoreSessionSettings(ctx);
|
|
1365
|
+
await warnWhenUnityCliUnavailable(pi, ctx);
|
|
1219
1366
|
const scope = ctx.sessionManager;
|
|
1220
1367
|
unregisterScope(registrations.get(scope));
|
|
1221
1368
|
// Optional package integrations resolve independently. The Unity extension and
|
|
@@ -1293,14 +1440,14 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1293
1440
|
await withUnityProjectLaunchMutex(candidate.projectRoot, { mode: "gui", toolName: "unity-open" }, async () => {
|
|
1294
1441
|
await enforceSingleProcessRule(candidate.projectRoot);
|
|
1295
1442
|
let launcher: "unity-cli" | "editor-executable" = "editor-executable";
|
|
1296
|
-
let editorPath =
|
|
1443
|
+
let editorPath = "Unity CLI";
|
|
1297
1444
|
let launch: { pid: number | undefined; args: string[]; command: string };
|
|
1298
|
-
if (await
|
|
1445
|
+
if (await shouldUseUnityCli(pi, undefined)) {
|
|
1299
1446
|
launcher = "unity-cli";
|
|
1300
|
-
launch = launchUnityCliOpenDetached(candidate.projectRoot
|
|
1447
|
+
launch = launchUnityCliOpenDetached(candidate.projectRoot);
|
|
1301
1448
|
} else {
|
|
1302
1449
|
await assertUnityProjectNotBusy(candidate.projectRoot);
|
|
1303
|
-
editorPath = await resolveUnityEditorPath(candidate
|
|
1450
|
+
editorPath = await resolveUnityEditorPath(await requireManualUnityVersion(candidate));
|
|
1304
1451
|
launch = launchUnityEditorDetached(editorPath, candidate.projectRoot);
|
|
1305
1452
|
}
|
|
1306
1453
|
const summary = buildEditorLaunchSummary(ctx.cwd, candidate, editorPath, discoveryWarning, launcher);
|
|
@@ -1392,6 +1539,27 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1392
1539
|
},
|
|
1393
1540
|
});
|
|
1394
1541
|
|
|
1542
|
+
pi.registerTool({
|
|
1543
|
+
name: "unity_run_tests",
|
|
1544
|
+
label: "Unity Run Tests",
|
|
1545
|
+
description: "Run Unity Test Framework tests through one intent-oriented workflow. It reuses a reachable exact-copy Pipeline Editor when compatible, otherwise uses isolated `unity test` execution.",
|
|
1546
|
+
promptSnippet: "Run Unity EditMode or PlayMode tests through one safe routed workflow with durable normalized evidence.",
|
|
1547
|
+
promptGuidelines: [
|
|
1548
|
+
"Use unity_run_tests for ordinary Unity Test Framework runs. It selects connected Pipeline only for compatible requests and isolated unity test only when the exact project copy is closed.",
|
|
1549
|
+
"Do not use unity_launch_batchmode for ordinary tests; raw test flags there are an unsupported escape hatch.",
|
|
1550
|
+
"A reachable Editor is never closed merely to obtain isolated-only options. Requests needing retries, sharding, reruns, coverage, multiple selectors, or XML reports are rejected before dispatch when it is open.",
|
|
1551
|
+
"Timeout, malformed evidence, cancellation, or missing artifacts never cause a backend fallback or relaunch.",
|
|
1552
|
+
],
|
|
1553
|
+
parameters: RUN_TESTS_PARAMS,
|
|
1554
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1555
|
+
throwIfAborted(signal);
|
|
1556
|
+
const { candidate, discoveryWarning } = await resolveProjectCandidate(ctx, params.path);
|
|
1557
|
+
return await runUnifiedUnityTests(pi, ctx, candidate, discoveryWarning, params as UnityRunTestsRequest, signal, onUpdate, sessionAllowsAutonomousPlayModeExit(ctx));
|
|
1558
|
+
},
|
|
1559
|
+
renderCall(args, theme, context) { return renderUnityToolCall("unity_run_tests", args, theme, "tests", `${args.testPlatform ?? "Unity"} • ${compactUnityRendererValue(args.testFilters?.[0] ?? args.testFilter ?? args.execution ?? "auto", 100)}`, context); },
|
|
1560
|
+
renderResult(result, { expanded }, theme) { return renderUnityToolResult(result, expanded, theme); },
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1395
1563
|
pi.registerTool({
|
|
1396
1564
|
name: "unity_pipeline_recompile",
|
|
1397
1565
|
label: "Unity Pipeline Recompile",
|
|
@@ -1406,47 +1574,19 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1406
1574
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1407
1575
|
throwIfAborted(signal);
|
|
1408
1576
|
const { candidate } = await resolveProjectCandidate(ctx, params.path);
|
|
1409
|
-
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), {
|
|
1410
1578
|
signal,
|
|
1411
1579
|
onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }] }),
|
|
1412
1580
|
});
|
|
1413
1581
|
return {
|
|
1414
1582
|
content: [{ type: "text", text: result.text }],
|
|
1415
|
-
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,
|
|
1416
1584
|
};
|
|
1417
1585
|
},
|
|
1418
1586
|
renderCall(args, theme, context) { return renderUnityPipelineCall("unity_pipeline_recompile", args, theme, context); },
|
|
1419
1587
|
renderResult(result, options, theme, context) { return renderUnityPipelineResult(result, options, theme, context); },
|
|
1420
1588
|
});
|
|
1421
1589
|
|
|
1422
|
-
pi.registerTool({
|
|
1423
|
-
name: "unity_pipeline_run_tests",
|
|
1424
|
-
label: "Unity Pipeline Run Tests",
|
|
1425
|
-
description: "Run one focused EditMode or PlayMode test selection through an already-open exact Unity Pipeline Editor, with internal bounded polling and aggregate output.",
|
|
1426
|
-
promptSnippet: "Run focused connected Unity EditMode or PlayMode tests in one bounded call without shell polling; aggregate passing results stay compact.",
|
|
1427
|
-
promptGuidelines: [
|
|
1428
|
-
"Use unity_pipeline_run_tests for one focused connected Unity test platform when the exact Editor is already open and reachable.",
|
|
1429
|
-
"unity_pipeline_run_tests may exit Play Mode through advertised editor_stop when needed, then verifies Edit Mode before dispatching tests.",
|
|
1430
|
-
"Use unity_run_test_batch instead of unity_pipeline_run_tests for closed projects, isolation, complex filters/categories, or required NUnit XML/log evidence.",
|
|
1431
|
-
"unity_pipeline_run_tests does not cancel uncertain work or switch to batchmode after timeout; report that the connected run may still be running.",
|
|
1432
|
-
],
|
|
1433
|
-
parameters: PIPELINE_TEST_PARAMS,
|
|
1434
|
-
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1435
|
-
throwIfAborted(signal);
|
|
1436
|
-
const { candidate } = await resolveProjectCandidate(ctx, params.path);
|
|
1437
|
-
const result = await runUnityPipelineTests({ projectRoot: candidate.projectRoot, unityVersion: candidate.unityVersion, testPlatform: params.testPlatform, testFilter: params.testFilter, timeoutSeconds: params.timeoutSeconds, allowAutonomousExitPlayMode: sessionAllowsAutonomousPlayModeExit(ctx) }, createPipelineDependencies(pi), {
|
|
1438
|
-
signal,
|
|
1439
|
-
onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }] }),
|
|
1440
|
-
});
|
|
1441
|
-
return {
|
|
1442
|
-
content: [{ type: "text", text: result.text }],
|
|
1443
|
-
details: { mode: "pipeline", projectRoot: result.details.projectRoot, unityVersion: candidate.unityVersion, editorPath: "", status: "passed", pipeline: result.details } satisfies UnityToolDetails,
|
|
1444
|
-
};
|
|
1445
|
-
},
|
|
1446
|
-
renderCall(args, theme, context) { return renderUnityPipelineCall("unity_pipeline_run_tests", args, theme, context); },
|
|
1447
|
-
renderResult(result, options, theme, context) { return renderUnityPipelineResult(result, options, theme, context); },
|
|
1448
|
-
});
|
|
1449
|
-
|
|
1450
1590
|
pi.registerTool({
|
|
1451
1591
|
name: "unity_pipeline_eval",
|
|
1452
1592
|
label: "Unity Pipeline Eval",
|
|
@@ -1465,7 +1605,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1465
1605
|
throwIfAborted(signal);
|
|
1466
1606
|
const result = await dispatchUnityPlanningInspection({
|
|
1467
1607
|
projectRoot: candidate.projectRoot,
|
|
1468
|
-
unityVersion: candidate
|
|
1608
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
1469
1609
|
command: "eval",
|
|
1470
1610
|
evalSnippet: params.code,
|
|
1471
1611
|
}, {
|
|
@@ -1482,7 +1622,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1482
1622
|
details: {
|
|
1483
1623
|
mode: "pipeline_eval",
|
|
1484
1624
|
projectRoot: candidate.projectRoot,
|
|
1485
|
-
unityVersion: candidate
|
|
1625
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
1486
1626
|
editorPath: "",
|
|
1487
1627
|
status: result.outcome === "dispatched" ? "passed" : "failed",
|
|
1488
1628
|
pipelineEval: result,
|
|
@@ -1514,7 +1654,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1514
1654
|
throwIfAborted(signal);
|
|
1515
1655
|
const result = await dispatchUnityPlanningInspection({
|
|
1516
1656
|
projectRoot: candidate.projectRoot,
|
|
1517
|
-
unityVersion: candidate
|
|
1657
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
1518
1658
|
command: params.command,
|
|
1519
1659
|
args: params.args,
|
|
1520
1660
|
}, {
|
|
@@ -1531,7 +1671,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1531
1671
|
details: {
|
|
1532
1672
|
mode: "pipeline_inspection",
|
|
1533
1673
|
projectRoot: candidate.projectRoot,
|
|
1534
|
-
unityVersion: candidate
|
|
1674
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
1535
1675
|
editorPath: "",
|
|
1536
1676
|
status: result.outcome === "dispatched" ? "passed" : "failed",
|
|
1537
1677
|
pipelineInspection: result,
|
|
@@ -1600,18 +1740,14 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1600
1740
|
const useUnityCli = await shouldUseUnityCli(pi, params.launcher as UnityLauncherPreference | undefined, signal);
|
|
1601
1741
|
throwIfAborted(signal);
|
|
1602
1742
|
let launcher: "unity-cli" | "editor-executable" = "editor-executable";
|
|
1603
|
-
let editorPath =
|
|
1743
|
+
let editorPath = "Unity CLI";
|
|
1604
1744
|
let launch: { pid: number | undefined; args: string[]; command: string };
|
|
1605
1745
|
if (useUnityCli) {
|
|
1606
1746
|
launcher = "unity-cli";
|
|
1607
|
-
launch = launchUnityCliOpenDetached(candidate.projectRoot, {
|
|
1608
|
-
editorVersion: candidate.unityVersion,
|
|
1609
|
-
editorPath: params.unityEditorPath,
|
|
1610
|
-
automated: params.automated,
|
|
1611
|
-
});
|
|
1747
|
+
launch = launchUnityCliOpenDetached(candidate.projectRoot, { automated: params.automated });
|
|
1612
1748
|
} else {
|
|
1613
1749
|
await assertUnityProjectNotBusy(candidate.projectRoot);
|
|
1614
|
-
editorPath = await resolveUnityEditorPath(candidate
|
|
1750
|
+
editorPath = await resolveUnityEditorPath(await requireManualUnityVersion(candidate));
|
|
1615
1751
|
launch = launchUnityEditorDetached(editorPath, candidate.projectRoot, { automated: params.automated });
|
|
1616
1752
|
}
|
|
1617
1753
|
const text = buildEditorLaunchSummary(ctx.cwd, candidate, editorPath, discoveryWarning, launcher);
|
|
@@ -1621,7 +1757,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1621
1757
|
details: {
|
|
1622
1758
|
mode: "gui",
|
|
1623
1759
|
projectRoot: candidate.projectRoot,
|
|
1624
|
-
unityVersion: candidate
|
|
1760
|
+
unityVersion: await requireManualUnityVersion(candidate),
|
|
1625
1761
|
editorPath,
|
|
1626
1762
|
pid: launch.pid,
|
|
1627
1763
|
command: launch.command,
|
|
@@ -1640,52 +1776,6 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1640
1776
|
},
|
|
1641
1777
|
});
|
|
1642
1778
|
|
|
1643
|
-
pi.registerTool({
|
|
1644
|
-
name: "unity_run_test_batch",
|
|
1645
|
-
label: "Unity Test Batch",
|
|
1646
|
-
description: "Run one bundled Unity Test Framework platform with normalized filters/categories and generated absolute XML/log paths under the project Logs directory.",
|
|
1647
|
-
promptSnippet: "Run a bundled Unity EditMode or PlayMode test batch with safe generated artifact paths",
|
|
1648
|
-
promptGuidelines: [
|
|
1649
|
-
"Before choosing a test route, call unity_project_status for the exact project copy. If it is already open with reachable Pipeline run_tests/test_status commands, use the connected workflow without closing the Editor.",
|
|
1650
|
-
"Prefer unity_run_test_batch over unity_launch_batchmode only for isolated or report-producing Unity Test Framework runs: closed projects, unavailable/unsupported connected testing, intentional CI isolation, unsupported filters, or required NUnit XML/log artifacts.",
|
|
1651
|
-
"Do not set closeBlockingUnityProcess merely to switch a reachable Pipeline Editor into batchmode; use it only after isolated execution is deliberately required and the guarded setting is enabled.",
|
|
1652
|
-
"Pass unity_run_test_batch exactly one testPlatform. Multiple test platforms require separate user-authorized launches.",
|
|
1653
|
-
"An empty unity_run_test_batch testFilters/testCategories selection runs all tests for that testPlatform; use narrow arrays when focused evidence is sufficient.",
|
|
1654
|
-
"Do not call unity_run_test_batch for PlayMode when user/project guidance says to skip PlayMode tests.",
|
|
1655
|
-
"Use unity_run_test_batch useGraphics=true only for graphics-dependent PlayMode tests or visual capture; ordinary EditMode and non-visual PlayMode remain headless.",
|
|
1656
|
-
"After unity_run_test_batch infrastructure failure, inspect the exact generated paths reported by the failed call once and do not repeat an unchanged launch.",
|
|
1657
|
-
],
|
|
1658
|
-
parameters: RUN_TEST_BATCH_PARAMS,
|
|
1659
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
1660
|
-
throwIfAborted(signal);
|
|
1661
|
-
const { candidate, discoveryWarning } = await resolveProjectCandidate(ctx, params.path);
|
|
1662
|
-
const plan = createUnityTestBatchPlan({
|
|
1663
|
-
projectRoot: candidate.projectRoot,
|
|
1664
|
-
testPlatform: params.testPlatform as UnityTestPlatform,
|
|
1665
|
-
testFilters: params.testFilters,
|
|
1666
|
-
testCategories: params.testCategories,
|
|
1667
|
-
});
|
|
1668
|
-
await mkdir(dirname(plan.testResultsPath), { recursive: true });
|
|
1669
|
-
throwIfAborted(signal);
|
|
1670
|
-
const result = await runGuardedUnityBatchmode(pi, ctx, candidate, discoveryWarning, {
|
|
1671
|
-
unityEditorPath: params.unityEditorPath,
|
|
1672
|
-
args: plan.args,
|
|
1673
|
-
useGraphics: params.useGraphics,
|
|
1674
|
-
timeoutSeconds: params.timeoutSeconds,
|
|
1675
|
-
launcher: params.launcher as UnityLauncherPreference | undefined,
|
|
1676
|
-
closeBlockingUnityProcess: params.closeBlockingUnityProcess,
|
|
1677
|
-
}, signal, "unity_run_test_batch");
|
|
1678
|
-
result.details = { ...result.details, testBatch: plan };
|
|
1679
|
-
return result;
|
|
1680
|
-
},
|
|
1681
|
-
renderCall(args, theme) {
|
|
1682
|
-
return renderUnityToolCall("unity_run_test_batch", args, theme, "batchmode", `${args.testPlatform} test batch`);
|
|
1683
|
-
},
|
|
1684
|
-
renderResult(result, { expanded }, theme) {
|
|
1685
|
-
return renderUnityToolResult(result, expanded, theme);
|
|
1686
|
-
},
|
|
1687
|
-
});
|
|
1688
|
-
|
|
1689
1779
|
pi.registerTool({
|
|
1690
1780
|
name: "unity_launch_batchmode",
|
|
1691
1781
|
label: "Unity CLI",
|