@aefree/pi-unity 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,285 @@
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
+
67
+ export function resolveUnityCliBackendReportPaths(
68
+ paths: { nunit: string; junit: string; log: string },
69
+ formats: UnityTestReportFormat[],
70
+ ): { nunit: string; junit?: string; log: string } {
71
+ // Normalized JSON is derived from Unity CLI's native NUnit XML, so the
72
+ // backend report is required even when callers do not retain NUnit output.
73
+ return {
74
+ nunit: paths.nunit,
75
+ junit: formats.includes("junit") ? paths.junit : undefined,
76
+ log: paths.log,
77
+ };
78
+ }
79
+ export type UnityTestOutcomeEvidence = {
80
+ cancelled?: boolean;
81
+ timedOut?: boolean;
82
+ uncertain?: boolean;
83
+ runError?: boolean;
84
+ intentionalEmptySelection?: boolean;
85
+ total?: number;
86
+ passed?: number;
87
+ failed?: number;
88
+ inconclusive?: number;
89
+ retryResolvedAllFailures?: boolean;
90
+ };
91
+
92
+ export type UnityCliRetrySummary = {
93
+ requested: number;
94
+ attempts: number;
95
+ passedFirstAttempt?: number;
96
+ flaky: Array<{ name: string; attempts: number }>;
97
+ failed: Array<{ name: string; attempts: number }>;
98
+ };
99
+
100
+ function selectors(values: string[] | undefined, label: string): string[] {
101
+ const result: string[] = []; const seen = new Set<string>();
102
+ for (const [index, raw] of (values ?? []).entries()) {
103
+ if (typeof raw !== "string") throw new Error(`${label}[${index}] must be a string.`);
104
+ const value = raw.trim();
105
+ if (!value || /[\0\r\n;]/.test(value)) throw new Error(`${label}[${index}] must be non-empty and contain no NUL, newlines, or semicolons.`);
106
+ if (!seen.has(value)) { seen.add(value); result.push(value); }
107
+ }
108
+ return result;
109
+ }
110
+ function optionalText(value: string | undefined, label: string): string | undefined {
111
+ if (value === undefined) return undefined;
112
+ const normalized = value.trim();
113
+ if (!normalized || /[\0\r\n]/.test(normalized)) throw new Error(`${label} must be non-empty and contain no NUL or newlines.`);
114
+ return normalized;
115
+ }
116
+
117
+ /** Validates public input without deciding a backend or launching Unity. */
118
+ export function normalizeUnityRunTestsRequest(input: UnityRunTestsRequest): NormalizedUnityRunTestsRequest {
119
+ if (input.testPlatform !== "EditMode" && input.testPlatform !== "PlayMode") throw new Error("testPlatform must be EditMode or PlayMode.");
120
+ const execution = input.execution ?? "auto";
121
+ const isolatedLauncher = input.isolatedLauncher ?? "auto";
122
+ if (!["auto", "connected", "isolated"].includes(execution)) throw new Error("execution must be auto, connected, or isolated.");
123
+ if (!["auto", "unity-cli", "editor-executable"].includes(isolatedLauncher)) throw new Error("isolatedLauncher must be auto, unity-cli, or editor-executable.");
124
+ if (!Number.isInteger(input.retries ?? 0) || (input.retries ?? 0) < 0) throw new Error("retries must be a non-negative integer.");
125
+ if (input.timeoutSeconds !== undefined && (!Number.isFinite(input.timeoutSeconds) || input.timeoutSeconds <= 0)) throw new Error("timeoutSeconds must be a positive number.");
126
+ if (input.rerunFailed && input.shard) throw new Error("shard and rerunFailed cannot be combined.");
127
+ const formats = input.reportFormats?.map(value => value.toLowerCase() as UnityTestReportFormat);
128
+ if (formats && formats.some(value => !["json", "nunit", "junit"].includes(value))) throw new Error("reportFormats may contain only json, nunit, or junit.");
129
+ return {
130
+ path: optionalText(input.path, "path"), testPlatform: input.testPlatform, execution, isolatedLauncher,
131
+ testFilters: selectors(input.testFilters, "testFilters"), testCategories: selectors(input.testCategories, "testCategories"),
132
+ retries: input.retries ?? 0, rerunFailed: input.rerunFailed ?? false, shard: optionalText(input.shard, "shard"),
133
+ shardInventoryPath: optionalText(input.shardInventoryPath, "shardInventoryPath"),
134
+ reportFormats: formats ? [...new Set(formats)] : undefined, coverage: input.coverage ?? false,
135
+ coverageOptions: optionalText(input.coverageOptions, "coverageOptions"), useGraphics: input.useGraphics ?? false,
136
+ timeoutSeconds: input.timeoutSeconds, closeBlockingUnityProcess: input.closeBlockingUnityProcess ?? false,
137
+ };
138
+ }
139
+
140
+ export function deriveUnityCliEffectiveReportPath(basePath: string, options: { rerunFailed?: boolean; shard?: string }): string {
141
+ const extension = path.extname(basePath);
142
+ const stem = extension ? basePath.slice(0, -extension.length) : basePath;
143
+ if (options.rerunFailed) return `${stem}.rerun${extension || ".xml"}`;
144
+ if (options.shard) {
145
+ const match = /^(\d+)\/(\d+)$/.exec(options.shard);
146
+ if (!match) throw new Error("shard must use the N/M form.");
147
+ return `${stem}.shard-${match[1]}-of-${match[2]}${extension || ".xml"}`;
148
+ }
149
+ return basePath;
150
+ }
151
+
152
+ export function defaultUnityTestReportFormats(route: "connected" | "isolated"): UnityTestReportFormat[] {
153
+ return route === "isolated" ? ["json", "nunit"] : ["json"];
154
+ }
155
+
156
+ /** Identifies options Pipeline cannot faithfully perform in one connected run. */
157
+ export function getUnityTestRouteRequirements(request: NormalizedUnityRunTestsRequest): UnityTestRouteRequirements {
158
+ const reasons: string[] = [];
159
+ if (request.testFilters.length > 1) reasons.push("multiple test filters require isolated execution");
160
+ if (request.testCategories.length > 1) reasons.push("multiple test categories require isolated execution");
161
+ if (request.testFilters.length > 0 && request.testCategories.length > 0) reasons.push("mixed test filters and categories require isolated execution");
162
+ if (request.retries > 0) reasons.push("retries require isolated execution");
163
+ if (request.rerunFailed) reasons.push("rerunFailed requires isolated execution");
164
+ if (request.shard) reasons.push("sharding requires isolated execution");
165
+ if (request.shardInventoryPath) reasons.push("shardInventoryPath requires isolated execution");
166
+ if (request.coverage || request.coverageOptions) reasons.push("coverage requires isolated execution");
167
+ if ((request.reportFormats ?? []).some(format => format !== "json")) reasons.push("requested XML reports require isolated execution");
168
+ return { requiresIsolation: reasons.length > 0, reasons };
169
+ }
170
+
171
+ function retryEntries(value: unknown): Array<{ name: string; attempts: number }> | null {
172
+ if (!Array.isArray(value)) return null;
173
+ const entries: Array<{ name: string; attempts: number }> = [];
174
+ for (const item of value) {
175
+ if (!item || typeof item !== "object") return null;
176
+ const record = item as Record<string, unknown>;
177
+ const name = typeof record.test === "string" ? record.test.trim() : "";
178
+ const attempts = typeof record.attempts === "number" && Number.isInteger(record.attempts) && record.attempts > 0 ? record.attempts : 0;
179
+ if (!name || !attempts) return null;
180
+ entries.push({ name, attempts });
181
+ }
182
+ return entries;
183
+ }
184
+
185
+ /** Parses the stable beta.6 retry sidecar without trusting arbitrary fields. */
186
+ export function parseUnityCliRetrySummary(value: unknown): UnityCliRetrySummary | null {
187
+ if (!value || typeof value !== "object") return null;
188
+ const record = value as Record<string, unknown>;
189
+ const requested = typeof record.requested === "number" && Number.isInteger(record.requested) && record.requested >= 0 ? record.requested : -1;
190
+ const attempts = typeof record.attempts === "number" && Number.isInteger(record.attempts) && record.attempts >= 1 ? record.attempts : 0;
191
+ const flaky = retryEntries(record.flaky);
192
+ const failed = retryEntries(record.failed);
193
+ if (requested < 0 || !attempts || !flaky || !failed) return null;
194
+ return { requested, attempts, ...(typeof record.passedFirstAttempt === "number" && record.passedFirstAttempt >= 0 ? { passedFirstAttempt: record.passedFirstAttempt } : {}), flaky, failed };
195
+ }
196
+
197
+ export function applyUnityCliRetrySummary(result: NormalizedUnityTestResult, retry: UnityCliRetrySummary): NormalizedUnityTestResult {
198
+ const flaky = new Map(retry.flaky.map(item => [item.name, item.attempts]));
199
+ const failed = new Map(retry.failed.map(item => [item.name, item.attempts]));
200
+ const tests = result.tests.map(test => flaky.has(test.name)
201
+ ? { ...test, status: "Passed", attempts: flaky.get(test.name) }
202
+ : failed.has(test.name) ? { ...test, attempts: failed.get(test.name) } : test);
203
+ const total = result.summary.total;
204
+ const finalFailed = retry.failed.length;
205
+ const passed = total === undefined ? result.summary.passed : Math.max(0, total - finalFailed - (result.summary.skipped ?? 0) - (result.summary.inconclusive ?? 0));
206
+ 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 };
207
+ }
208
+
209
+ /** Applies strict evidence precedence; successful transport alone is deliberately insufficient. */
210
+ export function determineUnityTestOutcome(evidence: UnityTestOutcomeEvidence): NormalizedUnityTestOutcome {
211
+ if (evidence.cancelled) return "cancelled";
212
+ if (evidence.timedOut) return "timed_out";
213
+ if (evidence.uncertain) return "uncertain";
214
+ if (evidence.runError) return "run_error";
215
+ if (evidence.intentionalEmptySelection) return "empty_selection";
216
+ if ((evidence.failed ?? 0) > 0) return "tests_failed";
217
+ if (evidence.failed !== 0) return "uncertain";
218
+ const total = evidence.total;
219
+ const passed = evidence.passed;
220
+ if (!Number.isFinite(total) || total! <= 0 || !Number.isFinite(passed) || passed! < total!) return "uncertain";
221
+ if ((evidence.inconclusive ?? 0) > 0) return "uncertain";
222
+ return evidence.retryResolvedAllFailures ? "passed_with_flakes" : "passed";
223
+ }
224
+
225
+ function bound(value: string | undefined, limit: number): string | undefined {
226
+ if (!value) return undefined;
227
+ const clean = value.replace(/\0/g, "").trim();
228
+ return clean.length > limit ? `${clean.slice(0, limit - 1)}…` : clean;
229
+ }
230
+ function numberOrUndefined(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; }
231
+ function projectRelative(value: string): string | undefined {
232
+ const clean = value.replace(/\\/g, "/").replace(/^\.\//, "");
233
+ return !clean || path.isAbsolute(clean) || /^[A-Za-z]:\//.test(clean) || clean.split("/").includes("..") ? undefined : clean;
234
+ }
235
+
236
+ /** Bounds and redacts the durable, backend-neutral evidence shape before serialization. */
237
+ export function normalizeUnityTestResult(result: NormalizedUnityTestResult): NormalizedUnityTestResult {
238
+ const tests = result.tests.slice(0, UNITY_TEST_MAX_TESTS).map(test => ({
239
+ name: bound(test.name, 1_000) || "Unnamed test", status: bound(test.status, 100) || "unknown",
240
+ ...(numberOrUndefined(test.durationSeconds) === undefined ? {} : { durationSeconds: numberOrUndefined(test.durationSeconds) }),
241
+ ...(bound(test.message, UNITY_TEST_MAX_MESSAGE_CHARS) ? { message: bound(test.message, UNITY_TEST_MAX_MESSAGE_CHARS) } : {}),
242
+ ...(bound(test.stackTrace, UNITY_TEST_MAX_STACK_CHARS) ? { stackTrace: bound(test.stackTrace, UNITY_TEST_MAX_STACK_CHARS) } : {}),
243
+ ...(Number.isInteger(test.attempts) && test.attempts! > 0 ? { attempts: test.attempts } : {}),
244
+ }));
245
+ const artifacts = Object.fromEntries(Object.entries(result.backendArtifacts ?? {}).flatMap(([key, value]) => {
246
+ const safe = projectRelative(value); return safe ? [[bound(key, 100) || "artifact", safe]] : [];
247
+ }));
248
+ return { ...result, projectRelativeId: result.projectRelativeId ? projectRelative(result.projectRelativeId) : undefined,
249
+ selection: { testFilters: selectors(result.selection.testFilters, "selection.testFilters"), testCategories: selectors(result.selection.testCategories, "selection.testCategories") },
250
+ summary: Object.fromEntries(Object.entries(result.summary).flatMap(([key, value]) => numberOrUndefined(value) === undefined ? [] : [[key, numberOrUndefined(value)!]])),
251
+ 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)) })) } : {}),
252
+ ...(Object.keys(artifacts).length ? { backendArtifacts: artifacts } : {}),
253
+ };
254
+ }
255
+
256
+ export function compactUnityTestSummary(result: NormalizedUnityTestResult): string {
257
+ const count = result.summary.total ?? result.tests.length;
258
+ switch (result.outcome) {
259
+ case "passed": return `Unity ${result.platform} tests passed: ${count} executed.`;
260
+ case "passed_with_flakes": return `Unity ${result.platform} tests passed with ${result.flakyTests?.length ?? 0} flaky test(s): ${count} executed.`;
261
+ case "empty_selection": return `Unity ${result.platform} test selection was empty; no Editor run was required.`;
262
+ case "tests_failed": return `Unity ${result.platform} tests failed: ${result.summary.failed ?? "unknown"} failed of ${count}.`;
263
+ default: return `Unity ${result.platform} test run ${result.outcome.replace(/_/g, " ")}.`;
264
+ }
265
+ }
266
+
267
+ export async function writeNormalizedUnityTestArtifact(projectRoot: string, result: NormalizedUnityTestResult, options: { now?: Date; token?: string } = {}): Promise<string> {
268
+ const normalized = normalizeUnityTestResult(result);
269
+ const stamp = (options.now ?? new Date()).toISOString().replace(/[-:.]/g, "");
270
+ const token = (options.token ?? randomUUID()).replace(/[^A-Za-z0-9]/g, "").slice(0, 32);
271
+ if (!token) throw new Error("Artifact token must contain an ASCII letter or digit.");
272
+ const logs = path.join(path.resolve(projectRoot), "Logs");
273
+ const fileName = `pi-unity-tests-${normalized.platform.toLowerCase()}-${stamp}-${token}.json`;
274
+ const destination = path.join(logs, fileName);
275
+ const temporary = path.join(logs, `.${fileName}.${randomUUID()}.tmp`);
276
+ const json = `${JSON.stringify(normalized)}\n`;
277
+ if (Buffer.byteLength(json) > UNITY_TEST_MAX_ARTIFACT_BYTES) throw new Error("Normalized Unity test artifact exceeds its size limit.");
278
+ await mkdir(logs, { recursive: true });
279
+ await writeFile(temporary, json, { encoding: "utf8", flag: "wx" });
280
+ try { await link(temporary, destination); } catch (error: unknown) {
281
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error("Refusing to overwrite an existing normalized Unity test artifact.");
282
+ throw error;
283
+ } finally { await rm(temporary, { force: true }); }
284
+ return path.relative(path.resolve(projectRoot), destination).replace(/\\/g, "/");
285
+ }