Compare commits

..

6 Commits

Author SHA1 Message Date
frostebite
55b45a4a0c 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:01:07 +00:00
frostebite
ae03bd2f13 ci: set macOS builds to continue-on-error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:33:16 +00:00
frostebite
1e2bb889bf style: fix prettier formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:13:02 +00:00
frostebite
7615bbd9dd fix(artifacts): validate rclone availability before storage upload
Check for rclone binary before attempting storage-based uploads.
Validate storage destination URI format (remoteName:path).
Provide clear error message with install link when rclone is missing.
Fail gracefully instead of cryptic ENOENT crash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:00:13 +00:00
frostebite
aa2e05d468 feat(artifacts): complete generic artifact system with upload handlers, tests, and action integration (#798)
- Add ArtifactUploadHandler with support for github-artifacts, storage (rclone),
  and local copy upload targets, including large file chunking for GitHub Artifacts
- Add 44 unit tests covering OutputTypeRegistry, OutputService, and
  ArtifactUploadHandler (config parsing, upload coordination, file collection)
- Add 6 new action.yml inputs for artifact configuration
- Add artifactManifestPath action output
- Wire artifact collection and upload into index.ts post-build flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:51:43 +00:00
frostebite
b3e1639029 feat(orchestrator): generic artifact system — output types, manifests, and collection service
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:38:55 +00:00
20 changed files with 2073 additions and 2655 deletions

View File

@@ -182,8 +182,8 @@ inputs:
required: false
default: ''
description:
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with
the keys image, secrets (name, value object array), command line string)'
'[Orchestrator] Run a custom job instead of the standard build automation for orchestrator (in yaml format with the
keys image, secrets (name, value object array), command line string)'
awsStackName:
default: 'game-ci'
required: false
@@ -269,6 +269,28 @@ inputs:
default: 'false'
required: false
description: 'Skip the activation/deactivation of Unity. This assumes Unity is already activated.'
artifactOutputTypes:
description: 'Comma-separated list of output types to collect (build, logs, test-results, coverage, images, metrics, data-export, server-build, custom)'
required: false
default: 'build,logs,test-results'
artifactUploadTarget:
description: 'Where to upload artifacts: github-artifacts, storage, local, none'
required: false
default: 'github-artifacts'
artifactUploadPath:
description: 'Destination path for artifact upload (storage URI or local path)'
required: false
artifactCompression:
description: 'Compression for artifacts: none, gzip, lz4'
required: false
default: 'gzip'
artifactRetentionDays:
description: 'Retention period for uploaded artifacts in days'
required: false
default: '30'
artifactCustomTypes:
description: 'JSON string defining custom output types [{name, defaultPath, description}]'
required: false
cloneDepth:
default: '50'
required: false
@@ -279,23 +301,6 @@ inputs:
description:
'[Orchestrator] Specifies the repo for the unity builder. Useful if you forked the repo for testing, features, or
fixes.'
testSuitePath:
description: 'Path to YAML test suite definition file'
required: false
testSuiteEvent:
description: 'CI event name for suite selection (pr, push, release)'
required: false
testTaxonomyPath:
description: 'Path to custom taxonomy definition YAML'
required: false
testResultFormat:
description: 'Test result output format: junit, json, or both'
required: false
default: 'junit'
testResultPath:
description: 'Directory for structured test result output'
required: false
default: './test-results'
outputs:
volume:
@@ -309,6 +314,8 @@ outputs:
'Returns the exit code from the build scripts. This code is 0 if the build was successful. If there was an error
during activation, the code is from the activation step. If activation is successful, the code is from the project
build step.'
artifactManifestPath:
description: 'Path to the generated artifact manifest JSON file'
branding:
icon: 'box'
color: 'gray-dark'

1512
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

