diff --git a/src/index-enterprise-features.test.ts b/src/index-enterprise-features.test.ts new file mode 100644 index 00000000..068d926e --- /dev/null +++ b/src/index-enterprise-features.test.ts @@ -0,0 +1,606 @@ +/** + * Integration wiring tests for enterprise features in index.ts + * + * These tests verify the conditional gating logic in runMain(): + * - Each enterprise feature is only invoked when its gate condition is met + * - Services are NOT called when their feature is disabled (the default) + * - The order of operations is correct (restore before build, save after build) + */ + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +import { BuildParameters } from './model'; + +// --------------------------------------------------------------------------- +// Service mocks — must be declared before importing index.ts (jest hoists them) +// --------------------------------------------------------------------------- + +const mockChildWorkspaceService = { + buildConfig: jest.fn().mockReturnValue({ enabled: true, workspaceName: 'Test' }), + initializeWorkspace: jest.fn().mockReturnValue(false), + getWorkspaceSize: jest.fn().mockReturnValue('0 B'), + saveWorkspace: jest.fn(), +}; + +const mockSubmoduleProfileService = { + createInitPlan: jest.fn().mockResolvedValue([]), + execute: jest.fn().mockResolvedValue(''), +}; + +const mockLfsAgentService = { + configure: jest.fn().mockResolvedValue(''), +}; + +const mockLocalCacheService = { + resolveCacheRoot: jest.fn().mockReturnValue('/cache'), + generateCacheKey: jest.fn().mockReturnValue('key-1'), + restoreLfsCache: jest.fn().mockResolvedValue(true), + restoreLibraryCache: jest.fn().mockResolvedValue(true), + saveLibraryCache: jest.fn().mockResolvedValue(''), + saveLfsCache: jest.fn().mockResolvedValue(''), +}; + +const mockGitHooksService = { + installHooks: jest.fn().mockResolvedValue(''), + configureSkipList: jest.fn().mockReturnValue({ LEFTHOOK_EXCLUDE: 'pre-commit' }), +}; + +// Mock the dynamic import() targets — jest.mock with factory functions. +// The services are imported dynamically via `await import(...)` in index.ts, +// so we mock the module path and return the mock objects as named exports. +jest.mock('./model/orchestrator/services/cache/child-workspace-service', () => ({ + ChildWorkspaceService: mockChildWorkspaceService, +})); + +jest.mock('./model/orchestrator/services/submodule/submodule-profile-service', () => ({ + SubmoduleProfileService: mockSubmoduleProfileService, +})); + +jest.mock('./model/orchestrator/services/lfs/lfs-agent-service', () => ({ + LfsAgentService: mockLfsAgentService, +})); + +jest.mock('./model/orchestrator/services/cache/local-cache-service', () => ({ + LocalCacheService: mockLocalCacheService, +})); + +jest.mock('./model/orchestrator/services/hooks/git-hooks-service', () => ({ + GitHooksService: mockGitHooksService, +})); + +// Mock all non-enterprise dependencies to isolate the wiring logic +jest.mock('@actions/core'); +jest.mock('./model', () => ({ + Action: { + checkCompatibility: jest.fn(), + workspace: '/workspace', + actionFolder: '/action', + }, + BuildParameters: { + create: jest.fn(), + }, + Cache: { + verify: jest.fn(), + }, + Orchestrator: { + run: jest.fn().mockResolvedValue(''), + }, + Docker: { + run: jest.fn().mockResolvedValue(0), + }, + ImageTag: jest.fn().mockImplementation(() => ({ + toString: () => 'mock-image:latest', + })), + Output: { + setBuildVersion: jest.fn().mockResolvedValue(''), + setAndroidVersionCode: jest.fn().mockResolvedValue(''), + setEngineExitCode: jest.fn().mockResolvedValue(''), + }, +})); + +jest.mock('./model/cli/cli', () => ({ + Cli: { + InitCliMode: jest.fn().mockReturnValue(false), + }, +})); + +jest.mock('./model/mac-builder', () => ({ + __esModule: true, + default: { + run: jest.fn().mockResolvedValue(0), + }, +})); + +jest.mock('./model/platform-setup', () => ({ + __esModule: true, + default: { + setup: jest.fn().mockResolvedValue(''), + }, +})); + +const mockedBuildParametersCreate = BuildParameters.create as jest.Mock; + +interface EnterpriseBuildParametersOverrides { + providerStrategy?: string; + childWorkspacesEnabled?: boolean; + childWorkspaceName?: string; + childWorkspaceCacheRoot?: string; + childWorkspacePreserveGit?: boolean; + childWorkspaceSeparateLibrary?: boolean; + submoduleProfilePath?: string; + submoduleVariantPath?: string; + submoduleToken?: string; + gitPrivateToken?: string; + lfsTransferAgent?: string; + lfsTransferAgentArgs?: string; + lfsStoragePaths?: string; + localCacheEnabled?: boolean; + localCacheRoot?: string; + localCacheLibrary?: boolean; + localCacheLfs?: boolean; + gitHooksEnabled?: boolean; + gitHooksSkipList?: string; + gitHooksRunBeforeBuild?: string; +} + +function createMockBuildParameters(overrides: EnterpriseBuildParametersOverrides = {}) { + return { + // Required base properties + providerStrategy: 'local', + targetPlatform: 'StandaloneLinux64', + editorVersion: '2021.3.1f1', + buildVersion: '1.0.0', + androidVersionCode: '1', + projectPath: '.', + branch: 'main', + runnerTempPath: '/tmp', + + // Enterprise features - all disabled by default + childWorkspacesEnabled: false, + childWorkspaceName: '', + childWorkspaceCacheRoot: '', + childWorkspacePreserveGit: true, + childWorkspaceSeparateLibrary: true, + submoduleProfilePath: '', + submoduleVariantPath: '', + submoduleToken: '', + gitPrivateToken: '', + lfsTransferAgent: '', + lfsTransferAgentArgs: '', + lfsStoragePaths: '', + localCacheEnabled: false, + localCacheRoot: '', + localCacheLibrary: true, + localCacheLfs: false, + gitHooksEnabled: false, + gitHooksSkipList: '', + gitHooksRunBeforeBuild: '', + + ...overrides, + }; +} + +/** + * The entry point (runMain) is invoked by importing index.ts. + * Since it calls `runMain()` at module scope, we need to re-import it + * for each test. jest.isolateModules() handles this. + */ +async function runIndex(overrides: EnterpriseBuildParametersOverrides = {}): Promise { + mockedBuildParametersCreate.mockResolvedValue(createMockBuildParameters(overrides)); + + return new Promise((resolve) => { + jest.isolateModules(() => { + require('./index'); + + // runMain() is async; give it a tick to complete + // We use setImmediate to ensure all microtasks from the dynamic imports resolve + }); + + // Allow all promises and microtasks to settle + setTimeout(resolve, 100); + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('index.ts enterprise feature wiring', () => { + const originalPlatform = process.platform; + const originalEnvironment = { ...process.env }; + + beforeEach(() => { + jest.clearAllMocks(); + process.env.GITHUB_WORKSPACE = '/workspace'; + + // Force linux platform so Docker.run is used (not MacBuilder) + Object.defineProperty(process, 'platform', { value: 'linux' }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + process.env = { ...originalEnvironment }; + }); + + // ----------------------------------------------------------------------- + // GitHooksService gating + // ----------------------------------------------------------------------- + + describe('GitHooksService gating', () => { + it('should NOT call GitHooksService when gitHooksEnabled is false (default)', async () => { + await runIndex({ gitHooksEnabled: false }); + + expect(mockGitHooksService.installHooks).not.toHaveBeenCalled(); + expect(mockGitHooksService.configureSkipList).not.toHaveBeenCalled(); + }); + + it('should call installHooks when gitHooksEnabled is true', async () => { + await runIndex({ gitHooksEnabled: true }); + + expect(mockGitHooksService.installHooks).toHaveBeenCalledWith('/workspace'); + }); + + it('should call configureSkipList when gitHooksEnabled and gitHooksSkipList is set', async () => { + await runIndex({ + gitHooksEnabled: true, + gitHooksSkipList: 'pre-commit,pre-push', + }); + + expect(mockGitHooksService.configureSkipList).toHaveBeenCalledWith(['pre-commit', 'pre-push']); + }); + + it('should NOT call configureSkipList when gitHooksSkipList is empty', async () => { + await runIndex({ + gitHooksEnabled: true, + gitHooksSkipList: '', + }); + + expect(mockGitHooksService.installHooks).toHaveBeenCalled(); + expect(mockGitHooksService.configureSkipList).not.toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // LocalCacheService gating + // ----------------------------------------------------------------------- + + describe('LocalCacheService gating', () => { + it('should NOT call LocalCacheService when localCacheEnabled is false (default)', async () => { + await runIndex({ localCacheEnabled: false }); + + expect(mockLocalCacheService.resolveCacheRoot).not.toHaveBeenCalled(); + expect(mockLocalCacheService.generateCacheKey).not.toHaveBeenCalled(); + expect(mockLocalCacheService.restoreLibraryCache).not.toHaveBeenCalled(); + expect(mockLocalCacheService.restoreLfsCache).not.toHaveBeenCalled(); + expect(mockLocalCacheService.saveLibraryCache).not.toHaveBeenCalled(); + expect(mockLocalCacheService.saveLfsCache).not.toHaveBeenCalled(); + }); + + it('should call restore and save operations when localCacheEnabled is true', async () => { + await runIndex({ + localCacheEnabled: true, + localCacheLibrary: true, + localCacheLfs: true, + }); + + expect(mockLocalCacheService.resolveCacheRoot).toHaveBeenCalled(); + expect(mockLocalCacheService.generateCacheKey).toHaveBeenCalled(); + expect(mockLocalCacheService.restoreLibraryCache).toHaveBeenCalled(); + expect(mockLocalCacheService.restoreLfsCache).toHaveBeenCalled(); + expect(mockLocalCacheService.saveLibraryCache).toHaveBeenCalled(); + expect(mockLocalCacheService.saveLfsCache).toHaveBeenCalled(); + }); + + it('should only cache Library when localCacheLibrary is true and localCacheLfs is false', async () => { + await runIndex({ + localCacheEnabled: true, + localCacheLibrary: true, + localCacheLfs: false, + }); + + expect(mockLocalCacheService.restoreLibraryCache).toHaveBeenCalled(); + expect(mockLocalCacheService.restoreLfsCache).not.toHaveBeenCalled(); + expect(mockLocalCacheService.saveLibraryCache).toHaveBeenCalled(); + expect(mockLocalCacheService.saveLfsCache).not.toHaveBeenCalled(); + }); + + it('should only cache LFS when localCacheLfs is true and localCacheLibrary is false', async () => { + await runIndex({ + localCacheEnabled: true, + localCacheLibrary: false, + localCacheLfs: true, + }); + + expect(mockLocalCacheService.restoreLibraryCache).not.toHaveBeenCalled(); + expect(mockLocalCacheService.restoreLfsCache).toHaveBeenCalled(); + expect(mockLocalCacheService.saveLibraryCache).not.toHaveBeenCalled(); + expect(mockLocalCacheService.saveLfsCache).toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // ChildWorkspaceService gating + // ----------------------------------------------------------------------- + + describe('ChildWorkspaceService gating', () => { + it('should NOT call ChildWorkspaceService when childWorkspacesEnabled is false (default)', async () => { + await runIndex({ childWorkspacesEnabled: false }); + + expect(mockChildWorkspaceService.buildConfig).not.toHaveBeenCalled(); + expect(mockChildWorkspaceService.initializeWorkspace).not.toHaveBeenCalled(); + expect(mockChildWorkspaceService.saveWorkspace).not.toHaveBeenCalled(); + }); + + it('should NOT call ChildWorkspaceService when childWorkspacesEnabled is true but childWorkspaceName is empty', async () => { + await runIndex({ + childWorkspacesEnabled: true, + childWorkspaceName: '', + }); + + expect(mockChildWorkspaceService.buildConfig).not.toHaveBeenCalled(); + }); + + it('should call buildConfig, initializeWorkspace, and saveWorkspace when enabled with a name', async () => { + mockChildWorkspaceService.buildConfig.mockReturnValue({ enabled: true, workspaceName: 'TurnOfWar' }); + + await runIndex({ + childWorkspacesEnabled: true, + childWorkspaceName: 'TurnOfWar', + childWorkspaceCacheRoot: '/cache/workspaces', + }); + + expect(mockChildWorkspaceService.buildConfig).toHaveBeenCalledWith( + expect.objectContaining({ + childWorkspacesEnabled: true, + childWorkspaceName: 'TurnOfWar', + }), + ); + expect(mockChildWorkspaceService.initializeWorkspace).toHaveBeenCalled(); + expect(mockChildWorkspaceService.getWorkspaceSize).toHaveBeenCalled(); + expect(mockChildWorkspaceService.saveWorkspace).toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // SubmoduleProfileService gating + // ----------------------------------------------------------------------- + + describe('SubmoduleProfileService gating', () => { + it('should NOT call SubmoduleProfileService when submoduleProfilePath is empty (default)', async () => { + await runIndex({ submoduleProfilePath: '' }); + + expect(mockSubmoduleProfileService.createInitPlan).not.toHaveBeenCalled(); + expect(mockSubmoduleProfileService.execute).not.toHaveBeenCalled(); + }); + + it('should call createInitPlan and execute when submoduleProfilePath is set', async () => { + await runIndex({ + submoduleProfilePath: '/path/to/profile.yml', + submoduleVariantPath: '', + submoduleToken: 'my-token', + }); + + expect(mockSubmoduleProfileService.createInitPlan).toHaveBeenCalledWith('/path/to/profile.yml', '', '/workspace'); + expect(mockSubmoduleProfileService.execute).toHaveBeenCalled(); + }); + + it('should pass variant path when provided', async () => { + await runIndex({ + submoduleProfilePath: '/path/to/profile.yml', + submoduleVariantPath: '/path/to/variant.yml', + }); + + expect(mockSubmoduleProfileService.createInitPlan).toHaveBeenCalledWith( + '/path/to/profile.yml', + '/path/to/variant.yml', + '/workspace', + ); + }); + + it('should use submoduleToken for auth, falling back to gitPrivateToken', async () => { + await runIndex({ + submoduleProfilePath: '/path/to/profile.yml', + submoduleToken: '', + gitPrivateToken: 'fallback-token', + }); + + expect(mockSubmoduleProfileService.execute).toHaveBeenCalledWith( + expect.anything(), + '/workspace', + 'fallback-token', + ); + }); + + it('should prefer submoduleToken over gitPrivateToken', async () => { + await runIndex({ + submoduleProfilePath: '/path/to/profile.yml', + submoduleToken: 'specific-token', + gitPrivateToken: 'fallback-token', + }); + + expect(mockSubmoduleProfileService.execute).toHaveBeenCalledWith( + expect.anything(), + '/workspace', + 'specific-token', + ); + }); + }); + + // ----------------------------------------------------------------------- + // LfsAgentService gating + // ----------------------------------------------------------------------- + + describe('LfsAgentService gating', () => { + it('should NOT call LfsAgentService when lfsTransferAgent is empty (default)', async () => { + await runIndex({ lfsTransferAgent: '' }); + + expect(mockLfsAgentService.configure).not.toHaveBeenCalled(); + }); + + it('should call configure when lfsTransferAgent is set', async () => { + await runIndex({ + lfsTransferAgent: '/tools/elastic-git-storage', + lfsTransferAgentArgs: '--verbose', + lfsStoragePaths: '/path/a;/path/b', + }); + + expect(mockLfsAgentService.configure).toHaveBeenCalledWith( + '/tools/elastic-git-storage', + '--verbose', + ['/path/a', '/path/b'], + '/workspace', + ); + }); + + it('should pass empty array when lfsStoragePaths is empty', async () => { + await runIndex({ + lfsTransferAgent: '/tools/agent', + lfsStoragePaths: '', + }); + + expect(mockLfsAgentService.configure).toHaveBeenCalledWith('/tools/agent', '', [], '/workspace'); + }); + }); + + // ----------------------------------------------------------------------- + // Order of operations (restore before build, save after build) + // ----------------------------------------------------------------------- + + describe('order of operations', () => { + it('should execute restore operations before build and save operations after build', async () => { + const callOrder: string[] = []; + + // Track call order for each relevant operation + mockChildWorkspaceService.buildConfig.mockReturnValue({ enabled: true, workspaceName: 'Test' }); + mockChildWorkspaceService.initializeWorkspace.mockImplementation(() => { + callOrder.push('child-workspace-restore'); + + return false; + }); + mockChildWorkspaceService.getWorkspaceSize.mockImplementation(() => { + callOrder.push('child-workspace-size'); + + return '0 B'; + }); + mockSubmoduleProfileService.createInitPlan.mockImplementation(async () => { + callOrder.push('submodule-profile-plan'); + + return []; + }); + mockSubmoduleProfileService.execute.mockImplementation(async () => { + callOrder.push('submodule-profile-execute'); + }); + mockLfsAgentService.configure.mockImplementation(async () => { + callOrder.push('lfs-agent-configure'); + }); + mockLocalCacheService.resolveCacheRoot.mockImplementation(() => { + callOrder.push('local-cache-resolve'); + + return '/cache'; + }); + mockLocalCacheService.generateCacheKey.mockImplementation(() => { + callOrder.push('local-cache-keygen'); + + return 'key-1'; + }); + mockLocalCacheService.restoreLfsCache.mockImplementation(async () => { + callOrder.push('local-cache-restore-lfs'); + + return true; + }); + mockLocalCacheService.restoreLibraryCache.mockImplementation(async () => { + callOrder.push('local-cache-restore-library'); + + return true; + }); + mockGitHooksService.installHooks.mockImplementation(async () => { + callOrder.push('git-hooks-install'); + }); + mockLocalCacheService.saveLibraryCache.mockImplementation(async () => { + callOrder.push('local-cache-save-library'); + }); + mockLocalCacheService.saveLfsCache.mockImplementation(async () => { + callOrder.push('local-cache-save-lfs'); + }); + mockChildWorkspaceService.saveWorkspace.mockImplementation(() => { + callOrder.push('child-workspace-save'); + }); + + await runIndex({ + childWorkspacesEnabled: true, + childWorkspaceName: 'TurnOfWar', + submoduleProfilePath: '/profile.yml', + lfsTransferAgent: '/tools/agent', + localCacheEnabled: true, + localCacheLfs: true, + localCacheLibrary: true, + gitHooksEnabled: true, + }); + + // Verify restore operations happen before save operations. + // The expected order from index.ts is: + // 1. Child workspace restore + // 2. Submodule profile init + // 3. LFS agent configure + // 4. Local cache restore (LFS then Library) + // 5. Git hooks install + // 6. [BUILD happens here - Docker.run or MacBuilder.run] + // 7. Local cache save (Library then LFS) + // 8. Child workspace save + + const restoreOps = [ + 'child-workspace-restore', + 'submodule-profile-plan', + 'submodule-profile-execute', + 'lfs-agent-configure', + 'local-cache-restore-lfs', + 'local-cache-restore-library', + 'git-hooks-install', + ]; + + const saveOps = ['local-cache-save-library', 'local-cache-save-lfs', 'child-workspace-save']; + + // All restore ops should appear before all save ops + for (const restoreOp of restoreOps) { + if (!callOrder.includes(restoreOp)) continue; // Skip if the operation wasn't called + for (const saveOp of saveOps) { + if (!callOrder.includes(saveOp)) continue; + expect(callOrder.indexOf(restoreOp)).toBeLessThan(callOrder.indexOf(saveOp)); + } + } + + // Child workspace save should be last + if (callOrder.includes('child-workspace-save') && callOrder.includes('local-cache-save-lfs')) { + expect(callOrder.indexOf('local-cache-save-lfs')).toBeLessThan(callOrder.indexOf('child-workspace-save')); + } + }); + }); + + // ----------------------------------------------------------------------- + // Non-local provider strategy + // ----------------------------------------------------------------------- + + describe('non-local provider strategy', () => { + it('should skip all enterprise features when providerStrategy is not local', async () => { + await runIndex({ + providerStrategy: 'aws', + childWorkspacesEnabled: true, + childWorkspaceName: 'Test', + submoduleProfilePath: '/profile.yml', + lfsTransferAgent: '/tools/agent', + localCacheEnabled: true, + gitHooksEnabled: true, + }); + + // None of the enterprise services should be called because + // they are inside the `if (providerStrategy === 'local')` block + expect(mockChildWorkspaceService.buildConfig).not.toHaveBeenCalled(); + expect(mockSubmoduleProfileService.createInitPlan).not.toHaveBeenCalled(); + expect(mockLfsAgentService.configure).not.toHaveBeenCalled(); + expect(mockLocalCacheService.resolveCacheRoot).not.toHaveBeenCalled(); + expect(mockGitHooksService.installHooks).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/model/enterprise-inputs.test.ts b/src/model/enterprise-inputs.test.ts new file mode 100644 index 00000000..05a97c98 --- /dev/null +++ b/src/model/enterprise-inputs.test.ts @@ -0,0 +1,509 @@ +/** + * Tests for enterprise input properties and their wiring into BuildParameters. + * + * Covers all 20 new input properties added for enterprise features: + * - Boolean inputs: localCacheEnabled, childWorkspacesEnabled, gitHooksEnabled, + * localCacheLibrary, localCacheLfs, childWorkspacePreserveGit, childWorkspaceSeparateLibrary + * - String inputs: submoduleProfilePath, submoduleVariantPath, submoduleToken, + * localCacheRoot, childWorkspaceName, childWorkspaceCacheRoot, lfsTransferAgent, + * lfsTransferAgentArgs, lfsStoragePaths, providerExecutable, gitHooksSkipList, + * gitHooksRunBeforeBuild + * + * Special attention to boolean inputs: GitHub Actions always passes inputs as strings, + * so 'false' must NOT evaluate as truthy (the #1 source of bugs). + */ + +import * as core from '@actions/core'; +import Input from './input'; +import Versioning from './versioning'; +import BuildParameters from './build-parameters'; + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +afterEach(() => { + jest.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Part 1: Input getters — defaults and explicit values +// --------------------------------------------------------------------------- + +describe('Enterprise Input properties', () => { + // ----------------------------------------------------------------------- + // Boolean inputs — default and string parsing + // ----------------------------------------------------------------------- + + describe('localCacheEnabled', () => { + it('returns false by default', () => { + expect(Input.localCacheEnabled).toBe(false); + }); + + it('returns true when string "true" is passed', () => { + jest.spyOn(core, 'getInput').mockReturnValue('true'); + expect(Input.localCacheEnabled).toBe(true); + }); + + it('returns false when string "false" is passed', () => { + jest.spyOn(core, 'getInput').mockReturnValue('false'); + expect(Input.localCacheEnabled).toBe(false); + }); + + it('returns false when empty string is passed', () => { + jest.spyOn(core, 'getInput').mockReturnValue(''); + expect(Input.localCacheEnabled).toBe(false); + }); + }); + + describe('localCacheLibrary', () => { + it('returns true by default (library caching on by default when cache enabled)', () => { + expect(Input.localCacheLibrary).toBe(true); + }); + + it('returns true when string "true" is passed', () => { + jest.spyOn(core, 'getInput').mockReturnValue('true'); + expect(Input.localCacheLibrary).toBe(true); + }); + + it('returns false when string "false" is passed', () => { + jest.spyOn(core, 'getInput').mockReturnValue('false'); + expect(Input.localCacheLibrary).toBe(false); + }); + }); + + describe('localCacheLfs', () => { + it('returns false by default', () => { + expect(Input.localCacheLfs).toBe(false); + }); + + it('returns true when string "true" is passed', () => { + jest.spyOn(core, 'getInput').mockReturnValue('true'); + expect(Input.localCacheLfs).toBe(true); + }); + + it('returns false when string "false" is passed', () => { + jest.spyOn(core, 'getInput').mockReturnValue('false'); + expect(Input.localCacheLfs).toBe(false); + }); + }); + + describe('childWorkspacesEnabled', () => { + it('returns false by default', () => { + expect(Input.childWorkspacesEnabled).toBe(false); + }); + + it('returns true when string "true" is passed', () => { + jest.spyOn(core, 'getInput').mockReturnValue('true'); + expect(Input.childWorkspacesEnabled).toBe(true); + }); + + it('returns false when string "false" is passed', () => { + jest.spyOn(core, 'getInput').mockReturnValue('false'); + expect(Input.childWorkspacesEnabled).toBe(false); + }); + + it('returns false when empty string is passed', () => { + jest.spyOn(core, 'getInput').mockReturnValue(''); + expect(Input.childWorkspacesEnabled).toBe(false); + }); + }); + + describe('childWorkspacePreserveGit', () => { + it('returns true by default', () => { + expect(Input.childWorkspacePreserveGit).toBe(true); + }); + + it('returns false when string "false" is passed', () => { + jest.spyOn(core, 'getInput').mockReturnValue('false'); + expect(Input.childWorkspacePreserveGit).toBe(false); + }); + + it('returns true when string "true" is passed', () => { + jest.spyOn(core, 'getInput').mockReturnValue('true'); + expect(Input.childWorkspacePreserveGit).toBe(true); + }); + }); + + describe('childWorkspaceSeparateLibrary', () => { + it('returns true by default', () => { + expect(Input.childWorkspaceSeparateLibrary).toBe(true); + }); + + it('returns false when string "false" is passed', () => { + jest.spyOn(core, 'getInput').mockReturnValue('false'); + expect(Input.childWorkspaceSeparateLibrary).toBe(false); + }); + + it('returns true when string "true" is passed', () => { + jest.spyOn(core, 'getInput').mockReturnValue('true'); + expect(Input.childWorkspaceSeparateLibrary).toBe(true); + }); + }); + + describe('gitHooksEnabled', () => { + it('returns false by default', () => { + expect(Input.gitHooksEnabled).toBe(false); + }); + + it('returns true when string "true" is passed', () => { + jest.spyOn(core, 'getInput').mockReturnValue('true'); + expect(Input.gitHooksEnabled).toBe(true); + }); + + it('returns false when string "false" is passed', () => { + jest.spyOn(core, 'getInput').mockReturnValue('false'); + expect(Input.gitHooksEnabled).toBe(false); + }); + + it('returns false when empty string is passed', () => { + jest.spyOn(core, 'getInput').mockReturnValue(''); + expect(Input.gitHooksEnabled).toBe(false); + }); + }); + + // ----------------------------------------------------------------------- + // Boolean truthiness edge cases — the #1 source of bugs + // ----------------------------------------------------------------------- + + describe('boolean input string handling (edge cases)', () => { + // These tests verify that the === 'true' comparison is correct. + // In JavaScript, 'false' is truthy when used in a boolean context, + // but the Input class correctly uses === 'true' comparison. + + const booleanInputs: Array<{ + name: string; + getter: () => boolean; + defaultValue: boolean; + }> = [ + { name: 'localCacheEnabled', getter: () => Input.localCacheEnabled, defaultValue: false }, + { name: 'localCacheLfs', getter: () => Input.localCacheLfs, defaultValue: false }, + { name: 'childWorkspacesEnabled', getter: () => Input.childWorkspacesEnabled, defaultValue: false }, + { name: 'gitHooksEnabled', getter: () => Input.gitHooksEnabled, defaultValue: false }, + + // These default to true: + { name: 'localCacheLibrary', getter: () => Input.localCacheLibrary, defaultValue: true }, + { name: 'childWorkspacePreserveGit', getter: () => Input.childWorkspacePreserveGit, defaultValue: true }, + { name: 'childWorkspaceSeparateLibrary', getter: () => Input.childWorkspaceSeparateLibrary, defaultValue: true }, + ]; + + test.each(booleanInputs)('$name: "false" string does NOT evaluate as truthy', ({ getter }) => { + jest.spyOn(core, 'getInput').mockReturnValue('false'); + expect(getter()).toBe(false); + }); + + test.each(booleanInputs)('$name: "true" string evaluates as truthy', ({ getter }) => { + jest.spyOn(core, 'getInput').mockReturnValue('true'); + expect(getter()).toBe(true); + }); + + test.each(booleanInputs)('$name: "TRUE" (uppercase) does NOT evaluate as true (case sensitive)', ({ getter }) => { + jest.spyOn(core, 'getInput').mockReturnValue('TRUE'); + expect(getter()).toBe(false); + }); + + test.each(booleanInputs)('$name: "1" does NOT evaluate as true', ({ getter }) => { + jest.spyOn(core, 'getInput').mockReturnValue('1'); + expect(getter()).toBe(false); + }); + + test.each(booleanInputs)('$name: "yes" does NOT evaluate as true', ({ getter }) => { + jest.spyOn(core, 'getInput').mockReturnValue('yes'); + expect(getter()).toBe(false); + }); + }); + + // ----------------------------------------------------------------------- + // String inputs — defaults and explicit values + // ----------------------------------------------------------------------- + + describe('submoduleProfilePath', () => { + it('returns empty string by default', () => { + expect(Input.submoduleProfilePath).toBe(''); + }); + + it('takes input from workflow', () => { + jest.spyOn(core, 'getInput').mockReturnValue('config/submodule-profiles/tow/ec/profile.yml'); + expect(Input.submoduleProfilePath).toBe('config/submodule-profiles/tow/ec/profile.yml'); + }); + }); + + describe('submoduleVariantPath', () => { + it('returns empty string by default', () => { + expect(Input.submoduleVariantPath).toBe(''); + }); + + it('takes input from workflow', () => { + jest.spyOn(core, 'getInput').mockReturnValue('config/submodule-profiles/tow/ec/server.yml'); + expect(Input.submoduleVariantPath).toBe('config/submodule-profiles/tow/ec/server.yml'); + }); + }); + + describe('submoduleToken', () => { + it('returns empty string by default', () => { + expect(Input.submoduleToken).toBe(''); + }); + + it('takes input from workflow', () => { + jest.spyOn(core, 'getInput').mockReturnValue('ghp_abc123'); + expect(Input.submoduleToken).toBe('ghp_abc123'); + }); + }); + + describe('localCacheRoot', () => { + it('returns empty string by default', () => { + expect(Input.localCacheRoot).toBe(''); + }); + + it('takes input from workflow', () => { + jest.spyOn(core, 'getInput').mockReturnValue('/d/cache/game-ci'); + expect(Input.localCacheRoot).toBe('/d/cache/game-ci'); + }); + }); + + describe('childWorkspaceName', () => { + it('returns empty string by default', () => { + expect(Input.childWorkspaceName).toBe(''); + }); + + it('takes input from workflow', () => { + jest.spyOn(core, 'getInput').mockReturnValue('TurnOfWarEndlessCrusade'); + expect(Input.childWorkspaceName).toBe('TurnOfWarEndlessCrusade'); + }); + }); + + describe('childWorkspaceCacheRoot', () => { + it('returns empty string by default', () => { + expect(Input.childWorkspaceCacheRoot).toBe(''); + }); + + it('takes input from workflow', () => { + jest.spyOn(core, 'getInput').mockReturnValue('/d/workspaces'); + expect(Input.childWorkspaceCacheRoot).toBe('/d/workspaces'); + }); + }); + + describe('lfsTransferAgent', () => { + it('returns empty string by default', () => { + expect(Input.lfsTransferAgent).toBe(''); + }); + + it('takes input from workflow', () => { + jest.spyOn(core, 'getInput').mockReturnValue('/tools/elastic-git-storage'); + expect(Input.lfsTransferAgent).toBe('/tools/elastic-git-storage'); + }); + }); + + describe('lfsTransferAgentArgs', () => { + it('returns empty string by default', () => { + expect(Input.lfsTransferAgentArgs).toBe(''); + }); + + it('takes input from workflow', () => { + jest.spyOn(core, 'getInput').mockReturnValue('--verbose --timeout=60'); + expect(Input.lfsTransferAgentArgs).toBe('--verbose --timeout=60'); + }); + }); + + describe('lfsStoragePaths', () => { + it('returns empty string by default', () => { + expect(Input.lfsStoragePaths).toBe(''); + }); + + it('takes input from workflow', () => { + jest.spyOn(core, 'getInput').mockReturnValue('/storage/primary;/storage/secondary'); + expect(Input.lfsStoragePaths).toBe('/storage/primary;/storage/secondary'); + }); + }); + + describe('providerExecutable', () => { + it('returns empty string by default', () => { + expect(Input.providerExecutable).toBe(''); + }); + + it('takes input from workflow', () => { + jest.spyOn(core, 'getInput').mockReturnValue('/usr/local/bin/custom-provider'); + expect(Input.providerExecutable).toBe('/usr/local/bin/custom-provider'); + }); + }); + + describe('gitHooksSkipList', () => { + it('returns empty string by default', () => { + expect(Input.gitHooksSkipList).toBe(''); + }); + + it('takes input from workflow', () => { + jest.spyOn(core, 'getInput').mockReturnValue('pre-commit,pre-push'); + expect(Input.gitHooksSkipList).toBe('pre-commit,pre-push'); + }); + }); + + describe('gitHooksRunBeforeBuild', () => { + it('returns empty string by default', () => { + expect(Input.gitHooksRunBeforeBuild).toBe(''); + }); + + it('takes input from workflow', () => { + jest.spyOn(core, 'getInput').mockReturnValue('pre-commit'); + expect(Input.gitHooksRunBeforeBuild).toBe('pre-commit'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Part 2: BuildParameters.create() maps new inputs to properties +// --------------------------------------------------------------------------- + +const testLicense = + '\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \nm0Db8UK+ktnOLJBtHybkfetpcKo=o/pUbSQAukz7+ZYAWhnA0AJbIlyyCPL7bKVEM2lVqbrXt7cyey+umkCXamuOgsWPVUKBMkXtMH8L\n5etLmD0getWIhTGhzOnDCk+gtIPfL4jMo9tkEuOCROQAXCci23VFscKcrkB+3X6h4wEOtA2APhOY\nB+wvC794o8/82ffjP79aVAi57rp3Wmzx+9pe9yMwoJuljAy2sc2tIMgdQGWVmOGBpQm3JqsidyzI\nJWG2kjnc7pDXK9pwYzXoKiqUqqrut90d+kQqRyv7MSZXR50HFqD/LI69h68b7P8Bjo3bPXOhNXGR\n9YCoemH6EkfCJxp2gIjzjWW+l2Hj2EsFQi8YXw=='; + +describe('BuildParameters.create() enterprise property mapping', () => { + beforeEach(() => { + jest.spyOn(Versioning, 'determineBuildVersion').mockImplementation(async () => '1.3.37'); + process.env.UNITY_LICENSE = testLicense; + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('maps submoduleProfilePath from Input', async () => { + jest.spyOn(Input, 'submoduleProfilePath', 'get').mockReturnValue('/path/to/profile.yml'); + const parameters = await BuildParameters.create(); + expect(parameters.submoduleProfilePath).toBe('/path/to/profile.yml'); + }); + + it('maps submoduleVariantPath from Input', async () => { + jest.spyOn(Input, 'submoduleVariantPath', 'get').mockReturnValue('/path/to/variant.yml'); + const parameters = await BuildParameters.create(); + expect(parameters.submoduleVariantPath).toBe('/path/to/variant.yml'); + }); + + it('maps submoduleToken from Input', async () => { + jest.spyOn(Input, 'submoduleToken', 'get').mockReturnValue('ghp_token123'); + const parameters = await BuildParameters.create(); + expect(parameters.submoduleToken).toBe('ghp_token123'); + }); + + it('maps localCacheEnabled from Input', async () => { + jest.spyOn(Input, 'localCacheEnabled', 'get').mockReturnValue(true); + const parameters = await BuildParameters.create(); + expect(parameters.localCacheEnabled).toBe(true); + }); + + it('maps localCacheRoot from Input', async () => { + jest.spyOn(Input, 'localCacheRoot', 'get').mockReturnValue('/d/cache'); + const parameters = await BuildParameters.create(); + expect(parameters.localCacheRoot).toBe('/d/cache'); + }); + + it('maps localCacheLibrary from Input', async () => { + jest.spyOn(Input, 'localCacheLibrary', 'get').mockReturnValue(false); + const parameters = await BuildParameters.create(); + expect(parameters.localCacheLibrary).toBe(false); + }); + + it('maps localCacheLfs from Input', async () => { + jest.spyOn(Input, 'localCacheLfs', 'get').mockReturnValue(true); + const parameters = await BuildParameters.create(); + expect(parameters.localCacheLfs).toBe(true); + }); + + it('maps childWorkspacesEnabled from Input', async () => { + jest.spyOn(Input, 'childWorkspacesEnabled', 'get').mockReturnValue(true); + const parameters = await BuildParameters.create(); + expect(parameters.childWorkspacesEnabled).toBe(true); + }); + + it('maps childWorkspaceName from Input', async () => { + jest.spyOn(Input, 'childWorkspaceName', 'get').mockReturnValue('TurnOfWar'); + const parameters = await BuildParameters.create(); + expect(parameters.childWorkspaceName).toBe('TurnOfWar'); + }); + + it('maps childWorkspaceCacheRoot from Input', async () => { + jest.spyOn(Input, 'childWorkspaceCacheRoot', 'get').mockReturnValue('/cache/workspaces'); + const parameters = await BuildParameters.create(); + expect(parameters.childWorkspaceCacheRoot).toBe('/cache/workspaces'); + }); + + it('maps childWorkspacePreserveGit from Input', async () => { + jest.spyOn(Input, 'childWorkspacePreserveGit', 'get').mockReturnValue(false); + const parameters = await BuildParameters.create(); + expect(parameters.childWorkspacePreserveGit).toBe(false); + }); + + it('maps childWorkspaceSeparateLibrary from Input', async () => { + jest.spyOn(Input, 'childWorkspaceSeparateLibrary', 'get').mockReturnValue(false); + const parameters = await BuildParameters.create(); + expect(parameters.childWorkspaceSeparateLibrary).toBe(false); + }); + + it('maps lfsTransferAgent from Input', async () => { + jest.spyOn(Input, 'lfsTransferAgent', 'get').mockReturnValue('/tools/elastic-git-storage'); + const parameters = await BuildParameters.create(); + expect(parameters.lfsTransferAgent).toBe('/tools/elastic-git-storage'); + }); + + it('maps lfsTransferAgentArgs from Input', async () => { + jest.spyOn(Input, 'lfsTransferAgentArgs', 'get').mockReturnValue('--verbose'); + const parameters = await BuildParameters.create(); + expect(parameters.lfsTransferAgentArgs).toBe('--verbose'); + }); + + it('maps lfsStoragePaths from Input', async () => { + jest.spyOn(Input, 'lfsStoragePaths', 'get').mockReturnValue('/path/a;/path/b'); + const parameters = await BuildParameters.create(); + expect(parameters.lfsStoragePaths).toBe('/path/a;/path/b'); + }); + + it('maps gitHooksEnabled from Input', async () => { + jest.spyOn(Input, 'gitHooksEnabled', 'get').mockReturnValue(true); + const parameters = await BuildParameters.create(); + expect(parameters.gitHooksEnabled).toBe(true); + }); + + it('maps gitHooksSkipList from Input', async () => { + jest.spyOn(Input, 'gitHooksSkipList', 'get').mockReturnValue('pre-commit,pre-push'); + const parameters = await BuildParameters.create(); + expect(parameters.gitHooksSkipList).toBe('pre-commit,pre-push'); + }); + + it('maps gitHooksRunBeforeBuild from Input', async () => { + jest.spyOn(Input, 'gitHooksRunBeforeBuild', 'get').mockReturnValue('pre-commit'); + const parameters = await BuildParameters.create(); + expect(parameters.gitHooksRunBeforeBuild).toBe('pre-commit'); + }); + + it('maps providerExecutable from Input', async () => { + jest.spyOn(Input, 'providerExecutable', 'get').mockReturnValue('/usr/local/bin/provider'); + const parameters = await BuildParameters.create(); + expect(parameters.providerExecutable).toBe('/usr/local/bin/provider'); + }); + + // Test that all enterprise properties have correct defaults when not explicitly set + it('has correct defaults for all enterprise properties', async () => { + const parameters = await BuildParameters.create(); + + expect(parameters.submoduleProfilePath).toBe(''); + expect(parameters.submoduleVariantPath).toBe(''); + expect(parameters.submoduleToken).toBe(''); + expect(parameters.localCacheEnabled).toBe(false); + expect(parameters.localCacheRoot).toBe(''); + expect(parameters.localCacheLibrary).toBe(true); + expect(parameters.localCacheLfs).toBe(false); + expect(parameters.childWorkspacesEnabled).toBe(false); + expect(parameters.childWorkspaceName).toBe(''); + expect(parameters.childWorkspaceCacheRoot).toBe(''); + expect(parameters.childWorkspacePreserveGit).toBe(true); + expect(parameters.childWorkspaceSeparateLibrary).toBe(true); + expect(parameters.lfsTransferAgent).toBe(''); + expect(parameters.lfsTransferAgentArgs).toBe(''); + expect(parameters.lfsStoragePaths).toBe(''); + expect(parameters.gitHooksEnabled).toBe(false); + expect(parameters.gitHooksSkipList).toBe(''); + expect(parameters.gitHooksRunBeforeBuild).toBe(''); + expect(parameters.providerExecutable).toBe(''); + }); +}); diff --git a/src/model/orchestrator/services/submodule/submodule-profile-service.test.ts b/src/model/orchestrator/services/submodule/submodule-profile-service.test.ts index 0068b328..4e113bf5 100644 --- a/src/model/orchestrator/services/submodule/submodule-profile-service.test.ts +++ b/src/model/orchestrator/services/submodule/submodule-profile-service.test.ts @@ -83,6 +83,7 @@ submodules: mockedFs.readFileSync.mockImplementation((filePath: any) => { if (filePath === '/base.yml') return baseYaml; if (filePath === '/variant.yml') return variantYaml; + return ''; }); @@ -196,6 +197,7 @@ submodules: mockedFs.readFileSync.mockImplementation((filePath: any) => { if (String(filePath).endsWith('profile.yml')) return profileYaml; if (String(filePath).endsWith('.gitmodules')) return gitmodulesContent; + return ''; }); @@ -249,6 +251,7 @@ submodules: if (p.endsWith('profile.yml')) return profileYaml; if (p.endsWith('variant.yml')) return variantYaml; if (p.endsWith('.gitmodules')) return gitmodulesContent; + return ''; }); @@ -309,4 +312,74 @@ submodules: expect(mockedSystem.Run).not.toHaveBeenCalledWith(expect.stringContaining('git config url')); }); }); + + describe('execute — command construction safety', () => { + it('constructs expected git commands for a standard path', async () => { + mockedSystem.Run.mockResolvedValue(''); + + const plan = [ + { + name: 'Assets/_Game/Submodules/TurnOfWar', + path: 'Assets/_Game/Submodules/TurnOfWar', + branch: 'main', + action: 'init' as const, + }, + ]; + + await SubmoduleProfileService.execute(plan, '/repo'); + + expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule update --init Assets/_Game/Submodules/TurnOfWar'); + }); + + it('passes path directly into git commands (paths with spaces are not quoted)', async () => { + mockedSystem.Run.mockResolvedValue(''); + + // This test documents current behavior: paths are passed as-is. + // If a path contained shell metacharacters, they would be passed through. + // This is acceptable because submodule paths come from .gitmodules (trusted source), + // not from user input. Still, this test documents the behavior for awareness. + const plan = [ + { name: 'Module With Spaces', path: 'Assets/Module With Spaces', branch: 'main', action: 'init' as const }, + ]; + + await SubmoduleProfileService.execute(plan, '/repo'); + + // The current implementation passes the path directly — no shell quoting + expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule update --init Assets/Module With Spaces'); + }); + + it('passes branch name directly into git checkout command', async () => { + mockedSystem.Run.mockResolvedValue(''); + + // Document that branch names are passed as-is into shell commands. + // Branch names come from the trusted profile YAML, not user input. + const plan = [{ name: 'ModuleX', path: 'Assets/ModuleX', branch: 'feature/my-branch', action: 'init' as const }]; + + await SubmoduleProfileService.execute(plan, '/repo'); + + expect(mockedSystem.Run).toHaveBeenCalledWith('git -C Assets/ModuleX checkout feature/my-branch'); + }); + + it('constructs deinit command with error suppression for skip actions', async () => { + mockedSystem.Run.mockResolvedValue(''); + + const plan = [{ name: 'Unused', path: 'Assets/Unused', branch: 'empty', action: 'skip' as const }]; + + await SubmoduleProfileService.execute(plan, '/repo'); + + expect(mockedSystem.Run).toHaveBeenCalledWith('git submodule deinit -f Assets/Unused 2>/dev/null || true'); + }); + + it('injects token into git config URL insteadOf pattern', async () => { + mockedSystem.Run.mockResolvedValue(''); + + // The token is embedded directly into the URL pattern. + // This is the standard Git credential approach for CI. + await SubmoduleProfileService.execute([], '/repo', 'ghp_abc123xyz'); + + const configCall = mockedSystem.Run.mock.calls.find((call: any[]) => String(call[0]).includes('git config url')); + expect(configCall).toBeDefined(); + expect(configCall![0]).toBe('git config url."https://ghp_abc123xyz@github.com/".insteadOf "https://github.com/"'); + }); + }); });