@aefree/pi-unity 0.10.0 → 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 CHANGED
@@ -5,6 +5,25 @@ 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
+
8
27
  ## [0.10.0] - 2026-08-14
9
28
 
10
29
  ### Changed
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
- - `unity_pipeline_run_tests` — run one focused EditMode or PlayMode selection with bounded polling and aggregate results.
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
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. The sole exception is Pipeline 0.5's explicit initial-settling `Server Busy` rejection for `unity_pipeline_recompile` and `unity_pipeline_run_tests`, which is known not to have dispatched a main-thread command and is retried only within the configured deadline.
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 connected tests when the exact project is already open and Pipeline testing is reachable. Use `unity_run_test_batch` for closed projects, CI-style isolation, categories or multiple filters, graphics-dependent PlayMode tests, or required NUnit XML/log evidence.
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 | `unity_run_test_batch` or `unity_launch_batchmode` |
82
- | Required NUnit XML or Unity log evidence | `unity_run_test_batch` |
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." })),
@@ -168,6 +188,7 @@ const GUIDANCE_AUDIT_PARAMS = Type.Object({
168
188
  const INSPECT_ARTIFACTS_PARAMS = Type.Object({
169
189
  path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
170
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." })),
171
192
  logFilePath: Type.Optional(Type.String({ description: "Unity log file path. Relative paths are resolved against cwd and the Unity project root." })),
172
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." })),
173
194
  maxLines: Type.Optional(Type.Integer({ minimum: 1, maximum: 500, default: 60, description: "Maximum log/output lines to include." })),
@@ -685,7 +706,7 @@ function compactUnityArtifacts(artifacts: UnityBatchmodeArtifacts): UnityBatchmo
685
706
  async function buildArtifactInspectionReport(
686
707
  ctx: ExtensionContext,
687
708
  candidate: UnityProjectCandidate,
688
- 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 },
689
710
  ): Promise<{ text: string; details: UnityToolDetails }> {
690
711
  const useLatest = params.latestFromLogs !== false;
691
712
  const logsRoot = join(candidate.projectRoot, "Logs");
@@ -693,6 +714,15 @@ async function buildArtifactInspectionReport(
693
714
  ?? (useLatest ? await findNewestFile(logsRoot, [".xml"]) : undefined);
694
715
  const logFilePath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.logFilePath)
695
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
+ }
696
726
  const invocation: UnityBatchmodeInvocation = {
697
727
  isTestRun: Boolean(testResultsPath),
698
728
  usesNoGraphics: false,
@@ -710,6 +740,8 @@ async function buildArtifactInspectionReport(
710
740
  `Unity artifacts inspected for ${formatPathForUser(ctx.cwd, candidate.projectRoot)} using Unity ${candidate.unityVersion}.`,
711
741
  testResultsPath ? `Requested test results: ${testResultsPath}` : "Requested test results: (none found)",
712
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] : []),
713
745
  ];
714
746
 
715
747
  if (parsedTestResults) {
@@ -1079,6 +1111,96 @@ async function runGuardedUnityBatchmode(
1079
1111
  );
1080
1112
  }
1081
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
+
1082
1204
  function renderUnityPipelineResult(result: any, options: { expanded: boolean; isPartial: boolean }, theme: any, context: { lastComponent?: unknown }): Text {
1083
1205
  const details = result.details as UnityToolDetails | undefined;
1084
1206
  const primaryText = getToolTextContent(result);
@@ -1392,6 +1514,27 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1392
1514
  },
1393
1515
  });
1394
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
+
1395
1538
  pi.registerTool({
1396
1539
  name: "unity_pipeline_recompile",
1397
1540
  label: "Unity Pipeline Recompile",
@@ -1419,34 +1562,6 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1419
1562
  renderResult(result, options, theme, context) { return renderUnityPipelineResult(result, options, theme, context); },
1420
1563
  });
1421
1564
 
