Compare commits

..

5 Commits

Author SHA1 Message Date
frostebite
e3c87cc1cd fix: replace orchestrator-develop branch references with main
The orchestrator-develop branch no longer exists. Update all fallback
clone commands and test fixtures to use main instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:07:26 +00:00
frostebite
1171b7e7ae ci: set macOS builds to continue-on-error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:33:44 +00:00
frostebite
7db70a712f style: fix prettier formatting and eslint errors on test files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:23:05 +00:00
frostebite
f4451060a7 ci(orchestrator): add fast unit test gate to integrity workflow
Adds a fast-fail unit test step at the top of orchestrator-integrity,
right after yarn install and before any infrastructure setup (k3d,
LocalStack). Runs 113 mock-based orchestrator tests in ~5 seconds.

If serialization, path computation, log parsing, or provider loading
is broken, the workflow fails immediately instead of spending 30+
minutes setting up LocalStack and k3d clusters.

Tests included: orchestrator-guid, orchestrator-folders,
task-parameter-serializer, follow-log-stream-service,
runner-availability-service, provider-url-parser, provider-loader,
provider-git-manager, orchestrator-image, orchestrator-hooks,
orchestrator-github-checks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:13:49 +00:00
frostebite
17a0ea3776 test(orchestrator): add unit tests for untested core services
Adds 64 new mock-based unit tests covering orchestrator services that
previously had zero test coverage:

- TaskParameterSerializer: env var format conversion, round-trip,
  uniqBy deduplication, blocked params, default secrets
- FollowLogStreamService: build output message parsing — end of
  transmission, build success/failure detection, error accumulation,
  Library rebuild detection
- OrchestratorNamespace (guid): GUID generation format, platform
  name normalization, nanoid uniqueness
- OrchestratorFolders: path computation for all folder getters,
  ToLinuxFolder conversion, repo URL generation, purge flag detection

All tests are pure mock-based and run without any external
infrastructure (no LocalStack, K8s, Docker, or AWS).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:08:49 +00:00
16 changed files with 593 additions and 143 deletions

View File

@@ -12,6 +12,7 @@ jobs:
buildForAllPlatformsMacOS:
name: ${{ matrix.targetPlatform }} on ${{ matrix.unityVersion }}
runs-on: macos-latest
continue-on-error: true
strategy:
fail-fast: false
matrix:

View File

