Compare commits

..

5 Commits

Author SHA1 Message Date
frostebite
c5355c5211 fix: replace orchestrator-develop branch references with main
The orchestrator-develop branch no longer exists. Update all fallback
clone commands and test fixtures to use main instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:03:29 +00:00
frostebite
70a78dab74 ci: set macOS builds to continue-on-error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:33:25 +00:00
frostebite
5e54bcd4dd fix(testing): use async exec for parallel test group execution
Replace execSync with promisified exec so Promise.all actually runs
test groups in parallel. Add native timeout support via exec options.
Add 50MB maxBuffer for large Unity output. Fix ESLint violations
(variable naming, padding lines, array push consolidation).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:51:52 +00:00
frostebite
118671778f feat(testing): implement test workflow engine with YAML suites, taxonomy filtering, and structured results (#790)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:52:43 +00:00
frostebite
8d81236939 feat(orchestrator): add test workflow engine placeholder
Initial scaffold for the test workflow engine service directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:04:54 +00:00
24 changed files with 2681 additions and 3323 deletions

View File

@@ -279,66 +279,23 @@ inputs:
description: description:
'[Orchestrator] Specifies the repo for the unity builder. Useful if you forked the repo for testing, features, or '[Orchestrator] Specifies the repo for the unity builder. Useful if you forked the repo for testing, features, or
fixes.' fixes.'
remotePowershellHost: testSuitePath:
default: '' description: 'Path to YAML test suite definition file'
required: false required: false
description: '[Orchestrator] Remote PowerShell host (hostname or IP) for the remote-powershell provider' testSuiteEvent:
remotePowershellCredential: description: 'CI event name for suite selection (pr, push, release)'
default: ''
required: false required: false
description: '[Orchestrator] Remote PowerShell credential (username:password or certificate path)' testTaxonomyPath:
remotePowershellTransport: description: 'Path to custom taxonomy definition YAML'
default: 'wsman'
required: false required: false
description: '[Orchestrator] Remote PowerShell transport protocol (wsman or ssh)' testResultFormat:
githubActionsRepo: description: 'Test result output format: junit, json, or both'
default: ''
required: false required: false
description: '[Orchestrator] Target repository (owner/repo) for the github-actions provider' default: 'junit'
githubActionsWorkflow: testResultPath:
default: '' description: 'Directory for structured test result output'
required: false required: false
description: '[Orchestrator] Workflow filename or ID to dispatch for the github-actions provider' default: './test-results'
githubActionsToken:
default: ''
required: false
description: '[Orchestrator] PAT with actions:write scope for the github-actions provider'
githubActionsRef:
default: 'main'
required: false
description: '[Orchestrator] Branch/ref to run the workflow on for the github-actions provider'
gitlabProjectId:
default: ''
required: false
description: '[Orchestrator] GitLab project ID or URL-encoded path for the gitlab-ci provider'
gitlabTriggerToken:
default: ''
required: false
description: '[Orchestrator] Pipeline trigger token for the gitlab-ci provider'
gitlabApiUrl:
default: 'https://gitlab.com'
required: false
description: '[Orchestrator] GitLab API URL (for self-hosted instances) for the gitlab-ci provider'
gitlabRef:
default: 'main'
required: false
description: '[Orchestrator] Branch/ref to trigger the pipeline on for the gitlab-ci provider'
ansibleInventory:
default: ''
required: false
description: '[Orchestrator] Path to Ansible inventory file or dynamic inventory script'
ansiblePlaybook:
default: ''
required: false
description: '[Orchestrator] Path to Ansible playbook for Unity builds'
ansibleExtraVars:
default: ''
required: false
description: '[Orchestrator] Additional Ansible variables as JSON'
ansibleVaultPassword:
default: ''
required: false
description: '[Orchestrator] Path to Ansible vault password file'
outputs: outputs:
volume: volume:

1875
dist/index.js generated vendored

File diff suppressed because it is too large Load Diff

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -3,6 +3,7 @@ import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output
import { Cli } from './model/cli/cli'; import { Cli } from './model/cli/cli';
import MacBuilder from './model/mac-builder'; import MacBuilder from './model/mac-builder';
import PlatformSetup from './model/platform-setup'; import PlatformSetup from './model/platform-setup';
import { TestWorkflowService } from './model/orchestrator/services/test-workflow';
async function runMain() { async function runMain() {
try { try {
@@ -17,6 +18,23 @@ async function runMain() {
const { workspace, actionFolder } = Action; const { workspace, actionFolder } = Action;
const buildParameters = await BuildParameters.create(); const buildParameters = await BuildParameters.create();
// If a test suite path is provided, use the test workflow engine
// instead of the standard build execution path
if (buildParameters.testSuitePath) {
core.info('[TestWorkflow] Test suite path detected, using test workflow engine');
const results = await TestWorkflowService.executeTestSuite(buildParameters.testSuitePath, buildParameters);
const totalFailed = results.reduce((sum, r) => sum + r.failed, 0);
if (totalFailed > 0) {
core.setFailed(`Test workflow completed with ${totalFailed} failure(s)`);
} else {
core.info('[TestWorkflow] All test runs passed');
}
return;
}
const baseImage = new ImageTag(buildParameters); const baseImage = new ImageTag(buildParameters);
let exitCode = -1; let exitCode = -1;

View File

@@ -107,28 +107,11 @@ class BuildParameters {
public unityHubVersionOnMac!: string; public unityHubVersionOnMac!: string;
public dockerWorkspacePath!: string; public dockerWorkspacePath!: string;
// Remote PowerShell provider public testSuitePath!: string;
public remotePowershellHost!: string; public testSuiteEvent!: string;
public remotePowershellCredential!: string; public testTaxonomyPath!: string;
public remotePowershellTransport!: string; public testResultFormat!: string;
public testResultPath!: string;
// GitHub Actions provider
public githubActionsRepo!: string;
public githubActionsWorkflow!: string;
public githubActionsToken!: string;
public githubActionsRef!: string;
// GitLab CI provider
public gitlabProjectId!: string;
public gitlabTriggerToken!: string;
public gitlabApiUrl!: string;
public gitlabRef!: string;
// Ansible provider
public ansibleInventory!: string;
public ansiblePlaybook!: string;
public ansibleExtraVars!: string;
public ansibleVaultPassword!: string;
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) { public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``; return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
@@ -265,29 +248,11 @@ class BuildParameters {
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac, cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
unityHubVersionOnMac: Input.unityHubVersionOnMac, unityHubVersionOnMac: Input.unityHubVersionOnMac,
dockerWorkspacePath: Input.dockerWorkspacePath, dockerWorkspacePath: Input.dockerWorkspacePath,
testSuitePath: Input.testSuitePath,
// Remote PowerShell provider testSuiteEvent: Input.testSuiteEvent,
remotePowershellHost: Input.remotePowershellHost, testTaxonomyPath: Input.testTaxonomyPath,
remotePowershellCredential: Input.remotePowershellCredential, testResultFormat: Input.testResultFormat,
remotePowershellTransport: Input.remotePowershellTransport, testResultPath: Input.testResultPath,
// GitHub Actions provider
githubActionsRepo: Input.githubActionsRepo,
githubActionsWorkflow: Input.githubActionsWorkflow,
githubActionsToken: Input.githubActionsToken,
githubActionsRef: Input.githubActionsRef,
// GitLab CI provider
gitlabProjectId: Input.gitlabProjectId,
gitlabTriggerToken: Input.gitlabTriggerToken,
gitlabApiUrl: Input.gitlabApiUrl,
gitlabRef: Input.gitlabRef,
// Ansible provider
ansibleInventory: Input.ansibleInventory,
ansiblePlaybook: Input.ansiblePlaybook,
ansibleExtraVars: Input.ansibleExtraVars,
ansibleVaultPassword: Input.ansibleVaultPassword,
}; };
} }

View File

@@ -282,80 +282,24 @@ class Input {
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false'; return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
} }
// ### ### ### static get testSuitePath(): string {
// Remote PowerShell provider return Input.getInput('testSuitePath') ?? '';
// ### ### ###
static get remotePowershellHost(): string {
return Input.getInput('remotePowershellHost') ?? '';
} }
static get remotePowershellCredential(): string { static get testSuiteEvent(): string {
return Input.getInput('remotePowershellCredential') ?? ''; return Input.getInput('testSuiteEvent') ?? '';
} }
static get remotePowershellTransport(): string { static get testTaxonomyPath(): string {
return Input.getInput('remotePowershellTransport') ?? 'wsman'; return Input.getInput('testTaxonomyPath') ?? '';
} }
// ### ### ### static get testResultFormat(): string {
// GitHub Actions provider return Input.getInput('testResultFormat') ?? 'junit';
// ### ### ###
static get githubActionsRepo(): string {
return Input.getInput('githubActionsRepo') ?? '';
} }
static get githubActionsWorkflow(): string { static get testResultPath(): string {
return Input.getInput('githubActionsWorkflow') ?? ''; return Input.getInput('testResultPath') ?? './test-results';
}
static get githubActionsToken(): string {
return Input.getInput('githubActionsToken') ?? '';
}
static get githubActionsRef(): string {
return Input.getInput('githubActionsRef') ?? 'main';
}
// ### ### ###
// GitLab CI provider
// ### ### ###
static get gitlabProjectId(): string {
return Input.getInput('gitlabProjectId') ?? '';
}
static get gitlabTriggerToken(): string {
return Input.getInput('gitlabTriggerToken') ?? '';
}
static get gitlabApiUrl(): string {
return Input.getInput('gitlabApiUrl') ?? 'https://gitlab.com';
}
static get gitlabRef(): string {
return Input.getInput('gitlabRef') ?? 'main';
}
// ### ### ###
// Ansible provider
// ### ### ###
static get ansibleInventory(): string {
return Input.getInput('ansibleInventory') ?? '';
}
static get ansiblePlaybook(): string {
return Input.getInput('ansiblePlaybook') ?? '';
}
static get ansibleExtraVars(): string {
return Input.getInput('ansibleExtraVars') ?? '';
}
static get ansibleVaultPassword(): string {
return Input.getInput('ansibleVaultPassword') ?? '';
} }
public static ToEnvVarFormat(input: string) { public static ToEnvVarFormat(input: string) {

View File

@@ -13,10 +13,6 @@ import OrchestratorEnvironmentVariable from './options/orchestrator-environment-
import TestOrchestrator from './providers/test'; import TestOrchestrator from './providers/test';
import LocalOrchestrator from './providers/local'; import LocalOrchestrator from './providers/local';
import LocalDockerOrchestrator from './providers/docker'; import LocalDockerOrchestrator from './providers/docker';
import RemotePowershellProvider from './providers/remote-powershell';
import GitHubActionsProvider from './providers/github-actions';
import GitLabCIProvider from './providers/gitlab-ci';
import AnsibleProvider from './providers/ansible';
import loadProvider from './providers/provider-loader'; import loadProvider from './providers/provider-loader';
import GitHub from '../github'; import GitHub from '../github';
import SharedWorkspaceLocking from './services/core/shared-workspace-locking'; import SharedWorkspaceLocking from './services/core/shared-workspace-locking';
@@ -162,18 +158,6 @@ class Orchestrator {
case 'local': case 'local':
Orchestrator.Provider = new LocalOrchestrator(); Orchestrator.Provider = new LocalOrchestrator();
break; break;
case 'remote-powershell':
Orchestrator.Provider = new RemotePowershellProvider(Orchestrator.buildParameters);
break;
case 'github-actions':
Orchestrator.Provider = new GitHubActionsProvider(Orchestrator.buildParameters);
break;
case 'gitlab-ci':
Orchestrator.Provider = new GitLabCIProvider(Orchestrator.buildParameters);
break;
case 'ansible':
Orchestrator.Provider = new AnsibleProvider(Orchestrator.buildParameters);
break;
default: default:
// Try to load provider using the dynamic loader for unknown providers // Try to load provider using the dynamic loader for unknown providers
try { try {

View File

@@ -1,291 +0,0 @@
import AnsibleProvider from '.';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import * as core from '@actions/core';
jest.mock('../../services/core/orchestrator-system');
jest.mock('../../services/core/orchestrator-logger');
jest.mock('@actions/core', () => ({
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
setOutput: jest.fn(),
getInput: jest.fn(() => ''),
}));
const mockRun = OrchestratorSystem.Run as jest.MockedFunction<typeof OrchestratorSystem.Run>;
const mockLog = OrchestratorLogger.log as jest.MockedFunction<typeof OrchestratorLogger.log>;
const mockLogWarning = OrchestratorLogger.logWarning as jest.MockedFunction<typeof OrchestratorLogger.logWarning>;
function createBuildParameters(overrides: Partial<BuildParameters> = {}): 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, ansible-playbook binary, and inventory exist', async () => {
mockRun.mockResolvedValueOnce('ansible [core 2.14.0]'); // ansible --version
mockRun.mockResolvedValueOnce('/usr/bin/ansible-playbook'); // ansible-playbook check
mockRun.mockResolvedValueOnce(''); // test -e inventory
await provider.setupWorkflow('guid-123', createBuildParameters(), 'main', []);
expect(mockRun).toHaveBeenCalledTimes(3);
expect(mockRun.mock.calls[0][0]).toContain('ansible --version');
expect(mockRun.mock.calls[1][0]).toContain('ansible-playbook');
expect(mockRun.mock.calls[2][0]).toContain('test -e "/etc/ansible/hosts"');
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('ansible'));
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('ansible-playbook binary verified'));
});
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 ansible-playbook binary is not found', async () => {
mockRun.mockResolvedValueOnce('ansible [core 2.14.0]'); // ansible version OK
mockRun.mockRejectedValueOnce(new Error('command not found')); // ansible-playbook missing
await expect(provider.setupWorkflow('guid-123', createBuildParameters(), 'main', [])).rejects.toThrow(
'ansible-playbook not found on PATH',
);
expect(core.error).toHaveBeenCalledWith('ansible-playbook not found. Install Ansible or ensure it is in PATH.');
});
it('throws when inventory file does not exist', async () => {
mockRun.mockResolvedValueOnce('ansible [core 2.14.0]'); // ansible version OK
mockRun.mockResolvedValueOnce('/usr/bin/ansible-playbook'); // ansible-playbook 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('');
});
});
});