@@ -1,9 +1,12 @@
import * as core from '@actions/core';
import path from 'node:path';
import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output } from './model';
import { Cli } from './model/cli/cli';
import MacBuilder from './model/mac-builder';
import PlatformSetup from './model/platform-setup';
import { TestWorkflowService } from './model/orchestrator/services/test-workflow';
import { OutputService } from './model/orchestrator/services/output/output-service';
import { OutputTypeRegistry } from './model/orchestrator/services/output/output-type-registry';
import { ArtifactUploadHandler } from './model/orchestrator/services/output/artifact-upload-handler';
async function runMain() {
try {
@@ -18,23 +21,6 @@ async function runMain() {
const { workspace, actionFolder } = Action;
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);
let exitCode = -1;
@@ -60,6 +46,64 @@ async function runMain() {
await Output.setAndroidVersionCode(buildParameters.androidVersionCode);
await Output.setEngineExitCode(exitCode);
// Artifact collection and upload (runs on both success and failure)
try {
// Register custom output types if provided
if (buildParameters.artifactCustomTypes) {
try {
const customTypes = JSON.parse(buildParameters.artifactCustomTypes);
if (Array.isArray(customTypes)) {
for (const ct of customTypes) {
OutputTypeRegistry.registerType({
name: ct.name,
defaultPath: ct.defaultPath || ct.pattern || `./${ct.name}/`,
description: ct.description || `Custom output type: ${ct.name}`,
builtIn: false,
});
}
}
} catch (parseError) {
core.warning(`Failed to parse artifactCustomTypes: ${(parseError as Error).message}`);
}
}
// Collect outputs and generate manifest
const manifestPath = path.join(buildParameters.projectPath, 'output-manifest.json');
const manifest = await OutputService.collectOutputs(
buildParameters.projectPath,
buildParameters.buildGuid,
buildParameters.artifactOutputTypes,
manifestPath,
);
core.setOutput('artifactManifestPath', manifestPath);
// Upload artifacts
const uploadConfig = ArtifactUploadHandler.parseConfig(
buildParameters.artifactUploadTarget,
buildParameters.artifactUploadPath || undefined,
buildParameters.artifactCompression,
buildParameters.artifactRetentionDays,
);
const uploadResult = await ArtifactUploadHandler.uploadArtifacts(
manifest,
uploadConfig,
buildParameters.projectPath,
);
if (!uploadResult.success) {
core.warning(
`Artifact upload completed with errors: ${uploadResult.entries
.filter((e) => !e.success)
.map((e) => `${e.type}: ${e.error}`)
.join('; ')}`,
);
}
} catch (artifactError) {
core.warning(`Artifact collection/upload failed: ${(artifactError as Error).message}`);
}
if (exitCode !== 0) {
core.setFailed(`Build failed with exit code ${exitCode}`);
}

View File

@@ -106,12 +106,12 @@ class BuildParameters {
public cacheUnityInstallationOnMac!: boolean;
public unityHubVersionOnMac!: string;
public dockerWorkspacePath!: string;
public testSuitePath!: string;
public testSuiteEvent!: string;
public testTaxonomyPath!: string;
public testResultFormat!: string;
public testResultPath!: string;
public artifactOutputTypes!: string;
public artifactUploadTarget!: string;
public artifactUploadPath!: string;
public artifactCompression!: string;
public artifactRetentionDays!: string;
public artifactCustomTypes!: string;
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
@@ -248,11 +248,12 @@ class BuildParameters {
cacheUnityInstallationOnMac: Input.cacheUnityInstallationOnMac,
unityHubVersionOnMac: Input.unityHubVersionOnMac,
dockerWorkspacePath: Input.dockerWorkspacePath,
testSuitePath: Input.testSuitePath,
testSuiteEvent: Input.testSuiteEvent,
testTaxonomyPath: Input.testTaxonomyPath,
testResultFormat: Input.testResultFormat,
testResultPath: Input.testResultPath,
artifactOutputTypes: Input.artifactOutputTypes,
artifactUploadTarget: Input.artifactUploadTarget,
artifactUploadPath: Input.artifactUploadPath,
artifactCompression: Input.artifactCompression,
artifactRetentionDays: Input.artifactRetentionDays,
artifactCustomTypes: Input.artifactCustomTypes,
};
}

View File

@@ -278,30 +278,34 @@ class Input {
return Input.getInput('containerRegistryImageVersion') ?? '3';
}
static get artifactOutputTypes(): string {
return Input.getInput('artifactOutputTypes') ?? 'build,logs,test-results';
}
static get artifactUploadTarget(): string {
return Input.getInput('artifactUploadTarget') ?? 'github-artifacts';
}
static get artifactUploadPath(): string {
return Input.getInput('artifactUploadPath') ?? '';
}
static get artifactCompression(): string {
return Input.getInput('artifactCompression') ?? 'gzip';
}
static get artifactRetentionDays(): string {
return Input.getInput('artifactRetentionDays') ?? '30';
}
static get artifactCustomTypes(): string {
return Input.getInput('artifactCustomTypes') ?? '';
}
static get skipActivation(): string {
return Input.getInput('skipActivation')?.toLowerCase() ?? 'false';
}
static get testSuitePath(): string {
return Input.getInput('testSuitePath') ?? '';
}
static get testSuiteEvent(): string {
return Input.getInput('testSuiteEvent') ?? '';
}
static get testTaxonomyPath(): string {
return Input.getInput('testTaxonomyPath') ?? '';
}
static get testResultFormat(): string {
return Input.getInput('testResultFormat') ?? 'junit';
}
static get testResultPath(): string {
return Input.getInput('testResultPath') ?? './test-results';
}
public static ToEnvVarFormat(input: string) {
if (input.toUpperCase() === input) {
return input;

View File

@@ -0,0 +1,607 @@
import fs from 'node:fs';
import path from 'node:path';
import { OutputTypeRegistry, OutputTypeDefinition } from './output-type-registry';
import { OutputService } from './output-service';
import { OutputManifest } from './output-manifest';
import { ArtifactUploadHandler, ArtifactUploadConfig } from './artifact-upload-handler';
// Mock node:fs
jest.mock('node:fs');
const mockedFs = fs as jest.Mocked<typeof fs>;
// Mock @actions/core (used by OrchestratorLogger)
jest.mock('@actions/core', () => ({
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
setOutput: jest.fn(),
getInput: jest.fn(),
setFailed: jest.fn(),
setSecret: jest.fn(),
}));
// Mock @actions/exec (used by upload handler for rclone)
jest.mock('@actions/exec', () => ({
exec: jest.fn().mockResolvedValue(0),
}));
afterEach(() => {
jest.restoreAllMocks();
OutputTypeRegistry.resetCustomTypes();
});
// ---------------------------------------------------------------------------
// OutputTypeRegistry Tests
// ---------------------------------------------------------------------------
describe('OutputTypeRegistry', () => {
describe('built-in types', () => {
it('should have 8 built-in types', () => {
const allTypes = OutputTypeRegistry.getAllTypes();
const builtInTypes = allTypes.filter((t) => t.builtIn);
expect(builtInTypes).toHaveLength(8);
});
it.each(['build', 'test-results', 'server-build', 'data-export', 'images', 'logs', 'metrics', 'coverage'])(
'should include built-in type "%s"',
(typeName) => {
const typeDef = OutputTypeRegistry.getType(typeName);
expect(typeDef).toBeDefined();
expect(typeDef!.name).toBe(typeName);
expect(typeDef!.builtIn).toBe(true);
},
);
it('should return undefined for unknown types', () => {
const typeDef = OutputTypeRegistry.getType('nonexistent');
expect(typeDef).toBeUndefined();
});
it('should include default paths for all built-in types', () => {
const allTypes = OutputTypeRegistry.getAllTypes();
for (const typeDef of allTypes) {
expect(typeDef.defaultPath).toBeTruthy();
expect(typeof typeDef.defaultPath).toBe('string');
}
});
it('should include descriptions for all built-in types', () => {
const allTypes = OutputTypeRegistry.getAllTypes();
for (const typeDef of allTypes) {
expect(typeDef.description).toBeTruthy();
expect(typeof typeDef.description).toBe('string');
}
});
});
describe('custom type registration', () => {
it('should register a custom type', () => {
const customType: OutputTypeDefinition = {
name: 'custom-reports',
defaultPath: './Reports/',
description: 'Custom generated reports',
builtIn: false,
};
OutputTypeRegistry.registerType(customType);
const retrieved = OutputTypeRegistry.getType('custom-reports');
expect(retrieved).toBeDefined();
expect(retrieved!.name).toBe('custom-reports');
expect(retrieved!.builtIn).toBe(false);
});
it('should not override built-in types', () => {
const override: OutputTypeDefinition = {
name: 'build',
defaultPath: './Override/',
description: 'Should not override',
builtIn: false,
};
OutputTypeRegistry.registerType(override);
const buildType = OutputTypeRegistry.getType('build');
expect(buildType!.defaultPath).not.toBe('./Override/');
expect(buildType!.builtIn).toBe(true);
});
it('should include custom types in getAllTypes', () => {
OutputTypeRegistry.registerType({
name: 'custom-a',
defaultPath: './A/',
description: 'Custom A',
builtIn: false,
});
const allTypes = OutputTypeRegistry.getAllTypes();
expect(allTypes.length).toBe(9); // 8 built-in + 1 custom
expect(allTypes.some((t) => t.name === 'custom-a')).toBe(true);
});
it('should reset custom types', () => {
OutputTypeRegistry.registerType({
name: 'temp-type',
defaultPath: './Temp/',
description: 'Temporary type',
builtIn: false,
});
expect(OutputTypeRegistry.getType('temp-type')).toBeDefined();
OutputTypeRegistry.resetCustomTypes();
expect(OutputTypeRegistry.getType('temp-type')).toBeUndefined();
});
it('should force builtIn to false when registering custom types', () => {
OutputTypeRegistry.registerType({
name: 'sneaky',
defaultPath: './Sneaky/',
description: 'Tries to be built-in',
builtIn: true, // Intentionally setting to true
});
const retrieved = OutputTypeRegistry.getType('sneaky');
expect(retrieved).toBeDefined();
expect(retrieved!.builtIn).toBe(false);
});
});
describe('parseOutputTypes', () => {
it('should parse a comma-separated string of valid types', () => {
const types = OutputTypeRegistry.parseOutputTypes('build,logs,coverage');
expect(types).toHaveLength(3);
expect(types.map((t) => t.name)).toEqual(['build', 'logs', 'coverage']);
});
it('should skip unknown types', () => {
const types = OutputTypeRegistry.parseOutputTypes('build,unknown,logs');
expect(types).toHaveLength(2);
expect(types.map((t) => t.name)).toEqual(['build', 'logs']);
});
it('should handle empty string', () => {
const types = OutputTypeRegistry.parseOutputTypes('');
expect(types).toHaveLength(0);
});
it('should handle whitespace in type names', () => {
const types = OutputTypeRegistry.parseOutputTypes(' build , logs , coverage ');
expect(types).toHaveLength(3);
});
it('should include custom types when parsing', () => {
OutputTypeRegistry.registerType({
name: 'my-reports',
defaultPath: './Reports/',
description: 'Custom reports',
builtIn: false,
});
const types = OutputTypeRegistry.parseOutputTypes('build,my-reports');
expect(types).toHaveLength(2);
expect(types[1].name).toBe('my-reports');
});
});
});
// ---------------------------------------------------------------------------
// OutputService Tests
// ---------------------------------------------------------------------------
describe('OutputService', () => {
const projectPath = '/project';
const buildGuid = 'test-guid-1234';
beforeEach(() => {
// Reset all fs mocks
mockedFs.existsSync.mockReset();
mockedFs.statSync.mockReset();
mockedFs.readdirSync.mockReset();
mockedFs.writeFileSync.mockReset();
mockedFs.mkdirSync.mockReset();
});
describe('collectOutputs', () => {
it('should return an empty manifest when no output types are declared', async () => {
const manifest = await OutputService.collectOutputs(projectPath, buildGuid, '');
expect(manifest.buildGuid).toBe(buildGuid);
expect(manifest.outputs).toHaveLength(0);
expect(manifest.timestamp).toBeTruthy();
});
it('should skip outputs where the path does not exist', async () => {
mockedFs.existsSync.mockReturnValue(false);
const manifest = await OutputService.collectOutputs(projectPath, buildGuid, 'build,logs');
expect(manifest.outputs).toHaveLength(0);
});
it('should collect directory outputs with file listings', async () => {
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => true, size: 0 } as any);
mockedFs.readdirSync.mockImplementation((_dirPath: any, options?: any) => {
if (options?.withFileTypes) {
return [
{ name: 'file1.txt', isDirectory: () => false },
{ name: 'file2.txt', isDirectory: () => false },
] as any;
}
return ['file1.txt', 'file2.txt'] as any;
});
const manifest = await OutputService.collectOutputs(projectPath, buildGuid, 'logs');
expect(manifest.outputs).toHaveLength(1);
expect(manifest.outputs[0].type).toBe('logs');
expect(manifest.outputs[0].files).toEqual(['file1.txt', 'file2.txt']);
});
it('should collect file output with correct size', async () => {
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 4096 } as any);
const manifest = await OutputService.collectOutputs(projectPath, buildGuid, 'coverage');
expect(manifest.outputs).toHaveLength(1);
expect(manifest.outputs[0].size).toBe(4096);
});
it('should write manifest to disk when manifestPath is provided', async () => {
// existsSync returns false for output paths (no outputs found) but mkdirSync/writeFileSync should still be called
// The service only writes manifest when at least one output type is declared and types are resolved
// So we need to provide a valid output type and have its path exist
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 100 } as any);
mockedFs.mkdirSync.mockReturnValue(undefined);
mockedFs.writeFileSync.mockImplementation(() => {});
const manifestPath = '/output/manifest.json';
await OutputService.collectOutputs(projectPath, buildGuid, 'logs', manifestPath);
expect(mockedFs.mkdirSync).toHaveBeenCalledWith(path.dirname(manifestPath), { recursive: true });
expect(mockedFs.writeFileSync).toHaveBeenCalledWith(manifestPath, expect.any(String), 'utf8');
});
it('should generate valid JSON in the manifest file', async () => {
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 200 } as any);
mockedFs.mkdirSync.mockReturnValue(undefined);
mockedFs.writeFileSync.mockImplementation(() => {});
const manifestPath = '/output/manifest.json';
await OutputService.collectOutputs(projectPath, buildGuid, 'coverage', manifestPath);
const writtenContent = (mockedFs.writeFileSync as jest.Mock).mock.calls[0][1];
const parsed = JSON.parse(writtenContent);
expect(parsed.buildGuid).toBe(buildGuid);
expect(Array.isArray(parsed.outputs)).toBe(true);
expect(parsed.outputs.length).toBeGreaterThan(0);
});
it('should set a valid ISO 8601 timestamp', async () => {
const manifest = await OutputService.collectOutputs(projectPath, buildGuid, '');
const parsed = new Date(manifest.timestamp);
expect(parsed.toISOString()).toBe(manifest.timestamp);
});
});
});
// ---------------------------------------------------------------------------
// ArtifactUploadHandler Tests
// ---------------------------------------------------------------------------
describe('ArtifactUploadHandler', () => {
const projectPath = '/project';
beforeEach(() => {
mockedFs.existsSync.mockReset();
mockedFs.statSync.mockReset();
mockedFs.readdirSync.mockReset();
mockedFs.mkdirSync.mockReset();
mockedFs.copyFileSync.mockReset();
});
describe('parseConfig', () => {
it('should parse valid config values', () => {
const config = ArtifactUploadHandler.parseConfig('github-artifacts', '/dest', 'gzip', '14');
expect(config.target).toBe('github-artifacts');
expect(config.destination).toBe('/dest');
expect(config.compression).toBe('gzip');
expect(config.retentionDays).toBe(14);
});
it('should default invalid target to github-artifacts', () => {
const config = ArtifactUploadHandler.parseConfig('invalid', undefined, 'none', '30');
expect(config.target).toBe('github-artifacts');
});
it('should default invalid compression to gzip', () => {
const config = ArtifactUploadHandler.parseConfig('local', '/dest', 'brotli', '30');
expect(config.compression).toBe('gzip');
});
it('should default invalid retention to 30 days', () => {
const config = ArtifactUploadHandler.parseConfig('local', '/dest', 'gzip', 'abc');
expect(config.retentionDays).toBe(30);
});
it('should default negative retention to 30 days', () => {
const config = ArtifactUploadHandler.parseConfig('local', '/dest', 'gzip', '-5');
expect(config.retentionDays).toBe(30);
});
it('should set destination to undefined when empty string', () => {
const config = ArtifactUploadHandler.parseConfig('storage', '', 'none', '7');
expect(config.destination).toBeUndefined();
});
});
describe('uploadArtifacts', () => {
it('should skip upload when target is none', async () => {
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [{ type: 'build', path: './Builds/' }],
};
const config: ArtifactUploadConfig = {
target: 'none',
compression: 'gzip',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
expect(result.success).toBe(true);
expect(result.entries).toHaveLength(0);
});
it('should return success with no entries for empty manifest', async () => {
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [],
};
const config: ArtifactUploadConfig = {
target: 'github-artifacts',
compression: 'gzip',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
expect(result.success).toBe(true);
expect(result.entries).toHaveLength(0);
expect(result.totalBytes).toBe(0);
});
it('should fail entry when output path does not exist', async () => {
mockedFs.existsSync.mockReturnValue(false);
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [{ type: 'build', path: './Builds/Missing/' }],
};
const config: ArtifactUploadConfig = {
target: 'local',
destination: '/output',
compression: 'none',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
expect(result.success).toBe(false);
expect(result.entries).toHaveLength(1);
expect(result.entries[0].success).toBe(false);
expect(result.entries[0].error).toContain('does not exist');
});
it('should copy files for local upload target', async () => {
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 1024 } as any);
mockedFs.mkdirSync.mockReturnValue(undefined);
mockedFs.copyFileSync.mockReturnValue(undefined);
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [{ type: 'logs', path: './Logs/build.log', size: 1024 }],
};
const config: ArtifactUploadConfig = {
target: 'local',
destination: '/output',
compression: 'none',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
expect(result.success).toBe(true);
expect(result.entries).toHaveLength(1);
expect(result.entries[0].success).toBe(true);
expect(result.totalBytes).toBe(1024);
});
it('should fail local upload when no destination is provided', async () => {
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 512 } as any);
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [{ type: 'logs', path: './Logs/build.log', size: 512 }],
};
const config: ArtifactUploadConfig = {
target: 'local',
compression: 'none',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
expect(result.success).toBe(false);
expect(result.entries[0].success).toBe(false);
expect(result.entries[0].error).toContain('destination path');
});
it('should report correct duration', async () => {
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [],
};
const config: ArtifactUploadConfig = {
target: 'none',
compression: 'gzip',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
expect(result.durationMs).toBeGreaterThanOrEqual(0);
});
});
describe('collectFiles', () => {
it('should return single file for a file path', () => {
mockedFs.statSync.mockReturnValue({ isDirectory: () => false } as any);
const files = ArtifactUploadHandler.collectFiles('/path/to/file.txt');
expect(files).toEqual(['/path/to/file.txt']);
});
it('should return all files recursively for a directory', () => {
mockedFs.statSync.mockImplementation((p: any) => {
const pathStr = typeof p === 'string' ? p : p.toString();
if (pathStr.endsWith('.txt') || pathStr.endsWith('.log')) {
return { isDirectory: () => false } as any;
}
return { isDirectory: () => true } as any;
});
mockedFs.readdirSync.mockImplementation((dirPath: any, _options?: any) => {
const dirStr = typeof dirPath === 'string' ? dirPath : dirPath.toString();
if (dirStr === '/root') {
return [
{ name: 'file1.txt', isDirectory: () => false },
{ name: 'sub', isDirectory: () => true },
] as any;
}
if (dirStr.endsWith('sub')) {
return [{ name: 'file2.log', isDirectory: () => false }] as any;
}
return [] as any;
});
const files = ArtifactUploadHandler.collectFiles('/root');
expect(files).toHaveLength(2);
expect(files).toContain(path.join('/root', 'file1.txt'));
expect(files).toContain(path.join('/root', 'sub', 'file2.log'));
});
});
describe('storage upload validation', () => {
it('should fail storage upload when no destination is provided', async () => {
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 256 } as any);
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [{ type: 'build', path: './Builds/', size: 256 }],
};
const config: ArtifactUploadConfig = {
target: 'storage',
compression: 'gzip',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
expect(result.success).toBe(false);
expect(result.entries[0].error).toContain('destination URI');
});
it('should fail storage upload when destination URI has invalid format', async () => {
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 256 } as any);
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [{ type: 'build', path: './Builds/', size: 256 }],
};
const config: ArtifactUploadConfig = {
target: 'storage',
destination: '/just/a/local/path',
compression: 'gzip',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
expect(result.success).toBe(false);
expect(result.entries[0].error).toContain('Invalid storage destination URI');
});
it('should fail storage upload when rclone is not installed', async () => {
// Mock child_process.execFileSync to throw (rclone not found)
const childProcess = require('node:child_process');
const originalExecFileSync = childProcess.execFileSync;
childProcess.execFileSync = jest.fn(() => {
throw new Error('ENOENT');
});
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 256 } as any);
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [{ type: 'build', path: './Builds/', size: 256 }],
};
const config: ArtifactUploadConfig = {
target: 'storage',
destination: 's3:my-bucket/artifacts',
compression: 'gzip',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
expect(result.success).toBe(false);
expect(result.entries[0].error).toContain('rclone is not installed');
// Restore
childProcess.execFileSync = originalExecFileSync;
});
it('should accept valid rclone storage URI formats', async () => {
// Mock child_process.execFileSync to succeed (rclone available)
const childProcess = require('node:child_process');
const originalExecFileSync = childProcess.execFileSync;
childProcess.execFileSync = jest.fn(() => 'rclone v1.65.0');
mockedFs.existsSync.mockReturnValue(true);
mockedFs.statSync.mockReturnValue({ isDirectory: () => false, size: 256 } as any);
const manifest: OutputManifest = {
buildGuid: 'test-guid',
timestamp: new Date().toISOString(),
outputs: [{ type: 'build', path: './Builds/', size: 256 }],
};
// s3:bucket format should pass URI validation and reach the exec call
const config: ArtifactUploadConfig = {
target: 'storage',
destination: 's3:my-bucket/artifacts',
compression: 'gzip',
retentionDays: 30,
};
const result = await ArtifactUploadHandler.uploadArtifacts(manifest, config, projectPath);
// Should succeed because exec is mocked to return 0
expect(result.entries[0].success).toBe(true);
// Restore
childProcess.execFileSync = originalExecFileSync;
});
});
});

