From ff56194b3011afc248d68c17d33a50510eb9b028 Mon Sep 17 00:00:00 2001 From: frostebite Date: Thu, 5 Mar 2026 11:47:14 +0000 Subject: [PATCH] test(providers): add comprehensive unit tests for GitHub Actions, GitLab CI, PowerShell, and Ansible providers (#806) Co-Authored-By: Claude Opus 4.6 --- .../ansible/ansible-provider.test.ts | 270 ++++++++++++++++ .../github-actions-provider.test.ts | 295 ++++++++++++++++++ .../gitlab-ci/gitlab-ci-provider.test.ts | 294 +++++++++++++++++ .../providers/provider-selection.test.ts | 166 ++++++++++ .../remote-powershell-provider.test.ts | 241 ++++++++++++++ 5 files changed, 1266 insertions(+) create mode 100644 src/model/orchestrator/providers/ansible/ansible-provider.test.ts create mode 100644 src/model/orchestrator/providers/github-actions/github-actions-provider.test.ts create mode 100644 src/model/orchestrator/providers/gitlab-ci/gitlab-ci-provider.test.ts create mode 100644 src/model/orchestrator/providers/provider-selection.test.ts create mode 100644 src/model/orchestrator/providers/remote-powershell/remote-powershell-provider.test.ts diff --git a/src/model/orchestrator/providers/ansible/ansible-provider.test.ts b/src/model/orchestrator/providers/ansible/ansible-provider.test.ts new file mode 100644 index 00000000..9f83e6f9 --- /dev/null +++ b/src/model/orchestrator/providers/ansible/ansible-provider.test.ts @@ -0,0 +1,270 @@ +import AnsibleProvider from '.'; +import BuildParameters from '../../../build-parameters'; +import { OrchestratorSystem } from '../../services/core/orchestrator-system'; +import OrchestratorLogger from '../../services/core/orchestrator-logger'; + +jest.mock('../../services/core/orchestrator-system'); +jest.mock('../../services/core/orchestrator-logger'); + +const mockRun = OrchestratorSystem.Run as jest.MockedFunction; +const mockLog = OrchestratorLogger.log as jest.MockedFunction; +const mockLogWarning = OrchestratorLogger.logWarning as jest.MockedFunction; + +function createBuildParameters(overrides: Partial = {}): BuildParameters { + return { + ansibleInventory: '/etc/ansible/hosts', + ansiblePlaybook: '/playbooks/unity-build.yml', + ansibleExtraVars: '', + ansibleVaultPassword: '', + ...overrides, + } as BuildParameters; +} + +describe('AnsibleProvider', () => { + let provider: AnsibleProvider; + + beforeEach(() => { + jest.clearAllMocks(); + provider = new AnsibleProvider(createBuildParameters()); + }); + + describe('constructor', () => { + it('initializes with all provided parameters', () => { + const params = createBuildParameters({ + ansibleInventory: '/custom/inventory', + ansiblePlaybook: '/custom/playbook.yml', + ansibleExtraVars: '{"key":"value"}', + ansibleVaultPassword: '/vault/pass', + }); + const p = new AnsibleProvider(params); + expect(p).toBeDefined(); + }); + + it('handles missing optional parameters gracefully', () => { + const params = createBuildParameters({ + ansiblePlaybook: undefined, + ansibleExtraVars: undefined, + ansibleVaultPassword: undefined, + }); + const p = new AnsibleProvider(params); + expect(p).toBeDefined(); + }); + }); + + describe('setupWorkflow', () => { + it('verifies ansible binary is available and inventory exists', async () => { + mockRun.mockResolvedValueOnce('ansible [core 2.14.0]'); // ansible --version + mockRun.mockResolvedValueOnce(''); // test -e inventory + + await provider.setupWorkflow('guid-123', createBuildParameters(), 'main', []); + + expect(mockRun).toHaveBeenCalledTimes(2); + expect(mockRun.mock.calls[0][0]).toContain('ansible --version'); + expect(mockRun.mock.calls[1][0]).toContain('test -e "/etc/ansible/hosts"'); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('ansible')); + }); + + it('throws when inventory is not configured', async () => { + const params = createBuildParameters({ ansibleInventory: '' }); + provider = new AnsibleProvider(params); + + await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow( + 'ansibleInventory is required', + ); + }); + + it('throws when ansible binary is not found on PATH', async () => { + mockRun.mockRejectedValueOnce(new Error('command not found: ansible')); + + await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow( + 'Ansible not found on PATH', + ); + }); + + it('throws when inventory file does not exist', async () => { + mockRun.mockResolvedValueOnce('ansible [core 2.14.0]'); // ansible version OK + mockRun.mockRejectedValueOnce(new Error('test -e failed')); // inventory missing + + await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow( + 'Inventory not found: /etc/ansible/hosts', + ); + }); + }); + + describe('runTaskInWorkflow', () => { + it('constructs ansible-playbook command with correct variables and returns output', async () => { + mockRun.mockResolvedValueOnce('PLAY [build] *****\nok: [server1]\nPLAY RECAP'); + + const result = await provider.runTaskInWorkflow( + 'guid-run1', + 'unityci/editor:2021.3', + 'echo build', + '/mount', + '/workspace', + [], + [], + ); + + expect(result).toContain('PLAY [build]'); + + const command = mockRun.mock.calls[0][0]; + expect(command).toContain('ansible-playbook'); + expect(command).toContain('-i "/etc/ansible/hosts"'); + expect(command).toContain('"/playbooks/unity-build.yml"'); + expect(command).toContain('--no-color'); + expect(command).toContain('build_guid'); + expect(command).toContain('guid-run1'); + expect(command).toContain('build_image'); + expect(command).toContain('unityci/editor:2021.3'); + expect(command).toContain('build_commands'); + expect(command).toContain('mount_dir'); + expect(command).toContain('working_dir'); + }); + + it('throws when playbook is not configured', async () => { + const params = createBuildParameters({ ansiblePlaybook: '' }); + provider = new AnsibleProvider(params); + + await expect( + provider.runTaskInWorkflow('guid-nopb', 'img', 'cmd', '/m', '/w', [], []), + ).rejects.toThrow('ansiblePlaybook is required'); + }); + + it('passes environment variables as extra-vars in snake_case', async () => { + mockRun.mockResolvedValueOnce('ok'); + + const env = [ + { name: 'UNITY_LICENSE', value: 'lic-data' }, + { name: 'BUILD_TARGET', value: 'Linux64' }, + ]; + + await provider.runTaskInWorkflow('guid-env', 'img', 'cmd', '/m', '/w', env as any, []); + + const command = mockRun.mock.calls[0][0]; + // Environment variable names are lowercased as Ansible variables + expect(command).toContain('unity_license'); + expect(command).toContain('lic-data'); + expect(command).toContain('build_target'); + expect(command).toContain('Linux64'); + }); + + it('merges user-provided extra vars from JSON string', async () => { + const params = createBuildParameters({ + ansibleExtraVars: JSON.stringify({ custom_var: 'custom_value', another: '42' }), + }); + provider = new AnsibleProvider(params); + mockRun.mockResolvedValueOnce('ok'); + + await provider.runTaskInWorkflow('guid-extra', 'img', 'cmd', '/m', '/w', [], []); + + const command = mockRun.mock.calls[0][0]; + expect(command).toContain('custom_var'); + expect(command).toContain('custom_value'); + expect(command).toContain('another'); + }); + + it('logs warning when extra vars JSON is invalid but continues', async () => { + const params = createBuildParameters({ ansibleExtraVars: 'not-valid-json{{{' }); + provider = new AnsibleProvider(params); + mockRun.mockResolvedValueOnce('ok'); + + await provider.runTaskInWorkflow('guid-badjson', 'img', 'cmd', '/m', '/w', [], []); + + expect(mockLogWarning).toHaveBeenCalledWith( + expect.stringContaining('Failed to parse ansibleExtraVars'), + ); + }); + + it('includes vault password file flag when configured', async () => { + const params = createBuildParameters({ ansibleVaultPassword: '/secure/vault-pass.txt' }); + provider = new AnsibleProvider(params); + mockRun.mockResolvedValueOnce('ok'); + + await provider.runTaskInWorkflow('guid-vault', 'img', 'cmd', '/m', '/w', [], []); + + const command = mockRun.mock.calls[0][0]; + expect(command).toContain('--vault-password-file "/secure/vault-pass.txt"'); + }); + + it('does not include vault password flag when not configured', async () => { + mockRun.mockResolvedValueOnce('ok'); + + await provider.runTaskInWorkflow('guid-novault', 'img', 'cmd', '/m', '/w', [], []); + + const command = mockRun.mock.calls[0][0]; + expect(command).not.toContain('--vault-password-file'); + }); + + it('prefixes secrets as environment variables in the command', async () => { + mockRun.mockResolvedValueOnce('ok'); + + const secrets = [ + { ParameterKey: 'key1', EnvironmentVariable: 'SECRET_TOKEN', ParameterValue: 'tok-abc' }, + { ParameterKey: 'key2', EnvironmentVariable: 'DEPLOY_KEY', ParameterValue: 'dk-xyz' }, + ]; + + await provider.runTaskInWorkflow('guid-secrets', 'img', 'cmd', '/m', '/w', [], secrets as any); + + const command = mockRun.mock.calls[0][0]; + expect(command).toMatch(/^SECRET_TOKEN='tok-abc'/); + expect(command).toContain("DEPLOY_KEY='dk-xyz'"); + expect(command).toContain('ansible-playbook'); + }); + + it('throws and logs warning when playbook execution fails', async () => { + const execError = new Error('UNREACHABLE! Host unreachable'); + mockRun.mockRejectedValueOnce(execError); + + await expect( + provider.runTaskInWorkflow('guid-hostfail', 'img', 'cmd', '/m', '/w', [], []), + ).rejects.toThrow('UNREACHABLE'); + + expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Playbook failed')); + }); + }); + + describe('cleanupWorkflow', () => { + it('completes without error and logs cleanup message', async () => { + await provider.cleanupWorkflow(createBuildParameters(), 'main', []); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Cleanup complete')); + }); + }); + + describe('garbageCollect', () => { + it('returns empty string (no-op)', async () => { + const result = await provider.garbageCollect('', false, 0, false, false); + expect(result).toBe(''); + }); + }); + + describe('listResources', () => { + it('returns inventory path as a resource when configured', async () => { + const resources = await provider.listResources(); + + expect(resources).toHaveLength(1); + expect(resources[0].Name).toBe('/etc/ansible/hosts'); + }); + + it('returns empty array when inventory is not configured', async () => { + const params = createBuildParameters({ ansibleInventory: '' }); + provider = new AnsibleProvider(params); + + const resources = await provider.listResources(); + expect(resources).toEqual([]); + }); + }); + + describe('listWorkflow', () => { + it('returns empty array (not implemented)', async () => { + const workflows = await provider.listWorkflow(); + expect(workflows).toEqual([]); + }); + }); + + describe('watchWorkflow', () => { + it('returns empty string (not implemented)', async () => { + const result = await provider.watchWorkflow(); + expect(result).toBe(''); + }); + }); +}); diff --git a/src/model/orchestrator/providers/github-actions/github-actions-provider.test.ts b/src/model/orchestrator/providers/github-actions/github-actions-provider.test.ts new file mode 100644 index 00000000..952fb5b5 --- /dev/null +++ b/src/model/orchestrator/providers/github-actions/github-actions-provider.test.ts @@ -0,0 +1,295 @@ +import GitHubActionsProvider from '.'; +import BuildParameters from '../../../build-parameters'; +import { OrchestratorSystem } from '../../services/core/orchestrator-system'; +import OrchestratorLogger from '../../services/core/orchestrator-logger'; + +jest.mock('../../services/core/orchestrator-system'); +jest.mock('../../services/core/orchestrator-logger'); + +const mockRun = OrchestratorSystem.Run as jest.MockedFunction; +const mockLog = OrchestratorLogger.log as jest.MockedFunction; + +function createBuildParameters(overrides: Partial = {}): BuildParameters { + return { + githubActionsRepo: 'owner/repo', + githubActionsWorkflow: 'build.yml', + githubActionsToken: 'ghp_test_token_123', + githubActionsRef: 'main', + ...overrides, + } as BuildParameters; +} + +// Override setTimeout to execute callbacks immediately so polling loops complete fast +const originalSetTimeout = global.setTimeout; +beforeAll(() => { + global.setTimeout = ((fn: (...args: any[]) => void, _ms?: number, ...args: any[]) => { + return originalSetTimeout(fn, 0, ...args); + }) as any; +}); +afterAll(() => { + global.setTimeout = originalSetTimeout; +}); + +describe('GitHubActionsProvider', () => { + let provider: GitHubActionsProvider; + + beforeEach(() => { + jest.clearAllMocks(); + provider = new GitHubActionsProvider(createBuildParameters()); + }); + + describe('constructor', () => { + it('sets default ref to main when not specified', () => { + const params = createBuildParameters({ githubActionsRef: undefined }); + const p = new GitHubActionsProvider(params); + expect(p).toBeDefined(); + }); + + it('uses provided ref when specified', () => { + const params = createBuildParameters({ githubActionsRef: 'develop' }); + const p = new GitHubActionsProvider(params); + expect(p).toBeDefined(); + }); + }); + + describe('setupWorkflow', () => { + it('verifies workflow exists via gh api and logs success', async () => { + mockRun.mockResolvedValueOnce('12345\n'); + + await provider.setupWorkflow('guid-123', createBuildParameters(), 'main', []); + + expect(mockRun).toHaveBeenCalledTimes(1); + const command = mockRun.mock.calls[0][0]; + expect(command).toContain('gh api repos/owner/repo/actions/workflows/build.yml'); + expect(command).toContain("--jq '.id'"); + expect(command).toContain('GH_TOKEN=ghp_test_token_123'); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Workflow verified')); + }); + + it('throws when repo is not configured', async () => { + const params = createBuildParameters({ githubActionsRepo: '' }); + provider = new GitHubActionsProvider(params); + + await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow( + 'githubActionsRepo and githubActionsWorkflow are required', + ); + }); + + it('throws when workflow is not configured', async () => { + const params = createBuildParameters({ githubActionsWorkflow: '' }); + provider = new GitHubActionsProvider(params); + + await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow( + 'githubActionsRepo and githubActionsWorkflow are required', + ); + }); + + it('throws when token is missing', async () => { + const params = createBuildParameters({ githubActionsToken: '' }); + provider = new GitHubActionsProvider(params); + + await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow( + 'githubActionsToken is required', + ); + }); + + it('throws descriptive error when workflow verification fails', async () => { + mockRun.mockRejectedValueOnce(new Error('Not Found')); + + await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow( + 'Failed to verify workflow build.yml in owner/repo', + ); + }); + }); + + describe('runTaskInWorkflow', () => { + it('dispatches workflow with correct inputs and returns logs on success', async () => { + // Dispatch succeeds + mockRun.mockResolvedValueOnce(''); + // First poll finds the run + mockRun.mockResolvedValueOnce(JSON.stringify({ id: 99001, status: 'in_progress' })); + // Status poll returns completed + mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'success' })); + // Log fetch succeeds + mockRun.mockResolvedValueOnce('Build output log content here'); + + const result = await provider.runTaskInWorkflow( + 'guid-abc', + 'unityci/editor:2021.3', + 'echo build', + '/mount', + '/work', + [], + [], + ); + + expect(result).toBe('Build output log content here'); + + // Verify dispatch command + const dispatchCommand = mockRun.mock.calls[0][0]; + expect(dispatchCommand).toContain('dispatches'); + expect(dispatchCommand).toContain('-X POST'); + expect(dispatchCommand).toContain("ref='main'"); + + // Verify log fetch command + const logCommand = mockRun.mock.calls[3][0]; + expect(logCommand).toContain('gh run view'); + expect(logCommand).toContain('--log'); + expect(logCommand).toContain('--repo owner/repo'); + }); + + it('base64 encodes commands in the inputs payload', async () => { + mockRun.mockResolvedValueOnce(''); // dispatch + mockRun.mockResolvedValueOnce(JSON.stringify({ id: 100, status: 'completed' })); // run found + mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'success' })); // status + mockRun.mockResolvedValueOnce('logs'); // logs + + await provider.runTaskInWorkflow('guid-1', 'image:latest', 'echo hello && build', '/mnt', '/w', [], []); + + const dispatchCommand = mockRun.mock.calls[0][0]; + const expectedB64 = Buffer.from('echo hello && build').toString('base64'); + expect(dispatchCommand).toContain(expectedB64); + }); + + it('includes environment variables as JSON input', async () => { + mockRun.mockResolvedValueOnce(''); // dispatch + mockRun.mockResolvedValueOnce(JSON.stringify({ id: 200, status: 'completed' })); // run found + mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'success' })); // status + mockRun.mockResolvedValueOnce('logs'); // logs + + const env = [ + { name: 'UNITY_LICENSE', value: 'license-data' }, + { name: 'BUILD_TARGET', value: 'StandaloneWindows64' }, + ]; + + await provider.runTaskInWorkflow('guid-2', 'img', 'cmd', '/m', '/w', env as any, []); + + const dispatchCommand = mockRun.mock.calls[0][0]; + expect(dispatchCommand).toContain('UNITY_LICENSE'); + expect(dispatchCommand).toContain('BUILD_TARGET'); + }); + + it('throws when workflow dispatch fails', async () => { + mockRun.mockRejectedValueOnce(new Error('403 Forbidden')); + + await expect(provider.runTaskInWorkflow('guid-err', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow( + 'Failed to dispatch workflow', + ); + }); + + it('throws when workflow run does not start within timeout', async () => { + mockRun.mockResolvedValueOnce(''); // dispatch succeeds + + // All 30 poll attempts fail + for (let i = 0; i < 30; i++) { + mockRun.mockRejectedValueOnce(new Error('not found')); + } + + await expect( + provider.runTaskInWorkflow('guid-timeout', 'img', 'cmd', '/m', '/w', [], []), + ).rejects.toThrow('Workflow run did not start within'); + }); + + it('throws when workflow run fails with non-success conclusion', async () => { + mockRun.mockResolvedValueOnce(''); // dispatch + mockRun.mockResolvedValueOnce(JSON.stringify({ id: 300, status: 'in_progress' })); // run appears + mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'failure' })); // fails + + await expect( + provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], []), + ).rejects.toThrow('Workflow run failed with conclusion: failure'); + }); + + it('returns fallback message when log fetch fails', async () => { + mockRun.mockResolvedValueOnce(''); // dispatch + mockRun.mockResolvedValueOnce(JSON.stringify({ id: 400, status: 'completed' })); // run appears + mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'success' })); // completes + mockRun.mockRejectedValueOnce(new Error('logs unavailable')); // log fetch fails + + const result = await provider.runTaskInWorkflow('guid-nologs', 'img', 'cmd', '/m', '/w', [], []); + + expect(result).toContain('completed successfully'); + expect(result).toContain('logs unavailable'); + }); + + it('handles cancelled workflow run conclusion', async () => { + mockRun.mockResolvedValueOnce(''); // dispatch + mockRun.mockResolvedValueOnce(JSON.stringify({ id: 500, status: 'in_progress' })); // run + mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'completed', conclusion: 'cancelled' })); // cancelled + + await expect( + provider.runTaskInWorkflow('guid-cancel', 'img', 'cmd', '/m', '/w', [], []), + ).rejects.toThrow('Workflow run failed with conclusion: cancelled'); + }); + }); + + describe('cleanupWorkflow', () => { + it('completes without error and logs cleanup message', async () => { + await provider.cleanupWorkflow(createBuildParameters(), 'main', []); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Cleanup complete')); + }); + }); + + describe('garbageCollect', () => { + it('returns empty string (no-op)', async () => { + const result = await provider.garbageCollect('', false, 0, false, false); + expect(result).toBe(''); + }); + }); + + describe('listResources', () => { + it('returns runner names from the repository', async () => { + mockRun.mockResolvedValueOnce('runner-1\nrunner-2\nrunner-3\n'); + + const resources = await provider.listResources(); + + expect(resources).toHaveLength(3); + expect(resources[0].Name).toBe('runner-1'); + expect(resources[1].Name).toBe('runner-2'); + expect(resources[2].Name).toBe('runner-3'); + }); + + it('returns empty array when repo or token is missing', async () => { + const params = createBuildParameters({ githubActionsRepo: '' }); + provider = new GitHubActionsProvider(params); + + const resources = await provider.listResources(); + expect(resources).toEqual([]); + expect(mockRun).not.toHaveBeenCalled(); + }); + + it('returns empty array when API call fails', async () => { + mockRun.mockRejectedValueOnce(new Error('API error')); + + const resources = await provider.listResources(); + expect(resources).toEqual([]); + }); + }); + + describe('listWorkflow', () => { + it('returns recent workflow run names', async () => { + mockRun.mockResolvedValueOnce('Build Unity\nRun Tests\n'); + + const workflows = await provider.listWorkflow(); + + expect(workflows).toHaveLength(2); + expect(workflows[0].Name).toBe('Build Unity'); + expect(workflows[1].Name).toBe('Run Tests'); + }); + + it('returns empty array when credentials missing', async () => { + const params = createBuildParameters({ githubActionsToken: '' }); + provider = new GitHubActionsProvider(params); + + const workflows = await provider.listWorkflow(); + expect(workflows).toEqual([]); + }); + }); + + describe('watchWorkflow', () => { + it('returns message when no active run exists', async () => { + const result = await provider.watchWorkflow(); + expect(result).toBe('No active run to watch'); + }); + }); +}); diff --git a/src/model/orchestrator/providers/gitlab-ci/gitlab-ci-provider.test.ts b/src/model/orchestrator/providers/gitlab-ci/gitlab-ci-provider.test.ts new file mode 100644 index 00000000..943d890b --- /dev/null +++ b/src/model/orchestrator/providers/gitlab-ci/gitlab-ci-provider.test.ts @@ -0,0 +1,294 @@ +import GitLabCIProvider from '.'; +import BuildParameters from '../../../build-parameters'; +import { OrchestratorSystem } from '../../services/core/orchestrator-system'; +import OrchestratorLogger from '../../services/core/orchestrator-logger'; + +jest.mock('../../services/core/orchestrator-system'); +jest.mock('../../services/core/orchestrator-logger'); + +const mockRun = OrchestratorSystem.Run as jest.MockedFunction; +const mockLog = OrchestratorLogger.log as jest.MockedFunction; +const mockLogWarning = OrchestratorLogger.logWarning as jest.MockedFunction; + +function createBuildParameters(overrides: Partial = {}): BuildParameters { + return { + gitlabProjectId: 'my-group/my-project', + gitlabTriggerToken: 'glptt-test-token-456', + gitlabApiUrl: 'https://gitlab.example.com', + gitlabRef: 'main', + ...overrides, + } as BuildParameters; +} + +// Override setTimeout to execute callbacks immediately so polling loops complete fast +const originalSetTimeout = global.setTimeout; +beforeAll(() => { + global.setTimeout = ((fn: (...args: any[]) => void, _ms?: number, ...args: any[]) => { + return originalSetTimeout(fn, 0, ...args); + }) as any; +}); +afterAll(() => { + global.setTimeout = originalSetTimeout; +}); + +describe('GitLabCIProvider', () => { + let provider: GitLabCIProvider; + + beforeEach(() => { + jest.clearAllMocks(); + provider = new GitLabCIProvider(createBuildParameters()); + }); + + describe('constructor', () => { + it('strips trailing slashes from apiUrl', () => { + const params = createBuildParameters({ gitlabApiUrl: 'https://gitlab.example.com///' }); + const p = new GitLabCIProvider(params); + expect(p).toBeDefined(); + }); + + it('defaults apiUrl to https://gitlab.com when not provided', () => { + const params = createBuildParameters({ gitlabApiUrl: undefined }); + const p = new GitLabCIProvider(params); + expect(p).toBeDefined(); + }); + + it('defaults ref to main when not provided', () => { + const params = createBuildParameters({ gitlabRef: undefined }); + const p = new GitLabCIProvider(params); + expect(p).toBeDefined(); + }); + }); + + describe('setupWorkflow', () => { + it('verifies project access via curl and logs success', async () => { + mockRun.mockResolvedValueOnce(''); + + await provider.setupWorkflow('guid-123', createBuildParameters(), 'main', []); + + expect(mockRun).toHaveBeenCalledTimes(1); + const command = mockRun.mock.calls[0][0]; + expect(command).toContain('curl -sf'); + expect(command).toContain('PRIVATE-TOKEN: glptt-test-token-456'); + expect(command).toContain('gitlab.example.com/api/v4/projects/'); + expect(command).toContain(encodeURIComponent('my-group/my-project')); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Project access verified')); + }); + + it('throws when projectId is not configured', async () => { + const params = createBuildParameters({ gitlabProjectId: '' }); + provider = new GitLabCIProvider(params); + + await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow( + 'gitlabProjectId and gitlabTriggerToken are required', + ); + }); + + it('throws when triggerToken is not configured', async () => { + const params = createBuildParameters({ gitlabTriggerToken: '' }); + provider = new GitLabCIProvider(params); + + await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow( + 'gitlabProjectId and gitlabTriggerToken are required', + ); + }); + + it('throws descriptive error when project access check fails', async () => { + mockRun.mockRejectedValueOnce(new Error('401 Unauthorized')); + + await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow( + 'Failed to access GitLab project my-group/my-project', + ); + }); + }); + + describe('runTaskInWorkflow', () => { + it('triggers pipeline and returns job logs on success', async () => { + // Pipeline trigger response + mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5001, status: 'pending' })); + // Status poll returns success + mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' })); + // Jobs list + mockRun.mockResolvedValueOnce( + JSON.stringify([ + { id: 10001, name: 'build-unity', status: 'success' }, + { id: 10002, name: 'test-unity', status: 'success' }, + ]), + ); + // Job traces + mockRun.mockResolvedValueOnce('Building Unity project...\nDone.'); + mockRun.mockResolvedValueOnce('Running tests...\nAll passed.'); + + const result = await provider.runTaskInWorkflow( + 'guid-gl1', + 'unityci/editor:2021.3', + 'echo build', + '/mount', + '/work', + [], + [], + ); + + expect(result).toContain('build-unity'); + expect(result).toContain('test-unity'); + expect(result).toContain('Building Unity project'); + expect(result).toContain('Running tests'); + + // Verify trigger command + const triggerCommand = mockRun.mock.calls[0][0]; + expect(triggerCommand).toContain('trigger/pipeline'); + expect(triggerCommand).toContain(`token=${createBuildParameters().gitlabTriggerToken}`); + expect(triggerCommand).toContain('ref=main'); + }); + + it('passes build variables including base64-encoded commands', async () => { + mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5002, status: 'success' })); + mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' })); + mockRun.mockResolvedValueOnce(JSON.stringify([])); + + await provider.runTaskInWorkflow( + 'guid-vars', + 'ubuntu:20.04', + 'make build', + '/mnt/data', + '/workspace', + [{ name: 'UNITY_VERSION', value: '2021.3.1f1' } as any], + [], + ); + + const triggerCommand = mockRun.mock.calls[0][0]; + const expectedB64 = Buffer.from('make build').toString('base64'); + expect(triggerCommand).toContain(`variables[BUILD_COMMANDS]=${expectedB64}`); + expect(triggerCommand).toContain('variables[BUILD_GUID]=guid-vars'); + expect(triggerCommand).toContain('variables[BUILD_IMAGE]=ubuntu:20.04'); + expect(triggerCommand).toContain('variables[MOUNT_DIR]=/mnt/data'); + expect(triggerCommand).toContain('variables[WORKING_DIR]=/workspace'); + expect(triggerCommand).toContain('variables[UNITY_VERSION]=2021.3.1f1'); + }); + + it('throws when pipeline trigger fails', async () => { + mockRun.mockRejectedValueOnce(new Error('404 Not Found')); + + await expect(provider.runTaskInWorkflow('guid-err', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow( + 'Failed to trigger pipeline', + ); + }); + + it('throws when pipeline finishes with failure status', async () => { + mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5003, status: 'pending' })); + mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'failed' })); + + await expect( + provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], []), + ).rejects.toThrow('Pipeline 5003 finished with status: failed'); + }); + + it('throws when pipeline is canceled', async () => { + mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5004, status: 'pending' })); + mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'canceled' })); + + await expect( + provider.runTaskInWorkflow('guid-cancel', 'img', 'cmd', '/m', '/w', [], []), + ).rejects.toThrow('Pipeline 5004 finished with status: canceled'); + }); + + it('handles job log fetch failures gracefully', async () => { + mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5005, status: 'success' })); + mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' })); + mockRun.mockResolvedValueOnce(JSON.stringify([{ id: 20001, name: 'build', status: 'success' }])); + // Job trace fetch fails + mockRun.mockRejectedValueOnce(new Error('trace unavailable')); + + const result = await provider.runTaskInWorkflow('guid-nologs', 'img', 'cmd', '/m', '/w', [], []); + + expect(result).toContain('build'); + expect(result).toContain('logs unavailable'); + }); + + it('returns fallback message when entire job fetch fails', async () => { + mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5006, status: 'success' })); + mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' })); + // Jobs list fails + mockRun.mockRejectedValueOnce(new Error('API error')); + + const result = await provider.runTaskInWorkflow('guid-noapi', 'img', 'cmd', '/m', '/w', [], []); + + expect(result).toContain('Pipeline 5006 completed successfully'); + expect(result).toContain('logs unavailable'); + }); + + it('continues polling through status check errors until completion', async () => { + mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5007, status: 'pending' })); + // First status check fails + mockRun.mockRejectedValueOnce(new Error('network blip')); + // Second status check succeeds + mockRun.mockResolvedValueOnce(JSON.stringify({ status: 'success' })); + // Jobs/logs + mockRun.mockResolvedValueOnce(JSON.stringify([])); + + await provider.runTaskInWorkflow('guid-retry', 'img', 'cmd', '/m', '/w', [], []); + + expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Status check error')); + }); + }); + + describe('cleanupWorkflow', () => { + it('completes without error and logs cleanup message', async () => { + await provider.cleanupWorkflow(createBuildParameters(), 'main', []); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Cleanup complete')); + }); + }); + + describe('garbageCollect', () => { + it('returns empty string (no-op)', async () => { + const result = await provider.garbageCollect('', false, 0, false, false); + expect(result).toBe(''); + }); + }); + + describe('listResources', () => { + it('returns empty array (not implemented)', async () => { + const resources = await provider.listResources(); + expect(resources).toEqual([]); + }); + }); + + describe('listWorkflow', () => { + it('returns recent pipeline names when credentials are available', async () => { + mockRun.mockResolvedValueOnce( + JSON.stringify([ + { id: 100, status: 'success' }, + { id: 101, status: 'failed' }, + ]), + ); + + const workflows = await provider.listWorkflow(); + + expect(workflows).toHaveLength(2); + expect(workflows[0].Name).toBe('Pipeline #100 (success)'); + expect(workflows[1].Name).toBe('Pipeline #101 (failed)'); + }); + + it('returns empty array when credentials are missing', async () => { + const params = createBuildParameters({ gitlabProjectId: '' }); + provider = new GitLabCIProvider(params); + + const workflows = await provider.listWorkflow(); + expect(workflows).toEqual([]); + expect(mockRun).not.toHaveBeenCalled(); + }); + + it('returns empty array when API call fails', async () => { + mockRun.mockRejectedValueOnce(new Error('API error')); + + const workflows = await provider.listWorkflow(); + expect(workflows).toEqual([]); + }); + }); + + describe('watchWorkflow', () => { + it('returns empty string (not implemented)', async () => { + const result = await provider.watchWorkflow(); + expect(result).toBe(''); + }); + }); +}); diff --git a/src/model/orchestrator/providers/provider-selection.test.ts b/src/model/orchestrator/providers/provider-selection.test.ts new file mode 100644 index 00000000..d1ba9c00 --- /dev/null +++ b/src/model/orchestrator/providers/provider-selection.test.ts @@ -0,0 +1,166 @@ +import BuildParameters from '../../build-parameters'; +import RemotePowershellProvider from './remote-powershell'; +import GitHubActionsProvider from './github-actions'; +import GitLabCIProvider from './gitlab-ci'; +import AnsibleProvider from './ansible'; + +/** + * Tests for provider selection logic in Orchestrator.setProvider. + * + * These tests verify that the correct provider class is instantiated based on + * the providerStrategy field in BuildParameters. Rather than invoking the full + * Orchestrator.setProvider (which has heavy dependencies on OrchestratorOptions, + * AWS detection, etc.), we test the provider constructors directly to verify + * they produce the right provider type from the same build parameters the + * orchestrator switch statement uses. + */ +describe('Provider Selection', () => { + describe('remote-powershell provider', () => { + it('creates RemotePowershellProvider from build parameters', () => { + const params = { + providerStrategy: 'remote-powershell', + remotePowershellHost: 'build-server.local', + remotePowershellTransport: 'wsman', + remotePowershellCredential: 'user:pass', + } as BuildParameters; + + const provider = new RemotePowershellProvider(params); + + expect(provider).toBeInstanceOf(RemotePowershellProvider); + expect(provider.constructor.name).toBe('RemotePowershellProvider'); + }); + }); + + describe('github-actions provider', () => { + it('creates GitHubActionsProvider from build parameters', () => { + const params = { + providerStrategy: 'github-actions', + githubActionsRepo: 'org/repo', + githubActionsWorkflow: 'ci.yml', + githubActionsToken: 'ghp_token', + githubActionsRef: 'main', + } as BuildParameters; + + const provider = new GitHubActionsProvider(params); + + expect(provider).toBeInstanceOf(GitHubActionsProvider); + expect(provider.constructor.name).toBe('GitHubActionsProvider'); + }); + }); + + describe('gitlab-ci provider', () => { + it('creates GitLabCIProvider from build parameters', () => { + const params = { + providerStrategy: 'gitlab-ci', + gitlabProjectId: 'group/project', + gitlabTriggerToken: 'glptt-token', + gitlabApiUrl: 'https://gitlab.com', + gitlabRef: 'main', + } as BuildParameters; + + const provider = new GitLabCIProvider(params); + + expect(provider).toBeInstanceOf(GitLabCIProvider); + expect(provider.constructor.name).toBe('GitLabCIProvider'); + }); + }); + + describe('ansible provider', () => { + it('creates AnsibleProvider from build parameters', () => { + const params = { + providerStrategy: 'ansible', + ansibleInventory: '/etc/ansible/hosts', + ansiblePlaybook: '/playbooks/build.yml', + ansibleExtraVars: '', + ansibleVaultPassword: '', + } as BuildParameters; + + const provider = new AnsibleProvider(params); + + expect(provider).toBeInstanceOf(AnsibleProvider); + expect(provider.constructor.name).toBe('AnsibleProvider'); + }); + }); + + describe('provider strategy routing', () => { + it('each provider strategy maps to a distinct provider class', () => { + const strategies: Record any> = { + 'remote-powershell': RemotePowershellProvider, + 'github-actions': GitHubActionsProvider, + 'gitlab-ci': GitLabCIProvider, + ansible: AnsibleProvider, + }; + + const params = { + remotePowershellHost: 'host', + remotePowershellTransport: 'wsman', + remotePowershellCredential: '', + githubActionsRepo: 'org/repo', + githubActionsWorkflow: 'ci.yml', + githubActionsToken: 'token', + githubActionsRef: 'main', + gitlabProjectId: 'proj', + gitlabTriggerToken: 'tok', + gitlabApiUrl: 'https://gitlab.com', + gitlabRef: 'main', + ansibleInventory: '/inv', + ansiblePlaybook: '/pb.yml', + ansibleExtraVars: '', + ansibleVaultPassword: '', + } as BuildParameters; + + const instances = Object.entries(strategies).map(([strategy, ProviderClass]) => { + const provider = new ProviderClass(params); + return { strategy, className: provider.constructor.name }; + }); + + // Verify all four strategies produce different provider classes + const classNames = instances.map((i) => i.className); + const uniqueClassNames = new Set(classNames); + expect(uniqueClassNames.size).toBe(4); + + // Verify expected mapping + expect(instances.find((i) => i.strategy === 'remote-powershell')!.className).toBe( + 'RemotePowershellProvider', + ); + expect(instances.find((i) => i.strategy === 'github-actions')!.className).toBe('GitHubActionsProvider'); + expect(instances.find((i) => i.strategy === 'gitlab-ci')!.className).toBe('GitLabCIProvider'); + expect(instances.find((i) => i.strategy === 'ansible')!.className).toBe('AnsibleProvider'); + }); + + it('all providers implement ProviderInterface methods', () => { + const params = { + remotePowershellHost: 'host', + githubActionsRepo: 'org/repo', + githubActionsWorkflow: 'ci.yml', + githubActionsToken: 'token', + gitlabProjectId: 'proj', + gitlabTriggerToken: 'tok', + ansibleInventory: '/inv', + } as BuildParameters; + + const providers = [ + new RemotePowershellProvider(params), + new GitHubActionsProvider(params), + new GitLabCIProvider(params), + new AnsibleProvider(params), + ]; + + const requiredMethods = [ + 'setupWorkflow', + 'runTaskInWorkflow', + 'cleanupWorkflow', + 'garbageCollect', + 'listResources', + 'listWorkflow', + 'watchWorkflow', + ]; + + for (const provider of providers) { + for (const method of requiredMethods) { + expect(typeof (provider as any)[method]).toBe('function'); + } + } + }); + }); +}); diff --git a/src/model/orchestrator/providers/remote-powershell/remote-powershell-provider.test.ts b/src/model/orchestrator/providers/remote-powershell/remote-powershell-provider.test.ts new file mode 100644 index 00000000..be56de5a --- /dev/null +++ b/src/model/orchestrator/providers/remote-powershell/remote-powershell-provider.test.ts @@ -0,0 +1,241 @@ +import RemotePowershellProvider from '.'; +import BuildParameters from '../../../build-parameters'; +import { OrchestratorSystem } from '../../services/core/orchestrator-system'; +import OrchestratorLogger from '../../services/core/orchestrator-logger'; + +jest.mock('../../services/core/orchestrator-system'); +jest.mock('../../services/core/orchestrator-logger'); + +const mockRun = OrchestratorSystem.Run as jest.MockedFunction; +const mockLog = OrchestratorLogger.log as jest.MockedFunction; +const mockLogWarning = OrchestratorLogger.logWarning as jest.MockedFunction; + +function createBuildParameters(overrides: Partial = {}): BuildParameters { + return { + remotePowershellHost: 'build-server-01.internal', + remotePowershellTransport: 'wsman', + remotePowershellCredential: 'admin:P@ssw0rd!', + ...overrides, + } as BuildParameters; +} + +describe('RemotePowershellProvider', () => { + let provider: RemotePowershellProvider; + + beforeEach(() => { + jest.clearAllMocks(); + provider = new RemotePowershellProvider(createBuildParameters()); + }); + + describe('constructor', () => { + it('defaults transport to wsman when not specified', () => { + const params = createBuildParameters({ remotePowershellTransport: undefined }); + const p = new RemotePowershellProvider(params); + expect(p).toBeDefined(); + }); + + it('accepts ssh transport', () => { + const params = createBuildParameters({ remotePowershellTransport: 'ssh' }); + const p = new RemotePowershellProvider(params); + expect(p).toBeDefined(); + }); + }); + + describe('setupWorkflow', () => { + it('tests WinRM connectivity via Test-WSMan and logs success', async () => { + mockRun.mockResolvedValueOnce('wsman output'); + + await provider.setupWorkflow('guid-123', createBuildParameters(), 'main', []); + + expect(mockRun).toHaveBeenCalledTimes(1); + const command = mockRun.mock.calls[0][0]; + expect(command).toContain('pwsh -NoProfile -NonInteractive'); + expect(command).toContain('Test-WSMan'); + expect(command).toContain('build-server-01.internal'); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Connection test passed')); + }); + + it('sets session ID to the build GUID', async () => { + mockRun.mockResolvedValueOnce(''); + + await provider.setupWorkflow('my-build-guid', createBuildParameters(), 'main', []); + + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('my-build-guid')); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('ready')); + }); + + it('throws when host is not configured', async () => { + const params = createBuildParameters({ remotePowershellHost: '' }); + provider = new RemotePowershellProvider(params); + + await expect(provider.setupWorkflow('guid-123', params, 'main', [])).rejects.toThrow( + 'remotePowershellHost is required', + ); + }); + + it('throws descriptive error when connectivity test fails', async () => { + mockRun.mockRejectedValueOnce(new Error('WinRM service not running')); + + await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow( + 'Failed to connect to remote host build-server-01.internal', + ); + }); + }); + + describe('runTaskInWorkflow', () => { + it('constructs WinRM Invoke-Command with credential and returns output', async () => { + mockRun.mockResolvedValueOnce('Build succeeded!'); + + const result = await provider.runTaskInWorkflow( + 'guid-run1', + 'unused-image', + 'Unity.exe -batchmode -buildTarget Win64', + '/mount', + 'C:\\Projects\\MyGame', + [], + [], + ); + + expect(result).toBe('Build succeeded!'); + + const command = mockRun.mock.calls[0][0]; + expect(command).toContain('pwsh -NoProfile -NonInteractive'); + expect(command).toContain("Invoke-Command -ComputerName 'build-server-01.internal'"); + expect(command).toContain('-Credential'); + expect(command).toContain('New-Object PSCredential'); + expect(command).toContain('-ScriptBlock'); + expect(command).toContain('Set-Location'); + }); + + it('constructs SSH Invoke-Command when transport is ssh', async () => { + const params = createBuildParameters({ remotePowershellTransport: 'ssh' }); + provider = new RemotePowershellProvider(params); + mockRun.mockResolvedValueOnce('SSH build output'); + + const result = await provider.runTaskInWorkflow('guid-ssh', 'img', 'build', '/m', '/w', [], []); + + expect(result).toBe('SSH build output'); + + const command = mockRun.mock.calls[0][0]; + expect(command).toContain("Invoke-Command -HostName 'build-server-01.internal'"); + expect(command).not.toContain('-ComputerName'); + expect(command).not.toContain('-Credential'); + }); + + it('includes environment variables in the remote script block', async () => { + mockRun.mockResolvedValueOnce('output'); + + const env = [ + { name: 'UNITY_LICENSE', value: 'license-data-abc' }, + { name: 'BUILD_TARGET', value: 'StandaloneWindows64' }, + ]; + + await provider.runTaskInWorkflow('guid-env', 'img', 'build-cmd', '/m', '/w', env as any, []); + + const command = mockRun.mock.calls[0][0]; + expect(command).toContain('$env:UNITY_LICENSE'); + expect(command).toContain('$env:BUILD_TARGET'); + }); + + it('includes secrets in the remote script block', async () => { + mockRun.mockResolvedValueOnce('output'); + + const secrets = [ + { ParameterKey: 'key1', EnvironmentVariable: 'SECRET_KEY', ParameterValue: 'secret-val-123' }, + ]; + + await provider.runTaskInWorkflow('guid-sec', 'img', 'build-cmd', '/m', '/w', [], secrets as any); + + const command = mockRun.mock.calls[0][0]; + expect(command).toContain('$env:SECRET_KEY'); + }); + + it('does not include credential in plaintext log output when using WinRM', async () => { + mockRun.mockResolvedValueOnce('output'); + + await provider.runTaskInWorkflow('guid-cred', 'img', 'cmd', '/m', '/w', [], []); + + // The credential is used via ConvertTo-SecureString, not logged directly + const command = mockRun.mock.calls[0][0]; + expect(command).toContain('ConvertTo-SecureString'); + expect(command).toContain('-AsPlainText -Force'); + }); + + it('omits credential part when no credential is configured (WinRM)', async () => { + const params = createBuildParameters({ remotePowershellCredential: '' }); + provider = new RemotePowershellProvider(params); + mockRun.mockResolvedValueOnce('output'); + + await provider.runTaskInWorkflow('guid-nocred', 'img', 'cmd', '/m', '/w', [], []); + + const command = mockRun.mock.calls[0][0]; + expect(command).toContain("Invoke-Command -ComputerName 'build-server-01.internal'"); + expect(command).not.toContain('-Credential'); + expect(command).not.toContain('PSCredential'); + }); + + it('throws and logs warning when remote execution fails', async () => { + const execError = new Error('Remote execution failed: access denied'); + mockRun.mockRejectedValueOnce(execError); + + await expect( + provider.runTaskInWorkflow('guid-fail', 'img', 'cmd', '/m', '/w', [], []), + ).rejects.toThrow('Remote execution failed'); + + expect(mockLogWarning).toHaveBeenCalledWith(expect.stringContaining('Task failed')); + }); + + it('sets working directory in the remote script', async () => { + mockRun.mockResolvedValueOnce('output'); + + await provider.runTaskInWorkflow('guid-wd', 'img', 'cmd', '/m', 'D:\\Builds\\Project', [], []); + + const command = mockRun.mock.calls[0][0]; + expect(command).toContain('Set-Location'); + expect(command).toContain('D:\\Builds\\Project'); + }); + }); + + describe('cleanupWorkflow', () => { + it('completes without error and logs session cleanup', async () => { + // Setup first to set sessionId + mockRun.mockResolvedValueOnce(''); + await provider.setupWorkflow('guid-cleanup', createBuildParameters(), 'main', []); + + await provider.cleanupWorkflow(createBuildParameters(), 'main', []); + + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Cleaning up session')); + }); + }); + + describe('garbageCollect', () => { + it('returns empty string and logs not-supported message', async () => { + const result = await provider.garbageCollect('', false, 0, false, false); + expect(result).toBe(''); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('not supported')); + }); + }); + + describe('listResources', () => { + it('returns the configured host as a resource', async () => { + const resources = await provider.listResources(); + + expect(resources).toHaveLength(1); + expect(resources[0].Name).toBe('build-server-01.internal'); + }); + }); + + describe('listWorkflow', () => { + it('returns empty array (not implemented)', async () => { + const workflows = await provider.listWorkflow(); + expect(workflows).toEqual([]); + }); + }); + + describe('watchWorkflow', () => { + it('returns empty string (not implemented)', async () => { + const result = await provider.watchWorkflow(); + expect(result).toBe(''); + }); + }); +});