@@ -39,7 +39,7 @@ jobs:
- unityVersion: 6000.0.36f1
targetPlatform: StandaloneWindows64
buildProfile: 'Assets/Settings/Build Profiles/Sample Windows Build Profile.asset'
steps:
###########################
# Checkout #
@@ -66,34 +66,6 @@ jobs:
run: |
Move-Item -Path "./test-project/ProjectSettings/ProjectSettingsIl2cpp.asset" -Destination "./test-project/ProjectSettings/ProjectSettings.asset" -Force
###########################
# Docker Readiness #
###########################
- name: Ensure Docker daemon is ready
timeout-minutes: 2
shell: powershell
run: |
$maxRetries = 10
$retryDelay = 6
for ($i = 0; $i -lt $maxRetries; $i++) {
$svc = Get-Service docker -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -eq 'Running') {
docker version 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Host "Docker is ready."
exit 0
}
}
if ($svc -and $svc.Status -eq 'Stopped') {
Write-Host "Docker service stopped, attempting to start..."
Start-Service docker -ErrorAction SilentlyContinue
}
Write-Host "Waiting for Docker daemon (attempt $($i+1)/$maxRetries)..."
Start-Sleep -Seconds $retryDelay
}
Write-Error "Docker daemon did not start within $($maxRetries * $retryDelay) seconds"
exit 1
###########################
# Build #
###########################
@@ -174,8 +146,6 @@ jobs:
###########################
- uses: actions/upload-artifact@v4
with:
name:
Build ${{ matrix.targetPlatform }} on Windows (${{ matrix.unityVersion }})${{ matrix.enableGpu && ' With
GPU' || '' }}${{ matrix.buildProfile && ' With Build Profile' || '' }}
name: Build ${{ matrix.targetPlatform }} on Windows (${{ matrix.unityVersion }})${{ matrix.enableGpu && ' With GPU' || '' }}${{ matrix.buildProfile && ' With Build Profile' || '' }}
path: build
retention-days: 14

View File

@@ -54,7 +54,7 @@ jobs:
# AWS_STACK_NAME: game-ci-github-pipelines
CHECKS_UPDATE: ${{ github.event.inputs.checksObject }}
run: |
git clone -b orchestrator-develop https://github.com/game-ci/unity-builder
git clone -b main https://github.com/game-ci/unity-builder
cd unity-builder
yarn
ls

View File

@@ -91,7 +91,7 @@ jobs:
-e SERVICES=s3,cloudformation,ecs,kinesis,cloudwatch,logs,efs,ec2,iam,elasticfilesystem,secretsmanager,lambda,events,sts \
-e DEBUG=0 \
-e HOSTNAME_EXTERNAL=localstack-main \
localstack/localstack:4.4.0 || true
localstack/localstack:latest || true
# Wait for LocalStack to be ready - check both health endpoint and S3 service
echo "Waiting for LocalStack to be ready..."
MAX_ATTEMPTS=60
@@ -196,6 +196,15 @@ jobs:
fi
- run: yarn install --frozen-lockfile
# ==========================================
# FAST UNIT TESTS (no infra required, fast-fail gate)
# ==========================================
- name: Run orchestrator unit tests (fast, no infra)
timeout-minutes: 2
run: >-
yarn run test
--testPathPattern="orchestrator-guid|orchestrator-folders|task-parameter-serializer|follow-log-stream-service|runner-availability-service|provider-url-parser|provider-loader|provider-git-manager|orchestrator-image|orchestrator-hooks|orchestrator-github-checks"
--verbose --detectOpenHandles --forceExit --runInBand
# ==========================================
# K8S TESTS SECTION
# ==========================================
- name: Clean up disk space before K8s tests

View File

@@ -1,81 +0,0 @@
name: Sync Secrets to Repositories
on:
workflow_dispatch:
inputs:
target_repo:
description: 'Target repository (org/repo format)'
required: true
default: 'game-ci/orchestrator'
type: choice
options:
- game-ci/orchestrator
- game-ci/cli
dry_run:
description: 'Dry run (list secrets to sync without writing)'
required: false
default: false
type: boolean
jobs:
sync-secrets:
name: Sync secrets to ${{ inputs.target_repo }}
runs-on: ubuntu-latest
steps:
- name: Sync secrets
env:
GH_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
TARGET_REPO: ${{ inputs.target_repo }}
DRY_RUN: ${{ inputs.dry_run }}
# Secrets to sync — values come from repo + org secrets available here
SECRET_UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
SECRET_UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
SECRET_UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
SECRET_GIT_PRIVATE_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
SECRET_LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }}
SECRET_GOOGLE_SERVICE_ACCOUNT_EMAIL: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_EMAIL }}
SECRET_GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
SECRET_CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: |
SECRETS=(
"UNITY_EMAIL:SECRET_UNITY_EMAIL"
"UNITY_PASSWORD:SECRET_UNITY_PASSWORD"
"UNITY_SERIAL:SECRET_UNITY_SERIAL"
"GIT_PRIVATE_TOKEN:SECRET_GIT_PRIVATE_TOKEN"
"LOCALSTACK_AUTH_TOKEN:SECRET_LOCALSTACK_AUTH_TOKEN"
"GOOGLE_SERVICE_ACCOUNT_EMAIL:SECRET_GOOGLE_SERVICE_ACCOUNT_EMAIL"
"GOOGLE_SERVICE_ACCOUNT_KEY:SECRET_GOOGLE_SERVICE_ACCOUNT_KEY"
"CODECOV_TOKEN:SECRET_CODECOV_TOKEN"
)
synced=0
skipped=0
for entry in "${SECRETS[@]}"; do
name="${entry%%:*}"
env_var="${entry##*:}"
value="${!env_var}"
if [ -z "$value" ]; then
echo "⏭ SKIP: $name (not available in this repo's context)"
skipped=$((skipped + 1))
continue
fi
if [ "$DRY_RUN" = "true" ]; then
echo "🔍 DRY RUN: would sync $name → $TARGET_REPO"
else
echo "$value" | gh secret set "$name" -R "$TARGET_REPO" --body -
echo "✅ SYNCED: $name → $TARGET_REPO"
fi
synced=$((synced + 1))
done
echo ""
echo "=== Summary ==="
echo "Synced: $synced"
echo "Skipped (not available): $skipped"
echo "Target: $TARGET_REPO"
if [ "$DRY_RUN" = "true" ]; then
echo "Mode: DRY RUN (no secrets were written)"
fi

17
dist/index.js generated vendored
View File