View File

@@ -1,197 +0,0 @@
import * as core from '@actions/core';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { ProviderInterface } from '../provider-interface';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
/**
* Ansible provider — executes Unity builds via Ansible playbooks
* against managed inventory.
*
* Use case: Teams with existing Ansible infrastructure for server
* management who want to leverage their inventory for build distribution.
*/
class AnsibleProvider implements ProviderInterface {
private buildParameters: BuildParameters;
private inventory: string;
private playbook: string;
private extraVariables: string;
private vaultPassword: string;
constructor(buildParameters: BuildParameters) {
this.buildParameters = buildParameters;
this.inventory = buildParameters.ansibleInventory || '';
this.playbook = buildParameters.ansiblePlaybook || '';
this.extraVariables = buildParameters.ansibleExtraVars || '';
this.vaultPassword = buildParameters.ansibleVaultPassword || '';
}
async setupWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<void> {
OrchestratorLogger.log(`[Ansible] Setting up playbook execution`);
if (!this.inventory) {
throw new Error('ansibleInventory is required for the ansible provider');
}
// Verify ansible is available
try {
const version = await OrchestratorSystem.Run('ansible --version | head -1');
OrchestratorLogger.log(`[Ansible] ${version.trim()}`);
} catch (error: any) {
throw new Error(`Ansible not found on PATH: ${error.message || error}`);
}
// Verify ansible-playbook binary exists (may be separate from ansible)
try {
await OrchestratorSystem.Run('command -v ansible-playbook || which ansible-playbook || where ansible-playbook');
OrchestratorLogger.log(`[Ansible] ansible-playbook binary verified`);
} catch (error: any) {
core.error('ansible-playbook not found. Install Ansible or ensure it is in PATH.');
throw new Error(`ansible-playbook not found on PATH: ${error.message || error}`);
}
// Verify inventory exists
try {
await OrchestratorSystem.Run(`test -e "${this.inventory}"`);
} catch {
throw new Error(`Inventory not found: ${this.inventory}`);
}
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(`[Ansible] Running playbook against inventory ${this.inventory}`);
if (!this.playbook) {
throw new Error(
'ansiblePlaybook is required — no default playbook is provided yet. ' +
'Provide a playbook that accepts build_guid, build_image, build_commands, mount_dir, and working_dir variables.',
);
}
// Build extra-vars JSON
// These use snake_case because they are Ansible variable names passed to playbooks
const playbookVariables: Record<string, string> = {
// eslint-disable-next-line camelcase
build_guid: buildGuid,
// eslint-disable-next-line camelcase
build_image: image,
// eslint-disable-next-line camelcase
build_commands: commands,
// eslint-disable-next-line camelcase
mount_dir: mountdir,
// eslint-disable-next-line camelcase
working_dir: workingdir,
};
for (const element of environment) {
playbookVariables[element.name.toLowerCase()] = element.value;
}
// Merge user-provided extra vars
if (this.extraVariables) {
try {
const userVariables = JSON.parse(this.extraVariables);
Object.assign(playbookVariables, userVariables);
} catch {
OrchestratorLogger.logWarning(`[Ansible] Failed to parse ansibleExtraVars as JSON, using as-is`);
}
}
const extraVariablesJson = JSON.stringify(playbookVariables).replace(/'/g, "'\\''");
// Build ansible-playbook command
const commandParts = [
'ansible-playbook',
`-i "${this.inventory}"`,
`"${this.playbook}"`,
`-e '${extraVariablesJson}'`,
'--no-color',
];
if (this.vaultPassword) {
commandParts.push(`--vault-password-file "${this.vaultPassword}"`);
}
// Add secret variables as extra environment
const environmentPrefix = secrets
.map((secret) => `${secret.EnvironmentVariable}='${secret.ParameterValue}'`)
.join(' ');
const fullCommand = environmentPrefix ? `${environmentPrefix} ${commandParts.join(' ')}` : commandParts.join(' ');
try {
const output = await OrchestratorSystem.Run(fullCommand);
OrchestratorLogger.log(`[Ansible] Playbook completed successfully`);
return output;
} catch (error: any) {
OrchestratorLogger.logWarning(`[Ansible] Playbook failed: ${error.message || error}`);
throw error;
}
}
async cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<void> {
OrchestratorLogger.log(`[Ansible] Cleanup complete`);
}
async garbageCollect(
// eslint-disable-next-line no-unused-vars
filter: string,
// eslint-disable-next-line no-unused-vars
previewOnly: boolean,
// eslint-disable-next-line no-unused-vars
olderThan: Number,
// eslint-disable-next-line no-unused-vars
fullCache: boolean,
// eslint-disable-next-line no-unused-vars
baseDependencies: boolean,
): Promise<string> {
return '';
}
async listResources(): Promise<ProviderResource[]> {
if (!this.inventory) return [];
const resource = new ProviderResource();
resource.Name = this.inventory;
return [resource];
}
async listWorkflow(): Promise<ProviderWorkflow[]> {
return [];
}
async watchWorkflow(): Promise<string> {
return '';
}
}
export default AnsibleProvider;

View File

@@ -1,333 +0,0 @@
import GitHubActionsProvider from '.';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import * as core from '@actions/core';
jest.mock('../../services/core/orchestrator-system');
jest.mock('../../services/core/orchestrator-logger');
jest.mock('@actions/core', () => ({
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
setOutput: jest.fn(),
getInput: jest.fn(() => ''),
}));
const mockRun = OrchestratorSystem.Run as jest.MockedFunction<typeof OrchestratorSystem.Run>;
const mockLog = OrchestratorLogger.log as jest.MockedFunction<typeof OrchestratorLogger.log>;
function createBuildParameters(overrides: Partial<BuildParameters> = {}): 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',
);
});
it('throws timeout error when polling exceeds maximum duration', async () => {
// Save real Date.now
const realDateNow = Date.now;
let callCount = 0;
// dispatch succeeds
mockRun.mockResolvedValueOnce('');
// run appears
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 600, status: 'in_progress' }));
// Status always returns in_progress
mockRun.mockImplementation(() => Promise.resolve(JSON.stringify({ status: 'in_progress' })));
// First call returns normal time, subsequent calls simulate 5 hours elapsed
Date.now = () => {
callCount++;
if (callCount <= 2) return realDateNow.call(Date);
return realDateNow.call(Date) + 14_400_001; // 4 hours + 1ms
};
try {
await expect(provider.runTaskInWorkflow('guid-poll-timeout', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'did not complete within 4 hours',
);
expect(core.error).toHaveBeenCalledWith(expect.stringContaining('did not complete within 4 hours'));
} finally {
Date.now = realDateNow;
}
});
});
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');
});
});
});

View File