View File

@@ -0,0 +1,474 @@
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { exec } from '@actions/exec';
import OrchestratorLogger from '../core/orchestrator-logger';
import { OutputManifest, OutputEntry } from './output-manifest';
/**
* Configuration for artifact upload.
*/
export interface ArtifactUploadConfig {
/** Upload target: 'github-artifacts', 'storage', 'local', 'none' */
target: 'github-artifacts' | 'storage' | 'local' | 'none';
/** Destination path — storage URI for 'storage', local path for 'local' */
destination?: string;
/** Compression method */
compression: 'none' | 'gzip' | 'lz4';
/** Retention period in days (GitHub Artifacts only) */
retentionDays: number;
}
/**
* Result of an artifact upload operation.
*/
export interface UploadResult {
/** Whether the upload succeeded overall */
success: boolean;
/** Per-entry upload results */
entries: UploadEntryResult[];
/** Total bytes uploaded */
totalBytes: number;
/** Duration in milliseconds */
durationMs: number;
}
export interface UploadEntryResult {
/** The output type name */
type: string;
/** The output path */
path: string;
/** Whether this entry uploaded successfully */
success: boolean;
/** Bytes uploaded for this entry */
bytes: number;
/** Error message if upload failed */
error?: string;
}
/**
* GitHub Artifacts size limit per artifact (10 GB).
* Files larger than this must be split.
*/
const GITHUB_ARTIFACT_SIZE_LIMIT = 10 * 1024 * 1024 * 1024;
/**
* Minimum valid storage URI pattern: "remote:path" or "remote:".
* rclone requires at least a remote name followed by a colon.
*/
const STORAGE_URI_PATTERN = /^[a-zA-Z][\w-]*:/;
/**
* Check whether rclone is installed and available on PATH.
* Returns true if `rclone version` executes successfully.
*/
function isRcloneAvailable(): boolean {
try {
execFileSync('rclone', ['version'], { stdio: 'pipe', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Validate that a storage destination URI has the correct rclone format.
* Valid format: "remoteName:path" (e.g., "s3:bucket/prefix", "gdrive:folder").
*/
function isValidStorageUri(uri: string): boolean {
return STORAGE_URI_PATTERN.test(uri);
}
/**
* Handles uploading build artifacts to various targets.
*/
export class ArtifactUploadHandler {
/**
* Upload artifacts described by a manifest to the configured target.
*/
static async uploadArtifacts(
manifest: OutputManifest,
config: ArtifactUploadConfig,
projectPath: string,
): Promise<UploadResult> {
const startTime = Date.now();
const result: UploadResult = {
success: true,
entries: [],
totalBytes: 0,
durationMs: 0,
};
if (config.target === 'none') {
OrchestratorLogger.log('[ArtifactUpload] Upload target is "none", skipping upload');
result.durationMs = Date.now() - startTime;
return result;
}
if (manifest.outputs.length === 0) {
OrchestratorLogger.log('[ArtifactUpload] No outputs in manifest, nothing to upload');
result.durationMs = Date.now() - startTime;
return result;
}
OrchestratorLogger.log(`[ArtifactUpload] Uploading ${manifest.outputs.length} output(s) to ${config.target}`);
for (const entry of manifest.outputs) {
const entryResult = await ArtifactUploadHandler.uploadEntry(entry, config, projectPath);
result.entries.push(entryResult);
result.totalBytes += entryResult.bytes;
if (!entryResult.success) {
result.success = false;
}
}
result.durationMs = Date.now() - startTime;
OrchestratorLogger.log(
`[ArtifactUpload] Upload complete: ${result.entries.filter((e) => e.success).length}/${
result.entries.length
} succeeded, ${result.totalBytes} bytes, ${result.durationMs}ms`,
);
return result;
}
/**
* Upload a single output entry.
*/
private static async uploadEntry(
entry: OutputEntry,
config: ArtifactUploadConfig,
projectPath: string,
): Promise<UploadEntryResult> {
const entryResult: UploadEntryResult = {
type: entry.type,
path: entry.path,
success: false,
bytes: entry.size || 0,
};
const resolvedPath = path.resolve(
projectPath,
entry.path.replace('{platform}', process.env.BUILD_TARGET || 'Unknown'),
);
if (!fs.existsSync(resolvedPath)) {
entryResult.error = `Output path does not exist: ${resolvedPath}`;
OrchestratorLogger.logWarning(`[ArtifactUpload] ${entryResult.error}`);
return entryResult;
}
try {
switch (config.target) {
case 'github-artifacts':
await ArtifactUploadHandler.uploadToGitHubArtifacts(entry, resolvedPath, config);
break;
case 'storage':
await ArtifactUploadHandler.uploadToStorage(entry, resolvedPath, config);
break;
case 'local':
await ArtifactUploadHandler.uploadToLocal(entry, resolvedPath, config);
break;
}
entryResult.success = true;
OrchestratorLogger.log(
`[ArtifactUpload] Uploaded '${entry.type}' (${entryResult.bytes} bytes) to ${config.target}`,
);
} catch (error: any) {
entryResult.error = error.message || String(error);
OrchestratorLogger.logWarning(`[ArtifactUpload] Failed to upload '${entry.type}': ${entryResult.error}`);
}
return entryResult;
}
/**
* Upload to GitHub Artifacts via @actions/artifact.
* Handles large file splitting if artifacts exceed the size limit.
*/
private static async uploadToGitHubArtifacts(
entry: OutputEntry,
resolvedPath: string,
config: ArtifactUploadConfig,
): Promise<void> {
// Dynamically require @actions/artifact — it may not be available in all environments.
// Using a variable to prevent TypeScript from resolving the module at compile time.
let artifact: any;
try {
const artifactModule = '@actions/artifact';
// eslint-disable-next-line @typescript-eslint/no-require-imports
artifact = require(artifactModule);
} catch {
throw new Error('@actions/artifact package is not available. Install it to use github-artifacts upload target.');
}
const artifactClient = artifact.DefaultArtifactClient
? new artifact.DefaultArtifactClient()
: artifact.default
? new artifact.default()
: artifact;
const files = ArtifactUploadHandler.collectFiles(resolvedPath);
if (files.length === 0) {
OrchestratorLogger.logWarning(`[ArtifactUpload] No files found at ${resolvedPath} for '${entry.type}'`);
return;
}
const totalSize = entry.size || 0;
const artifactName = `unity-output-${entry.type}`;
if (totalSize > GITHUB_ARTIFACT_SIZE_LIMIT) {
OrchestratorLogger.log(
`[ArtifactUpload] Output '${entry.type}' exceeds GitHub Artifacts size limit (${totalSize} > ${GITHUB_ARTIFACT_SIZE_LIMIT}), splitting into chunks`,
);
await ArtifactUploadHandler.uploadChunked(artifactClient, artifactName, files, resolvedPath, config);
} else {
const rootDirectory = fs.statSync(resolvedPath).isDirectory() ? resolvedPath : path.dirname(resolvedPath);
if (typeof artifactClient.uploadArtifact === 'function') {
await artifactClient.uploadArtifact(artifactName, files, rootDirectory, {
retentionDays: config.retentionDays,
compressionLevel: config.compression === 'none' ? 0 : 6,
});
} else {
throw new Error(
'@actions/artifact client does not have uploadArtifact method. Ensure the package version is compatible.',
);
}
}
}
/**
* Upload large artifacts in chunks to stay within GitHub size limits.
*/
private static async uploadChunked(
artifactClient: any,
baseName: string,
files: string[],
rootDirectory: string,
config: ArtifactUploadConfig,
): Promise<void> {
const chunkSize = GITHUB_ARTIFACT_SIZE_LIMIT;
let currentChunkFiles: string[] = [];
let currentChunkSize = 0;
let chunkIndex = 0;
for (const filePath of files) {
const fileSize = fs.statSync(filePath).size;
if (currentChunkSize + fileSize > chunkSize && currentChunkFiles.length > 0) {
await ArtifactUploadHandler.uploadSingleChunk(
artifactClient,
`${baseName}-part${chunkIndex}`,
currentChunkFiles,
rootDirectory,
config,
);
chunkIndex++;
currentChunkFiles = [];
currentChunkSize = 0;
}
currentChunkFiles.push(filePath);
currentChunkSize += fileSize;
}
if (currentChunkFiles.length > 0) {
await ArtifactUploadHandler.uploadSingleChunk(
artifactClient,
chunkIndex > 0 ? `${baseName}-part${chunkIndex}` : baseName,
currentChunkFiles,
rootDirectory,
config,
);
}
}
private static async uploadSingleChunk(
artifactClient: any,
name: string,
files: string[],
rootDirectory: string,
config: ArtifactUploadConfig,
): Promise<void> {
OrchestratorLogger.log(`[ArtifactUpload] Uploading chunk '${name}' with ${files.length} file(s)`);
if (typeof artifactClient.uploadArtifact === 'function') {
await artifactClient.uploadArtifact(name, files, rootDirectory, {
retentionDays: config.retentionDays,
compressionLevel: config.compression === 'none' ? 0 : 6,
});
}
}
/**
* Upload to remote storage via rclone.
*
* Validates rclone availability and destination URI format before attempting
* the upload. If rclone is not installed, falls back to local copy when a
* local-compatible destination is provided, or skips with a clear error.
*/
private static async uploadToStorage(
entry: OutputEntry,
resolvedPath: string,
config: ArtifactUploadConfig,
): Promise<void> {
if (!config.destination) {
throw new Error('Storage upload requires a destination URI in artifactUploadPath');
}
// Validate storage URI format before attempting upload
if (!isValidStorageUri(config.destination)) {
throw new Error(
`Invalid storage destination URI: "${config.destination}". ` +
'Expected rclone remote format "remoteName:path" (e.g., "s3:my-bucket/artifacts", "gdrive:builds").',
);
}
// Check rclone availability before attempting upload
if (!isRcloneAvailable()) {
OrchestratorLogger.error(
'rclone is not installed or not in PATH. ' +
'Install rclone (https://rclone.org/install/) to use storage-based artifact upload. ' +
'Falling back to local copy.',
);
// Attempt local copy fallback using the destination as a hint
// Strip the remote prefix to get a local-ish path for fallback
OrchestratorLogger.logWarning(
`[ArtifactUpload] Storage upload skipped for '${entry.type}' — rclone not available`,
);
throw new Error(
'rclone is not installed or not in PATH. ' +
'Install rclone from https://rclone.org/install/ to use storage-based artifact upload.',
);
}
const destination = `${config.destination}/${entry.type}`;
OrchestratorLogger.log(`[ArtifactUpload] Uploading '${entry.type}' to storage: ${destination}`);
const args = ['copy', resolvedPath, destination, '--progress'];
if (config.compression !== 'none') {
// rclone doesn't have built-in compression flags for copy;
// compression is typically handled by the remote configuration.
// Log as informational.
OrchestratorLogger.log(
`[ArtifactUpload] Note: compression '${config.compression}' is configured at the remote level for rclone`,
);
}
await exec('rclone', args);
}
/**
* Upload to a local path (copy).
*/
private static async uploadToLocal(
entry: OutputEntry,
resolvedPath: string,
config: ArtifactUploadConfig,
): Promise<void> {
if (!config.destination) {
throw new Error('Local upload requires a destination path in artifactUploadPath');
}
const destination = path.join(config.destination, entry.type);
fs.mkdirSync(destination, { recursive: true });
OrchestratorLogger.log(`[ArtifactUpload] Copying '${entry.type}' to local path: ${destination}`);
ArtifactUploadHandler.copyRecursive(resolvedPath, destination);
}
/**
* Recursively copy files from source to destination.
*/
private static copyRecursive(source: string, destination: string): void {
const stat = fs.statSync(source);
if (stat.isDirectory()) {
fs.mkdirSync(destination, { recursive: true });
const entries = fs.readdirSync(source);
for (const entry of entries) {
ArtifactUploadHandler.copyRecursive(path.join(source, entry), path.join(destination, entry));
}
} else {
fs.copyFileSync(source, destination);
}
}
/**
* Collect all files at a given path (recursively if directory).
*/
static collectFiles(targetPath: string): string[] {
const stat = fs.statSync(targetPath);
if (!stat.isDirectory()) {
return [targetPath];
}
const files: string[] = [];
const entries = fs.readdirSync(targetPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(targetPath, entry.name);
if (entry.isDirectory()) {
files.push(...ArtifactUploadHandler.collectFiles(fullPath));
} else {
files.push(fullPath);
}
}
return files;
}
/**
* Parse an ArtifactUploadConfig from action inputs.
*/
static parseConfig(
target: string,
destination: string | undefined,
compression: string,
retentionDays: string,
): ArtifactUploadConfig {
const validTargets = ['github-artifacts', 'storage', 'local', 'none'] as const;
const resolvedTarget = validTargets.includes(target as any)
? (target as ArtifactUploadConfig['target'])
: 'github-artifacts';
const validCompressions = ['none', 'gzip', 'lz4'] as const;
const resolvedCompression = validCompressions.includes(compression as any)
? (compression as ArtifactUploadConfig['compression'])
: 'gzip';
const parsedRetention = Number.parseInt(retentionDays, 10);
const resolvedRetention = Number.isNaN(parsedRetention) || parsedRetention <= 0 ? 30 : parsedRetention;
return {
target: resolvedTarget,
destination: destination || undefined,
compression: resolvedCompression,
retentionDays: resolvedRetention,
};
}
}

View File

@@ -0,0 +1,3 @@
export { OutputManifest, OutputEntry } from './output-manifest';
export { OutputTypeRegistry, OutputTypeDefinition } from './output-type-registry';
export { OutputService } from './output-service';

View File

@@ -0,0 +1,41 @@
/**
* Structured build output manifest.
* Describes all artifacts produced by a build with type, path, size, hash, and metadata.
*/
export interface OutputEntry {
/** Output type identifier (e.g., 'build', 'test-results', 'images') */
type: string;
/** Relative path to the output */
path: string;
/** Output format (e.g., 'nunit3', 'junit', 'json') */
format?: string;
/** File size in bytes */
size?: number;
/** Content hash (e.g., 'sha256:abc...') */
hash?: string;
/** Individual files within the output path */
files?: string[];
/** Type-specific summary (e.g., test counts, build size) */
summary?: Record<string, unknown>;
/** Arbitrary metadata */
metadata?: Record<string, unknown>;
}
export interface OutputManifest {
/** Unique build identifier */
buildGuid: string;
/** ISO 8601 timestamp */
timestamp: string;
/** All outputs produced by this build */
outputs: OutputEntry[];
}

View File

@@ -0,0 +1,118 @@
import fs from 'node:fs';
import path from 'node:path';
import OrchestratorLogger from '../core/orchestrator-logger';
import { OutputManifest, OutputEntry } from './output-manifest';
import { OutputTypeRegistry } from './output-type-registry';
/**
* Service for collecting, manifesting, and managing build outputs.
*
* After a build completes, this service scans declared output paths,
* generates a structured manifest, and prepares outputs for post-processing.
*/
export class OutputService {
/**
* Collect outputs from the workspace and generate a manifest.
*
* @param projectPath - Path to the Unity project root
* @param buildGuid - Unique build identifier
* @param outputTypesInput - Comma-separated output type names
* @param manifestPath - Where to write the manifest JSON (optional)
* @returns The generated output manifest
*/
static async collectOutputs(
projectPath: string,
buildGuid: string,
outputTypesInput: string,
manifestPath?: string,
): Promise<OutputManifest> {
const types = OutputTypeRegistry.parseOutputTypes(outputTypesInput);
const manifest: OutputManifest = {
buildGuid,
timestamp: new Date().toISOString(),
outputs: [],
};
if (types.length === 0) {
OrchestratorLogger.log('[Output] No output types declared, skipping collection');
return manifest;
}
OrchestratorLogger.log(
`[Output] Collecting ${types.length} output type(s): ${types.map((t) => t.name).join(', ')}`,
);
for (const typeDef of types) {
const outputPath = path.join(
projectPath,
typeDef.defaultPath.replace('{platform}', process.env.BUILD_TARGET || 'Unknown'),
);
if (!fs.existsSync(outputPath)) {
OrchestratorLogger.log(`[Output] No output found for '${typeDef.name}' at ${outputPath}`);
continue;
}
const entry: OutputEntry = {
type: typeDef.name,
path: typeDef.defaultPath,
};
// Collect file listing for directory outputs
try {
const stat = fs.statSync(outputPath);
if (stat.isDirectory()) {
entry.files = fs.readdirSync(outputPath);
entry.size = OutputService.getDirectorySize(outputPath);
} else {
entry.size = stat.size;
}
} catch {
OrchestratorLogger.logWarning(`[Output] Failed to stat output '${typeDef.name}' at ${outputPath}`);
}
manifest.outputs.push(entry);
OrchestratorLogger.log(
`[Output] Collected '${typeDef.name}': ${entry.files?.length || 1} file(s), ${entry.size || 0} bytes`,
);
}
// Write manifest to disk
if (manifestPath) {
try {
const manifestDir = path.dirname(manifestPath);
fs.mkdirSync(manifestDir, { recursive: true });
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
OrchestratorLogger.log(`[Output] Manifest written to ${manifestPath}`);
} catch (error: any) {
OrchestratorLogger.logWarning(`[Output] Failed to write manifest: ${error.message}`);
}
}
return manifest;
}
/**
* Calculate total size of a directory recursively.
*/
private static getDirectorySize(dirPath: string): number {
let totalSize = 0;
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
totalSize += OutputService.getDirectorySize(fullPath);
} else {
totalSize += fs.statSync(fullPath).size;
}
}
} catch {
// Ignore errors in size calculation
}
return totalSize;
}
}

View File

@@ -0,0 +1,136 @@
import OrchestratorLogger from '../core/orchestrator-logger';
/**
* Registry of known output types with default paths and processing hints.
*/
export interface OutputTypeDefinition {
/** Type identifier */
name: string;
/** Default output path (relative to project root) */
defaultPath: string;
/** Human-readable description */
description: string;
/** Whether this type is built-in or user-registered */
builtIn: boolean;
}
export class OutputTypeRegistry {
private static readonly builtInTypes: Record<string, OutputTypeDefinition> = {
build: {
name: 'build',
defaultPath: './Builds/{platform}/',
description: 'Standard game build artifact',
builtIn: true,
},
'test-results': {
name: 'test-results',
defaultPath: './TestResults/',
description: 'NUnit/JUnit XML test results',
builtIn: true,
},
'server-build': {
name: 'server-build',
defaultPath: './Builds/{platform}-server/',
description: 'Dedicated server build artifact',
builtIn: true,
},
'data-export': {
name: 'data-export',
defaultPath: './Exports/',
description: 'Exported data files (CSV, JSON, binary)',
builtIn: true,
},
images: {
name: 'images',
defaultPath: './Captures/',
description: 'Screenshots, render captures, atlas previews',
builtIn: true,
},
logs: {
name: 'logs',
defaultPath: './Logs/',
description: 'Structured build and test logs',
builtIn: true,
},
metrics: {
name: 'metrics',
defaultPath: './Metrics/',
description: 'Build performance metrics and asset statistics',
builtIn: true,
},
coverage: {
name: 'coverage',
defaultPath: './Coverage/',
description: 'Code coverage reports',
builtIn: true,
},
};
private static customTypes: Record<string, OutputTypeDefinition> = {};
/**
* Get a type definition by name. Checks custom types first, then built-in.
*/
static getType(name: string): OutputTypeDefinition | undefined {
return OutputTypeRegistry.customTypes[name] || OutputTypeRegistry.builtInTypes[name];
}
/**
* Get all registered types (built-in + custom).
*/
static getAllTypes(): OutputTypeDefinition[] {
return [...Object.values(OutputTypeRegistry.builtInTypes), ...Object.values(OutputTypeRegistry.customTypes)];
}
/**
* Register a custom output type.
*/
static registerType(definition: OutputTypeDefinition): void {
if (OutputTypeRegistry.builtInTypes[definition.name]) {
OrchestratorLogger.logWarning(`[OutputTypes] Cannot override built-in type '${definition.name}'`);
return;
}
OutputTypeRegistry.customTypes[definition.name] = { ...definition, builtIn: false };
OrchestratorLogger.log(`[OutputTypes] Registered custom type '${definition.name}'`);
}
/**
* Parse a comma-separated output types string into type definitions.
* Unknown types are logged as warnings and skipped.
*/
static parseOutputTypes(outputTypesInput: string): OutputTypeDefinition[] {
if (!outputTypesInput) {
return [];
}
const names = outputTypesInput
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const types: OutputTypeDefinition[] = [];
for (const name of names) {
const typeDef = OutputTypeRegistry.getType(name);
if (typeDef) {
types.push(typeDef);
} else {
OrchestratorLogger.logWarning(`[OutputTypes] Unknown output type '${name}', skipping`);
}
}
return types;
}
/**
* Reset custom types (for testing).
*/
static resetCustomTypes(): void {
OutputTypeRegistry.customTypes = {};
}
}

View File

@@ -1,12 +0,0 @@
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

@@ -1,198 +0,0 @@
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

@@ -1,316 +0,0 @@
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

@@ -1,263 +0,0 @@
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

@@ -1,246 +0,0 @@
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

@@ -1,41 +0,0 @@
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

@@ -1,562 +0,0 @@
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

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