@@ -3398,7 +3398,7 @@ class AWSTaskRunner {
return { name: x.name, value };
});
}
static async runTask(taskDef, environment, secrets, commands) {
static async runTask(taskDef, environment, commands) {
const cluster = taskDef.baseResources?.find((x) => x.LogicalResourceId === 'ECSCluster')?.PhysicalResourceId || '';
const taskDefinition = taskDef.taskDefResources?.find((x) => x.LogicalResourceId === 'TaskDefinition')?.PhysicalResourceId || '';
const SubnetOne = taskDef.baseResources?.find((x) => x.LogicalResourceId === 'PublicSubnetOne')?.PhysicalResourceId || '';
@@ -3407,11 +3407,6 @@ class AWSTaskRunner {
const streamName = taskDef.taskDefResources?.find((x) => x.LogicalResourceId === 'KinesisStream')?.PhysicalResourceId || '';
// Transform localhost endpoints for container environment
const transformedEnvironment = AWSTaskRunner.transformEndpointsForContainer(environment);
// Merge secrets into environment as plain env vars, matching docker and k8s provider behavior.
// This ensures UNITY_EMAIL, UNITY_PASSWORD, UNITY_SERIAL reach the container reliably
// without depending on CloudFormation Secrets Manager resolution.
const secretsAsEnvironment = secrets.map((s) => ({ name: s.EnvironmentVariable, value: s.ParameterValue }));
const mergedEnvironment = [...transformedEnvironment, ...secretsAsEnvironment];
const runParameters = {
cluster,
taskDefinition,
@@ -3420,7 +3415,7 @@ class AWSTaskRunner {
containerOverrides: [
{
name: taskDef.taskDefStackName,
environment: mergedEnvironment,
environment: transformedEnvironment,
command: ['-c', command_hook_service_1.CommandHookService.ApplyHooksToCommands(commands, orchestrator_1.default.buildParameters)],
},
],
@@ -4454,7 +4449,7 @@ class AWSBuildEnvironment {
try {
const postSetupStacksTimeMs = Date.now();
orchestrator_logger_1.default.log(`Setup job time: ${Math.floor((postSetupStacksTimeMs - startTimeMs) / 1000)}s`);
const { output, shouldCleanup } = await aws_task_runner_1.default.runTask(taskDef, environment, secrets, commands);
const { output, shouldCleanup } = await aws_task_runner_1.default.runTask(taskDef, environment, commands);
postRunTaskTimeMs = Date.now();
orchestrator_logger_1.default.log(`Run job time: ${Math.floor((postRunTaskTimeMs - postSetupStacksTimeMs) / 1000)}s`);
if (shouldCleanup) {
@@ -9736,8 +9731,7 @@ if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
git clone -q -b "$BRANCH" "$REPO" /builder
else
echo "Remote branch $BRANCH not found in $REPO; falling back to a known branch"
git clone -q -b orchestrator-develop "$REPO" /builder \
|| git clone -q -b main "$REPO" /builder \
git clone -q -b main "$REPO" /builder \
|| git clone -q "$REPO" /builder
fi
git clone -q -b ${orchestrator_1.default.buildParameters.branch} ${orchestrator_folders_1.OrchestratorFolders.targetBuildRepoUrl} /repo
@@ -9854,8 +9848,7 @@ if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
git clone -q -b "$BRANCH" "$REPO" "$DEST"
else
echo "Remote branch $BRANCH not found in $REPO; falling back to a known branch"
git clone -q -b orchestrator-develop "$REPO" "$DEST" \
|| git clone -q -b main "$REPO" "$DEST" \
git clone -q -b main "$REPO" "$DEST" \
|| git clone -q "$REPO" "$DEST"
fi
chmod +x ${builderPath}`;

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,161 @@
import { OrchestratorFolders } from './orchestrator-folders';
// Mock Orchestrator
jest.mock('../orchestrator', () => ({
__esModule: true,
default: {
buildParameters: {
buildGuid: 'test-guid-abc',
cacheKey: 'my-cache-key',
projectPath: 'test-project',
buildPath: 'Builds',
maxRetainedWorkspaces: 0,
gitPrivateToken: 'ghp_test123',
orchestratorRepoName: 'game-ci/unity-builder',
githubRepo: 'user/my-game',
},
lockedWorkspace: '',
},
}));
jest.mock('../../build-parameters', () => ({
__esModule: true,
default: {
shouldUseRetainedWorkspaceMode: jest.fn().mockReturnValue(false),
},
}));
jest.mock('./orchestrator-options', () => ({
__esModule: true,
default: {
useSharedBuilder: false,
},
}));
// Normalize paths for cross-platform test compatibility
const normalize = (p: string) => p.replace(/\\/g, '/');
describe('OrchestratorFolders', () => {
describe('static constants', () => {
it('repositoryFolder is "repo"', () => {
expect(OrchestratorFolders.repositoryFolder).toBe('repo');
});
it('buildVolumeFolder is "data"', () => {
expect(OrchestratorFolders.buildVolumeFolder).toBe('data');
});
it('cacheFolder is "cache"', () => {
expect(OrchestratorFolders.cacheFolder).toBe('cache');
});
});
describe('ToLinuxFolder', () => {
it('converts backslashes to forward slashes', () => {
expect(OrchestratorFolders.ToLinuxFolder('C:\\Users\\test\\project')).toBe('C:/Users/test/project');
});
it('preserves forward slashes', () => {
expect(OrchestratorFolders.ToLinuxFolder('/home/user/project')).toBe('/home/user/project');
});
it('handles mixed slashes', () => {
expect(OrchestratorFolders.ToLinuxFolder('some/path\\mixed/slashes\\here')).toBe('some/path/mixed/slashes/here');
});
it('handles empty string', () => {
expect(OrchestratorFolders.ToLinuxFolder('')).toBe('');
});
});
describe('path computations (non-retained workspace mode)', () => {
it('uniqueOrchestratorJobFolderAbsolute uses buildGuid', () => {
const result = normalize(OrchestratorFolders.uniqueOrchestratorJobFolderAbsolute);
expect(result).toBe('/data/test-guid-abc');
});
it('cacheFolderForAllFull returns /data/cache', () => {
const result = normalize(OrchestratorFolders.cacheFolderForAllFull);
expect(result).toBe('/data/cache');
});
it('cacheFolderForCacheKeyFull includes cache key', () => {
const result = normalize(OrchestratorFolders.cacheFolderForCacheKeyFull);
expect(result).toBe('/data/cache/my-cache-key');
});
it('repoPathAbsolute is under job folder', () => {
const result = normalize(OrchestratorFolders.repoPathAbsolute);
expect(result).toBe('/data/test-guid-abc/repo');
});
it('projectPathAbsolute includes project path', () => {
const result = normalize(OrchestratorFolders.projectPathAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/test-project');
});
it('libraryFolderAbsolute is under project path', () => {
const result = normalize(OrchestratorFolders.libraryFolderAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/test-project/Library');
});
it('projectBuildFolderAbsolute uses buildPath', () => {
const result = normalize(OrchestratorFolders.projectBuildFolderAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/Builds');
});
it('lfsFolderAbsolute is under .git/lfs', () => {
const result = normalize(OrchestratorFolders.lfsFolderAbsolute);
expect(result).toBe('/data/test-guid-abc/repo/.git/lfs');
});
it('lfsCacheFolderFull is under cache key', () => {
const result = normalize(OrchestratorFolders.lfsCacheFolderFull);
expect(result).toBe('/data/cache/my-cache-key/lfs');
});
it('libraryCacheFolderFull is under cache key', () => {
const result = normalize(OrchestratorFolders.libraryCacheFolderFull);
expect(result).toBe('/data/cache/my-cache-key/Library');
});
});
describe('builderPathAbsolute', () => {
it('uses job folder when shared builder is disabled', () => {
const result = normalize(OrchestratorFolders.builderPathAbsolute);
expect(result).toBe('/data/test-guid-abc/builder');
});
});
describe('repo URLs', () => {
it('unityBuilderRepoUrl includes token and repo name', () => {
const url = OrchestratorFolders.unityBuilderRepoUrl;
expect(url).toBe('https://ghp_test123@github.com/game-ci/unity-builder.git');
});
it('targetBuildRepoUrl includes token and github repo', () => {
const url = OrchestratorFolders.targetBuildRepoUrl;
expect(url).toBe('https://ghp_test123@github.com/user/my-game.git');
});
});
describe('purgeRemoteCaching', () => {
it('returns false when env var is not set', () => {
const original = process.env.PURGE_REMOTE_BUILDER_CACHE;
delete process.env.PURGE_REMOTE_BUILDER_CACHE;
expect(OrchestratorFolders.purgeRemoteCaching).toBe(false);
if (original !== undefined) process.env.PURGE_REMOTE_BUILDER_CACHE = original;
});
it('returns true when env var is set', () => {
const original = process.env.PURGE_REMOTE_BUILDER_CACHE;
process.env.PURGE_REMOTE_BUILDER_CACHE = 'true';
expect(OrchestratorFolders.purgeRemoteCaching).toBe(true);
if (original !== undefined) {
process.env.PURGE_REMOTE_BUILDER_CACHE = original;
} else {
delete process.env.PURGE_REMOTE_BUILDER_CACHE;
}
});
});
});

View File

@@ -0,0 +1,53 @@
import OrchestratorNamespace from './orchestrator-guid';
describe('OrchestratorNamespace', () => {
describe('generateGuid', () => {
it('generates a guid with correct format', () => {
const guid = OrchestratorNamespace.generateGuid('42', 'StandaloneLinux64');
// Format: {runNumber}-{platform}-{nanoid4}
expect(guid).toMatch(/^42-linux64-[a-z0-9]{4}$/);
});
it('strips "standalone" prefix from platform (case-insensitive)', () => {
const guid = OrchestratorNamespace.generateGuid('1', 'StandaloneWindows64');
expect(guid).toMatch(/^1-windows64-[a-z0-9]{4}$/);
});
it('lowercases platform name', () => {
const guid = OrchestratorNamespace.generateGuid('5', 'Android');
expect(guid).toMatch(/^5-android-[a-z0-9]{4}$/);
});
it('handles numeric run number', () => {
const guid = OrchestratorNamespace.generateGuid(100, 'iOS');
expect(guid).toMatch(/^100-ios-[a-z0-9]{4}$/);
});
it('generates unique guids on repeated calls', () => {
const guids = new Set<string>();
for (let i = 0; i < 20; i++) {
guids.add(OrchestratorNamespace.generateGuid('1', 'StandaloneLinux64'));
}
// With 4 alphanumeric chars (36^4 = ~1.7M possibilities), 20 calls should almost certainly be unique
expect(guids.size).toBeGreaterThan(1);
});
it('handles StandaloneOSX platform', () => {
const guid = OrchestratorNamespace.generateGuid('7', 'StandaloneOSX');
expect(guid).toMatch(/^7-osx-[a-z0-9]{4}$/);
});
it('handles WebGL platform (no standalone prefix)', () => {
const guid = OrchestratorNamespace.generateGuid('3', 'WebGL');
expect(guid).toMatch(/^3-webgl-[a-z0-9]{4}$/);
});
it('uses only lowercase alphanumeric characters in nanoid portion', () => {
for (let i = 0; i < 10; i++) {
const guid = OrchestratorNamespace.generateGuid('1', 'test');
const nanoidPart = guid.split('-').pop()!;
expect(nanoidPart).toMatch(/^[0-9a-z]{4}$/);
}
});
});
});

View File

@@ -1,7 +1,6 @@
import { DescribeTasksCommand, RunTaskCommand, waitUntilTasksRunning } from '@aws-sdk/client-ecs';
import { DescribeStreamCommand, GetRecordsCommand, GetShardIteratorCommand } from '@aws-sdk/client-kinesis';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorSecret from '../../options/orchestrator-secret';
import * as core from '@actions/core';
import OrchestratorAWSTaskDef from './orchestrator-aws-task-def';
import * as zlib from 'node:zlib';
@@ -57,7 +56,6 @@ class AWSTaskRunner {
static async runTask(
taskDef: OrchestratorAWSTaskDef,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
commands: string,
): Promise<{ output: string; shouldCleanup: boolean }> {
const cluster = taskDef.baseResources?.find((x) => x.LogicalResourceId === 'ECSCluster')?.PhysicalResourceId || '';
@@ -75,12 +73,6 @@ class AWSTaskRunner {
// Transform localhost endpoints for container environment
const transformedEnvironment = AWSTaskRunner.transformEndpointsForContainer(environment);
// Merge secrets into environment as plain env vars, matching docker and k8s provider behavior.
// This ensures UNITY_EMAIL, UNITY_PASSWORD, UNITY_SERIAL reach the container reliably
// without depending on CloudFormation Secrets Manager resolution.
const secretsAsEnvironment = secrets.map((s) => ({ name: s.EnvironmentVariable, value: s.ParameterValue }));
const mergedEnvironment = [...transformedEnvironment, ...secretsAsEnvironment];
const runParameters = {
cluster,
taskDefinition,
@@ -89,7 +81,7 @@ class AWSTaskRunner {
containerOverrides: [
{
name: taskDef.taskDefStackName,
environment: mergedEnvironment,
environment: transformedEnvironment,
command: ['-c', CommandHookService.ApplyHooksToCommands(commands, Orchestrator.buildParameters)],
},
],

View File

@@ -125,7 +125,7 @@ class AWSBuildEnvironment implements ProviderInterface {
try {
const postSetupStacksTimeMs = Date.now();
OrchestratorLogger.log(`Setup job time: ${Math.floor((postSetupStacksTimeMs - startTimeMs) / 1000)}s`);
const { output, shouldCleanup } = await AwsTaskRunner.runTask(taskDef, environment, secrets, commands);
const { output, shouldCleanup } = await AwsTaskRunner.runTask(taskDef, environment, commands);
postRunTaskTimeMs = Date.now();
OrchestratorLogger.log(`Run job time: ${Math.floor((postRunTaskTimeMs - postSetupStacksTimeMs) / 1000)}s`);
if (shouldCleanup) {

View File

@@ -0,0 +1,147 @@
import { FollowLogStreamService } from './follow-log-stream-service';
import * as core from '@actions/core';
import GitHub from '../../../github';
// Mock dependencies
jest.mock('../../../github', () => ({
__esModule: true,
default: {
updateGitHubCheck: jest.fn(),
githubInputEnabled: false,
},
}));
jest.mock('@actions/core', () => ({
warning: jest.fn(),
setOutput: jest.fn(),
setFailed: jest.fn(),
error: jest.fn(),
getInput: jest.fn().mockReturnValue(''),
}));
jest.mock('../../orchestrator', () => ({
__esModule: true,
default: {
buildParameters: {
logId: 'test-log-id-123',
},
},
}));
jest.mock('../../options/orchestrator-statics', () => ({
OrchestratorStatics: {
logPrefix: 'TEST',
},
}));
jest.mock('./orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
},
}));
describe('FollowLogStreamService', () => {
beforeEach(() => {
jest.clearAllMocks();
FollowLogStreamService.Reset();
FollowLogStreamService.errors = '';
});
describe('Reset', () => {
it('resets DidReceiveEndOfTransmission to false', () => {
FollowLogStreamService.DidReceiveEndOfTransmission = true;
FollowLogStreamService.Reset();
expect(FollowLogStreamService.DidReceiveEndOfTransmission).toBe(false);
});
});
describe('handleIteration', () => {
it('detects end of transmission marker', () => {
const result = FollowLogStreamService.handleIteration('---test-log-id-123', true, false, '');
expect(FollowLogStreamService.DidReceiveEndOfTransmission).toBe(true);
expect(result.shouldReadLogs).toBe(false);
});
it('does not trigger end of transmission for non-matching log ID', () => {
const result = FollowLogStreamService.handleIteration('---different-log-id', true, false, '');
expect(FollowLogStreamService.DidReceiveEndOfTransmission).toBe(false);
expect(result.shouldReadLogs).toBe(true);
});
it('detects Library rebuild message', () => {
FollowLogStreamService.handleIteration(
'Rebuilding Library because the asset database could not be found!',
true,
false,
'',
);
expect(GitHub.updateGitHubCheck).toHaveBeenCalledWith('Library was not found, importing new Library', '');
expect(core.warning).toHaveBeenCalledWith('LIBRARY NOT FOUND!');
expect(core.setOutput).toHaveBeenCalledWith('library-found', 'false');
});
it('detects Build succeeded message', () => {
FollowLogStreamService.handleIteration('Build succeeded', true, false, '');
expect(GitHub.updateGitHubCheck).toHaveBeenCalledWith('Build succeeded', 'Build succeeded');
expect(core.setOutput).toHaveBeenCalledWith('build-result', 'success');
});
it('detects Build fail message', () => {
FollowLogStreamService.handleIteration('Build fail', true, false, '');
expect(GitHub.updateGitHubCheck).toHaveBeenCalled();
expect(core.setOutput).toHaveBeenCalledWith('build-result', 'failed');
expect(core.setFailed).toHaveBeenCalledWith('unity build failed');
expect(core.error).toHaveBeenCalledWith('BUILD FAILED!');
});
it('accumulates error messages with "error " pattern', () => {
FollowLogStreamService.handleIteration('error CS0001: Something went wrong', true, false, '');
expect(FollowLogStreamService.errors).toContain('error CS0001: Something went wrong');
expect(core.error).toHaveBeenCalled();
});
it('accumulates error messages with "error: " pattern', () => {
FollowLogStreamService.handleIteration('Fatal Error: Out of memory', true, false, '');
expect(FollowLogStreamService.errors).toContain('Fatal Error: Out of memory');
});
it('accumulates "command failed: " messages', () => {
FollowLogStreamService.handleIteration('command failed: git pull', true, false, '');
expect(FollowLogStreamService.errors).toContain('command failed: git pull');
});
it('accumulates "invalid " messages', () => {
FollowLogStreamService.handleIteration('invalid configuration value', true, false, '');
expect(FollowLogStreamService.errors).toContain('invalid configuration value');
});
it('accumulates "cannot be found" messages', () => {
FollowLogStreamService.handleIteration('Assembly cannot be found', true, false, '');
expect(FollowLogStreamService.errors).toContain('Assembly cannot be found');
});
it('appends message to output', () => {
const result = FollowLogStreamService.handleIteration('Some normal log line', true, false, 'previous output\n');
expect(result.output).toContain('Some normal log line');
expect(result.output).toContain('previous output');
});
it('preserves shouldCleanup value', () => {
const result = FollowLogStreamService.handleIteration('normal message', true, true, '');
expect(result.shouldCleanup).toBe(true);
});
it('does not change shouldReadLogs for normal messages', () => {
const result = FollowLogStreamService.handleIteration('Just a regular build log', true, false, '');
expect(result.shouldReadLogs).toBe(true);
});
it('includes accumulated errors in Build fail GitHub check message', () => {
FollowLogStreamService.errors = '\nprevious error';
FollowLogStreamService.handleIteration('Build fail', true, false, '');
const updateCall = (GitHub.updateGitHubCheck as jest.Mock).mock.calls[0];
expect(updateCall[0]).toContain('previous error');
});
});
});

View File

@@ -0,0 +1,207 @@
import { TaskParameterSerializer } from './task-parameter-serializer';
// Mock dependencies that TaskParameterSerializer uses internally
jest.mock('@actions/core', () => ({
getInput: jest.fn().mockReturnValue(''),
setOutput: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
}));
jest.mock('../../options/orchestrator-options', () => ({
__esModule: true,
default: {
getInput: jest.fn().mockReturnValue(undefined),
ToEnvVarFormat: (input: string) => {
if (input.toUpperCase() === input) {
return input;
}
return input
.replace(/([A-Z])/g, ' $1')
.trim()
.toUpperCase()
.replace(/ /g, '_');
},
},
}));
jest.mock('../../options/orchestrator-options-reader', () => ({
__esModule: true,
default: {
GetProperties: jest.fn().mockReturnValue([]),
},
}));
jest.mock('../../options/orchestrator-query-override', () => ({
__esModule: true,
default: {
queryOverrides: undefined,
},
}));
jest.mock('../hooks/command-hook-service', () => ({
CommandHookService: {
getHooks: jest.fn().mockReturnValue([]),
getSecrets: jest.fn().mockReturnValue([]),
},
}));
jest.mock('../../../input', () => ({
__esModule: true,
default: {},
}));
jest.mock('../../../github', () => ({
__esModule: true,
default: {
githubInputEnabled: false,
},
}));
describe('TaskParameterSerializer', () => {
describe('ToEnvVarFormat', () => {
it('converts camelCase to UPPER_SNAKE_CASE', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('targetPlatform')).toBe('TARGET_PLATFORM');
});
it('converts single word to uppercase', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('version')).toBe('VERSION');
});
it('preserves already-uppercase strings', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('AWS_REGION')).toBe('AWS_REGION');
});
it('handles multi-word camelCase', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('buildPlatformTarget')).toBe('BUILD_PLATFORM_TARGET');
});
it('handles string starting with uppercase', () => {
expect(TaskParameterSerializer.ToEnvVarFormat('BuildGuid')).toBe('BUILD_GUID');
});
});
describe('UndoEnvVarFormat', () => {
it('converts UPPER_SNAKE_CASE back to camelCase', () => {
expect(TaskParameterSerializer.UndoEnvVarFormat('TARGET_PLATFORM')).toBe('targetPlatform');
});
it('handles single word', () => {
expect(TaskParameterSerializer.UndoEnvVarFormat('VERSION')).toBe('version');
});
it('handles multiple underscores', () => {
expect(TaskParameterSerializer.UndoEnvVarFormat('BUILD_PLATFORM_TARGET')).toBe('buildPlatformTarget');
});
});
describe('round-trip conversion', () => {
it('ToEnvVarFormat -> UndoEnvVarFormat returns original for simple camelCase', () => {
const original = 'targetPlatform';
const envVar = TaskParameterSerializer.ToEnvVarFormat(original);
const roundTrip = TaskParameterSerializer.UndoEnvVarFormat(envVar);
expect(roundTrip).toBe(original);
});
it('round-trips multi-word keys', () => {
const original = 'cacheKey';
const envVar = TaskParameterSerializer.ToEnvVarFormat(original);
const roundTrip = TaskParameterSerializer.UndoEnvVarFormat(envVar);
expect(roundTrip).toBe(original);
});
});
describe('uniqBy', () => {
it('removes duplicates by key function', () => {
const items = [
{ name: 'A', value: '1' },
{ name: 'B', value: '2' },
{ name: 'A', value: '3' },
];
const result = TaskParameterSerializer.uniqBy(items, (x) => x.name);
expect(result).toHaveLength(2);
expect(result[0].value).toBe('1');
expect(result[1].value).toBe('2');
});
it('returns all items when no duplicates', () => {
const items = [
{ name: 'A', value: '1' },
{ name: 'B', value: '2' },
{ name: 'C', value: '3' },
];
const result = TaskParameterSerializer.uniqBy(items, (x) => x.name);
expect(result).toHaveLength(3);
});
it('handles empty array', () => {
const result = TaskParameterSerializer.uniqBy([], (x) => x.name);
expect(result).toHaveLength(0);
});
it('keeps first occurrence when duplicates exist', () => {
const items = [
{ name: 'KEY', value: 'first' },
{ name: 'KEY', value: 'second' },
{ name: 'KEY', value: 'third' },
];
const result = TaskParameterSerializer.uniqBy(items, (x) => x.name);
expect(result).toHaveLength(1);
expect(result[0].value).toBe('first');
});
});
describe('blockedParameterNames', () => {
it('contains expected blocked names', () => {
expect(TaskParameterSerializer.blockedParameterNames.has('0')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('length')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('prototype')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('unityVersion')).toBe(true);
expect(TaskParameterSerializer.blockedParameterNames.has('CUSTOM_JOB')).toBe(true);
});
it('does not block valid parameter names', () => {
expect(TaskParameterSerializer.blockedParameterNames.has('targetPlatform')).toBe(false);
expect(TaskParameterSerializer.blockedParameterNames.has('buildGuid')).toBe(false);
expect(TaskParameterSerializer.blockedParameterNames.has('cacheKey')).toBe(false);
});
});
describe('readDefaultSecrets', () => {
it('returns an array', () => {
const secrets = TaskParameterSerializer.readDefaultSecrets();
expect(Array.isArray(secrets)).toBe(true);
});
it('includes secrets from environment when present', () => {
const originalSerial = process.env.UNITY_SERIAL;
process.env.UNITY_SERIAL = 'test-serial';
const secrets = TaskParameterSerializer.readDefaultSecrets();
const serialSecret = secrets.find((s) => s.ParameterKey === 'UNITY_SERIAL');
expect(serialSecret).toBeDefined();
expect(serialSecret?.ParameterValue).toBe('test-serial');
if (originalSerial !== undefined) {
process.env.UNITY_SERIAL = originalSerial;
} else {
delete process.env.UNITY_SERIAL;
}
});
it('excludes secrets not in environment', () => {
const originalSerial = process.env.UNITY_SERIAL;
delete process.env.UNITY_SERIAL;
const secrets = TaskParameterSerializer.readDefaultSecrets();
const serialSecret = secrets.find((s) => s.ParameterKey === 'UNITY_SERIAL');
expect(serialSecret).toBeUndefined();
if (originalSerial !== undefined) {
process.env.UNITY_SERIAL = originalSerial;
}
});
});
});

View File

@@ -30,7 +30,7 @@ describe('Orchestrator Caching', () => {
targetPlatform: 'StandaloneLinux64',
cacheKey: `test-case-${uuidv4()}`,
containerHookFiles: `debug-cache`,
orchestratorBranch: `orchestrator-develop`,
orchestratorBranch: `main`,
orchestratorDebug: true,
};

View File

@@ -33,8 +33,7 @@ if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
git clone -q -b "$BRANCH" "$REPO" /builder
else
echo "Remote branch $BRANCH not found in $REPO; falling back to a known branch"
git clone -q -b orchestrator-develop "$REPO" /builder \
|| git clone -q -b main "$REPO" /builder \
git clone -q -b main "$REPO" /builder \
|| git clone -q "$REPO" /builder
fi
git clone -q -b ${Orchestrator.buildParameters.branch} ${OrchestratorFolders.targetBuildRepoUrl} /repo

View File

@@ -99,8 +99,7 @@ if [ -n "$(git ls-remote --heads "$REPO" "$BRANCH" 2>/dev/null)" ]; then
git clone -q -b "$BRANCH" "$REPO" "$DEST"
else
echo "Remote branch $BRANCH not found in $REPO; falling back to a known branch"
git clone -q -b orchestrator-develop "$REPO" "$DEST" \
|| git clone -q -b main "$REPO" "$DEST" \
git clone -q -b main "$REPO" "$DEST" \
|| git clone -q "$REPO" "$DEST"
fi
chmod +x ${builderPath}`;