Compare commits

..

5 Commits

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:04:54 +00:00
18 changed files with 2652 additions and 1189 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
@@ -194,15 +194,6 @@ inputs:
description:
'[Orchestrator] Either local, k8s or aws can be used to run builds on a remote cluster. Additional parameters must
be configured.'
secretSource:
default: ''
required: false
description:
'[Orchestrator] Premade secret source for pulling build secrets. Supported values: aws-secrets-manager,
aws-parameter-store, gcp-secret-manager, azure-key-vault, hashicorp-vault, hashicorp-vault-kv1,
vault (alias for hashicorp-vault), env. Can also be a custom shell command with {0} placeholder
for the key, or a path to a YAML file defining custom sources. Takes precedence over
inputPullCommand when set.'
resourceTracking:
default: 'false'
required: false
@@ -288,6 +279,23 @@ 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:

1275
dist/index.js generated vendored

File diff suppressed because it is too large Load Diff

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -3,6 +3,7 @@ import { Action, BuildParameters, Cache, Orchestrator, Docker, ImageTag, Output
import { Cli } from './model/cli/cli';
import MacBuilder from './model/mac-builder';
import PlatformSetup from './model/platform-setup';
import { TestWorkflowService } from './model/orchestrator/services/test-workflow';
async function runMain() {
try {
@@ -17,6 +18,23 @@ 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;

View File

@@ -107,6 +107,12 @@ class BuildParameters {
public unityHubVersionOnMac!: string;
public dockerWorkspacePath!: string;
public testSuitePath!: string;
public testSuiteEvent!: string;
public testTaxonomyPath!: string;
public testResultFormat!: string;
public testResultPath!: string;
public static shouldUseRetainedWorkspaceMode(buildParameters: BuildParameters) {
return buildParameters.maxRetainedWorkspaces > 0 && Orchestrator.lockedWorkspace !== ``;
}
@@ -242,6 +248,11 @@ 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,
};
}

View File

@@ -282,6 +282,26 @@ class Input {
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

@@ -190,10 +190,6 @@ class OrchestratorOptions {
return OrchestratorOptions.getInput('pullInputList')?.split(`,`) || [];
}
static get secretSource(): string {
return OrchestratorOptions.getInput('secretSource') || '';
}
static get inputPullCommand(): string {
const value = OrchestratorOptions.getInput('inputPullCommand');

View File

@@ -1,9 +1,6 @@
import * as core from '@actions/core';
import Input from '../../input';
import { GenericInputReader } from '../../input-readers/generic-input-reader';
import OrchestratorOptions from './orchestrator-options';
import { SecretSourceService, validateSecretKey } from '../services/secrets/secret-source-service';
import OrchestratorLogger from '../services/core/orchestrator-logger';
const formatFunction = (value: string, arguments_: any[]) => {
for (const element of arguments_) {
@@ -16,6 +13,8 @@ const formatFunction = (value: string, arguments_: any[]) => {
class OrchestratorQueryOverride {
static queryOverrides: { [key: string]: string } | undefined;
// TODO accept premade secret sources or custom secret source definition yamls
public static query(key: string, alternativeKey: string) {
if (OrchestratorQueryOverride.queryOverrides && OrchestratorQueryOverride.queryOverrides[key] !== undefined) {
return OrchestratorQueryOverride.queryOverrides[key];
@@ -50,62 +49,14 @@ class OrchestratorQueryOverride {
throw new Error(`Should not be trying to run override query on ${query}`);
}
// Validate the query key before interpolating it into a shell command
validateSecretKey(query);
const result = await GenericInputReader.Run(
return await GenericInputReader.Run(
formatFunction(OrchestratorOptions.inputPullCommand, [{ key: 0, value: query }]),
);
// Mask the fetched secret value so it does not appear in GitHub Actions logs
if (result && result.trim().length > 0) {
core.setSecret(result);
}
return result;
}
/**
* Populate query overrides using either:
* 1. Premade/custom secret sources (via secretSource input), or
* 2. Shell command (via inputPullCommand, legacy approach)
*
* The secretSource input takes precedence if set. It supports:
* - Premade names: 'aws-secrets-manager', 'aws-parameter-store', 'gcp-secret-manager', 'azure-key-vault', 'env'
* - Custom commands: any string containing {0} placeholder
* - YAML file path: a path ending in .yml or .yaml containing custom source definitions
*/
public static async PopulateQueryOverrideInput() {
const queries = OrchestratorOptions.pullInputList;
OrchestratorQueryOverride.queryOverrides = {};
const secretSource = OrchestratorOptions.secretSource;
// Use SecretSourceService if secretSource is configured
if (secretSource) {
OrchestratorLogger.log(`Using secret source: ${secretSource}`);
// YAML file: load definitions and use the first source
if (secretSource.endsWith('.yml') || secretSource.endsWith('.yaml')) {
const definitions = SecretSourceService.loadFromYaml(secretSource);
if (definitions.length > 0) {
OrchestratorLogger.log(`Loaded ${definitions.length} secret source(s) from ${secretSource}`);
for (const key of queries) {
OrchestratorQueryOverride.queryOverrides[key] = await SecretSourceService.fetchSecret(definitions[0], key);
}
}
return;
}
// Premade or custom command source
const results = await SecretSourceService.fetchAll(secretSource, queries);
Object.assign(OrchestratorQueryOverride.queryOverrides, results);
return;
}
// Legacy: use inputPullCommand if set
for (const element of queries) {
if (OrchestratorQueryOverride.shouldUseOverride(element)) {
OrchestratorQueryOverride.queryOverrides[element] = await OrchestratorQueryOverride.queryOverride(element);

View File

@@ -1,446 +0,0 @@
import fs from 'node:fs';
import * as core from '@actions/core';
import { SecretSourceService, validateSecretKey } from './secret-source-service';
jest.mock('node:fs');
jest.mock('@actions/core', () => ({
setSecret: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
}));
jest.mock('../core/orchestrator-system', () => ({
OrchestratorSystem: {
Run: jest.fn().mockResolvedValue(''),
},
}));
jest.mock('../core/orchestrator-logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
logWarning: jest.fn(),
error: jest.fn(),
},
}));
const mockFs = fs as jest.Mocked<typeof fs>;
describe('SecretSourceService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('validateSecretKey', () => {
it('should accept alphanumeric keys', () => {
expect(validateSecretKey('MY_SECRET_KEY')).toBe('MY_SECRET_KEY');
});
it('should accept keys with hyphens', () => {
expect(validateSecretKey('my-secret-key')).toBe('my-secret-key');
});
it('should accept keys with dots', () => {
expect(validateSecretKey('my.secret.key')).toBe('my.secret.key');
});
it('should accept keys with forward slashes', () => {
expect(validateSecretKey('path/to/secret')).toBe('path/to/secret');
});
it('should accept keys with mixed valid characters', () => {
expect(validateSecretKey('my-app/prod_db.password')).toBe('my-app/prod_db.password');
});
it('should reject keys with semicolons (shell injection)', () => {
expect(() => validateSecretKey('; rm -rf /')).toThrow('Invalid secret key name');
});
it('should reject keys with backticks (command substitution)', () => {
expect(() => validateSecretKey('`whoami`')).toThrow('Invalid secret key name');
});
it('should reject keys with dollar signs (variable expansion)', () => {
expect(() => validateSecretKey('$HOME')).toThrow('Invalid secret key name');
});
it('should reject keys with pipe characters', () => {
expect(() => validateSecretKey('key | cat /etc/passwd')).toThrow('Invalid secret key name');
});
it('should reject keys with ampersands', () => {
expect(() => validateSecretKey('key && echo pwned')).toThrow('Invalid secret key name');
});
it('should reject keys with newlines', () => {
expect(() => validateSecretKey('key\nmalicious')).toThrow('Invalid secret key name');
});
it('should reject keys with quotes', () => {
expect(() => validateSecretKey('"key"')).toThrow('Invalid secret key name');
expect(() => validateSecretKey("'key'")).toThrow('Invalid secret key name');
});
it('should reject keys with parentheses (subshell)', () => {
expect(() => validateSecretKey('$(whoami)')).toThrow('Invalid secret key name');
});
it('should reject empty keys', () => {
expect(() => validateSecretKey('')).toThrow('Invalid secret key name');
});
it('should reject keys with spaces', () => {
expect(() => validateSecretKey('key with spaces')).toThrow('Invalid secret key name');
});
});
describe('isPremadeSource', () => {
it('should return true for aws-secrets-manager', () => {
expect(SecretSourceService.isPremadeSource('aws-secrets-manager')).toBe(true);
});
it('should return true for aws-secret-manager (legacy alias)', () => {
expect(SecretSourceService.isPremadeSource('aws-secret-manager')).toBe(true);
});
it('should return true for aws-parameter-store', () => {
expect(SecretSourceService.isPremadeSource('aws-parameter-store')).toBe(true);
});
it('should return true for gcp-secret-manager', () => {
expect(SecretSourceService.isPremadeSource('gcp-secret-manager')).toBe(true);
});
it('should return true for azure-key-vault', () => {
expect(SecretSourceService.isPremadeSource('azure-key-vault')).toBe(true);
});
it('should return true for hashicorp-vault', () => {
expect(SecretSourceService.isPremadeSource('hashicorp-vault')).toBe(true);
});
it('should return true for hashicorp-vault-kv1', () => {
expect(SecretSourceService.isPremadeSource('hashicorp-vault-kv1')).toBe(true);
});
it('should return true for vault (short alias)', () => {
expect(SecretSourceService.isPremadeSource('vault')).toBe(true);
});
it('should return false for unknown source', () => {
expect(SecretSourceService.isPremadeSource('unknown-source')).toBe(false);
});
});
describe('getAvailableSources', () => {
it('should return all premade source names', () => {
const sources = SecretSourceService.getAvailableSources();
expect(sources).toContain('aws-secrets-manager');
expect(sources).toContain('aws-parameter-store');
expect(sources).toContain('gcp-secret-manager');
expect(sources).toContain('azure-key-vault');
expect(sources).toContain('hashicorp-vault');
expect(sources).toContain('hashicorp-vault-kv1');
expect(sources).toContain('vault');
expect(sources.length).toBeGreaterThanOrEqual(8);
});
});
describe('resolveSource', () => {
it('should resolve premade source by name', () => {
const source = SecretSourceService.resolveSource('aws-secrets-manager');
expect(source).toBeDefined();
expect(source!.name).toBe('aws-secrets-manager');
expect(source!.command).toContain('secretsmanager');
});
it('should resolve custom command with {0} placeholder', () => {
const source = SecretSourceService.resolveSource('vault kv get -field=value secret/{0}');
expect(source).toBeDefined();
expect(source!.name).toBe('custom-command');
expect(source!.command).toContain('{0}');
});
it('should resolve command with spaces as custom command', () => {
const source = SecretSourceService.resolveSource('my-tool get-secret');
expect(source).toBeDefined();
expect(source!.name).toBe('custom-command');
});
it('should return undefined for unknown single-word source', () => {
const source = SecretSourceService.resolveSource('unknown');
expect(source).toBeUndefined();
});
});
describe('fetchSecret', () => {
it('should run the command with {0} replaced by key', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue('my-secret-value');
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
const result = await SecretSourceService.fetchSecret(source, 'MY_SECRET');
expect(result).toBe('my-secret-value');
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('MY_SECRET'), false, true);
});
it('should parse JSON output when parseOutput is json-field', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue(JSON.stringify({ value: 'extracted-secret' }));
const source = {
name: 'test-source',
command: 'fetch {0}',
parseOutput: 'json-field' as const,
jsonField: 'value',
};
const result = await SecretSourceService.fetchSecret(source, 'KEY');
expect(result).toBe('extracted-secret');
});
it('should fall back to raw output on invalid JSON with json-field mode', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue('not-json');
const source = {
name: 'test-source',
command: 'fetch {0}',
parseOutput: 'json-field' as const,
jsonField: 'value',
};
const result = await SecretSourceService.fetchSecret(source, 'KEY');
expect(result).toBe('not-json');
});
it('should return empty string on command failure', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockRejectedValue(new Error('command not found'));
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
const result = await SecretSourceService.fetchSecret(source, 'KEY');
expect(result).toBe('');
});
it('should reject keys with shell injection characters', async () => {
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
await expect(SecretSourceService.fetchSecret(source, '; rm -rf /')).rejects.toThrow('Invalid secret key name');
});
it('should reject keys with command substitution', async () => {
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
await expect(SecretSourceService.fetchSecret(source, '$(whoami)')).rejects.toThrow('Invalid secret key name');
});
it('should reject keys with backtick command substitution', async () => {
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
await expect(SecretSourceService.fetchSecret(source, '`cat /etc/passwd`')).rejects.toThrow(
'Invalid secret key name',
);
});
it('should accept keys with valid path-like patterns', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue('secret-value');
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
const result = await SecretSourceService.fetchSecret(source, 'prod/database/password');
expect(result).toBe('secret-value');
});
it('should mask fetched secret values with core.setSecret', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue('super-secret-value');
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
await SecretSourceService.fetchSecret(source, 'MY_SECRET');
expect(core.setSecret).toHaveBeenCalledWith('super-secret-value');
});
it('should not mask empty secret values', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue('');
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
await SecretSourceService.fetchSecret(source, 'MY_SECRET');
expect(core.setSecret).not.toHaveBeenCalled();
});
it('should mask JSON-extracted secret values', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValue(JSON.stringify({ value: 'json-secret' }));
const source = {
name: 'test-source',
command: 'fetch {0}',
parseOutput: 'json-field' as const,
jsonField: 'value',
};
await SecretSourceService.fetchSecret(source, 'KEY');
expect(core.setSecret).toHaveBeenCalledWith('json-secret');
});
});
describe('fetchFromEnv', () => {
it('should return env var value when set', () => {
process.env.TEST_SECRET_KEY = 'env-value';
const result = SecretSourceService.fetchFromEnv('TEST_SECRET_KEY');
expect(result).toBe('env-value');
delete process.env.TEST_SECRET_KEY;
});
it('should return empty string when env var is not set', () => {
const result = SecretSourceService.fetchFromEnv('NONEXISTENT_KEY_12345');
expect(result).toBe('');
});
it('should mask env var values with core.setSecret', () => {
process.env.TEST_MASK_KEY = 'masked-env-value';
SecretSourceService.fetchFromEnv('TEST_MASK_KEY');
expect(core.setSecret).toHaveBeenCalledWith('masked-env-value');
delete process.env.TEST_MASK_KEY;
});
it('should not mask empty env var values', () => {
const result = SecretSourceService.fetchFromEnv('NONEXISTENT_KEY_99999');
expect(result).toBe('');
expect(core.setSecret).not.toHaveBeenCalled();
});
});
describe('fetchAll', () => {
it('should fetch all keys from env source', async () => {
process.env.KEY_A = 'val-a';
process.env.KEY_B = 'val-b';
const results = await SecretSourceService.fetchAll('env', ['KEY_A', 'KEY_B']);
expect(results.KEY_A).toBe('val-a');
expect(results.KEY_B).toBe('val-b');
delete process.env.KEY_A;
delete process.env.KEY_B;
});
it('should fetch all keys from premade source', async () => {
const { OrchestratorSystem } = require('../core/orchestrator-system');
OrchestratorSystem.Run.mockResolvedValueOnce('secret-1').mockResolvedValueOnce('secret-2');
const results = await SecretSourceService.fetchAll('aws-parameter-store', ['param1', 'param2']);
expect(results.param1).toBe('secret-1');
expect(results.param2).toBe('secret-2');
expect(OrchestratorSystem.Run).toHaveBeenCalledTimes(2);
});
it('should return empty results for unknown source', async () => {
const results = await SecretSourceService.fetchAll('unknown', ['key1']);
expect(results).toEqual({});
});
});
describe('loadFromYaml', () => {
it('should return empty array when file does not exist', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(false);
const result = SecretSourceService.loadFromYaml('/nonexistent.yml');
expect(result).toEqual([]);
});
it('should parse valid YAML source definitions', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockReturnValue(`
sources:
- name: my-vault
command: 'vault kv get -field=value secret/{0}'
- name: my-api
command: 'curl -s https://api.example.com/{0}'
parseOutput: json-field
jsonField: secret_value
`);
const result = SecretSourceService.loadFromYaml('/sources.yml');
expect(result).toHaveLength(2);
expect(result[0].name).toBe('my-vault');
expect(result[0].command).toBe('vault kv get -field=value secret/{0}');
expect(result[1].name).toBe('my-api');
expect(result[1].parseOutput).toBe('json-field');
expect(result[1].jsonField).toBe('secret_value');
});
it('should handle YAML with single source', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockReturnValue(`
sources:
- name: simple
command: echo {0}
`);
const result = SecretSourceService.loadFromYaml('/simple.yml');
expect(result).toHaveLength(1);
expect(result[0].name).toBe('simple');
});
it('should return empty array on parse error', () => {
(mockFs.existsSync as jest.Mock).mockReturnValue(true);
(mockFs.readFileSync as jest.Mock).mockImplementation(() => {
throw new Error('Permission denied');
});
const result = SecretSourceService.loadFromYaml('/error.yml');
expect(result).toEqual([]);
});
});
describe('premade source commands', () => {
it('aws-secrets-manager uses --query SecretString', () => {
const source = SecretSourceService.resolveSource('aws-secrets-manager')!;
expect(source.command).toContain('--query SecretString');
expect(source.command).toContain('--output text');
});
it('aws-parameter-store uses --with-decryption', () => {
const source = SecretSourceService.resolveSource('aws-parameter-store')!;
expect(source.command).toContain('--with-decryption');
expect(source.command).toContain('--query Parameter.Value');
});
it('gcp-secret-manager uses latest version', () => {
const source = SecretSourceService.resolveSource('gcp-secret-manager')!;
expect(source.command).toContain('latest');
});
it('azure-key-vault uses AZURE_VAULT_NAME env var', () => {
const source = SecretSourceService.resolveSource('azure-key-vault')!;
expect(source.command).toContain('$AZURE_VAULT_NAME');
});
it('hashicorp-vault uses vault kv get with VAULT_MOUNT', () => {
const source = SecretSourceService.resolveSource('hashicorp-vault')!;
expect(source.command).toContain('vault kv get');
expect(source.command).toContain('VAULT_MOUNT');
expect(source.command).toContain('-field=value');
});
it('hashicorp-vault-kv1 uses vault read for KV v1', () => {
const source = SecretSourceService.resolveSource('hashicorp-vault-kv1')!;
expect(source.command).toContain('vault read');
expect(source.command).toContain('-field=value');
});
it('vault alias resolves to same command as hashicorp-vault', () => {
const vault = SecretSourceService.resolveSource('vault')!;
const hashicorpVault = SecretSourceService.resolveSource('hashicorp-vault')!;
expect(vault.command).toBe(hashicorpVault.command);
});
});
});

