From 17a0ea3776f09974db38451ccf977195fc1426e8 Mon Sep 17 00:00:00 2001 From: frostebite Date: Thu, 5 Mar 2026 08:08:49 +0000 Subject: [PATCH] test(orchestrator): add unit tests for untested core services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../options/orchestrator-folders.test.ts | 164 +++++++++++++ .../options/orchestrator-guid.test.ts | 53 +++++ .../core/follow-log-stream-service.test.ts | 216 ++++++++++++++++++ .../core/task-parameter-serializer.test.ts | 207 +++++++++++++++++ 4 files changed, 640 insertions(+) create mode 100644 src/model/orchestrator/options/orchestrator-folders.test.ts create mode 100644 src/model/orchestrator/options/orchestrator-guid.test.ts create mode 100644 src/model/orchestrator/services/core/follow-log-stream-service.test.ts create mode 100644 src/model/orchestrator/services/core/task-parameter-serializer.test.ts diff --git a/src/model/orchestrator/options/orchestrator-folders.test.ts b/src/model/orchestrator/options/orchestrator-folders.test.ts new file mode 100644 index 00000000..efb5247e --- /dev/null +++ b/src/model/orchestrator/options/orchestrator-folders.test.ts @@ -0,0 +1,164 @@ +import { OrchestratorFolders } from './orchestrator-folders'; +import path from 'node:path'; + +// 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; + } + }); + }); +}); diff --git a/src/model/orchestrator/options/orchestrator-guid.test.ts b/src/model/orchestrator/options/orchestrator-guid.test.ts new file mode 100644 index 00000000..7542885c --- /dev/null +++ b/src/model/orchestrator/options/orchestrator-guid.test.ts @@ -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(); + 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}$/); + } + }); + }); +}); diff --git a/src/model/orchestrator/services/core/follow-log-stream-service.test.ts b/src/model/orchestrator/services/core/follow-log-stream-service.test.ts new file mode 100644 index 00000000..5d9cc513 --- /dev/null +++ b/src/model/orchestrator/services/core/follow-log-stream-service.test.ts @@ -0,0 +1,216 @@ +import { FollowLogStreamService } from './follow-log-stream-service'; + +// 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(), + }, +})); + +import * as core from '@actions/core'; +import GitHub from '../../../github'; + +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'); + }); + }); +}); diff --git a/src/model/orchestrator/services/core/task-parameter-serializer.test.ts b/src/model/orchestrator/services/core/task-parameter-serializer.test.ts new file mode 100644 index 00000000..30640e0a --- /dev/null +++ b/src/model/orchestrator/services/core/task-parameter-serializer.test.ts @@ -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; + } + }); + }); +});