@@ -1,284 +0,0 @@
import * as core from '@actions/core';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { ProviderInterface } from '../provider-interface';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
const MAX_POLLING_DURATION_MS = 14_400_000; // 4 hours
/**
* GitHub Actions provider — triggers builds as workflow_dispatch events
* on a target repository via the GitHub API.
*
* Use case: Distribute builds across orgs, use specialized runner pools,
* or trigger builds in repos with Unity licenses.
*/
class GitHubActionsProvider implements ProviderInterface {
private buildParameters: BuildParameters;
private repo: string;
private workflow: string;
private token: string;
private ref: string;
private runId: number = 0;
constructor(buildParameters: BuildParameters) {
this.buildParameters = buildParameters;
this.repo = buildParameters.githubActionsRepo || '';
this.workflow = buildParameters.githubActionsWorkflow || '';
this.token = buildParameters.githubActionsToken || '';
this.ref = buildParameters.githubActionsRef || 'main';
}
async setupWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<void> {
OrchestratorLogger.log(`[GitHubActions] Setting up workflow dispatch to ${this.repo}`);
if (!this.repo || !this.workflow) {
throw new Error('githubActionsRepo and githubActionsWorkflow are required for the github-actions provider');
}
if (!this.token) {
throw new Error('githubActionsToken is required (PAT with actions:write scope)');
}
// Verify repository and workflow exist
try {
const result = await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/workflows/${this.workflow} --jq '.id'`,
);
OrchestratorLogger.log(`[GitHubActions] Workflow verified: ${this.workflow} (ID: ${result.trim()})`);
} catch (error: any) {
throw new Error(`Failed to verify workflow ${this.workflow} in ${this.repo}: ${error.message || error}`);
}
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
// eslint-disable-next-line no-unused-vars
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(`[GitHubActions] Dispatching workflow ${this.workflow} on ${this.repo}@${this.ref}`);
// Build inputs payload
const inputs: Record<string, string> = {
buildGuid,
image,
commands: Buffer.from(commands).toString('base64'),
mountdir,
workingdir,
};
// Add environment variables as a JSON input
if (environment.length > 0) {
inputs.environment = JSON.stringify(environment.map((element) => ({ name: element.name, value: element.value })));
}
// Record the time before dispatch to identify the run
const beforeDispatch = new Date().toISOString();
// Dispatch the workflow
const inputsJson = JSON.stringify(inputs).replace(/'/g, "'\\''");
try {
await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/workflows/${this.workflow}/dispatches -X POST -f ref='${this.ref}' -f "inputs=${inputsJson}"`,
);
OrchestratorLogger.log(`[GitHubActions] Workflow dispatched`);
} catch (error: any) {
throw new Error(`Failed to dispatch workflow: ${error.message || error}`);
}
// Poll for the run to appear
OrchestratorLogger.log(`[GitHubActions] Waiting for workflow run to start...`);
let attempts = 0;
const maxAttempts = 30;
while (attempts < maxAttempts) {
attempts++;
await new Promise((resolve) => setTimeout(resolve, 10_000));
try {
const runsJson = await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh api "repos/${this.repo}/actions/workflows/${this.workflow}/runs?created=>${beforeDispatch}&per_page=5" --jq '.workflow_runs[0] | {id, status, conclusion}'`,
true,
);
const run = JSON.parse(runsJson.trim());
if (run.id) {
this.runId = run.id;
OrchestratorLogger.log(`[GitHubActions] Run started: ${this.runId} (status: ${run.status})`);
break;
}
} catch {
// Run not yet available
}
}
if (!this.runId) {
throw new Error(`Workflow run did not start within ${maxAttempts * 10}s`);
}
// Poll until completion and stream logs (with maximum duration guard)
let status = 'in_progress';
const pollingStartTime = Date.now();
const runUrl = `https://github.com/${this.repo}/actions/runs/${this.runId}`;
while (status === 'in_progress' || status === 'queued') {
const elapsedMs = Date.now() - pollingStartTime;
if (elapsedMs >= MAX_POLLING_DURATION_MS) {
const hours = Math.round(MAX_POLLING_DURATION_MS / 3_600_000);
const message = `GitHub Actions workflow did not complete within ${hours} hours. Run URL: ${runUrl}`;
core.error(message);
throw new Error(message);
}
await new Promise((resolve) => setTimeout(resolve, 15_000));
try {
const statusJson = await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/runs/${this.runId} --jq '{status, conclusion}'`,
true,
);
const result = JSON.parse(statusJson.trim());
status = result.status;
if (status === 'completed') {
OrchestratorLogger.log(`[GitHubActions] Run ${this.runId} completed: ${result.conclusion}`);
if (result.conclusion !== 'success') {
throw new Error(`Workflow run failed with conclusion: ${result.conclusion}`);
}
break;
}
OrchestratorLogger.log(`[GitHubActions] Run ${this.runId} status: ${status}`);
} catch (error: any) {
if (error.message && error.message.includes('conclusion')) {
throw error;
}
if (error.message && error.message.includes('did not complete within')) {
throw error;
}
OrchestratorLogger.logWarning(`[GitHubActions] Status check error: ${error.message || error}`);
}
}
// Fetch logs
try {
const logs = await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh run view ${this.runId} --repo ${this.repo} --log`,
true,
);
return logs;
} catch {
return `Run ${this.runId} completed successfully (logs unavailable)`;
}
}
async cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<void> {
OrchestratorLogger.log(`[GitHubActions] Cleanup complete (no resources to tear down)`);
}
async garbageCollect(
// eslint-disable-next-line no-unused-vars
filter: string,
// eslint-disable-next-line no-unused-vars
previewOnly: boolean,
// eslint-disable-next-line no-unused-vars
olderThan: Number,
// eslint-disable-next-line no-unused-vars
fullCache: boolean,
// eslint-disable-next-line no-unused-vars
baseDependencies: boolean,
): Promise<string> {
return '';
}
async listResources(): Promise<ProviderResource[]> {
if (!this.repo || !this.token) return [];
try {
const runnersJson = await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/runners --jq '.runners[] | .name'`,
true,
);
return runnersJson
.trim()
.split('\n')
.filter(Boolean)
.map((name) => {
const resource = new ProviderResource();
resource.Name = name.trim();
return resource;
});
} catch {
return [];
}
}
async listWorkflow(): Promise<ProviderWorkflow[]> {
if (!this.repo || !this.token) return [];
try {
const runsJson = await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh api repos/${this.repo}/actions/runs?per_page=10 --jq '.workflow_runs[] | .name'`,
true,
);
return runsJson
.trim()
.split('\n')
.filter(Boolean)
.map((name) => {
const workflow = new ProviderWorkflow();
workflow.Name = name.trim();
return workflow;
});
} catch {
return [];
}
}
async watchWorkflow(): Promise<string> {
if (!this.runId) return 'No active run to watch';
try {
return await OrchestratorSystem.Run(
`GH_TOKEN=${this.token} gh run watch ${this.runId} --repo ${this.repo}`,
true,
);
} catch {
return '';
}
}
}
export default GitHubActionsProvider;

View File

@@ -1,329 +0,0 @@
import GitLabCIProvider from '.';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import * as core from '@actions/core';
jest.mock('../../services/core/orchestrator-system');
jest.mock('../../services/core/orchestrator-logger');
jest.mock('@actions/core', () => ({
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
setOutput: jest.fn(),
getInput: jest.fn(() => ''),
}));
const mockRun = OrchestratorSystem.Run as jest.MockedFunction<typeof OrchestratorSystem.Run>;
const mockLog = OrchestratorLogger.log as jest.MockedFunction<typeof OrchestratorLogger.log>;
const mockLogWarning = OrchestratorLogger.logWarning as jest.MockedFunction<typeof OrchestratorLogger.logWarning>;
function createBuildParameters(overrides: Partial<BuildParameters> = {}): 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'));
});
it('throws timeout error when polling exceeds maximum duration', async () => {
const realDateNow = Date.now;
let callCount = 0;
// Trigger pipeline succeeds
mockRun.mockResolvedValueOnce(JSON.stringify({ id: 5008, status: 'running' }));
// Status always returns running
mockRun.mockImplementation(() => Promise.resolve(JSON.stringify({ status: 'running' })));
// After first call, simulate 5 hours elapsed
Date.now = () => {
callCount++;
if (callCount <= 1) return realDateNow.call(Date);
return realDateNow.call(Date) + 14_400_001; // 4 hours + 1ms
};
try {
await expect(provider.runTaskInWorkflow('guid-poll-timeout', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'did not complete within 4 hours',
);
expect(core.error).toHaveBeenCalledWith(expect.stringContaining('did not complete within 4 hours'));
} finally {
Date.now = realDateNow;
}
});
});
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('');
});
});
});

View File

@@ -1,224 +0,0 @@
import * as core from '@actions/core';
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { ProviderInterface } from '../provider-interface';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
const MAX_POLLING_DURATION_MS = 14_400_000; // 4 hours
/**
* GitLab CI provider — triggers builds as GitLab CI pipelines
* via the GitLab API.
*
* Use case: Teams using GitLab CI, hybrid GitHub/GitLab setups,
* or GitLab runners with Unity licenses.
*/
class GitLabCIProvider implements ProviderInterface {
private buildParameters: BuildParameters;
private projectId: string;
private triggerToken: string;
private apiUrl: string;
private ref: string;
private pipelineId: number = 0;
constructor(buildParameters: BuildParameters) {
this.buildParameters = buildParameters;
this.projectId = buildParameters.gitlabProjectId || '';
this.triggerToken = buildParameters.gitlabTriggerToken || '';
this.apiUrl = (buildParameters.gitlabApiUrl || 'https://gitlab.com').replace(/\/+$/, '');
this.ref = buildParameters.gitlabRef || 'main';
}
async setupWorkflow(
// eslint-disable-next-line no-unused-vars
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<void> {
OrchestratorLogger.log(`[GitLabCI] Setting up pipeline trigger for project ${this.projectId}`);
if (!this.projectId || !this.triggerToken) {
throw new Error('gitlabProjectId and gitlabTriggerToken are required for the gitlab-ci provider');
}
// Verify project access
const encodedProject = encodeURIComponent(this.projectId);
try {
await OrchestratorSystem.Run(
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}" -o /dev/null`,
);
OrchestratorLogger.log(`[GitLabCI] Project access verified`);
} catch (error: any) {
throw new Error(`Failed to access GitLab project ${this.projectId}: ${error.message || error}`);
}
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
// eslint-disable-next-line no-unused-vars
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(`[GitLabCI] Triggering pipeline on project ${this.projectId}@${this.ref}`);
const encodedProject = encodeURIComponent(this.projectId);
// Build variables for the pipeline
const pipelineVariables: string[] = [
`-f "variables[BUILD_GUID]=${buildGuid}"`,
`-f "variables[BUILD_IMAGE]=${image}"`,
`-f "variables[BUILD_COMMANDS]=${Buffer.from(commands).toString('base64')}"`,
`-f "variables[MOUNT_DIR]=${mountdir}"`,
`-f "variables[WORKING_DIR]=${workingdir}"`,
];
for (const element of environment) {
pipelineVariables.push(`-f "variables[${element.name}]=${element.value}"`);
}
// Trigger pipeline
try {
const response = await OrchestratorSystem.Run(
`curl -sf -X POST "${this.apiUrl}/api/v4/projects/${encodedProject}/trigger/pipeline" -f "token=${
this.triggerToken
}" -f "ref=${this.ref}" ${pipelineVariables.join(' ')}`,
);
const pipeline = JSON.parse(response);
this.pipelineId = pipeline.id;
OrchestratorLogger.log(`[GitLabCI] Pipeline triggered: ${this.pipelineId} (status: ${pipeline.status})`);
} catch (error: any) {
throw new Error(`Failed to trigger pipeline: ${error.message || error}`);
}
// Poll until completion (with maximum duration guard)
let status = 'pending';
const terminalStatuses = new Set(['success', 'failed', 'canceled', 'skipped']);
const pollingStartTime = Date.now();
const pipelineUrl = `${this.apiUrl}/${this.projectId}/-/pipelines/${this.pipelineId}`;
while (!terminalStatuses.has(status)) {
const elapsedMs = Date.now() - pollingStartTime;
if (elapsedMs >= MAX_POLLING_DURATION_MS) {
const hours = Math.round(MAX_POLLING_DURATION_MS / 3_600_000);
const message = `GitLab CI pipeline did not complete within ${hours} hours. Pipeline URL: ${pipelineUrl}`;
core.error(message);
throw new Error(message);
}
await new Promise((resolve) => setTimeout(resolve, 15_000));
try {
const statusResponse = await OrchestratorSystem.Run(
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/pipelines/${this.pipelineId}"`,
true,
);
const pipelineStatus = JSON.parse(statusResponse);
status = pipelineStatus.status;
OrchestratorLogger.log(`[GitLabCI] Pipeline ${this.pipelineId} status: ${status}`);
} catch (error: any) {
OrchestratorLogger.logWarning(`[GitLabCI] Status check error: ${error.message || error}`);
}
}
if (status !== 'success') {
throw new Error(`Pipeline ${this.pipelineId} finished with status: ${status}`);
}
// Fetch job logs
try {
const jobsResponse = await OrchestratorSystem.Run(
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/pipelines/${this.pipelineId}/jobs"`,
true,
);
const jobs = JSON.parse(jobsResponse);
const logs: string[] = [];
for (const job of jobs) {
try {
const jobLog = await OrchestratorSystem.Run(
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/jobs/${job.id}/trace"`,
true,
);
logs.push(`=== Job: ${job.name} (${job.status}) ===\n${jobLog}`);
} catch {
logs.push(`=== Job: ${job.name} (${job.status}) === (logs unavailable)`);
}
}
return logs.join('\n\n');
} catch {
return `Pipeline ${this.pipelineId} completed successfully (logs unavailable)`;
}
}
async cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<void> {
OrchestratorLogger.log(`[GitLabCI] Cleanup complete`);
}
async garbageCollect(
// eslint-disable-next-line no-unused-vars
filter: string,
// eslint-disable-next-line no-unused-vars
previewOnly: boolean,
// eslint-disable-next-line no-unused-vars
olderThan: Number,
// eslint-disable-next-line no-unused-vars
fullCache: boolean,
// eslint-disable-next-line no-unused-vars
baseDependencies: boolean,
): Promise<string> {
return '';
}
async listResources(): Promise<ProviderResource[]> {
return [];
}
async listWorkflow(): Promise<ProviderWorkflow[]> {
if (!this.projectId || !this.triggerToken) return [];
try {
const encodedProject = encodeURIComponent(this.projectId);
const response = await OrchestratorSystem.Run(
`curl -sf -H "PRIVATE-TOKEN: ${this.triggerToken}" "${this.apiUrl}/api/v4/projects/${encodedProject}/pipelines?per_page=10"`,
true,
);
return JSON.parse(response).map((pipeline: any) => {
const workflow = new ProviderWorkflow();
workflow.Name = `Pipeline #${pipeline.id} (${pipeline.status})`;
return workflow;
});
} catch {
return [];
}
}
async watchWorkflow(): Promise<string> {
return '';
}
}
export default GitLabCIProvider;

