mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-06-08 15:03:58 -07:00
5268630ef0
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>
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
import fs from 'node:fs';
|
|
import { SubmoduleProfileService } from './submodule-profile-service';
|
|
import { OrchestratorSystem } from '../core/orchestrator-system';
|
|
|
|
jest.mock('node:fs');
|
|
jest.mock('../core/orchestrator-system');
|
|
jest.mock('../core/orchestrator-logger', () => ({
|
|
__esModule: true,
|
|
default: {
|
|
log: jest.fn(),
|
|
logWarning: jest.fn(),
|
|
error: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
const mockedFs = fs as jest.Mocked<typeof fs>;
|
|
const mockedSystem = OrchestratorSystem as jest.Mocked<typeof OrchestratorSystem>;
|
|
|
|
describe('SubmoduleProfileService', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('parseProfile', () => {
|
|
it('reads and parses a valid YAML profile', () => {
|
|
const profileYaml = `
|
|
primary_submodule: Assets/_Game/Submodules/TurnOfWarEndlessCrusade
|
|
product_name: Endless Crusade
|
|
submodules:
|
|
- name: TurnOfWar
|
|
branch: main
|
|
- name: TurnOfWarEndlessCrusade
|
|
branch: main
|
|
- name: AreaOfOperations
|
|
branch: empty
|
|
`;
|
|
mockedFs.existsSync.mockReturnValue(true);
|
|
mockedFs.readFileSync.mockReturnValue(profileYaml);
|
|
|
|
const profile = SubmoduleProfileService.parseProfile('/path/to/profile.yml');
|
|
|
|
expect(profile.primary_submodule).toBe('Assets/_Game/Submodules/TurnOfWarEndlessCrusade');
|
|
expect(profile.product_name).toBe('Endless Crusade');
|
|
expect(profile.submodules).toHaveLength(3);
|
|
expect(profile.submodules[0]).toEqual({ name: 'TurnOfWar', branch: 'main' });
|
|
expect(profile.submodules[1]).toEqual({ name: 'TurnOfWarEndlessCrusade', branch: 'main' });
|
|
expect(profile.submodules[2]).toEqual({ name: 'AreaOfOperations', branch: 'empty' });
|
|
});
|
|
|
|
it('throws if profile file does not exist', () => {
|
|
mockedFs.existsSync.mockReturnValue(false);
|
|
|
|
expect(() => SubmoduleProfileService.parseProfile('/missing/profile.yml')).toThrow('Submodule profile not found');
|
|
});
|
|
|
|
it('throws if YAML is missing submodules array', () => {
|
|
mockedFs.existsSync.mockReturnValue(true);
|
|
mockedFs.readFileSync.mockReturnValue('product_name: Test\n');
|
|
|
|
expect(() => SubmoduleProfileService.parseProfile('/path/to/bad.yml')).toThrow("expected 'submodules' array");
|
|
});
|
|
});
|
|
|
|
describe('mergeVariant', () => {
|
|
it('correctly overlays variant entries on base profile', () => {
|
|
const baseYaml = `
|
|
submodules:
|
|
- name: ModuleA
|
|
branch: main
|
|
- name: ModuleB
|
|
branch: main
|
|
`;
|
|
const variantYaml = `
|
|
product_name: Server Build
|
|
submodules:
|
|
- name: ModuleB
|
|
branch: empty
|
|
- name: ModuleC
|
|
branch: develop
|
|
`;
|
|
|
|
mockedFs.existsSync.mockReturnValue(true);
|
|
mockedFs.readFileSync.mockImplementation((filePath: any) => {
|
|
if (filePath === '/base.yml') return baseYaml;
|
|
if (filePath === '/variant.yml') return variantYaml;
|
|
return '';
|
|
});
|
|
|
|
const base = SubmoduleProfileService.parseProfile('/base.yml');
|
|
const merged = SubmoduleProfileService.mergeVariant(base, '/variant.yml');
|
|
|
|
expect(merged.product_name).toBe('Server Build');
|
|
expect(merged.submodules).toHaveLength(3);
|
|
|
|
const moduleA = merged.submodules.find((s) => s.name === 'ModuleA');
|
|
const moduleB = merged.submodules.find((s) => s.name === 'ModuleB');
|
|
const moduleC = merged.submodules.find((s) => s.name === 'ModuleC');
|
|
|
|
expect(moduleA?.branch).toBe('main');
|
|
expect(moduleB?.branch).toBe('empty');
|
|
expect(moduleC?.branch).toBe('develop');
|
|
});
|
|
});
|
|
|
|
describe('matchSubmodule', () => {
|
|
it('matches exact submodule name', () => {
|
|
expect(SubmoduleProfileService.matchSubmodule('TurnOfWar', 'TurnOfWar')).toBe(true);
|
|
});
|
|
|
|
it('matches exact leaf folder name against full path', () => {
|
|
expect(SubmoduleProfileService.matchSubmodule('Assets/_Game/Submodules/TurnOfWar', 'TurnOfWar')).toBe(true);
|
|
});
|
|
|
|
it('does not match unrelated names', () => {
|
|
expect(SubmoduleProfileService.matchSubmodule('TurnOfWar', 'AreaOfOperations')).toBe(false);
|
|
});
|
|
|
|
it('matches trailing wildcard against full path', () => {
|
|
expect(SubmoduleProfileService.matchSubmodule('Assets/_Engine/Submodules/PluginsFoo', 'Plugins*')).toBe(true);
|
|
});
|
|
|
|
it('matches trailing wildcard against full path prefix', () => {
|
|
expect(
|
|
SubmoduleProfileService.matchSubmodule(
|
|
'Assets/_Engine/Submodules/PluginsFoo',
|
|
'Assets/_Engine/Submodules/Plugins*',
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('does not match wildcard that does not align', () => {
|
|
expect(SubmoduleProfileService.matchSubmodule('Assets/_Engine/Submodules/SensorToolkit', 'Plugins*')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('parseGitmodules', () => {
|
|
it('parses a typical .gitmodules file', () => {
|
|
const gitmodulesContent = `[submodule "Assets/_Game/Submodules/TurnOfWar"]
|
|
\tpath = Assets/_Game/Submodules/TurnOfWar
|
|
\turl = https://github.com/org/TurnOfWar.git
|
|
[submodule "Assets/_Game/Submodules/EndlessCrusade"]
|
|
\tpath = Assets/_Game/Submodules/EndlessCrusade
|
|
\turl = https://github.com/org/EndlessCrusade.git
|
|
[submodule "Assets/_Engine/Submodules/SensorToolkit"]
|
|
\tpath = Assets/_Engine/Submodules/SensorToolkit
|
|
\turl = https://github.com/org/SensorToolkit.git
|
|
`;
|
|
|
|
mockedFs.existsSync.mockReturnValue(true);
|
|
mockedFs.readFileSync.mockReturnValue(gitmodulesContent);
|
|
|
|
const result = SubmoduleProfileService.parseGitmodules('/repo');
|
|
|
|
expect(result.size).toBe(3);
|
|
expect(result.get('Assets/_Game/Submodules/TurnOfWar')).toBe('Assets/_Game/Submodules/TurnOfWar');
|
|
expect(result.get('Assets/_Game/Submodules/EndlessCrusade')).toBe('Assets/_Game/Submodules/EndlessCrusade');
|
|
expect(result.get('Assets/_Engine/Submodules/SensorToolkit')).toBe('Assets/_Engine/Submodules/SensorToolkit');
|
|
});
|
|
|
|
it('returns empty map when .gitmodules does not exist', () => {
|
|
mockedFs.existsSync.mockReturnValue(false);
|
|
|
|
const result = SubmoduleProfileService.parseGitmodules('/repo');
|
|
|
|
expect(result.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('createInitPlan', () => {
|
|
it('generates correct init and skip actions', async () => {
|
|
const profileYaml = `
|
|
submodules:
|
|
- name: TurnOfWar
|
|
branch: main
|
|
- name: EndlessCrusade
|
|
branch: main
|
|
- name: SensorToolkit
|
|
branch: empty
|
|
`;
|
|
|
|
const gitmodulesContent = `[submodule "Assets/_Game/Submodules/TurnOfWar"]
|
|
\tpath = Assets/_Game/Submodules/TurnOfWar
|
|
\turl = https://github.com/org/TurnOfWar.git
|
|
[submodule "Assets/_Game/Submodules/EndlessCrusade"]
|
|
\tpath = Assets/_Game/Submodules/EndlessCrusade
|
|
\turl = https://github.com/org/EndlessCrusade.git
|
|
[submodule "Assets/_Engine/Submodules/SensorToolkit"]
|
|
\tpath = Assets/_Engine/Submodules/SensorToolkit
|
|
\turl = https://github.com/org/SensorToolkit.git
|
|
[submodule "Assets/_Game/Submodules/Unmatched"]
|
|
\tpath = Assets/_Game/Submodules/Unmatched
|
|
\turl = https://github.com/org/Unmatched.git
|
|
`;
|
|
|
|
mockedFs.existsSync.mockReturnValue(true);
|
|
mockedFs.readFileSync.mockImplementation((filePath: any) => {
|
|
if (String(filePath).endsWith('profile.yml')) return profileYaml;
|
|
if (String(filePath).endsWith('.gitmodules')) return gitmodulesContent;
|
|
return '';
|
|
});
|
|
|
|
const plan = await SubmoduleProfileService.createInitPlan('/path/to/profile.yml', '', '/repo');
|
|
|
|
expect(plan).toHaveLength(4);
|
|
|
|
const turnOfWar = plan.find((a) => a.name === 'Assets/_Game/Submodules/TurnOfWar');
|
|
expect(turnOfWar?.action).toBe('init');
|
|
expect(turnOfWar?.branch).toBe('main');
|
|
|
|
const endlessCrusade = plan.find((a) => a.name === 'Assets/_Game/Submodules/EndlessCrusade');
|
|
expect(endlessCrusade?.action).toBe('init');
|
|
expect(endlessCrusade?.branch).toBe('main');
|
|
|
|
const sensorToolkit = plan.find((a) => a.name === 'Assets/_Engine/Submodules/SensorToolkit');
|
|
expect(sensorToolkit?.action).toBe('skip');
|
|
expect(sensorToolkit?.branch).toBe('empty');
|
|
|
|
const unmatched = plan.find((a) => a.name === 'Assets/_Game/Submodules/Unmatched');
|
|
expect(unmatched?.action).toBe('skip');
|
|
expect(unmatched?.branch).toBe('empty');
|
|
});
|
|
|
|
it('applies variant overlay when variantPath is provided', async () => {
|
|
const profileYaml = `
|
|
submodules:
|
|
- name: TurnOfWar
|
|
branch: main
|
|
- name: EndlessCrusade
|
|
branch: main
|
|
`;
|
|
|
|
const variantYaml = `
|
|
submodules:
|
|
- name: EndlessCrusade
|
|
branch: empty
|
|
`;
|
|
|
|
const gitmodulesContent = `[submodule "Assets/_Game/Submodules/TurnOfWar"]
|
|
\tpath = Assets/_Game/Submodules/TurnOfWar
|
|
\turl = https://github.com/org/TurnOfWar.git
|
|
[submodule "Assets/_Game/Submodules/EndlessCrusade"]
|
|
\tpath = Assets/_Game/Submodules/EndlessCrusade
|
|
\turl = https://github.com/org/EndlessCrusade.git
|
|
`;
|
|
|
|
mockedFs.existsSync.mockReturnValue(true);
|
|
mockedFs.readFileSync.mockImplementation((filePath: any) => {
|
|
const p = String(filePath);
|
|
if (p.endsWith('profile.yml')) return profileYaml;
|
|
if (p.endsWith('variant.yml')) return variantYaml;
|
|
if (p.endsWith('.gitmodules')) return gitmodulesContent;
|
|
return '';
|
|
});
|
|
|
|
const plan = await SubmoduleProfileService.createInitPlan(
|
|
'/path/to/profile.yml',
|
|
'/path/to/variant.yml',
|
|
'/repo',
|
|
);
|
|
|
|
expect(plan).toHaveLength(2);
|
|
|
|
const turnOfWar = plan.find((a) => a.name === 'Assets/_Game/Submodules/TurnOfWar');
|
|
expect(turnOfWar?.action).toBe('init');
|
|
|
|
const endlessCrusade = plan.find((a) => a.name === 'Assets/_Game/Submodules/EndlessCrusade');
|
|
expect(endlessCrusade?.action).toBe('skip');
|
|
});
|
|
});
|
|
|
|
describe('execute', () => {
|
|
it('runs init commands for init actions and deinit for skip actions', async () => {
|
|
mockedSystem.Run.mockResolvedValue('');
|
|
|
|
const plan = [
|
|
{ name: 'ModuleA', path: 'Assets/ModuleA', branch: 'main', action: 'init' as const },
|
|
{ name: 'ModuleB', path: 'Assets/ModuleB', branch: 'develop', action: 'init' as const },
|
|
{ name: 'ModuleC', path: 'Assets/ModuleC', branch: 'empty', action: 'skip' as const },
|
|
];
|
|
|
|
await SubmoduleProfileService.execute(plan, '/repo');
|
|
|
|
// ModuleA: init only (branch is main, no checkout needed)
|
|
expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule update --init Assets/ModuleA');
|
|
|
|
// ModuleB: init + checkout develop
|
|
expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule update --init Assets/ModuleB');
|
|
expect(mockedSystem.Run).toHaveBeenCalledWith('git -C Assets/ModuleB checkout develop');
|
|
|
|
// ModuleC: deinit
|
|
expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule deinit -f Assets/ModuleC 2>/dev/null || true');
|
|
});
|
|
|
|
it('configures auth when token is provided', async () => {
|
|
mockedSystem.Run.mockResolvedValue('');
|
|
|
|
await SubmoduleProfileService.execute([], '/repo', 'my-secret-token');
|
|
|
|
expect(mockedSystem.Run).toHaveBeenCalledWith(
|
|
'git config url."https://my-secret-token@github.com/".insteadOf "https://github.com/"',
|
|
);
|
|
});
|
|
|
|
it('does not configure auth when no token is provided', async () => {
|
|
mockedSystem.Run.mockResolvedValue('');
|
|
|
|
await SubmoduleProfileService.execute([], '/repo');
|
|
|
|
expect(mockedSystem.Run).not.toHaveBeenCalledWith(expect.stringContaining('git config url'));
|
|
});
|
|
});
|
|
});
|