@aefree/pi-unity 0.9.3 → 0.11.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 +26 -0
- package/README.md +6 -7
- package/index.ts +149 -79
- 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 +6 -6
- package/skills/unity-pipeline-workflows/SKILL.md +9 -9
- package/src/unity-batchmode.ts +9 -9
- package/src/unity-cli.ts +37 -0
- package/src/unity-pipeline.ts +58 -8
- package/src/unity-test-batch.ts +3 -1
- package/src/unity-tests.ts +272 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project follows semantic versioning for public package releases.
|
|
7
7
|
|
|
8
|
+
## Unreleased
|
|
9
|
+
|
|
10
|
+
## [0.11.0] - 2026-08-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Added unified `unity_run_tests`, which routes compatible exact-copy connected runs through Pipeline and closed-project runs through `unity test`, with durable normalized JSON evidence.
|
|
15
|
+
- Added normalized outcomes for passes, flaky passes, test failures, empty selections, run errors, timeouts, cancellation, and uncertain evidence.
|
|
16
|
+
- Added isolated Unity CLI support for retries, rerunning failures, deterministic sharding, NUnit/JUnit reports, and coverage controls.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Connected Pipeline tests now preserve complete bounded result evidence in durable normalized JSON while keeping routine tool output compact.
|
|
21
|
+
- Isolated tests now prefer the Unity CLI `unity test` workflow and reconcile retry sidecars and derived rerun/shard reports.
|
|
22
|
+
|
|
23
|
+
### Removed
|
|
24
|
+
|
|
25
|
+
- Removed the public `unity_pipeline_run_tests` and `unity_run_test_batch` registrations; `unity_launch_batchmode` remains a non-test escape hatch.
|
|
26
|
+
|
|
27
|
+
## [0.10.0] - 2026-08-14
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- Added a configurable 1–86,400-second timeout to `unity_pipeline_eval`, forwarding it to both Unity CLI and the host process.
|
|
32
|
+
- Handle Pipeline 0.5's explicitly rejected initial-settling `Server Busy` envelopes with bounded retries for connected recompiles and tests, while preserving no-retry handling for ambiguous responses.
|
|
33
|
+
|
|
8
34
|
## [0.9.3] - 2026-08-10
|
|
9
35
|
|
|
10
36
|
### Added
|
package/README.md
CHANGED
|
@@ -33,22 +33,21 @@ Use these tools with an already-open exact Unity project copy that has a reachab
|
|
|
33
33
|
|
|
34
34
|
- `unity_project_status` — inspect lockfiles, matching Unity processes, Pipeline reachability, package version, and advertised commands without launching Unity.
|
|
35
35
|
- `unity_pipeline_recompile` — recompile through Pipeline with exact-copy preflight, bounded polling, and compact compiler evidence.
|
|
36
|
-
- `
|
|
37
|
-
- `unity_pipeline_eval` — execute bounded project-specific C# through Pipeline's Roslyn REPL.
|
|
36
|
+
- `unity_run_tests` — one intent-oriented EditMode or PlayMode workflow. It reuses compatible connected Pipeline execution or selects isolated `unity test` when the exact project copy is closed.
|
|
37
|
+
- `unity_pipeline_eval` — execute bounded project-specific C# through Pipeline's Roslyn REPL. It accepts `timeoutSeconds` from 1–86,400 seconds; a timeout is uncertain and does not cancel or retry Editor work.
|
|
38
38
|
- `unity_pipeline_inspect` — dispatch supported package-owned inspection commands and return structured evidence.
|
|
39
39
|
|
|
40
40
|
Connected recompilation follows Unity's Script Changes While Playing policy and never preemptively sends `editor_stop`. Connected tests may exit Play Mode through advertised `editor_stop` when necessary, then verify Edit Mode before dispatch. Play Mode exit is allowed by default; `/unity-playmode-exit allow|disallow|status` controls the current session.
|
|
41
41
|
|
|
42
|
-
A timeout is uncertain: work may still be running. The tools do not silently cancel, retry, launch another Editor, or switch to batchmode.
|
|
42
|
+
A timeout is uncertain: work may still be running. The tools do not silently cancel, retry, launch another Editor, or switch to batchmode. The sole exception is Pipeline 0.5's explicit initial-settling `Server Busy` rejection for `unity_pipeline_recompile` and `unity_run_tests`, which is known not to have dispatched a main-thread command and is retried only within the configured deadline.
|
|
43
43
|
|
|
44
44
|
### Editor and batchmode
|
|
45
45
|
|
|
46
46
|
- `unity_open_editor` — open the Unity Editor GUI. Pass `automated: true` to add the Unity Editor `-automated` flag; this is distinct from the Unity CLI's own `--non-interactive` option.
|
|
47
47
|
- `unity_launch_batchmode` — run a bounded batchmode command through Unity CLI or the direct Editor executable.
|
|
48
|
-
- `unity_run_test_batch` — run one isolated or report-producing Unity Test Framework platform with generated XML and log paths.
|
|
49
48
|
- `unity_inspect_artifacts` — summarize existing Unity Test Framework XML and Unity logs without launching Unity.
|
|
50
49
|
|
|
51
|
-
Use
|
|
50
|
+
Use `unity_run_tests` for all ordinary Unity Test Framework work. It writes a durable normalized JSON result under `Logs/`; isolated `unity test` runs retain requested native reports. Connected Pipeline is selected only for compatible requests. A reachable Editor is never closed automatically to obtain isolated-only features.
|
|
52
51
|
|
|
53
52
|
Batchmode runs use `-nographics` by default. Set `useGraphics: true` only for screenshots, visual capture, render checks, or graphics-dependent tests. Unity permits only one process per project folder, so all launch routes verify the exact project and use a per-project mutex.
|
|
54
53
|
|
|
@@ -78,8 +77,8 @@ Operation-specific recovery belongs to the operational skill. `unity-debugging`
|
|
|
78
77
|
| Situation | Preferred route |
|
|
79
78
|
| --- | --- |
|
|
80
79
|
| Open exact-copy Editor with reachable Pipeline | Connected Pipeline tools |
|
|
81
|
-
| Closed project or intentional CI isolation | `
|
|
82
|
-
| Required NUnit XML or Unity log evidence | `
|
|
80
|
+
| Closed project or intentional CI isolation | `unity_run_tests` |
|
|
81
|
+
| Required NUnit XML or Unity log evidence | `unity_run_tests` |
|
|
83
82
|
| Existing failed-run artifacts | `unity_inspect_artifacts` |
|
|
84
83
|
| Project-specific C# query or operation | `unity_pipeline_eval` |
|
|
85
84
|
| Supported structured project inspection | `unity_pipeline_inspect` |
|
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,14 @@ 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";
|
|
23
|
+
import { createUnityCliBatchmodeReportArgs, createUnityCliEditorExitCommand, createUnityCliRunCommand, createUnityCliTestCommand, dispatchUnityPlanningInspection, haveSameKnownProcessIds, inspectUnityCliProjectCapabilities, listRunningUnityCliEditorsForProject, resolveUnityCliCommand, UNITY_PLANNING_READ_COMMANDS, type UnityCliProjectCapabilities } from "./src/unity-cli";
|
|
24
24
|
import { createUnityBatchmodeCommand, launchUnityCliOpenDetached, launchUnityEditorDetached, resolveUnityEditorPath } from "./src/unity-launch";
|
|
25
25
|
import { loadPiUnitySettings, type PiUnitySettings } from "./src/pi-unity-settings";
|
|
26
26
|
import { dedupeRunningUnityProcesses, listRunningUnityProcessesForProject, redactUnityProcessCommandLine, terminateRunningUnityProcesses, verifyUnityProcessIdentity, type RunningUnityProcess } from "./src/unity-processes";
|
|
27
27
|
import { assertUnityProjectNotBusy, evaluateUnityLaunchSafety, getUnityNativeLockfilePath, inspectUnityProjectBusyState, withUnityProjectLaunchMutex } from "./src/unity-project-lock";
|
|
28
28
|
import { resolveUnityProjectCandidates, type UnityProjectCandidate } from "./src/unity-projects";
|
|
29
29
|
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";
|
|
30
31
|
import { auditUnityGuidance, type UnityGuidanceAuditResult } from "./src/unity-guidance-audit";
|
|
31
32
|
import { runUnityPipelineRecompile, runUnityPipelineTests, type UnityPipelineOperationDetails } from "./src/unity-pipeline";
|
|
32
33
|
import {
|
|
@@ -66,7 +67,7 @@ const GUI_WARNING = "This launches the full Unity Editor GUI and is not the same
|
|
|
66
67
|
const SINGLE_PROCESS_WARNING = "Unity allows only one process per project folder. GUI Editor and batchmode/headless both count as that one process.";
|
|
67
68
|
|
|
68
69
|
type UnityToolDetails = {
|
|
69
|
-
mode: "gui" | "batchmode" | "status" | "artifacts" | "pipeline_inspection" | "pipeline_eval" | "pipeline";
|
|
70
|
+
mode: "gui" | "batchmode" | "status" | "artifacts" | "pipeline_inspection" | "pipeline_eval" | "pipeline" | "tests";
|
|
70
71
|
projectRoot: string;
|
|
71
72
|
unityVersion: string;
|
|
72
73
|
editorPath: string;
|
|
@@ -117,6 +118,25 @@ const LAUNCH_BATCHMODE_PARAMS = Type.Object({
|
|
|
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
|
});
|
|
119
120
|
|
|
121
|
+
const RUN_TESTS_PARAMS = Type.Object({
|
|
122
|
+
path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
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 })),
|
|
136
|
+
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 14400, default: 3600 })),
|
|
137
|
+
closeBlockingUnityProcess: Type.Optional(Type.Boolean({ default: false })),
|
|
138
|
+
}, { additionalProperties: false });
|
|
139
|
+
|
|
120
140
|
const RUN_TEST_BATCH_PARAMS = Type.Object({
|
|
121
141
|
path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
122
142
|
unityEditorPath: Type.Optional(Type.String({ description: "Optional explicit Unity executable path override." })),
|
|
@@ -148,6 +168,7 @@ const PIPELINE_TEST_PARAMS = Type.Object({
|
|
|
148
168
|
const PIPELINE_EVAL_PARAMS = Type.Object({
|
|
149
169
|
path: Type.Optional(Type.String({ maxLength: 1000, description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
150
170
|
code: Type.String({ minLength: 1, maxLength: 4000, description: "Bounded C# source for advertised Pipeline eval. Roslyn compiles it on the connected Editor main thread; include an explicit return value when evidence is needed." }),
|
|
171
|
+
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 86400, default: 12, description: "Connected eval deadline in seconds (maximum 24 hours). A timeout is uncertain and does not retry or cancel Unity work." })),
|
|
151
172
|
}, { additionalProperties: false });
|
|
152
173
|
|
|
153
174
|
const PIPELINE_INSPECTION_PARAMS = Type.Object({
|
|
@@ -167,6 +188,7 @@ const GUIDANCE_AUDIT_PARAMS = Type.Object({
|
|
|
167
188
|
const INSPECT_ARTIFACTS_PARAMS = Type.Object({
|
|
168
189
|
path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
|
|
169
190
|
testResultsPath: Type.Optional(Type.String({ description: "Unity Test Framework XML results path. Relative paths are resolved against cwd and the Unity project root." })),
|
|
191
|
+
normalizedResultPath: Type.Optional(Type.String({ description: "pi-unity normalized JSON test artifact path." })),
|
|
170
192
|
logFilePath: Type.Optional(Type.String({ description: "Unity log file path. Relative paths are resolved against cwd and the Unity project root." })),
|
|
171
193
|
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." })),
|
|
172
194
|
maxLines: Type.Optional(Type.Integer({ minimum: 1, maximum: 500, default: 60, description: "Maximum log/output lines to include." })),
|
|
@@ -684,7 +706,7 @@ function compactUnityArtifacts(artifacts: UnityBatchmodeArtifacts): UnityBatchmo
|
|
|
684
706
|
async function buildArtifactInspectionReport(
|
|
685
707
|
ctx: ExtensionContext,
|
|
686
708
|
candidate: UnityProjectCandidate,
|
|
687
|
-
params: { testResultsPath?: string; logFilePath?: string; latestFromLogs?: boolean; maxLines?: number; maxChars?: number },
|
|
709
|
+
params: { testResultsPath?: string; normalizedResultPath?: string; logFilePath?: string; latestFromLogs?: boolean; maxLines?: number; maxChars?: number },
|
|
688
710
|
): Promise<{ text: string; details: UnityToolDetails }> {
|
|
689
711
|
const useLatest = params.latestFromLogs !== false;
|
|
690
712
|
const logsRoot = join(candidate.projectRoot, "Logs");
|
|
@@ -692,6 +714,15 @@ async function buildArtifactInspectionReport(
|
|
|
692
714
|
?? (useLatest ? await findNewestFile(logsRoot, [".xml"]) : undefined);
|
|
693
715
|
const logFilePath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.logFilePath)
|
|
694
716
|
?? (useLatest ? await findNewestFile(logsRoot, [".log", ".txt"]) : undefined);
|
|
717
|
+
const normalizedResultPath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.normalizedResultPath)
|
|
718
|
+
?? (useLatest ? await findNewestFile(logsRoot, [".json"]) : undefined);
|
|
719
|
+
let normalizedSummary: string | undefined;
|
|
720
|
+
if (normalizedResultPath) {
|
|
721
|
+
try {
|
|
722
|
+
const normalized = JSON.parse(await readFile(normalizedResultPath, "utf8")) as Partial<NormalizedUnityTestResult>;
|
|
723
|
+
if (normalized.schemaVersion === 1 && typeof normalized.outcome === "string") normalizedSummary = `Normalized test result: ${normalized.platform ?? "Unity"} ${normalized.outcome}; ${normalized.summary?.total ?? "unknown"} total.`;
|
|
724
|
+
} catch { normalizedSummary = `Normalized test result JSON could not be parsed: ${normalizedResultPath}`; }
|
|
725
|
+
}
|
|
695
726
|
const invocation: UnityBatchmodeInvocation = {
|
|
696
727
|
isTestRun: Boolean(testResultsPath),
|
|
697
728
|
usesNoGraphics: false,
|
|
@@ -709,6 +740,8 @@ async function buildArtifactInspectionReport(
|
|
|
709
740
|
`Unity artifacts inspected for ${formatPathForUser(ctx.cwd, candidate.projectRoot)} using Unity ${candidate.unityVersion}.`,
|
|
710
741
|
testResultsPath ? `Requested test results: ${testResultsPath}` : "Requested test results: (none found)",
|
|
711
742
|
logFilePath ? `Requested log file: ${logFilePath}` : "Requested log file: (none found)",
|
|
743
|
+
normalizedResultPath ? `Requested normalized result: ${normalizedResultPath}` : "Requested normalized result: (none found)",
|
|
744
|
+
...(normalizedSummary ? [normalizedSummary] : []),
|
|
712
745
|
];
|
|
713
746
|
|
|
714
747
|
if (parsedTestResults) {
|
|
@@ -1078,6 +1111,96 @@ async function runGuardedUnityBatchmode(
|
|
|
1078
1111
|
);
|
|
1079
1112
|
}
|
|
1080
1113
|
|
|
1114
|
+
async function runUnifiedUnityTests(
|
|
1115
|
+
pi: ExtensionAPI,
|
|
1116
|
+
ctx: ExtensionContext,
|
|
1117
|
+
candidate: UnityProjectCandidate,
|
|
1118
|
+
discoveryWarning: string | undefined,
|
|
1119
|
+
raw: UnityRunTestsRequest,
|
|
1120
|
+
signal: AbortSignal | undefined,
|
|
1121
|
+
onUpdate?: (update: { content: Array<{ type: "text"; text: string }> }) => void,
|
|
1122
|
+
allowAutonomousExitPlayMode = true,
|
|
1123
|
+
): Promise<{ content: Array<{ type: "text"; text: string }>; details: UnityToolDetails & { testResult: NormalizedUnityTestResult; artifactPath: string; route: "connected" | "isolated" } }> {
|
|
1124
|
+
const request = normalizeUnityRunTestsRequest(raw);
|
|
1125
|
+
const requirements = getUnityTestRouteRequirements(request);
|
|
1126
|
+
const capabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, candidate.unityVersion, { signal, execute: createPipelineUnityCliExecutor(pi) });
|
|
1127
|
+
const reachable = capabilities.matchingInstances.some(instance => instance.reachable === true);
|
|
1128
|
+
const busy = (await listBlockingUnityProcesses(candidate.projectRoot)).processes.length > 0 || capabilities.matchingInstances.length > 0;
|
|
1129
|
+
let route: "connected" | "isolated";
|
|
1130
|
+
if (request.execution === "connected") {
|
|
1131
|
+
if (requirements.requiresIsolation) throw new Error(`Connected execution cannot honor this request: ${requirements.reasons.join("; ")}.`);
|
|
1132
|
+
if (!reachable) throw new Error("Connected execution requires an already-open exact-copy reachable Pipeline Editor; no Unity was launched.");
|
|
1133
|
+
route = "connected";
|
|
1134
|
+
} else if (request.execution === "isolated") {
|
|
1135
|
+
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.");
|
|
1136
|
+
route = "isolated";
|
|
1137
|
+
} else if (reachable) {
|
|
1138
|
+
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.`);
|
|
1139
|
+
route = "connected";
|
|
1140
|
+
} else {
|
|
1141
|
+
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.");
|
|
1142
|
+
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.");
|
|
1143
|
+
route = "isolated";
|
|
1144
|
+
}
|
|
1145
|
+
const formats = request.reportFormats ?? defaultUnityTestReportFormats(route);
|
|
1146
|
+
if (route === "connected") {
|
|
1147
|
+
const result = await runUnityPipelineTests({ projectRoot: candidate.projectRoot, unityVersion: candidate.unityVersion, 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
|
+
const counts = result.details.counts!;
|
|
1149
|
+
const normalized: NormalizedUnityTestResult = {
|
|
1150
|
+
schemaVersion: 1, source: "pipeline", platform: request.testPlatform,
|
|
1151
|
+
selection: { testFilters: request.testFilters, testCategories: request.testCategories },
|
|
1152
|
+
durationSeconds: result.details.elapsedSeconds, outcome: determineUnityTestOutcome(counts), summary: counts, tests: result.testRecords ?? [],
|
|
1153
|
+
};
|
|
1154
|
+
const artifactPath = await writeNormalizedUnityTestArtifact(candidate.projectRoot, normalized);
|
|
1155
|
+
const text = `${compactUnityTestSummary(normalized)}\nRoute: connected Pipeline. Normalized artifact: ${artifactPath}`;
|
|
1156
|
+
return { content: [{ type: "text", text }], details: { mode: "tests", projectRoot: candidate.projectRoot, unityVersion: candidate.unityVersion, editorPath: "", status: normalized.outcome === "passed" ? "passed" : "failed", pipeline: result.details, testResult: { ...normalized, tests: [] }, artifactPath, route } };
|
|
1157
|
+
}
|
|
1158
|
+
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
|
+
const plan = createUnityTestBatchPlan({ projectRoot: candidate.projectRoot, testPlatform: request.testPlatform, testFilters: request.testFilters, testCategories: request.testCategories });
|
|
1160
|
+
const cliAvailable = request.isolatedLauncher !== "editor-executable" && await canUseUnityCli(pi, signal);
|
|
1161
|
+
if (!cliAvailable && request.isolatedLauncher === "unity-cli") throw new Error("Unity CLI was requested but is unavailable.");
|
|
1162
|
+
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
|
+
if (!cliAvailable) {
|
|
1164
|
+
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");
|
|
1165
|
+
const parsed = fallback.details.parsedTestResults;
|
|
1166
|
+
const summary = { total: parsed?.total, passed: parsed?.passed, failed: parsed?.failed, skipped: parsed?.skipped };
|
|
1167
|
+
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 ?? [] };
|
|
1168
|
+
const artifactPath = await writeNormalizedUnityTestArtifact(candidate.projectRoot, normalized);
|
|
1169
|
+
return { content: [{ type: "text", text: `${compactUnityTestSummary(normalized)}\nRoute: isolated direct Editor. Normalized artifact: ${artifactPath}` }], details: { ...fallback.details, mode: "tests", testResult: { ...normalized, tests: [] }, artifactPath, route } };
|
|
1170
|
+
}
|
|
1171
|
+
return await withUnityProjectLaunchMutex(candidate.projectRoot, { mode: "batchmode", toolName: "unity_run_tests" }, async () => {
|
|
1172
|
+
const invocation = parseUnityBatchmodeInvocation(plan.args);
|
|
1173
|
+
const closeReport = await closeBlockingUnityProcessesForBatchmode(pi, ctx, candidate, invocation, request.closeBlockingUnityProcess, signal);
|
|
1174
|
+
await removeStaleLockfileAfterGuardedClose(candidate, closeReport);
|
|
1175
|
+
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: formats.includes("nunit") ? plan.testResultsPath : undefined, junit: formats.includes("junit") ? plan.junitResultsPath : undefined, log: plan.logFilePath }, coverage: request.coverage, coverageOptions: request.coverageOptions, useGraphics: request.useGraphics, timeoutSeconds: request.timeoutSeconds, editorVersion: candidate.unityVersion });
|
|
1177
|
+
const execution = await pi.exec(command.command, command.args, { signal, timeout: (request.timeoutSeconds ?? 3600) * 1000 + 30_000 });
|
|
1178
|
+
const effectiveResultsPath = deriveUnityCliEffectiveReportPath(plan.testResultsPath, { rerunFailed: request.rerunFailed, shard: request.shard });
|
|
1179
|
+
let effectiveResultsXml: string | undefined;
|
|
1180
|
+
try { effectiveResultsXml = await readFile(effectiveResultsPath, "utf8"); } catch { /* Missing current-run evidence is handled below. */ }
|
|
1181
|
+
const parsed = effectiveResultsXml ? parseUnityTestResultsXml(effectiveResultsXml) : null;
|
|
1182
|
+
const summary = { total: parsed?.total, passed: parsed?.passed, failed: parsed?.failed, skipped: parsed?.skipped };
|
|
1183
|
+
const outcome = execution.killed ? "timed_out" : execution.code === 8 ? "tests_failed" : execution.code === 6 ? "run_error" : determineUnityTestOutcome(summary);
|
|
1184
|
+
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()}` } };
|
|
1185
|
+
if (request.retries > 0 && formats.includes("nunit")) {
|
|
1186
|
+
const retryPath = effectiveResultsPath.replace(/\.[^.\\/]+$/, ".retries.json");
|
|
1187
|
+
try {
|
|
1188
|
+
const retry = parseUnityCliRetrySummary(JSON.parse(await readFile(retryPath, "utf8")));
|
|
1189
|
+
if (!retry) normalized = { ...normalized, outcome: "uncertain" };
|
|
1190
|
+
else {
|
|
1191
|
+
normalized = applyUnityCliRetrySummary(normalized, retry);
|
|
1192
|
+
normalized.backendArtifacts = { ...normalized.backendArtifacts, retrySummary: `Logs/${retryPath.split(/[\\/]/).pop()}` };
|
|
1193
|
+
}
|
|
1194
|
+
} catch {
|
|
1195
|
+
normalized = { ...normalized, outcome: "uncertain" };
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
const artifactPath = await writeNormalizedUnityTestArtifact(candidate.projectRoot, normalized);
|
|
1199
|
+
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.unityVersion, 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
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1081
1204
|
function renderUnityPipelineResult(result: any, options: { expanded: boolean; isPartial: boolean }, theme: any, context: { lastComponent?: unknown }): Text {
|
|
1082
1205
|
const details = result.details as UnityToolDetails | undefined;
|
|
1083
1206
|
const primaryText = getToolTextContent(result);
|
|
@@ -1391,6 +1514,27 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1391
1514
|
},
|
|
1392
1515
|
});
|
|
1393
1516
|
|
|
1517
|
+
pi.registerTool({
|
|
1518
|
+
name: "unity_run_tests",
|
|
1519
|
+
label: "Unity Run Tests",
|
|
1520
|
+
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.",
|
|
1521
|
+
promptSnippet: "Run Unity EditMode or PlayMode tests through one safe routed workflow with durable normalized evidence.",
|
|
1522
|
+
promptGuidelines: [
|
|
1523
|
+
"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.",
|
|
1524
|
+
"Do not use unity_launch_batchmode for ordinary tests; raw test flags there are an unsupported escape hatch.",
|
|
1525
|
+
"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.",
|
|
1526
|
+
"Timeout, malformed evidence, cancellation, or missing artifacts never cause a backend fallback or relaunch.",
|
|
1527
|
+
],
|
|
1528
|
+
parameters: RUN_TESTS_PARAMS,
|
|
1529
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1530
|
+
throwIfAborted(signal);
|
|
1531
|
+
const { candidate, discoveryWarning } = await resolveProjectCandidate(ctx, params.path);
|
|
1532
|
+
return await runUnifiedUnityTests(pi, ctx, candidate, discoveryWarning, params as UnityRunTestsRequest, signal, onUpdate, sessionAllowsAutonomousPlayModeExit(ctx));
|
|
1533
|
+
},
|
|
1534
|
+
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); },
|
|
1535
|
+
renderResult(result, { expanded }, theme) { return renderUnityToolResult(result, expanded, theme); },
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1394
1538
|
pi.registerTool({
|
|
1395
1539
|
name: "unity_pipeline_recompile",
|
|
1396
1540
|
label: "Unity Pipeline Recompile",
|
|
@@ -1418,34 +1562,6 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1418
1562
|
renderResult(result, options, theme, context) { return renderUnityPipelineResult(result, options, theme, context); },
|
|
1419
1563
|
});
|
|
1420
1564
|
|
|
1421
|
-
pi.registerTool({
|
|
1422
|
-
name: "unity_pipeline_run_tests",
|
|
1423
|
-
label: "Unity Pipeline Run Tests",
|
|
1424
|
-
description: "Run one focused EditMode or PlayMode test selection through an already-open exact Unity Pipeline Editor, with internal bounded polling and aggregate output.",
|
|
1425
|
-
promptSnippet: "Run focused connected Unity EditMode or PlayMode tests in one bounded call without shell polling; aggregate passing results stay compact.",
|
|
1426
|
-
promptGuidelines: [
|
|
1427
|
-
"Use unity_pipeline_run_tests for one focused connected Unity test platform when the exact Editor is already open and reachable.",
|
|
1428
|
-
"unity_pipeline_run_tests may exit Play Mode through advertised editor_stop when needed, then verifies Edit Mode before dispatching tests.",
|
|
1429
|
-
"Use unity_run_test_batch instead of unity_pipeline_run_tests for closed projects, isolation, complex filters/categories, or required NUnit XML/log evidence.",
|
|
1430
|
-
"unity_pipeline_run_tests does not cancel uncertain work or switch to batchmode after timeout; report that the connected run may still be running.",
|
|
1431
|
-
],
|
|
1432
|
-
parameters: PIPELINE_TEST_PARAMS,
|
|
1433
|
-
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1434
|
-
throwIfAborted(signal);
|
|
1435
|
-
const { candidate } = await resolveProjectCandidate(ctx, params.path);
|
|
1436
|
-
const result = await runUnityPipelineTests({ projectRoot: candidate.projectRoot, unityVersion: candidate.unityVersion, testPlatform: params.testPlatform, testFilter: params.testFilter, timeoutSeconds: params.timeoutSeconds, allowAutonomousExitPlayMode: sessionAllowsAutonomousPlayModeExit(ctx) }, createPipelineDependencies(pi), {
|
|
1437
|
-
signal,
|
|
1438
|
-
onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }] }),
|
|
1439
|
-
});
|
|
1440
|
-
return {
|
|
1441
|
-
content: [{ type: "text", text: result.text }],
|
|
1442
|
-
details: { mode: "pipeline", projectRoot: result.details.projectRoot, unityVersion: candidate.unityVersion, editorPath: "", status: "passed", pipeline: result.details } satisfies UnityToolDetails,
|
|
1443
|
-
};
|
|
1444
|
-
},
|
|
1445
|
-
renderCall(args, theme, context) { return renderUnityPipelineCall("unity_pipeline_run_tests", args, theme, context); },
|
|
1446
|
-
renderResult(result, options, theme, context) { return renderUnityPipelineResult(result, options, theme, context); },
|
|
1447
|
-
});
|
|
1448
|
-
|
|
1449
1565
|
pi.registerTool({
|
|
1450
1566
|
name: "unity_pipeline_eval",
|
|
1451
1567
|
label: "Unity Pipeline Eval",
|
|
@@ -1470,7 +1586,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1470
1586
|
}, {
|
|
1471
1587
|
execute: createPlanningUnityCliExecutor(pi),
|
|
1472
1588
|
signal,
|
|
1473
|
-
timeout:
|
|
1589
|
+
timeout: (params.timeoutSeconds ?? 12) * 1000,
|
|
1474
1590
|
});
|
|
1475
1591
|
throwIfAborted(signal);
|
|
1476
1592
|
const text = result.outcome === "dispatched"
|
|
@@ -1639,52 +1755,6 @@ export default function freeUnityPi(pi: ExtensionAPI) {
|
|
|
1639
1755
|
},
|
|
1640
1756
|
});
|
|
1641
1757
|
|
|
1642
|
-
pi.registerTool({
|
|
1643
|
-
name: "unity_run_test_batch",
|
|
1644
|
-
label: "Unity Test Batch",
|
|
1645
|
-
description: "Run one bundled Unity Test Framework platform with normalized filters/categories and generated absolute XML/log paths under the project Logs directory.",
|
|
1646
|
-
promptSnippet: "Run a bundled Unity EditMode or PlayMode test batch with safe generated artifact paths",
|
|
1647
|
-
promptGuidelines: [
|
|
1648
|
-
"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.",
|
|
1649
|
-
"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.",
|
|
1650
|
-
"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.",
|
|
1651
|
-
"Pass unity_run_test_batch exactly one testPlatform. Multiple test platforms require separate user-authorized launches.",
|
|
1652
|
-
"An empty unity_run_test_batch testFilters/testCategories selection runs all tests for that testPlatform; use narrow arrays when focused evidence is sufficient.",
|
|
1653
|
-
"Do not call unity_run_test_batch for PlayMode when user/project guidance says to skip PlayMode tests.",
|
|
1654
|
-
"Use unity_run_test_batch useGraphics=true only for graphics-dependent PlayMode tests or visual capture; ordinary EditMode and non-visual PlayMode remain headless.",
|
|
1655
|
-
"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.",
|
|
1656
|
-
],
|
|
1657
|
-
parameters: RUN_TEST_BATCH_PARAMS,
|
|
1658
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
1659
|
-
throwIfAborted(signal);
|
|
1660
|
-
const { candidate, discoveryWarning } = await resolveProjectCandidate(ctx, params.path);
|
|
1661
|
-
const plan = createUnityTestBatchPlan({
|
|
1662
|
-
projectRoot: candidate.projectRoot,
|
|
1663
|
-
testPlatform: params.testPlatform as UnityTestPlatform,
|
|
1664
|
-
testFilters: params.testFilters,
|
|
1665
|
-
testCategories: params.testCategories,
|
|
1666
|
-
});
|
|
1667
|
-
await mkdir(dirname(plan.testResultsPath), { recursive: true });
|
|
1668
|
-
throwIfAborted(signal);
|
|
1669
|
-
const result = await runGuardedUnityBatchmode(pi, ctx, candidate, discoveryWarning, {
|
|
1670
|
-
unityEditorPath: params.unityEditorPath,
|
|
1671
|
-
args: plan.args,
|
|
1672
|
-
useGraphics: params.useGraphics,
|
|
1673
|
-
timeoutSeconds: params.timeoutSeconds,
|
|
1674
|
-
launcher: params.launcher as UnityLauncherPreference | undefined,
|
|
1675
|
-
closeBlockingUnityProcess: params.closeBlockingUnityProcess,
|
|
1676
|
-
}, signal, "unity_run_test_batch");
|
|
1677
|
-
result.details = { ...result.details, testBatch: plan };
|
|
1678
|
-
return result;
|
|
1679
|
-
},
|
|
1680
|
-
renderCall(args, theme) {
|
|
1681
|
-
return renderUnityToolCall("unity_run_test_batch", args, theme, "batchmode", `${args.testPlatform} test batch`);
|
|
1682
|
-
},
|
|
1683
|
-
renderResult(result, { expanded }, theme) {
|
|
1684
|
-
return renderUnityToolResult(result, expanded, theme);
|
|
1685
|
-
},
|
|
1686
|
-
});
|
|
1687
|
-
|
|
1688
1758
|
pi.registerTool({
|
|
1689
1759
|
name: "unity_launch_batchmode",
|
|
1690
1760
|
label: "Unity CLI",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aefree/pi-unity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./index.ts"
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
]
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "tsx tests/unity-core.test.ts && tsx tests/unity-pipeline.test.ts && tsx tests/unity-processes.test.ts && tsx tests/pi-unity-settings.test.ts && tsx tests/unity-projects.test.ts && tsx tests/unity-guidance-audit.test.ts && tsx tests/unity-batchmode.test.ts && tsx tests/unity-test-batch.test.ts && tsx tests/unity-cli.test.ts && tsx tests/unity-project-lock.test.ts && tsx tests/unity-artifact-profile.test.ts && tsx tests/unity-file-discovery-filter.test.ts && tsx tests/unity-registration.test.ts && tsx tests/unity-optional-integrations.test.ts && tsx tests/unity-package-validation.test.ts",
|
|
21
|
+
"test": "tsx tests/unity-core.test.ts && tsx tests/unity-pipeline.test.ts && tsx tests/unity-processes.test.ts && tsx tests/pi-unity-settings.test.ts && tsx tests/unity-projects.test.ts && tsx tests/unity-guidance-audit.test.ts && tsx tests/unity-batchmode.test.ts && tsx tests/unity-test-batch.test.ts && tsx tests/unity-tests.test.ts && tsx tests/unity-cli.test.ts && tsx tests/unity-project-lock.test.ts && tsx tests/unity-artifact-profile.test.ts && tsx tests/unity-file-discovery-filter.test.ts && tsx tests/unity-registration.test.ts && tsx tests/unity-optional-integrations.test.ts && tsx tests/unity-package-validation.test.ts",
|
|
22
22
|
"eval:guidance-skill": "tsx evals/auditing-unity-agent-guidance/run-eval.ts"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
@@ -11,7 +11,7 @@ Always resolve and pass the exact Unity project-copy path. Do not route by proje
|
|
|
11
11
|
|
|
12
12
|
- Inspect: `unity_project_status`
|
|
13
13
|
- Connected compile/test: use the package's typed connected tools when available
|
|
14
|
-
- Isolated tests: `
|
|
14
|
+
- Isolated tests: `unity_run_tests`
|
|
15
15
|
- Custom isolated Editor arguments: `unity_launch_batchmode`
|
|
16
16
|
- Existing evidence: `unity_inspect_artifacts`
|
|
17
17
|
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
- Connected EditMode: `run_tests --mode editor`; asynchronous execution plus `test_status` is safest for uniform wrappers.
|
|
20
20
|
- Connected PlayMode: require `--async_tests true`, then poll `test_status` because domain reload can drop the initiating request.
|
|
21
|
-
- Isolated/report-producing: use `unity test` or the packaged `
|
|
21
|
+
- Isolated/report-producing: use `unity test` or the packaged `unity_run_tests` when NUnit XML/log artifacts are required.
|
|
22
22
|
- Preserve graphics requirements and reject zero-test, malformed, incomplete, or nested `success:false` results.
|
|
23
23
|
|
|
24
24
|
## Build and ExecuteMethod
|
|
@@ -14,7 +14,7 @@ Run Unity Test Framework tests from the command line without opening the Editor
|
|
|
14
14
|
- **Use absolute paths** for `-testResults` and `-logFile` to ensure logs are easy to find.
|
|
15
15
|
- **Unity allows only one process per project folder** - GUI Editor and batchmode/headless both count as that one process.
|
|
16
16
|
- **Do not open the GUI editor for the same project before or during batchmode runs** - `/unity-open` and `unity_open_editor` launch the full Unity Editor GUI and are not equivalent to headless batchmode.
|
|
17
|
-
- **Do not close a reachable Pipeline Editor merely to run tests** -
|
|
17
|
+
- **Do not close a reachable Pipeline Editor merely to run tests** - call `unity_run_tests`; it selects connected Pipeline for supported requests and rejects isolated-only options rather than closing the Editor.
|
|
18
18
|
- **Only close a blocking Unity Editor through `unity_launch_batchmode` safeguards after deliberately choosing isolated execution** - this is limited to cases such as required NUnit XML, unsupported connected filters/commands, or explicit isolation. Use `closeBlockingUnityProcess: true` only when `unity_project_status` shows `piUnity.allowCloseRunningUnityProcess` is enabled or the user explicitly says it is enabled; pi-unity re-scans the resolved project and never accepts arbitrary PIDs.
|
|
19
19
|
- **Use `unity_launch_batchmode` when you want to run headless Unity directly** - keep test-specific flags deliberate, especially around `-runTests` and `-quit`.
|
|
20
20
|
- **Bundle tests into one Unity batchmode turn whenever practical** - starting/stopping Unity, importing assets, and domain reloads dominate runtime. A broader single run is usually faster than many sequential one-test Unity launches, and same-project runs cannot use useful parallelism.
|
|
@@ -40,12 +40,12 @@ Run Unity Test Framework tests from the command line without opening the Editor
|
|
|
40
40
|
Use the `pi-unity` tools first instead of forming raw Unity CLI commands on the fly:
|
|
41
41
|
- `unity_project_status` to inspect lockfile/process state without launching Unity
|
|
42
42
|
- `unity_inspect_artifacts` to summarize existing Unity logs/test XML without launching Unity
|
|
43
|
-
- `
|
|
43
|
+
- `unity_run_tests` for isolated/report-producing Unity Test Framework runs with one platform and bundled filters/categories
|
|
44
44
|
- `unity_launch_batchmode` for custom headless Unity execution that needs raw Editor arguments
|
|
45
45
|
- `unity_open_editor` only when the user explicitly wants the GUI Editor
|
|
46
46
|
- `/unity-open` as the user-facing GUI launcher helper
|
|
47
47
|
|
|
48
|
-
`
|
|
48
|
+
`unity_run_tests` is the one ordinary Unity Test Framework tool. It routes supported requests to a reachable exact-copy Pipeline Editor or a closed-project isolated `unity test` run, writes a durable normalized JSON artifact, and retains native NUnit/JUnit reports when requested. It never silently switches after uncertain dispatch.
|
|
49
49
|
|
|
50
50
|
`unity_launch_batchmode` remains the default for custom agent-run headless Unity work because it already:
|
|
51
51
|
- resolves the Unity project from a direct project root, a coordination root, or another nearby folder
|
|
@@ -98,8 +98,8 @@ If not found, ask the user for their Unity install path.
|
|
|
98
98
|
|
|
99
99
|
Route before planning a batch:
|
|
100
100
|
1. Call `unity_project_status` for the exact project copy.
|
|
101
|
-
2. If that copy is already open, Pipeline is reachable, and `run_tests` plus `test_status` are advertised, use `unity-pipeline-workflows`; do not close the Editor or invoke `
|
|
102
|
-
3. Choose isolated `
|
|
101
|
+
2. If that copy is already open, Pipeline is reachable, and `run_tests` plus `test_status` are advertised, use `unity-pipeline-workflows`; do not close the Editor or invoke `unity_run_tests` merely because the test tool is more convenient.
|
|
102
|
+
3. Choose isolated `unity_run_tests` only when the Editor is closed, connected testing is unavailable, CI/isolation is intentional, required filters are unsupported, or NUnit XML/log artifacts are required. State the reason.
|
|
103
103
|
4. If project state is uncertain, stop rather than closing the Editor or starting batchmode.
|
|
104
104
|
|
|
105
105
|
After choosing the isolated route:
|
|
@@ -108,7 +108,7 @@ After choosing the isolated route:
|
|
|
108
108
|
- when several specific tests are relevant, prefer a broader class/namespace/suite/category filter, or no `-testFilter` for the affected platform, over separate Unity launches
|
|
109
109
|
- use a single-test launch only for a quick smoke check or to isolate/rerun a known failure; do not use one-test launches as the default validation strategy
|
|
110
110
|
- do not queue multiple `unity_launch_batchmode` calls back-to-back in one agent turn; wait for the structured summary, inspect failures, and only then decide whether another Unity launch is necessary
|
|
111
|
-
- call `
|
|
111
|
+
- call `unity_run_tests` for isolated/report-producing tests; use `unity_launch_batchmode` only when custom raw Unity arguments are required
|
|
112
112
|
- pass one `testPlatform` (`EditMode` or `PlayMode`) and bundle applicable `testFilters`/`testCategories`; empty arrays mean all tests on that platform
|
|
113
113
|
- use the generated exact result/log paths from the tool report for any follow-up artifact inspection
|
|
114
114
|
- pass `closeBlockingUnityProcess: true` only after connected testing was ruled out or isolated evidence was explicitly required, a same-project Unity process is blocking the chosen run, and Pi settings enable `piUnity.allowCloseRunningUnityProcess`
|
|
@@ -12,19 +12,19 @@ Use this workflow only for an already-running exact project copy with `com.unity
|
|
|
12
12
|
Use one typed tool call for each supported connected operation:
|
|
13
13
|
|
|
14
14
|
- `unity_pipeline_recompile` for connected script compilation.
|
|
15
|
-
- `
|
|
15
|
+
- `unity_run_tests` for one compatible `EditMode` or `PlayMode` test-name or category selection.
|
|
16
16
|
|
|
17
17
|
These tools resolve the exact copy, require advertised commands, inspect lifecycle state, dispatch once, validate identity, and poll internally with a fixed deadline. Do not recreate their wait loops with `bash`, `unity recompile_status`, or `unity test_status` calls.
|
|
18
18
|
|
|
19
|
-
A timeout or malformed response is uncertain: the Unity operation may still be running. Do not cancel, retry, launch batchmode, close the Editor, or claim a result without a new user-authorized decision.
|
|
19
|
+
A timeout or malformed response is uncertain: the Unity operation may still be running. Do not cancel, retry, launch batchmode, close the Editor, or claim a result without a new user-authorized decision. The only automatic retry is Pipeline 0.5's explicit initial-settling `Server Busy` response for `unity_pipeline_recompile` or `unity_run_tests`, which confirms that a main-thread command was rejected before dispatch.
|
|
20
20
|
|
|
21
21
|
## Preconditions and boundaries
|
|
22
22
|
|
|
23
23
|
1. Pass an explicit `path` when multiple project copies may be found; paths identify copies, not display names.
|
|
24
24
|
2. The typed tools require a reachable exact-copy Pipeline and advertised `editor_status` plus operation commands. A different connected client is not itself a project lock.
|
|
25
|
-
3. `unity_pipeline_recompile` never sends `editor_stop` or overrides Unity's Script Changes While Playing preference. Known recompile-and-continue, stop-and-recompile, and defer policies proceed according to Unity's configured behavior. Pipeline 0.4 does not currently expose that preference, so the tool reports the unavailable policy while allowing recompilation to proceed. `
|
|
25
|
+
3. `unity_pipeline_recompile` never sends `editor_stop` or overrides Unity's Script Changes While Playing preference. Known recompile-and-continue, stop-and-recompile, and defer policies proceed according to Unity's configured behavior. Pipeline 0.4 does not currently expose that preference, so the tool reports the unavailable policy while allowing recompilation to proceed. `unity_run_tests` may dispatch advertised `editor_stop` when needed, then verifies Edit Mode before running tests. The tools never enter Play Mode, pause, save, launch, or close Unity; recompilation may perform Unity's normal asset refresh/import and script-change behavior.
|
|
26
26
|
4. Test success requires a well-formed terminal result, a known positive executed count, and zero failures. An asynchronous initiation with `Total: 0` and `running` is nonterminal.
|
|
27
|
-
5.
|
|
27
|
+
5. Routine tool output remains compact. Complete bounded terminal test records are persisted immediately in the durable normalized JSON artifact before Pipeline status can be displaced.
|
|
28
28
|
|
|
29
29
|
## Compile
|
|
30
30
|
|
|
@@ -32,11 +32,11 @@ Call `unity_pipeline_recompile` with optional `path` and `timeoutSeconds` (defau
|
|
|
32
32
|
|
|
33
33
|
## Focused tests
|
|
34
34
|
|
|
35
|
-
Call `
|
|
35
|
+
Call `unity_run_tests` with:
|
|
36
36
|
|
|
37
37
|
- required `testPlatform`: `EditMode` or `PlayMode`;
|
|
38
|
-
- optional `
|
|
39
|
-
- optional `path
|
|
38
|
+
- optional `testFilters` or `testCategories`: at most one selector family and one selector for connected execution;
|
|
39
|
+
- optional `execution`, `path`, and `timeoutSeconds` (default 600, maximum 3600).
|
|
40
40
|
- before running PlayMode tests, check the Game View focus setting. Set it to Play Unfocused for the test run, then restore the previous setting afterward.
|
|
41
41
|
|
|
42
42
|
The tool treats `no_tests`, idle, and not-started statuses as safe inactivity, detects a pre-existing active connected test before dispatch, and stops rather than claiming or replacing active work. It captures returned mode/filter/run identity fields when available and stops as uncertain if status is clearly displaced by another run.
|
|
@@ -47,6 +47,6 @@ Normally call the typed tools, not raw CLI commands. If a typed tool is unavaila
|
|
|
47
47
|
|
|
48
48
|
## When not to use connected tools
|
|
49
49
|
|
|
50
|
-
Use `
|
|
50
|
+
Use `unity_run_tests` with `execution: "isolated"` for a closed project, intentional isolation/CI, multiple selectors, retries, sharding, coverage, or required NUnit/JUnit evidence. A single category is supported connected when Pipeline advertises the command; do not claim broader connected selector support. Do not use batchmode as an automatic fallback after an uncertain connected dispatch.
|
|
51
51
|
|
|
52
|
-
Use the typed compile/test tools when their polling and terminal evidence fit the task. Advertised Pipeline `eval` remains available through `unity_pipeline_eval` for bounded project-specific inspection or operations outside those typed workflows;
|
|
52
|
+
Use the typed compile/test tools when their polling and terminal evidence fit the task. Advertised Pipeline `eval` remains available through `unity_pipeline_eval` for bounded project-specific inspection or operations outside those typed workflows; its `timeoutSeconds` range is 1–86,400 seconds, and a timeout remains uncertain without cancellation or retry. It is an assistance surface, not a forbidden fallback or a substitute for the typed tools' completion protocol. Eval compiles arbitrary C# with Roslyn on the Editor main thread, so ordinary properties and local-variable snippets are valid; it is not expression-only or reliably statically read-only. Prefer typed tools for their stronger evidence, but let user intent and project guidance govern mutations. Lifecycle, persistent-setting, destructive, asset, scene-save, package, build, and test mutations require explicit authorization.
|
package/src/unity-batchmode.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type UnityFailedTest = {
|
|
|
18
18
|
stackTrace?: string;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
export type UnityParsedTestCase = { name: string; status: string; durationSeconds?: number; message?: string; stackTrace?: string };
|
|
21
22
|
export type UnityParsedTestResults = {
|
|
22
23
|
total?: number;
|
|
23
24
|
passed?: number;
|
|
@@ -26,6 +27,8 @@ export type UnityParsedTestResults = {
|
|
|
26
27
|
inconclusive?: number;
|
|
27
28
|
durationSeconds?: number;
|
|
28
29
|
failedTests: UnityFailedTest[];
|
|
30
|
+
/** Complete bounded per-test evidence for normalized artifacts, never routine tool output. */
|
|
31
|
+
tests: UnityParsedTestCase[];
|
|
29
32
|
};
|
|
30
33
|
|
|
31
34
|
export type UnityBatchmodeArtifacts = {
|
|
@@ -108,6 +111,7 @@ export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults |
|
|
|
108
111
|
|
|
109
112
|
const rootAttributes = parseAttributes(testRunMatch[1] ?? "");
|
|
110
113
|
const failedTests: UnityFailedTest[] = [];
|
|
114
|
+
const tests: UnityParsedTestCase[] = [];
|
|
111
115
|
|
|
112
116
|
const testCaseRegex = /<test-case\b([^>]*)>([\s\S]*?)<\/test-case>/gi;
|
|
113
117
|
for (const match of xml.matchAll(testCaseRegex)) {
|
|
@@ -116,17 +120,12 @@ export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults |
|
|
|
116
120
|
const result = String(attributes.result ?? attributes.label ?? "").toLowerCase();
|
|
117
121
|
const success = String(attributes.success ?? "").toLowerCase();
|
|
118
122
|
const isFailure = result === "failed" || success === "false";
|
|
119
|
-
if (!isFailure) continue;
|
|
120
|
-
|
|
121
123
|
const failureMessage = body.match(/<message[^>]*>([\s\S]*?)<\/message>/i);
|
|
122
124
|
const stackTrace = body.match(/<stack-trace[^>]*>([\s\S]*?)<\/stack-trace>/i);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
stackTrace: truncateEvidence(decodeXmlText(stackTrace?.[1]), 4_000),
|
|
128
|
-
});
|
|
129
|
-
}
|
|
125
|
+
const name = truncateEvidence(attributes.fullname ?? attributes.name ?? "(unknown test)", 1_000) ?? "(unknown test)";
|
|
126
|
+
if (tests.length < 2_000) tests.push({ name, status: attributes.result ?? attributes.label ?? "Unknown", ...(parseOptionalNumber(attributes.duration) === undefined ? {} : { durationSeconds: parseOptionalNumber(attributes.duration) }), ...(truncateEvidence(decodeXmlText(failureMessage?.[1]), 4_000) ? { message: truncateEvidence(decodeXmlText(failureMessage?.[1]), 4_000) } : {}), ...(truncateEvidence(decodeXmlText(stackTrace?.[1]), 8_000) ? { stackTrace: truncateEvidence(decodeXmlText(stackTrace?.[1]), 8_000) } : {}) });
|
|
127
|
+
if (!isFailure) continue;
|
|
128
|
+
if (failedTests.length < 50) failedTests.push({ name, message: truncateEvidence(decodeXmlText(failureMessage?.[1]), 1_000), stackTrace: truncateEvidence(decodeXmlText(stackTrace?.[1]), 4_000) });
|
|
130
129
|
}
|
|
131
130
|
|
|
132
131
|
const skipped = parseOptionalNumber(rootAttributes.skipped) ?? parseOptionalNumber(rootAttributes.inconclusive);
|
|
@@ -139,6 +138,7 @@ export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults |
|
|
|
139
138
|
inconclusive: parseOptionalNumber(rootAttributes.inconclusive),
|
|
140
139
|
durationSeconds: parseOptionalNumber(rootAttributes.duration),
|
|
141
140
|
failedTests,
|
|
141
|
+
tests,
|
|
142
142
|
};
|
|
143
143
|
if (parsed.total === undefined && parsed.passed === undefined && parsed.failed === undefined && parsed.failedTests.length === 0) {
|
|
144
144
|
return null;
|
package/src/unity-cli.ts
CHANGED
|
@@ -22,6 +22,19 @@ export type UnityCliLaunchOptions = {
|
|
|
22
22
|
automated?: boolean;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
export type UnityCliTestOptions = UnityCliLaunchOptions & {
|
|
26
|
+
testPlatform: "EditMode" | "PlayMode";
|
|
27
|
+
testFilters?: string[];
|
|
28
|
+
testCategories?: string[];
|
|
29
|
+
retries?: number;
|
|
30
|
+
rerunFailed?: boolean;
|
|
31
|
+
shard?: string;
|
|
32
|
+
shardInventoryPath?: string;
|
|
33
|
+
reportPaths?: { nunit?: string; junit?: string; log?: string };
|
|
34
|
+
coverage?: boolean;
|
|
35
|
+
coverageOptions?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
25
38
|
export type UnityCliPipelineInstance = {
|
|
26
39
|
projectPath: string;
|
|
27
40
|
pid: number | null;
|
|
@@ -116,6 +129,30 @@ export function normalizeUnityCliForwardedArgs(extraEditorArgs: string[] = []):
|
|
|
116
129
|
return normalized;
|
|
117
130
|
}
|
|
118
131
|
|
|
132
|
+
export function createUnityCliTestCommand(projectRoot: string, options: UnityCliTestOptions): UnityCliCommand {
|
|
133
|
+
const args = [...unityCliBaseArgs(), "test", projectRoot, "--mode", options.testPlatform];
|
|
134
|
+
appendUnityCliEditorOptions(args, options);
|
|
135
|
+
if (options.timeoutSeconds !== undefined) args.push("--timeout", String(options.timeoutSeconds));
|
|
136
|
+
if (options.testFilters?.length) args.push("--filter", options.testFilters.join(";"));
|
|
137
|
+
if (options.retries) args.push("--retries", String(options.retries));
|
|
138
|
+
if (options.rerunFailed) args.push("--rerun-failed");
|
|
139
|
+
if (options.shard) args.push("--shard", options.shard);
|
|
140
|
+
if (options.shardInventoryPath) args.push("--shard-inventory", options.shardInventoryPath);
|
|
141
|
+
const nunit = options.reportPaths?.nunit;
|
|
142
|
+
const junit = options.reportPaths?.junit;
|
|
143
|
+
if (nunit && junit) args.push("--output", nunit, "--report-format", "nunit,junit", "--junit-output", junit);
|
|
144
|
+
else if (junit) args.push("--output", junit, "--report-format", "junit");
|
|
145
|
+
else if (nunit) args.push("--output", nunit);
|
|
146
|
+
if (options.coverage) args.push("--coverage");
|
|
147
|
+
if (options.coverageOptions) args.push("--coverage-options", options.coverageOptions);
|
|
148
|
+
const editorArgs: string[] = [];
|
|
149
|
+
if (!options.useGraphics) editorArgs.push("-nographics");
|
|
150
|
+
if (options.testCategories?.length) editorArgs.push("-testCategory", options.testCategories.join(";"));
|
|
151
|
+
if (options.reportPaths?.log) editorArgs.push("-logFile", options.reportPaths.log);
|
|
152
|
+
if (editorArgs.length > 0) args.push("--", ...editorArgs);
|
|
153
|
+
return { command: resolveUnityCliCommand(options), args };
|
|
154
|
+
}
|
|
155
|
+
|
|
119
156
|
export function createUnityCliRunCommand(projectRoot: string, extraEditorArgs: string[] = [], options: UnityCliLaunchOptions = {}): UnityCliCommand {
|
|
120
157
|
const args = [...unityCliBaseArgs(), "run", projectRoot];
|
|
121
158
|
const forwardedArgs = normalizeUnityCliForwardedArgs(applyDefaultUnityBatchmodeArgs(extraEditorArgs, { useGraphics: options.useGraphics }));
|
package/src/unity-pipeline.ts
CHANGED
|
@@ -11,7 +11,7 @@ export const UNITY_PIPELINE_MAX_DIAGNOSTICS = 8;
|
|
|
11
11
|
export const UNITY_PIPELINE_MAX_STACK_CHARS = 600;
|
|
12
12
|
|
|
13
13
|
export type UnityPipelineCompileRequest = { projectRoot: string; unityVersion: string; timeoutSeconds?: number; allowAutonomousExitPlayMode?: boolean };
|
|
14
|
-
export type UnityPipelineTestRequest = { projectRoot: string; unityVersion: string; testPlatform: "EditMode" | "PlayMode"; testFilter?: string; timeoutSeconds?: number; allowAutonomousExitPlayMode?: boolean };
|
|
14
|
+
export type UnityPipelineTestRequest = { projectRoot: string; unityVersion: string; testPlatform: "EditMode" | "PlayMode"; testFilter?: string; testCategory?: string; timeoutSeconds?: number; allowAutonomousExitPlayMode?: boolean };
|
|
15
15
|
export type UnityPipelineProgress = (message: string) => void;
|
|
16
16
|
/** Unity's EditorSettings.ScriptChangesWhilePlaying values when a future editor_status payload supplies one. */
|
|
17
17
|
export type UnityScriptChangesWhilePlayingPolicy = "recompile_and_continue" | "stop_and_recompile" | "defer" | "unknown";
|
|
@@ -32,7 +32,9 @@ export type UnityPipelineOperationDetails = {
|
|
|
32
32
|
testFilter?: string;
|
|
33
33
|
counts?: { total: number; passed?: number; failed: number; inconclusive?: number };
|
|
34
34
|
};
|
|
35
|
-
export type
|
|
35
|
+
export type UnityPipelineTestRecord = { name: string; status: string; durationSeconds?: number; message?: string; stackTrace?: string };
|
|
36
|
+
/** testRecords are terminal evidence for the caller's durable artifact only; do not expose them in tool details. */
|
|
37
|
+
export type UnityPipelineOperationResult = { text: string; details: UnityPipelineOperationDetails; testRecords?: UnityPipelineTestRecord[] };
|
|
36
38
|
|
|
37
39
|
type RecordValue = Record<string, unknown>;
|
|
38
40
|
type ParsedEnvelope = { result: RecordValue; outerSuccess: boolean; malformed?: string };
|
|
@@ -41,6 +43,7 @@ type NormalizedTest = {
|
|
|
41
43
|
state: "inactive" | "starting" | "running" | "completed" | "failed" | "cancelled" | "uncertain";
|
|
42
44
|
total?: number; passed?: number; failed?: number; inconclusive?: number; failures: string[];
|
|
43
45
|
correlation: Record<string, string>;
|
|
46
|
+
testRecords?: UnityPipelineTestRecord[];
|
|
44
47
|
};
|
|
45
48
|
|
|
46
49
|
type PipelineDependencies = {
|
|
@@ -145,6 +148,17 @@ function diagnostics(result: RecordValue): string[] {
|
|
|
145
148
|
});
|
|
146
149
|
return [...new Set(values)].slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS);
|
|
147
150
|
}
|
|
151
|
+
/** True only for Pipeline 0.5's explicit rejected outer envelope, before a main-thread command is dispatched. */
|
|
152
|
+
export function isUnityPipelineInitialSettlingBusy(output: string): boolean {
|
|
153
|
+
let outer: RecordValue | undefined;
|
|
154
|
+
try { outer = record(JSON.parse(output)); } catch { return false; }
|
|
155
|
+
const data = record(outer?.data);
|
|
156
|
+
return outer?.success === false
|
|
157
|
+
&& string(field(data ?? {}, "error"))?.toLowerCase() === "server busy"
|
|
158
|
+
&& statusOf(data ?? {}) === "busy"
|
|
159
|
+
&& field(data ?? {}, "retryable") === true;
|
|
160
|
+
}
|
|
161
|
+
|
|
148
162
|
export function normalizeUnityPipelineCompile(output: string): NormalizedCompile {
|
|
149
163
|
const parsed = parseUnityPipelineEnvelope(output);
|
|
150
164
|
if (parsed.malformed) return { state: "uncertain", diagnostics: [], failed: false };
|
|
@@ -161,6 +175,24 @@ function summary(result: RecordValue): RecordValue | undefined {
|
|
|
161
175
|
walk(result, item => { if (!found && record(field(item, "summary"))) found = record(field(item, "summary")); });
|
|
162
176
|
return found;
|
|
163
177
|
}
|
|
178
|
+
function testRecords(result: RecordValue): UnityPipelineTestRecord[] {
|
|
179
|
+
const values: UnityPipelineTestRecord[] = [];
|
|
180
|
+
walk(result, item => {
|
|
181
|
+
for (const key of ["tests", "results", "testresults"]) {
|
|
182
|
+
const entries = field(item, key);
|
|
183
|
+
if (!Array.isArray(entries)) continue;
|
|
184
|
+
for (const entry of entries.slice(0, 2_000)) {
|
|
185
|
+
const test = record(entry); if (!test) continue;
|
|
186
|
+
const name = string(field(test, "name", "fullname", "testname"));
|
|
187
|
+
const status = string(field(test, "result", "status", "outcome"));
|
|
188
|
+
if (!name || !status) continue;
|
|
189
|
+
const durationSeconds = number(field(test, "duration", "durationseconds", "time"));
|
|
190
|
+
values.push({ name: bounded(name, 1_000), status: bounded(status, 100), ...(durationSeconds === undefined ? {} : { durationSeconds }), ...(string(field(test, "message", "error", "failuremessage")) ? { message: bounded(string(field(test, "message", "error", "failuremessage"))!, 4_000) } : {}), ...(string(field(test, "stacktrace", "stack", "trace")) ? { stackTrace: bounded(string(field(test, "stacktrace", "stack", "trace"))!, 8_000) } : {}) });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
return values.slice(0, 2_000);
|
|
195
|
+
}
|
|
164
196
|
function testFailures(result: RecordValue): string[] {
|
|
165
197
|
const values: string[] = [];
|
|
166
198
|
walk(result, item => {
|
|
@@ -212,7 +244,7 @@ export function normalizeUnityPipelineTest(output: string): NormalizedTest {
|
|
|
212
244
|
: raw === "no_tests" || raw === "idle" || raw === "not_started" || raw === "not_running" ? "inactive"
|
|
213
245
|
: raw === "running" ? "running" : raw === "starting" || raw === "queued" ? "starting"
|
|
214
246
|
: raw === "completed" || raw === "complete" || raw === "success" ? "completed" : "uncertain";
|
|
215
|
-
return { state, total, passed, failed: failedCount, inconclusive, failures: testFailures(parsed.result), correlation: correlation(parsed.result) };
|
|
247
|
+
return { state, total, passed, failed: failedCount, inconclusive, failures: testFailures(parsed.result), correlation: correlation(parsed.result), testRecords: testRecords(parsed.result) };
|
|
216
248
|
}
|
|
217
249
|
|
|
218
250
|
function editorStopSucceeded(output: string): boolean {
|
|
@@ -316,6 +348,19 @@ async function executeCommand(deps: PipelineDependencies, projectRoot: string, c
|
|
|
316
348
|
if (deadline !== undefined) ensureBeforeDeadline(deadline, now, command);
|
|
317
349
|
return result;
|
|
318
350
|
}
|
|
351
|
+
async function dispatchMainThreadCommand(deps: PipelineDependencies, projectRoot: string, command: "recompile" | "run_tests", args: string[], operation: string, signal: AbortSignal | undefined, deadline: number, now: () => number, sleep: (milliseconds: number, signal?: AbortSignal) => Promise<void>): Promise<UnityCliExecResult> {
|
|
352
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
353
|
+
const response = await executeCommand(deps, projectRoot, command, args, signal, deadline, now);
|
|
354
|
+
if (!isUnityPipelineInitialSettlingBusy(response.stdout)) return response;
|
|
355
|
+
const remaining = deadline - now();
|
|
356
|
+
if (remaining <= 0) throw new Error(`Unity Pipeline server remained busy while settling; ${operation} was not started before the deadline.`);
|
|
357
|
+
const delay = Math.min(UNITY_PIPELINE_BACKOFF_SECONDS[Math.min(attempt, UNITY_PIPELINE_BACKOFF_SECONDS.length - 1)]! * 1000, remaining);
|
|
358
|
+
await sleep(delay, signal);
|
|
359
|
+
throwIfAborted(signal);
|
|
360
|
+
if (now() >= deadline) throw new Error(`Unity Pipeline server remained busy while settling; ${operation} was not started before the deadline.`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
319
364
|
async function requirePreflight(deps: PipelineDependencies, projectRoot: string, unityVersion: string, commands: string[], operation: "recompile" | "tests", signal: AbortSignal | undefined, deadline: number, now: () => number, allowAutonomousExitPlayMode = true): Promise<{ capabilities: UnityCliProjectCapabilities; exitedPlayMode: boolean; playModeHandling: UnityPipelinePlayModeHandling; scriptChangesWhilePlaying?: UnityScriptChangesWhilePlayingPolicy }> {
|
|
320
365
|
const capabilities = await inspectWithDeadline(deps, projectRoot, unityVersion, signal, deadline, now, "preflight");
|
|
321
366
|
const error = capabilityError(capabilities, commands); if (error) throw new Error(error);
|
|
@@ -404,7 +449,7 @@ export async function runUnityPipelineRecompile(request: UnityPipelineCompileReq
|
|
|
404
449
|
const lifecyclePrefix = playModeOutcomeText(preflight);
|
|
405
450
|
ensureBeforeDeadline(deadline, now, "recompile before dispatch");
|
|
406
451
|
throwIfAborted(signal);
|
|
407
|
-
const dispatched = await
|
|
452
|
+
const dispatched = await dispatchMainThreadCommand(deps, projectRoot, "recompile", [], "recompile", signal, deadline, now, sleep);
|
|
408
453
|
if (dispatched.error) throw new Error("Unity Pipeline recompile dispatch failed; operation may not have started.");
|
|
409
454
|
let state = normalizeUnityPipelineCompile(dispatched.stdout);
|
|
410
455
|
if (state.state === "uncertain") throw new Error("Unity Pipeline recompile dispatch returned malformed or uncertain evidence; operation may have started.");
|
|
@@ -421,6 +466,7 @@ export async function runUnityPipelineRecompile(request: UnityPipelineCompileReq
|
|
|
421
466
|
if (identityState === "temporary_disconnect") continue;
|
|
422
467
|
const response = await executeCommand(deps, projectRoot, "recompile_status", [], signal, deadline, now);
|
|
423
468
|
if (response.error) continue; // Domain reload can briefly disconnect the same exact copy.
|
|
469
|
+
if (isUnityPipelineInitialSettlingBusy(response.stdout)) continue;
|
|
424
470
|
state = normalizeUnityPipelineCompile(response.stdout);
|
|
425
471
|
if (state.state === "failed") throw new Error(`Unity recompile failed: ${state.diagnostics.join("; ") || "compiler failure reported"}`);
|
|
426
472
|
if (state.state === "completed" || state.state === "up_to_date") return { text: `${lifecyclePrefix}Unity recompile completed for ${projectRoot} in ${elapsed(start, now).toFixed(1)}s; 0 compiler errors.`, details: { projectRoot, operation: "recompile", terminalState: state.state, elapsedSeconds: elapsed(start, now), compilationTriggered: true, ...playModeDetails(preflight) } };
|
|
@@ -446,9 +492,12 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
|
|
|
446
492
|
const observed = statusOf(parseUnityPipelineEnvelope(before.stdout).result) ?? "unknown";
|
|
447
493
|
throw new Error(`Unity Pipeline returned unsupported preflight test status '${bounded(observed, 80)}'; test run not started.`);
|
|
448
494
|
}
|
|
449
|
-
|
|
495
|
+
if (request.testFilter && request.testCategory) throw new Error("Connected Pipeline cannot combine a test-name filter and category in one run; operation not started.");
|
|
496
|
+
const selectorArgs = request.testFilter ? ["--filter", request.testFilter, "--filter_type", "testName"]
|
|
497
|
+
: request.testCategory ? ["--filter", request.testCategory, "--filter_type", "category"] : [];
|
|
498
|
+
const args = ["--mode", request.testPlatform === "EditMode" ? "editor" : "playmode", ...selectorArgs, "--async_tests", "true"];
|
|
450
499
|
ensureBeforeDeadline(deadline, now, "tests before dispatch"); throwIfAborted(signal);
|
|
451
|
-
const dispatched = await
|
|
500
|
+
const dispatched = await dispatchMainThreadCommand(deps, projectRoot, "run_tests", args, "tests", signal, deadline, now, sleep);
|
|
452
501
|
if (dispatched.error) throw new Error("Unity Pipeline test dispatch failed; test run may not have started.");
|
|
453
502
|
let state = normalizeUnityPipelineTest(dispatched.stdout);
|
|
454
503
|
if (state.state === "uncertain" || state.state === "inactive") throw new Error("Unity Pipeline test dispatch returned inactive, malformed, or uncertain evidence; test run may not have started.");
|
|
@@ -460,7 +509,7 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
|
|
|
460
509
|
if (state.state === "completed") {
|
|
461
510
|
const counts = passingCounts(state);
|
|
462
511
|
if (!counts) throw new Error("Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).");
|
|
463
|
-
return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter, counts } };
|
|
512
|
+
return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter ?? request.testCategory, counts }, testRecords: state.testRecords };
|
|
464
513
|
}
|
|
465
514
|
for (let poll = 0; now() < deadline; poll += 1) {
|
|
466
515
|
options.onUpdate?.(`Unity ${request.testPlatform} tests ${state.state}; ${elapsed(start, now).toFixed(1)}s elapsed.`);
|
|
@@ -473,6 +522,7 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
|
|
|
473
522
|
if (identityState === "temporary_disconnect") continue;
|
|
474
523
|
const response = await executeCommand(deps, projectRoot, "test_status", [], signal, deadline, now);
|
|
475
524
|
if (response.error) continue;
|
|
525
|
+
if (isUnityPipelineInitialSettlingBusy(response.stdout)) continue;
|
|
476
526
|
state = normalizeUnityPipelineTest(response.stdout);
|
|
477
527
|
if (!checkCorrelation(expected, state.correlation)) throw new Error("Unity Pipeline test status was displaced by a different run; operation state is uncertain.");
|
|
478
528
|
if (state.state === "failed" || state.state === "cancelled") throw new Error(`Unity ${request.testPlatform} tests failed: ${state.failures.join("; ") || state.state}.`);
|
|
@@ -481,7 +531,7 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
|
|
|
481
531
|
if (state.state !== "completed") continue;
|
|
482
532
|
const counts = passingCounts(state);
|
|
483
533
|
if (!counts) throw new Error("Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).");
|
|
484
|
-
return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter, counts } };
|
|
534
|
+
return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter ?? request.testCategory, counts }, testRecords: state.testRecords };
|
|
485
535
|
}
|
|
486
536
|
throw timeoutMessage("tests");
|
|
487
537
|
}
|
package/src/unity-test-batch.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type UnityTestBatchPlan = {
|
|
|
18
18
|
testFilters: string[];
|
|
19
19
|
testCategories: string[];
|
|
20
20
|
testResultsPath: string;
|
|
21
|
+
junitResultsPath: string;
|
|
21
22
|
logFilePath: string;
|
|
22
23
|
args: string[];
|
|
23
24
|
};
|
|
@@ -59,6 +60,7 @@ export function createUnityTestBatchPlan(input: UnityTestBatchPlanInput): UnityT
|
|
|
59
60
|
const platformSlug = input.testPlatform.toLowerCase();
|
|
60
61
|
const basename = `unity-tests-${platformSlug}-${safeTimestamp(input.now ?? new Date())}-${token}`;
|
|
61
62
|
const testResultsPath = pathApi.join(logsRoot, `${basename}.xml`);
|
|
63
|
+
const junitResultsPath = pathApi.join(logsRoot, `${basename}.junit.xml`);
|
|
62
64
|
const logFilePath = pathApi.join(logsRoot, `${basename}.log`);
|
|
63
65
|
const args = ["-runTests", "-testPlatform", input.testPlatform];
|
|
64
66
|
if (testFilters.length > 0) args.push("-testFilter", testFilters.join(";"));
|
|
@@ -78,5 +80,5 @@ export function createUnityTestBatchPlan(input: UnityTestBatchPlanInput): UnityT
|
|
|
78
80
|
throw new Error("Unity test batch arguments must not contain -quit.");
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
return { testPlatform: input.testPlatform, testFilters, testCategories, testResultsPath, logFilePath, args };
|
|
83
|
+
return { testPlatform: input.testPlatform, testFilters, testCategories, testResultsPath, junitResultsPath, logFilePath, args };
|
|
82
84
|
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, link, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const UNITY_TEST_RESULT_SCHEMA_VERSION = 1;
|
|
6
|
+
export const UNITY_TEST_MAX_MESSAGE_CHARS = 4_000;
|
|
7
|
+
export const UNITY_TEST_MAX_STACK_CHARS = 8_000;
|
|
8
|
+
export const UNITY_TEST_MAX_TESTS = 2_000;
|
|
9
|
+
export const UNITY_TEST_MAX_ARTIFACT_BYTES = 2_000_000;
|
|
10
|
+
|
|
11
|
+
export type UnityTestPlatform = "EditMode" | "PlayMode";
|
|
12
|
+
export type UnityTestExecution = "auto" | "connected" | "isolated";
|
|
13
|
+
export type UnityTestIsolatedLauncher = "auto" | "unity-cli" | "editor-executable";
|
|
14
|
+
export type UnityTestReportFormat = "json" | "nunit" | "junit";
|
|
15
|
+
export type NormalizedUnityTestOutcome = "passed" | "passed_with_flakes" | "tests_failed" | "empty_selection" | "run_error" | "timed_out" | "cancelled" | "uncertain";
|
|
16
|
+
export type UnityTestSource = "pipeline" | "unity-cli" | "editor-executable";
|
|
17
|
+
|
|
18
|
+
export type UnityRunTestsRequest = {
|
|
19
|
+
path?: string;
|
|
20
|
+
testPlatform: UnityTestPlatform;
|
|
21
|
+
testFilters?: string[];
|
|
22
|
+
testCategories?: string[];
|
|
23
|
+
execution?: UnityTestExecution;
|
|
24
|
+
isolatedLauncher?: UnityTestIsolatedLauncher;
|
|
25
|
+
retries?: number;
|
|
26
|
+
rerunFailed?: boolean;
|
|
27
|
+
shard?: string;
|
|
28
|
+
shardInventoryPath?: string;
|
|
29
|
+
reportFormats?: UnityTestReportFormat[];
|
|
30
|
+
coverage?: boolean;
|
|
31
|
+
coverageOptions?: string;
|
|
32
|
+
useGraphics?: boolean;
|
|
33
|
+
timeoutSeconds?: number;
|
|
34
|
+
closeBlockingUnityProcess?: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type NormalizedUnityRunTestsRequest = Omit<Required<Pick<UnityRunTestsRequest, "testPlatform" | "execution" | "isolatedLauncher" | "retries" | "rerunFailed" | "coverage" | "useGraphics" | "closeBlockingUnityProcess">>, never> & {
|
|
38
|
+
path?: string;
|
|
39
|
+
testFilters: string[];
|
|
40
|
+
testCategories: string[];
|
|
41
|
+
shard?: string;
|
|
42
|
+
shardInventoryPath?: string;
|
|
43
|
+
reportFormats?: UnityTestReportFormat[];
|
|
44
|
+
coverageOptions?: string;
|
|
45
|
+
timeoutSeconds?: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type NormalizedUnityTest = { name: string; status: string; durationSeconds?: number; message?: string; stackTrace?: string; attempts?: number };
|
|
49
|
+
export type NormalizedUnityTestResult = {
|
|
50
|
+
schemaVersion: typeof UNITY_TEST_RESULT_SCHEMA_VERSION;
|
|
51
|
+
source: UnityTestSource;
|
|
52
|
+
projectRelativeId?: string;
|
|
53
|
+
platform: UnityTestPlatform;
|
|
54
|
+
selection: { testFilters: string[]; testCategories: string[] };
|
|
55
|
+
startedAt?: string;
|
|
56
|
+
completedAt?: string;
|
|
57
|
+
durationSeconds?: number;
|
|
58
|
+
outcome: NormalizedUnityTestOutcome;
|
|
59
|
+
summary: { total?: number; passed?: number; failed?: number; skipped?: number; inconclusive?: number };
|
|
60
|
+
tests: NormalizedUnityTest[];
|
|
61
|
+
flakyTests?: Array<{ name: string; attempts: number }>;
|
|
62
|
+
backendArtifacts?: Record<string, string>;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type UnityTestRouteRequirements = { requiresIsolation: boolean; reasons: string[] };
|
|
66
|
+
export type UnityTestOutcomeEvidence = {
|
|
67
|
+
cancelled?: boolean;
|
|
68
|
+
timedOut?: boolean;
|
|
69
|
+
uncertain?: boolean;
|
|
70
|
+
runError?: boolean;
|
|
71
|
+
intentionalEmptySelection?: boolean;
|
|
72
|
+
total?: number;
|
|
73
|
+
passed?: number;
|
|
74
|
+
failed?: number;
|
|
75
|
+
inconclusive?: number;
|
|
76
|
+
retryResolvedAllFailures?: boolean;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type UnityCliRetrySummary = {
|
|
80
|
+
requested: number;
|
|
81
|
+
attempts: number;
|
|
82
|
+
passedFirstAttempt?: number;
|
|
83
|
+
flaky: Array<{ name: string; attempts: number }>;
|
|
84
|
+
failed: Array<{ name: string; attempts: number }>;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function selectors(values: string[] | undefined, label: string): string[] {
|
|
88
|
+
const result: string[] = []; const seen = new Set<string>();
|
|
89
|
+
for (const [index, raw] of (values ?? []).entries()) {
|
|
90
|
+
if (typeof raw !== "string") throw new Error(`${label}[${index}] must be a string.`);
|
|
91
|
+
const value = raw.trim();
|
|
92
|
+
if (!value || /[\0\r\n;]/.test(value)) throw new Error(`${label}[${index}] must be non-empty and contain no NUL, newlines, or semicolons.`);
|
|
93
|
+
if (!seen.has(value)) { seen.add(value); result.push(value); }
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
function optionalText(value: string | undefined, label: string): string | undefined {
|
|
98
|
+
if (value === undefined) return undefined;
|
|
99
|
+
const normalized = value.trim();
|
|
100
|
+
if (!normalized || /[\0\r\n]/.test(normalized)) throw new Error(`${label} must be non-empty and contain no NUL or newlines.`);
|
|
101
|
+
return normalized;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Validates public input without deciding a backend or launching Unity. */
|
|
105
|
+
export function normalizeUnityRunTestsRequest(input: UnityRunTestsRequest): NormalizedUnityRunTestsRequest {
|
|
106
|
+
if (input.testPlatform !== "EditMode" && input.testPlatform !== "PlayMode") throw new Error("testPlatform must be EditMode or PlayMode.");
|
|
107
|
+
const execution = input.execution ?? "auto";
|
|
108
|
+
const isolatedLauncher = input.isolatedLauncher ?? "auto";
|
|
109
|
+
if (!["auto", "connected", "isolated"].includes(execution)) throw new Error("execution must be auto, connected, or isolated.");
|
|
110
|
+
if (!["auto", "unity-cli", "editor-executable"].includes(isolatedLauncher)) throw new Error("isolatedLauncher must be auto, unity-cli, or editor-executable.");
|
|
111
|
+
if (!Number.isInteger(input.retries ?? 0) || (input.retries ?? 0) < 0) throw new Error("retries must be a non-negative integer.");
|
|
112
|
+
if (input.timeoutSeconds !== undefined && (!Number.isFinite(input.timeoutSeconds) || input.timeoutSeconds <= 0)) throw new Error("timeoutSeconds must be a positive number.");
|
|
113
|
+
if (input.rerunFailed && input.shard) throw new Error("shard and rerunFailed cannot be combined.");
|
|
114
|
+
const formats = input.reportFormats?.map(value => value.toLowerCase() as UnityTestReportFormat);
|
|
115
|
+
if (formats && formats.some(value => !["json", "nunit", "junit"].includes(value))) throw new Error("reportFormats may contain only json, nunit, or junit.");
|
|
116
|
+
return {
|
|
117
|
+
path: optionalText(input.path, "path"), testPlatform: input.testPlatform, execution, isolatedLauncher,
|
|
118
|
+
testFilters: selectors(input.testFilters, "testFilters"), testCategories: selectors(input.testCategories, "testCategories"),
|
|
119
|
+
retries: input.retries ?? 0, rerunFailed: input.rerunFailed ?? false, shard: optionalText(input.shard, "shard"),
|
|
120
|
+
shardInventoryPath: optionalText(input.shardInventoryPath, "shardInventoryPath"),
|
|
121
|
+
reportFormats: formats ? [...new Set(formats)] : undefined, coverage: input.coverage ?? false,
|
|
122
|
+
coverageOptions: optionalText(input.coverageOptions, "coverageOptions"), useGraphics: input.useGraphics ?? false,
|
|
123
|
+
timeoutSeconds: input.timeoutSeconds, closeBlockingUnityProcess: input.closeBlockingUnityProcess ?? false,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function deriveUnityCliEffectiveReportPath(basePath: string, options: { rerunFailed?: boolean; shard?: string }): string {
|
|
128
|
+
const extension = path.extname(basePath);
|
|
129
|
+
const stem = extension ? basePath.slice(0, -extension.length) : basePath;
|
|
130
|
+
if (options.rerunFailed) return `${stem}.rerun${extension || ".xml"}`;
|
|
131
|
+
if (options.shard) {
|
|
132
|
+
const match = /^(\d+)\/(\d+)$/.exec(options.shard);
|
|
133
|
+
if (!match) throw new Error("shard must use the N/M form.");
|
|
134
|
+
return `${stem}.shard-${match[1]}-of-${match[2]}${extension || ".xml"}`;
|
|
135
|
+
}
|
|
136
|
+
return basePath;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function defaultUnityTestReportFormats(route: "connected" | "isolated"): UnityTestReportFormat[] {
|
|
140
|
+
return route === "isolated" ? ["json", "nunit"] : ["json"];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Identifies options Pipeline cannot faithfully perform in one connected run. */
|
|
144
|
+
export function getUnityTestRouteRequirements(request: NormalizedUnityRunTestsRequest): UnityTestRouteRequirements {
|
|
145
|
+
const reasons: string[] = [];
|
|
146
|
+
if (request.testFilters.length > 1) reasons.push("multiple test filters require isolated execution");
|
|
147
|
+
if (request.testCategories.length > 1) reasons.push("multiple test categories require isolated execution");
|
|
148
|
+
if (request.testFilters.length > 0 && request.testCategories.length > 0) reasons.push("mixed test filters and categories require isolated execution");
|
|
149
|
+
if (request.retries > 0) reasons.push("retries require isolated execution");
|
|
150
|
+
if (request.rerunFailed) reasons.push("rerunFailed requires isolated execution");
|
|
151
|
+
if (request.shard) reasons.push("sharding requires isolated execution");
|
|
152
|
+
if (request.shardInventoryPath) reasons.push("shardInventoryPath requires isolated execution");
|
|
153
|
+
if (request.coverage || request.coverageOptions) reasons.push("coverage requires isolated execution");
|
|
154
|
+
if ((request.reportFormats ?? []).some(format => format !== "json")) reasons.push("requested XML reports require isolated execution");
|
|
155
|
+
return { requiresIsolation: reasons.length > 0, reasons };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function retryEntries(value: unknown): Array<{ name: string; attempts: number }> | null {
|
|
159
|
+
if (!Array.isArray(value)) return null;
|
|
160
|
+
const entries: Array<{ name: string; attempts: number }> = [];
|
|
161
|
+
for (const item of value) {
|
|
162
|
+
if (!item || typeof item !== "object") return null;
|
|
163
|
+
const record = item as Record<string, unknown>;
|
|
164
|
+
const name = typeof record.test === "string" ? record.test.trim() : "";
|
|
165
|
+
const attempts = typeof record.attempts === "number" && Number.isInteger(record.attempts) && record.attempts > 0 ? record.attempts : 0;
|
|
166
|
+
if (!name || !attempts) return null;
|
|
167
|
+
entries.push({ name, attempts });
|
|
168
|
+
}
|
|
169
|
+
return entries;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Parses the stable beta.6 retry sidecar without trusting arbitrary fields. */
|
|
173
|
+
export function parseUnityCliRetrySummary(value: unknown): UnityCliRetrySummary | null {
|
|
174
|
+
if (!value || typeof value !== "object") return null;
|
|
175
|
+
const record = value as Record<string, unknown>;
|
|
176
|
+
const requested = typeof record.requested === "number" && Number.isInteger(record.requested) && record.requested >= 0 ? record.requested : -1;
|
|
177
|
+
const attempts = typeof record.attempts === "number" && Number.isInteger(record.attempts) && record.attempts >= 1 ? record.attempts : 0;
|
|
178
|
+
const flaky = retryEntries(record.flaky);
|
|
179
|
+
const failed = retryEntries(record.failed);
|
|
180
|
+
if (requested < 0 || !attempts || !flaky || !failed) return null;
|
|
181
|
+
return { requested, attempts, ...(typeof record.passedFirstAttempt === "number" && record.passedFirstAttempt >= 0 ? { passedFirstAttempt: record.passedFirstAttempt } : {}), flaky, failed };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function applyUnityCliRetrySummary(result: NormalizedUnityTestResult, retry: UnityCliRetrySummary): NormalizedUnityTestResult {
|
|
185
|
+
const flaky = new Map(retry.flaky.map(item => [item.name, item.attempts]));
|
|
186
|
+
const failed = new Map(retry.failed.map(item => [item.name, item.attempts]));
|
|
187
|
+
const tests = result.tests.map(test => flaky.has(test.name)
|
|
188
|
+
? { ...test, status: "Passed", attempts: flaky.get(test.name) }
|
|
189
|
+
: failed.has(test.name) ? { ...test, attempts: failed.get(test.name) } : test);
|
|
190
|
+
const total = result.summary.total;
|
|
191
|
+
const finalFailed = retry.failed.length;
|
|
192
|
+
const passed = total === undefined ? result.summary.passed : Math.max(0, total - finalFailed - (result.summary.skipped ?? 0) - (result.summary.inconclusive ?? 0));
|
|
193
|
+
return { ...result, summary: { ...result.summary, passed, failed: finalFailed }, tests, ...(retry.flaky.length ? { flakyTests: retry.flaky } : {}), outcome: finalFailed > 0 ? "tests_failed" : retry.flaky.length > 0 ? "passed_with_flakes" : result.outcome };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Applies strict evidence precedence; successful transport alone is deliberately insufficient. */
|
|
197
|
+
export function determineUnityTestOutcome(evidence: UnityTestOutcomeEvidence): NormalizedUnityTestOutcome {
|
|
198
|
+
if (evidence.cancelled) return "cancelled";
|
|
199
|
+
if (evidence.timedOut) return "timed_out";
|
|
200
|
+
if (evidence.uncertain) return "uncertain";
|
|
201
|
+
if (evidence.runError) return "run_error";
|
|
202
|
+
if (evidence.intentionalEmptySelection) return "empty_selection";
|
|
203
|
+
if ((evidence.failed ?? 0) > 0) return "tests_failed";
|
|
204
|
+
if (evidence.failed !== 0) return "uncertain";
|
|
205
|
+
const total = evidence.total;
|
|
206
|
+
const passed = evidence.passed;
|
|
207
|
+
if (!Number.isFinite(total) || total! <= 0 || !Number.isFinite(passed) || passed! < total!) return "uncertain";
|
|
208
|
+
if ((evidence.inconclusive ?? 0) > 0) return "uncertain";
|
|
209
|
+
return evidence.retryResolvedAllFailures ? "passed_with_flakes" : "passed";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function bound(value: string | undefined, limit: number): string | undefined {
|
|
213
|
+
if (!value) return undefined;
|
|
214
|
+
const clean = value.replace(/\0/g, "").trim();
|
|
215
|
+
return clean.length > limit ? `${clean.slice(0, limit - 1)}…` : clean;
|
|
216
|
+
}
|
|
217
|
+
function numberOrUndefined(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; }
|
|
218
|
+
function projectRelative(value: string): string | undefined {
|
|
219
|
+
const clean = value.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
220
|
+
return !clean || path.isAbsolute(clean) || /^[A-Za-z]:\//.test(clean) || clean.split("/").includes("..") ? undefined : clean;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Bounds and redacts the durable, backend-neutral evidence shape before serialization. */
|
|
224
|
+
export function normalizeUnityTestResult(result: NormalizedUnityTestResult): NormalizedUnityTestResult {
|
|
225
|
+
const tests = result.tests.slice(0, UNITY_TEST_MAX_TESTS).map(test => ({
|
|
226
|
+
name: bound(test.name, 1_000) || "Unnamed test", status: bound(test.status, 100) || "unknown",
|
|
227
|
+
...(numberOrUndefined(test.durationSeconds) === undefined ? {} : { durationSeconds: numberOrUndefined(test.durationSeconds) }),
|
|
228
|
+
...(bound(test.message, UNITY_TEST_MAX_MESSAGE_CHARS) ? { message: bound(test.message, UNITY_TEST_MAX_MESSAGE_CHARS) } : {}),
|
|
229
|
+
...(bound(test.stackTrace, UNITY_TEST_MAX_STACK_CHARS) ? { stackTrace: bound(test.stackTrace, UNITY_TEST_MAX_STACK_CHARS) } : {}),
|
|
230
|
+
...(Number.isInteger(test.attempts) && test.attempts! > 0 ? { attempts: test.attempts } : {}),
|
|
231
|
+
}));
|
|
232
|
+
const artifacts = Object.fromEntries(Object.entries(result.backendArtifacts ?? {}).flatMap(([key, value]) => {
|
|
233
|
+
const safe = projectRelative(value); return safe ? [[bound(key, 100) || "artifact", safe]] : [];
|
|
234
|
+
}));
|
|
235
|
+
return { ...result, projectRelativeId: result.projectRelativeId ? projectRelative(result.projectRelativeId) : undefined,
|
|
236
|
+
selection: { testFilters: selectors(result.selection.testFilters, "selection.testFilters"), testCategories: selectors(result.selection.testCategories, "selection.testCategories") },
|
|
237
|
+
summary: Object.fromEntries(Object.entries(result.summary).flatMap(([key, value]) => numberOrUndefined(value) === undefined ? [] : [[key, numberOrUndefined(value)!]])),
|
|
238
|
+
tests, ...(result.flakyTests ? { flakyTests: result.flakyTests.slice(0, UNITY_TEST_MAX_TESTS).map(item => ({ name: bound(item.name, 1_000) || "Unnamed test", attempts: Math.max(1, Math.floor(item.attempts)) })) } : {}),
|
|
239
|
+
...(Object.keys(artifacts).length ? { backendArtifacts: artifacts } : {}),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function compactUnityTestSummary(result: NormalizedUnityTestResult): string {
|
|
244
|
+
const count = result.summary.total ?? result.tests.length;
|
|
245
|
+
switch (result.outcome) {
|
|
246
|
+
case "passed": return `Unity ${result.platform} tests passed: ${count} executed.`;
|
|
247
|
+
case "passed_with_flakes": return `Unity ${result.platform} tests passed with ${result.flakyTests?.length ?? 0} flaky test(s): ${count} executed.`;
|
|
248
|
+
case "empty_selection": return `Unity ${result.platform} test selection was empty; no Editor run was required.`;
|
|
249
|
+
case "tests_failed": return `Unity ${result.platform} tests failed: ${result.summary.failed ?? "unknown"} failed of ${count}.`;
|
|
250
|
+
default: return `Unity ${result.platform} test run ${result.outcome.replace(/_/g, " ")}.`;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function writeNormalizedUnityTestArtifact(projectRoot: string, result: NormalizedUnityTestResult, options: { now?: Date; token?: string } = {}): Promise<string> {
|
|
255
|
+
const normalized = normalizeUnityTestResult(result);
|
|
256
|
+
const stamp = (options.now ?? new Date()).toISOString().replace(/[-:.]/g, "");
|
|
257
|
+
const token = (options.token ?? randomUUID()).replace(/[^A-Za-z0-9]/g, "").slice(0, 32);
|
|
258
|
+
if (!token) throw new Error("Artifact token must contain an ASCII letter or digit.");
|
|
259
|
+
const logs = path.join(path.resolve(projectRoot), "Logs");
|
|
260
|
+
const fileName = `pi-unity-tests-${normalized.platform.toLowerCase()}-${stamp}-${token}.json`;
|
|
261
|
+
const destination = path.join(logs, fileName);
|
|
262
|
+
const temporary = path.join(logs, `.${fileName}.${randomUUID()}.tmp`);
|
|
263
|
+
const json = `${JSON.stringify(normalized)}\n`;
|
|
264
|
+
if (Buffer.byteLength(json) > UNITY_TEST_MAX_ARTIFACT_BYTES) throw new Error("Normalized Unity test artifact exceeds its size limit.");
|
|
265
|
+
await mkdir(logs, { recursive: true });
|
|
266
|
+
await writeFile(temporary, json, { encoding: "utf8", flag: "wx" });
|
|
267
|
+
try { await link(temporary, destination); } catch (error: unknown) {
|
|
268
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error("Refusing to overwrite an existing normalized Unity test artifact.");
|
|
269
|
+
throw error;
|
|
270
|
+
} finally { await rm(temporary, { force: true }); }
|
|
271
|
+
return path.relative(path.resolve(projectRoot), destination).replace(/\\/g, "/");
|
|
272
|
+
}
|