View File

@@ -1,164 +0,0 @@
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<string, new (params: BuildParameters) => 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');
}
}
});
});
});

View File

@@ -1,166 +0,0 @@
import BuildParameters from '../../../build-parameters';
import { OrchestratorSystem } from '../../services/core/orchestrator-system';
import OrchestratorEnvironmentVariable from '../../options/orchestrator-environment-variable';
import OrchestratorLogger from '../../services/core/orchestrator-logger';
import { ProviderInterface } from '../provider-interface';
import OrchestratorSecret from '../../options/orchestrator-secret';
import { ProviderResource } from '../provider-resource';
import { ProviderWorkflow } from '../provider-workflow';
/**
* Remote PowerShell provider — executes Unity builds on remote machines
* via PowerShell Remoting (WinRM or SSH).
*
* Use case: Teams with dedicated build machines not part of a CI system.
*/
class RemotePowershellProvider implements ProviderInterface {
private buildParameters: BuildParameters;
private host: string;
private transport: string;
private credential: string;
private sessionId: string = '';
constructor(buildParameters: BuildParameters) {
this.buildParameters = buildParameters;
this.host = buildParameters.remotePowershellHost || '';
this.transport = buildParameters.remotePowershellTransport || 'wsman';
this.credential = buildParameters.remotePowershellCredential || '';
}
async setupWorkflow(
buildGuid: string,
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<void> {
OrchestratorLogger.log(`[RemotePowershell] Setting up remote session to ${this.host} via ${this.transport}`);
if (!this.host) {
throw new Error('remotePowershellHost is required for the remote-powershell provider');
}
// Test connectivity
const testCommand = this.buildPwshCommand(`Test-WSMan -ComputerName "${this.host}" -ErrorAction Stop`);
try {
await OrchestratorSystem.Run(testCommand);
OrchestratorLogger.log(`[RemotePowershell] Connection test passed`);
} catch (error: any) {
throw new Error(`Failed to connect to remote host ${this.host}: ${error.message || error}`);
}
this.sessionId = buildGuid;
OrchestratorLogger.log(`[RemotePowershell] Session ${this.sessionId} ready`);
}
async runTaskInWorkflow(
buildGuid: string,
image: string,
commands: string,
mountdir: string,
workingdir: string,
environment: OrchestratorEnvironmentVariable[],
secrets: OrchestratorSecret[],
): Promise<string> {
OrchestratorLogger.log(`[RemotePowershell] Executing task on ${this.host}`);
// Build environment variable block for remote session
const environmentBlock = environment.map((element) => `$env:${element.name} = '${element.value}'`).join('; ');
const secretBlock = secrets
.map((secret) => `$env:${secret.EnvironmentVariable} = '${secret.ParameterValue}'`)
.join('; ');
// Wrap commands for remote execution
const remoteScript = [environmentBlock, secretBlock, `Set-Location "${workingdir}"`, commands]
.filter(Boolean)
.join('; ');
const invokeCommand = this.buildInvokeCommand(remoteScript);
try {
const output = await OrchestratorSystem.Run(invokeCommand);
OrchestratorLogger.log(`[RemotePowershell] Task completed successfully`);
return output;
} catch (error: any) {
OrchestratorLogger.logWarning(`[RemotePowershell] Task failed: ${error.message || error}`);
throw error;
}
}
async cleanupWorkflow(
// eslint-disable-next-line no-unused-vars
buildParameters: BuildParameters,
// eslint-disable-next-line no-unused-vars
branchName: string,
// eslint-disable-next-line no-unused-vars
defaultSecretsArray: { ParameterKey: string; EnvironmentVariable: string; ParameterValue: string }[],
): Promise<void> {
OrchestratorLogger.log(`[RemotePowershell] Cleaning up session ${this.sessionId}`);
// Remote sessions are stateless per invocation — no cleanup needed
}
async garbageCollect(
// eslint-disable-next-line no-unused-vars
filter: string,
// eslint-disable-next-line no-unused-vars
previewOnly: boolean,
// eslint-disable-next-line no-unused-vars
olderThan: Number,
// eslint-disable-next-line no-unused-vars
fullCache: boolean,
// eslint-disable-next-line no-unused-vars
baseDependencies: boolean,
): Promise<string> {
OrchestratorLogger.log(`[RemotePowershell] Garbage collection not supported for remote PowerShell provider`);
return '';
}
async listResources(): Promise<ProviderResource[]> {
const resource = new ProviderResource();
resource.Name = this.host;
return [resource];
}
async listWorkflow(): Promise<ProviderWorkflow[]> {
return [];
}
async watchWorkflow(): Promise<string> {
return '';
}
private buildPwshCommand(script: string): string {
return `pwsh -NoProfile -NonInteractive -Command "${script.replace(/"/g, '\\"')}"`;
}
private buildInvokeCommand(remoteScript: string): string {
const escapedScript = remoteScript.replace(/"/g, '\\"').replace(/'/g, "''");
if (this.transport === 'ssh') {
return `pwsh -NoProfile -NonInteractive -Command "Invoke-Command -HostName '${this.host}' -ScriptBlock { ${escapedScript} }"`;
}
// WinRM (default)
// Split on the FIRST colon only — passwords may contain colons
let credentialPart = '';
if (this.credential) {
const colonIndex = this.credential.indexOf(':');
if (colonIndex === -1) {
throw new Error('remotePowershellCredential must be in "username:password" format (no colon found)');
}
const user = this.credential.substring(0, colonIndex);
const pass = this.credential.substring(colonIndex + 1);
credentialPart = `-Credential (New-Object PSCredential('${user}', (ConvertTo-SecureString '${pass}' -AsPlainText -Force)))`;
}
return `pwsh -NoProfile -NonInteractive -Command "Invoke-Command -ComputerName '${this.host}' ${credentialPart} -ScriptBlock { ${escapedScript} }"`;
}
}
export default RemotePowershellProvider;

View File

@@ -1,264 +0,0 @@
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<typeof OrchestratorSystem.Run>;
const mockLog = OrchestratorLogger.log as jest.MockedFunction<typeof OrchestratorLogger.log>;
const mockLogWarning = OrchestratorLogger.logWarning as jest.MockedFunction<typeof OrchestratorLogger.logWarning>;
function createBuildParameters(overrides: Partial<BuildParameters> = {}): 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('preserves passwords containing colons when splitting credentials', async () => {
const params = createBuildParameters({
remotePowershellCredential: 'admin:P@ss:w0rd:with:colons!',
});
provider = new RemotePowershellProvider(params);
mockRun.mockResolvedValueOnce('output');
await provider.runTaskInWorkflow('guid-colon', 'img', 'cmd', '/m', '/w', [], []);
const command = mockRun.mock.calls[0][0];
expect(command).toContain("PSCredential('admin'");
expect(command).toContain("ConvertTo-SecureString 'P@ss:w0rd:with:colons!'");
});
it('throws when credential has no colon separator', async () => {
const params = createBuildParameters({
remotePowershellCredential: 'nocolonhere',
});
provider = new RemotePowershellProvider(params);
await expect(provider.runTaskInWorkflow('guid-badcred', 'img', 'cmd', '/m', '/w', [], [])).rejects.toThrow(
'username:password',
);
});
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('');
});
});
});

View File

@@ -0,0 +1,12 @@
export { TestSuiteParser } from './test-suite-parser';
export { TaxonomyFilterService } from './taxonomy-filter-service';
export { TestResultReporter } from './test-result-reporter';
export { TestWorkflowService } from './test-workflow-service';
export {
TestSuiteDefinition,
TestRunDefinition,
TaxonomyDimension,
TaxonomyDefinition,
TestResult,
TestFailure,
} from './test-workflow-types';

View File