1422
- pi.registerTool({
1423
- name: "unity_pipeline_run_tests",
1424
- label: "Unity Pipeline Run Tests",
1425
- description: "Run one focused EditMode or PlayMode test selection through an already-open exact Unity Pipeline Editor, with internal bounded polling and aggregate output.",
1426
- promptSnippet: "Run focused connected Unity EditMode or PlayMode tests in one bounded call without shell polling; aggregate passing results stay compact.",
1427
- promptGuidelines: [
1428
- "Use unity_pipeline_run_tests for one focused connected Unity test platform when the exact Editor is already open and reachable.",
1429
- "unity_pipeline_run_tests may exit Play Mode through advertised editor_stop when needed, then verifies Edit Mode before dispatching tests.",
1430
- "Use unity_run_test_batch instead of unity_pipeline_run_tests for closed projects, isolation, complex filters/categories, or required NUnit XML/log evidence.",
1431
- "unity_pipeline_run_tests does not cancel uncertain work or switch to batchmode after timeout; report that the connected run may still be running.",
1432
- ],
1433
- parameters: PIPELINE_TEST_PARAMS,
1434
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
1435
- throwIfAborted(signal);
1436
- const { candidate } = await resolveProjectCandidate(ctx, params.path);
1437
- const result = await runUnityPipelineTests({ projectRoot: candidate.projectRoot, unityVersion: candidate.unityVersion, testPlatform: params.testPlatform, testFilter: params.testFilter, timeoutSeconds: params.timeoutSeconds, allowAutonomousExitPlayMode: sessionAllowsAutonomousPlayModeExit(ctx) }, createPipelineDependencies(pi), {
1438
- signal,
1439
- onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }] }),
1440
- });
1441
- return {
1442
- content: [{ type: "text", text: result.text }],
1443
- details: { mode: "pipeline", projectRoot: result.details.projectRoot, unityVersion: candidate.unityVersion, editorPath: "", status: "passed", pipeline: result.details } satisfies UnityToolDetails,
1444
- };
1445
- },
1446
- renderCall(args, theme, context) { return renderUnityPipelineCall("unity_pipeline_run_tests", args, theme, context); },
1447
- renderResult(result, options, theme, context) { return renderUnityPipelineResult(result, options, theme, context); },
1448
- });
1449
-
1450
1565
  pi.registerTool({
1451
1566
  name: "unity_pipeline_eval",
1452
1567
  label: "Unity Pipeline Eval",
@@ -1640,52 +1755,6 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1640
1755
  },
1641
1756
  });
1642
1757
 