View File

@@ -1,337 +0,0 @@
import fs from 'node:fs';
import * as core from '@actions/core';
import OrchestratorLogger from '../core/orchestrator-logger';
import { OrchestratorSystem } from '../core/orchestrator-system';
/**
* A secret source definition: how to fetch a secret value by key.
*/
export interface SecretSourceDefinition {
name: string;
command: string;
parseOutput?: 'raw' | 'json-field';
jsonField?: string;
}
/**
* Validate that a secret key name contains only safe characters.
* Prevents shell injection when keys are interpolated into commands.
*
* Allowed characters: alphanumeric, hyphens, underscores, dots, forward slashes.
*
* @param key - The secret key name to validate
* @returns The validated key (unchanged)
* @throws Error if the key contains disallowed characters
*/
export function validateSecretKey(key: string): string {
if (!/^[a-zA-Z0-9\-_./]+$/.test(key)) {
throw new Error(
`Invalid secret key name: "${key}". Keys may only contain alphanumeric characters, hyphens, underscores, dots, and forward slashes.`,
);
}
return key;
}
/**
* Mask a secret value so it does not appear in GitHub Actions logs.
* Empty or whitespace-only values are skipped (core.setSecret would be a no-op).
*/
function maskSecretValue(value: string): void {
if (value.trim().length > 0) {
core.setSecret(value);
}
}
/**
* Premade secret sources and custom YAML-based secret source definitions.
*
* Premade sources are string shortcuts that expand to shell commands:
* - `aws-secrets-manager` -- AWS Secrets Manager
* - `aws-parameter-store` -- AWS Systems Manager Parameter Store
* - `gcp-secret-manager` -- Google Cloud Secret Manager
* - `azure-key-vault` -- Azure Key Vault (requires AZURE_VAULT_NAME env var)
* - `hashicorp-vault` -- HashiCorp Vault KV v2 (requires VAULT_ADDR, optionally VAULT_MOUNT)
* - `hashicorp-vault-kv1` -- HashiCorp Vault KV v1 (requires VAULT_ADDR, optionally VAULT_MOUNT)
* - `env` -- Read from environment variables (no shell command needed)
*
* Custom YAML format:
* sources:
* - name: my-vault
* command: 'vault kv get -field=value secret/{0}'
* - name: my-api
* command: 'curl -s https://secrets.example.com/api/{0}'
* parseOutput: json-field
* jsonField: value
*/
export class SecretSourceService {
private static readonly premadeSources: Record<string, SecretSourceDefinition> = {
'aws-secrets-manager': {
name: 'aws-secrets-manager',
command: 'aws secretsmanager get-secret-value --secret-id {0} --query SecretString --output text',
parseOutput: 'raw',
},
'aws-secret-manager': {
// Alias for backward compatibility (original name in inputPullCommand)
name: 'aws-secret-manager',
command: 'aws secretsmanager get-secret-value --secret-id {0} --query SecretString --output text',
parseOutput: 'raw',
},
'aws-parameter-store': {
name: 'aws-parameter-store',
command: 'aws ssm get-parameter --name {0} --with-decryption --query Parameter.Value --output text',
parseOutput: 'raw',
},
'gcp-secret-manager': {
name: 'gcp-secret-manager',
command: 'gcloud secrets versions access latest --secret="{0}"',
parseOutput: 'raw',
},
'azure-key-vault': {
name: 'azure-key-vault',
command: 'az keyvault secret show --vault-name "$AZURE_VAULT_NAME" --name {0} --query value --output tsv',
parseOutput: 'raw',
},
'hashicorp-vault': {
// HashiCorp Vault KV v2 (default). Requires VAULT_ADDR env var.
// Optionally set VAULT_MOUNT to override the mount path (default: 'secret').
// Authentication is handled by VAULT_TOKEN or other Vault auth env vars.
name: 'hashicorp-vault',
command: 'vault kv get -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
parseOutput: 'raw',
},
'hashicorp-vault-kv1': {
// HashiCorp Vault KV v1. Requires VAULT_ADDR env var.
// Optionally set VAULT_MOUNT to override the mount path (default: 'secret').
name: 'hashicorp-vault-kv1',
command: 'vault read -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
parseOutput: 'raw',
},
vault: {
// Short alias for hashicorp-vault (KV v2)
name: 'vault',
command: 'vault kv get -mount="${VAULT_MOUNT:-secret}" -field=value {0}',
parseOutput: 'raw',
},
};
/**
* Check if a source name is a known premade source.
*/
static isPremadeSource(sourceName: string): boolean {
return sourceName in SecretSourceService.premadeSources;
}
/**
* Get the list of available premade source names.
*/
static getAvailableSources(): string[] {
return Object.keys(SecretSourceService.premadeSources);
}
/**
* Resolve a source name to a SecretSourceDefinition.
*
* - If the name matches a premade source, returns that definition.
* - If it looks like a shell command (contains spaces or {0}), wraps it as a custom command.
* - Otherwise, returns undefined.
*/
static resolveSource(sourceName: string): SecretSourceDefinition | undefined {
// Check premade sources
if (SecretSourceService.isPremadeSource(sourceName)) {
return SecretSourceService.premadeSources[sourceName];
}
// If it contains a placeholder or spaces, treat it as a raw command
if (sourceName.includes('{0}') || sourceName.includes(' ')) {
return {
name: 'custom-command',
command: sourceName,
parseOutput: 'raw',
};
}
return undefined;
}
/**
* Load custom secret source definitions from a YAML file.
*
* Expected format:
* sources:
* - name: my-source
* command: 'my-tool get-secret {0}'
* - name: my-api
* command: 'curl -s https://api.example.com/secrets/{0}'
* parseOutput: json-field
* jsonField: value
*/
static loadFromYaml(filePath: string): SecretSourceDefinition[] {
if (!fs.existsSync(filePath)) {
OrchestratorLogger.logWarning(`Secret source YAML not found: ${filePath}`);
return [];
}
try {
const content = fs.readFileSync(filePath, 'utf8');
const parsed = SecretSourceService.parseSimpleYaml(content);
return parsed;
} catch (error: any) {
OrchestratorLogger.logWarning(`Failed to parse secret source YAML: ${error.message}`);
return [];
}
}
/**
* Fetch a secret value using the given source definition.
*
* Validates the key against an allowlist pattern before interpolating it
* into the command string to prevent shell injection. The fetched secret
* value is masked via core.setSecret() so it does not leak in logs.
*
* @param source - The secret source definition to use
* @param key - The secret key to fetch
* @returns The secret value, or empty string on failure
*/
static async fetchSecret(source: SecretSourceDefinition, key: string): Promise<string> {
// Validate the key to prevent shell injection
validateSecretKey(key);
const command = source.command.replace(/\{0\}/g, key);
try {
const output = await OrchestratorSystem.Run(command, false, true);
let value: string;
if (source.parseOutput === 'json-field' && source.jsonField) {
try {
const parsed = JSON.parse(output);
value = parsed[source.jsonField] || '';
} catch {
OrchestratorLogger.logWarning(`Failed to parse JSON output from ${source.name} for key ${key}`);
value = output.trim();
}
} else {
value = output.trim();
}
// Mask the secret value so it does not appear in GitHub Actions logs
maskSecretValue(value);
return value;
} catch (error: any) {
OrchestratorLogger.logWarning(`Failed to fetch secret '${key}' from ${source.name}: ${error.message}`);
return '';
}
}
/**
* Fetch a secret from an environment variable. No shell command needed.
* The value is masked via core.setSecret() so it does not leak in logs.
*/
static fetchFromEnv(key: string): string {
const value = process.env[key] || '';
maskSecretValue(value);
return value;
}
/**
* Resolve a source name and fetch all secrets from it.
*
* @param sourceName - Premade source name, shell command, or 'env'
* @param keys - List of secret keys to fetch
* @returns Map of key -> value
*/
static async fetchAll(sourceName: string, keys: string[]): Promise<Record<string, string>> {
const results: Record<string, string> = {};
if (sourceName === 'env') {
for (const key of keys) {
results[key] = SecretSourceService.fetchFromEnv(key);
}
return results;
}
const source = SecretSourceService.resolveSource(sourceName);
if (!source) {
OrchestratorLogger.logWarning(
`Unknown secret source '${sourceName}'. Available sources: ${SecretSourceService.getAvailableSources().join(
', ',
)}`,
);
return results;
}
OrchestratorLogger.log(`Fetching ${keys.length} secret(s) from ${source.name}`);
for (const key of keys) {
results[key] = await SecretSourceService.fetchSecret(source, key);
}
return results;
}
/**
* Simple YAML parser for secret source definitions.
* Handles the specific structure we expect without requiring a YAML library.
*/
private static parseSimpleYaml(content: string): SecretSourceDefinition[] {
const definitions: SecretSourceDefinition[] = [];
const lines = content.split('\n');
let current: Partial<SecretSourceDefinition> | null = null;
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, '');
const trimmed = line.trim();
if (trimmed === '' || trimmed.startsWith('#')) continue;
if (trimmed === '- name:' || trimmed.startsWith('- name:')) {
if (current?.name && current?.command) {
definitions.push(current as SecretSourceDefinition);
}
current = {
name: trimmed
.replace('- name:', '')
.trim()
.replace(/^['"]|['"]$/g, ''),
parseOutput: 'raw',
};
continue;
}
if (current && trimmed.startsWith('command:')) {
current.command = trimmed
.replace('command:', '')
.trim()
.replace(/^['"]|['"]$/g, '');
} else if (current && trimmed.startsWith('parseOutput:')) {
const value = trimmed
.replace('parseOutput:', '')
.trim()
.replace(/^['"]|['"]$/g, '');
current.parseOutput = value as 'raw' | 'json-field';
} else if (current && trimmed.startsWith('jsonField:')) {
current.jsonField = trimmed
.replace('jsonField:', '')
.trim()
.replace(/^['"]|['"]$/g, '');
}
}
if (current?.name && current?.command) {
definitions.push(current as SecretSourceDefinition);
}
return definitions;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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