@@ -0,0 +1,198 @@
import fs from 'node:fs';
import YAML from 'yaml';
import { TaxonomyDimension, TaxonomyDefinition } from './test-workflow-types';
/**
* Manages test taxonomy dimensions and builds filter arguments for
* the Unity test runner CLI. Supports comma-separated value lists,
* regex patterns (/pattern/), and hierarchical dot-notation matching.
*/
export class TaxonomyFilterService {
/**
* Built-in taxonomy dimensions that are always available.
* Projects may extend these via a custom taxonomy file.
*/
private static readonly BUILT_IN_DIMENSIONS: TaxonomyDimension[] = [
{ name: 'Scope', values: ['Unit', 'Integration', 'System', 'End To End'] },
{ name: 'Maturity', values: ['Trusted', 'Adolescent', 'Experimental'] },
{ name: 'FeedbackSpeed', values: ['Fast', 'Moderate', 'Slow'] },
{ name: 'Execution', values: ['Synchronous', 'Asynchronous', 'Coroutine'] },
{ name: 'Rigor', values: ['Strict', 'Normal', 'Relaxed'] },
{ name: 'Determinism', values: ['Deterministic', 'NonDeterministic'] },
{ name: 'IsolationLevel', values: ['Full', 'Partial', 'None'] },
];
/**
* Load taxonomy dimensions: built-in dimensions plus any custom dimensions
* from an optional taxonomy file.
*/
static loadTaxonomy(filePath?: string): TaxonomyDimension[] {
const dimensions = [...TaxonomyFilterService.BUILT_IN_DIMENSIONS];
if (filePath && fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf8');
const parsed = YAML.parse(content) as TaxonomyDefinition;
if (parsed?.extensible_groups && Array.isArray(parsed.extensible_groups)) {
for (const group of parsed.extensible_groups) {
if (group.name && Array.isArray(group.values)) {
// If a custom dimension has the same name as a built-in, merge values
const existing = dimensions.find((d) => d.name === group.name);
if (existing) {
const existingValues = new Set(existing.values);
for (const value of group.values) {
if (!existingValues.has(value)) {
existing.values.push(value);
}
}
} else {
dimensions.push({ name: group.name, values: [...group.values] });
}
}
}
}
}
return dimensions;
}
/**
* Convert a filter map to Unity test runner CLI args (--testFilter).
*
* Each filter dimension becomes a category expression. Multiple values in one
* dimension are OR'd; multiple dimensions are AND'd. The result is a single
* --testFilter string suitable for passing to Unity's test runner CLI.
*
* Regex patterns (values wrapped in /.../) are converted to category regex
* expressions supported by the Unity test runner.
*/
static buildFilterArgs(filters: Record<string, string>): string {
if (!filters || Object.keys(filters).length === 0) {
return '';
}
const categoryExpressions: string[] = [];
for (const [dimension, valueSpec] of Object.entries(filters)) {
const expression = TaxonomyFilterService.buildDimensionExpression(dimension, valueSpec);
if (expression) {
categoryExpressions.push(expression);
}
}
if (categoryExpressions.length === 0) {
return '';
}
// Unity test runner uses --testFilter with category expressions
// Multiple dimensions are AND'd by joining with ';'
const filterString = categoryExpressions.join(';');
return `--testFilter "${filterString}"`;
}
/**
* Build a filter expression for a single taxonomy dimension.
*/
private static buildDimensionExpression(dimension: string, valueSpec: string): string {
if (!valueSpec || valueSpec.trim() === '') {
return '';
}
const trimmed = valueSpec.trim();
// Check if the value is a regex pattern: /pattern/
if (trimmed.startsWith('/') && trimmed.endsWith('/') && trimmed.length > 2) {
const pattern = trimmed.slice(1, -1);
return `${dimension}=~${pattern}`;
}
// Comma-separated values: OR'd together
const values = trimmed
.split(',')
.map((v) => v.trim())
.filter((v) => v.length > 0);
if (values.length === 0) {
return '';
}
if (values.length === 1) {
return `${dimension}=${values[0]}`;
}
// Multiple values: use pipe-separated OR syntax
return `${dimension}=${values.join('|')}`;
}
/**
* Check if a test's taxonomy metadata matches the given filter criteria.
*
* A test matches if ALL filter dimensions match (AND across dimensions).
* Within a single dimension, the test must match ANY of the specified values (OR).
* Regex patterns are matched as regular expressions.
* Hierarchical dot-notation supports prefix matching (e.g., filter "Combat.Melee"
* matches test category "Combat.Melee.Sword").
*/
static matchesFilter(testCategories: Record<string, string>, filters: Record<string, string>): boolean {
for (const [dimension, valueSpec] of Object.entries(filters)) {
const testValue = testCategories[dimension];
// If the test has no value for this dimension, it does not match
if (testValue === undefined || testValue === null) {
return false;
}
if (!TaxonomyFilterService.matchesDimensionFilter(testValue, valueSpec)) {
return false;
}
}
return true;
}
/**
* Check if a single test category value matches a dimension filter spec.
*/
private static matchesDimensionFilter(testValue: string, valueSpec: string): boolean {
const trimmed = valueSpec.trim();
// Regex pattern
if (trimmed.startsWith('/') && trimmed.endsWith('/') && trimmed.length > 2) {
const pattern = trimmed.slice(1, -1);
try {
const regex = new RegExp(pattern);
return regex.test(testValue);
} catch {
// Invalid regex, treat as literal
return testValue === trimmed;
}
}
// Comma-separated values
const values = trimmed
.split(',')
.map((v) => v.trim())
.filter((v) => v.length > 0);
return values.some((filterValue) => {
// Exact match
if (testValue === filterValue) {
return true;
}
// Hierarchical dot-notation prefix match
// Filter "Combat.Melee" matches test "Combat.Melee" and "Combat.Melee.Sword"
if (filterValue.includes('.') || testValue.includes('.')) {
if (testValue.startsWith(filterValue + '.') || testValue === filterValue) {
return true;
}
// Also allow the test to be a prefix of the filter for upward matching
if (filterValue.startsWith(testValue + '.')) {
return true;
}
}
return false;
});
}
}

View File

