@aefree/pi-unity 0.9.0 → 0.9.2

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.
@@ -1,355 +1,355 @@
1
- import * as fs from "node:fs/promises";
2
- import * as path from "node:path";
3
- import { hasUnityCommandLineFlag } from "./unity-core";
4
-
5
- export type UnityBatchmodeInvocation = {
6
- isTestRun: boolean;
7
- usesNoGraphics: boolean;
8
- testPlatform?: string;
9
- testFilter?: string;
10
- testCategory?: string;
11
- testResultsPath?: string;
12
- logFilePath?: string;
13
- };
14
-
15
- export type UnityFailedTest = {
16
- name: string;
17
- message?: string;
18
- stackTrace?: string;
19
- };
20
-
21
- export type UnityParsedTestResults = {
22
- total?: number;
23
- passed?: number;
24
- failed?: number;
25
- skipped?: number;
26
- inconclusive?: number;
27
- durationSeconds?: number;
28
- failedTests: UnityFailedTest[];
29
- };
30
-
31
- export type UnityBatchmodeArtifacts = {
32
- testResultsPath?: string;
33
- logFilePath?: string;
34
- testResultsXml?: string;
35
- logText?: string;
36
- testResultsBytes?: number;
37
- logBytes?: number;
38
- logExcerpt?: string;
39
- warnings: string[];
40
- };
41
-
42
- export function parseUnityBatchmodeInvocation(args: string[]): UnityBatchmodeInvocation {
43
- const getValue = (flag: string): string | undefined => {
44
- for (let index = 0; index < args.length; index += 1) {
45
- const value = args[index];
46
- if (value === flag) {
47
- return args[index + 1];
48
- }
49
- if (value.startsWith(`${flag}=`)) {
50
- return value.slice(flag.length + 1);
51
- }
52
- }
53
- return undefined;
54
- };
55
-
56
- return {
57
- isTestRun: hasUnityCommandLineFlag(args, "-runTests"),
58
- usesNoGraphics: hasUnityCommandLineFlag(args, "-nographics"),
59
- testPlatform: getValue("-testPlatform"),
60
- testFilter: getValue("-testFilter"),
61
- testCategory: getValue("-testCategory"),
62
- testResultsPath: getValue("-testResults"),
63
- logFilePath: getValue("-logFile"),
64
- };
65
- }
66
-
67
- function decodeXmlText(value: string | undefined): string | undefined {
68
- if (!value) return undefined;
69
- const trimmed = value.trim();
70
- const withoutCdata = trimmed.replace(/^<!\[CDATA\[([\s\S]*?)\]\]>$/u, "$1");
71
- return withoutCdata
72
- .replace(/&lt;/g, "<")
73
- .replace(/&gt;/g, ">")
74
- .replace(/&quot;/g, '"')
75
- .replace(/&apos;/g, "'")
76
- .replace(/&amp;/g, "&")
77
- .trim();
78
- }
79
-
80
- function parseAttributes(tagSource: string): Record<string, string> {
81
- const attributes: Record<string, string> = {};
82
- const attributeRegex = /(\w[\w:-]*)\s*=\s*"([^"]*)"/g;
83
- for (const match of tagSource.matchAll(attributeRegex)) {
84
- const key = match[1];
85
- const value = match[2] ?? "";
86
- attributes[key] = value;
87
- }
88
- return attributes;
89
- }
90
-
91
- function truncateEvidence(value: string | undefined, maxChars: number): string | undefined {
92
- if (!value) return undefined;
93
- return value.length <= maxChars ? value : `${value.slice(0, Math.max(0, maxChars - 1))}…`;
94
- }
95
-
96
- function parseOptionalNumber(value: string | undefined): number | undefined {
97
- if (!value) return undefined;
98
- const parsed = Number(value);
99
- return Number.isFinite(parsed) ? parsed : undefined;
100
- }
101
-
102
- export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults | null {
103
- const testRunMatch = xml.match(/<test-run\b([^>]*)>/i);
104
- const testRunCloseIndex = xml.search(/<\/test-run\s*>/i);
105
- if (!testRunMatch || testRunCloseIndex < (testRunMatch.index ?? 0) + testRunMatch[0].length) {
106
- return null;
107
- }
108
-
109
- const rootAttributes = parseAttributes(testRunMatch[1] ?? "");
110
- const failedTests: UnityFailedTest[] = [];
111
-
112
- const testCaseRegex = /<test-case\b([^>]*)>([\s\S]*?)<\/test-case>/gi;
113
- for (const match of xml.matchAll(testCaseRegex)) {
114
- const attributes = parseAttributes(match[1] ?? "");
115
- const body = match[2] ?? "";
116
- const result = String(attributes.result ?? attributes.label ?? "").toLowerCase();
117
- const success = String(attributes.success ?? "").toLowerCase();
118
- const isFailure = result === "failed" || success === "false";
119
- if (!isFailure) continue;
120
-
121
- const failureMessage = body.match(/<message[^>]*>([\s\S]*?)<\/message>/i);
122
- 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
- }
130
- }
131
-
132
- const skipped = parseOptionalNumber(rootAttributes.skipped) ?? parseOptionalNumber(rootAttributes.inconclusive);
133
-
134
- const parsed: UnityParsedTestResults = {
135
- total: parseOptionalNumber(rootAttributes.total) ?? parseOptionalNumber(rootAttributes.testcasecount),
136
- passed: parseOptionalNumber(rootAttributes.passed),
137
- failed: parseOptionalNumber(rootAttributes.failed),
138
- skipped,
139
- inconclusive: parseOptionalNumber(rootAttributes.inconclusive),
140
- durationSeconds: parseOptionalNumber(rootAttributes.duration),
141
- failedTests,
142
- };
143
- if (parsed.total === undefined && parsed.passed === undefined && parsed.failed === undefined && parsed.failedTests.length === 0) {
144
- return null;
145
- }
146
- return parsed;
147
- }
148
-
149
- function buildArtifactCandidates(cwd: string, projectRoot: string, rawPath: string): string[] {
150
- if (path.isAbsolute(rawPath)) {
151
- return [path.normalize(rawPath)];
152
- }
153
-
154
- const candidates = [
155
- path.resolve(cwd, rawPath),
156
- path.resolve(projectRoot, rawPath),
157
- ].map((value) => path.normalize(value));
158
-
159
- return Array.from(new Set(candidates));
160
- }
161
-
162
- async function readFirstExistingText(pathsToTry: string[]): Promise<{ path?: string; text?: string }> {
163
- for (const candidate of pathsToTry) {
164
- try {
165
- const text = await fs.readFile(candidate, "utf8");
166
- return { path: candidate, text };
167
- } catch {
168
- // Try next candidate.
169
- }
170
- }
171
- return {};
172
- }
173
-
174
- export async function loadUnityBatchmodeArtifacts(
175
- cwd: string,
176
- projectRoot: string,
177
- invocation: UnityBatchmodeInvocation,
178
- ): Promise<UnityBatchmodeArtifacts> {
179
- const warnings: string[] = [];
180
- const artifacts: UnityBatchmodeArtifacts = { warnings };
181
-
182
- if (invocation.testResultsPath) {
183
- const result = await readFirstExistingText(buildArtifactCandidates(cwd, projectRoot, invocation.testResultsPath));
184
- if (result.path && result.text !== undefined) {
185
- artifacts.testResultsPath = result.path;
186
- artifacts.testResultsXml = result.text;
187
- } else {
188
- warnings.push(`Unity test results file was not found: ${invocation.testResultsPath}`);
189
- }
190
- }
191
-
192
- if (invocation.logFilePath && invocation.logFilePath !== "-") {
193
- const result = await readFirstExistingText(buildArtifactCandidates(cwd, projectRoot, invocation.logFilePath));
194
- if (result.path && result.text !== undefined) {
195
- artifacts.logFilePath = result.path;
196
- artifacts.logText = result.text;
197
- } else {
198
- warnings.push(`Unity log file was not found: ${invocation.logFilePath}`);
199
- }
200
- }
201
-
202
- return artifacts;
203
- }
204
-
205
- export function summarizeTextForAgent(value: string | undefined, maxLines = 40, maxChars = 4000): string | undefined {
206
- if (!value) return undefined;
207
- const trimmed = value.trim();
208
- if (!trimmed) return undefined;
209
-
210
- const lines = trimmed.split(/\r?\n/);
211
- const selected = lines.length > maxLines ? lines.slice(-maxLines) : lines;
212
- let text = selected.join("\n");
213
- if (text.length > maxChars) {
214
- text = text.slice(text.length - maxChars);
215
- }
216
-
217
- const omittedLines = lines.length - selected.length;
218
- const prefix = omittedLines > 0 ? `[showing last ${selected.length} of ${lines.length} lines]\n` : "";
219
- return `${prefix}${text}`;
220
- }
221
-
222
- export function formatParsedTestResultsForAgent(results: UnityParsedTestResults): string[] {
223
- const counts: string[] = [];
224
- if (results.total !== undefined) counts.push(`total=${results.total}`);
225
- if (results.passed !== undefined) counts.push(`passed=${results.passed}`);
226
- if (results.failed !== undefined) counts.push(`failed=${results.failed}`);
227
- if (results.skipped !== undefined) counts.push(`skipped=${results.skipped}`);
228
- if (results.inconclusive !== undefined) counts.push(`inconclusive=${results.inconclusive}`);
229
- if (results.durationSeconds !== undefined) counts.push(`duration=${results.durationSeconds}s`);
230
-
231
- const lines = counts.length > 0 ? [`Results: ${counts.join(", ")}`] : [];
232
- if (results.failedTests.length > 0) {
233
- lines.push("Failed tests:");
234
- for (const failed of results.failedTests.slice(0, 8)) {
235
- lines.push(`- ${failed.name}`);
236
- if (failed.message) {
237
- lines.push(` ${failed.message.split(/\r?\n/)[0]}`);
238
- }
239
- }
240
- if (results.failedTests.length > 8) {
241
- lines.push(`- ... ${results.failedTests.length - 8} more failed tests`);
242
- }
243
- }
244
- return lines;
245
- }
246
-
247
- export type UnityBatchmodeAgentTextInput = {
248
- displayProjectPath: string;
249
- unityVersion: string;
250
- editorPath: string;
251
- exitCode: number;
252
- killed: boolean;
253
- invocation: UnityBatchmodeInvocation;
254
- artifacts: UnityBatchmodeArtifacts;
255
- parsedTestResults?: UnityParsedTestResults | null;
256
- stdout?: string;
257
- stderr?: string;
258
- warning?: string;
259
- singleProcessWarning: string;
260
- };
261
-
262
- export function hasKnownPositiveExecutedTestCount(
263
- parsedTestResults?: UnityParsedTestResults | null,
264
- ): boolean {
265
- return parsedTestResults?.total !== undefined
266
- && Number.isFinite(parsedTestResults.total)
267
- && parsedTestResults.total > 0;
268
- }
269
-
270
- export function isPassingUnityTestEvidence(
271
- parsedTestResults?: UnityParsedTestResults | null,
272
- ): boolean {
273
- return hasKnownPositiveExecutedTestCount(parsedTestResults)
274
- && (parsedTestResults?.failed ?? 0) === 0
275
- && (parsedTestResults?.failedTests.length ?? 0) === 0;
276
- }
277
-
278
- export function deriveUnityArtifactInspectionStatus(
279
- hasLoadedArtifacts: boolean,
280
- invocation: UnityBatchmodeInvocation,
281
- parsedTestResults?: UnityParsedTestResults | null,
282
- ): "passed" | "failed" {
283
- if (!hasLoadedArtifacts) return "failed";
284
- if (invocation.isTestRun && !isPassingUnityTestEvidence(parsedTestResults)) return "failed";
285
- return "passed";
286
- }
287
-
288
- export function deriveUnityBatchmodeStatus(
289
- exitCode: number,
290
- killed: boolean,
291
- invocation: UnityBatchmodeInvocation,
292
- parsedTestResults?: UnityParsedTestResults | null,
293
- ): "passed" | "failed" | "killed" {
294
- if (killed) return "killed";
295
- if (invocation.isTestRun && !isPassingUnityTestEvidence(parsedTestResults)) return "failed";
296
- if (parsedTestResults && ((parsedTestResults.failed ?? 0) > 0 || parsedTestResults.failedTests.length > 0)) {
297
- return "failed";
298
- }
299
- return exitCode === 0 ? "passed" : "failed";
300
- }
301
-
302
- function getOutcomeLabel(input: UnityBatchmodeAgentTextInput): "passed" | "failed" | "killed" {
303
- return deriveUnityBatchmodeStatus(input.exitCode, input.killed, input.invocation, input.parsedTestResults);
304
- }
305
-
306
- function getBatchmodeVariantLabel(invocation: UnityBatchmodeInvocation): "Unity (headless)" | "Unity (graphics)" {
307
- return invocation.usesNoGraphics ? "Unity (headless)" : "Unity (graphics)";
308
- }
309
-
310
- export function buildUnityBatchmodeAgentText(input: UnityBatchmodeAgentTextInput): string {
311
- const outcome = getOutcomeLabel(input);
312
- const batchmodeVariant = getBatchmodeVariantLabel(input.invocation);
313
- const lines = [
314
- `${batchmodeVariant} ${outcome} for ${input.displayProjectPath} using Unity ${input.unityVersion}.`,
315
- `Editor: ${input.editorPath}`,
316
- `Exit code: ${input.exitCode}`,
317
- `Mode: ${batchmodeVariant}`,
318
- input.singleProcessWarning,
319
- ];
320
-
321
- if (input.invocation.isTestRun) {
322
- lines.push("Run type: Unity Test Framework");
323
- if (input.invocation.testPlatform) lines.push(`Test platform: ${input.invocation.testPlatform}`);
324
- if (input.invocation.testFilter) lines.push(`Test filter: ${input.invocation.testFilter}`);
325
- if (input.invocation.testCategory) lines.push(`Test category: ${input.invocation.testCategory}`);
326
- }
327
-
328
- if (input.parsedTestResults) {
329
- lines.push(...formatParsedTestResultsForAgent(input.parsedTestResults));
330
- }
331
- if (input.invocation.isTestRun && !hasKnownPositiveExecutedTestCount(input.parsedTestResults)) {
332
- lines.push(input.parsedTestResults?.total === 0
333
- ? "Unity reported zero executed tests; this batch is not passing evidence."
334
- : "Unity did not report a known positive executed-test count; this batch is not passing evidence.");
335
- }
336
-
337
- if (input.artifacts.testResultsPath) lines.push(`Test results: ${input.artifacts.testResultsPath}`);
338
- if (input.artifacts.logFilePath) lines.push(`Log file: ${input.artifacts.logFilePath}`);
339
- for (const artifactWarning of input.artifacts.warnings) lines.push(artifactWarning);
340
- if (input.invocation.testResultsPath && input.artifacts.testResultsXml && !input.parsedTestResults) {
341
- lines.push(`Unity test results XML could not be parsed: ${input.artifacts.testResultsPath ?? input.invocation.testResultsPath}`);
342
- }
343
- if (input.warning) lines.push(input.warning);
344
-
345
- const preferredOutput = input.parsedTestResults
346
- ? undefined
347
- : summarizeTextForAgent(input.stderr) ?? summarizeTextForAgent(input.stdout) ?? summarizeTextForAgent(input.artifacts.logText);
348
-
349
- if (preferredOutput) {
350
- lines.push("Relevant output:");
351
- lines.push(preferredOutput);
352
- }
353
-
354
- return lines.join("\n");
355
- }
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { hasUnityCommandLineFlag } from "./unity-core";
4
+
5
+ export type UnityBatchmodeInvocation = {
6
+ isTestRun: boolean;
7
+ usesNoGraphics: boolean;
8
+ testPlatform?: string;
9
+ testFilter?: string;
10
+ testCategory?: string;
11
+ testResultsPath?: string;
12
+ logFilePath?: string;
13
+ };
14
+
15
+ export type UnityFailedTest = {
16
+ name: string;
17
+ message?: string;
18
+ stackTrace?: string;
19
+ };
20
+
21
+ export type UnityParsedTestResults = {
22
+ total?: number;
23
+ passed?: number;
24
+ failed?: number;
25
+ skipped?: number;
26
+ inconclusive?: number;
27
+ durationSeconds?: number;
28
+ failedTests: UnityFailedTest[];
29
+ };
30
+
31
+ export type UnityBatchmodeArtifacts = {
32
+ testResultsPath?: string;
33
+ logFilePath?: string;
34
+ testResultsXml?: string;
35
+ logText?: string;
36
+ testResultsBytes?: number;
37
+ logBytes?: number;
38
+ logExcerpt?: string;
39
+ warnings: string[];
40
+ };
41
+
42
+ export function parseUnityBatchmodeInvocation(args: string[]): UnityBatchmodeInvocation {
43
+ const getValue = (flag: string): string | undefined => {
44
+ for (let index = 0; index < args.length; index += 1) {
45
+ const value = args[index];
46
+ if (value === flag) {
47
+ return args[index + 1];
48
+ }
49
+ if (value.startsWith(`${flag}=`)) {
50
+ return value.slice(flag.length + 1);
51
+ }
52
+ }
53
+ return undefined;
54
+ };
55
+
56
+ return {
57
+ isTestRun: hasUnityCommandLineFlag(args, "-runTests"),
58
+ usesNoGraphics: hasUnityCommandLineFlag(args, "-nographics"),
59
+ testPlatform: getValue("-testPlatform"),
60
+ testFilter: getValue("-testFilter"),
61
+ testCategory: getValue("-testCategory"),
62
+ testResultsPath: getValue("-testResults"),
63
+ logFilePath: getValue("-logFile"),
64
+ };
65
+ }
66
+
67
+ function decodeXmlText(value: string | undefined): string | undefined {
68
+ if (!value) return undefined;
69
+ const trimmed = value.trim();
70
+ const withoutCdata = trimmed.replace(/^<!\[CDATA\[([\s\S]*?)\]\]>$/u, "$1");
71
+ return withoutCdata
72
+ .replace(/&lt;/g, "<")
73
+ .replace(/&gt;/g, ">")
74
+ .replace(/&quot;/g, '"')
75
+ .replace(/&apos;/g, "'")
76
+ .replace(/&amp;/g, "&")
77
+ .trim();
78
+ }
79
+
80
+ function parseAttributes(tagSource: string): Record<string, string> {
81
+ const attributes: Record<string, string> = {};
82
+ const attributeRegex = /(\w[\w:-]*)\s*=\s*"([^"]*)"/g;
83
+ for (const match of tagSource.matchAll(attributeRegex)) {
84
+ const key = match[1];
85
+ const value = match[2] ?? "";
86
+ attributes[key] = value;
87
+ }
88
+ return attributes;
89
+ }
90
+
91
+ function truncateEvidence(value: string | undefined, maxChars: number): string | undefined {
92
+ if (!value) return undefined;
93
+ return value.length <= maxChars ? value : `${value.slice(0, Math.max(0, maxChars - 1))}…`;
94
+ }
95
+
96
+ function parseOptionalNumber(value: string | undefined): number | undefined {
97
+ if (!value) return undefined;
98
+ const parsed = Number(value);
99
+ return Number.isFinite(parsed) ? parsed : undefined;
100
+ }
101
+
102
+ export function parseUnityTestResultsXml(xml: string): UnityParsedTestResults | null {
103
+ const testRunMatch = xml.match(/<test-run\b([^>]*)>/i);
104
+ const testRunCloseIndex = xml.search(/<\/test-run\s*>/i);
105
+ if (!testRunMatch || testRunCloseIndex < (testRunMatch.index ?? 0) + testRunMatch[0].length) {
106
+ return null;
107
+ }
108
+
109
+ const rootAttributes = parseAttributes(testRunMatch[1] ?? "");
110
+ const failedTests: UnityFailedTest[] = [];
111
+
112
+ const testCaseRegex = /<test-case\b([^>]*)>([\s\S]*?)<\/test-case>/gi;
113
+ for (const match of xml.matchAll(testCaseRegex)) {
114
+ const attributes = parseAttributes(match[1] ?? "");
115
+ const body = match[2] ?? "";
116
+ const result = String(attributes.result ?? attributes.label ?? "").toLowerCase();
117
+ const success = String(attributes.success ?? "").toLowerCase();
118
+ const isFailure = result === "failed" || success === "false";
119
+ if (!isFailure) continue;
120
+
121
+ const failureMessage = body.match(/<message[^>]*>([\s\S]*?)<\/message>/i);
122
+ 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
+ }
130
+ }
131
+
132
+ const skipped = parseOptionalNumber(rootAttributes.skipped) ?? parseOptionalNumber(rootAttributes.inconclusive);
133
+
134
+ const parsed: UnityParsedTestResults = {
135
+ total: parseOptionalNumber(rootAttributes.total) ?? parseOptionalNumber(rootAttributes.testcasecount),
136
+ passed: parseOptionalNumber(rootAttributes.passed),
137
+ failed: parseOptionalNumber(rootAttributes.failed),
138
+ skipped,
139
+ inconclusive: parseOptionalNumber(rootAttributes.inconclusive),
140
+ durationSeconds: parseOptionalNumber(rootAttributes.duration),
141
+ failedTests,
142
+ };
143
+ if (parsed.total === undefined && parsed.passed === undefined && parsed.failed === undefined && parsed.failedTests.length === 0) {
144
+ return null;
145
+ }
146
+ return parsed;
147
+ }
148
+
149
+ function buildArtifactCandidates(cwd: string, projectRoot: string, rawPath: string): string[] {
150
+ if (path.isAbsolute(rawPath)) {
151
+ return [path.normalize(rawPath)];
152
+ }
153
+
154
+ const candidates = [
155
+ path.resolve(cwd, rawPath),
156
+ path.resolve(projectRoot, rawPath),
157
+ ].map((value) => path.normalize(value));
158
+
159
+ return Array.from(new Set(candidates));
160
+ }
161
+
162
+ async function readFirstExistingText(pathsToTry: string[]): Promise<{ path?: string; text?: string }> {
163
+ for (const candidate of pathsToTry) {
164
+ try {
165
+ const text = await fs.readFile(candidate, "utf8");
166
+ return { path: candidate, text };
167
+ } catch {
168
+ // Try next candidate.
169
+ }
170
+ }
171
+ return {};
172
+ }
173
+
174
+ export async function loadUnityBatchmodeArtifacts(
175
+ cwd: string,
176
+ projectRoot: string,
177
+ invocation: UnityBatchmodeInvocation,
178
+ ): Promise<UnityBatchmodeArtifacts> {
179
+ const warnings: string[] = [];
180
+ const artifacts: UnityBatchmodeArtifacts = { warnings };
181
+
182
+ if (invocation.testResultsPath) {
183
+ const result = await readFirstExistingText(buildArtifactCandidates(cwd, projectRoot, invocation.testResultsPath));
184
+ if (result.path && result.text !== undefined) {
185
+ artifacts.testResultsPath = result.path;
186
+ artifacts.testResultsXml = result.text;
187
+ } else {
188
+ warnings.push(`Unity test results file was not found: ${invocation.testResultsPath}`);
189
+ }
190
+ }
191
+
192
+ if (invocation.logFilePath && invocation.logFilePath !== "-") {
193
+ const result = await readFirstExistingText(buildArtifactCandidates(cwd, projectRoot, invocation.logFilePath));
194
+ if (result.path && result.text !== undefined) {
195
+ artifacts.logFilePath = result.path;
196
+ artifacts.logText = result.text;
197
+ } else {
198
+ warnings.push(`Unity log file was not found: ${invocation.logFilePath}`);
199
+ }
200
+ }
201
+
202
+ return artifacts;
203
+ }
204
+
205
+ export function summarizeTextForAgent(value: string | undefined, maxLines = 40, maxChars = 4000): string | undefined {
206
+ if (!value) return undefined;
207
+ const trimmed = value.trim();
208
+ if (!trimmed) return undefined;
209
+
210
+ const lines = trimmed.split(/\r?\n/);
211
+ const selected = lines.length > maxLines ? lines.slice(-maxLines) : lines;
212
+ let text = selected.join("\n");
213
+ if (text.length > maxChars) {
214
+ text = text.slice(text.length - maxChars);
215
+ }
216
+
217
+ const omittedLines = lines.length - selected.length;
218
+ const prefix = omittedLines > 0 ? `[showing last ${selected.length} of ${lines.length} lines]\n` : "";
219
+ return `${prefix}${text}`;
220
+ }
221
+
222
+ export function formatParsedTestResultsForAgent(results: UnityParsedTestResults): string[] {
223
+ const counts: string[] = [];
224
+ if (results.total !== undefined) counts.push(`total=${results.total}`);
225
+ if (results.passed !== undefined) counts.push(`passed=${results.passed}`);
226
+ if (results.failed !== undefined) counts.push(`failed=${results.failed}`);
227
+ if (results.skipped !== undefined) counts.push(`skipped=${results.skipped}`);
228
+ if (results.inconclusive !== undefined) counts.push(`inconclusive=${results.inconclusive}`);
229
+ if (results.durationSeconds !== undefined) counts.push(`duration=${results.durationSeconds}s`);
230
+
231
+ const lines = counts.length > 0 ? [`Results: ${counts.join(", ")}`] : [];
232
+ if (results.failedTests.length > 0) {
233
+ lines.push("Failed tests:");
234
+ for (const failed of results.failedTests.slice(0, 8)) {
235
+ lines.push(`- ${failed.name}`);
236
+ if (failed.message) {
237
+ lines.push(` ${failed.message.split(/\r?\n/)[0]}`);
238
+ }
239
+ }
240
+ if (results.failedTests.length > 8) {
241
+ lines.push(`- ... ${results.failedTests.length - 8} more failed tests`);
242
+ }
243
+ }
244
+ return lines;
245
+ }
246
+
247
+ export type UnityBatchmodeAgentTextInput = {
248
+ displayProjectPath: string;
249
+ unityVersion: string;
250
+ editorPath: string;
251
+ exitCode: number;
252
+ killed: boolean;
253
+ invocation: UnityBatchmodeInvocation;
254
+ artifacts: UnityBatchmodeArtifacts;
255
+ parsedTestResults?: UnityParsedTestResults | null;
256
+ stdout?: string;
257
+ stderr?: string;
258
+ warning?: string;
259
+ singleProcessWarning: string;
260
+ };
261
+
262
+ export function hasKnownPositiveExecutedTestCount(
263
+ parsedTestResults?: UnityParsedTestResults | null,
264
+ ): boolean {
265
+ return parsedTestResults?.total !== undefined
266
+ && Number.isFinite(parsedTestResults.total)
267
+ && parsedTestResults.total > 0;
268
+ }
269
+
270
+ export function isPassingUnityTestEvidence(
271
+ parsedTestResults?: UnityParsedTestResults | null,
272
+ ): boolean {
273
+ return hasKnownPositiveExecutedTestCount(parsedTestResults)
274
+ && (parsedTestResults?.failed ?? 0) === 0
275
+ && (parsedTestResults?.failedTests.length ?? 0) === 0;
276
+ }
277
+
278
+ export function deriveUnityArtifactInspectionStatus(
279
+ hasLoadedArtifacts: boolean,
280
+ invocation: UnityBatchmodeInvocation,
281
+ parsedTestResults?: UnityParsedTestResults | null,
282
+ ): "passed" | "failed" {
283
+ if (!hasLoadedArtifacts) return "failed";
284
+ if (invocation.isTestRun && !isPassingUnityTestEvidence(parsedTestResults)) return "failed";
285
+ return "passed";
286
+ }
287
+
288
+ export function deriveUnityBatchmodeStatus(
289
+ exitCode: number,
290
+ killed: boolean,
291
+ invocation: UnityBatchmodeInvocation,
292
+ parsedTestResults?: UnityParsedTestResults | null,
293
+ ): "passed" | "failed" | "killed" {
294
+ if (killed) return "killed";
295
+ if (invocation.isTestRun && !isPassingUnityTestEvidence(parsedTestResults)) return "failed";
296
+ if (parsedTestResults && ((parsedTestResults.failed ?? 0) > 0 || parsedTestResults.failedTests.length > 0)) {
297
+ return "failed";
298
+ }
299
+ return exitCode === 0 ? "passed" : "failed";
300
+ }
301
+
302
+ function getOutcomeLabel(input: UnityBatchmodeAgentTextInput): "passed" | "failed" | "killed" {
303
+ return deriveUnityBatchmodeStatus(input.exitCode, input.killed, input.invocation, input.parsedTestResults);
304
+ }
305
+
306
+ function getBatchmodeVariantLabel(invocation: UnityBatchmodeInvocation): "Unity (headless)" | "Unity (graphics)" {
307
+ return invocation.usesNoGraphics ? "Unity (headless)" : "Unity (graphics)";
308
+ }
309
+
310
+ export function buildUnityBatchmodeAgentText(input: UnityBatchmodeAgentTextInput): string {
311
+ const outcome = getOutcomeLabel(input);
312
+ const batchmodeVariant = getBatchmodeVariantLabel(input.invocation);
313
+ const lines = [
314
+ `${batchmodeVariant} ${outcome} for ${input.displayProjectPath} using Unity ${input.unityVersion}.`,
315
+ `Editor: ${input.editorPath}`,
316
+ `Exit code: ${input.exitCode}`,
317
+ `Mode: ${batchmodeVariant}`,
318
+ input.singleProcessWarning,
319
+ ];
320
+
321
+ if (input.invocation.isTestRun) {
322
+ lines.push("Run type: Unity Test Framework");
323
+ if (input.invocation.testPlatform) lines.push(`Test platform: ${input.invocation.testPlatform}`);
324
+ if (input.invocation.testFilter) lines.push(`Test filter: ${input.invocation.testFilter}`);
325
+ if (input.invocation.testCategory) lines.push(`Test category: ${input.invocation.testCategory}`);
326
+ }
327
+
328
+ if (input.parsedTestResults) {
329
+ lines.push(...formatParsedTestResultsForAgent(input.parsedTestResults));
330
+ }
331
+ if (input.invocation.isTestRun && !hasKnownPositiveExecutedTestCount(input.parsedTestResults)) {
332
+ lines.push(input.parsedTestResults?.total === 0
333
+ ? "Unity reported zero executed tests; this batch is not passing evidence."
334
+ : "Unity did not report a known positive executed-test count; this batch is not passing evidence.");
335
+ }
336
+
337
+ if (input.artifacts.testResultsPath) lines.push(`Test results: ${input.artifacts.testResultsPath}`);
338
+ if (input.artifacts.logFilePath) lines.push(`Log file: ${input.artifacts.logFilePath}`);
339
+ for (const artifactWarning of input.artifacts.warnings) lines.push(artifactWarning);
340
+ if (input.invocation.testResultsPath && input.artifacts.testResultsXml && !input.parsedTestResults) {
341
+ lines.push(`Unity test results XML could not be parsed: ${input.artifacts.testResultsPath ?? input.invocation.testResultsPath}`);
342
+ }
343
+ if (input.warning) lines.push(input.warning);
344
+
345
+ const preferredOutput = input.parsedTestResults
346
+ ? undefined
347
+ : summarizeTextForAgent(input.stderr) ?? summarizeTextForAgent(input.stdout) ?? summarizeTextForAgent(input.artifacts.logText);
348
+
349
+ if (preferredOutput) {
350
+ lines.push("Relevant output:");
351
+ lines.push(preferredOutput);
352
+ }
353
+
354
+ return lines.join("\n");
355
+ }