diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..526c8a38 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf \ No newline at end of file diff --git a/.github/actions/run-unity-test-batch/action.yml b/.github/actions/run-unity-test-batch/action.yml new file mode 100644 index 00000000..56dc7838 --- /dev/null +++ b/.github/actions/run-unity-test-batch/action.yml @@ -0,0 +1,85 @@ +name: Run Unity UTP Test Batch +description: Runs a batch of Unity UTP tests in a given Unity project. +inputs: + unity-project-path: + description: Absolute path to the Unity project. + required: true + build-target: + description: Build target to use. + required: true + build-args: + description: Additional build args. + required: false + default: "" + artifact-name: + description: Artifact name for uploaded test artifacts (UTP logs, Unity Editor/Player logs, and test results XML; must be unique per matrix job). + required: false + default: unity-tests-batch-utp-logs + test-profile: + description: Predefined UTP scenario profile to run (normal|negative|all). + required: false + default: normal + tests-input: + description: Optional explicit comma-separated test list override. When provided, this takes precedence over test-profile. + required: false + default: "" +runs: + using: composite + steps: + - name: Prepare test list and install packages + shell: bash + working-directory: ${{ inputs.unity-project-path }} + run: | + set -euo pipefail + tests_input="${{ inputs.tests-input }}" + test_profile="${{ inputs.test-profile }}" + + if [ -z "$tests_input" ]; then + case "$test_profile" in + normal) + tests_input="CompilerWarnings,BuildWarnings" + ;; + negative) + tests_input="CompilerErrors,BuildErrors" + ;; + all) + tests_input="CompilerWarnings,CompilerErrors,BuildWarnings,BuildErrors,PlaymodeTestsErrors,EditmodeTestsErrors,EditmodeTestsPassing,EditmodeTestsSkipped,PlaymodeTestsPassing,PlaymodeTestsSkipped,EditmodeSuite,PlaymodeSuite" + ;; + *) + echo "::error::Unknown test-profile '$test_profile'. Expected one of: normal, negative, all." + exit 1 + ;; + esac + fi + + echo "Using UTP tests list: ${tests_input}" + echo "TESTS_INPUT=$tests_input" >> $GITHUB_ENV + + needs_test_framework=false + if [[ "$tests_input" == *"PlaymodeTests"* || "$tests_input" == *"EditmodeTests"* || "$tests_input" == *"EditmodeSuite"* || "$tests_input" == *"PlaymodeSuite"* ]]; then + needs_test_framework=true + fi + + npm install -g openupm-cli + openupm add com.utilities.buildpipeline + if [ "$needs_test_framework" = true ]; then + openupm add com.unity.test-framework + openupm add com.unity.test-framework.utp-reporter || true + fi + + - name: Run tests + shell: bash + env: + UNITY_PROJECT_PATH: ${{ inputs.unity-project-path }} + BUILD_TARGET: ${{ inputs.build-target }} + BUILD_ARGS: ${{ inputs.build-args }} + run: | + bash "${GITHUB_WORKSPACE}/.github/actions/scripts/run-utp-tests.sh" + + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.artifact-name }} + path: utp-artifacts/ + if-no-files-found: ignore diff --git a/.github/actions/scripts/run-utp-tests.sh b/.github/actions/scripts/run-utp-tests.sh new file mode 100755 index 00000000..892cfdfd --- /dev/null +++ b/.github/actions/scripts/run-utp-tests.sh @@ -0,0 +1,394 @@ +#!/usr/bin/env bash +set -uo pipefail + +_UTP_HELPERS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/utp-ci-assertion-helpers.sh" +# shellcheck source=utp-ci-assertion-helpers.sh +source "$_UTP_HELPERS" + +UNITY_PROJECT_PATH=${UNITY_PROJECT_PATH:?UNITY_PROJECT_PATH is required} +BUILD_TARGET=${BUILD_TARGET:?BUILD_TARGET is required} +BUILD_ARGS=${BUILD_ARGS:-} +TESTS_INPUT=${TESTS_INPUT:-} + +if printf '%s' "$BUILD_ARGS" | grep -qE '[;&`|]'; then + echo "::error::BUILD_ARGS contains disallowed shell metacharacters" + exit 1 +fi + +declare -a build_args=() +if [ -n "$BUILD_ARGS" ]; then + # Split on whitespace into an array without invoking the shell + read -r -a build_args <<< "$BUILD_ARGS" +fi + +IFS=',' read -ra tests <<< "$TESTS_INPUT" +failures=0 + +declare -A known_tests=( + [CompilerWarnings]=1 + [CompilerErrors]=1 + [BuildWarnings]=1 + [BuildErrors]=1 + [PlaymodeTestsErrors]=1 + [EditmodeTestsErrors]=1 + [EditmodeTestsPassing]=1 + [EditmodeTestsSkipped]=1 + [PlaymodeTestsPassing]=1 + [PlaymodeTestsSkipped]=1 + [EditmodeSuite]=1 + [PlaymodeSuite]=1 +) + +effective_tests=0 +declare -a selected_tests=() +for raw_test in "${tests[@]}"; do + tname="$(echo "$raw_test" | xargs)" + if [ -n "$tname" ] && [ "$tname" != "None" ]; then + if [ -z "${known_tests[$tname]+x}" ]; then + echo "::error::TESTS_INPUT includes unknown test selection '$tname'" + exit 1 + fi + selected_tests+=("$tname") + effective_tests=$((effective_tests + 1)) + fi +done +if [ "$effective_tests" -eq 0 ]; then + echo "::error::TESTS_INPUT is empty or contains no runnable test entries" + exit 1 +fi + +clean_tests() { + rm -f "$UNITY_PROJECT_PATH/Assets/UnityCliTests"/*.cs 2>/dev/null || true + rm -f "$UNITY_PROJECT_PATH/Assets/Editor/UnityCliTests"/*.cs 2>/dev/null || true + rm -f "$UNITY_PROJECT_PATH/Assets/Tests/PlayMode/UnityCliTests"/*.cs 2>/dev/null || true + rm -f "$UNITY_PROJECT_PATH/Assets/Tests/EditMode/UnityCliTests"/*.cs 2>/dev/null || true + rm -f "$UNITY_PROJECT_PATH/Assets/Tests/EditMode/UnityCliTests"/*.asmdef 2>/dev/null || true + rm -f "$UNITY_PROJECT_PATH/Assets/Tests/EditMode/Editor/UnityCliTests"/*.cs 2>/dev/null || true +} + +clean_build_outputs() { + rm -rf "$UNITY_PROJECT_PATH/Builds" 2>/dev/null || true + mkdir -p "$UNITY_PROJECT_PATH/Builds/Logs" +} + +# Expectations for each synthetic test +# expected_status: 0 = should succeed, 1 = should fail +expected_status_for() { + case "$1" in + CompilerWarnings|BuildWarnings) echo 0 ;; + CompilerErrors|BuildErrors) echo 1 ;; + PlaymodeTestsErrors|EditmodeTestsErrors) echo 1 ;; + EditmodeSuite|PlaymodeSuite) echo 1 ;; + EditmodeTestsPassing|EditmodeTestsSkipped|PlaymodeTestsPassing|PlaymodeTestsSkipped) echo 0 ;; + *) echo 0 ;; + esac +} + +expected_message_for() { + case "$1" in + CompilerErrors) echo "Intentional compiler error" ;; + BuildErrors) echo "Intentional build failure" ;; + PlaymodeTestsErrors|PlaymodeSuite) echo "Intentional playmode failure" ;; + EditmodeTestsErrors|EditmodeSuite) echo "Intentional editmode failure" ;; + CompilerWarnings) echo "Intentional warning" ;; + BuildWarnings) echo "Intentional build warning" ;; + *) echo "" ;; + esac +} + +echo "UTP preflight: selected ${effective_tests} scenario(s): ${selected_tests[*]}" +for tname in "${selected_tests[@]}"; do + echo " - ${tname}: expected_status=$(expected_status_for "$tname") expected_message='$(expected_message_for "$tname")'" +done + +mkdir -p "$GITHUB_WORKSPACE/utp-artifacts" + +for raw_test in "${tests[@]}"; do + test_name="$(echo "$raw_test" | xargs)" + if [ -z "$test_name" ] || [ "$test_name" = "None" ]; then + echo "Skipping empty/None test entry" + continue + fi + + src="$GITHUB_WORKSPACE/unity-tests/${test_name}.cs" + is_suite=0 + case "$test_name" in + EditmodeSuite|PlaymodeSuite) is_suite=1 ;; + esac + if [ "$is_suite" -eq 0 ] && [ ! -f "$src" ]; then + echo "::error::Requested test '$test_name' not found at $src" + failures=$((failures+1)) + continue + fi + + clean_tests + clean_build_outputs + + asmdef_src="" + + case "$test_name" in + CompilerWarnings|CompilerErrors) + dest="$UNITY_PROJECT_PATH/Assets/UnityCliTests" + ;; + BuildWarnings|BuildErrors) + dest="$UNITY_PROJECT_PATH/Assets/Editor/UnityCliTests" + ;; + PlaymodeTestsErrors|PlaymodeTestsPassing|PlaymodeTestsSkipped) + dest="$UNITY_PROJECT_PATH/Assets/Tests/PlayMode/UnityCliTests" + asmdef_src="$GITHUB_WORKSPACE/unity-tests/UnityCliTests.PlayMode.asmdef" + ;; + EditmodeTestsErrors|EditmodeTestsPassing|EditmodeTestsSkipped) + dest="$UNITY_PROJECT_PATH/Assets/Tests/EditMode/UnityCliTests" + asmdef_src="$GITHUB_WORKSPACE/unity-tests/UnityCliTests.EditMode.Editor.asmdef" + ;; + EditmodeSuite) + dest="$UNITY_PROJECT_PATH/Assets/Tests/EditMode/UnityCliTests" + asmdef_src="$GITHUB_WORKSPACE/unity-tests/UnityCliTests.EditMode.Editor.asmdef" + suite_sources="EditmodeTestsErrors,EditmodeTestsPassing,EditmodeTestsSkipped" + ;; + PlaymodeSuite) + dest="$UNITY_PROJECT_PATH/Assets/Tests/PlayMode/UnityCliTests" + asmdef_src="$GITHUB_WORKSPACE/unity-tests/UnityCliTests.PlayMode.asmdef" + suite_sources="PlaymodeTestsErrors,PlaymodeTestsPassing,PlaymodeTestsSkipped" + ;; + *) + echo "::error::Unknown test selection '$test_name'" + failures=$((failures+1)) + continue + ;; + esac + + mkdir -p "$dest" + if [ -n "$asmdef_src" ]; then + if [ ! -f "$asmdef_src" ]; then + echo "::error::Assembly definition for tests not found at $asmdef_src" + failures=$((failures+1)) + continue + fi + cp "$asmdef_src" "$dest/" + fi + + if [ -n "${suite_sources:-}" ]; then + IFS=',' read -ra suite_files <<< "$suite_sources" + for f in "${suite_files[@]}"; do + f="${f// /}" + suite_src="$GITHUB_WORKSPACE/unity-tests/${f}.cs" + if [ -f "$suite_src" ]; then + cp "$suite_src" "$dest/" + fi + done + unset suite_sources + echo "Running suite: $test_name (copied ${#suite_files[@]} test files to $dest)" + elif [ -f "$src" ]; then + cp "$src" "$dest/" + echo "Running test: $test_name (copied to $dest)" + else + echo "::error::Requested test '$test_name' not found at $src" + failures=$((failures+1)) + continue + fi + + validate_rc=0 + build_rc=0 + + ran_custom_flow=0 + expected_for_flow=$(expected_status_for "$test_name") + + if [ "$test_name" = "EditmodeTestsErrors" ] || [ "$test_name" = "EditmodeTestsPassing" ] || [ "$test_name" = "EditmodeTestsSkipped" ] || [ "$test_name" = "EditmodeSuite" ]; then + unity_rc=0 + unity-cli run --log-name "${test_name}-EditMode" -runTests -testPlatform editmode -assemblyNames "UnityCli.EditMode.EditorTests" -testResults "$UNITY_PROJECT_PATH/Builds/Logs/${test_name}-results.xml" -quit || unity_rc=$? + + results_xml="" + if results_xml="$(find_nunit_results_xml "$test_name")"; then + : + else + results_xml="" + fi + + xml_ok=0 + if [ -n "$results_xml" ] && [ -f "$results_xml" ] && grep -q "]" "$results_xml" 2>/dev/null; then + xml_ok=1 + fi + + validate_rc=$unity_rc + if [ "$xml_ok" -eq 0 ]; then + if [ "$unity_rc" -ne 0 ]; then + validate_rc=$unity_rc + elif [ "$expected_for_flow" -eq 0 ] && edit_play_log_suggests_tests_completed_ok "$test_name" "EditMode"; then + validate_rc=0 + echo "::notice::${test_name}: using log-based test completion evidence (no NUnit XML with at expected path)" + elif [ "$expected_for_flow" -eq 0 ]; then + validate_rc=1 + echo "::warning::${test_name}: no NUnit XML with and no trustworthy log completion markers (unity_rc=$unity_rc)" + else + validate_rc=$unity_rc + fi + fi + + build_rc=$validate_rc + ran_custom_flow=1 + fi + + if [ "$test_name" = "PlaymodeTestsErrors" ] || [ "$test_name" = "PlaymodeTestsPassing" ] || [ "$test_name" = "PlaymodeTestsSkipped" ] || [ "$test_name" = "PlaymodeSuite" ]; then + unity_rc=0 + unity-cli run --log-name "${test_name}-PlayMode" -runTests -testPlatform playmode -assemblyNames "UnityCli.PlayMode.Tests" -testResults "$UNITY_PROJECT_PATH/Builds/Logs/${test_name}-results.xml" -quit || unity_rc=$? + + results_xml="" + if results_xml="$(find_nunit_results_xml "$test_name")"; then + : + else + results_xml="" + fi + + xml_ok=0 + if [ -n "$results_xml" ] && [ -f "$results_xml" ] && grep -q "]" "$results_xml" 2>/dev/null; then + xml_ok=1 + fi + + validate_rc=$unity_rc + if [ "$xml_ok" -eq 0 ]; then + if [ "$unity_rc" -ne 0 ]; then + validate_rc=$unity_rc + elif [ "$expected_for_flow" -eq 0 ] && edit_play_log_suggests_tests_completed_ok "$test_name" "PlayMode"; then + validate_rc=0 + echo "::notice::${test_name}: using log-based test completion evidence (no NUnit XML with at expected path)" + elif [ "$expected_for_flow" -eq 0 ]; then + validate_rc=1 + echo "::warning::${test_name}: no NUnit XML with and no trustworthy log completion markers (unity_rc=$unity_rc)" + else + validate_rc=$unity_rc + fi + fi + + build_rc=$validate_rc + ran_custom_flow=1 + fi + + if [ "$ran_custom_flow" -eq 0 ]; then + unity-cli run --log-name "${test_name}-Validate" -quit -executeMethod Utilities.Editor.BuildPipeline.UnityPlayerBuildTools.ValidateProject -importTMProEssentialsAsset || validate_rc=$? + + build_cmd=( + unity-cli run + --log-name "${test_name}-Build" + -buildTarget "$BUILD_TARGET" + -quit + -executeMethod Utilities.Editor.BuildPipeline.UnityPlayerBuildTools.StartCommandLineBuild + -sceneList Assets/Scenes/SampleScene.unity + ) + + if [ ${#build_args[@]} -gt 0 ]; then + build_cmd+=("${build_args[@]}") + fi + + "${build_cmd[@]}" || build_rc=$? + fi + + expected=$(expected_status_for "$test_name") + exp_msg=$(expected_message_for "$test_name") + + test_failed=0 + message_found=0 + utp_error_found=0 + utp_any_signal=0 + + if [ -n "$exp_msg" ]; then + while IFS= read -r log_file; do + if [ -z "$log_file" ]; then + continue + fi + if grep -qi -- "$exp_msg" "$log_file" 2>/dev/null; then + message_found=1 + break + fi + done < <(find "$UNITY_PROJECT_PATH/Builds/Logs" -maxdepth 1 -type f -name "*${test_name}*.log") + fi + + # UTP: severity rules differ for warning-only scenarios vs everything else. + while IFS= read -r utp_file; do + if [ -z "$utp_file" ]; then + continue + fi + if utp_signals_failure_for_expected_success "$test_name" "$utp_file"; then + utp_error_found=1 + break + fi + done < <(find "$UNITY_PROJECT_PATH/Builds/Logs" -maxdepth 1 -type f -name "*${test_name}*-utp-json.log") + + while IFS= read -r utp_file; do + if [ -z "$utp_file" ]; then + continue + fi + if utp_signals_any_severity_problem "$utp_file"; then + utp_any_signal=1 + break + fi + done < <(find "$UNITY_PROJECT_PATH/Builds/Logs" -maxdepth 1 -type f -name "*${test_name}*-utp-json.log") + + if [ "$expected" -eq 0 ]; then + if [ "$validate_rc" -ne 0 ] || [ "$build_rc" -ne 0 ]; then + echo "::error::Test $test_name was expected to succeed but failed (validate_rc=$validate_rc, build_rc=$build_rc)" + test_failed=1 + fi + if [ "$utp_error_found" -eq 1 ]; then + echo "::error::Test $test_name produced UTP errors but was expected to succeed" + test_failed=1 + fi + if [ -n "$exp_msg" ] && [ "$message_found" -eq 0 ]; then + echo "::error::Test $test_name did not emit expected message '$exp_msg'" + test_failed=1 + fi + else + if [ "$validate_rc" -ne 0 ] || [ "$build_rc" -ne 0 ] || [ "$message_found" -eq 1 ] || [ "$utp_any_signal" -eq 1 ]; then + : # Expected failure observed + else + echo "::error::Test $test_name was expected to fail but succeeded" + test_failed=1 + fi + + # Only insist on the expected message if both invocations claimed success. + if [ -n "$exp_msg" ] && [ "$message_found" -eq 0 ] && [ "$validate_rc" -eq 0 ] && [ "$build_rc" -eq 0 ]; then + echo "::error::Test $test_name did not emit expected message '$exp_msg'" + test_failed=1 + fi + fi + + if [ "$test_failed" -eq 0 ]; then + echo "::notice::Test $test_name behaved as expected (validate_rc=$validate_rc, build_rc=$build_rc)" + else + failures=$((failures+1)) + fi + + test_artifacts="$GITHUB_WORKSPACE/utp-artifacts/$test_name" + mkdir -p "$test_artifacts" + logs_dir="$UNITY_PROJECT_PATH/Builds/Logs" + utp_pattern="*${test_name}*-utp-json.log" + # Primary: project Builds/Logs; fallback: workspace (e.g. alternate log roots). Exclude staging and .git. + { + if [ -d "$logs_dir" ]; then + find "$logs_dir" -maxdepth 1 -type f -name "$utp_pattern" -print + fi + if [ -n "${GITHUB_WORKSPACE:-}" ] && [ -d "$GITHUB_WORKSPACE" ]; then + find "$GITHUB_WORKSPACE" \( -path '*/utp-artifacts/*' -o -path '*/.git/*' \) -prune -o -type f -name "$utp_pattern" -print 2>/dev/null + fi + } | sort -u | while IFS= read -r utp_src; do + [ -z "$utp_src" ] && continue + dest_file="$test_artifacts/$(basename "$utp_src")" + if [ ! -f "$dest_file" ]; then + cp "$utp_src" "$dest_file" || true + fi + done || true + # Copy test results XML when present (Edit/Play mode) for later analysis + if nunit_copy="$(find_nunit_results_xml "$test_name")" && [ -n "$nunit_copy" ] && [ -f "$nunit_copy" ]; then + cp "$nunit_copy" "$test_artifacts/" || true + fi + # Copy all Unity Editor/Player logs for this scenario + find "$UNITY_PROJECT_PATH/Builds/Logs" -maxdepth 1 -type f -name "*${test_name}*.log" -exec cp {} "$test_artifacts/" \; 2>/dev/null || true + +done + +if [ "$failures" -gt 0 ]; then + echo "::error::One or more tests did not meet expectations ($failures)" + exit 1 +fi + +exit 0 diff --git a/.github/actions/scripts/utp-ci-assertion-helpers.sh b/.github/actions/scripts/utp-ci-assertion-helpers.sh new file mode 100644 index 00000000..004c34f3 --- /dev/null +++ b/.github/actions/scripts/utp-ci-assertion-helpers.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Shared helpers for UTP CI batch validation (.github/actions/scripts/run-utp-tests.sh). +# Keep behavior in sync with contract tests: tests/run-utp-tests-contract.sh + +# Returns 0 (true) if this UTP JSON log should fail an *expected-success* scenario. +utp_signals_failure_for_expected_success() { + local test_name="$1" + local utp_file="$2" + case "$test_name" in + CompilerWarnings|BuildWarnings) + # Engine / allocator assert telemetry is common here; only treat Error/Exception as hard failures. + grep -qi '"severity"[[:space:]]*:[[:space:]]*"\(Error\|Exception\)"' "$utp_file" 2>/dev/null + ;; + *) + grep -qi '"severity"[[:space:]]*:[[:space:]]*"\(Error\|Exception\|Assert\)"' "$utp_file" 2>/dev/null + ;; + esac +} + +# Returns 0 if UTP log contains any Error/Exception/Assert (used for expected-failure scenarios). +utp_signals_any_severity_problem() { + local utp_file="$1" + grep -qi '"severity"[[:space:]]*:[[:space:]]*"\(Error\|Exception\|Assert\)"' "$utp_file" 2>/dev/null +} + +# Prints first path to an NUnit results file containing , or nothing. +find_nunit_results_xml() { + local test_name="$1" + local f + + for f in \ + "$UNITY_PROJECT_PATH/Builds/Logs/${test_name}-results.xml" \ + "$UNITY_PROJECT_PATH/Builds/Logs/${test_name}-Results.xml"; do + if [ -f "$f" ] && grep -q "]" "$f" 2>/dev/null; then + printf '%s\n' "$f" + return 0 + fi + done + + while IFS= read -r f; do + [ -n "$f" ] || continue + case "$f" in + */PackageCache/*|*/.git/*) continue ;; + esac + if grep -q "]" "$f" 2>/dev/null; then + printf '%s\n' "$f" + return 0 + fi + done < <( + find "$UNITY_PROJECT_PATH" -type f \( \ + -name "${test_name}-results.xml" -o \ + -name "${test_name}-Results.xml" -o \ + -name "*${test_name}*results.xml" -o \ + -name "*${test_name}*Results.xml" \ + \) ! -path "*/PackageCache/*" ! -path "*/.git/*" 2>/dev/null | head -n 80 + ) + + return 1 +} + +# Heuristic: Unity wrote no usable XML but logs show the test runner finished successfully. +edit_play_log_suggests_tests_completed_ok() { + local test_name="$1" + local mode="$2" + local logf + local saw_success=0 + + while IFS= read -r logf; do + [ -z "$logf" ] && continue + [ -f "$logf" ] || continue + # Any explicit failure marker across matching logs should fail the heuristic. + if grep -qiE 'test run failed|one or more child tests failed|failures:[[:space:]]*[1-9]|errors:[[:space:]]*[1-9]' "$logf" 2>/dev/null; then + return 1 + fi + if grep -qiE \ + 'test run completed|tests run:.*passed|total tests:.*failed:[[:space:]]*0(\>|[^0-9]|$)|Executed[[:space:]]+[0-9]+[[:space:]]+tests|Test run[[:space:]]+\[.*\][[:space:]]+finished|NUnit[[:space:]]+Engine|UnityEditor\.TestTools\.TestRunner' \ + "$logf" 2>/dev/null; then + saw_success=1 + fi + done < <(find "$UNITY_PROJECT_PATH/Builds/Logs" -maxdepth 1 -type f -name "*${test_name}*${mode}*.log" 2>/dev/null) + + [ "$saw_success" -eq 1 ] +} diff --git a/.github/scripts/scan-utp-artifacts.cjs b/.github/scripts/scan-utp-artifacts.cjs new file mode 100644 index 00000000..65131c32 --- /dev/null +++ b/.github/scripts/scan-utp-artifacts.cjs @@ -0,0 +1,99 @@ +/** + * CI / local maintenance: scan *-utp-json.log trees and verify every object only uses + * top-level keys that normalizeTelemetryEntry (UTP_SUPPORTED_TOP_LEVEL_PROPERTIES) recognizes. + * Exits non-zero on JSON parse errors or unknown keys. + * + * Kept as CommonJS so `node .github/scripts/scan-utp-artifacts.cjs` can require `dist/utp.js` + * after `npm run build` without ts-node or compiling this file. + * + * Usage: node .github/scripts/scan-utp-artifacts.cjs [directory] + * Default directory: $GITHUB_WORKSPACE/utp-artifacts, else ./utp-artifacts + */ +const fs = require('fs'); +const path = require('path'); +const { normalizeTelemetryEntry } = require(path.join(__dirname, '..', '..', 'dist', 'utp.js')); + +function defaultScanRoot() { + if (process.argv[2]) { + return process.argv[2]; + } + if (process.env.GITHUB_WORKSPACE) { + return path.join(process.env.GITHUB_WORKSPACE, 'utp-artifacts'); + } + return path.join(process.cwd(), 'utp-artifacts'); +} + +const root = path.resolve(defaultScanRoot()); +if (!fs.existsSync(root)) { + console.warn(`scan-utp-artifacts: directory not found (skipping): ${root}`); + process.exit(0); +} +if (!fs.statSync(root).isDirectory()) { + console.warn(`scan-utp-artifacts: not a directory (skipping): ${root}`); + process.exit(0); +} + +const typeCount = new Map(); +const unknownKeyOccurrences = new Map(); +let totalObjects = 0; +const parseErrors = []; + +function walk(dir) { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) { + walk(p); + } else if (e.name.endsWith('-utp-json.log')) { + const raw = fs.readFileSync(p, 'utf8').trim(); + if (!raw) { + continue; + } + let data; + try { + data = JSON.parse(raw); + } catch (err) { + parseErrors.push(`${path.relative(root, p)}: ${err.message}`); + continue; + } + const arr = Array.isArray(data) ? data : [data]; + for (const o of arr) { + if (!o || typeof o !== 'object') { + continue; + } + totalObjects++; + const t = o.type ?? '(missing)'; + typeCount.set(t, (typeCount.get(t) || 0) + 1); + const { unknownTopLevelKeys } = normalizeTelemetryEntry(o); + for (const k of unknownTopLevelKeys) { + unknownKeyOccurrences.set(k, (unknownKeyOccurrences.get(k) || 0) + 1); + } + } + } + } +} + +walk(root); + +const out = { + artifactRoot: root, + totalObjects, + types: Object.fromEntries([...typeCount.entries()].sort((a, b) => b[1] - a[1])), + unknownTopLevelKeys: Object.fromEntries([...unknownKeyOccurrences.entries()].sort((a, b) => b[1] - a[1])), + parseErrorCount: parseErrors.length, + parseErrorsSample: parseErrors.slice(0, 50), +}; +console.log(JSON.stringify(out, null, 2)); + +let code = 0; +if (parseErrors.length > 0) { + console.error(`scan-utp-artifacts: ${parseErrors.length} JSON parse error(s)`); + code = 1; +} +if (unknownKeyOccurrences.size > 0) { + console.error( + 'scan-utp-artifacts: unknown top-level key(s) on UTP objects — extend UTP_SUPPORTED_TOP_LEVEL_PROPERTIES in src/utp.ts:', + [...unknownKeyOccurrences.keys()].join(', ') + ); + code = 1; +} +process.exit(code); diff --git a/.github/workflows/build-options.json b/.github/workflows/build-options.json index 9178fb3a..3d4812cc 100644 --- a/.github/workflows/build-options.json +++ b/.github/workflows/build-options.json @@ -18,7 +18,8 @@ "6000.2", "6000.3", "6000.4", - "6000.5" + "6000.5", + "6000.6" ], "include": [ { diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 06d1bd8e..776b545c 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -10,11 +10,21 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + utp-batch-contract: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - name: UTP batch assertion helpers (contract) + run: bash tests/run-utp-tests-contract.sh setup: if: github.event.pull_request.draft == false runs-on: ubuntu-latest permissions: contents: read + checks: write # to publish unit test results via checks github api steps: - uses: actions/checkout@v6 with: @@ -32,6 +42,7 @@ jobs: name: build ${{ matrix.jobs.name }} permissions: contents: read + checks: write # required by nested unity-build workflow strategy: matrix: ${{ fromJSON(needs.setup.outputs.jobs) }} fail-fast: false @@ -40,12 +51,24 @@ jobs: uses: ./.github/workflows/unity-build.yml with: matrix: ${{ toJSON(matrix.jobs.matrix) }} + utp-test-profile: normal + validate-negative-scenarios: + if: github.event.pull_request.draft == false + name: build negative-scenarios + permissions: + contents: read + checks: write + secrets: inherit + uses: ./.github/workflows/unity-build.yml + with: + matrix: '{"include":[{"os":"ubuntu-latest","unity-version":"6000.1","build-target":"StandaloneLinux64","name":"negative-scenarios / ubuntu-latest StandaloneLinux64"}]}' + utp-test-profile: negative timeline: - needs: [setup, validate] + needs: [setup, validate, validate-negative-scenarios] if: always() runs-on: ubuntu-latest permissions: contents: read steps: - - uses: Kesin11/actions-timeline@c2f474758e8e9ac6f37ec64a6442dead7fd1dad2 # v2.2.5 + - uses: Kesin11/actions-timeline@44c9c178ffb2fb1d9859614a3ffa79ccfb77565e # v3.1.0 continue-on-error: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 564f9015..62ed36d8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,4 @@ -name: Publish +name: publish on: push: branches: [main] diff --git a/.github/workflows/unity-build.yml b/.github/workflows/unity-build.yml index fae4712e..f85dda16 100644 --- a/.github/workflows/unity-build.yml +++ b/.github/workflows/unity-build.yml @@ -5,6 +5,10 @@ on: matrix: required: true type: string + utp-test-profile: + required: false + type: string + default: normal secrets: UNITY_USERNAME: required: true @@ -17,15 +21,19 @@ jobs: strategy: matrix: ${{ fromJSON(inputs.matrix) }} fail-fast: false + permissions: + contents: read + checks: write # to publish unit test results via checks github api defaults: run: shell: bash env: UNITY_PROJECT_PATH: '' # Create Unity Project step will set this if needed + UNITY_CLI_WORKFLOW_SUMMARY: 'true' # opt-in: append rich markdown to GITHUB_STEP_SUMMARY from unity-cli log parsing steps: - name: Free Disk Space if: ${{ matrix.os == 'ubuntu-latest' && (matrix.unity-version != '2018' && matrix.unity-version != '2017.4.40f1') }} - uses: endersonmenezes/free-disk-space@e6ed9b02e683a3b55ed0252f1ee469ce3b39a885 # v3.1.0 + uses: endersonmenezes/free-disk-space@7901478139cff6e9d44df5972fd8ab8fcade4db1 # v3.2.2 with: remove_android: true remove_dotnet: false @@ -50,6 +58,13 @@ jobs: unity-cli hub-install --auto-update fi unity-cli hub-version + unity-cli upm-install --json + UPM_VER=$(unity-cli upm-version | tr -d '\n\r') + echo "UPM CLI version: ${UPM_VER}" + if ! [[ "${UPM_VER}" =~ ^v[0-9] ]]; then + echo "::error::upm-version expected a v-prefixed release (e.g. v9.0.0); got: ${UPM_VER}" + exit 1 + fi if [ "${{ matrix.unity-version }}" != "none" ]; then unity-cli setup-unity --unity-version "${{ matrix.unity-version }}" --build-targets "${{ matrix.build-target }}" --json fi @@ -77,7 +92,22 @@ jobs: if: ${{ matrix.unity-version != 'none' }} run: | unity-cli list-project-templates --unity-editor "${UNITY_EDITOR_PATH}" --json - unity-cli create-project --name "Unity Project" --unity-editor "${UNITY_EDITOR_PATH}" --json + create_rc=1 + for attempt in 1 2 3; do + echo "Create Unity Project attempt ${attempt}/3" + if unity-cli create-project --name "Unity Project" --unity-editor "${UNITY_EDITOR_PATH}" --json; then + create_rc=0 + break + fi + if [ "$attempt" -lt 3 ]; then + echo "::warning::create-project failed on attempt ${attempt}; retrying after short backoff" + sleep 10 + fi + done + if [ "$create_rc" -ne 0 ]; then + echo "::error::Failed to create Unity project after 3 attempts" + exit 1 + fi - name: Verify UNITY_PROJECT_PATH variable if: ${{ matrix.unity-version != 'none' }} id: verify-project-path @@ -101,28 +131,94 @@ jobs: else echo "Skipping build: Unity version $version does not support the build pipeline package (requires 2019.4+)" fi - - name: Install OpenUPM and build pipeline package - if: ${{ steps.verify-project-path.outputs.RUN_BUILD == 'true' }} - working-directory: ${{ env.UNITY_PROJECT_PATH }} - run: | - npm install -g openupm-cli - openupm add com.utilities.buildpipeline - name: Update Android Target Sdk Version - if: ${{ matrix.build-target == 'Android' }} + if: ${{ matrix.build-target == 'Android' && matrix.unity-version != 'none' }} run: | # update AndroidTargetSdkVersion to 32 in ProjectSettings/ProjectSettings.asset sed -i 's/AndroidTargetSdkVersion: [0-9]*/AndroidTargetSdkVersion: 32/' "${UNITY_PROJECT_PATH}/ProjectSettings/ProjectSettings.asset" - # ensure android dependencies are installed + # ensure android dependencies are installed before UTP/build batches unity-cli setup-unity -p "${UNITY_PROJECT_PATH}" -m android - - name: Build Project + - name: Compute safe artifact name + id: artifact-name + env: + MATRIX_NAME: ${{ matrix.name }} + run: | + set -euo pipefail + unity_version="${{ matrix.unity-version }}" + unity_version="${unity_version//'*'/x}" + bt="${{ matrix.build-target }}" + bt="${bt:-none}" + # Per-job slug so parallel matrix rows never collide (name encodes the job-builder row). + base="${{ matrix.os }}-${unity_version}-${bt}" + ba="${{ matrix.build-args }}" + if [ -n "${ba}" ]; then + if command -v sha256sum >/dev/null 2>&1; then + bah=$(printf '%s' "$ba" | sha256sum | awk '{print $1}' | cut -c1-12) + else + bah=$(printf '%s' "$ba" | shasum -a 256 2>/dev/null | awk '{print $1}' | cut -c1-12) + fi + base="${base}-ba${bah}" + fi + mname="${MATRIX_NAME:-}" + if [ -n "$mname" ]; then + slug=$(printf '%s' "$mname" | sed 's/[^a-zA-Z0-9._-]/_/g' | cut -c1-100) + base="${slug}__${base}" + fi + echo "name=${base}-utp-batch-logs" >> $GITHUB_OUTPUT + shell: bash + - name: Run Unity UTP test batches + if: ${{ steps.verify-project-path.outputs.RUN_BUILD == 'true' }} + uses: ./.github/actions/run-unity-test-batch + with: + unity-project-path: ${{ env.UNITY_PROJECT_PATH }} + build-target: ${{ matrix.build-target }} + build-args: ${{ matrix.build-args }} + test-profile: ${{ inputs.utp-test-profile }} + artifact-name: ${{ steps.artifact-name.outputs.name }} + - name: Verify UTP JSON keys + if: ${{ steps.verify-project-path.outputs.RUN_BUILD == 'true' }} + run: node .github/scripts/scan-utp-artifacts.cjs "${GITHUB_WORKSPACE}/utp-artifacts" + - name: Guardrail hidden UTP failures if: ${{ steps.verify-project-path.outputs.RUN_BUILD == 'true' }} - timeout-minutes: 60 run: | - # we don't have to specify the project path or unity editor path as unity-cli will use the environment variables - unity-cli run --log-name Validate -quit -executeMethod Utilities.Editor.BuildPipeline.UnityPlayerBuildTools.ValidateProject -importTMProEssentialsAsset - unity-cli run --log-name Build -buildTarget ${{ matrix.build-target }} -quit -executeMethod Utilities.Editor.BuildPipeline.UnityPlayerBuildTools.StartCommandLineBuild -sceneList Assets/Scenes/SampleScene.unity ${{ matrix.build-args }} + set -euo pipefail + # Keep this alternation in sync with hard failures from .github/actions/scripts/run-utp-tests.sh + failure_markers='One or more tests did not meet expectations|was expected to succeed but failed|produced UTP errors but was expected to succeed' + log_dir="${UNITY_PROJECT_PATH}/Builds/Logs" + artifacts_dir="${GITHUB_WORKSPACE}/utp-artifacts" + + marker_found=0 + + scan_markers() { + local target_dir="$1" + if command -v rg >/dev/null 2>&1; then + rg -n --no-ignore -S "$failure_markers" "$target_dir" + else + grep -RInE "$failure_markers" "$target_dir" + fi + } + + if [ -d "$log_dir" ]; then + if scan_markers "$log_dir"; then + echo "::error::Hidden UTP failure marker detected in ${log_dir}" + marker_found=1 + fi + fi + + if [ -d "$artifacts_dir" ]; then + if scan_markers "$artifacts_dir"; then + echo "::error::Hidden UTP failure marker detected in ${artifacts_dir}" + marker_found=1 + fi + fi + + if [ "$marker_found" -ne 0 ]; then + exit 1 + fi - name: Uninstall Editor if: ${{ matrix.unity-version != 'none' }} + timeout-minutes: 5 + continue-on-error: true run: | if [ -z "${UNITY_EDITOR_PATH}" ]; then echo "UNITY_EDITOR_PATH is not set, skipping uninstall" @@ -136,12 +232,12 @@ jobs: PACKAGE_MANAGER_LOG_PATH=$(unity-cli package-manager-logs) LICENSING_CLIENT_LOG_PATH=$(unity-cli licensing-client-logs) LICENSING_AUDIT_LOG_PATH=$(unity-cli licensing-audit-logs) - + echo "Hub Log Path: ${HUB_LOG_PATH}" echo "Package Manager Log Path: ${PACKAGE_MANAGER_LOG_PATH}" echo "Licensing Client Log Path: ${LICENSING_CLIENT_LOG_PATH}" echo "Licensing Audit Log Path: ${LICENSING_AUDIT_LOG_PATH}" - + if [ ! -f "${HUB_LOG_PATH}" ]; then echo "::warning:: Hub log file does not exist at ${HUB_LOG_PATH}" # find all info-log.json files in ~/.config/unity3d/ - print their paths @@ -155,15 +251,15 @@ jobs: find ~/.config/ -type f -exec echo "{}" \; echo "::warning:: Hub log file does not exist at any known location" fi - + if [ ! -f "${PACKAGE_MANAGER_LOG_PATH}" ]; then echo "::warning::Package Manager log file does not exist at ${PACKAGE_MANAGER_LOG_PATH}" fi - + if [ ! -f "${LICENSING_CLIENT_LOG_PATH}" ]; then echo "::error::Licensing Client log file does not exist at ${LICENSING_CLIENT_LOG_PATH}" fi - + if [ ! -f "${LICENSING_AUDIT_LOG_PATH}" ]; then echo "::error::Licensing Audit log file does not exist at ${LICENSING_AUDIT_LOG_PATH}" fi diff --git a/.gitignore b/.gitignore index 2e92ab99..f2017871 100644 --- a/.gitignore +++ b/.gitignore @@ -137,4 +137,6 @@ dist # Vite logs files vite.config.js.timestamp-* vite.config.ts.timestamp-* -_temp + +.artifacts/ +_temp/ diff --git a/README.md b/README.md index e6ea61a6..1ea5429d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,12 @@ [![Discord](https://img.shields.io/discord/855294214065487932.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/xQgMW9ufN4) [![NPM Version](https://img.shields.io/npm/v/%40rage-against-the-pixel%2Funity-cli)](https://www.npmjs.com/package/@rage-against-the-pixel/unity-cli) [![NPM Downloads](https://img.shields.io/npm/dw/%40rage-against-the-pixel%2Funity-cli)](https://www.npmjs.com/package/@rage-against-the-pixel/unity-cli) -A powerful command line utility for the Unity Game Engine. Automate Unity project setup, editor installation, license management, building, and more—ideal for CI/CD pipelines and developer workflows. +A powerful all-in-one command line utility for the Unity Game Engine. Automate Unity project setup, editor installation, license management, building, upm package signing and more! Built specifially for CI/CD pipelines and developer workflows. + +> [!IMPORTANT] +> The documented commands can download, install, or run software from Unity (Hub, Editor, Package Manager CLI, licensing tools, and similar binaries from Unity CDNs or services). That use is covered by Unity’s [Terms of Service](https://unity.com/legal/terms-of-service), the [Unity Editor Software Additional Terms](https://unity.com/legal/terms-of-service/software), and any other [Additional Terms](https://unity.com/legal/additional-terms) that apply to the offerings you use. Keep your Unity account, seats, and subscriptions in order, and read the agreements that actually bind you before relying on automation in CI or production. The full legal index is at [Unity Legal](https://unity.com/legal). +> +> Unity, Unity Hub, Unity Editor, and related names and logos are trademarks and other intellectual property of Unity Technologies Inc. and its affiliates. This project is independent and not affiliated with Unity. Names are used here only to describe what the commands talk to. If you ship Unity marks, artwork, or binaries, use Unity’s guidance, including their [IP policy](https://unity.com/legal/ip-policy-takedown-requests). ## Table of Contents @@ -10,30 +15,34 @@ A powerful command line utility for the Unity Game Engine. Automate Unity projec - [Installation](#installation) - [Usage](#usage) - [Common Commands](#common-commands) - - [Auth](#auth) - - [License Version](#license-version) - - [Activate License](#activate-license) - - [Return License](#return-license) - - [License Context](#license-context) - - [Licensing Client Logs](#licensing-client-logs) - - [Licensing Audit Logs](#licensing-audit-logs) - - [Unity Hub](#unity-hub) - - [Hub Version](#hub-version) - - [Hub Path](#hub-path) - - [Hub Logs](#hub-logs) - - [Package Manager Logs](#package-manager-logs) - - [Unity Hub Install](#unity-hub-install) - - [Run Unity Hub Commands](#run-unity-hub-commands) - - [Setup Unity Editor](#setup-unity-editor) - - [Uninstall Unity Editor](#uninstall-unity-editor) - - [Unity Editor](#unity-editor) - - [Run Unity Editor Commands](#run-unity-editor-commands) - - [List Project Templates](#list-project-templates) - - [Create Unity Project](#create-unity-project) - - [Open Unity Project](#open-unity-project) - - [Unity Editor Logs](#unity-editor-logs) - - [Unity Package Manager](#unity-package-manager) - - [Sign a Unity Package](#sign-a-unity-package) + - [Install all tools](#install-all-tools) + - [Auth](#auth) + - [License Version](#license-version) + - [Activate License](#activate-license) + - [Return License](#return-license) + - [License Context](#license-context) + - [Licensing Client Logs](#licensing-client-logs) + - [Licensing Audit Logs](#licensing-audit-logs) + - [Unity Hub](#unity-hub) + - [Hub Version](#hub-version) + - [Hub Path](#hub-path) + - [Hub Logs](#hub-logs) + - [Package Manager Logs](#package-manager-logs) + - [Unity Hub Install](#unity-hub-install) + - [Run Unity Hub Commands](#run-unity-hub-commands) + - [Setup Unity Editor](#setup-unity-editor) + - [Uninstall Unity Editor](#uninstall-unity-editor) + - [Unity Editor](#unity-editor) + - [Run Unity Editor Commands](#run-unity-editor-commands) + - [List Project Templates](#list-project-templates) + - [Create Unity Project](#create-unity-project) + - [Open Unity Project](#open-unity-project) + - [Unity Editor Logs](#unity-editor-logs) + - [Unity Package Manager](#unity-package-manager) + - [Install Unity Package Manager](#install-unity-package-manager) + - [UPM Version](#upm-version) + - [Pack a Unity Package](#pack-a-unity-package) + - [Deprecated Sign Package Command](#deprecated-sign-package-command) - [Logging](#logging) - [Local cli](#local-cli) - [Github Actions](#github-actions) @@ -81,9 +90,23 @@ With options always using double dashes (`--option`) and arguments passed direct unity-cli --help ``` -#### Auth +### Install all tools + +`install-all-tools`: Install the Unity Hub and the Unity Package Manager cli (pack/sign). Runs `hub-install` and `upm-install` in parallel. Use `unity-cli install-all-tools --help` for all options. + +- `--verbose`: Enable verbose logging. +- `--auto-update`: If any tools are installed, they're automatically updated to the latest versions. Cannot be used with `--hub-version` or `--upm-version`. +- `--hub-version `: Specify to install a specific version of Unity Hub. Cannot be used with `--auto-update`. +- `--upm-version `: Specify to install a specific version of the Unity Package Manager cli. Cannot be used with `--auto-update`. +- `--json`: Print hub path, UPM CLI version, and resolved UPM CLI path as JSON. + +```bash +unity-cli install-all-tools --auto-update +``` + +### Auth -##### License Version +#### License Version `license-version`: Print the Unity License Client version. @@ -101,7 +124,7 @@ unity-cli license-version - `-s`, `--serial`: License serial number. Required when activating a professional license. - `-c`, `--config`: Path to the configuration file, raw JSON, or base64 encoded JSON string. Required when activating a floating license. - `--json`: Prints the last line of output as JSON string. -- `--verbose`: Enable verbose output. +- `--verbose`: Enable verbose logging. ```bash unity-cli activate-license --license personal --email --password @@ -113,21 +136,21 @@ unity-cli activate-license --license personal --email --password `: Run Unity Hub command line arguments (passes args directly to the hub executable). +`hub [options] `: Run commands directly to the Unity Hub. (You need not to pass `--headless` or `--` to this command). -- `--verbose`: Enable verbose output. +- `--verbose`: Enable verbose logging. - `--json`: Prints the last line of output as a json string, which contains the operation results. - ``: Arguments to pass directly to the Unity Hub executable. @@ -210,7 +235,7 @@ Gets a list of installed editors: unity-cli hub editors --installed ``` -##### Setup Unity Editor +#### Setup Unity Editor `setup-unity [options]`: Find or install the Unity Editor for a project or specific version. @@ -230,7 +255,7 @@ Installs the latest Unity 6 version with Android and iOS modules: unity-cli setup-unity --unity-version 6000 --modules android,ios ``` -##### Uninstall Unity Editor +#### Uninstall Unity Editor `uninstall-unity [options]`: Uninstall a Unity Editor version. @@ -244,17 +269,17 @@ unity-cli setup-unity --unity-version 6000 --modules android,ios unity-cli uninstall-unity --unity-version 6000 ``` -#### Unity Editor +### Unity Editor -##### Run Unity Editor Commands +#### Run Unity Editor Commands `run [options] `: Run Unity Editor command line arguments (passes args directly to the editor). - `--unity-editor ` The path to the Unity Editor executable. If unspecified, `--unity-project` or the `UNITY_EDITOR_PATH` environment variable must be set. - `--unity-project ` The path to a Unity project. If unspecified, the `UNITY_PROJECT_PATH` environment variable will be used, otherwise no project will be specified. - `--log-name ` The name of the log file. -- `--log-level ` Override the logger verbosity (`debug`, `info`, `minimal`, `warning`, `error`). Defaults to `info`. -- `--verbose` Enable verbose logging. (Deprecated, use `--log-level ` instead) +- `--log-level ` Override the logger verbosity (debug, info, minimal, warning, error). Defaults to info. +- `--verbose` Enable verbose logging. Deprecated, use `--log-level` instead. - `` Arguments to pass directly to the Unity Editor executable. > [!NOTE] @@ -266,17 +291,15 @@ unity-cli uninstall-unity --unity-version 6000 unity-cli run --unity-project -quit -batchmode -executeMethod StartCommandLineBuild ``` -##### List Project Templates +#### List Project Templates > [!NOTE] > Regex patterns are supported for the `--template` option. For example, to create a 3D project with either the standard or cross-platform template, you can use `com.unity.template.3d(-cross-platform)?`. `list-project-templates [options]`: List available Unity project templates for an editor. -- `-e`, `--unity-editor ` The path to the Unity Editor executable. If unspecified, `-u`, `--unity-version` or the `UNITY_EDITOR_PATH` environment variable must be set. - `-u`, `--unity-version ` The Unity version to get (e.g. `2020.3.1f1`, `2021.x`, `2022.1.*`, `6000`). If unspecified, then `--unity-editor` must be specified. -- `-c`, `--changeset ` The Unity changeset to get (e.g. `1234567890ab`). -- `-a`, `--arch ` The Unity architecture to get (e.g. `x86_64`, `arm64`). Defaults to the architecture of the current process. +- `-e`, `--unity-editor ` The path to the Unity Editor executable. If unspecified, `-u`, `--unity-version` or the `UNITY_EDITOR_PATH` environment variable must be set. - `--verbose` Enable verbose logging. - `--json` Prints the last line of output as JSON string. @@ -286,9 +309,9 @@ Lists available project templates for Unity 6: unity-cli list-project-templates --unity-version 6000 ``` -##### Create Unity Project +#### Create Unity Project -`create-project [options]`: Create a new Unity project from a template. +`create-project [options]`: Create a new Unity project. - `-n`, `--name ` The name of the new Unity project. If unspecified, the project will be created in the specified path or the current working directory. - `-p`, `--path ` The path to create the new Unity project. If unspecified, the current working directory will be used. @@ -305,7 +328,7 @@ Creates a new Unity project named "MyGame" using the latest version of Unity 6 a unity-cli create-project --name "MyGame" --template com.unity.template.3d(-cross-platform)? --unity-version 6000 ``` -##### Open Unity Project +#### Open Unity Project `open-project [options]`: Open a Unity project in the Unity Editor. @@ -327,7 +350,7 @@ unity-cli open-project --unity-project --unity-version 6000 -- unity-cli open-project ``` -##### Unity Editor Logs +#### Unity Editor Logs `editor-logs`: Prints the path to the Unity Editor log files. @@ -335,16 +358,54 @@ unity-cli open-project unity-cli editor-logs ``` -#### Unity Package Manager +### Unity Package Manager + +#### Install Unity Package Manager + +`upm-install [options]`: Download and install the Unity Package Manager cli (pack/sign). + +- `--auto-update`: Automatically updates the upm cli if it is already installed and a newer release is available. Cannot be used with `--version`. +- `--version `: Specify to install a specific version of the Unity Package Manager cli. Cannot be used with `--auto-update`. +- `--json`: Print version and managed paths as JSON. +- `--verbose`: Enable verbose logging. + +```bash +unity-cli upm-install --auto-update +``` + +#### UPM Version + +`upm-version`: Print the Unity Package Manager cli version. + +```bash +unity-cli upm-version +``` + +#### Pack a Unity Package + +**Prerequisites:** In the [Unity Cloud Dashboard](https://cloud.unity.com/), create a **service account** on the organization you use for signing. When you assign **organization** access, open **Manage organization roles**, set the **Package Manager** role to **Package Manager Package Signer**, and save. Put the generated key id and secret in `UPM_SERVICE_ACCOUNT_KEY_ID` and `UPM_SERVICE_ACCOUNT_KEY_SECRET` (or your CI secret store). Copy **Organization ID** from **Administration** → **Settings** in that same org. If you have multiple orgs, switch to the correct one in the dashboard before creating keys or copying the id. + +`upm-pack [options]`: Sign and pack a Unity package. + +- `--source `: An absolute or relative path to the root folder of the custom package to pack. This is the folder that contains the package manifest file (package.json). (optional; defaults to the current working directory). +- `--destination `: The output path for the signed tarball. If you specify a folder that doesn’t exist, it will be created for you. Note: If you omit this parameter, the tarball will be placed in the current working directory. +- `--verbose`: Enable verbose logging. + +> [!NOTE] +> Set `UNITY_ORGANIZATION_ID` or `UNITY_ORG_ID`, `UPM_SERVICE_ACCOUNT_KEY_ID`, and `UPM_SERVICE_ACCOUNT_KEY_SECRET`, or leave them unset in an interactive terminal to be prompted securely. + +```bash +unity-cli upm-pack --source --destination +``` -##### Sign a Unity Package +#### Deprecated Sign Package Command > [!WARNING] -> This command feature is in beta and may change in future releases. +> **Deprecated:** The `sign-package` command is deprecated. Use `unity-cli upm-pack --source --destination ` with `UNITY_ORGANIZATION_ID` or `UNITY_ORG_ID`, `UPM_SERVICE_ACCOUNT_KEY_ID`, and `UPM_SERVICE_ACCOUNT_KEY_SECRET` (or secure prompts). -`sign-package [options]`: Sign a Unity package for distribution. +`sign-package [options]`: [Deprecated] Sign a Unity package using Unity Editor 6000.3+ batch mode (`-upmPack`). Use `unity-cli upm-pack` with organization and service account credentials for new workflows. -- `--package ` Required. The fully qualified path to the folder that contains the package.json file for the package you want to sign. Note: Don’t include package.json in this parameter value. +- `--package ` Required. The fully qualified path to the folder that contains the package.json file for the package you want to sign. Note: Don't include package.json in this parameter value. - `--output ` Optional. The output directory where you want to save the signed tarball file (.tgz). If unspecified, the package contents will be updated in place with the signed .attestation.p7m file. - `--email ` Email associated with the Unity account. If unspecified, the `UNITY_USERNAME` environment variable will be used. - `--password ` The password of the Unity account. If unspecified, the `UNITY_PASSWORD` environment variable will be used. @@ -374,7 +435,8 @@ When `GITHUB_ACTIONS=true`, the logger emits GitHub workflow commands automatica - Defaults to `info` level; add `--verbose` (or temporarily set `ACTIONS_STEP_DEBUG=true`) to surface `debug` lines. - `Logger.annotate(...)` escapes `%`, `\r`, and `\n`, then includes `file`, `line`, `endLine`, `col`, `endColumn`, and `title` metadata so annotations are clickable in the Checks UI. - `startGroup`/`endGroup` become `::group::` / `::endgroup::` blocks. -- Helper methods (`CI_mask`, `CI_setEnvironmentVariable`, `CI_setOutput`, `CI_appendWorkflowSummary`) write to the corresponding GitHub-provided files, so secrets stay masked and workflow outputs update automatically. +- `CI_mask`, `CI_setEnvironmentVariable`, and `CI_setOutput` write to the corresponding GitHub-provided files when those features are configured. +- **Job summary (`GITHUB_STEP_SUMMARY`) is opt-in:** set `UNITY_CLI_WORKFLOW_SUMMARY` to `1`, `true`, `yes`, or `on` (case-insensitive) so `CI_appendWorkflowSummary` can append the rich markdown block from Unity log / UTP parsing. If unset, summary output is skipped (annotations and stdout behavior are unchanged). The same command line you run locally therefore produces colorized console output on your machine and rich annotations once it runs inside Actions. diff --git a/package-lock.json b/package-lock.json index 8bb40984..e227c764 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rage-against-the-pixel/unity-cli", - "version": "1.8.3", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rage-against-the-pixel/unity-cli", - "version": "1.8.3", + "version": "2.0.0", "license": "MIT", "dependencies": { "@electron/asar": "^4.2.0", @@ -17,7 +17,7 @@ "source-map-support": "^0.5.21", "tar": "^7.5.13", "update-notifier": "^7.3.1", - "yaml": "^2.8.3" + "yaml": "^2.8.4" }, "bin": { "unity-cli": "dist/cli.js" @@ -31,6 +31,9 @@ "ts-jest": "^29.4.9", "ts-node": "^10.9.2", "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.12.0" } }, "node_modules/@babel/code-frame": { @@ -49,9 +52,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", "dev": true, "license": "MIT", "engines": { @@ -240,9 +243,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "dev": true, "license": "MIT", "dependencies": { @@ -2311,9 +2314,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.21", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", - "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2445,9 +2448,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001790", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", - "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", "dev": true, "funding": [ { @@ -2825,9 +2828,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.344", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", - "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", "dev": true, "license": "ISC" }, @@ -6472,9 +6475,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 8da8d46d..4763619c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rage-against-the-pixel/unity-cli", - "version": "1.8.3", + "version": "2.0.0", "description": "A command line utility for the Unity Game Engine.", "author": "RageAgainstThePixel", "license": "MIT", @@ -12,6 +12,9 @@ "url": "https://github.com/RageAgainstThePixel/unity-cli/issues" }, "homepage": "https://github.com/RageAgainstThePixel/unity-cli#readme", + "engines": { + "node": ">=22.12.0" + }, "keywords": [ "unity-cli", "unity", @@ -44,6 +47,8 @@ "build": "tsc", "dev": "tsc --watch", "tests": "jest --roots tests", + "test:utp-batch-contract": "bash tests/run-utp-tests-contract.sh", + "scan-utp-artifacts": "node .github/scripts/scan-utp-artifacts.cjs", "link": "npm link", "unlink": "npm unlink @rage-against-the-pixel/unity-cli" }, @@ -56,7 +61,7 @@ "source-map-support": "^0.5.21", "tar": "^7.5.13", "update-notifier": "^7.3.1", - "yaml": "^2.8.3" + "yaml": "^2.8.4" }, "devDependencies": { "@types/jest": "^30.0.0", diff --git a/src/cli.ts b/src/cli.ts index c24a617f..61ed8088 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,6 +16,7 @@ import { ChildProcess, spawn } from 'child_process'; import { CheckAndroidSdkInstalled } from './android-sdk'; import { LicenseType, LicensingClient } from './license-client'; import { PromptForSecretInput, ResolveGlobToPath } from './utilities'; +import { UpmCli, UpmPackOptions } from './upm-cli'; const pkgPath = path.join(__dirname, '..', 'package.json'); const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); @@ -23,13 +24,93 @@ updateNotifier({ pkg }).notify(); const program = new Command(); program.name('unity-cli') - .description('A command line utility for the Unity Game Engine.') + .description('A powerful all-in-one command line utility for the Unity Game Engine.') .version(pkg.version); +program.command('install-all-tools') + .description('Install the Unity Hub and the Unity Package Manager cli (pack/sign). Runs hub-install and upm-install in parallel.') + .option('--verbose', 'Enable verbose logging.') + .option('--auto-update', 'If any tools are installed, they\'re automatically updated to the latest versions. Cannot be used with --hub-version or --upm-version.') + .option('--hub-version ', 'Specify to install a specific version of Unity Hub. Cannot be used with --auto-update.') + .option('--upm-version ', 'Specify to install a specific version of the Unity Package Manager cli. Cannot be used with --auto-update.') + .option('--json', 'Print hub path, UPM CLI version, and resolved UPM CLI path as JSON.') + .action(async (options) => { + if (options.verbose) { + Logger.instance.logLevel = LogLevel.DEBUG; + } + + Logger.instance.debugOptions(options); + + if (options.autoUpdate === true && options.hubVersion) { + Logger.instance.error('Cannot use --auto-update with --hub-version.'); + process.exit(1); + } + + if (options.autoUpdate === true && options.upmVersion) { + Logger.instance.error('Cannot use --auto-update with --upm-version.'); + process.exit(1); + } + + try { + const unityHub = new UnityHub(); + const upm = new UpmCli(); + let upmRequestedVersion = options.upmVersion?.toString()?.trim(); + if (options.autoUpdate === true) { + const currentVersion = upm.GetInstalledReleaseTag(); + const latestVersion = await upm.GetLatestReleaseTag(); + if (currentVersion && !upm.IsUpdateAvailable(latestVersion)) { + Logger.instance.info(`Upm cli is already up to date (${currentVersion}).`); + upmRequestedVersion = currentVersion; + } else { + if (currentVersion) { + Logger.instance.info(`Updating upm cli ${currentVersion} -> ${latestVersion}...`); + } + upmRequestedVersion = latestVersion; + } + } + + const [hubPath, upmVer] = await Promise.all([ + unityHub.Install(options.autoUpdate === true, options.hubVersion), + upm.Install({ + version: upmRequestedVersion, + skipIfInstalled: true + }), + ]); + Logger.instance.CI_setEnvironmentVariable('UNITY_HUB_PATH', hubPath); + let upmCliPath: string | undefined; + try { + await upm.Version(upmVer); + upmCliPath = upm.executable; + } catch (verifyError) { + Logger.instance.warn(`Upm cli version check failed after install: ${verifyError}`); + if (process.env.CI === 'true') { + Logger.instance.error('Failing in CI because the installed UPM CLI did not match the expected release.'); + process.exit(1); + } + upmCliPath = upm.ResolveManagedPrimaryPath(); + } + + if (options.json) { + process.stdout.write(`\n${JSON.stringify({ + UNITY_HUB_PATH: hubPath, + UPM_VERSION: upmVer, + UPM_CLI_PATH: upmCliPath, + })}\n`); + } else { + process.stdout.write(`Unity Hub: ${hubPath}\nUpm cli: ${upmVer}\n${upmCliPath ?? ''}\n`); + } + + process.exit(0); + } catch (error) { + Logger.instance.error(`${error}`); + process.exit(1); + } + }); + program.commandsGroup('Auth:'); program.command('license-version') - .description('Print the version of the Unity License Client.') + .description('Print the Unity License Client version.') .action(async () => { const client = new LicensingClient(); await client.Version(); @@ -104,7 +185,7 @@ program.command('activate-license') program.command('return-license') .description('Return a Unity license.') .option('-l, --license ', 'License type (personal, professional, floating)') - .option('-t, --token ', 'Token received when acquiring a floating license lease. Required when returning a floating license.') + .option('-t, --token ', 'Floating license token. Required when returning a floating license.') .option('--verbose', 'Enable verbose logging.') .action(async (options) => { if (options.verbose) { @@ -149,7 +230,7 @@ program.command('return-license') }); program.command('license-context') - .description('Display the context information of the Unity Licensing Client.') + .description('Print the current license context information.') .action(async () => { const client = new LicensingClient(); await client.Context(); @@ -164,7 +245,7 @@ program.command('licensing-client-logs') }); program.command('licensing-audit-logs') - .description('Prints the path to the Unity Licensing Client audit log file.') + .description('Prints the path to the Unity Licensing Client audit log.') .action(async () => { process.stdout.write(`${LicensingClient.ClientAuditLogPath()}\n`); process.exit(0); @@ -173,7 +254,7 @@ program.command('licensing-audit-logs') program.commandsGroup('Unity Hub:'); program.command('hub-version') - .description('Print the version of the Unity Hub.') + .description('Print the Unity Hub version.') .action(async () => { const unityHub = new UnityHub(); try { @@ -218,7 +299,7 @@ program.command('package-manager-logs') }); program.command('hub-install') - .description('Install the Unity Hub.') + .description('Install or update the Unity Hub.') .option('--verbose', 'Enable verbose logging.') .option('--auto-update', 'Automatically updates the Unity Hub if it is already installed. Cannot be used with --hub-version.') .option('--hub-version ', 'Specify to install a specific version of Unity Hub. Cannot be used with --auto-update.') @@ -273,7 +354,7 @@ program.command('hub') }); program.command('setup-unity') - .description('Sets up the environment for the specified project and finds or installs the Unity Editor version for it.') + .description('Find or install the Unity Editor for a project or specific version.') .option('-p, --unity-project ', 'The path to a Unity project or "none" to skip project detection.') .option('-u, --unity-version ', 'The Unity version to get (e.g. 2020.3.1f1, 2021.x, 2022.1.*, 6000). If specified, it will override the version read from the project.') .option('-c, --changeset ', 'The Unity changeset to get (e.g. 1234567890ab).') @@ -370,7 +451,7 @@ program.command('setup-unity') }); program.command('uninstall-unity') - .description('Uninstall the specified Unity Editor version.') + .description('Uninstall a Unity Editor version.') .option('-e, --unity-editor ', 'The path to the Unity Editor executable. If unspecified, -u, --unity-version or the UNITY_EDITOR_PATH environment variable must be set.') .option('-u, --unity-version ', 'The Unity version to get (e.g. 2020.3.1f1, 2021.x, 2022.1.*, 6000). If unspecified, then --unity-editor must be specified.') .option('-c, --changeset ', 'The Unity changeset to get (e.g. 1234567890ab).') @@ -425,11 +506,11 @@ program.command('uninstall-unity') program.commandsGroup('Unity Editor:'); program.command('run') - .description('Run command line args directly to the Unity Editor.') + .description('Run Unity Editor command line arguments (passes args directly to the editor).') .option('--unity-editor ', 'The path to the Unity Editor executable. If unspecified, --unity-project or the UNITY_EDITOR_PATH environment variable must be set.') .option('--unity-project ', 'The path to a Unity project. If unspecified, the UNITY_PROJECT_PATH environment variable will be used, otherwise no project will be specified.') .option('--log-name ', 'The name of the log file.') - .option('--log-level ', 'Set the logging level (debug, info, minimal, warning, error). Default is info.') + .option('--log-level ', 'Override the logger verbosity (debug, info, minimal, warning, error). Defaults to info.') .option('--verbose', 'Enable verbose logging. Deprecated, use --log-level instead.') .allowUnknownOption(true) .argument('', 'Arguments to pass to the Unity Editor executable.') @@ -538,9 +619,9 @@ program.command('run') }); program.command('list-project-templates') - .description('List all available project templates for the given Unity editor.') + .description('List available Unity project templates for an editor.') .option('-u, --unity-version ', 'The Unity version to get (e.g. 2020.3.1f1, 2021.x, 2022.1.*, 6000). If unspecified, then --unity-editor must be specified.') - .option('-e, --unity-editor ', 'The path to the Unity Editor executable. If unspecified, the UNITY_EDITOR_PATH environment variable must be set.') + .option('-e, --unity-editor ', 'The path to the Unity Editor executable. If unspecified, -u, --unity-version or the UNITY_EDITOR_PATH environment variable must be set.') .option('--verbose', 'Enable verbose logging.') .option('--json', 'Prints the last line of output as JSON string.') .action(async (options) => { @@ -551,9 +632,10 @@ program.command('list-project-templates') Logger.instance.debugOptions(options); const unityVersionStr = options.unityVersion?.toString()?.trim(); + const editorFromEnv = process.env.UNITY_EDITOR_PATH?.trim(); - if (!unityVersionStr && !options.unityEditor) { - Logger.instance.error('You must specify a Unity version or editor path with -u, --unity-version, -e, --unity-editor.'); + if (!unityVersionStr && !options.unityEditor?.toString()?.trim() && !editorFromEnv) { + Logger.instance.error('You must specify a Unity version or editor path with -u, --unity-version, -e, --unity-editor, or set UNITY_EDITOR_PATH.'); process.exit(1); } @@ -563,7 +645,7 @@ program.command('list-project-templates') const unityVersion = new UnityVersion(unityVersionStr); unityEditor = await new UnityHub().GetEditor(unityVersion); } else { - const editorPath = options.unityEditor?.toString()?.trim() || process.env.UNITY_EDITOR_PATH; + const editorPath = options.unityEditor?.toString()?.trim() || editorFromEnv; if (!editorPath || editorPath.length === 0) { throw new Error('The Unity Editor path was not specified. Use -e or --unity-editor to specify it, or set the UNITY_EDITOR_PATH environment variable.'); @@ -604,9 +686,10 @@ program.command('create-project') Logger.instance.debugOptions(options); const unityVersionStr = options.unityVersion?.toString()?.trim(); + const editorFromEnv = process.env.UNITY_EDITOR_PATH?.trim(); - if (!unityVersionStr && !options.unityEditor) { - Logger.instance.error('You must specify a Unity version or editor path with -u, --unity-version, -e, --unity-editor.'); + if (!unityVersionStr && !options.unityEditor?.toString()?.trim() && !editorFromEnv) { + Logger.instance.error('You must specify a Unity version or editor path with -u, --unity-version, -e, --unity-editor, or set UNITY_EDITOR_PATH.'); process.exit(1); } @@ -616,7 +699,7 @@ program.command('create-project') const unityVersion = new UnityVersion(unityVersionStr); unityEditor = await new UnityHub().GetEditor(unityVersion); } else { - const editorPath = options.unityEditor?.toString()?.trim() || process.env.UNITY_EDITOR_PATH; + const editorPath = options.unityEditor?.toString()?.trim() || editorFromEnv; if (!editorPath || editorPath.length === 0) { Logger.instance.error('The Unity Editor path was not specified. Use -e or --unity-editor to specify it, or set the UNITY_EDITOR_PATH environment variable.'); @@ -674,7 +757,9 @@ program.command('open-project') } Logger.instance.debugOptions(options); - const projectPath = options.unityProject?.toString()?.trim() || process.env.UNITY_PROJECT_PATH || undefined; + const projectPath = options.unityProject?.toString()?.trim() || + process.env.UNITY_PROJECT_PATH || + undefined; const unityProject = await UnityProject.GetProject(projectPath); if (!unityProject) { @@ -726,15 +811,191 @@ program.command('editor-logs') program.commandsGroup("Unity Package Manager:"); +program.command('upm-install') + .description('Download and install the Unity Package Manager cli (pack/sign).') + .option('--verbose', 'Enable verbose logging.') + .option('--auto-update', 'Automatically updates the upm cli if it is already installed and a newer release is available. Cannot be used with --version.') + .option('--version ', 'Specify to install a specific version of the Unity Package Manager cli. Cannot be used with --auto-update.') + .option('--json', 'Print version and managed paths as JSON.') + .action(async (options) => { + if (options.verbose) { + Logger.instance.logLevel = LogLevel.DEBUG; + } + + Logger.instance.debugOptions(options); + + try { + if (options.autoUpdate === true && options.version) { + Logger.instance.error('Cannot use --auto-update with --version.'); + process.exit(1); + } + + const upm = new UpmCli(); + let requestedVersion = options.version?.toString()?.trim(); + if (options.autoUpdate === true) { + const currentVersion = upm.GetInstalledReleaseTag(); + const latestVersion = await upm.GetLatestReleaseTag(); + + if (currentVersion && !upm.IsUpdateAvailable(latestVersion)) { + Logger.instance.info(`Upm cli is already up to date (${currentVersion}).`); + requestedVersion = currentVersion; + } else { + if (currentVersion) { + Logger.instance.info(`Updating upm cli ${currentVersion} -> ${latestVersion}...`); + } + requestedVersion = latestVersion; + } + } + + const ver = await upm.Install({ + version: requestedVersion, + skipIfInstalled: true + }); + + await upm.Version(ver); + const exe = upm.executable; + + if (options.json) { + process.stdout.write(`\n${JSON.stringify({ + UPM_VERSION: ver, + UPM_CLI_PATH: exe, + UPM_MANAGED_ROOT: upm.managedRoot, + })}\n`); + } else { + process.stdout.write(`Upm cli ${ver} installed.\n`); + process.stdout.write(`${exe}\n`); + } + + process.exit(0); + } catch (error) { + Logger.instance.error(`${error}`); + process.exit(1); + } + }); + +program.command('upm-version') + .description('Print the Unity Package Manager cli version.') + .action(async () => { + try { + const upmCli = new UpmCli(); + const version = await upmCli.Version(); + process.stdout.write(`v${version.version}\n`); + process.exit(0); + } catch (error) { + Logger.instance.error(`${error}`); + process.exit(1); + } + }); + +interface UpmPackCliOptions { + source?: string; + destination?: string; + verbose?: boolean; +} + +program.command('upm-pack') + .description('Sign and pack a Unity package.') + .option('--source ', 'An absolute or relative path to the root folder of the custom package to pack. This is the folder that contains the package manifest file (package.json). (optional; defaults to the current working directory).') + .option('--destination ', 'The output path for the signed tarball. If you specify a folder that doesn\'t exist, it will be created for you. Note: If you omit this parameter, the tarball will be placed in the current working directory.') + .option('--verbose', 'Enable verbose logging.') + .action(async (options: UpmPackCliOptions) => { + if (options.verbose) { + Logger.instance.logLevel = LogLevel.DEBUG; + } + + Logger.instance.debugOptions({ options }); + + try { + const upm = new UpmCli(); + await upm.PromptInstallOrUpdateWhenInteractive(); + + let serviceAccountKeyId = process.env.UPM_SERVICE_ACCOUNT_KEY_ID?.trim(); + + if (!serviceAccountKeyId) { + serviceAccountKeyId = (await PromptForSecretInput('UPM_SERVICE_ACCOUNT_KEY_ID: ')).trim(); + } + + if (!serviceAccountKeyId) { + Logger.instance.error( + 'UPM_SERVICE_ACCOUNT_KEY_ID is required. Set the environment variable or enter a value when prompted.' + ); + process.exit(1); + } + + let serviceAccountKeySecret = process.env.UPM_SERVICE_ACCOUNT_KEY_SECRET?.trim(); + + if (!serviceAccountKeySecret) { + serviceAccountKeySecret = (await PromptForSecretInput('UPM_SERVICE_ACCOUNT_KEY_SECRET: ')).trim(); + } + + if (!serviceAccountKeySecret) { + Logger.instance.error( + 'UPM_SERVICE_ACCOUNT_KEY_SECRET is required. Set the environment variable or enter a value when prompted.' + ); + process.exit(1); + } + + process.env.UPM_SERVICE_ACCOUNT_KEY_ID = serviceAccountKeyId; + Logger.instance.maskCredential(serviceAccountKeyId); + process.env.UPM_SERVICE_ACCOUNT_KEY_SECRET = serviceAccountKeySecret; + Logger.instance.maskCredential(serviceAccountKeySecret); + + let orgId = process.env.UNITY_ORGANIZATION_ID?.trim() || process.env.UNITY_ORG_ID?.trim(); + const dest = options.destination?.toString()?.trim(); + + if (!orgId) { + orgId = (await PromptForSecretInput('UNITY_ORGANIZATION_ID: ')).trim(); + } + + if (!orgId) { + Logger.instance.error( + 'Organization ID is required. Set UNITY_ORGANIZATION_ID or UNITY_ORG_ID, or enter a value when prompted.' + ); + process.exit(1); + } + + Logger.instance.maskCredential(orgId); + + const redactLiterals = [orgId, serviceAccountKeyId, serviceAccountKeySecret].filter( + (s): s is string => typeof s === 'string' && s.trim().length > 0 + ); + + const packOptions: UpmPackOptions = { + organizationId: orgId, + }; + + if (dest) { + packOptions.destination = dest; + } + + const sourceArg = options.source?.toString()?.trim(); + packOptions.packageDirectory = + sourceArg && sourceArg.length > 0 ? path.resolve(sourceArg) : process.cwd(); + + await upm.Pack(packOptions, { + silent: false, + showCommand: Logger.instance.logLevel === LogLevel.DEBUG, + redactLiterals, + }); + + process.exit(0); + } catch (error) { + Logger.instance.error(`${error}`); + process.exit(1); + } + }); + program.command('sign-package') - .description('Sign a Unity package.') - .option('--package ', 'Required. The fully qualified path to the folder that contains the package.json file for the package you want to sign. Note: Don’t include package.json in this parameter value.') + .description('[Deprecated] Sign a Unity package using Unity Editor 6000.3+ batch mode (-upmPack). Use unity-cli upm-pack with organization and service account credentials for new workflows.') + .option('--package ', 'Required. The fully qualified path to the folder that contains the package.json file for the package you want to sign. Note: Don\'t include package.json in this parameter value.') .option('--output ', 'Optional. The output directory where you want to save the signed tarball file (.tgz). If unspecified, the package contents will be updated in place with the signed .attestation.p7m file.') .option('--email ', 'Email associated with the Unity account. If unspecified, the UNITY_USERNAME environment variable will be used.') .option('--password ', 'The password of the Unity account. If unspecified, the UNITY_PASSWORD environment variable will be used.') .option('--organization ', 'The Organization ID you copied from the Unity Cloud Dashboard. If unspecified, the UNITY_ORGANIZATION_ID environment variable will be used.') .option('--verbose', 'Enable verbose logging.') .action(async (options) => { + Logger.instance.warn('The sign-package command is deprecated. Use unity-cli upm-pack --source --destination with UNITY_ORGANIZATION_ID or UNITY_ORG_ID, UPM_SERVICE_ACCOUNT_KEY_ID, and UPM_SERVICE_ACCOUNT_KEY_SECRET (or secure prompts).'); + if (options.verbose) { Logger.instance.logLevel = LogLevel.DEBUG; } diff --git a/src/github-actions-ci.ts b/src/github-actions-ci.ts new file mode 100644 index 00000000..a3aa05de --- /dev/null +++ b/src/github-actions-ci.ts @@ -0,0 +1,108 @@ +import * as fs from 'fs'; +import { + ILoggerProvider, + LoggerAnnotationOptions, + LoggerProviderLevel, + MarkdownTarget +} from './logger-provider'; + +export enum GitHubAnnotationLevel { + Notice = 'notice', + Warning = 'warning', + Error = 'error', +} + +/** When set to 1/true/yes/on (case-insensitive), unity-cli may append to `GITHUB_STEP_SUMMARY`. Default: off. */ +export function isUnityCliWorkflowSummaryEnabled(): boolean { + const v = process.env.UNITY_CLI_WORKFLOW_SUMMARY?.trim().toLowerCase(); + return v === '1' || v === 'true' || v === 'yes' || v === 'on'; +} + +export class GitHubActionsLoggerProvider implements ILoggerProvider { + public readonly isCi = process.env.GITHUB_ACTIONS === 'true'; + + public log(level: LoggerProviderLevel, message: any, optionalParams: any[] = []): void { + switch (level) { + case 'debug': { + message.toString().split('\n').forEach((line: string) => { + process.stdout.write(`::debug::${line}\n`, ...optionalParams); + }); + break; + } + case 'ci': + case 'info': + process.stdout.write(`${message}\n`, ...optionalParams); + break; + default: + process.stdout.write(`::${level}::${message}\n`, ...optionalParams); + break; + } + } + + public startGroup(message: any, optionalParams: any[] = []): void { + const firstLine: string = message.toString().split('\n')[0]; + process.stdout.write(`::group::${firstLine}\n`, ...optionalParams); + } + + public endGroup(): void { + process.stdout.write('::endgroup::\n'); + } + + public annotate(level: GitHubAnnotationLevel, message: string, options?: LoggerAnnotationOptions): void { + const parts: string[] = []; + const appendPart = (key: string, value?: string | number): void => { + if (value === undefined || value === null) { return; } + const stringValue = value.toString(); + if (stringValue.length === 0) { return; } + parts.push(`${key}=${this.escapeGitHubCommandValue(stringValue)}`); + }; + + appendPart('file', options?.file); + if (options?.line !== undefined && options.line > 0) appendPart('line', options.line); + if (options?.endLine !== undefined && options.endLine > 0) appendPart('endLine', options.endLine); + if (options?.column !== undefined && options.column > 0) appendPart('col', options.column); + if (options?.endColumn !== undefined && options.endColumn > 0) appendPart('endColumn', options.endColumn); + appendPart('title', options?.title); + + const metadata = parts.length > 0 ? ` ${parts.join(',')}` : ''; + process.stdout.write(`::${level}${metadata}::${this.escapeGitHubCommandValue(message)}\n`); + } + + public mask(message: string): void { + process.stdout.write(`::add-mask::${message}\n`); + } + + public setEnvironmentVariable(name: string, value: string): void { + const githubEnv = process.env.GITHUB_ENV; + if (githubEnv) { + fs.appendFileSync(githubEnv, `${name}=${value}\n`, { encoding: 'utf8' }); + } + } + + public setOutput(name: string, value: string): void { + const githubOutput = process.env.GITHUB_OUTPUT; + if (githubOutput) { + fs.appendFileSync(githubOutput, `${name}=${value}\n`, { encoding: 'utf8' }); + } + } + + public appendStepSummary(summary: string): void { + const githubSummary = process.env.GITHUB_STEP_SUMMARY; + if (!githubSummary) { return; } + fs.appendFileSync(githubSummary, summary, { encoding: 'utf8' }); + } + + public getMarkdownByteLimit(target: MarkdownTarget): number { + if (target === 'workflow-summary' && isUnityCliWorkflowSummaryEnabled()) { + return 1024 * 1024; + } + return Number.POSITIVE_INFINITY; + } + + private escapeGitHubCommandValue(value: string): string { + return value + .replace(/%/g, '%25') + .replace(/\r/g, '%0D') + .replace(/\n/g, '%0A'); + } +} diff --git a/src/index.ts b/src/index.ts index 1bb1ae6d..e87c89df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,3 +8,4 @@ export * from './unity-project'; export * from './unity-version'; export * from './utilities'; export * from './unity-logging'; +export * from './upm-cli'; diff --git a/src/logger-provider.ts b/src/logger-provider.ts new file mode 100644 index 00000000..70f42e3f --- /dev/null +++ b/src/logger-provider.ts @@ -0,0 +1,78 @@ +export type MarkdownTarget = 'workflow-summary' | 'stdout'; +export type LoggerProviderLevel = 'debug' | 'ci' | 'utp' | 'info' | 'warning' | 'error'; +export type LoggerProviderAnnotationLevel = 'notice' | 'warning' | 'error'; + +export interface LoggerAnnotationOptions { + file?: string; + line?: number; + endLine?: number; + column?: number; + endColumn?: number; + title?: string; +} + +export interface ILoggerProvider { + readonly isCi: boolean; + log(level: LoggerProviderLevel, message: any, optionalParams?: any[]): void; + startGroup(message: any, optionalParams?: any[]): void; + endGroup(): void; + annotate(level: LoggerProviderAnnotationLevel, message: string, options?: LoggerAnnotationOptions): void; + mask(message: string): void; + setEnvironmentVariable(name: string, value: string): void; + setOutput(name: string, value: string): void; + appendStepSummary(summary: string): void; + getMarkdownByteLimit(target: MarkdownTarget): number; +} + +export class LocalCliLoggerProvider implements ILoggerProvider { + public readonly isCi = false; + + public log(level: LoggerProviderLevel, message: any, optionalParams: any[] = []): void { + const stringColor: string | undefined = { + debug: '\x1b[35m', + ci: undefined, + utp: undefined, + info: undefined, + warning: '\x1b[33m', + error: '\x1b[31m', + }[level]; + if (stringColor && stringColor.length > 0) { + process.stdout.write(`${stringColor}${message}\x1b[0m\n`, ...optionalParams); + return; + } + process.stdout.write(`${message}\n`, ...optionalParams); + } + + public startGroup(message: any, optionalParams: any[] = []): void { + this.log('info', message, optionalParams); + } + + public endGroup(): void { + // no-op for local terminal + } + + public annotate(level: LoggerProviderAnnotationLevel, message: string): void { + const mapped: LoggerProviderLevel = level === 'error' ? 'error' : (level === 'warning' ? 'warning' : 'info'); + this.log(mapped, message); + } + + public mask(_message: string): void { + // no-op for local terminal + } + + public setEnvironmentVariable(_name: string, _value: string): void { + // no-op for local terminal + } + + public setOutput(_name: string, _value: string): void { + // no-op for local terminal + } + + public appendStepSummary(_summary: string): void { + // no-op for local terminal + } + + public getMarkdownByteLimit(_target: MarkdownTarget): number { + return Number.POSITIVE_INFINITY; + } +} diff --git a/src/logging.ts b/src/logging.ts index 54f9e863..164a8029 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -1,4 +1,517 @@ -import * as fs from 'fs'; +import { UTP, Severity } from './utp'; +import { GitHubActionsLoggerProvider, GitHubAnnotationLevel } from './github-actions-ci'; +import { ILoggerProvider, LocalCliLoggerProvider, LoggerAnnotationOptions, MarkdownTarget } from './logger-provider'; + +/** Severity order for display: Error first, then Warning, then Info. Undefined treats as Warning. */ +function severityRank(s: string | undefined): number { + if (s === Severity.Error || s === Severity.Exception || s === Severity.Assert) return 0; + if (s === Severity.Warning || s === undefined) return 1; + return 2; // Info +} + +function dedupeKey(e: UTP): string { + const msg = (e.message || '').trim(); + const file = (e.file || (e as { fileName?: string }).fileName || '').replace(/\\/g, '/'); + const line = e.line ?? (e as { lineNumber?: number }).lineNumber ?? 0; + return `${msg}\n${file}\n${line}`; +} + +/** + * Returns true if the path looks absolute (Unix / or Windows X:/). + */ +function isAbsolutePath(file: string): boolean { + const norm = file.replace(/\\/g, '/'); + if (norm.startsWith('/')) return true; + return /^[a-zA-Z]:\//.test(norm); +} + +/** + * Returns true if the entry's file is under the project path (or entry has no file). + * Relative paths (e.g. Assets/..., Packages/...) are always kept so Unity UTP log/compiler + * entries with relative file paths still appear in the summary. + */ +function isEntryUnderProjectPath(e: UTP, projectPath: string): boolean { + const file = (e.file || (e as { fileName?: string }).fileName || '').trim(); + if (!file) return true; + const normFile = file.replace(/\\/g, '/'); + if (!isAbsolutePath(normFile)) return true; + const normProject = projectPath.replace(/\\/g, '/'); + const base = normProject.endsWith('/') ? normProject : normProject + '/'; + return normFile === normProject || normFile.startsWith(base); +} + +/** + * Returns true if the entry's file looks like a Unity engine path (should be omitted when not using projectPath). + */ +function isUnityEnginePath(file: string): boolean { + const norm = file.replace(/\\/g, '/'); + if (UNITY_ENGINE_PATH_PREFIXES.some(p => norm.startsWith(p))) return true; + if (norm.includes('/Runtime/') || norm.includes('\\Runtime\\')) return true; + if (!norm.endsWith('.cpp')) return false; + const underProject = norm.includes('/Assets/') || norm.includes('/Packages/') || norm.includes('/Library/PackageCache/'); + return !underProject; +} + +/** + * Merges LogEntry/Compiler rows by message+file+line; on collision keeps the more severe entry. + * Exported for unit tests. + */ +export function mergeLogEntriesPreferringSeverity(candidates: UTP[]): UTP[] { + const byKey = new Map(); + for (const e of candidates) { + const key = dedupeKey(e); + const existing = byKey.get(key); + if (!existing || severityRank(e.severity) < severityRank(existing.severity)) { + byKey.set(key, e); + } + } + const merged = [...byKey.values()]; + merged.sort((a, b) => severityRank(a.severity) - severityRank(b.severity)); + return merged; +} + +/** + * Builds one merged list from LogEntry and Compiler entries. + * Deduplicated by message+file+line (keeping worse severity on collision), sorted by severity. + */ +function buildMergedLogList(filtered: UTP[]): UTP[] { + const candidates = filtered.filter(e => e.type === 'LogEntry' || e.type === 'Compiler'); + return mergeLogEntriesPreferringSeverity(candidates); +} + +/** + * Filters merged list to project-relevant entries only. + * When projectPath is set: keep entries with no file or file under projectPath. + * When projectPath is not set: exclude Unity engine paths only (keep PackageCache and project paths). + */ +function filterMergedByPath(merged: UTP[], options: { projectPath?: string } | undefined): UTP[] { + if (options?.projectPath != null && options.projectPath !== '') { + return merged.filter(e => isEntryUnderProjectPath(e, options.projectPath!)); + } + return merged.filter(e => { + const file = (e.file || (e as { fileName?: string }).fileName || '').trim(); + if (!file) return true; + return !isUnityEnginePath(file); + }); +} + +/** Groups merged log by severity for foldouts (Error, Warning, Info). Missing severity is grouped as Warning. */ +function groupBySeverity(merged: UTP[]): { errorCritical: UTP[]; warning: UTP[]; info: UTP[] } { + const errorCritical: UTP[] = []; + const warning: UTP[] = []; + const info: UTP[] = []; + for (const e of merged) { + if (e.severity === Severity.Error || e.severity === Severity.Exception || e.severity === Severity.Assert) { + errorCritical.push(e); + } else if (e.severity === Severity.Warning || e.severity === undefined) { + warning.push(e); + } else { + info.push(e); + } + } + return { errorCritical, warning, info }; +} + +/** Single test result row for summary and CLI table. */ +export interface TestResultSummary { + status: string; + durationMs: number; + description: string; + message?: string; + file?: string; + line?: number; +} + +/** Maps UTPTestStatus.state to display status (Unity/NUnit-style: 0 Inconclusive, 1 Passed, 2 Failed, 3 Skipped). */ +export function testStatusFromState(state: number | undefined): string { + switch (state) { + case 1: return '✅'; + case 2: return '❌'; + case 3: return '⏭️'; + case 0: + default: return '◯'; + } +} + +/** Converts a single TestStatus UTP to TestResultSummary. Exported for CLI use. */ +export function utpToTestResultSummary(e: UTP): TestResultSummary { + const state = (e as { state?: number }).state; + const durationMs = e.duration ?? (e.durationMicroseconds != null ? e.durationMicroseconds / 1000 : 0); + const description = (e.name || e.description || '-').trim(); + const msg = (e.message || '').trim(); + const summary: TestResultSummary = { + status: testStatusFromState(state), + durationMs, + description, + }; + if (msg !== '') { + summary.message = msg; + } + const file = (e.file || (e as { fileName?: string }).fileName || '').trim(); + const line = e.line ?? (e as { lineNumber?: number }).lineNumber; + if (file !== '') { + summary.file = file.replace(/\\/g, '/'); + } + if (line !== undefined && line > 0) { + summary.line = line; + } + return summary; +} + +/** Collects TestStatus entries from telemetry into TestResultSummary rows. */ +function collectTestResults(filtered: UTP[]): TestResultSummary[] { + return filtered.filter(e => e.type === 'TestStatus').map(utpToTestResultSummary); +} + +function escapeMarkdownTableCell(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|'); +} + +/** Builds a markdown table string for test results (Status | Duration | Test). Exported for CLI use. */ +export function buildTestResultsTableMarkdown(testResults: TestResultSummary[], byteLimit: number, prefix?: string): string { + if (testResults.length === 0) return ''; + const p = prefix ?? ''; + let out = p + `### Test results\n\n`; + out += `| Status | Duration | Test |\n`; + out += `|--------|----------|------|\n`; + let shown = 0; + for (const row of testResults) { + const durationStr = row.durationMs >= 1000 + ? `${(row.durationMs / 1000).toFixed(1)}s` + : `${Math.round(row.durationMs)} ms`; + const rawDesc = row.description.length > 80 ? row.description.slice(0, 77) + '…' : row.description; + const desc = escapeMarkdownTableCell(rawDesc); + const line = `| ${escapeMarkdownTableCell(row.status)} | ${escapeMarkdownTableCell(durationStr)} | ${desc} |\n`; + if (Buffer.byteLength(out + line, 'utf8') > byteLimit) break; + out += line; + shown++; + } + if (shown < testResults.length) { + out += `| … | … | … and ${testResults.length - shown} more |\n`; + } + out += `\n`; + return out; +} + +function summarizeTestOutcomes(testResults: TestResultSummary[]): { passed: number; failed: number; skipped: number; inconclusive: number; totalDurationMs: number } { + let passed = 0; + let failed = 0; + let skipped = 0; + let inconclusive = 0; + let totalDurationMs = 0; + for (const t of testResults) { + totalDurationMs += t.durationMs; + switch (t.status) { + case '✅': passed++; break; + case '❌': failed++; break; + case '⏭️': skipped++; break; + default: inconclusive++; break; + } + } + return { passed, failed, skipped, inconclusive, totalDurationMs }; +} + +/** + * Rich unit-test markdown block used by workflow summary and stdout. + * Keeps byte-budget behavior and truncation hints. + */ +export function buildUnitTestJobSummaryMarkdown(testResults: TestResultSummary[], byteLimit: number, prefix?: string): string { + if (testResults.length === 0) return ''; + const p = prefix ?? ''; + let out = p + '### Unit test results\n\n'; + const counts = summarizeTestOutcomes(testResults); + const durationStr = counts.totalDurationMs >= 1000 + ? `${(counts.totalDurationMs / 1000).toFixed(1)}s` + : `${Math.round(counts.totalDurationMs)} ms`; + out += `**${testResults.length}** tests - **${counts.passed}** ✓, **${counts.failed}** ✗, **${counts.skipped}** skipped, **${counts.inconclusive}** inconclusive - **${durationStr}** total\n\n`; + out += '| Test | Result | Time | Message |\n'; + out += '| --- | --- | --- | --- |\n'; + + const ordered = [...testResults].sort((a, b) => { + const aFail = a.status === '❌' ? 0 : 1; + const bFail = b.status === '❌' ? 0 : 1; + if (aFail !== bFail) return aFail - bFail; + return b.durationMs - a.durationMs; + }); + + let shown = 0; + for (const row of ordered) { + const durationText = row.durationMs >= 1000 ? `${(row.durationMs / 1000).toFixed(1)}s` : `${Math.round(row.durationMs)} ms`; + const loc = row.file && row.line ? ` (${row.file}:${row.line})` : ''; + const rawName = `${row.description}${loc}`; + const name = escapeMarkdownTableCell(rawName.length > 90 ? `${rawName.slice(0, 87)}…` : rawName); + const msgRaw = (row.message ?? '').replace(/\r?\n/g, ' ').trim(); + const msg = escapeMarkdownTableCell(msgRaw.length > 120 ? `${msgRaw.slice(0, 117)}…` : msgRaw); + const line = `| ${name} | ${escapeMarkdownTableCell(row.status)} | ${escapeMarkdownTableCell(durationText)} | ${msg} |\n`; + if (Buffer.byteLength(out + line, 'utf8') > byteLimit) break; + out += line; + shown++; + } + if (shown < ordered.length) { + out += `| … | … | … | … and ${ordered.length - shown} more |\n`; + } + out += '\n'; + return out; +} + +function buildActionTimelineTableMarkdown( + completedActions: UTP[], + byteLimit: number, + prefix?: string +): { markdown: string; truncated: boolean } { + if (completedActions.length === 0) return { markdown: '', truncated: false }; + const p = prefix ?? ''; + let out = p + '| Status | Duration | Errors | Action |\n'; + out += '| --- | --- | --- | --- |\n'; + + let shown = 0; + for (const a of completedActions) { + const durationMs = a.duration ?? (a.durationMicroseconds != null ? a.durationMicroseconds / 1000 : undefined); + const errCount = Array.isArray(a.errors) ? a.errors.length : 0; + const status = errCount > 0 ? '❌' : '✅'; + const action = truncateStr(toSingleLineText(a.description || a.name || '-'), 120); + const row = `| ${escapeMarkdownTableCell(status)} | ${escapeMarkdownTableCell(formatDurationMsForSummary(durationMs))} | ${errCount} | ${escapeMarkdownTableCell(action)} |\n`; + if (Buffer.byteLength(out + row, 'utf8') > byteLimit) break; + out += row; + shown++; + } + + const truncated = shown < completedActions.length; + if (truncated) { + out += `| ... | ... | ... | ... and ${completedActions.length - shown} more actions |\n`; + } + out += '\n'; + return { markdown: out, truncated }; +} + +function buildActionTimelineCodeblockMarkdown(completedActions: UTP[], byteLimit: number, prefix?: string): string { + if (completedActions.length === 0) return ''; + const p = prefix ?? ''; + let out = p + '```text\n'; + let timelineShown = 0; + for (const a of completedActions) { + const durationMs = a.duration ?? (a.durationMicroseconds != null ? a.durationMicroseconds / 1000 : undefined); + const errCount = Array.isArray(a.errors) ? a.errors.length : 0; + const status = errCount > 0 ? '❌' : '✅'; + const desc = toSingleLineText(a.description || a.name || '-'); + const durationStr = formatDurationMsForSummary(durationMs); + const row = `${status} ${durationStr} ${errCount} - ${desc}\n`; + if (Buffer.byteLength(out + row, 'utf8') > byteLimit) break; + out += row; + timelineShown++; + } + if (timelineShown < completedActions.length) { + out += `... and ${completedActions.length - timelineShown} more actions\n`; + } + out += '```\n\n'; + return out; +} + +function truncateStr(s: string, max: number): string { + return s.length <= max ? s : s.slice(0, max) + '…'; +} + +/** + * Truncates s to fit within maxBytes in UTF-8. If truncated, appends an ellipsis (…). + * If s already fits, returns s unchanged. + * Exported for unit tests. + */ +export function truncateStringToUtf8ByteLength(s: string, maxBytes: number): string { + if (maxBytes <= 0) return ''; + const ellipsis = '…'; + const ellBytes = Buffer.byteLength(ellipsis, 'utf8'); + if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s; + if (maxBytes <= ellBytes) { + let end = 0; + for (let i = 1; i <= s.length; i++) { + const sub = s.slice(0, i); + if (Buffer.byteLength(sub, 'utf8') > maxBytes) break; + end = i; + } + return s.slice(0, end); + } + let low = 0; + let high = s.length; + while (low < high) { + const mid = Math.floor((low + high + 1) / 2); + const sub = s.slice(0, mid); + if (Buffer.byteLength(sub, 'utf8') + ellBytes <= maxBytes) low = mid; + else high = mid - 1; + } + return s.slice(0, low) + ellipsis; +} + +/** + * Appends one formatted log line per entry, truncating each line only when it would exceed the + * remaining bytes in the workflow summary (byteLimit is total cap for the final string starting from out). + */ +function appendWorkflowSummaryLogLines(out: string, entries: UTP[], byteLimit: number): { out: string; shown: number; omitted: number } { + let o = out; + let shown = 0; + const newline = '\n'; + const nlBytes = Buffer.byteLength(newline, 'utf8'); + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry === undefined) { + return { out: o, shown, omitted: entries.length - shown }; + } + const room = byteLimit - Buffer.byteLength(o, 'utf8'); + if (room < nlBytes) { + return { out: o, shown, omitted: entries.length - shown }; + } + const rawLine = formatLogEntryLine(entry, Number.POSITIVE_INFINITY).replace(/\n$/, ''); + const maxContentBytes = room - nlBytes; + const lineBody = Buffer.byteLength(rawLine, 'utf8') <= maxContentBytes + ? rawLine + : truncateStringToUtf8ByteLength(rawLine, maxContentBytes); + o += lineBody + newline; + shown++; + } + return { out: o, shown, omitted: 0 }; +} + +function toSingleLineText(value: string): string { + return value + .replace(/\r?\n+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function formatDurationMsForSummary(ms: number | undefined): string { + if (ms === undefined || !Number.isFinite(ms)) { return '-'; } + if (ms < 1000) { return `${Math.round(ms)}ms`; } + return `${(ms / 1000).toFixed(1)}s`; +} + +/** Unity/CI noise shown in logs; omit from workflow summary foldouts and counts. */ +const SUMMARY_NOISE_ACCESS_TOKEN = 'Access token is unavailable; failed to update'; + +/** + * Removes known noise phrases from a log message for summary display. + * Exported for unit tests. + */ +export function stripSummaryNoiseFromLogMessage(message: string): string { + const flat = toSingleLineText(message); + if (!flat) return ''; + const pattern = SUMMARY_NOISE_ACCESS_TOKEN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const out = flat.replace(new RegExp(pattern, 'gi'), ' ').replace(/\s+/g, ' ').trim(); + return out; +} + +function filterNoiseFromSummaryLogEntries(entries: UTP[]): UTP[] { + const out: UTP[] = []; + for (const e of entries) { + const stripped = stripSummaryNoiseFromLogMessage(e.message || ''); + if (stripped === '') continue; + const originalFlat = toSingleLineText(e.message || ''); + if (stripped !== originalFlat) { + out.push({ ...e, message: stripped }); + } else { + out.push(e); + } + } + return out; +} + +function renderBuildActionsFoldoutMarkdown(completedActions: UTP[], maxBytes: number): string { + const n = completedActions.length; + const open = `
Build actions (${n})\n\n`; + const close = `
\n\n`; + const overhead = Buffer.byteLength(open + close, 'utf8'); + const innerBudget = Math.max(0, maxBytes - overhead); + const table = buildActionTimelineTableMarkdown(completedActions, innerBudget, ''); + const inner = !table.truncated + ? table.markdown + : buildActionTimelineCodeblockMarkdown(completedActions, innerBudget, ''); + return open + inner + close; +} + +/** Paths to treat as Unity engine (omit from summary when using heuristic filter). */ +const UNITY_ENGINE_PATH_PREFIXES = [ + 'Runtime/', + './Runtime/', + 'Modules/', + './Modules/', +]; + +/** + * Normalizes a log message for display by stripping a redundant file:line prefix + * when it matches the entry's file/line so the path appears only once. + * Returns the normalized message and optional column if present in the prefix. + */ +function normalizeMessageForDisplay( + message: string, + file: string, + line: number | undefined +): { message: string; column?: number } { + const trimmed = message.trim(); + const normFile = file.replace(/\\/g, '/'); + if (!normFile && line === undefined) return { message: trimmed }; + + // path(line,col): e.g. Assets/File.cs(2,8): error ... + const parenColon = trimmed.match(/^(.+?)\((\d+),(\d+)\):\s*/); + if (parenColon && parenColon[1] != null && parenColon[2] != null && parenColon[3] != null) { + const fullMatch = parenColon[0]; + const msgPath = parenColon[1].replace(/\\/g, '/'); + const msgLine = parseInt(parenColon[2], 10); + const msgCol = parseInt(parenColon[3], 10); + const pathMatches = msgPath === normFile || normFile.endsWith(msgPath) || msgPath.endsWith(normFile); + if (pathMatches && (line === undefined || line === msgLine)) { + return { message: trimmed.slice(fullMatch.length).trim(), column: msgCol }; + } + } + + // path(line): e.g. Assets/File.cs(2): ... + const parenOnly = trimmed.match(/^(.+?)\((\d+)\):\s*/); + if (parenOnly && parenOnly[1] != null && parenOnly[2] != null) { + const fullMatch = parenOnly[0]; + const msgPath = parenOnly[1].replace(/\\/g, '/'); + const msgLine = parseInt(parenOnly[2], 10); + const pathMatches = msgPath === normFile || normFile.endsWith(msgPath) || msgPath.endsWith(normFile); + if (pathMatches && (line === undefined || line === msgLine)) { + return { message: trimmed.slice(fullMatch.length).trim() }; + } + } + + // path:line: e.g. path/to/file.cs:10: + const pathLineColon = trimmed.match(/^(.+?):(\d+):\s*/); + if (pathLineColon && pathLineColon[1] != null && pathLineColon[2] != null) { + const fullMatch = pathLineColon[0]; + const msgPath = pathLineColon[1].replace(/\\/g, '/'); + const msgLine = parseInt(pathLineColon[2], 10); + const pathMatches = msgPath === normFile || normFile.endsWith(msgPath) || msgPath.endsWith(normFile); + if (pathMatches && (line === undefined || line === msgLine)) { + return { message: trimmed.slice(fullMatch.length).trim() }; + } + } + + return { message: trimmed }; +} + +/** + * One line per entry: path(line,col): <message> or path(line): <message> when column is missing. + * When file/line are missing, outputs: - <message>. + */ +function formatLogEntryLine(e: UTP, maxMsgLen: number = Number.POSITIVE_INFINITY): string { + const file = (e.file || (e as { fileName?: string }).fileName || '').replace(/\\/g, '/'); + const line = e.line ?? (e as { lineNumber?: number }).lineNumber; + const hasLocation = file && (line !== undefined && line > 0); + const rawMsg = toSingleLineText(e.message || ''); + const { message: normalizedMsg, column } = hasLocation + ? normalizeMessageForDisplay(rawMsg, file, line) + : { message: rawMsg, column: undefined as number | undefined }; + const msg = Number.isFinite(maxMsgLen) && maxMsgLen >= 0 && maxMsgLen < Number.POSITIVE_INFINITY + ? truncateStr(normalizedMsg, maxMsgLen) + : normalizedMsg; + + if (hasLocation) { + const loc = column !== undefined ? `${file}(${line},${column})` : `${file}(${line})`; + return `${loc}: ${msg}\n`; + } + return `${msg}\n`; +} export enum LogLevel { DEBUG = 'debug', @@ -11,24 +524,18 @@ export enum LogLevel { export class Logger { public logLevel: LogLevel = LogLevel.INFO; - private readonly _ci: string | undefined; + private readonly _provider: ILoggerProvider; static readonly instance: Logger = new Logger(); private constructor() { + this._provider = process.env.GITHUB_ACTIONS === 'true' + ? new GitHubActionsLoggerProvider() + : new LocalCliLoggerProvider(); if (process.env.GITHUB_ACTIONS === 'true') { - this._ci = 'GITHUB_ACTIONS'; this.logLevel = process.env.ACTIONS_STEP_DEBUG === 'true' ? LogLevel.DEBUG : LogLevel.CI; } } - private printLine(message: any, lineColor: string | undefined, optionalParams: any[] = []): void { - if (lineColor && lineColor.length > 0) { - process.stdout.write(`${lineColor}${message}\x1b[0m\n`, ...optionalParams); - } else { - process.stdout.write(`${message}\n`, ...optionalParams); - } - } - /** * Logs a message to the console. * @param level The log level for this message. @@ -37,40 +544,7 @@ export class Logger { */ public log(level: LogLevel, message: any, optionalParams: any[] = []): void { if (this.shouldLog(level)) { - switch (this._ci) { - case 'GITHUB_ACTIONS': { - switch (level) { - case LogLevel.DEBUG: { - message.toString().split('\n').forEach((line: string) => { - process.stdout.write(`::debug::${line}\n`, ...optionalParams); - }); - break; - } - case LogLevel.CI: - case LogLevel.INFO: { - process.stdout.write(`${message}\n`, ...optionalParams); - break; - } - default: { - process.stdout.write(`::${level}::${message}\n`, ...optionalParams); - break; - } - } - break; - } - default: { - const stringColor: string | undefined = { - [LogLevel.DEBUG]: '\x1b[35m', // Purple - [LogLevel.INFO]: undefined, // No color / White - [LogLevel.CI]: undefined, // No color / White - [LogLevel.UTP]: undefined, // No color / White - [LogLevel.WARN]: '\x1b[33m', // Yellow - [LogLevel.ERROR]: '\x1b[31m', // Red - }[level] || undefined; // Default to no color / White - this.printLine(message, stringColor, optionalParams); - break; - } - } + this._provider.log(level, message, optionalParams); } } @@ -78,39 +552,18 @@ export class Logger { * Starts a log group. In CI environments that support grouping, this will create a collapsible group. */ public startGroup(message: any, optionalParams: any[] = [], logLevel: LogLevel = LogLevel.INFO): void { - switch (this._ci) { - case 'GITHUB_ACTIONS': { - // if there is newline in message, only use the first line for group title - // then print the rest of the lines inside the group in cyan color - const firstLine: string = message.toString().split('\n')[0]; - const restLines: string[] = message.toString().split('\n').slice(1); - process.stdout.write(`::group::${firstLine}\n`, ...optionalParams); - restLines.forEach(line => { - this.printLine(line, '\x1b[36m', ...optionalParams); - }); - break; - } - default: { - // No grouping in standard console - this.log(logLevel, message, optionalParams); - break; - } + if (this._provider.isCi) { + this._provider.startGroup(message, optionalParams); + return; } + this.log(logLevel, message, optionalParams); } /** * Ends a log group. In CI environments that support grouping, this will end the current group. */ public endGroup(): void { - switch (this._ci) { - case 'GITHUB_ACTIONS': { - process.stdout.write(`::endgroup::\n`); - break; - } - default: { - break; // No grouping in standard console - } - } + this._provider.endGroup(); } /** @@ -150,60 +603,25 @@ export class Logger { * @param title The title of the annotation. */ public annotate(logLevel: LogLevel, message: string, file?: string, line?: number, endLine?: number, column?: number, endColumn?: number, title?: string): void { - let annotation = ''; - - switch (this._ci) { - case 'GITHUB_ACTIONS': { - const level = { - [LogLevel.CI]: 'notice', - [LogLevel.INFO]: 'notice', - [LogLevel.DEBUG]: 'notice', - [LogLevel.UTP]: 'notice', - [LogLevel.WARN]: 'warning', - [LogLevel.ERROR]: 'error', - }[logLevel] ?? 'notice'; - - const parts: string[] = []; - const appendPart = (key: string, value?: string | number): void => { - if (value === undefined || value === null) { return; } - const stringValue = value.toString(); - if (stringValue.length === 0) { return; } - parts.push(`${key}=${this.escapeGitHubCommandValue(stringValue)}`); - }; - - appendPart('file', file); - if (line !== undefined && line > 0) { - appendPart('line', line); - } - if (endLine !== undefined && endLine > 0) { - appendPart('endLine', endLine); - } - if (column !== undefined && column > 0) { - appendPart('col', column); - } - if (endColumn !== undefined && endColumn > 0) { - appendPart('endColumn', endColumn); - } - appendPart('title', title); - - const metadata = parts.length > 0 ? ` ${parts.join(',')}` : ''; - annotation = `::${level}${metadata}::${this.escapeGitHubCommandValue(message)}`; - break; - } - } - - if (annotation.length > 0) { - process.stdout.write(`${annotation}\n`); - } else { - this.log(logLevel, message); - } - } - - private escapeGitHubCommandValue(value: string): string { - return value - .replace(/%/g, '%25') - .replace(/\r/g, '%0D') - .replace(/\n/g, '%0A'); + const level = { + [LogLevel.CI]: 'notice', + [LogLevel.INFO]: 'notice', + [LogLevel.DEBUG]: 'notice', + [LogLevel.UTP]: 'notice', + [LogLevel.WARN]: 'warning', + [LogLevel.ERROR]: 'error', + }[logLevel] ?? 'notice'; + const options: LoggerAnnotationOptions = {}; + if (file !== undefined && file !== '') { options.file = file; } + if (line !== undefined) { options.line = line; } + if (endLine !== undefined) { options.endLine = endLine; } + if (column !== undefined) { options.column = column; } + if (endColumn !== undefined) { options.endColumn = endColumn; } + if (title !== undefined && title !== '') { options.title = title; } + const backendLevel = level === 'error' + ? GitHubAnnotationLevel.Error + : (level === 'warning' ? GitHubAnnotationLevel.Warning : GitHubAnnotationLevel.Notice); + this._provider.annotate(backendLevel, message, options); } private shouldLog(level: LogLevel): boolean { @@ -217,12 +635,7 @@ export class Logger { * @param message The string to mask. */ public CI_mask(message: string): void { - switch (this._ci) { - case 'GITHUB_ACTIONS': { - process.stdout.write(`::add-mask::${message}\n`); - break; - } - } + this._provider.mask(message); } /** @@ -262,7 +675,8 @@ export class Logger { 'config', 'organization', 'username', - 'servicesConfig' + 'servicesConfig', + 'serviceaccountkey', ]; /** @@ -312,47 +726,210 @@ export class Logger { * @param value The value of the environment variable. */ public CI_setEnvironmentVariable(name: string, value: string): void { - switch (this._ci) { - case 'GITHUB_ACTIONS': { - // needs to be appended to the temporary file specified in the GITHUB_ENV environment variable - const githubEnv = process.env.GITHUB_ENV; - // echo "MY_ENV_VAR=myValue" >> $GITHUB_ENV - if (githubEnv) { - fs.appendFileSync(githubEnv, `${name}=${value}\n`, { encoding: 'utf8' }); - } - break; - } - } + this._provider.setEnvironmentVariable(name, value); } public CI_setOutput(name: string, value: string): void { - switch (this._ci) { - case 'GITHUB_ACTIONS': { - // needs to be appended to the temporary file specified in the GITHUB_OUTPUT environment variable - const githubOutput = process.env.GITHUB_OUTPUT; - // echo "myOutput=myValue" >> $GITHUB_OUTPUT - if (githubOutput) { - fs.appendFileSync(githubOutput, `${name}=${value}\n`, { encoding: 'utf8' }); - } - break; - } + this._provider.setOutput(name, value); + } + + private static formatDurationMs(ms: number | undefined): string { + if (ms === undefined || !Number.isFinite(ms)) { return '-'; } + if (ms < 1000) { return `${Math.round(ms)}ms`; } + return `${(ms / 1000).toFixed(1)}s`; + } + + private static truncateStr(s: string, max: number): string { + return s.length <= max ? s : s.slice(0, max) + '…'; + } + + private static truncateSummaryToByteLimit(summary: string, byteLimit: number): string { + const footer = `\n***Summary truncated due to size limits.***\n`; + const footerSize = Buffer.byteLength(footer, 'utf8'); + const lines = summary.split('\n'); + let rebuilt = ''; + for (const line of lines) { + const nextSize = Buffer.byteLength(rebuilt + line + '\n', 'utf8') + footerSize; + if (nextSize > byteLimit) { break; } + rebuilt += `${line}\n`; } + return rebuilt + footer; } - public CI_appendWorkflowSummary(telemetry: any[]) { - switch (this._ci) { - case 'GITHUB_ACTIONS': { - const githubSummary = process.env.GITHUB_STEP_SUMMARY; + /** + * Returns the markdown byte limit for a given output target. + * Workflow summary may be backend constrained; stdout is intentionally uncapped. + */ + public getMarkdownByteLimit(target: MarkdownTarget): number { + return this._provider.getMarkdownByteLimit(target); + } - if (githubSummary) { - let table = `| Key | Value |\n| --- | ----- |\n`; - telemetry.forEach(item => { - table += `| ${item.key} | ${item.value} |\n`; - }); + public CI_appendWorkflowSummary(name: string, telemetry: UTP[], options?: { projectPath?: string; additionalLogEntries?: UTP[] }) { + if (telemetry.length === 0) { return; } + if (this.getMarkdownByteLimit('workflow-summary') === Number.POSITIVE_INFINITY) { + return; + } + const excludedTypes = new Set(['MemoryLeaks', 'MemoryLeak']); + const filtered = telemetry.filter(entry => !excludedTypes.has(entry.type || '')); + if (filtered.length === 0) { return; } + + const completedActions = filtered.filter( + e => e.type === 'Action' && e.phase === 'End' + ); + const testResults = collectTestResults(filtered); + const additional = options?.additionalLogEntries ?? []; + const merged = mergeLogEntriesPreferringSeverity([ + ...buildMergedLogList(filtered), + ...additional.filter(e => e.type === 'LogEntry' || e.type === 'Compiler'), + ]); + const pathFiltered = filterMergedByPath(merged, options); + const summaryLogs = filterNoiseFromSummaryLogEntries(pathFiltered); + const bySeverity = groupBySeverity(summaryLogs); + const limit = this.getMarkdownByteLimit('workflow-summary'); + + const builders: (() => string)[] = [ + () => this.buildSummaryTimelineAndMergedLog(name, completedActions, bySeverity, testResults, limit), + () => this.buildSummaryCollapsibleWithMergedLog(name, completedActions, bySeverity, testResults, limit), + () => this.buildSummaryTimelineAndCounts(name, completedActions, summaryLogs.length, testResults, limit), + ]; + let summary = ''; + for (const build of builders) { + summary = build(); + if (Buffer.byteLength(summary, 'utf8') <= limit) { break; } + } + if (Buffer.byteLength(summary, 'utf8') > limit) { + summary = Logger.truncateSummaryToByteLimit(summary, limit); + } + this._provider.appendStepSummary(summary); + } - fs.appendFileSync(githubSummary, table, { encoding: 'utf8' }); - } + /** + * Builds summary: stats + action table + unit-test block + severity foldouts. + */ + private buildSummaryTimelineAndMergedLog( + name: string, + completedActions: UTP[], + bySeverity: { errorCritical: UTP[]; warning: UTP[]; info: UTP[] }, + testResults: TestResultSummary[], + byteLimit: number + ): string { + let out = `## ${name} Summary\n\n`; + + const totalDurationMs = completedActions.reduce( + (sum, a) => sum + (a.duration ?? (a.durationMicroseconds != null ? a.durationMicroseconds / 1000 : 0)), + 0 + ); + const totalSec = totalDurationMs / 1000; + const totalStr = totalSec >= 60 ? `${Math.round(totalSec / 60)}m ${Math.round(totalSec % 60)}s` : `${totalSec.toFixed(1)}s`; + out += `Errors: ${bySeverity.errorCritical.length}\n`; + out += `Warnings: ${bySeverity.warning.length}\n`; + out += `Total duration: ${totalStr}\n`; + out += `Actions: ${completedActions.length}\n`; + if (testResults.length > 0) { + out += `Tests: ${testResults.length}\n`; + } + out += '\n'; + + if (completedActions.length > 0) { + const remaining = byteLimit - Buffer.byteLength(out, 'utf8'); + out += renderBuildActionsFoldoutMarkdown(completedActions, remaining); + } + + if (testResults.length > 0) { + const remaining = byteLimit - Buffer.byteLength(out, 'utf8'); + out += buildUnitTestJobSummaryMarkdown(testResults, remaining, ''); + } + + const limit = byteLimit; + const appendFoldout = (title: string, entries: UTP[], dropSuffix: string, openByDefault?: boolean): void => { + if (entries.length === 0) return; + const openAttr = openByDefault ? ' open' : ''; + out += `${title} (${entries.length})\n\n`; + out += '```text\n'; + const appended = appendWorkflowSummaryLogLines(out, entries, limit); + out = appended.out; + if (appended.omitted > 0) { + out += `... and ${appended.omitted} more ${dropSuffix}\n`; } + out += '```\n\n'; + out += `\n\n`; + }; + + appendFoldout('Error', bySeverity.errorCritical, '(see annotations).', true); + appendFoldout('Warning', bySeverity.warning, '(truncated; see full log).'); + appendFoldout('Info', bySeverity.info, '(truncated; see full log).'); + + return out; + } + + /** + * Builds summary with timeline in a
and merged log foldouts by severity. + * Used when primary builder would exceed size limit. + */ + private buildSummaryCollapsibleWithMergedLog( + name: string, + completedActions: UTP[], + bySeverity: { errorCritical: UTP[]; warning: UTP[]; info: UTP[] }, + testResults: TestResultSummary[], + byteLimit: number + ): string { + let out = `## ${name} Summary\n\n`; + + if (completedActions.length > 0) { + const remaining = byteLimit - Buffer.byteLength(out, 'utf8'); + out += renderBuildActionsFoldoutMarkdown(completedActions, remaining); } + + if (testResults.length > 0) { + const remaining = byteLimit - Buffer.byteLength(out, 'utf8'); + out += buildUnitTestJobSummaryMarkdown(testResults, remaining, ''); + } + + const limit = byteLimit; + const appendFoldout = (title: string, entries: UTP[], dropSuffix: string, openByDefault?: boolean): void => { + if (entries.length === 0) return; + const openAttr = openByDefault ? ' open' : ''; + out += `${title} (${entries.length})\n\n`; + out += '```text\n'; + const appended = appendWorkflowSummaryLogLines(out, entries, limit); + out = appended.out; + if (appended.omitted > 0) out += `... and ${appended.omitted} more ${dropSuffix}\n`; + out += '```\n\n'; + out += `
\n\n`; + }; + appendFoldout('Error', bySeverity.errorCritical, '(see annotations).', true); + appendFoldout('Warning', bySeverity.warning, '(truncated; see full log).'); + appendFoldout('Info', bySeverity.info, '(truncated; see full log).'); + + return out; + } + + /** + * Fallback: list timeline (when actions exist) + unit-test block (when present) + compact count lines. + * Used when even collapsible summary would exceed 1 MB. + */ + private buildSummaryTimelineAndCounts( + name: string, + completedActions: UTP[], + logCount: number, + testResults: TestResultSummary[], + byteLimit: number + ): string { + let out = `## ${name} Summary\n\n`; + if (completedActions.length > 0) { + const remaining = byteLimit - Buffer.byteLength(out, 'utf8'); + out += renderBuildActionsFoldoutMarkdown(completedActions, remaining); + } + if (testResults.length > 0) { + const remaining = byteLimit - Buffer.byteLength(out, 'utf8'); + out += buildUnitTestJobSummaryMarkdown(testResults, remaining, ''); + } + out += `Log entries: ${logCount}\n`; + out += `Actions: ${completedActions.length}\n`; + if (testResults.length > 0) { + out += `Tests: ${testResults.length}\n`; + } + out += `\nSee annotations for details.\n`; + return out; } -} \ No newline at end of file +} diff --git a/src/unity-editor.ts b/src/unity-editor.ts index 60da2e63..e8eea3d2 100644 --- a/src/unity-editor.ts +++ b/src/unity-editor.ts @@ -306,6 +306,7 @@ export class UnityEditor { const baseEditorEnv: NodeJS.ProcessEnv = { ...process.env, UNITY_THISISABUILDMACHINE: '1', + DISABLE_EMBEDDED_BUILD_PIPELINE_PLUGIN_LOGGING: '1', ...(linuxEnvOverrides ?? {}) }; diff --git a/src/unity-hub.ts b/src/unity-hub.ts index 35b5cf58..9223f405 100644 --- a/src/unity-hub.ts +++ b/src/unity-hub.ts @@ -18,8 +18,9 @@ import { DownloadFile, Exec, ExecOptions, - ReadFileContents, GetTempDir, + HttpsGetText, + ReadFileContents, } from './utilities'; import { UnityReleasesClient, @@ -380,6 +381,7 @@ wget -qO - https://hub.unity3d.com/linux/keys/public | gpg --dearmor | sudo tee sudo sh -c 'echo "deb [signed-by=/usr/share/keyrings/Unity_Technologies_ApS.gpg] https://hub.unity3d.com/linux/repos/deb stable main" > /etc/apt/sources.list.d/unityhub.list' sudo apt-get update --allow-releaseinfo-change sudo apt-get install -y --no-install-recommends --only-upgrade unityhub${version ? '=' + version : ''}`]); + this.logger.info(`Unity Hub updated successfully.`); } else { throw new Error(`Unsupported platform: ${process.platform}`); } @@ -585,8 +587,7 @@ chmod -R 777 "$hubPath"`]); throw new Error(`Unsupported platform: ${process.platform}`); } - const response = await fetch(url); - const data = await response.text(); + const data = await HttpsGetText(url); const parsed = yaml.parse(data); const version = coerce(parsed.version); @@ -676,8 +677,9 @@ chmod -R 777 "$hubPath"`]); if (!editorPath) { try { installDir = await this.installUnity(unityVersion, modules); - } catch (error: Error | any) { - if (retryErrorMessages.some(msg => error.message.includes(msg))) { + } catch (error: unknown) { + const errMessage = error instanceof Error ? error.message : String(error); + if (retryErrorMessages.some((msg) => errMessage.includes(msg))) { if (editorPath) { await DeleteDirectory(editorPath); } @@ -725,8 +727,9 @@ chmod -R 777 "$hubPath"`]); this.logger.info(` > ${module}`); } } - } catch (error: Error | any) { - if (error.message.includes(`No modules found`)) { + } catch (error: unknown) { + const errMessage = error instanceof Error ? error.message : String(error); + if (errMessage.includes(`No modules found`)) { await DeleteDirectory(editorPath); await this.GetEditor(unityVersion, modules); } else { @@ -971,10 +974,16 @@ done // Filter to stable 'f' releases only unless the user explicitly asked for a pre-release const isExplicitPrerelease = /[abcpx]$/.test(unityVersion.version) || /[abcpx]/.test(unityVersion.version); const releases: ReleaseInfo[] = (data.results || []) - .filter(release => isExplicitPrerelease || release.version.includes('f')) + .filter((release) => { + const v = release.version; + if (v == null || v === '') { + return false; + } + return isExplicitPrerelease || v.includes('f'); + }) .map(release => ({ unityRelease: release, - unityVersion: new UnityVersion(release.version, release.shortRevision, unityVersion.architecture) + unityVersion: new UnityVersion(release.version!, release.shortRevision, unityVersion.architecture) })); if (releases.length === 0) { diff --git a/src/unity-logging.ts b/src/unity-logging.ts index 52cbb9a2..09db7c3d 100644 --- a/src/unity-logging.ts +++ b/src/unity-logging.ts @@ -1,7 +1,17 @@ import * as fs from 'fs'; import * as path from 'path'; -import { LogLevel, Logger } from './logging'; -import { Delay, WaitForFileToBeUnlocked } from './utilities'; +import { + LogLevel, + Logger, + buildUnitTestJobSummaryMarkdown, + TestResultSummary, + utpToTestResultSummary +} from './logging'; +import { + Delay, + isStdoutTTY, + WaitForFileToBeUnlocked +} from './utilities'; import { Phase, Severity, @@ -9,8 +19,9 @@ import { UTPBase, UTPMemoryLeak, UTPPlayerBuildInfo, + UTPTestStatus, normalizeTelemetryEntry -} from './utp/utp'; +} from './utp'; /** * Result of the tailLogFile function containing cleanup resources. @@ -24,8 +35,8 @@ export interface LogTailResult { telemetry: UTP[]; } -// Detects GitHub-style annotation markers to avoid emitting duplicates -const githubAnnotationPrefixRegex = /\n::[a-z]+::/i; +// Detects workflow command markers to avoid emitting duplicate annotations +const annotationCommandPrefixRegex = /\n::[a-z]+::/i; // Matches ANSI escape sequences (CSI and single-character) const ansiEscapeSequenceRegex = /\u001b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g; @@ -82,16 +93,208 @@ export function sanitizeTelemetryJson(raw: string | undefined): string | undefin return sanitized; } +/** Builds the warning when a `##utp:` payload includes unrecognized root properties. Exported for tests. */ +export function formatUtpUnrecognizedTopLevelPropertiesMessage( + unknownTopLevelKeys: string[], + fullTelemetryLine: string +): string { + return `UTP entry contains unrecognized top-level properties: ${unknownTopLevelKeys.join(', ')}\nFull line: ${fullTelemetryLine}`; +} + +/** + * Single-line debug text for `--log-level UTP` for telemetry types that do not use the action / memory / player-build tables. + * Returns `undefined` when the type should fall back to unknown-type handling (warn + raw JSON). + */ +export function describeUtpForUtpLogLevel(utp: UTP): string | undefined { + switch (utp.type) { + case 'Compiler': + case 'LogEntry': { + const u = utp as UTPBase; + const loc = u.file != null && u.line != null ? `${u.file}:${u.line}` : (u.file ?? ''); + const sev = u.severity != null ? String(u.severity) : ''; + const msg = (u.message ?? ''); + return `[UTP] ${utp.type} ${sev} ${loc} ${msg}`.replace(/\s+/gu, ' ').trim(); + } + case 'TestStatus': { + const u = utp as UTPTestStatus; + const name = (u.name ?? u.description ?? '—').trim(); + const dur = u.duration ?? (u.durationMicroseconds != null ? u.durationMicroseconds / 1000 : 0); + const msg = (u.message ?? ''); + return `[UTP] TestStatus state=${u.state ?? '?'} durMs=${dur} ${name} ${msg}`.replace(/\s+/gu, ' ').trim(); + } + case 'TestPlan': + case 'ScreenSettings': + case 'PlayerSettings': + case 'BuildSettings': + case 'PlayerSystemInfo': + case 'QualitySettings': + return `[UTP] ${utp.type} ${JSON.stringify(utp)}`; + default: + return undefined; + } +} + function sanitizeStackTrace(raw: string | undefined): string | undefined { if (!raw) { return undefined; } const sanitized = raw - .replace(githubAnnotationPrefixRegex, '') + .replace(annotationCommandPrefixRegex, '') .replace(ansiEscapeSequenceRegex, '') .trim(); if (sanitized === '') { return undefined; } return sanitized; } +interface StackFrame { + file: string; + line: number; + title: string; +} + +const MAX_STACK_FRAME_ANNOTATIONS = 5; +const MAX_PLAIN_SCAN_ANNOTATIONS = 100; + +interface PlainLogIssue { + severity: Severity.Error | Severity.Warning; + message: string; + file?: string; + line?: number; +} + +export interface NormalizedAnnotationPath { + absoluteFile?: string; + annotationFile?: string; +} + +function normalizePathSlashes(filePath: string): string { + return path.normalize(filePath).replace(/\\/g, '/'); +} + +/** + * Normalizes a candidate issue file path for annotation and project-path checks. + * - absoluteFile: used for `isFileUnderProjectPath` gating. + * - annotationFile: project-relative path preferred for GitHub annotation rendering. + */ +export function normalizeAnnotationPath(filePath: string | undefined, projectPath: string | undefined): NormalizedAnnotationPath { + if (!filePath) { + return {}; + } + + const trimmed = filePath.trim(); + if (!trimmed) { + return {}; + } + + const projectRootAbsolute = projectPath ? path.resolve(projectPath) : undefined; + const normalizedProject = projectRootAbsolute ? normalizePathSlashes(projectRootAbsolute) : undefined; + const isAbsolute = path.isAbsolute(trimmed); + const absoluteFile = normalizePathSlashes(isAbsolute + ? trimmed + : (projectRootAbsolute ? path.resolve(projectRootAbsolute, trimmed) : trimmed)); + + if (!normalizedProject) { + return { absoluteFile, annotationFile: normalizePathSlashes(trimmed) }; + } + + if (!isFileUnderProjectPath(absoluteFile, normalizedProject)) { + return { absoluteFile }; + } + + const relative = normalizePathSlashes(path.relative(normalizedProject, absoluteFile)); + if (!relative || relative.startsWith('../')) { + return { absoluteFile }; + } + + return { + absoluteFile, + annotationFile: relative, + }; +} + +function parsePlainLogIssue(line: string): PlainLogIssue | undefined { + const paren = line.match(/^(.+?)\((\d+)(?:,\d+)?\):\s*(warning|error)\b[:\s-]*(.*)$/i); + if (paren && paren[1] && paren[2] && paren[3]) { + const severity = paren[3].toLowerCase() === 'warning' ? Severity.Warning : Severity.Error; + const file = paren[1].trim().replace(/\\/g, '/'); + const lineNum = parseInt(paren[2], 10); + const remainder = (paren[4] ?? '').trim(); + const message = remainder.length > 0 ? remainder : line.trim(); + const issue: PlainLogIssue = { severity, file, message }; + if (Number.isFinite(lineNum)) { + issue.line = lineNum; + } + return issue; + } + + const colon = line.match(/^(.+?):(\d+):\s*(warning|error)\b[:\s-]*(.*)$/i); + if (colon && colon[1] && colon[2] && colon[3]) { + const severity = colon[3].toLowerCase() === 'warning' ? Severity.Warning : Severity.Error; + const file = colon[1].trim().replace(/\\/g, '/'); + const lineNum = parseInt(colon[2], 10); + const remainder = (colon[4] ?? '').trim(); + const message = remainder.length > 0 ? remainder : line.trim(); + const issue: PlainLogIssue = { severity, file, message }; + if (Number.isFinite(lineNum)) { + issue.line = lineNum; + } + return issue; + } + + const generic = line.match(/\b(error|warning)\b[:\s-]+(.+)/i); + if (generic && generic[1] && generic[2]) { + const severity = generic[1].toLowerCase() === 'warning' ? Severity.Warning : Severity.Error; + return { severity, message: generic[2].trim() }; + } + + return undefined; +} + +/** + * True if filePath is the project root or under it. Normalizes separators; on Windows compares case-insensitively. + * Exported for unit tests. + */ +export function isFileUnderProjectPath(filePath: string, projectRoot: string): boolean { + const normFile = normalizePathSlashes(filePath); + const normRoot = normalizePathSlashes(projectRoot); + const base = normRoot.endsWith('/') ? normRoot : `${normRoot}/`; + if (process.platform === 'win32') { + const f = normFile.toLowerCase(); + const r = normRoot.toLowerCase(); + const b = base.toLowerCase(); + return f === r || f.startsWith(b); + } + return normFile === normRoot || normFile.startsWith(base); +} + +function parseStackFrames(stackTrace: string, projectPath: string | undefined): StackFrame[] { + const frames: StackFrame[] = []; + const lines = stackTrace.split(/\r?\n/).map(l => l.trim()).filter(Boolean); + for (const stackLine of lines) { + const inMatch = stackLine.match(/\s+in\s+([^\s]+):(\d+)\s*$/); + const parenMatch = stackLine.match(/\(([^)]+):(\d+)\)\s*$/); + const plainMatch = stackLine.match(/^(.+):(\d+)\s*$/); + let file: string | undefined; + let lineNum: number | undefined; + if (inMatch && inMatch[1] != null && inMatch[2] != null) { + file = inMatch[1].replace(/\\/g, '/'); + lineNum = parseInt(inMatch[2], 10); + } else if (parenMatch && parenMatch[1] != null && parenMatch[2] != null) { + file = parenMatch[1].replace(/\\/g, '/'); + lineNum = parseInt(parenMatch[2], 10); + } else if (plainMatch && plainMatch[1] != null && plainMatch[2] != null) { + file = plainMatch[1].replace(/\\/g, '/'); + lineNum = parseInt(plainMatch[2], 10); + } + const line = lineNum !== undefined && Number.isFinite(lineNum) ? lineNum : undefined; + if (file != null && line != null && line > 0) { + const normalized = normalizeAnnotationPath(file, projectPath); + if (projectPath != null && normalized.absoluteFile && normalized.annotationFile) { + frames.push({ file: normalized.annotationFile, line, title: stackLine }); + } + } + } + return frames; +} + const MIN_DESCRIPTION_COLUMN_WIDTH = 16; const DEFAULT_TERMINAL_WIDTH = 120; const TERMINAL_WIDTH_SAFETY_MARGIN = 2; @@ -956,11 +1159,24 @@ export function TailLogFile(logPath: string, projectPath: string | undefined): L const logPollingInterval = 250; let pendingPartialLine = ''; const telemetry: UTP[] = []; + const testResults: TestResultSummary[] = []; + const scannedLogEntries: UTP[] = []; + const seenIssueKeys = new Set(); + const seenAnnotationKeys = new Set(); + let plainScanAnnotations = 0; + /** Dedupe stdout test table rows when Unity emits duplicate TestStatus lines (key: name + state + description). */ + const seenTestStatusKeys = new Set(); const logger = Logger.instance; const actionAccumulator = new ActionTelemetryAccumulator(); - const actionTableRenderer = new ActionTableRenderer(process.stdout.isTTY === true && process.env.CI !== 'true'); + const actionTableRenderer = new ActionTableRenderer(isStdoutTTY()); const utpLogPath = buildUtpLogPath(logPath); let telemetryFlushed = false; + const buildIssueKey = (file: string | undefined, lineNo: number | undefined, message: string): string => { + const normalized = normalizeAnnotationPath(file, projectPath); + const canonicalFile = (normalized.absoluteFile ?? normalizePathSlashes(file ?? '')).toLowerCase(); + const canonicalLine = lineNo ?? 0; + return `${canonicalFile}\u0000${canonicalLine}\u0000${message}`; + }; const renderActionTable = (): void => { const snapshot = actionAccumulator.snapshot(); @@ -973,6 +1189,17 @@ export function TailLogFile(logPath: string, projectPath: string | undefined): L if (telemetryFlushed) { return; } telemetryFlushed = true; await writeUtpTelemetryLog(utpLogPath, telemetry, logger); + const parsed = path.parse(logPath); + Logger.instance.CI_appendWorkflowSummary( + parsed.name, + telemetry, + projectPath != null && projectPath !== '' ? { projectPath, additionalLogEntries: scannedLogEntries } : { additionalLogEntries: scannedLogEntries } + ); + if (testResults.length > 0) { + const limit = logger.getMarkdownByteLimit('stdout'); + const summary = buildUnitTestJobSummaryMarkdown(testResults, limit, '\n'); + process.stdout.write(summary); + } }; const writeStdoutThenTableContent = (content: string, restoreTable: boolean = true): void => { @@ -995,8 +1222,36 @@ export function TailLogFile(logPath: string, projectPath: string | undefined): L if (!sanitizedJson) { return; } const utpJson = JSON.parse(sanitizedJson); - const utp = normalizeTelemetryEntry(utpJson); + const { utp, unknownTopLevelKeys } = normalizeTelemetryEntry(utpJson); + if (unknownTopLevelKeys.length > 0) { + logger.warn(formatUtpUnrecognizedTopLevelPropertiesMessage(unknownTopLevelKeys, line)); + } telemetry.push(utp); + const utpMsg = (utp.message ?? '').trim(); + if ((utp.type === 'LogEntry' || utp.type === 'Compiler') && utpMsg !== '') { + seenIssueKeys.add(buildIssueKey(utp.file, utp.line, utpMsg)); + } + if (utp.type === 'TestStatus') { + const ts = utp as UTP & { name?: string; state?: number; description?: string }; + const dedupeKey = `${ts.name ?? ''}\u0000${ts.state ?? ''}\u0000${ts.description ?? ''}`; + if (!seenTestStatusKeys.has(dedupeKey)) { + seenTestStatusKeys.add(dedupeKey); + const result = utpToTestResultSummary(utp); + testResults.push(result); + } + if ((ts.state === 2 || ts.state === 0) && ts.message && !annotationCommandPrefixRegex.test(ts.message)) { + const normalizedPath = normalizeAnnotationPath(utp.file, projectPath); + const lineNumber = utp.line; + const title = (ts.name ?? ts.description ?? 'Test failure').trim(); + if (normalizedPath.annotationFile && lineNumber) { + const key = buildIssueKey(normalizedPath.annotationFile, lineNumber, ts.message); + if (!seenAnnotationKeys.has(key)) { + seenAnnotationKeys.add(key); + logger.annotate(ts.state === 2 ? LogLevel.ERROR : LogLevel.WARN, ts.message, normalizedPath.annotationFile, lineNumber, undefined, undefined, undefined, title); + } + } + } + } if (utp.message && 'severity' in utp && (utp.severity === Severity.Error || utp.severity === Severity.Exception || utp.severity === Severity.Assert)) { @@ -1007,14 +1262,22 @@ export function TailLogFile(logPath: string, projectPath: string | undefined): L messageLevel = remappedLevel; } - const file = utp.file ? utp.file.replace(/\\/g, '/') : undefined; + const normalizedPath = normalizeAnnotationPath(utp.file, projectPath); const stacktrace = sanitizeStackTrace(utp.stackTrace); const message = stacktrace == undefined ? utp.message : `${utp.message}\n${stacktrace}`; - if (!githubAnnotationPrefixRegex.test(message)) { + if (!annotationCommandPrefixRegex.test(message)) { // only annotate if the file is within the current project - if (projectPath && file && file.startsWith(projectPath)) { - logger.annotate(LogLevel.ERROR, message, file, utp.line); + if (normalizedPath.annotationFile) { + logger.annotate(LogLevel.ERROR, message, normalizedPath.annotationFile, utp.line); + // Link stack trace to annotations: emit one annotation per frame (capped) for clickable stack in Checks + if (stacktrace && projectPath) { + const frames = parseStackFrames(stacktrace, projectPath); + const toEmit = frames.slice(0, MAX_STACK_FRAME_ANNOTATIONS); + for (const frame of toEmit) { + logger.annotate(LogLevel.ERROR, frame.title, frame.file, frame.line, undefined, undefined, undefined, 'Stack frame'); + } + } } else { switch (messageLevel) { case LogLevel.WARN: @@ -1037,6 +1300,31 @@ export function TailLogFile(logPath: string, projectPath: string | undefined): L logger.warn(`Failed to parse telemetry JSON: ${error} -- raw: ${jsonPart}`); } } else { + const scan = parsePlainLogIssue(line); + if (scan) { + const key = buildIssueKey(scan.file, scan.line, scan.message); + if (!seenIssueKeys.has(key)) { + seenIssueKeys.add(key); + scannedLogEntries.push({ + type: 'Compiler', + severity: scan.severity, + message: scan.message, + file: scan.file, + line: scan.line, + } as UTP); + } + if (!annotationCommandPrefixRegex.test(scan.message) && plainScanAnnotations < MAX_PLAIN_SCAN_ANNOTATIONS) { + const normalizedPath = normalizeAnnotationPath(scan.file, projectPath); + const annotationKey = buildIssueKey(normalizedPath.annotationFile ?? scan.file, scan.line, scan.message); + if (!seenAnnotationKeys.has(annotationKey)) { + if (normalizedPath.annotationFile && scan.line) { + seenAnnotationKeys.add(annotationKey); + plainScanAnnotations++; + logger.annotate(scan.severity === Severity.Warning ? LogLevel.WARN : LogLevel.ERROR, scan.message, normalizedPath.annotationFile, scan.line); + } + } + } + } if (Logger.instance.logLevel !== LogLevel.UTP) { process.stdout.write(`${line}\n`); } @@ -1057,6 +1345,7 @@ export function TailLogFile(logPath: string, projectPath: string | undefined): L break; } case 'MemoryLeaks': + case 'MemoryLeak': logger.debug(formatMemoryLeakTable(utp as UTPMemoryLeak)); break; case 'PlayerBuildInfo': { @@ -1069,11 +1358,16 @@ export function TailLogFile(logPath: string, projectPath: string | undefined): L break; } - default: + default: { + const desc = describeUtpForUtpLogLevel(utp); + if (desc !== undefined) { + logger.debug(desc); + break; + } logger.warn(`UTP entry has unknown type: ${utp.type ?? 'undefined'}`); - // Print raw JSON for unhandled UTP types writeStdoutThenTableContent(`${JSON.stringify(utp)}\n`); break; + } } } diff --git a/src/upm-cli.ts b/src/upm-cli.ts new file mode 100644 index 00000000..43b27b96 --- /dev/null +++ b/src/upm-cli.ts @@ -0,0 +1,519 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + SemVer, + coerce, + compare, + parse, + valid, +} from 'semver'; +import { + Logger, + LogLevel +} from './logging'; +import { + DeleteDirectory, + DownloadFile, + Exec, + ExecOptions, + extractZipNative, + GetTempDir, + HttpsGetText, + isInteractiveTerminalSession, + PromptYesNo, + Sha256FileHex, +} from './utilities'; + +export interface EnsureUpmInstalledOptions { + version?: string; + skipIfInstalled?: boolean; +} + +/** Arguments for {@link UpmCli.Pack} (mapped to `UnityPackageManager pack …`). */ +export interface UpmPackOptions { + /** Unity Cloud organization id. */ + organizationId: string; + /** Output path for the packed artifact. */ + destination?: string; + /** Folder containing `package.json` to pack; when omitted the child process cwd applies. */ + packageDirectory?: string; +} + +/** + * Managed Unity Package Manager CLI (unity-cli–installed `UnityPackageManager`), modeled after {@link UnityHub}: + * parameterless constructor resolves roots and executable preference, {@link Install} manages downloads, {@link Exec} runs the binary. + */ +export class UpmCli { + /** Root directory for managed installs (~/.unity-cli/upm), analogous to {@link UnityHub.rootDirectory}. */ + public readonly managedRoot: string; + + private readonly logger: Logger = Logger.instance; + + constructor() { + this.managedRoot = path.join(os.homedir(), '.unity-cli', 'upm'); + } + + private static getCdnBaseUrl(): string { + const override = process.env.UPM_CDN_BASE_URL?.trim(); + if (override && override.length > 0) { + return `${override.replace(/\/$/, '')}/upm-cli`; + } + return 'https://cdn.packages.unity.com/upm-cli'; + } + + private static normalizeSemver(version: string): string | undefined { + const normalized = valid(version); + if (normalized) { + return normalized; + } + const coerced = coerce(version); + return coerced?.version; + } + + private static parseVerifiedSemVerFromLine(line: string): SemVer | null { + const t = line.trim(); + if (!t) { + return null; + } + const direct = valid(t); + if (direct) { + const parsed = parse(direct, false); + if (parsed && valid(parsed.version)) { + return parsed; + } + } + const coerced = coerce(t); + if (coerced && valid(coerced)) { + return coerced; + } + return null; + } + + private static parseCliVersionStdout(output: string): SemVer { + const trimmed = output.trim(); + if (!trimmed) { + throw new Error('Upm cli --version produced empty output.'); + } + const lines = trimmed.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0); + for (let i = lines.length - 1; i >= 0; i--) { + const version = UpmCli.parseVerifiedSemVerFromLine(lines[i]!); + if (version) { + return version; + } + } + const fallback = UpmCli.parseVerifiedSemVerFromLine(trimmed); + if (fallback) { + return fallback; + } + throw new Error(`Failed to parse upm cli version: ${JSON.stringify(trimmed)}`); + } + + private getVersionInstallDir(version: string): string { + const t = version.trim(); + this.validateVersionFormat(t); + if (t.includes('..') || path.normalize(t) !== t) { + throw new Error(`Invalid upm cli release tag for path use: ${version}`); + } + const dir = path.join(this.managedRoot, t); + const resolvedDir = path.resolve(dir); + const resolvedRoot = path.resolve(this.managedRoot); + const rootPrefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`; + if (resolvedDir !== resolvedRoot && !resolvedDir.startsWith(rootPrefix)) { + throw new Error('Resolved UPM install directory left managed root.'); + } + return dir; + } + + private getCurrentVersionFilePath(): string { + return path.join(this.managedRoot, 'current-version.txt'); + } + + private getPlatformId(): string { + const plat = process.platform; + const arch = process.arch; + + if (plat === 'win32') { + if (arch === 'arm64') { + return 'windows-arm64'; + } + return 'windows-x64'; + } + + if (plat === 'darwin') { + if (arch === 'arm64') { + return 'macos-arm64'; + } + return 'macos-x64'; + } + + if (plat === 'linux') { + if (arch === 'arm64') { + return 'linux-arm64'; + } + return 'linux-x64'; + } + + throw new Error(`Unsupported platform for upm cli: ${plat} ${arch}`); + } + + private validateVersionFormat(version: string): void { + const t = version.trim(); + if (!t.startsWith('v') || !valid(t)) { + throw new Error(`Invalid upm cli version format: ${version}. Expected a semver release tag with leading v (e.g. v9.27.0).`); + } + } + + private findPrimaryExecutable(installDir: string): string { + if (process.platform === 'win32') { + const exe = path.join(installDir, 'UnityPackageManager.exe'); + if (fs.existsSync(exe)) { + return exe; + } + } else { + const bin = path.join(installDir, 'UnityPackageManager'); + if (fs.existsSync(bin)) { + return bin; + } + } + throw new Error(`Could not find UnityPackageManager binary under ${installDir}`); + } + + /** Optional executable override (mirrors `UNITY_HUB_PATH` for {@link UnityHub}). */ + private getExecutablePathOverride(): string | undefined { + const p = process.env.UPM_CLI_PATH?.trim(); + return p && p.length > 0 ? path.normalize(p) : undefined; + } + + private executableOverrideIsUsable(): boolean { + const p = this.getExecutablePathOverride(); + if (!p) { + return false; + } + try { + fs.accessSync(p, fs.constants.R_OK | fs.constants.X_OK); + return true; + } catch { + return false; + } + } + + /** + * Release tag of the managed install from `current-version.txt`, if present and valid. + */ + public GetInstalledReleaseTag(): string | undefined { + const currentFile = this.getCurrentVersionFilePath(); + if (!fs.existsSync(currentFile)) { + return undefined; + } + try { + const version = fs.readFileSync(currentFile, 'utf8').trim(); + if (!version) { + return undefined; + } + this.validateVersionFormat(version); + return version; + } catch { + return undefined; + } + } + + /** + * Path to the primary `UnityPackageManager` binary for a managed release, or `undefined` if missing. + */ + public ResolveManagedPrimaryPath(version?: string): string | undefined { + let v = version?.trim() || this.GetInstalledReleaseTag(); + + if (!v) { + return undefined; + } + + const installDir = this.getVersionInstallDir(v); + try { + return this.findPrimaryExecutable(installDir); + } catch { + return undefined; + } + } + + /** + * Resolved path used to spawn the UPM CLI: `UPM_CLI_PATH` override when set, otherwise the managed primary binary. + * @throws If nothing usable is installed (mirrors Hub/Editor behavior when the executable cannot be used). + */ + public GetExecutablePath(): string { + const overridePath = this.getExecutablePathOverride(); + if (overridePath) { + fs.accessSync(overridePath, fs.constants.R_OK | fs.constants.X_OK); + return overridePath; + } + const managed = this.ResolveManagedPrimaryPath(); + if (!managed) { + throw new Error('Upm cli is not installed. Run `unity-cli upm-install` first.'); + } + fs.accessSync(managed, fs.constants.R_OK | fs.constants.X_OK); + return managed; + } + + /** Same role as {@link UnityHub.executable}: path used to spawn the UPM CLI (may reflect `UPM_CLI_PATH` or the managed install). */ + public get executable(): string { + return this.GetExecutablePath(); + } + + public async GetLatestReleaseTag(): Promise { + const cdn = UpmCli.getCdnBaseUrl(); + const latestUrl = `${cdn}/latest.txt`; + const version = (await HttpsGetText(latestUrl)).trim(); + this.validateVersionFormat(version); + return version; + } + + /** True if `latestTag` is newer than the installed managed release, or nothing is installed yet. */ + public IsUpdateAvailable(latestTag: string): boolean { + const current = this.GetInstalledReleaseTag(); + if (!current) { + return true; + } + const normalizedCurrent = UpmCli.normalizeSemver(current); + const normalizedLatest = UpmCli.normalizeSemver(latestTag); + if (normalizedCurrent && normalizedLatest) { + return compare(normalizedLatest, normalizedCurrent) > 0; + } + return latestTag.trim() !== current.trim(); + } + + /** + * Installs or updates the managed UPM CLI (mirrors {@link UnityHub.Install} for the Hub itself). + * @returns Installed release tag (e.g. v9.27.0). + */ + public async Install(options?: EnsureUpmInstalledOptions): Promise { + const cdn = UpmCli.getCdnBaseUrl(); + let version = options?.version?.trim(); + + if (!version || version.length === 0) { + version = await this.GetLatestReleaseTag(); + } + + version = version.trim(); + + const installDir = this.getVersionInstallDir(version); + const markerPath = path.join(installDir, '.unity-cli-upm-installed'); + + if (options?.skipIfInstalled !== false && fs.existsSync(markerPath)) { + try { + this.findPrimaryExecutable(installDir); + const recordedTag = path.basename(installDir); + await fs.promises.writeFile(this.getCurrentVersionFilePath(), `${recordedTag}\n`, 'utf8'); + return version; + } catch { + // reinstall + } + } + + const platform = this.getPlatformId(); + const zipName = `upm-${platform}.zip`; + const baseReleaseUrl = `${cdn}/releases/${version}`; + const zipUrl = `${baseReleaseUrl}/${zipName}`; + const checksumUrl = `${baseReleaseUrl}/${zipName}.sha256`; + + const tempRoot = path.join(GetTempDir(), `unity-cli-upm-${Date.now()}`); + const resolvedTempRoot = path.resolve(tempRoot); + const zipPath = path.join(resolvedTempRoot, zipName); + const checksumPath = path.join(resolvedTempRoot, `${zipName}.sha256`); + + try { + this.logger.info(`Installing upm cli ${version} (${platform})...`); + await DownloadFile(zipUrl, zipPath); + await DownloadFile(checksumUrl, checksumPath); + + const checksumContent = (await fs.promises.readFile(checksumPath, 'utf8')).trim(); + const expectedHash = checksumContent.split(/\s+/)[0]?.toLowerCase(); + if (!expectedHash) { + throw new Error(`Could not read SHA-256 from ${checksumPath}`); + } + + const actualHash = (await Sha256FileHex(zipPath)).toLowerCase(); + if (actualHash !== expectedHash) { + throw new Error(`SHA-256 mismatch for upm cli zip. Expected ${expectedHash}, got ${actualHash}`); + } + + await DeleteDirectory(installDir); + await fs.promises.mkdir(installDir, { recursive: true }); + await extractZipNative(zipPath, installDir, { + zipUnder: resolvedTempRoot, + destUnder: path.resolve(this.managedRoot), + }, { + silent: false, + showCommand: this.logger.logLevel === LogLevel.DEBUG + }); + + const primary = this.findPrimaryExecutable(installDir); + if (process.platform !== 'win32') { + try { + fs.chmodSync(primary, 0o755); + } catch { + // ignore + } + } + + const wrapperUnix = path.join(installDir, 'upm'); + if (process.platform !== 'win32' && fs.existsSync(wrapperUnix)) { + try { + fs.chmodSync(wrapperUnix, 0o755); + } catch { + // ignore + } + } + + await fs.promises.writeFile(markerPath, `${new Date().toISOString()}\n`, 'utf8'); + const recordedTag = path.basename(installDir); + await fs.promises.writeFile(this.getCurrentVersionFilePath(), `${recordedTag}\n`, 'utf8'); + + return version; + } finally { + await DeleteDirectory(tempRoot); + } + } + + /** + * When running in an interactive terminal, may prompt to install a missing UPM CLI or update to the latest CDN release. + * When not interactive, logs a warning if the running binary is older than the CDN latest (no install). + * Compares the running binary ({@link Version}) to {@link GetLatestReleaseTag} (including when {@code UPM_CLI_PATH} overrides the managed install). + * Call before {@link GetExecutablePath} / {@link Exec} for Hub-style optional install/update (e.g. pack). + */ + public async PromptInstallOrUpdateWhenInteractive(): Promise { + const overrideUsable = this.executableOverrideIsUsable(); + const managedExe = this.ResolveManagedPrimaryPath(); + const hasExecutable = overrideUsable || managedExe !== undefined; + + if (!hasExecutable) { + if (isInteractiveTerminalSession()) { + const install = await PromptYesNo( + 'The upm cli is not installed. Download and install it now?', + true + ); + if (install) { + await this.Install({ skipIfInstalled: false }); + } + } + return; + } + + try { + const latestTag = await this.GetLatestReleaseTag(); + const latestSem = UpmCli.parseVerifiedSemVerFromLine(latestTag); + if (!latestSem) { + return; + } + + const installedSem = await this.Version(); + if (compare(latestSem, installedSem) <= 0) { + return; + } + + const usingOverride = overrideUsable; + + if (!isInteractiveTerminalSession()) { + if (usingOverride) { + this.logger.warn( + `The upm cli (UPM_CLI_PATH) reports ${installedSem.version}, but ${latestTag} is available on the CDN. This run still uses UPM_CLI_PATH; update that binary or unset it and run unity-cli upm-install to use the managed release.`, + ); + } else { + this.logger.warn( + `The upm cli (${installedSem.version}) is older than the latest release (${latestTag}). Run unity-cli upm-install or unity-cli upm-install --auto-update to update.`, + ); + } + return; + } + + const prompt = usingOverride + ? `Your upm cli (UPM_CLI_PATH) reports ${installedSem.version}, but ${latestTag} is available. Install the latest to the managed location now? This run will keep using UPM_CLI_PATH until you unset it or point it at the new binary.` + : `A newer upm cli version is available (${installedSem.version} -> ${latestTag}). Install it now?`; + + const shouldUpdate = await PromptYesNo(prompt, !usingOverride); + if (!shouldUpdate) { + return; + } + + await this.Install({ + version: latestTag, + skipIfInstalled: false, + }); + + if (usingOverride) { + this.logger.warn( + `Installed upm cli ${latestTag} under ${this.managedRoot}. Unset UPM_CLI_PATH (or update it) so subsequent commands use the new install.`, + ); + } + } catch (error) { + this.logger.debug(`Failed to check for upm cli updates: ${error}`); + } + } + + /** + * Executes the UPM CLI with the given arguments (mirrors {@link UnityHub.Exec}). + */ + public async Exec(args: string[], options: ExecOptions = { silent: this.logger.logLevel > LogLevel.CI, showCommand: this.logger.logLevel <= LogLevel.CI }): Promise { + const exe = this.GetExecutablePath(); + if (exe.includes(path.sep)) { + fs.accessSync(exe, fs.constants.R_OK | fs.constants.X_OK); + } + return Exec(exe, args, options); + } + + /** + * Runs `--version` and returns the verified semver from the binary. + * @param expectedReleaseTag When set (e.g. from {@link Install}), ensures the reported semver matches this CDN release tag. + */ + public async Version(expectedReleaseTag?: string): Promise { + const raw = await this.Exec(['--version'], { + silent: true, + showCommand: this.logger.logLevel === LogLevel.DEBUG, + }); + const version = UpmCli.parseCliVersionStdout(raw); + if (expectedReleaseTag !== undefined && expectedReleaseTag.trim().length > 0) { + const tag = expectedReleaseTag.trim(); + const expected = UpmCli.parseVerifiedSemVerFromLine(tag); + if (!expected) { + throw new Error(`Invalid installed upm cli release tag: ${expectedReleaseTag}`); + } + if (compare(version, expected) !== 0) { + throw new Error( + `Upm cli binary version mismatch: binary reported ${version.version} (--version), expected ${expected.version} (${expectedReleaseTag}).` + ); + } + } + return version; + } + + /** + * Runs the UPM CLI `pack` subcommand (builds argv from {@link UpmPackOptions}, then {@link Exec}). + */ + public async Pack(options: UpmPackOptions, execOptions?: ExecOptions): Promise { + const orgId = options.organizationId.trim(); + + if (!orgId) { + throw new Error('UpmCli.Pack requires a non-empty organizationId.'); + } + + const args: string[] = []; + + if (this.logger.logLevel === LogLevel.DEBUG) { + args.push('--log-level', '5', '--console-log-level', '5'); + } + + args.push('pack', '--organization-id', orgId); + const dest = options.destination?.trim(); + + if (dest && dest.length > 0) { + args.push('--destination', dest); + } + + const dir = options.packageDirectory?.trim(); + + if (dir && dir.length > 0) { + args.push(dir); + } + + return this.Exec(args, execOptions); + } +} diff --git a/src/utilities.ts b/src/utilities.ts index 7b910b77..6ece8820 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -1,3 +1,4 @@ +import * as crypto from 'crypto'; import * as os from 'os'; import * as fs from 'fs'; import * as path from 'path'; @@ -68,9 +69,87 @@ export async function PromptForSecretInput(prompt: string): Promise { }); } +/** + * Prompts for y/n. Empty input uses `defaultYes` (Y/n vs y/N suffix). + */ +export async function PromptYesNo(prompt: string, defaultYes: boolean): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + const hint = defaultYes ? ' [Y/n]: ' : ' [y/N]: '; + rl.question(`${prompt}${hint}`, (input) => { + rl.close(); + const a = input.trim().toLowerCase(); + if (a.length === 0) { + resolve(defaultYes); + return; + } + resolve(a === 'y' || a === 'yes'); + }); + }); +} + +/** + * True when stdin and stdout are TTYs and the process is not running under CI. + * Use before interactive prompts (readline). + */ +export function isInteractiveTerminalSession(): boolean { + return ( + process.stdin.isTTY === true && + process.stdout.isTTY === true && + process.env.CI !== 'true' + ); +} + +/** + * True when {@link process.stdout} is a TTY and the process is not running under CI. + * Use for terminal-only output (e.g. live tables, ANSI) that does not read from stdin. + * This is not the same as {@link isInteractiveTerminalSession} (which also requires a TTY on stdin for prompts). + */ +export function isStdoutTTY(): boolean { + return process.stdout.isTTY === true && process.env.CI !== 'true'; +} + export type ExecOptions = { silent?: boolean; showCommand?: boolean; + /** + * Substrings replaced with `*****` in streamed lines, captured output, and the logged command line. + * Only values with length >= 4 are applied (avoids noisy replacements). Longer literals are applied first. + */ + redactLiterals?: readonly string[]; +} + +/** Dedupes, trims, drops short values, longest-first (so one secret cannot leak via another). */ +export function orderedRedactionSecrets(literals: readonly string[] | undefined): string[] { + if (!literals || literals.length === 0) { + return []; + } + const seen = new Set(); + for (const raw of literals) { + const s = raw.trim(); + if (s.length >= 4) { + seen.add(s); + } + } + return [...seen].sort((a, b) => b.length - a.length); +} + +/** Replaces each configured literal with `*****` everywhere it appears in `text`. */ +export function redactSensitiveLiterals(text: string, literals: readonly string[] | undefined): string { + const secrets = orderedRedactionSecrets(literals); + if (secrets.length === 0 || text.length === 0) { + return text; + } + let result = text; + for (const sec of secrets) { + if (result.includes(sec)) { + result = result.split(sec).join('*****'); + } + } + return result; } /** @@ -88,9 +167,12 @@ export async function Exec(command: string, args: string[], options: ExecOptions const isDebug = logger.logLevel === LogLevel.DEBUG; const isSilent = isDebug ? false : options.silent ? options.silent : false; const mustShowCommand = isDebug ? true : options.showCommand ? options.showCommand : false; + const redactionSecrets = orderedRedactionSecrets(options.redactLiterals); + const redact = (text: string): string => + redactionSecrets.length === 0 ? text : redactSensitiveLiterals(text, redactionSecrets); if (mustShowCommand) { - const commandStr = `\x1b[34m${command} ${args.join(' ')}\x1b[0m`; + const commandStr = redact(`\x1b[34m${command} ${args.join(' ')}\x1b[0m`); if (isSilent) { logger.info(commandStr); @@ -138,10 +220,11 @@ export async function Exec(command: string, args: string[], options: ExecOptions } for (const line of lines) { - output += `${line}\n`; + const safeLine = redact(line); + output += `${safeLine}\n`; if (!isSilent) { - process.stdout.write(`${line}\n`); + process.stdout.write(`${safeLine}\n`); } } } catch (error: any) { @@ -168,10 +251,11 @@ export async function Exec(command: string, args: string[], options: ExecOptions .filter(line => line.length > 0); // filter out empty lines for (const line of lines) { - output += `${line}\n`; + const safeLine = redact(line); + output += `${safeLine}\n`; if (!isSilent) { - process.stdout.write(`${line}\n`); + process.stdout.write(`${safeLine}\n`); } } } @@ -192,15 +276,136 @@ export async function Exec(command: string, args: string[], options: ExecOptions } if (exitCode !== 0) { - throw new Error(`${command} failed with exit code ${exitCode}\n${output}`); + const tail = isSilent && output.length > 0 ? `\n${output}` : ''; + throw new Error(`${command} failed with exit code ${exitCode}${tail}`); } } return output; } +/** + * Confines archive extraction paths before spawning tools (mitigates CodeQL `js/shell-command-constructed-from-input`). + * Both paths must resolve under the given roots (e.g. temp download dir and managed UPM root). + */ +export interface ZipExtractPathTrust { + /** Directory tree that must contain `zipPath` (e.g. resolved temp root for this download). */ + zipUnder: string; + /** Directory tree that must contain `destDir` (e.g. managed `~/.unity-cli/upm`). */ + destUnder: string; +} + +function assertResolvedPathUnderRoot(candidate: string, root: string, label: string): void { + const resolved = path.resolve(candidate); + const resolvedRoot = path.resolve(root); + const prefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`; + if (resolved !== resolvedRoot && !resolved.startsWith(prefix)) { + throw new Error(`${label}: path is outside permitted root (${root}): ${candidate}`); + } +} + +/** + * Extracts a zip archive using only OS tools (`tar` or PowerShell on Windows, `unzip` on macOS/Linux). + * Does not use a Node unzip library. + */ +export async function extractZipNative( + zipPath: string, + destDir: string, + pathTrust: ZipExtractPathTrust, + execOptions?: ExecOptions +): Promise { + assertResolvedPathUnderRoot(zipPath, pathTrust.zipUnder, 'extractZipNative zipPath'); + assertResolvedPathUnderRoot(destDir, pathTrust.destUnder, 'extractZipNative destDir'); + await fs.promises.mkdir(destDir, { recursive: true }); + const silent = execOptions?.silent ?? true; + const show = execOptions?.showCommand ?? false; + + if (process.platform === 'win32') { + try { + await Exec('tar', [ + '-xf', + zipPath, + '-C', + destDir + ], { + silent, + showCommand: show + }); + } catch { + const scriptBody = + 'param([Parameter(Mandatory=$true)][string]$ZipPath,[Parameter(Mandatory=$true)][string]$DestPath)\n' + + '$ErrorActionPreference = "Stop"\n' + + 'Expand-Archive -LiteralPath $ZipPath -DestinationPath $DestPath -Force\n'; + const tmpDir = await fs.promises.mkdtemp(path.join(GetTempDir(), 'unity-cli-expand-zip-')); + const scriptPath = path.join(tmpDir, 'Expand-Archive.ps1'); + try { + await fs.promises.writeFile(scriptPath, scriptBody, 'utf8'); + await Exec('powershell.exe', [ + '-NoProfile', + '-NonInteractive', + '-File', + scriptPath, + zipPath, + destDir, + ], { + silent, + showCommand: show, + }); + } finally { + await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); + } + } + } else { + await Exec('unzip', [ + '-o', + '-q', + zipPath, + '-d', + destDir + ], { + silent, + showCommand: show + }); + } +} + +/** + * GET an HTTPS URL and return the response body as UTF-8 text (trimmed). + * @throws If the response status is not 200 or the request fails. + */ +export async function HttpsGetText(url: string): Promise { + return new Promise((resolve, reject) => { + https.get(url, (response) => { + if (response.statusCode !== 200) { + reject(new Error(`GET ${url} failed: HTTP ${response.statusCode}`)); + response.resume(); + return; + } + const chunks: Buffer[] = []; + response.on('data', (c: Buffer) => chunks.push(c)); + response.on('end', () => resolve(Buffer.concat(chunks).toString('utf8').trim())); + }).on('error', reject); + }); +} + +/** + * Computes the SHA-256 digest of a file as a lowercase hex string. + */ +export async function Sha256FileHex(filePath: string): Promise { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + return new Promise((resolve, reject) => { + stream.on('data', (chunk: string | Buffer) => { + hash.update(chunk); + }); + stream.on('end', () => resolve(hash.digest('hex'))); + stream.on('error', reject); + }); +} + /** * Downloads a file from a URL to a specified path. + * Requires HTTP status 200 before writing. Verifies the file is readable after download. * @param url The URL to download from. * @param downloadPath The path to save the downloaded file. * @throws An error if the download fails or the file is not accessible after download. @@ -209,20 +414,32 @@ export async function DownloadFile(url: string, downloadPath: string): Promise((resolve, reject) => { - const file = fs.createWriteStream(downloadPath, { mode: 0o755 }); https.get(url, (response) => { + if (response.statusCode !== 200) { + response.resume(); + reject(new Error(`GET ${url} failed: HTTP ${response.statusCode}`)); + return; + } + const file = fs.createWriteStream(downloadPath, { mode: 0o755 }); + const fail = (err: Error) => { + file.destroy(); + void fs.promises.unlink(downloadPath).catch(() => undefined); + reject(err); + }; + response.once('error', fail); + file.once('error', fail); response.pipe(file); file.on('finish', () => { - file.close(); - resolve(); + file.close(() => resolve()); }); }).on('error', (error) => { - fs.unlink(downloadPath, () => reject(`Download failed: ${error}`)); + void fs.promises.unlink(downloadPath).catch(() => undefined); + reject(error); }); }); - // make sure the file is closed and accessible + await new Promise((r) => setTimeout(r, 100)); - await fs.promises.access(downloadPath, fs.constants.R_OK | fs.constants.X_OK); + await fs.promises.access(downloadPath, fs.constants.R_OK); } /** diff --git a/src/utp/utp.ts b/src/utp.ts similarity index 79% rename from src/utp/utp.ts rename to src/utp.ts index a549304a..f917ad53 100644 --- a/src/utp/utp.ts +++ b/src/utp.ts @@ -1,4 +1,4 @@ -import { Logger } from "../logging"; +import { Logger } from "./logging"; export class UTPBase { type?: string; @@ -20,13 +20,19 @@ export class UTPBase { errors?: unknown[]; } +export class UTPAction extends UTPBase { } + export class UTPMemoryLeak extends UTPBase { allocatedMemory?: number; memoryLabels?: Record | Array>; } +export class UTPMemoryLeaks extends UTPMemoryLeak { } + export class UTPLogEntry extends UTPBase { } +export class UTPCompiler extends UTPBase { } + export class UTPTestPlan extends UTPBase { tests?: string[]; } @@ -113,10 +119,13 @@ export interface PlayerBuildInfoStep { } export class UTPPlayerBuildInfo extends UTPBase { + success?: boolean; steps?: PlayerBuildInfoStep[]; } export type UTP = + | UTPAction + | UTPCompiler | UTPBase | UTPLogEntry | UTPTestPlan @@ -127,6 +136,7 @@ export type UTP = | UTPQualitySettings | UTPTestStatus | UTPMemoryLeak + | UTPMemoryLeaks | UTPPlayerBuildInfo; export enum Phase { @@ -143,7 +153,11 @@ export enum Severity { Assert = 'Assert' } -const allowedUtpKeys = new Set([ +/** + * Root-level JSON keys on UTP objects that this CLI recognizes. Other keys are still parsed + * but reported via {@link normalizeTelemetryEntry}'s `unknownTopLevelKeys` for logging. + */ +export const UTP_SUPPORTED_TOP_LEVEL_PROPERTIES = new Set([ 'allocatedMemory', 'BuildSettings', 'description', @@ -165,6 +179,7 @@ const allowedUtpKeys = new Set([ 'QualitySettings', 'ScreenSettings', 'severity', + 'success', 'stacktrace', 'stackTrace', 'state', @@ -175,12 +190,19 @@ const allowedUtpKeys = new Set([ 'version', ]); +export interface NormalizeTelemetryResult { + utp: UTP; + /** Top-level property names present in the payload but not in {@link UTP_SUPPORTED_TOP_LEVEL_PROPERTIES}. */ + unknownTopLevelKeys: string[]; +} + /** - * Normalizes UTP telemetry entries to canonical shapes and reports unexpected properties. + * Normalizes UTP telemetry entries to canonical shapes. Unknown top-level keys are listed + * for the caller to log (with the raw `##utp:` line when tailing logs). */ -export function normalizeTelemetryEntry(entry: unknown): UTP { +export function normalizeTelemetryEntry(entry: unknown): NormalizeTelemetryResult { if (!entry || typeof entry !== 'object') { - return entry as UTP; + return { utp: entry as UTP, unknownTopLevelKeys: [] }; } const utp = entry as UTP; @@ -216,16 +238,12 @@ export function normalizeTelemetryEntry(entry: unknown): UTP { Logger.instance.warn('UTP entry missing type property; telemetry entry may be ignored.'); } - const extras: string[] = []; + const unknownTopLevelKeys: string[] = []; for (const key of Object.keys(record)) { - if (!allowedUtpKeys.has(key)) { - extras.push(key); + if (!UTP_SUPPORTED_TOP_LEVEL_PROPERTIES.has(key)) { + unknownTopLevelKeys.push(key); } } - if (extras.length > 0) { - Logger.instance.warn(`UTP entry contains unrecognized properties: ${extras.join(', ')}`); - } - - return utp; + return { utp, unknownTopLevelKeys }; } \ No newline at end of file diff --git a/tests/exec-redaction.test.ts b/tests/exec-redaction.test.ts new file mode 100644 index 00000000..34e74fd1 --- /dev/null +++ b/tests/exec-redaction.test.ts @@ -0,0 +1,15 @@ +import { orderedRedactionSecrets, redactSensitiveLiterals } from '../src/utilities'; + +describe('exec redaction helpers', () => { + it('orderedRedactionSecrets dedupes, drops short strings, longest first', () => { + expect(orderedRedactionSecrets(['ab', 'abcd', 'abcd', 'wxyz'])).toEqual(['abcd', 'wxyz']); + }); + + it('redactSensitiveLiterals replaces configured secrets', () => { + const secrets = ['2474207050017', 'my-long-service-secret']; + const line = 'Organization ID: 2474207050017 token my-long-service-secret end'; + expect(redactSensitiveLiterals(line, secrets)).toBe( + 'Organization ID: ***** token ***** end' + ); + }); +}); diff --git a/tests/fixtures/utp-ci/README.md b/tests/fixtures/utp-ci/README.md new file mode 100644 index 00000000..cc06a40a --- /dev/null +++ b/tests/fixtures/utp-ci/README.md @@ -0,0 +1,5 @@ +# UTP CI fixtures + +Synthetic files used by `tests/run-utp-tests-contract.sh` are generated inline in that script. + +Real CI artifacts for regression reviews are stored under repository `_temp/` (gitignored) when downloaded locally, e.g. `_temp/-artifacts-full/`. diff --git a/tests/fixtures/utp/compiler-and-logentry.json b/tests/fixtures/utp/compiler-and-logentry.json new file mode 100644 index 00000000..fa1c7176 --- /dev/null +++ b/tests/fixtures/utp/compiler-and-logentry.json @@ -0,0 +1,32 @@ +[ + { + "type": "Compiler", + "version": 2, + "phase": "Immediate", + "time": 1776545848626, + "processId": 11122, + "severity": "Error", + "message": "Assets/UnityCliTests/CompilerErrors.cs(2,8): error CS1029: #error: 'Intentional compiler error: CS1029'", + "stacktrace": "", + "line": 2, + "file": "Assets/UnityCliTests/CompilerErrors.cs", + "stackTrace": "", + "fileName": "Assets/UnityCliTests/CompilerErrors.cs", + "lineNumber": 2 + }, + { + "type": "LogEntry", + "version": 2, + "phase": "Immediate", + "time": 1776545849441, + "processId": 11122, + "severity": "Error", + "message": "Scripts have compiler errors.", + "stacktrace": "", + "line": 376, + "file": "./Runtime/Utilities/Argv.cpp", + "stackTrace": "", + "fileName": "./Runtime/Utilities/Argv.cpp", + "lineNumber": 376 + } +] diff --git a/tests/fixtures/utp/memory-leaks.json b/tests/fixtures/utp/memory-leaks.json new file mode 100644 index 00000000..61447a42 --- /dev/null +++ b/tests/fixtures/utp/memory-leaks.json @@ -0,0 +1,16 @@ +[ + { + "type": "MemoryLeaks", + "version": 2, + "phase": "Immediate", + "time": 1776560249323, + "processId": 11224, + "allocatedMemory": 11431386, + "memoryLabels": [ + { "Default": 22013 }, + { "Permanent": 16136 }, + { "Thread": 1084656 }, + { "GfxDevice": 36008 } + ] + } +] diff --git a/tests/fixtures/utp/player-build-info.json b/tests/fixtures/utp/player-build-info.json new file mode 100644 index 00000000..1c7ad9b5 --- /dev/null +++ b/tests/fixtures/utp/player-build-info.json @@ -0,0 +1,16 @@ +[ + { + "success": true, + "type": "PlayerBuildInfo", + "version": 2, + "phase": "Immediate", + "time": 1776554398641, + "processId": 14606, + "steps": [ + { "description": "Preprocess Player", "duration": 473 }, + { "description": "Prepare For Build", "duration": 2023 }, + { "description": "Postprocess built player", "duration": 559541 } + ], + "duration": 743780 + } +] diff --git a/tests/fixtures/utp/test-status.json b/tests/fixtures/utp/test-status.json new file mode 100644 index 00000000..4a6263a9 --- /dev/null +++ b/tests/fixtures/utp/test-status.json @@ -0,0 +1,47 @@ +[ + { + "type": "TestStatus", + "version": 2, + "phase": "Immediate", + "time": 1000, + "processId": 1, + "name": "EditMode.Foo.Passes", + "state": 1, + "duration": 12, + "description": "Passing test" + }, + { + "type": "TestStatus", + "version": 2, + "phase": "Immediate", + "time": 1001, + "processId": 1, + "name": "EditMode.Foo.Fails", + "state": 2, + "durationMicroseconds": 5000000, + "message": "Expected 1 Was 2", + "description": "Failing test" + }, + { + "type": "TestStatus", + "version": 2, + "phase": "Immediate", + "time": 1002, + "processId": 1, + "name": "EditMode.Foo.Skipped", + "state": 3, + "duration": 0, + "description": "Skipped test" + }, + { + "type": "TestStatus", + "version": 2, + "phase": "Immediate", + "time": 1003, + "processId": 1, + "name": "EditMode.Foo.Inconclusive", + "state": 0, + "duration": 5, + "description": "Inconclusive" + } +] diff --git a/tests/https-and-hash.test.ts b/tests/https-and-hash.test.ts new file mode 100644 index 00000000..9acb86db --- /dev/null +++ b/tests/https-and-hash.test.ts @@ -0,0 +1,19 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Sha256FileHex } from '../src/utilities'; + +describe('Sha256FileHex', () => { + it('Sha256FileHex matches known digest', async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'unity-cli-sha-')); + const filePath = path.join(dir, 'sample.txt'); + await fs.promises.writeFile(filePath, 'hello', 'utf8'); + + const hex = await Sha256FileHex(filePath); + expect(hex).toBe( + '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824' + ); + + await fs.promises.rm(dir, { recursive: true, force: true }); + }); +}); diff --git a/tests/hub-cdn-urls.test.ts b/tests/hub-cdn-urls.test.ts new file mode 100644 index 00000000..f04b5785 --- /dev/null +++ b/tests/hub-cdn-urls.test.ts @@ -0,0 +1,41 @@ +/** + * Locks Hub CDN URL contracts against Unity's public CDN (HEAD, short timeouts). + * Fails if Unity removes or reshuffles artifacts we rely on. + */ +jest.setTimeout(90_000); + +async function httpStatus(url: string, method: 'HEAD' | 'GET' = 'HEAD'): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 60_000); + try { + const res = await fetch(url, { method, redirect: 'follow', signal: controller.signal }); + return res.status; + } finally { + clearTimeout(timer); + } +} + +describe('Unity Hub public CDN URLs', () => { + it('serves prod Windows arch-specific installers (not legacy UnityHubSetup.exe)', async () => { + expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/prod/UnityHubSetup-x64.exe')).toBe(200); + expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/prod/UnityHubSetup-arm64.exe')).toBe(200); + expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/prod/UnityHubSetup.exe')).toBe(404); + }); + + it('serves pinned Hub semver Windows layout (single UnityHubSetup.exe)', async () => { + expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/3.12.0/UnityHubSetup.exe')).toBe(200); + expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/3.12.0/UnityHubSetup-x64.exe')).toBe(404); + expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/3.12.0/UnityHubSetup-arm64.exe')).toBe(404); + }); + + it('serves prod and pinned Hub macOS arm64 dmgs (installer path used by unity-cli)', async () => { + expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/prod/UnityHubSetup-arm64.dmg')).toBe(200); + expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/3.12.0/UnityHubSetup-arm64.dmg')).toBe(200); + }); + + it('serves latest.yml for Hub version discovery (latest-linux.yml is not published)', async () => { + expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/prod/latest.yml')).toBe(200); + expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/prod/latest-mac.yml')).toBe(200); + expect(await httpStatus('https://public-cdn.cloud.unity3d.com/hub/prod/latest-linux.yml')).toBe(404); + }); +}); diff --git a/tests/logger-provider.test.ts b/tests/logger-provider.test.ts new file mode 100644 index 00000000..f391d3e2 --- /dev/null +++ b/tests/logger-provider.test.ts @@ -0,0 +1,74 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { GitHubActionsLoggerProvider, GitHubAnnotationLevel } from '../src/github-actions-ci'; +import { LocalCliLoggerProvider } from '../src/logger-provider'; + +describe('logger providers', () => { + afterEach(() => { + jest.restoreAllMocks(); + delete process.env.GITHUB_ENV; + delete process.env.GITHUB_OUTPUT; + delete process.env.GITHUB_STEP_SUMMARY; + delete process.env.UNITY_CLI_WORKFLOW_SUMMARY; + }); + + it('github provider disables workflow summary limit unless UNITY_CLI_WORKFLOW_SUMMARY is set', () => { + const provider = new GitHubActionsLoggerProvider(); + expect(provider.getMarkdownByteLimit('workflow-summary')).toBe(Number.POSITIVE_INFINITY); + expect(provider.getMarkdownByteLimit('stdout')).toBe(Number.POSITIVE_INFINITY); + }); + + it('github provider enforces 1MB workflow summary limit when UNITY_CLI_WORKFLOW_SUMMARY is truthy', () => { + process.env.UNITY_CLI_WORKFLOW_SUMMARY = 'true'; + const provider = new GitHubActionsLoggerProvider(); + expect(provider.getMarkdownByteLimit('workflow-summary')).toBe(1024 * 1024); + expect(provider.getMarkdownByteLimit('stdout')).toBe(Number.POSITIVE_INFINITY); + }); + + it('local provider is safe no-op for CI side effects and uncapped markdown', () => { + const provider = new LocalCliLoggerProvider(); + expect(provider.getMarkdownByteLimit('workflow-summary')).toBe(Number.POSITIVE_INFINITY); + expect(provider.getMarkdownByteLimit('stdout')).toBe(Number.POSITIVE_INFINITY); + expect(() => provider.mask('secret')).not.toThrow(); + expect(() => provider.setEnvironmentVariable('A', 'B')).not.toThrow(); + expect(() => provider.setOutput('A', 'B')).not.toThrow(); + expect(() => provider.appendStepSummary('hello')).not.toThrow(); + }); + + it('github provider formats annotations with metadata and escaping', () => { + const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true as any); + const provider = new GitHubActionsLoggerProvider(); + provider.annotate(GitHubAnnotationLevel.Error, 'line1\nline2', { + file: 'Assets/Test.cs', + line: 10, + title: 'Compiler', + }); + expect(writeSpy).toHaveBeenCalled(); + const output = String(writeSpy.mock.calls[0][0]); + expect(output).toContain('::error '); + expect(output).toContain('file=Assets/Test.cs'); + expect(output).toContain('line=10'); + expect(output).toContain('title=Compiler'); + expect(output).toContain('line1%0Aline2'); + }); + + it('github provider appends env/output/summary files when configured', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'unity-cli-provider-')); + const envFile = path.join(tempDir, 'env'); + const outputFile = path.join(tempDir, 'output'); + const summaryFile = path.join(tempDir, 'summary'); + process.env.GITHUB_ENV = envFile; + process.env.GITHUB_OUTPUT = outputFile; + process.env.GITHUB_STEP_SUMMARY = summaryFile; + const provider = new GitHubActionsLoggerProvider(); + + provider.setEnvironmentVariable('KEY', 'VALUE'); + provider.setOutput('OUT', '123'); + provider.appendStepSummary('summary'); + + expect(fs.readFileSync(envFile, 'utf8')).toBe('KEY=VALUE\n'); + expect(fs.readFileSync(outputFile, 'utf8')).toBe('OUT=123\n'); + expect(fs.readFileSync(summaryFile, 'utf8')).toBe('summary'); + }); +}); diff --git a/tests/logging-summary.test.ts b/tests/logging-summary.test.ts new file mode 100644 index 00000000..cef432a8 --- /dev/null +++ b/tests/logging-summary.test.ts @@ -0,0 +1,142 @@ +import { Severity } from '../src/utp'; +import { + mergeLogEntriesPreferringSeverity, + buildTestResultsTableMarkdown, + buildUnitTestJobSummaryMarkdown, + stripSummaryNoiseFromLogMessage, + truncateStringToUtf8ByteLength, + utpToTestResultSummary, +} from '../src/logging'; + +describe('truncateStringToUtf8ByteLength', () => { + it('returns the string unchanged when it fits', () => { + expect(truncateStringToUtf8ByteLength('hello', 100)).toBe('hello'); + }); + + it('truncates with ellipsis when UTF-8 length exceeds the budget', () => { + const long = 'a'.repeat(200); + const out = truncateStringToUtf8ByteLength(long, 20); + expect(out.endsWith('…')).toBe(true); + expect(Buffer.byteLength(out, 'utf8')).toBeLessThanOrEqual(20); + }); +}); + +describe('stripSummaryNoiseFromLogMessage', () => { + it('removes access token noise and trims', () => { + expect(stripSummaryNoiseFromLogMessage('Scripts have compiler errors.\nAccess token is unavailable; failed to update')).toBe( + 'Scripts have compiler errors.' + ); + expect(stripSummaryNoiseFromLogMessage('Access token is unavailable; failed to update')).toBe(''); + }); +}); + +describe('mergeLogEntriesPreferringSeverity', () => { + it('keeps Error over Info when dedupe key matches', () => { + const info = { + type: 'LogEntry', + message: 'dup', + file: 'Assets/Foo.cs', + line: 3, + severity: Severity.Info, + }; + const err = { + type: 'LogEntry', + message: 'dup', + file: 'Assets/Foo.cs', + line: 3, + severity: Severity.Error, + }; + const merged = mergeLogEntriesPreferringSeverity([info, err]); + expect(merged).toHaveLength(1); + expect(merged[0].severity).toBe(Severity.Error); + }); + + it('keeps first entry when severities tie', () => { + const a = { + type: 'Compiler', + message: 'm', + file: 'Assets/Foo.cs', + line: 1, + severity: Severity.Warning, + }; + const b = { + type: 'Compiler', + message: 'm', + file: 'Assets/Foo.cs', + line: 1, + severity: Severity.Warning, + }; + const merged = mergeLogEntriesPreferringSeverity([a, b]); + expect(merged).toHaveLength(1); + expect(merged[0]).toBe(a); + }); +}); + +describe('buildTestResultsTableMarkdown', () => { + it('escapes pipe characters in cells', () => { + const rows = [ + utpToTestResultSummary({ + type: 'TestStatus', + name: 'A|B', + state: 1, + duration: 10, + } as any), + ]; + const md = buildTestResultsTableMarkdown(rows, 1024 * 1024, ''); + expect(md).toContain('A\\|B'); + expect(md.split('\n').filter(l => l.startsWith('|')).length).toBeGreaterThanOrEqual(3); + }); + + it('escapes backslashes before pipes so markdown cells stay well-formed', () => { + const rows = [ + utpToTestResultSummary({ + type: 'TestStatus', + name: 'a\\b|c', + state: 1, + duration: 10, + } as any), + ]; + const md = buildTestResultsTableMarkdown(rows, 1024 * 1024, ''); + expect(md).toMatch(/a\\\\b\\|c/); + }); +}); + +describe('buildUnitTestJobSummaryMarkdown', () => { + it('renders aggregate counts and failure-first rows', () => { + const rows = [ + utpToTestResultSummary({ + type: 'TestStatus', + name: 'Pass.Test', + state: 1, + duration: 10, + } as any), + utpToTestResultSummary({ + type: 'TestStatus', + name: 'Fail.Test', + state: 2, + duration: 20, + message: 'assert fail', + file: 'Assets/Tests/Fail.cs', + line: 42, + } as any), + ]; + const md = buildUnitTestJobSummaryMarkdown(rows, 1024 * 1024, ''); + expect(md).toContain('### Unit test results'); + expect(md).toContain('**2** tests'); + expect(md).toContain('Fail.Test (Assets/Tests/Fail.cs:42)'); + }); +}); + +describe('utpToTestResultSummary', () => { + it('preserves file and line when available', () => { + const summary = utpToTestResultSummary({ + type: 'TestStatus', + name: 'A.Test', + state: 2, + file: 'Assets/A.cs', + line: 12, + } as any); + expect(summary.file).toBe('Assets/A.cs'); + expect(summary.line).toBe(12); + }); +}); diff --git a/tests/logging-workflow-summary.test.ts b/tests/logging-workflow-summary.test.ts new file mode 100644 index 00000000..2459e088 --- /dev/null +++ b/tests/logging-workflow-summary.test.ts @@ -0,0 +1,180 @@ +import { Logger } from '../src/logging'; +import type { UTP } from '../src/utp'; + +describe('workflow summary formatting', () => { + it('renders build timeline as a table when budget allows', () => { + const summaryWrites: string[] = []; + const logger = Logger.instance as unknown as { + _provider: { + appendStepSummary: (summary: string) => void; + getMarkdownByteLimit: (target: 'workflow-summary' | 'stdout') => number; + }; + }; + + logger._provider = { + appendStepSummary: (summary: string) => summaryWrites.push(summary), + getMarkdownByteLimit: () => 1024 * 1024, + }; + + const telemetry: UTP[] = [ + { + type: 'Action', + phase: 'End', + description: 'Build Player', + duration: 1234, + errors: [], + } as UTP, + { + type: 'Compiler', + severity: 'Error', + file: 'Assets/UnityCliTests/CompilerErrors.cs', + line: 2, + message: "error CS1029: #error: 'Intentional compiler error: CS1029'", + } as UTP, + ]; + + Logger.instance.CI_appendWorkflowSummary('Build-Unity', telemetry); + + expect(summaryWrites).toHaveLength(1); + const summary = summaryWrites[0]; + expect(summary).toContain('
Build actions (1)'); + expect(summary).toContain('| Status | Duration | Errors | Action |'); + expect(summary).toContain('| ✅ | 1.2s | 0 | Build Player |'); + expect(summary).toContain(`Assets/UnityCliTests/CompilerErrors.cs(2): error CS1029: #error: 'Intentional compiler error: CS1029'`); + expect(summary).not.toContain('```text\n✅'); + }); + + it('does not cap log lines at a fixed character length when under byte budget', () => { + const summaryWrites: string[] = []; + const logger = Logger.instance as unknown as { + _provider: { + appendStepSummary: (summary: string) => void; + getMarkdownByteLimit: (target: 'workflow-summary' | 'stdout') => number; + }; + }; + + logger._provider = { + appendStepSummary: (summary: string) => summaryWrites.push(summary), + getMarkdownByteLimit: () => 1024 * 1024, + }; + + const longTail = 'Z'.repeat(250); + const telemetry: UTP[] = [ + { + type: 'LogEntry', + severity: 'Warning', + message: `Overlay.png (TextureImporter) -> artifact tail ${longTail}`, + } as UTP, + ]; + + Logger.instance.CI_appendWorkflowSummary('Build-Unity', telemetry); + + expect(summaryWrites).toHaveLength(1); + expect(summaryWrites[0]).toContain(longTail); + expect(summaryWrites[0]).not.toMatch(/artifact tail Z+…/); + }); + + it('collapses multiline log messages into one summary line', () => { + const summaryWrites: string[] = []; + const logger = Logger.instance as unknown as { + _provider: { + appendStepSummary: (summary: string) => void; + getMarkdownByteLimit: (target: 'workflow-summary' | 'stdout') => number; + }; + }; + + logger._provider = { + appendStepSummary: (summary: string) => summaryWrites.push(summary), + getMarkdownByteLimit: () => 1024 * 1024, + }; + + const telemetry: UTP[] = [ + { + type: 'Compiler', + severity: 'Error', + file: 'Assets/UnityCliTests/CompilerErrors.cs', + line: 2, + message: 'Scripts have compiler errors.\nAccess token is unavailable; failed to update', + } as UTP, + ]; + + Logger.instance.CI_appendWorkflowSummary('Build-Unity', telemetry); + + expect(summaryWrites).toHaveLength(1); + const summary = summaryWrites[0]; + expect(summary).toContain('Scripts have compiler errors.'); + expect(summary).not.toContain('Access token is unavailable; failed to update'); + expect(summary).not.toContain('\n- Access token is unavailable; failed to update'); + }); + + it('omits access-token noise-only log lines from the summary', () => { + const summaryWrites: string[] = []; + const logger = Logger.instance as unknown as { + _provider: { + appendStepSummary: (summary: string) => void; + getMarkdownByteLimit: (target: 'workflow-summary' | 'stdout') => number; + }; + }; + + logger._provider = { + appendStepSummary: (summary: string) => summaryWrites.push(summary), + getMarkdownByteLimit: () => 1024 * 1024, + }; + + const telemetry: UTP[] = [ + { + type: 'LogEntry', + severity: 'Warning', + message: 'Access token is unavailable; failed to update', + } as UTP, + ]; + + Logger.instance.CI_appendWorkflowSummary('Build-Unity', telemetry); + + expect(summaryWrites).toHaveLength(1); + const summary = summaryWrites[0]; + expect(summary).toContain('Errors: 0'); + expect(summary).not.toContain('Access token is unavailable'); + }); + + it('drops action table and uses plaintext timeline when near byte limit', () => { + const summaryWrites: string[] = []; + const logger = Logger.instance as unknown as { + _provider: { + appendStepSummary: (summary: string) => void; + getMarkdownByteLimit: (target: 'workflow-summary' | 'stdout') => number; + }; + }; + + logger._provider = { + appendStepSummary: (summary: string) => summaryWrites.push(summary), + getMarkdownByteLimit: () => 380, + }; + + const telemetry: UTP[] = [ + ...Array.from({ length: 12 }, (_, i) => ({ + type: 'Action', + phase: 'End', + description: `Build Player step ${i} with a very long action description to consume summary bytes quickly`, + duration: 1234 + i, + errors: [], + } as UTP)), + { + type: 'Compiler', + severity: 'Error', + file: 'Assets/UnityCliTests/CompilerErrors.cs', + line: 2, + message: "error CS1029: #error: 'Intentional compiler error: CS1029'", + } as UTP, + ]; + + Logger.instance.CI_appendWorkflowSummary('Build-Unity', telemetry); + + expect(summaryWrites).toHaveLength(1); + const summary = summaryWrites[0]; + expect(summary).toContain('
Build actions (12)'); + expect(summary).not.toContain('| Status | Duration | Errors | Action |'); + expect(summary).toContain('```text'); + expect(summary).toContain('Build Player step'); + }); +}); diff --git a/tests/run-utp-tests-contract.sh b/tests/run-utp-tests-contract.sh new file mode 100644 index 00000000..e4531d83 --- /dev/null +++ b/tests/run-utp-tests-contract.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Contract tests for UTP CI assertion helpers (bash; run on Linux CI or Git Bash). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=../.github/actions/scripts/utp-ci-assertion-helpers.sh +source "$ROOT/.github/actions/scripts/utp-ci-assertion-helpers.sh" + +fail() { + echo "::error::$1" >&2 + exit 1 +} + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +# --- UTP severity: warning scenarios ignore Assert-only noise --- +printf '%s\n' '{"type":"Log","severity":"Assert","message":"StackAllocator"}' >"$tmpdir/warn-assert.json" +if utp_signals_failure_for_expected_success CompilerWarnings "$tmpdir/warn-assert.json"; then + fail "CompilerWarnings + Assert-only should not signal failure for expected-success check" +fi + +printf '%s\n' '{"type":"Log","severity":"Error","message":"boom"}' >"$tmpdir/warn-err.json" +if ! utp_signals_failure_for_expected_success CompilerWarnings "$tmpdir/warn-err.json"; then + fail "CompilerWarnings + Error should signal failure for expected-success check" +fi + +printf '%s\n' '{"severity":"Assert"}' >"$tmpdir/nonwarn-assert.json" +if ! utp_signals_failure_for_expected_success EditmodeTestsPassing "$tmpdir/nonwarn-assert.json"; then + fail "Non-warning scenario should still treat Assert as failure for expected-success check" +fi + +# --- UTP any-signal (expected-failure branch) --- +if ! utp_signals_any_severity_problem "$tmpdir/nonwarn-assert.json"; then + fail "utp_signals_any_severity_problem should match Assert" +fi + +# --- NUnit XML discovery --- +export UNITY_PROJECT_PATH="$tmpdir/proj" +mkdir -p "$UNITY_PROJECT_PATH/Builds/Logs" +printf '\n' >"$UNITY_PROJECT_PATH/Builds/Logs/EditmodeTestsPassing-results.xml" +found="$(find_nunit_results_xml EditmodeTestsPassing)" +if [ "$found" != "$UNITY_PROJECT_PATH/Builds/Logs/EditmodeTestsPassing-results.xml" ]; then + fail "find_nunit_results_xml should resolve default Builds/Logs path (got: $found)" +fi + +mkdir -p "$UNITY_PROJECT_PATH/Builds/Alt" +printf '\n' >"$UNITY_PROJECT_PATH/Builds/Alt/EditmodeTestsPassing-results.xml" +rm -f "$UNITY_PROJECT_PATH/Builds/Logs/EditmodeTestsPassing-results.xml" +found2="$(find_nunit_results_xml EditmodeTestsPassing)" +if [ "$found2" != "$UNITY_PROJECT_PATH/Builds/Alt/EditmodeTestsPassing-results.xml" ]; then + fail "find_nunit_results_xml should discover alternate path under project (got: $found2)" +fi + +# --- Log completion heuristic --- +printf '%s\n' 'Some noise' 'Test run completed.' 'more' >"$UNITY_PROJECT_PATH/Builds/Logs/EditmodeTestsPassing-EditMode-Unity-1.log" +if ! edit_play_log_suggests_tests_completed_ok EditmodeTestsPassing EditMode; then + fail "edit_play_log_suggests_tests_completed_ok should match Test run completed marker" +fi + +printf '%s\n' 'Test run completed.' 'test run failed' >"$UNITY_PROJECT_PATH/Builds/Logs/EditmodeTestsPassing-EditMode-Unity-2.log" +if edit_play_log_suggests_tests_completed_ok EditmodeTestsPassing EditMode; then + fail "edit_play_log_suggests_tests_completed_ok should reject logs that also contain failure markers" +fi + +echo "run-utp-tests-contract: OK" diff --git a/tests/unity-hub-release-api-filter.test.ts b/tests/unity-hub-release-api-filter.test.ts new file mode 100644 index 00000000..3b8580bb --- /dev/null +++ b/tests/unity-hub-release-api-filter.test.ts @@ -0,0 +1,65 @@ +import { UnityReleasesClient } from '@rage-against-the-pixel/unity-releases-api'; +import { UnityHub } from '../src/unity-hub'; +import { UnityVersion } from '../src/unity-version'; + +jest.mock('@rage-against-the-pixel/unity-releases-api', () => { + const actual = jest.requireActual( + '@rage-against-the-pixel/unity-releases-api' + ); + return { + ...actual, + UnityReleasesClient: jest.fn(), + }; +}); + +const mockGetUnityReleases = jest.fn(); + +describe('UnityHub GetEditorReleaseInfo (sparse API rows)', () => { + beforeEach(() => { + jest.clearAllMocks(); + (UnityReleasesClient as jest.Mock).mockImplementation(() => ({ + api: { + Release: { + getUnityReleases: mockGetUnityReleases, + }, + }, + })); + }); + + it('skips results with missing or empty version and still returns the first stable f release', async () => { + mockGetUnityReleases.mockResolvedValue({ + data: { + results: [ + {}, + { version: undefined, shortRevision: 'bad1' }, + { version: null as unknown as string, shortRevision: 'bad2' }, + { version: '', shortRevision: 'bad3' }, + { + version: '2021.3.45f1', + shortRevision: 'goodrev', + recommended: true, + }, + ], + }, + error: undefined, + }); + + const hub = new UnityHub(); + const info = await hub.GetEditorReleaseInfo(new UnityVersion('2021')); + + expect(info.version).toBe('2021.3.45f1'); + expect(info.shortRevision).toBe('goodrev'); + }); + + it('throws when no result row has a usable version for stable channel', async () => { + mockGetUnityReleases.mockResolvedValue({ + data: { + results: [{}, { version: undefined }, { version: '2021.3.1a1', shortRevision: 'onlyalpha' }], + }, + error: undefined, + }); + + const hub = new UnityHub(); + await expect(hub.GetEditorReleaseInfo(new UnityVersion('2021'))).rejects.toThrow(/No suitable Unity releases/); + }); +}); diff --git a/tests/unity-logging-project-path.test.ts b/tests/unity-logging-project-path.test.ts new file mode 100644 index 00000000..9cb87116 --- /dev/null +++ b/tests/unity-logging-project-path.test.ts @@ -0,0 +1,57 @@ +import { isFileUnderProjectPath, normalizeAnnotationPath } from '../src/unity-logging'; +import * as path from 'path'; + +describe('isFileUnderProjectPath', () => { + const origPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: origPlatform }); + }); + + it('returns true for file under unix-style project root', () => { + expect(isFileUnderProjectPath('/home/runner/proj/Assets/a.cs', '/home/runner/proj')).toBe(true); + }); + + it('returns false when file is outside project', () => { + expect(isFileUnderProjectPath('/other/Assets/a.cs', '/home/runner/proj')).toBe(false); + }); + + it('on win32 matches case-insensitively', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + expect(isFileUnderProjectPath('D:/Work/MyProj/Assets/Foo.cs', 'd:/work/myproj')).toBe(true); + }); +}); + +describe('normalizeAnnotationPath', () => { + const origPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: origPlatform }); + }); + + it('resolves relative project file to project-relative annotation path', () => { + const out = normalizeAnnotationPath('Assets/Scripts/Foo.cs', '/home/runner/proj'); + const expectedAbsolute = path.resolve('/home/runner/proj', 'Assets/Scripts/Foo.cs').replace(/\\/g, '/'); + expect(out.absoluteFile).toBe(expectedAbsolute); + expect(out.annotationFile).toBe('Assets/Scripts/Foo.cs'); + }); + + it('returns only absolute path when file is outside project root', () => { + const out = normalizeAnnotationPath('/other/Foo.cs', '/home/runner/proj'); + expect(out.absoluteFile).toBe('/other/Foo.cs'); + expect(out.annotationFile).toBeUndefined(); + }); + + it('normalizes windows relative paths for annotation output', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + const out = normalizeAnnotationPath('Assets\\UnityCliTests\\CompilerErrors.cs', 'D:\\Work\\MyProj'); + expect(out.absoluteFile).toBe('D:/Work/MyProj/Assets/UnityCliTests/CompilerErrors.cs'); + expect(out.annotationFile).toBe('Assets/UnityCliTests/CompilerErrors.cs'); + }); + + it('supports windows case-insensitive project roots', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + const out = normalizeAnnotationPath('D:\\WORK\\MYPROJ\\Assets\\Bar.cs', 'd:/work/myproj'); + expect(out.annotationFile).toBe('Assets/Bar.cs'); + }); +}); diff --git a/tests/unity-logging.test.ts b/tests/unity-logging.test.ts index 21748711..08b81e5c 100644 --- a/tests/unity-logging.test.ts +++ b/tests/unity-logging.test.ts @@ -1,4 +1,11 @@ -import { type ActionTableSnapshot, formatActionTimelineTable, sanitizeTelemetryJson, stringDisplayWidth } from '../src/unity-logging'; +import { + type ActionTableSnapshot, + describeUtpForUtpLogLevel, + formatActionTimelineTable, + normalizeAnnotationPath, + sanitizeTelemetryJson, + stringDisplayWidth +} from '../src/unity-logging'; describe('sanitizeTelemetryJson', () => { it('removes trailing null characters that break JSON.parse', () => { @@ -145,3 +152,54 @@ describe('formatActionTimelineTable', () => { expect(formatted?.text).toContain('# of Errors'); }); }); + +describe('describeUtpForUtpLogLevel', () => { + it('returns a one-line debug string for Compiler', () => { + const s = describeUtpForUtpLogLevel({ + type: 'Compiler', + severity: 'Error', + message: 'bad', + file: 'Assets/A.cs', + line: 3, + } as any); + expect(s).toContain('[UTP] Compiler'); + expect(s).toContain('Assets/A.cs:3'); + expect(s).toContain('bad'); + }); + + it('returns a one-line debug string for TestStatus', () => { + const s = describeUtpForUtpLogLevel({ + type: 'TestStatus', + name: 'T.Name', + state: 1, + duration: 42, + } as any); + expect(s).toContain('TestStatus'); + expect(s).toContain('state=1'); + expect(s).toContain('T.Name'); + }); + + it('returns JSON for settings-like types', () => { + const s = describeUtpForUtpLogLevel({ + type: 'BuildSettings', + BuildSettings: { Platform: 'Android' }, + } as any); + expect(s).toContain('BuildSettings'); + expect(s).toContain('Android'); + }); + + it('returns undefined for an unknown type string', () => { + expect(describeUtpForUtpLogLevel({ type: 'FutureUnityType', x: 1 } as any)).toBeUndefined(); + }); +}); + +describe('normalizeAnnotationPath edge cases', () => { + it('returns empty result for undefined file', () => { + expect(normalizeAnnotationPath(undefined, '/tmp/proj')).toEqual({}); + }); + + it('keeps normalized relative path without project path', () => { + const out = normalizeAnnotationPath('Assets\\X.cs', undefined); + expect(out.annotationFile).toBe('Assets/X.cs'); + }); +}); diff --git a/tests/upm-cli.test.ts b/tests/upm-cli.test.ts new file mode 100644 index 00000000..95d831d5 --- /dev/null +++ b/tests/upm-cli.test.ts @@ -0,0 +1,34 @@ +import * as path from 'path'; +import * as os from 'os'; +import { UpmCli } from '../src/upm-cli'; + +describe('UpmCli', () => { + it('managedRoot is under home', () => { + const upm = new UpmCli(); + expect(upm.managedRoot).toBe(path.join(os.homedir(), '.unity-cli', 'upm')); + }); + + it('IsUpdateAvailable is true when nothing is installed', () => { + const spy = jest.spyOn(UpmCli.prototype, 'GetInstalledReleaseTag'); + spy.mockReturnValue(undefined); + try { + const upm = new UpmCli(); + expect(upm.IsUpdateAvailable('v9.27.0')).toBe(true); + } finally { + spy.mockRestore(); + } + }); + + it('IsUpdateAvailable compares release tags when a release is recorded', () => { + const spy = jest.spyOn(UpmCli.prototype, 'GetInstalledReleaseTag'); + spy.mockReturnValue('v9.28.0'); + try { + const upm = new UpmCli(); + expect(upm.IsUpdateAvailable('v9.29.0')).toBe(true); + expect(upm.IsUpdateAvailable('v9.28.0')).toBe(false); + expect(upm.IsUpdateAvailable('v9.27.0')).toBe(false); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/tests/utp-telemetry-fixtures.test.ts b/tests/utp-telemetry-fixtures.test.ts new file mode 100644 index 00000000..ef705f75 --- /dev/null +++ b/tests/utp-telemetry-fixtures.test.ts @@ -0,0 +1,82 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { normalizeTelemetryEntry, UTP_SUPPORTED_TOP_LEVEL_PROPERTIES } from '../src/utp'; +import { buildTestResultsTableMarkdown, utpToTestResultSummary } from '../src/logging'; +import { formatUtpUnrecognizedTopLevelPropertiesMessage } from '../src/unity-logging'; + +const fixturesDir = path.join(__dirname, 'fixtures', 'utp'); + +function loadFixture(name: string): unknown[] { + const p = path.join(fixturesDir, name); + const raw = fs.readFileSync(p, 'utf8'); + const data = JSON.parse(raw) as unknown; + return Array.isArray(data) ? data : [data]; +} + +describe('UTP telemetry fixtures', () => { + const fixtureFiles = fs.readdirSync(fixturesDir).filter(f => f.endsWith('.json')); + + it.each(fixtureFiles)('%s has only supported top-level keys and normalizes cleanly', fileName => { + for (const obj of loadFixture(fileName)) { + const { utp, unknownTopLevelKeys } = normalizeTelemetryEntry(obj); + expect(unknownTopLevelKeys).toEqual([]); + expect(utp).toBeDefined(); + for (const k of Object.keys(obj as object)) { + expect(UTP_SUPPORTED_TOP_LEVEL_PROPERTIES.has(k)).toBe(true); + } + } + }); + + it('merges legacy stacktrace and file/line fields on Compiler', () => { + const [first] = loadFixture('compiler-and-logentry.json'); + const { utp } = normalizeTelemetryEntry(first); + expect(utp.type).toBe('Compiler'); + expect(utp.stackTrace).toBe(''); + expect(utp.file).toBe('Assets/UnityCliTests/CompilerErrors.cs'); + expect(utp.fileName).toBe('Assets/UnityCliTests/CompilerErrors.cs'); + expect(utp.line).toBe(2); + expect(utp.lineNumber).toBe(2); + }); + + it('maps TestStatus fixtures to summaries and markdown', () => { + const rows = loadFixture('test-status.json').map(e => { + const { utp } = normalizeTelemetryEntry(e); + return utpToTestResultSummary(utp); + }); + expect(rows[0].status).toBe('✅'); + expect(rows[1].status).toBe('❌'); + expect(rows[2].status).toBe('⏭️'); + expect(rows[3].status).toBe('◯'); + expect(rows[1].durationMs).toBe(5000); + const md = buildTestResultsTableMarkdown(rows, 1024 * 1024, ''); + expect(md).toContain('### Test results'); + expect(md).toContain('EditMode.Foo.Passes'); + }); + + it('reports unknown top-level keys without failing normalization', () => { + const payload = { + type: 'Compiler', + version: 2, + phase: 'Immediate', + time: 1, + processId: 1, + severity: 'Warning', + message: 'm', + file: 'Assets/X.cs', + line: 1, + futureUnityOnlyField: 'surprise', + }; + const { utp, unknownTopLevelKeys } = normalizeTelemetryEntry(payload); + expect(unknownTopLevelKeys).toEqual(['futureUnityOnlyField']); + expect(utp.type).toBe('Compiler'); + }); +}); + +describe('formatUtpUnrecognizedTopLevelPropertiesMessage', () => { + it('includes unknown key names and the full ##utp line', () => { + const line = '##utp:{"type":"Action","extra":1}'; + const msg = formatUtpUnrecognizedTopLevelPropertiesMessage(['extra'], line); + expect(msg).toContain('unrecognized top-level properties: extra'); + expect(msg).toContain(`Full line: ${line}`); + }); +}); diff --git a/tests/utp-workflow-profiles.test.ts b/tests/utp-workflow-profiles.test.ts new file mode 100644 index 00000000..e33ca17a --- /dev/null +++ b/tests/utp-workflow-profiles.test.ts @@ -0,0 +1,35 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { parse } from 'yaml'; + +function loadYaml(filePath: string): any { + return parse(fs.readFileSync(filePath, 'utf8')); +} + +describe('UTP workflow profiles', () => { + const repoRoot = path.resolve(__dirname, '..'); + + it('defines profile-aware test selection in run-unity-test-batch action', () => { + const actionPath = path.join(repoRoot, '.github', 'actions', 'run-unity-test-batch', 'action.yml'); + const action = loadYaml(actionPath); + + expect(action.inputs['test-profile'].default).toBe('normal'); + expect(action.inputs['tests-input'].default).toBe(''); + + const prepareStep = action.runs.steps.find((step: any) => step.name === 'Prepare test list and install packages'); + expect(prepareStep).toBeDefined(); + expect(prepareStep.run).toContain('case "$test_profile" in'); + expect(prepareStep.run).toContain('normal)'); + expect(prepareStep.run).toContain('negative)'); + expect(prepareStep.run).toContain('all)'); + }); + + it('wires integration workflow to normal matrix plus dedicated negative scenario run', () => { + const workflowPath = path.join(repoRoot, '.github', 'workflows', 'integration-tests.yml'); + const workflow = loadYaml(workflowPath); + + expect(workflow.jobs.validate.with['utp-test-profile']).toBe('normal'); + expect(workflow.jobs['validate-negative-scenarios']).toBeDefined(); + expect(workflow.jobs['validate-negative-scenarios'].with['utp-test-profile']).toBe('negative'); + }); +}); diff --git a/unity-tests/BuildErrors.cs b/unity-tests/BuildErrors.cs new file mode 100644 index 00000000..dc344195 --- /dev/null +++ b/unity-tests/BuildErrors.cs @@ -0,0 +1,20 @@ +using UnityEditor; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; + +namespace UnityCli.UtpSamples +{ + /// + /// Forces the build pipeline to fail by throwing a BuildFailedException. + /// Place under an Editor folder when copying into a project. + /// + public class BuildErrors : IPreprocessBuildWithReport + { + public int callbackOrder => 0; + + public void OnPreprocessBuild(BuildReport report) + { + throw new System.Exception("Intentional build failure."); + } + } +} diff --git a/unity-tests/BuildWarnings.cs b/unity-tests/BuildWarnings.cs new file mode 100644 index 00000000..e4c2a3d7 --- /dev/null +++ b/unity-tests/BuildWarnings.cs @@ -0,0 +1,20 @@ +using UnityEditor; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; + +namespace UnityCli.UtpSamples +{ + /// + /// Emits a build-time warning via the build pipeline (no custom UTP JSON logging). + /// Place under an Editor folder when copying into a project. + /// + public class BuildWarnings : IPreprocessBuildWithReport + { + public int callbackOrder => 0; + + public void OnPreprocessBuild(BuildReport report) + { + UnityEngine.Debug.LogWarning("Intentional build warning."); + } + } +} diff --git a/unity-tests/CompilerErrors.cs b/unity-tests/CompilerErrors.cs new file mode 100644 index 00000000..056f16ee --- /dev/null +++ b/unity-tests/CompilerErrors.cs @@ -0,0 +1,4 @@ +// Intentional compiler error for matrix scenario coverage. +#error Intentional compiler error: CS1029 + +// Note: file is kept minimal so it can be copied into a project to force a build failure. diff --git a/unity-tests/CompilerWarnings.cs b/unity-tests/CompilerWarnings.cs new file mode 100644 index 00000000..ae35bd22 --- /dev/null +++ b/unity-tests/CompilerWarnings.cs @@ -0,0 +1,20 @@ +using UnityEngine; + +namespace UnityCli.UtpSamples +{ + /// + /// Introduces a benign compiler warning (unused variable) without emitting custom logs. + /// + public class CompilerWarnings : MonoBehaviour + { + private void Awake() + { + ObsoleteApi(); // CS0618: call to obsolete member + } + + [System.Obsolete("Intentional warning", false)] + private static void ObsoleteApi() + { + } + } +} diff --git a/unity-tests/EditmodeTestsErrors.cs b/unity-tests/EditmodeTestsErrors.cs new file mode 100644 index 00000000..a0304d8d --- /dev/null +++ b/unity-tests/EditmodeTestsErrors.cs @@ -0,0 +1,16 @@ +using NUnit.Framework; + +namespace UnityCli.UtpSamples +{ + /// + /// Editmode test that intentionally fails to produce real test failure output. + /// + public class EditmodeTestsErrors + { + [Test] + public void FailsEditmodeSuite() + { + Assert.Fail("Intentional editmode failure"); + } + } +} diff --git a/unity-tests/EditmodeTestsPassing.cs b/unity-tests/EditmodeTestsPassing.cs new file mode 100644 index 00000000..48c093e4 --- /dev/null +++ b/unity-tests/EditmodeTestsPassing.cs @@ -0,0 +1,16 @@ +using NUnit.Framework; + +namespace UnityCli.UtpSamples +{ + /// + /// Editmode test that passes for test matrix and summary table coverage. + /// + public class EditmodeTestsPassing + { + [Test] + public void PassesEditmodeSuite() + { + Assert.Pass("Intentional editmode pass"); + } + } +} diff --git a/unity-tests/EditmodeTestsSkipped.cs b/unity-tests/EditmodeTestsSkipped.cs new file mode 100644 index 00000000..4d7e6d88 --- /dev/null +++ b/unity-tests/EditmodeTestsSkipped.cs @@ -0,0 +1,17 @@ +using NUnit.Framework; + +namespace UnityCli.UtpSamples +{ + /// + /// Editmode test that is skipped for test matrix and summary table coverage. + /// + public class EditmodeTestsSkipped + { + [Test] + [Ignore("Intentional editmode skip")] + public void SkippedEditmodeSuite() + { + Assert.Fail("Should not run"); + } + } +} diff --git a/unity-tests/PlaymodeTestsErrors.cs b/unity-tests/PlaymodeTestsErrors.cs new file mode 100644 index 00000000..4c08ce9e --- /dev/null +++ b/unity-tests/PlaymodeTestsErrors.cs @@ -0,0 +1,21 @@ +using System.Collections; +using UnityEngine; +using UnityEditor; +using NUnit.Framework; +using UnityEngine.TestTools; + +namespace UnityCli.UtpSamples +{ + /// + /// Playmode test that intentionally fails to generate real test failure output. + /// + public class PlaymodeTestsErrors + { + [UnityTest] + public IEnumerator FailsPlaymodeSuite() + { + yield return null; + Assert.Fail("Intentional playmode failure for test matrix coverage."); + } + } +} diff --git a/unity-tests/PlaymodeTestsPassing.cs b/unity-tests/PlaymodeTestsPassing.cs new file mode 100644 index 00000000..144fcf34 --- /dev/null +++ b/unity-tests/PlaymodeTestsPassing.cs @@ -0,0 +1,20 @@ +using System.Collections; +using UnityEngine; +using NUnit.Framework; +using UnityEngine.TestTools; + +namespace UnityCli.UtpSamples +{ + /// + /// Playmode test that passes for test matrix and summary table coverage. + /// + public class PlaymodeTestsPassing + { + [UnityTest] + public IEnumerator PassesPlaymodeSuite() + { + yield return null; + Assert.Pass("Intentional playmode pass"); + } + } +} diff --git a/unity-tests/PlaymodeTestsSkipped.cs b/unity-tests/PlaymodeTestsSkipped.cs new file mode 100644 index 00000000..a60dbedb --- /dev/null +++ b/unity-tests/PlaymodeTestsSkipped.cs @@ -0,0 +1,21 @@ +using System.Collections; +using UnityEngine; +using NUnit.Framework; +using UnityEngine.TestTools; + +namespace UnityCli.UtpSamples +{ + /// + /// Playmode test that is skipped for test matrix and summary table coverage. + /// + public class PlaymodeTestsSkipped + { + [UnityTest] + [Ignore("Intentional playmode skip")] + public IEnumerator SkippedPlaymodeSuite() + { + yield return null; + Assert.Fail("Should not run"); + } + } +} diff --git a/unity-tests/UnityCliTests.EditMode.Editor.asmdef b/unity-tests/UnityCliTests.EditMode.Editor.asmdef new file mode 100644 index 00000000..fc0a73b9 --- /dev/null +++ b/unity-tests/UnityCliTests.EditMode.Editor.asmdef @@ -0,0 +1,18 @@ +{ + "name": "UnityCli.EditMode.EditorTests", + "references": [], + "optionalUnityReferences": [ + "TestAssemblies" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/unity-tests/UnityCliTests.PlayMode.asmdef b/unity-tests/UnityCliTests.PlayMode.asmdef new file mode 100644 index 00000000..ab7fb34e --- /dev/null +++ b/unity-tests/UnityCliTests.PlayMode.asmdef @@ -0,0 +1,23 @@ +{ + "name": "UnityCli.PlayMode.Tests", + "references": [], + "optionalUnityReferences": [ + "TestAssemblies" + ], + "includePlatforms": [ + "Editor", + "WindowsStandalone64", + "LinuxStandaloneUniversal", + "macOSStandalone", + "Android", + "iOS" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file