@@ -0,0 +1,316 @@
import fs from 'node:fs';
import path from 'node:path';
import { TestResult, TestFailure } from './test-workflow-types';
/**
* Parses test result files (JUnit XML, Unity JSON) and generates structured
* summary reports. Supports writing results in multiple formats for CI
* integration (GitHub Checks, artifact upload).
*/
export class TestResultReporter {
/**
* Parse a JUnit XML test result file into a TestResult.
* JUnit XML is the standard format produced by Unity's test runner.
*/
static parseJUnitResults(xmlPath: string): TestResult {
if (!fs.existsSync(xmlPath)) {
throw new Error(`JUnit result file not found: ${xmlPath}`);
}
const content = fs.readFileSync(xmlPath, 'utf8');
return TestResultReporter.parseJUnitXml(content);
}
/**
* Parse JUnit XML content string into a TestResult.
*/
static parseJUnitXml(xmlContent: string): TestResult {
// Extract the testsuite opening tag
const suiteTagMatch = xmlContent.match(/<testsuite\s[^>]*>/);
let runName = 'unknown';
let totalTests = 0;
let failureCount = 0;
let skippedCount = 0;
let duration = 0;
if (suiteTagMatch) {
const tag = suiteTagMatch[0];
// Extract individual attributes -- order-independent
const nameMatch = tag.match(/\sname="([^"]*)"/);
const testsMatch = tag.match(/\stests="(\d+)"/);
const failuresMatch = tag.match(/\sfailures="(\d+)"/);
const skippedMatch = tag.match(/\sskipped="(\d+)"/);
const timeMatch = tag.match(/\stime="([^"]*)"/);
runName = nameMatch ? nameMatch[1] : 'unknown';
totalTests = testsMatch ? Number.parseInt(testsMatch[1], 10) : 0;
failureCount = failuresMatch ? Number.parseInt(failuresMatch[1], 10) : 0;
skippedCount = skippedMatch ? Number.parseInt(skippedMatch[1], 10) : 0;
duration = timeMatch ? Number.parseFloat(timeMatch[1]) : 0;
}
// Extract individual test failures by splitting into testcase blocks
const failures: TestFailure[] = [];
const testcasePattern = /<testcase\s[^>]*>[\s\S]*?<\/testcase>/g;
let tcMatch;
while ((tcMatch = testcasePattern.exec(xmlContent)) !== null) {
const block = tcMatch[0];
// Only process testcases that contain a <failure> element
if (!block.includes('<failure')) {
continue;
}
// Extract testcase attributes (order-independent)
const tcTag = block.match(/<testcase\s[^>]*>/);
if (!tcTag) continue;
const cnMatch = tcTag[0].match(/\sclassname="([^"]*)"/);
const tnMatch = tcTag[0].match(/\sname="([^"]*)"/);
const className = cnMatch ? cnMatch[1] : 'unknown';
const testName = tnMatch ? tnMatch[1] : 'unknown';
// Extract failure message
const failTag = block.match(/<failure\s[^>]*>/);
const msgMatch = failTag ? failTag[0].match(/\smessage="([^"]*)"/) : null;
const message = msgMatch ? msgMatch[1] : 'Test failed';
// Extract stack trace from CDATA or text content
const cdataMatch = block.match(/<failure[^>]*>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>/);
const textMatch = !cdataMatch ? block.match(/<failure[^>]*>([^<]*)<\/failure>/) : null;
const stackTrace = cdataMatch ? cdataMatch[1].trim() : textMatch ? textMatch[1].trim() : undefined;
failures.push({ testName, className, message, stackTrace: stackTrace || undefined });
}
const passed = totalTests - failureCount - skippedCount;
return {
runName,
passed: Math.max(0, passed),
failed: failureCount,
skipped: skippedCount,
duration,
failures,
};
}
/**
* Parse a Unity JSON test result file into a TestResult.
*/
static parseJsonResults(jsonPath: string): TestResult {
if (!fs.existsSync(jsonPath)) {
throw new Error(`JSON result file not found: ${jsonPath}`);
}
const content = fs.readFileSync(jsonPath, 'utf8');
const data = JSON.parse(content);
return TestResultReporter.parseJsonData(data);
}
/**
* Parse Unity JSON test result data into a TestResult.
*/
static parseJsonData(data: any): TestResult {
const runName = data.name ?? data.suiteName ?? 'unknown';
const passed = data.passed ?? data.passCount ?? 0;
const failed = data.failed ?? data.failCount ?? 0;
const skipped = data.skipped ?? data.skipCount ?? data.inconclusive ?? 0;
const duration = data.duration ?? data.time ?? 0;
const failures: TestFailure[] = [];
// Unity test results may have a 'testResults' or 'results' array
const results = data.testResults ?? data.results ?? data.children ?? [];
if (Array.isArray(results)) {
for (const result of results) {
TestResultReporter.extractFailures(result, failures);
}
}
return {
runName,
passed,
failed,
skipped,
duration,
failures,
};
}
/**
* Recursively extract failures from nested Unity test result JSON.
*/
private static extractFailures(node: any, failures: TestFailure[]): void {
if (!node) return;
const status = (node.result ?? node.status ?? '').toLowerCase();
if (status === 'failed' || status === 'failure') {
failures.push({
testName: node.name ?? node.testName ?? 'unknown',
className: node.className ?? node.fullName ?? node.name ?? 'unknown',
message: node.message ?? node.output ?? 'Test failed',
stackTrace: node.stackTrace ?? node.trace ?? undefined,
});
}
// Recurse into children (Unity nests test fixtures inside suites)
const children = node.children ?? node.testResults ?? node.results ?? [];
if (Array.isArray(children)) {
for (const child of children) {
TestResultReporter.extractFailures(child, failures);
}
}
}
/**
* Generate a markdown summary table from an array of test results.
*/
static generateSummary(results: TestResult[]): string {
if (results.length === 0) {
return 'No test results available.';
}
const lines: string[] = [];
lines.push('## Test Results Summary');
lines.push('');
lines.push('| Run | Passed | Failed | Skipped | Duration |');
lines.push('|-----|--------|--------|---------|----------|');
let totalPassed = 0;
let totalFailed = 0;
let totalSkipped = 0;
let totalDuration = 0;
for (const result of results) {
const status = result.failed > 0 ? 'X' : 'OK';
const durationStr = TestResultReporter.formatDuration(result.duration);
lines.push(
`| ${status} ${result.runName} | ${result.passed} | ${result.failed} | ${result.skipped} | ${durationStr} |`,
);
totalPassed += result.passed;
totalFailed += result.failed;
totalSkipped += result.skipped;
totalDuration += result.duration;
}
lines.push(
`| **Total** | **${totalPassed}** | **${totalFailed}** | **${totalSkipped}** | **${TestResultReporter.formatDuration(
totalDuration,
)}** |`,
);
lines.push('');
// Append failure details if any
const allFailures = results.flatMap((r) => r.failures.map((f) => ({ ...f, run: r.runName })));
if (allFailures.length > 0) {
lines.push('### Failures');
lines.push('');
for (const failure of allFailures) {
lines.push(`**${failure.run}** - \`${failure.className}.${failure.testName}\``);
lines.push(`> ${failure.message}`);
if (failure.stackTrace) {
lines.push('```');
lines.push(failure.stackTrace.slice(0, 500));
lines.push('```');
}
lines.push('');
}
}
return lines.join('\n');
}
/**
* Write test results to the output path in the specified format(s).
*/
static writeResults(results: TestResult[], outputPath: string, format: 'junit' | 'json' | 'both'): void {
if (!fs.existsSync(outputPath)) {
fs.mkdirSync(outputPath, { recursive: true });
}
if (format === 'json' || format === 'both') {
const jsonPath = path.join(outputPath, 'test-results.json');
fs.writeFileSync(jsonPath, JSON.stringify(results, null, 2), 'utf8');
}
if (format === 'junit' || format === 'both') {
const junitPath = path.join(outputPath, 'test-results.xml');
const xml = TestResultReporter.toJUnitXml(results);
fs.writeFileSync(junitPath, xml, 'utf8');
}
// Always write markdown summary
const summaryPath = path.join(outputPath, 'test-summary.md');
const summary = TestResultReporter.generateSummary(results);
fs.writeFileSync(summaryPath, summary, 'utf8');
}
/**
* Convert TestResult array to JUnit XML format.
*/
private static toJUnitXml(results: TestResult[]): string {
const lines: string[] = [];
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
lines.push('<testsuites>');
for (const result of results) {
const total = result.passed + result.failed + result.skipped;
lines.push(
` <testsuite name="${TestResultReporter.escapeXml(result.runName)}" tests="${total}" failures="${
result.failed
}" skipped="${result.skipped}" time="${result.duration.toFixed(3)}">`,
);
// Write failure test cases
for (const failure of result.failures) {
lines.push(
` <testcase classname="${TestResultReporter.escapeXml(
failure.className,
)}" name="${TestResultReporter.escapeXml(failure.testName)}">`,
);
lines.push(` <failure message="${TestResultReporter.escapeXml(failure.message)}">`);
if (failure.stackTrace) {
lines.push(` <![CDATA[${failure.stackTrace}]]>`);
}
lines.push(' </failure>');
lines.push(' </testcase>');
}
lines.push(' </testsuite>');
}
lines.push('</testsuites>');
return lines.join('\n');
}
/**
* Escape special XML characters.
*/
private static escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Format a duration in seconds to a human-readable string.
*/
private static formatDuration(seconds: number): string {
if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds.toFixed(0)}s`;
}
}

View File

@@ -0,0 +1,263 @@
import fs from 'node:fs';
import YAML from 'yaml';
import { TestSuiteDefinition, TestRunDefinition } from './test-workflow-types';
/**
* Parses and validates YAML-based test suite definition files.
* Handles dependency resolution (topological sort) for ordered test run execution.
*/
export class TestSuiteParser {
/**
* Read and parse a YAML test suite definition file.
* Validates the structure and returns a typed TestSuiteDefinition.
*/
static parseSuiteFile(filePath: string): TestSuiteDefinition {
if (!fs.existsSync(filePath)) {
throw new Error(`Test suite file not found: ${filePath}`);
}
const content = fs.readFileSync(filePath, 'utf8');
const parsed = YAML.parse(content);
if (!parsed || typeof parsed !== 'object') {
throw new Error(`Invalid YAML in test suite file: ${filePath}`);
}
if (!parsed.name || typeof parsed.name !== 'string') {
throw new Error(`Test suite must have a 'name' field (string): ${filePath}`);
}
if (!Array.isArray(parsed.runs) || parsed.runs.length === 0) {
throw new Error(`Test suite must have a non-empty 'runs' array: ${filePath}`);
}
const suite: TestSuiteDefinition = {
name: parsed.name,
description: parsed.description,
runs: parsed.runs.map((run: any) => TestSuiteParser.parseRun(run)),
};
const errors = TestSuiteParser.validateSuite(suite);
if (errors.length > 0) {
throw new Error(`Test suite validation failed:\n ${errors.join('\n ')}`);
}
return suite;
}
/**
* Parse a single run definition from raw YAML data.
*/
private static parseRun(raw: any): TestRunDefinition {
if (!raw || typeof raw !== 'object') {
throw new Error(`Each run must be an object`);
}
if (!raw.name || typeof raw.name !== 'string') {
throw new Error(`Each run must have a 'name' field (string)`);
}
const run: TestRunDefinition = {
name: raw.name,
};
if (raw.needs !== undefined) {
if (!Array.isArray(raw.needs)) {
throw new Error(`Run '${raw.name}': 'needs' must be an array of strings`);
}
run.needs = raw.needs;
}
if (raw.editMode !== undefined) {
run.editMode = Boolean(raw.editMode);
}
if (raw.playMode !== undefined) {
run.playMode = Boolean(raw.playMode);
}
if (raw.builtClient !== undefined) {
run.builtClient = Boolean(raw.builtClient);
}
if (raw.builtClientPath !== undefined) {
run.builtClientPath = String(raw.builtClientPath);
}
if (raw.filters !== undefined) {
if (typeof raw.filters !== 'object' || Array.isArray(raw.filters)) {
throw new Error(`Run '${raw.name}': 'filters' must be a key-value object`);
}
run.filters = {};
for (const [key, value] of Object.entries(raw.filters)) {
run.filters[key] = String(value);
}
}
if (raw.timeout !== undefined) {
const timeout = Number(raw.timeout);
if (Number.isNaN(timeout) || timeout <= 0) {
throw new Error(`Run '${raw.name}': 'timeout' must be a positive number`);
}
run.timeout = timeout;
}
return run;
}
/**
* Resolve run execution order via topological sort based on 'needs' dependencies.
* Returns an array of parallel groups -- each group contains runs that can execute concurrently.
* Runs within the same group have no inter-dependencies.
*/
static resolveRunOrder(suite: TestSuiteDefinition): TestRunDefinition[][] {
const runMap = new Map<string, TestRunDefinition>();
for (const run of suite.runs) {
runMap.set(run.name, run);
}
// Build adjacency: inDegree counts and dependents map
const inDegree = new Map<string, number>();
const dependents = new Map<string, string[]>();
for (const run of suite.runs) {
if (!inDegree.has(run.name)) {
inDegree.set(run.name, 0);
}
if (!dependents.has(run.name)) {
dependents.set(run.name, []);
}
if (run.needs) {
for (const dep of run.needs) {
inDegree.set(run.name, (inDegree.get(run.name) ?? 0) + 1);
if (!dependents.has(dep)) {
dependents.set(dep, []);
}
dependents.get(dep)!.push(run.name);
}
}
}
// Kahn's algorithm producing parallel layers
const groups: TestRunDefinition[][] = [];
let ready = suite.runs.filter((r) => (inDegree.get(r.name) ?? 0) === 0);
let processed = 0;
while (ready.length > 0) {
groups.push(ready);
processed += ready.length;
const nextReady: TestRunDefinition[] = [];
for (const run of ready) {
for (const dep of dependents.get(run.name) ?? []) {
const newDegree = (inDegree.get(dep) ?? 1) - 1;
inDegree.set(dep, newDegree);
if (newDegree === 0) {
nextReady.push(runMap.get(dep)!);
}
}
}
ready = nextReady;
}
if (processed !== suite.runs.length) {
throw new Error(`Circular dependency detected in test suite '${suite.name}'`);
}
return groups;
}
/**
* Validate a parsed test suite definition.
* Returns an array of validation error messages (empty = valid).
*/
static validateSuite(suite: TestSuiteDefinition): string[] {
const errors: string[] = [];
const runNames = new Set<string>();
// Check for duplicate run names
for (const run of suite.runs) {
if (runNames.has(run.name)) {
errors.push(`Duplicate run name: '${run.name}'`);
}
runNames.add(run.name);
}
// Check that all 'needs' references exist
for (const run of suite.runs) {
if (run.needs) {
for (const dep of run.needs) {
if (!runNames.has(dep)) {
errors.push(`Run '${run.name}' depends on unknown run '${dep}'`);
}
}
// Self-dependency
if (run.needs.includes(run.name)) {
errors.push(`Run '${run.name}' depends on itself`);
}
}
}
// Check that at least one test mode is specified per run
for (const run of suite.runs) {
if (!run.editMode && !run.playMode && !run.builtClient) {
errors.push(`Run '${run.name}' must specify at least one of: editMode, playMode, builtClient`);
}
}
// Detect circular dependencies via DFS
const circularError = TestSuiteParser.detectCircularDependencies(suite);
if (circularError) {
errors.push(circularError);
}
return errors;
}
/**
* Detect circular dependencies using DFS cycle detection.
*/
private static detectCircularDependencies(suite: TestSuiteDefinition): string | null {
const adjacency = new Map<string, string[]>();
for (const run of suite.runs) {
adjacency.set(run.name, run.needs ?? []);
}
const visited = new Set<string>();
const visiting = new Set<string>();
const dfs = (node: string, path: string[]): string | null => {
if (visiting.has(node)) {
const cycleStart = path.indexOf(node);
const cycle = path.slice(cycleStart).concat(node);
return `Circular dependency: ${cycle.join(' -> ')}`;
}
if (visited.has(node)) {
return null;
}
visiting.add(node);
path.push(node);
for (const dep of adjacency.get(node) ?? []) {
if (adjacency.has(dep)) {
const result = dfs(dep, [...path]);
if (result) return result;
}
}
visiting.delete(node);
visited.add(node);
return null;
};
for (const run of suite.runs) {
const result = dfs(run.name, []);
if (result) return result;
}
return null;
}
}

View File

@@ -0,0 +1,246 @@
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import path from 'node:path';
import * as core from '@actions/core';
import BuildParameters from '../../../build-parameters';
import { TestSuiteParser } from './test-suite-parser';
import { TaxonomyFilterService } from './taxonomy-filter-service';
import { TestResultReporter } from './test-result-reporter';
import { TestRunDefinition, TestResult } from './test-workflow-types';
const execAsync = promisify(exec);
/**
* Main entry point for the test workflow engine.
* Orchestrates parsing of YAML suite definitions, resolving run order,
* executing test runs via Unity CLI, and collecting structured results.
*/
export class TestWorkflowService {
/**
* Execute a full test suite from a YAML definition file.
* Parses the suite, resolves dependency order, executes each parallel
* group sequentially (runs within a group execute concurrently), and
* collects all results.
*/
static async executeTestSuite(suitePath: string, parameters: BuildParameters): Promise<TestResult[]> {
core.info(`[TestWorkflow] Loading test suite from: ${suitePath}`);
const suite = TestSuiteParser.parseSuiteFile(suitePath);
core.info(`[TestWorkflow] Suite '${suite.name}' loaded with ${suite.runs.length} run(s)`);
if (suite.description) {
core.info(`[TestWorkflow] Description: ${suite.description}`);
}
const groups = TestSuiteParser.resolveRunOrder(suite);
core.info(`[TestWorkflow] Resolved into ${groups.length} execution group(s)`);
const allResults: TestResult[] = [];
let groupIndex = 0;
for (const group of groups) {
groupIndex++;
const runNames = group.map((r) => r.name).join(', ');
core.info(`[TestWorkflow] Executing group ${groupIndex}/${groups.length}: [${runNames}]`);
// Execute runs within a group concurrently
const groupResults = await Promise.all(group.map((run) => TestWorkflowService.executeTestRun(run, parameters)));
allResults.push(...groupResults);
// Check for failures -- if any run in this group failed, log a warning
// but continue to the next group (fail-forward for maximum feedback)
const failedRuns = groupResults.filter((r) => r.failed > 0);
if (failedRuns.length > 0) {
const failedNames = failedRuns.map((r) => r.runName).join(', ');
core.warning(`[TestWorkflow] Failures detected in group ${groupIndex}: [${failedNames}]`);
}
}
// Generate and output summary
const summary = TestResultReporter.generateSummary(allResults);
core.info(summary);
// Write results if output path is configured
const resultPath = parameters.testResultPath;
const resultFormat = parameters.testResultFormat;
if (resultPath) {
TestResultReporter.writeResults(allResults, resultPath, resultFormat as 'junit' | 'json' | 'both');
core.info(`[TestWorkflow] Results written to: ${resultPath}`);
}
return allResults;
}
/**
* Execute a single test run definition.
* Builds the Unity CLI arguments based on the run configuration (edit mode,
* play mode, built client) and taxonomy filters, executes the command
* asynchronously, and parses the result output.
*
* Uses promisified exec instead of execSync so that Promise.all can
* actually run multiple test groups in parallel without blocking the
* Node.js event loop.
*/
static async executeTestRun(run: TestRunDefinition, parameters: BuildParameters): Promise<TestResult> {
core.info(`[TestWorkflow] Starting run: '${run.name}'`);
const unityArguments = TestWorkflowService.buildUnityArgs(run, parameters);
const timeoutMs = (run.timeout ?? 600) * 1000;
core.info(`[TestWorkflow] Unity args: ${unityArguments}`);
const startTime = Date.now();
try {
const resultDirectory = path.join(parameters.testResultPath ?? './test-results', run.name);
const resultFile = path.join(resultDirectory, 'results.xml');
// Build the full Unity command
const unityPath = TestWorkflowService.resolveUnityPath(parameters);
const command = `"${unityPath}" ${unityArguments} -testResults "${resultFile}"`;
core.info(`[TestWorkflow] Executing: ${command}`);
await execAsync(command, {
timeout: timeoutMs,
maxBuffer: 50 * 1024 * 1024, // 50 MB to handle large Unity output
cwd: parameters.projectPath || process.cwd(),
});
const duration = (Date.now() - startTime) / 1000;
// Parse the result file
try {
const result = TestResultReporter.parseJUnitResults(resultFile);
result.runName = run.name;
result.duration = duration;
return result;
} catch {
// Result file may not exist if Unity exited early
core.warning(`[TestWorkflow] Could not parse results for run '${run.name}' -- result file may be missing`);
return {
runName: run.name,
passed: 0,
failed: 0,
skipped: 0,
duration,
failures: [],
};
}
} catch (error: any) {
const duration = (Date.now() - startTime) / 1000;
// The promisified exec sets error.killed when the process is terminated
// due to timeout, and error.signal will be 'SIGTERM'
const isTimeout = error.killed === true || error.signal === 'SIGTERM';
if (isTimeout) {
core.error(`[TestWorkflow] Run '${run.name}' timed out after ${run.timeout ?? 600}s`);
} else {
core.error(`[TestWorkflow] Run '${run.name}' failed: ${error.message}`);
}
// Try to parse partial results even on failure
const resultDirectory = path.join(parameters.testResultPath ?? './test-results', run.name);
const resultFile = path.join(resultDirectory, 'results.xml');
try {
const result = TestResultReporter.parseJUnitResults(resultFile);
result.runName = run.name;
result.duration = duration;
return result;
} catch {
return {
runName: run.name,
passed: 0,
failed: 1,
skipped: 0,
duration,
failures: [
{
testName: isTimeout ? 'Timeout' : 'ExecutionError',
className: run.name,
message: isTimeout
? `Test run timed out after ${run.timeout ?? 600}s`
: error.message ?? 'Unknown execution error',
stackTrace: error.stderr ?? undefined,
},
],
};
}
}
}
/**
* Build Unity CLI arguments for a test run based on its configuration.
*/
static buildUnityArgs(run: TestRunDefinition, parameters: BuildParameters): string {
const unityArguments: string[] = ['-batchmode', '-nographics'];
// Project path
if (parameters.projectPath) {
unityArguments.push(`-projectPath "${parameters.projectPath}"`);
}
// Test mode
if (run.builtClient && run.builtClientPath) {
// Built client testing: run tests against a built player
unityArguments.push(
'-runTests',
`-testPlatform StandalonePlayer`,
`-assemblyNames Assembly-CSharp-Tests`,
`-builtPlayerPath "${run.builtClientPath}"`,
);
} else if (run.editMode && run.playMode) {
// Both modes: run EditMode first, then PlayMode will require a separate invocation
// For combined mode, use EditMode (the service handles sequencing)
unityArguments.push('-runTests', '-testPlatform EditMode');
} else if (run.playMode) {
unityArguments.push('-runTests', '-testPlatform PlayMode');
} else if (run.editMode) {
unityArguments.push('-runTests', '-testPlatform EditMode');
}
// Apply taxonomy filters
if (run.filters && Object.keys(run.filters).length > 0) {
const filterArguments = TaxonomyFilterService.buildFilterArgs(run.filters);
if (filterArguments) {
unityArguments.push(filterArguments);
}
}
// Target platform
if (parameters.targetPlatform) {
unityArguments.push(`-buildTarget ${parameters.targetPlatform}`);
}
return unityArguments.join(' ');
}
/**
* Resolve the path to the Unity editor executable.
*/
private static resolveUnityPath(parameters: BuildParameters): string {
// In CI, Unity path is typically set via environment or the docker container
const environmentUnityPath = process.env.UNITY_PATH ?? process.env.UNITY_EDITOR;
if (environmentUnityPath) {
return environmentUnityPath;
}
// Default paths by platform
if (process.platform === 'win32') {
return `C:/Program Files/Unity/Hub/Editor/${parameters.editorVersion}/Editor/Unity.exe`;
}
if (process.platform === 'darwin') {
return `/Applications/Unity/Hub/Editor/${parameters.editorVersion}/Unity.app/Contents/MacOS/Unity`;
}
// Linux default (Docker container path)
return '/opt/unity/Editor/Unity';
}
}

View File

@@ -0,0 +1,41 @@
export interface TestSuiteDefinition {
name: string;
description?: string;
runs: TestRunDefinition[];
}
export interface TestRunDefinition {
name: string;
needs?: string[];
editMode?: boolean;
playMode?: boolean;
builtClient?: boolean;
builtClientPath?: string;
filters?: Record<string, string>; // dimension -> comma-separated values or /regex/
timeout?: number;
}
export interface TaxonomyDimension {
name: string;
values: string[];
}
export interface TaxonomyDefinition {
extensible_groups: TaxonomyDimension[];
}
export interface TestResult {
runName: string;
passed: number;
failed: number;
skipped: number;
duration: number;
failures: TestFailure[];
}
export interface TestFailure {
testName: string;
className: string;
message: string;
stackTrace?: string;
}

View File

@@ -0,0 +1,562 @@
import fs from 'node:fs';
import { TestSuiteParser } from './test-suite-parser';
import { TaxonomyFilterService } from './taxonomy-filter-service';
import { TestResultReporter } from './test-result-reporter';
import { TestWorkflowService } from './test-workflow-service';
import { TestSuiteDefinition, TestResult, TestRunDefinition } from './test-workflow-types';
jest.mock('node:fs');
jest.mock('@actions/core');
const mockFs = fs as jest.Mocked<typeof fs>;
afterEach(() => {
jest.clearAllMocks();
});
// ============================================================================
// Test Suite Parser
// ============================================================================
describe('TestSuiteParser', () => {
describe('parseSuiteFile', () => {
it('should parse a valid YAML suite file', () => {
const yaml = `
name: pull-request
description: Fast feedback for pull requests
runs:
- name: fast
editMode: true
filters:
Maturity: Trusted
FeedbackSpeed: Fast,Moderate
timeout: 300
- name: basic
needs: [fast]
editMode: true
playMode: true
filters:
Maturity: Trusted,Adolescent
timeout: 600
`;
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(yaml);
const suite = TestSuiteParser.parseSuiteFile('/path/to/suite.yml');
expect(suite.name).toBe('pull-request');
expect(suite.description).toBe('Fast feedback for pull requests');
expect(suite.runs).toHaveLength(2);
expect(suite.runs[0].name).toBe('fast');
expect(suite.runs[0].editMode).toBe(true);
expect(suite.runs[0].filters?.Maturity).toBe('Trusted');
expect(suite.runs[0].timeout).toBe(300);
expect(suite.runs[1].needs).toEqual(['fast']);
});
it('should throw when file does not exist', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => TestSuiteParser.parseSuiteFile('/missing.yml')).toThrow('not found');
});
it('should throw on invalid YAML', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue('not: valid: yaml: [');
expect(() => TestSuiteParser.parseSuiteFile('/bad.yml')).toThrow();
});
it('should throw when suite has no name', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue('runs:\n - name: test\n editMode: true');
expect(() => TestSuiteParser.parseSuiteFile('/no-name.yml')).toThrow("'name'");
});
it('should throw when suite has no runs', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue('name: test');
expect(() => TestSuiteParser.parseSuiteFile('/no-runs.yml')).toThrow("'runs'");
});
it('should throw on invalid needs reference', () => {
const yaml = `
name: bad-deps
runs:
- name: first
editMode: true
needs: [nonexistent]
`;
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(yaml);
expect(() => TestSuiteParser.parseSuiteFile('/bad-deps.yml')).toThrow("unknown run 'nonexistent'");
});
});
describe('resolveRunOrder', () => {
it('should return single group when no dependencies exist', () => {
const suite: TestSuiteDefinition = {
name: 'test',
runs: [
{ name: 'a', editMode: true },
{ name: 'b', playMode: true },
],
};
const groups = TestSuiteParser.resolveRunOrder(suite);
expect(groups).toHaveLength(1);
expect(groups[0]).toHaveLength(2);
});
it('should resolve linear dependencies into sequential groups', () => {
const suite: TestSuiteDefinition = {
name: 'test',
runs: [
{ name: 'a', editMode: true },
{ name: 'b', needs: ['a'], playMode: true },
{ name: 'c', needs: ['b'], editMode: true },
],
};
const groups = TestSuiteParser.resolveRunOrder(suite);
expect(groups).toHaveLength(3);
expect(groups[0][0].name).toBe('a');
expect(groups[1][0].name).toBe('b');
expect(groups[2][0].name).toBe('c');
});
it('should place independent runs in the same group', () => {
const suite: TestSuiteDefinition = {
name: 'test',
runs: [
{ name: 'root', editMode: true },
{ name: 'branch-a', needs: ['root'], playMode: true },
{ name: 'branch-b', needs: ['root'], editMode: true },
],
};
const groups = TestSuiteParser.resolveRunOrder(suite);
expect(groups).toHaveLength(2);
expect(groups[0]).toHaveLength(1);
expect(groups[1]).toHaveLength(2);
});
it('should detect circular dependencies', () => {
const suite: TestSuiteDefinition = {
name: 'circular',
runs: [
{ name: 'a', needs: ['b'], editMode: true },
{ name: 'b', needs: ['a'], playMode: true },
],
};
expect(() => TestSuiteParser.resolveRunOrder(suite)).toThrow('Circular dependency');
});
it('should detect three-way circular dependencies', () => {
const suite: TestSuiteDefinition = {
name: 'circular3',
runs: [
{ name: 'a', needs: ['c'], editMode: true },
{ name: 'b', needs: ['a'], playMode: true },
{ name: 'c', needs: ['b'], editMode: true },
],
};
expect(() => TestSuiteParser.resolveRunOrder(suite)).toThrow('Circular dependency');
});
});
describe('validateSuite', () => {
it('should return no errors for a valid suite', () => {
const suite: TestSuiteDefinition = {
name: 'valid',
runs: [
{ name: 'a', editMode: true },
{ name: 'b', needs: ['a'], playMode: true },
],
};
const errors = TestSuiteParser.validateSuite(suite);
expect(errors).toHaveLength(0);
});
it('should detect duplicate run names', () => {
const suite: TestSuiteDefinition = {
name: 'dupes',
runs: [
{ name: 'a', editMode: true },
{ name: 'a', playMode: true },
],
};
const errors = TestSuiteParser.validateSuite(suite);
expect(errors.some((e) => e.includes('Duplicate'))).toBe(true);
});
it('should detect missing test mode', () => {
const suite: TestSuiteDefinition = {
name: 'no-mode',
runs: [{ name: 'empty' }],
};
const errors = TestSuiteParser.validateSuite(suite);
expect(errors.some((e) => e.includes('editMode'))).toBe(true);
});
it('should detect self-dependency', () => {
const suite: TestSuiteDefinition = {
name: 'self-dep',
runs: [{ name: 'a', needs: ['a'], editMode: true }],
};
const errors = TestSuiteParser.validateSuite(suite);
expect(errors.some((e) => e.includes('depends on itself'))).toBe(true);
});
});
});
// ============================================================================
// Taxonomy Filter Service
// ============================================================================
describe('TaxonomyFilterService', () => {
describe('loadTaxonomy', () => {
it('should return built-in dimensions when no file provided', () => {
const dimensions = TaxonomyFilterService.loadTaxonomy();
expect(dimensions.length).toBeGreaterThanOrEqual(7);
expect(dimensions.find((d) => d.name === 'Scope')).toBeDefined();
expect(dimensions.find((d) => d.name === 'Maturity')).toBeDefined();
expect(dimensions.find((d) => d.name === 'FeedbackSpeed')).toBeDefined();
expect(dimensions.find((d) => d.name === 'Execution')).toBeDefined();
expect(dimensions.find((d) => d.name === 'Rigor')).toBeDefined();
expect(dimensions.find((d) => d.name === 'Determinism')).toBeDefined();
expect(dimensions.find((d) => d.name === 'IsolationLevel')).toBeDefined();
});
it('should merge custom dimensions from file', () => {
const yaml = `
extensible_groups:
- name: SubjectLevel
values: [Class, Feature, System, Product]
- name: DataScenario
values: [HappyPath, EdgeCase]
`;
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(yaml);
const dimensions = TaxonomyFilterService.loadTaxonomy('/taxonomy.yml');
expect(dimensions.find((d) => d.name === 'SubjectLevel')).toBeDefined();
expect(dimensions.find((d) => d.name === 'DataScenario')).toBeDefined();
// Built-ins should still exist
expect(dimensions.find((d) => d.name === 'Scope')).toBeDefined();
});
it('should merge values for existing dimensions', () => {
const yaml = `
extensible_groups:
- name: Scope
values: [Unit, Integration, Acceptance]
`;
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(yaml);
const dimensions = TaxonomyFilterService.loadTaxonomy('/taxonomy.yml');
const scope = dimensions.find((d) => d.name === 'Scope');
expect(scope).toBeDefined();
// Should contain built-in + custom values without duplicates
expect(scope!.values).toContain('Unit');
expect(scope!.values).toContain('Integration');
expect(scope!.values).toContain('Acceptance');
expect(scope!.values).toContain('System');
});
});
describe('buildFilterArgs', () => {
it('should return empty string for empty filters', () => {
expect(TaxonomyFilterService.buildFilterArgs({})).toBe('');
});
it('should build single-value filter', () => {
const result = TaxonomyFilterService.buildFilterArgs({ Maturity: 'Trusted' });
expect(result).toBe('--testFilter "Maturity=Trusted"');
});
it('should build multi-value filter with pipe separator', () => {
const result = TaxonomyFilterService.buildFilterArgs({ Scope: 'Unit,Integration' });
expect(result).toBe('--testFilter "Scope=Unit|Integration"');
});
it('should build regex filter', () => {
const result = TaxonomyFilterService.buildFilterArgs({ Maturity: '/Trusted|Adolescent/' });
expect(result).toBe('--testFilter "Maturity=~Trusted|Adolescent"');
});
it('should AND multiple dimensions with semicolon', () => {
const result = TaxonomyFilterService.buildFilterArgs({
Maturity: 'Trusted',
Scope: 'Unit',
});
expect(result).toContain(';');
expect(result).toContain('Maturity=Trusted');
expect(result).toContain('Scope=Unit');
});
});
describe('matchesFilter', () => {
it('should match exact value', () => {
const match = TaxonomyFilterService.matchesFilter({ Scope: 'Unit', Maturity: 'Trusted' }, { Scope: 'Unit' });
expect(match).toBe(true);
});
it('should match comma-separated values', () => {
const match = TaxonomyFilterService.matchesFilter({ Scope: 'Integration' }, { Scope: 'Unit,Integration' });
expect(match).toBe(true);
});
it('should not match when value is not in list', () => {
const match = TaxonomyFilterService.matchesFilter({ Scope: 'End To End' }, { Scope: 'Unit,Integration' });
expect(match).toBe(false);
});
it('should require all dimensions to match (AND)', () => {
const match = TaxonomyFilterService.matchesFilter(
{ Scope: 'Unit', Maturity: 'Experimental' },
{ Scope: 'Unit', Maturity: 'Trusted' },
);
expect(match).toBe(false);
});
it('should match regex patterns', () => {
const match = TaxonomyFilterService.matchesFilter({ Maturity: 'Trusted' }, { Maturity: '/Trusted|Adolescent/' });
expect(match).toBe(true);
});
it('should not match when regex does not match', () => {
const match = TaxonomyFilterService.matchesFilter(
{ Maturity: 'Experimental' },
{ Maturity: '/Trusted|Adolescent/' },
);
expect(match).toBe(false);
});
it('should return false when test lacks a required dimension', () => {
const match = TaxonomyFilterService.matchesFilter({}, { Scope: 'Unit' });
expect(match).toBe(false);
});
it('should handle hierarchical dot-notation matching', () => {
const match = TaxonomyFilterService.matchesFilter({ Domain: 'Combat.Melee.Sword' }, { Domain: 'Combat.Melee' });
expect(match).toBe(true);
});
});
});
// ============================================================================
// Test Result Reporter
// ============================================================================
describe('TestResultReporter', () => {
describe('parseJUnitXml', () => {
it('should parse a valid JUnit XML string', () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<testsuite name="EditMode" tests="10" failures="2" skipped="1" time="5.432">
<testcase classname="MyTests" name="TestA">
</testcase>
<testcase classname="MyTests" name="TestB">
<failure message="Expected true">
<![CDATA[at MyTests.TestB() in TestFile.cs:42]]>
</failure>
</testcase>
</testsuite>`;
const result = TestResultReporter.parseJUnitXml(xml);
expect(result.runName).toBe('EditMode');
expect(result.passed).toBe(7);
expect(result.failed).toBe(2);
expect(result.skipped).toBe(1);
expect(result.duration).toBeCloseTo(5.432);
expect(result.failures).toHaveLength(1);
expect(result.failures[0].testName).toBe('TestB');
expect(result.failures[0].className).toBe('MyTests');
expect(result.failures[0].message).toBe('Expected true');
expect(result.failures[0].stackTrace).toContain('TestFile.cs:42');
});
it('should handle empty test suite', () => {
const xml = `<testsuite name="Empty" tests="0" failures="0" time="0.0"></testsuite>`;
const result = TestResultReporter.parseJUnitXml(xml);
expect(result.passed).toBe(0);
expect(result.failed).toBe(0);
expect(result.failures).toHaveLength(0);
});
});
describe('parseJsonData', () => {
it('should parse Unity JSON test result data', () => {
const data = {
name: 'PlayMode',
passed: 15,
failed: 2,
skipped: 3,
duration: 12.5,
testResults: [{ name: 'FailingTest', className: 'MyClass', result: 'Failed', message: 'Assert failed' }],
};
const result = TestResultReporter.parseJsonData(data);
expect(result.runName).toBe('PlayMode');
expect(result.passed).toBe(15);
expect(result.failed).toBe(2);
expect(result.skipped).toBe(3);
expect(result.failures).toHaveLength(1);
expect(result.failures[0].testName).toBe('FailingTest');
});
});
describe('generateSummary', () => {
it('should generate a markdown summary table', () => {
const results: TestResult[] = [
{
runName: 'fast',
passed: 10,
failed: 0,
skipped: 2,
duration: 5.0,
failures: [],
},
{
runName: 'basic',
passed: 20,
failed: 1,
skipped: 0,
duration: 30.0,
failures: [{ testName: 'TestX', className: 'ClassX', message: 'Expected 1 but got 2' }],
},
];
const summary = TestResultReporter.generateSummary(results);
expect(summary).toContain('Test Results Summary');
expect(summary).toContain('fast');
expect(summary).toContain('basic');
expect(summary).toContain('Total');
expect(summary).toContain('Failures');
expect(summary).toContain('TestX');
});
it('should return message when no results available', () => {
const summary = TestResultReporter.generateSummary([]);
expect(summary).toContain('No test results');
});
});
describe('writeResults', () => {
it('should create output directory and write files for json format', () => {
mockFs.existsSync.mockReturnValue(false);
mockFs.mkdirSync.mockReturnValue(undefined as any);
mockFs.writeFileSync.mockReturnValue(undefined);
const results: TestResult[] = [
{ runName: 'test', passed: 5, failed: 0, skipped: 0, duration: 1.0, failures: [] },
];
TestResultReporter.writeResults(results, '/output', 'json');
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/output', { recursive: true });
// Should write JSON + summary
expect(mockFs.writeFileSync).toHaveBeenCalledTimes(2);
});
it('should write both formats when specified', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.writeFileSync.mockReturnValue(undefined);
const results: TestResult[] = [
{ runName: 'test', passed: 5, failed: 0, skipped: 0, duration: 1.0, failures: [] },
];
TestResultReporter.writeResults(results, '/output', 'both');
// Should write JSON + JUnit XML + summary = 3 calls
expect(mockFs.writeFileSync).toHaveBeenCalledTimes(3);
});
});
});
// ============================================================================
// Test Workflow Service - buildUnityArgs
// ============================================================================
describe('TestWorkflowService', () => {
describe('buildUnityArgs', () => {
const baseParams = {
projectPath: '/project',
targetPlatform: 'StandaloneLinux64',
testResultPath: './test-results',
testResultFormat: 'junit',
} as any;
it('should build EditMode args', () => {
const run: TestRunDefinition = { name: 'edit', editMode: true };
const args = TestWorkflowService.buildUnityArgs(run, baseParams);
expect(args).toContain('-batchmode');
expect(args).toContain('-nographics');
expect(args).toContain('-runTests');
expect(args).toContain('-testPlatform EditMode');
expect(args).toContain('-projectPath');
});
it('should build PlayMode args', () => {
const run: TestRunDefinition = { name: 'play', playMode: true };
const args = TestWorkflowService.buildUnityArgs(run, baseParams);
expect(args).toContain('-testPlatform PlayMode');
});
it('should build built-client args', () => {
const run: TestRunDefinition = {
name: 'client',
builtClient: true,
builtClientPath: './Builds/Linux',
};
const args = TestWorkflowService.buildUnityArgs(run, baseParams);
expect(args).toContain('-testPlatform StandalonePlayer');
expect(args).toContain('-builtPlayerPath');
expect(args).toContain('./Builds/Linux');
});
it('should include taxonomy filter args', () => {
const run: TestRunDefinition = {
name: 'filtered',
editMode: true,
filters: { Maturity: 'Trusted', Scope: 'Unit,Integration' },
};
const args = TestWorkflowService.buildUnityArgs(run, baseParams);
expect(args).toContain('--testFilter');
expect(args).toContain('Maturity=Trusted');
});
it('should include build target', () => {
const run: TestRunDefinition = { name: 'test', editMode: true };
const args = TestWorkflowService.buildUnityArgs(run, baseParams);
expect(args).toContain('-buildTarget StandaloneLinux64');
});
});
});

View File

@@ -0,0 +1,5 @@
# Test Workflow Engine
Service for YAML-based test suite definitions, taxonomy filtering, and structured test results.
See GitHub Issue for full specification.