1643
- pi.registerTool({
1644
- name: "unity_run_test_batch",
1645
- label: "Unity Test Batch",
1646
- description: "Run one bundled Unity Test Framework platform with normalized filters/categories and generated absolute XML/log paths under the project Logs directory.",
1647
- promptSnippet: "Run a bundled Unity EditMode or PlayMode test batch with safe generated artifact paths",
1648
- promptGuidelines: [
1649
- "Before choosing a test route, call unity_project_status for the exact project copy. If it is already open with reachable Pipeline run_tests/test_status commands, use the connected workflow without closing the Editor.",
1650
- "Prefer unity_run_test_batch over unity_launch_batchmode only for isolated or report-producing Unity Test Framework runs: closed projects, unavailable/unsupported connected testing, intentional CI isolation, unsupported filters, or required NUnit XML/log artifacts.",
1651
- "Do not set closeBlockingUnityProcess merely to switch a reachable Pipeline Editor into batchmode; use it only after isolated execution is deliberately required and the guarded setting is enabled.",
1652
- "Pass unity_run_test_batch exactly one testPlatform. Multiple test platforms require separate user-authorized launches.",
1653
- "An empty unity_run_test_batch testFilters/testCategories selection runs all tests for that testPlatform; use narrow arrays when focused evidence is sufficient.",
1654
- "Do not call unity_run_test_batch for PlayMode when user/project guidance says to skip PlayMode tests.",
1655
- "Use unity_run_test_batch useGraphics=true only for graphics-dependent PlayMode tests or visual capture; ordinary EditMode and non-visual PlayMode remain headless.",
1656
- "After unity_run_test_batch infrastructure failure, inspect the exact generated paths reported by the failed call once and do not repeat an unchanged launch.",
1657
- ],
1658
- parameters: RUN_TEST_BATCH_PARAMS,
1659
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
1660
- throwIfAborted(signal);
1661
- const { candidate, discoveryWarning } = await resolveProjectCandidate(ctx, params.path);
1662
- const plan = createUnityTestBatchPlan({
1663
- projectRoot: candidate.projectRoot,
1664
- testPlatform: params.testPlatform as UnityTestPlatform,
1665
- testFilters: params.testFilters,
1666
- testCategories: params.testCategories,
1667
- });
1668
- await mkdir(dirname(plan.testResultsPath), { recursive: true });
1669
- throwIfAborted(signal);
1670
- const result = await runGuardedUnityBatchmode(pi, ctx, candidate, discoveryWarning, {
1671
- unityEditorPath: params.unityEditorPath,
1672
- args: plan.args,
1673
- useGraphics: params.useGraphics,
1674
- timeoutSeconds: params.timeoutSeconds,
1675
- launcher: params.launcher as UnityLauncherPreference | undefined,
1676
- closeBlockingUnityProcess: params.closeBlockingUnityProcess,
1677
- }, signal, "unity_run_test_batch");
1678
- result.details = { ...result.details, testBatch: plan };
1679
- return result;
1680
- },
1681
- renderCall(args, theme) {
1682
- return renderUnityToolCall("unity_run_test_batch", args, theme, "batchmode", `${args.testPlatform} test batch`);
1683
- },
1684
- renderResult(result, { expanded }, theme) {
1685
- return renderUnityToolResult(result, expanded, theme);
1686
- },
1687
- });
1688
-
1689
1758
  pi.registerTool({
1690
1759
  name: "unity_launch_batchmode",
1691
1760
  label: "Unity CLI",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-unity",
3
- "version": "0.10.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: `unity_run_test_batch`
14
+ - Isolated tests: `unity_run_tests`
15
15
  - Custom isolated Editor arguments: `unity_launch_batchmode`
16
16
  - Existing evidence: `unity_inspect_artifacts`
17
17
 
@@ -18,7 +18,7 @@
18
18
 
19
19
  - Connected EditMode: `run_tests --mode editor`; asynchronous execution plus `test_status` is safest for uniform wrappers.
20
20
  - Connected PlayMode: require `--async_tests true`, then poll `test_status` because domain reload can drop the initiating request.
21
- - Isolated/report-producing: use `unity test` or the packaged `unity_run_test_batch` when NUnit XML/log artifacts are required.
21
+ - Isolated/report-producing: use `unity test` or the packaged `unity_run_tests` when NUnit XML/log artifacts are required.
22
22
  - Preserve graphics requirements and reject zero-test, malformed, incomplete, or nested `success:false` results.
23
23
 
24
24
  ## Build and ExecuteMethod
@@ -14,7 +14,7 @@ Run Unity Test Framework tests from the command line without opening the Editor
14
14
  - **Use absolute paths** for `-testResults` and `-logFile` to ensure logs are easy to find.
15
15
  - **Unity allows only one process per project folder** - GUI Editor and batchmode/headless both count as that one process.
16
16
  - **Do not open the GUI editor for the same project before or during batchmode runs** - `/unity-open` and `unity_open_editor` launch the full Unity Editor GUI and are not equivalent to headless batchmode.
17
- - **Do not close a reachable Pipeline Editor merely to run tests** - an already-open exact copy can run supported tests through `run_tests` plus `test_status`; use the `unity-pipeline-workflows` skill.
17
+ - **Do not close a reachable Pipeline Editor merely to run tests** - call `unity_run_tests`; it selects connected Pipeline for supported requests and rejects isolated-only options rather than closing the Editor.
18
18
  - **Only close a blocking Unity Editor through `unity_launch_batchmode` safeguards after deliberately choosing isolated execution** - this is limited to cases such as required NUnit XML, unsupported connected filters/commands, or explicit isolation. Use `closeBlockingUnityProcess: true` only when `unity_project_status` shows `piUnity.allowCloseRunningUnityProcess` is enabled or the user explicitly says it is enabled; pi-unity re-scans the resolved project and never accepts arbitrary PIDs.
19
19
  - **Use `unity_launch_batchmode` when you want to run headless Unity directly** - keep test-specific flags deliberate, especially around `-runTests` and `-quit`.
20
20
  - **Bundle tests into one Unity batchmode turn whenever practical** - starting/stopping Unity, importing assets, and domain reloads dominate runtime. A broader single run is usually faster than many sequential one-test Unity launches, and same-project runs cannot use useful parallelism.
@@ -40,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
- - `unity_run_test_batch` for isolated/report-producing Unity Test Framework runs with one platform and bundled filters/categories
43
+ - `unity_run_tests` for isolated/report-producing Unity Test Framework runs with one platform and bundled filters/categories
44
44
  - `unity_launch_batchmode` for custom headless Unity execution that needs raw Editor arguments
45
45
  - `unity_open_editor` only when the user explicitly wants the GUI Editor
46
46
  - `/unity-open` as the user-facing GUI launcher helper
47
47
 
48
- `unity_run_test_batch` should be the default for isolated or report-producing Unity Test Framework work. It generates unique absolute XML/log paths under the project `Logs` directory, normalizes filter/category arrays into one launch, omits `-quit`, and uses the same guarded executor as `unity_launch_batchmode`. For an already-open reachable exact-copy Pipeline Editor, use the `unity-pipeline-workflows` skill instead.
48
+ `unity_run_tests` is the one ordinary Unity Test Framework tool. It routes supported requests to a reachable exact-copy Pipeline Editor or a closed-project isolated `unity test` run, writes a durable normalized JSON artifact, and retains native NUnit/JUnit reports when requested. It never silently switches after uncertain dispatch.
49
49
 
50
50
  `unity_launch_batchmode` remains the default for custom agent-run headless Unity work because it already:
51
51
  - resolves the Unity project from a direct project root, a coordination root, or another nearby folder
@@ -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 `unity_run_test_batch` merely because the test tool is more convenient.
102
- 3. Choose isolated `unity_run_test_batch` only when the Editor is closed, connected testing is unavailable, CI/isolation is intentional, required filters are unsupported, or NUnit XML/log artifacts are required. State the reason.
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 `unity_run_test_batch` for isolated/report-producing tests; use `unity_launch_batchmode` only when custom raw Unity arguments are required
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
- - `unity_pipeline_run_tests` for one focused `EditMode` or `PlayMode` test-name selection.
15
+ - `unity_run_tests` for one compatible `EditMode` or `PlayMode` test-name or category selection.
16
16
 
17
17
  These tools resolve the exact copy, require advertised commands, inspect lifecycle state, dispatch once, validate identity, and poll internally with a fixed deadline. Do not recreate their wait loops with `bash`, `unity recompile_status`, or `unity test_status` calls.
18
18
 
19
- A timeout or malformed response is uncertain: the Unity operation may still be running. Do not cancel, retry, launch batchmode, close the Editor, or claim a result without a new user-authorized decision. The only automatic retry is Pipeline 0.5's explicit initial-settling `Server Busy` response for `unity_pipeline_recompile` or `unity_pipeline_run_tests`, which confirms that a main-thread command was rejected before dispatch.
19
+ A timeout or malformed response is uncertain: the Unity operation may still be running. Do not cancel, retry, launch batchmode, close the Editor, or claim a result without a new user-authorized decision. The only automatic retry is Pipeline 0.5's explicit initial-settling `Server Busy` response for `unity_pipeline_recompile` or `unity_run_tests`, which confirms that a main-thread command was rejected before dispatch.
20
20
 
21
21
  ## Preconditions and boundaries
22
22
 
23
23
  1. Pass an explicit `path` when multiple project copies may be found; paths identify copies, not display names.
24
24
  2. The typed tools require a reachable exact-copy Pipeline and advertised `editor_status` plus operation commands. A different connected client is not itself a project lock.
25
- 3. `unity_pipeline_recompile` never sends `editor_stop` or overrides Unity's Script Changes While Playing preference. Known recompile-and-continue, stop-and-recompile, and defer policies proceed according to Unity's configured behavior. Pipeline 0.4 does not currently expose that preference, so the tool reports the unavailable policy while allowing recompilation to proceed. `unity_pipeline_run_tests` may dispatch advertised `editor_stop` when needed, then verifies Edit Mode before running tests. The tools never enter Play Mode, pause, save, launch, or close Unity; recompilation may perform Unity's normal asset refresh/import and script-change behavior.
25
+ 3. `unity_pipeline_recompile` never sends `editor_stop` or overrides Unity's Script Changes While Playing preference. Known recompile-and-continue, stop-and-recompile, and defer policies proceed according to Unity's configured behavior. Pipeline 0.4 does not currently expose that preference, so the tool reports the unavailable policy while allowing recompilation to proceed. `unity_run_tests` may dispatch advertised `editor_stop` when needed, then verifies Edit Mode before running tests. The tools never enter Play Mode, pause, save, launch, or close Unity; recompilation may perform Unity's normal asset refresh/import and script-change behavior.
26
26
  4. Test success requires a well-formed terminal result, a known positive executed count, and zero failures. An asynchronous initiation with `Total: 0` and `running` is nonterminal.
27
- 5. Passing test records are intentionally discarded. Failures retain only a bounded set of failed/inconclusive names, messages, and stack excerpts.
27
+ 5. Routine tool output remains compact. Complete bounded terminal test records are persisted immediately in the durable normalized JSON artifact before Pipeline status can be displaced.
28
28
 
29
29
  ## Compile
30
30
 
@@ -32,11 +32,11 @@ Call `unity_pipeline_recompile` with optional `path` and `timeoutSeconds` (defau
32
32
 
33
33
  ## Focused tests
34
34
 
35
- Call `unity_pipeline_run_tests` with:
35
+ Call `unity_run_tests` with:
36
36
 
37
37
  - required `testPlatform`: `EditMode` or `PlayMode`;
38
- - optional `testFilter`: one test-name filter only;
39
- - optional `path` and `timeoutSeconds` (default 600, maximum 3600).
38
+ - optional `testFilters` or `testCategories`: at most one selector family and one selector for connected execution;
39
+ - optional `execution`, `path`, and `timeoutSeconds` (default 600, maximum 3600).
40
40
  - before running PlayMode tests, check the Game View focus setting. Set it to Play Unfocused for the test run, then restore the previous setting afterward.
41
41
 
42
42
  The tool treats `no_tests`, idle, and not-started statuses as safe inactivity, detects a pre-existing active connected test before dispatch, and stops rather than claiming or replacing active work. It captures returned mode/filter/run identity fields when available and stops as uncertain if status is clearly displaced by another run.
@@ -47,6 +47,6 @@ Normally call the typed tools, not raw CLI commands. If a typed tool is unavaila
47
47
 
48
48
  ## When not to use connected tools
49
49
 
50
- Use `unity_run_test_batch` for a closed project, intentional isolation/CI, category or multiple filters, or required NUnit XML/log evidence. State the reason for that route. Do not use batchmode as an automatic fallback after an uncertain connected dispatch.
50
+ Use `unity_run_tests` with `execution: "isolated"` for a closed project, intentional isolation/CI, multiple selectors, retries, sharding, coverage, or required NUnit/JUnit evidence. A single category is supported connected when Pipeline advertises the command; do not claim broader connected selector support. Do not use batchmode as an automatic fallback after an uncertain connected dispatch.
51
51
 
52
52
  Use the typed compile/test tools when their polling and terminal evidence fit the task. Advertised Pipeline `eval` remains available through `unity_pipeline_eval` for bounded project-specific inspection or operations outside those typed workflows; its `timeoutSeconds` range is 1–86,400 seconds, and a timeout remains uncertain without cancellation or retry. It is an assistance surface, not a forbidden fallback or a substitute for the typed tools' completion protocol. Eval compiles arbitrary C# with Roslyn on the Editor main thread, so ordinary properties and local-variable snippets are valid; it is not expression-only or reliably statically read-only. Prefer typed tools for their stronger evidence, but let user intent and project guidance govern mutations. Lifecycle, persistent-setting, destructive, asset, scene-save, package, build, and test mutations require explicit authorization.
@@ -18,6 +18,7 @@ export type UnityFailedTest = {
18
18
  stackTrace?: string;
19
19
  };
20
20
 
21
+ export type UnityParsedTestCase = { name: string; status: string; durationSeconds?: number; message?: string; stackTrace?: string };
21
22
  export type UnityParsedTestResults = {
22
23
  total?: number;
23
24
  passed?: number;
@@ -26,6 +27,8 @@ export type UnityParsedTestResults = {
26
27
  inconclusive?: number;
27
28
  durationSeconds?: number;
28
29
  failedTests: UnityFailedTest[];
30
+ /** Complete bounded per-test evidence for normalized artifacts, never routine tool output. */
31
+ tests: UnityParsedTestCase[];
29
32
  };
30
33
 
31
34
  export type UnityBatchmodeArtifacts = {
@@ -108,6 +111,7 @@ export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults |
108
111
 
109
112
  const rootAttributes = parseAttributes(testRunMatch[1] ?? "");
110
113
  const failedTests: UnityFailedTest[] = [];
114
+ const tests: UnityParsedTestCase[] = [];
111
115
 
112
116
  const testCaseRegex = /<test-case\b([^>]*)>([\s\S]*?)<\/test-case>/gi;
113
117
  for (const match of xml.matchAll(testCaseRegex)) {
@@ -116,17 +120,12 @@ export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults |
116
120
  const result = String(attributes.result ?? attributes.label ?? "").toLowerCase();
117
121
  const success = String(attributes.success ?? "").toLowerCase();
118
122
  const isFailure = result === "failed" || success === "false";
119
- if (!isFailure) continue;
120
-
121
123
  const failureMessage = body.match(/<message[^>]*>([\s\S]*?)<\/message>/i);
122
124
  const stackTrace = body.match(/<stack-trace[^>]*>([\s\S]*?)<\/stack-trace>/i);
123
- if (failedTests.length < 50) {
124
- failedTests.push({
125
- name: truncateEvidence(attributes.fullname ?? attributes.name ?? "(unknown test)", 500) ?? "(unknown test)",
126
- message: truncateEvidence(decodeXmlText(failureMessage?.[1]), 1_000),
127
- stackTrace: truncateEvidence(decodeXmlText(stackTrace?.[1]), 4_000),
128
- });
129
- }
125
+ const name = truncateEvidence(attributes.fullname ?? attributes.name ?? "(unknown test)", 1_000) ?? "(unknown test)";
126
+ if (tests.length < 2_000) tests.push({ name, status: attributes.result ?? attributes.label ?? "Unknown", ...(parseOptionalNumber(attributes.duration) === undefined ? {} : { durationSeconds: parseOptionalNumber(attributes.duration) }), ...(truncateEvidence(decodeXmlText(failureMessage?.[1]), 4_000) ? { message: truncateEvidence(decodeXmlText(failureMessage?.[1]), 4_000) } : {}), ...(truncateEvidence(decodeXmlText(stackTrace?.[1]), 8_000) ? { stackTrace: truncateEvidence(decodeXmlText(stackTrace?.[1]), 8_000) } : {}) });
127
+ if (!isFailure) continue;
128
+ if (failedTests.length < 50) failedTests.push({ name, message: truncateEvidence(decodeXmlText(failureMessage?.[1]), 1_000), stackTrace: truncateEvidence(decodeXmlText(stackTrace?.[1]), 4_000) });
130
129
  }
131
130
 
132
131
  const skipped = parseOptionalNumber(rootAttributes.skipped) ?? parseOptionalNumber(rootAttributes.inconclusive);
@@ -139,6 +138,7 @@ export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults |
139
138
  inconclusive: parseOptionalNumber(rootAttributes.inconclusive),
140
139
  durationSeconds: parseOptionalNumber(rootAttributes.duration),
141
140
  failedTests,
141
+ tests,
142
142
  };
143
143
  if (parsed.total === undefined && parsed.passed === undefined && parsed.failed === undefined && parsed.failedTests.length === 0) {
144
144
  return null;
package/src/unity-cli.ts CHANGED
@@ -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 }));
@@ -11,7 +11,7 @@ export const UNITY_PIPELINE_MAX_DIAGNOSTICS = 8;
11
11
  export const UNITY_PIPELINE_MAX_STACK_CHARS = 600;
12
12
 
13
13
  export type UnityPipelineCompileRequest = { projectRoot: string; unityVersion: string; timeoutSeconds?: number; allowAutonomousExitPlayMode?: boolean };
14
- export type UnityPipelineTestRequest = { projectRoot: string; unityVersion: string; testPlatform: "EditMode" | "PlayMode"; testFilter?: string; timeoutSeconds?: number; allowAutonomousExitPlayMode?: boolean };
14
+ export type UnityPipelineTestRequest = { projectRoot: string; unityVersion: string; testPlatform: "EditMode" | "PlayMode"; testFilter?: string; testCategory?: string; timeoutSeconds?: number; allowAutonomousExitPlayMode?: boolean };
15
15
  export type UnityPipelineProgress = (message: string) => void;
16
16
  /** Unity's EditorSettings.ScriptChangesWhilePlaying values when a future editor_status payload supplies one. */
17
17
  export type UnityScriptChangesWhilePlayingPolicy = "recompile_and_continue" | "stop_and_recompile" | "defer" | "unknown";
@@ -32,7 +32,9 @@ export type UnityPipelineOperationDetails = {
32
32
  testFilter?: string;
33
33
  counts?: { total: number; passed?: number; failed: number; inconclusive?: number };
34
34
  };
35
- export type UnityPipelineOperationResult = { text: string; details: UnityPipelineOperationDetails };
35
+ export type UnityPipelineTestRecord = { name: string; status: string; durationSeconds?: number; message?: string; stackTrace?: string };
36
+ /** testRecords are terminal evidence for the caller's durable artifact only; do not expose them in tool details. */
37
+ export type UnityPipelineOperationResult = { text: string; details: UnityPipelineOperationDetails; testRecords?: UnityPipelineTestRecord[] };
36
38
 
37
39
  type RecordValue = Record<string, unknown>;
38
40
  type ParsedEnvelope = { result: RecordValue; outerSuccess: boolean; malformed?: string };
@@ -41,6 +43,7 @@ type NormalizedTest = {
41
43
  state: "inactive" | "starting" | "running" | "completed" | "failed" | "cancelled" | "uncertain";
42
44
  total?: number; passed?: number; failed?: number; inconclusive?: number; failures: string[];
43
45
  correlation: Record<string, string>;
46
+ testRecords?: UnityPipelineTestRecord[];
44
47
  };
45
48
 
46
49
  type PipelineDependencies = {
@@ -172,6 +175,24 @@ function summary(result: RecordValue): RecordValue | undefined {
172
175
  walk(result, item => { if (!found && record(field(item, "summary"))) found = record(field(item, "summary")); });
173
176
  return found;
174
177
  }
178
+ function testRecords(result: RecordValue): UnityPipelineTestRecord[] {
179
+ const values: UnityPipelineTestRecord[] = [];
180
+ walk(result, item => {
181
+ for (const key of ["tests", "results", "testresults"]) {
182
+ const entries = field(item, key);
183
+ if (!Array.isArray(entries)) continue;
184
+ for (const entry of entries.slice(0, 2_000)) {
185
+ const test = record(entry); if (!test) continue;
186
+ const name = string(field(test, "name", "fullname", "testname"));
187
+ const status = string(field(test, "result", "status", "outcome"));
188
+ if (!name || !status) continue;
189
+ const durationSeconds = number(field(test, "duration", "durationseconds", "time"));
190
+ values.push({ name: bounded(name, 1_000), status: bounded(status, 100), ...(durationSeconds === undefined ? {} : { durationSeconds }), ...(string(field(test, "message", "error", "failuremessage")) ? { message: bounded(string(field(test, "message", "error", "failuremessage"))!, 4_000) } : {}), ...(string(field(test, "stacktrace", "stack", "trace")) ? { stackTrace: bounded(string(field(test, "stacktrace", "stack", "trace"))!, 8_000) } : {}) });
191
+ }
192
+ }
193
+ });
194
+ return values.slice(0, 2_000);
195
+ }
175
196
  function testFailures(result: RecordValue): string[] {
176
197
  const values: string[] = [];
177
198
  walk(result, item => {
@@ -223,7 +244,7 @@ export function normalizeUnityPipelineTest(output: string): NormalizedTest {
223
244
  : raw === "no_tests" || raw === "idle" || raw === "not_started" || raw === "not_running" ? "inactive"
224
245
  : raw === "running" ? "running" : raw === "starting" || raw === "queued" ? "starting"
225
246
  : raw === "completed" || raw === "complete" || raw === "success" ? "completed" : "uncertain";
226
- return { state, total, passed, failed: failedCount, inconclusive, failures: testFailures(parsed.result), correlation: correlation(parsed.result) };
247
+ return { state, total, passed, failed: failedCount, inconclusive, failures: testFailures(parsed.result), correlation: correlation(parsed.result), testRecords: testRecords(parsed.result) };
227
248
  }
228
249
 
229
250
  function editorStopSucceeded(output: string): boolean {
@@ -471,7 +492,10 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
471
492
  const observed = statusOf(parseUnityPipelineEnvelope(before.stdout).result) ?? "unknown";
472
493
  throw new Error(`Unity Pipeline returned unsupported preflight test status '${bounded(observed, 80)}'; test run not started.`);
473
494
  }
474
- const args = ["--mode", request.testPlatform === "EditMode" ? "editor" : "playmode", ...(request.testFilter ? ["--filter", request.testFilter, "--filter_type", "testName"] : []), "--async_tests", "true"];
495
+ if (request.testFilter && request.testCategory) throw new Error("Connected Pipeline cannot combine a test-name filter and category in one run; operation not started.");
496
+ const selectorArgs = request.testFilter ? ["--filter", request.testFilter, "--filter_type", "testName"]
497
+ : request.testCategory ? ["--filter", request.testCategory, "--filter_type", "category"] : [];
498
+ const args = ["--mode", request.testPlatform === "EditMode" ? "editor" : "playmode", ...selectorArgs, "--async_tests", "true"];
475
499
  ensureBeforeDeadline(deadline, now, "tests before dispatch"); throwIfAborted(signal);
476
500
  const dispatched = await dispatchMainThreadCommand(deps, projectRoot, "run_tests", args, "tests", signal, deadline, now, sleep);
477
501
  if (dispatched.error) throw new Error("Unity Pipeline test dispatch failed; test run may not have started.");
@@ -485,7 +509,7 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
485
509
  if (state.state === "completed") {
486
510
  const counts = passingCounts(state);
487
511
  if (!counts) throw new Error("Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).");
488
- return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter, counts } };
512
+ return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter ?? request.testCategory, counts }, testRecords: state.testRecords };
489
513
  }
490
514
  for (let poll = 0; now() < deadline; poll += 1) {
491
515
  options.onUpdate?.(`Unity ${request.testPlatform} tests ${state.state}; ${elapsed(start, now).toFixed(1)}s elapsed.`);
@@ -507,7 +531,7 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
507
531
  if (state.state !== "completed") continue;
508
532
  const counts = passingCounts(state);
509
533
  if (!counts) throw new Error("Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).");
510
- return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter, counts } };
534
+ return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter ?? request.testCategory, counts }, testRecords: state.testRecords };
511
535
  }
512
536
  throw timeoutMessage("tests");
513
537
  }
@@ -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
+ }