mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-13 01:13:54 -07:00
feat(orchestrator): enterprise feature support — CLI provider, submodule profiles, caching, LFS, hooks
Add generic enterprise-grade features to the orchestrator, enabling Unity projects with complex CI/CD pipelines to adopt game-ci/unity-builder with built-in support for: - CLI provider protocol: JSON-over-stdin/stdout bridge enabling providers in any language (Go, Python, Rust, shell) via the `providerExecutable` input - Submodule profiles: YAML-based selective submodule initialization with glob patterns and variant overlays (`submoduleProfilePath`, `submoduleVariantPath`) - Local build caching: Filesystem-based Library and LFS caching for local builds without external cache actions (`localCacheEnabled`, `localCacheRoot`) - Custom LFS transfer agents: Register external transfer agents like elastic-git-storage (`lfsTransferAgent`, `lfsTransferAgentArgs`, `lfsStoragePaths`) - Git hooks support: Detect and install lefthook/husky with configurable skip lists (`gitHooksEnabled`, `gitHooksSkipList`) Also removes all `orchestrator-develop` branch references, replacing with `main`. 13 new action inputs, 13 new files, 14 new CLI provider tests, 17 submodule tests, plus cache/LFS/hooks unit tests. All 452 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { LocalCacheService } from './local-cache-service';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('node:fs');
|
||||
jest.mock('../core/orchestrator-system', () => ({
|
||||
OrchestratorSystem: {
|
||||
Run: jest.fn().mockResolvedValue(''),
|
||||
},
|
||||
}));
|
||||
jest.mock('../core/orchestrator-logger', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
log: jest.fn(),
|
||||
logWarning: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockFs = fs as jest.Mocked<typeof fs>;
|
||||
|
||||
describe('LocalCacheService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('generateCacheKey', () => {
|
||||
it('should generate a key from platform, version, and branch', () => {
|
||||
const key = LocalCacheService.generateCacheKey('StandaloneLinux64', '2021.3.1f1', 'main');
|
||||
expect(key).toBe('StandaloneLinux64-2021_3_1f1-main');
|
||||
});
|
||||
|
||||
it('should sanitize non-alphanumeric characters except hyphens', () => {
|
||||
const key = LocalCacheService.generateCacheKey('WebGL', '2022.3.0f1', 'feature/my-branch');
|
||||
expect(key).toBe('WebGL-2022_3_0f1-feature_my-branch');
|
||||
});
|
||||
|
||||
it('should handle empty branch', () => {
|
||||
const key = LocalCacheService.generateCacheKey('StandaloneWindows64', '2021.3.1f1', '');
|
||||
expect(key).toBe('StandaloneWindows64-2021_3_1f1-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCacheRoot', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should use localCacheRoot when set', () => {
|
||||
const result = LocalCacheService.resolveCacheRoot({ localCacheRoot: '/custom/cache' });
|
||||
expect(result).toBe('/custom/cache');
|
||||
});
|
||||
|
||||
it('should use RUNNER_TEMP when localCacheRoot is empty', () => {
|
||||
process.env.RUNNER_TEMP = '/tmp/runner';
|
||||
const result = LocalCacheService.resolveCacheRoot({ localCacheRoot: '' });
|
||||
expect(result).toBe(path.join('/tmp/runner', 'game-ci-cache'));
|
||||
});
|
||||
|
||||
it('should fall back to .game-ci/cache when neither is set', () => {
|
||||
delete process.env.RUNNER_TEMP;
|
||||
const result = LocalCacheService.resolveCacheRoot({ localCacheRoot: '' });
|
||||
expect(result).toBe(path.join(process.cwd(), '.game-ci', 'cache'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreLibraryCache', () => {
|
||||
it('should return false on cache miss (directory does not exist)', async () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||
const result = await LocalCacheService.restoreLibraryCache('/project', '/cache', 'key1');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when cache directory has no tar files', async () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||
(mockFs.readdirSync as jest.Mock).mockReturnValue(['readme.txt', 'info.json']);
|
||||
const result = await LocalCacheService.restoreLibraryCache('/project', '/cache', 'key1');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveLibraryCache', () => {
|
||||
it('should skip save when Library folder does not exist', async () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||
await LocalCacheService.saveLibraryCache('/project', '/cache', 'key1');
|
||||
// Should not throw, just log and return
|
||||
expect(mockFs.mkdirSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create cache directory structure', async () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
|
||||
(mockFs.readdirSync as jest.Mock).mockImplementation((dirPath: string) => {
|
||||
if (String(dirPath).includes('Library') && !String(dirPath).includes('cache')) {
|
||||
return ['file1.asset', 'file2.asset'];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
(mockFs.statSync as jest.Mock).mockReturnValue({ mtimeMs: Date.now() });
|
||||
(mockFs.mkdirSync as jest.Mock).mockReturnValue(undefined);
|
||||
|
||||
const { OrchestratorSystem } = require('../core/orchestrator-system');
|
||||
OrchestratorSystem.Run.mockResolvedValue('');
|
||||
|
||||
await LocalCacheService.saveLibraryCache('/project', '/cache', 'key1');
|
||||
expect(mockFs.mkdirSync).toHaveBeenCalledWith(path.join('/cache', 'key1', 'Library'), { recursive: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('garbageCollect', () => {
|
||||
it('should skip when cache root does not exist', async () => {
|
||||
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
|
||||
await LocalCacheService.garbageCollect('/nonexistent');
|
||||
// Should not throw
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user