diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml new file mode 100644 index 00000000..07d5f50e --- /dev/null +++ b/.github/workflows/publish-cli.yml @@ -0,0 +1,57 @@ +name: Publish CLI + +on: + release: + types: [published] + workflow_dispatch: + inputs: + dry-run: + description: 'Dry run (no actual publish)' + required: false + default: 'true' + type: boolean + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + publish: + name: Publish to npm + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Build + run: yarn build + + - name: Run tests + run: yarn test + + - name: Verify CLI + run: | + node lib/cli.js version + node lib/cli.js --help + + - name: Publish (dry run) + if: github.event_name == 'workflow_dispatch' && inputs.dry-run == 'true' + run: npm publish --dry-run + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.dry-run == 'false') + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/src/cli/__tests__/cli-integration.test.ts b/src/cli/__tests__/cli-integration.test.ts new file mode 100644 index 00000000..d7dc12a9 --- /dev/null +++ b/src/cli/__tests__/cli-integration.test.ts @@ -0,0 +1,100 @@ +import { execFile } from 'node:child_process'; +import path from 'node:path'; + +/** + * Integration tests that spawn the CLI as a child process and verify + * exit codes and output. Uses node with --require ts-node/register to + * run the TypeScript entry point directly so no build step is required. + */ + +const CLI_ENTRY = path.resolve(__dirname, '..', '..', 'cli.ts'); + +function runCli(cliArguments: string[]): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolve) => { + execFile( + process.execPath, + ['--require', 'ts-node/register/transpile-only', CLI_ENTRY, ...cliArguments], + { timeout: 30_000, cwd: path.resolve(__dirname, '..', '..', '..') }, + (error, stdout, stderr) => { + resolve({ + code: error ? error.code ?? 1 : 0, + stdout: stdout.toString(), + stderr: stderr.toString(), + }); + }, + ); + }); +} + +// Integration tests spawn child processes which need more time than the default 5s +jest.setTimeout(30_000); + +describe('CLI integration', () => { + it('exits 0 and shows all commands for --help', async () => { + const result = await runCli(['--help']); + + expect(result.code).toStrictEqual(0); + expect(result.stdout).toContain('game-ci'); + expect(result.stdout).toContain('build'); + expect(result.stdout).toContain('activate'); + expect(result.stdout).toContain('orchestrate'); + expect(result.stdout).toContain('cache'); + expect(result.stdout).toContain('status'); + expect(result.stdout).toContain('version'); + }); + + it('exits 0 and shows version info for version command', async () => { + const result = await runCli(['version']); + + expect(result.code).toStrictEqual(0); + expect(result.stdout).toContain('unity-builder'); + }); + + it('exits 0 and shows build flags for build --help', async () => { + const result = await runCli(['build', '--help']); + + expect(result.code).toStrictEqual(0); + expect(result.stdout).toContain('--target-platform'); + expect(result.stdout).toContain('--unity-version'); + expect(result.stdout).toContain('--project-path'); + expect(result.stdout).toContain('--build-name'); + expect(result.stdout).toContain('--builds-path'); + expect(result.stdout).toContain('--build-method'); + expect(result.stdout).toContain('--custom-parameters'); + expect(result.stdout).toContain('--provider-strategy'); + }); + + it('exits non-zero for an unknown command', async () => { + const result = await runCli(['nonexistent']); + + expect(result.code).not.toStrictEqual(0); + }); + + it('exits non-zero when no command is provided', async () => { + const result = await runCli([]); + + expect(result.code).not.toStrictEqual(0); + }); + + it('exits 0 for orchestrate --help', async () => { + const result = await runCli(['orchestrate', '--help']); + + expect(result.code).toStrictEqual(0); + expect(result.stdout).toContain('--target-platform'); + expect(result.stdout).toContain('--provider-strategy'); + }); + + it('exits 0 for activate --help', async () => { + const result = await runCli(['activate', '--help']); + + expect(result.code).toStrictEqual(0); + expect(result.stdout).toContain('activate'); + }); + + it('exits 0 for cache --help', async () => { + const result = await runCli(['cache', '--help']); + + expect(result.code).toStrictEqual(0); + expect(result.stdout).toContain('cache'); + }); +}); diff --git a/src/cli/__tests__/commands.test.ts b/src/cli/__tests__/commands.test.ts new file mode 100644 index 00000000..e410456a --- /dev/null +++ b/src/cli/__tests__/commands.test.ts @@ -0,0 +1,224 @@ +import buildCommand from '../commands/build'; +import activateCommand from '../commands/activate'; +import orchestrateCommand from '../commands/orchestrate'; +import cacheCommand from '../commands/cache'; +import statusCommand from '../commands/status'; +import versionCommand from '../commands/version'; + +function createFakeYargs(): { yargs: any; options: Record } { + const options: Record = {}; + const yargs: any = { + option: jest.fn(), + positional: jest.fn(), + example: jest.fn(), + env: jest.fn(), + }; + + yargs.option.mockImplementation((name: string, config: any) => { + options[name] = config; + + return yargs; + }); + yargs.positional.mockImplementation((name: string, config: any) => { + options[name] = config; + + return yargs; + }); + yargs.example.mockReturnValue(yargs); + yargs.env.mockReturnValue(yargs); + + return { yargs, options }; +} + +describe('CLI commands', () => { + describe('build command', () => { + it('exports the correct command name', () => { + expect(buildCommand.command).toStrictEqual('build'); + }); + + it('has a description', () => { + expect(buildCommand.describe).toBeTruthy(); + }); + + it('has a builder function', () => { + expect(typeof buildCommand.builder).toStrictEqual('function'); + }); + + it('has a handler function', () => { + expect(typeof buildCommand.handler).toStrictEqual('function'); + }); + + it('defines all expected build flags via builder', () => { + const { yargs, options } = createFakeYargs(); + + (buildCommand.builder as Function)(yargs); + + // Core build flags + expect(options['target-platform']).toBeDefined(); + expect(options['target-platform'].demandOption).toStrictEqual(true); + expect(options['unity-version']).toBeDefined(); + expect(options['project-path']).toBeDefined(); + expect(options['build-profile']).toBeDefined(); + expect(options['build-name']).toBeDefined(); + expect(options['builds-path']).toBeDefined(); + expect(options['build-method']).toBeDefined(); + expect(options['custom-parameters']).toBeDefined(); + expect(options['versioning']).toBeDefined(); + expect(options['version']).toBeDefined(); + expect(options['custom-image']).toBeDefined(); + expect(options['manual-exit']).toBeDefined(); + expect(options['enable-gpu']).toBeDefined(); + + // Android flags + expect(options['android-version-code']).toBeDefined(); + expect(options['android-export-type']).toBeDefined(); + expect(options['android-keystore-name']).toBeDefined(); + expect(options['android-keystore-base64']).toBeDefined(); + expect(options['android-keystore-pass']).toBeDefined(); + expect(options['android-keyalias-name']).toBeDefined(); + expect(options['android-keyalias-pass']).toBeDefined(); + expect(options['android-target-sdk-version']).toBeDefined(); + expect(options['android-symbol-type']).toBeDefined(); + + // Docker flags + expect(options['docker-cpu-limit']).toBeDefined(); + expect(options['docker-memory-limit']).toBeDefined(); + expect(options['docker-workspace-path']).toBeDefined(); + expect(options['run-as-host-user']).toBeDefined(); + expect(options['chown-files-to']).toBeDefined(); + + // Provider flags + expect(options['provider-strategy']).toBeDefined(); + expect(options['skip-activation']).toBeDefined(); + expect(options['unity-licensing-server']).toBeDefined(); + }); + + it('sets correct default values', () => { + const { yargs, options } = createFakeYargs(); + + (buildCommand.builder as Function)(yargs); + + expect(options['unity-version'].default).toStrictEqual('auto'); + expect(options['project-path'].default).toStrictEqual('.'); + expect(options['builds-path'].default).toStrictEqual('build'); + expect(options['versioning'].default).toStrictEqual('Semantic'); + expect(options['manual-exit'].default).toStrictEqual(false); + expect(options['enable-gpu'].default).toStrictEqual(false); + expect(options['android-export-type'].default).toStrictEqual('androidPackage'); + expect(options['android-symbol-type'].default).toStrictEqual('none'); + expect(options['provider-strategy'].default).toStrictEqual('local'); + }); + + it('provides camelCase aliases for kebab-case options', () => { + const { yargs, options } = createFakeYargs(); + + (buildCommand.builder as Function)(yargs); + + expect(options['target-platform'].alias).toStrictEqual('targetPlatform'); + expect(options['unity-version'].alias).toStrictEqual('unityVersion'); + expect(options['project-path'].alias).toStrictEqual('projectPath'); + expect(options['build-name'].alias).toStrictEqual('buildName'); + expect(options['builds-path'].alias).toStrictEqual('buildsPath'); + expect(options['build-method'].alias).toStrictEqual('buildMethod'); + }); + }); + + describe('activate command', () => { + it('exports the correct command name', () => { + expect(activateCommand.command).toStrictEqual('activate'); + }); + + it('has a description', () => { + expect(activateCommand.describe).toBeTruthy(); + }); + + it('has a builder function', () => { + expect(typeof activateCommand.builder).toStrictEqual('function'); + }); + + it('has a handler function', () => { + expect(typeof activateCommand.handler).toStrictEqual('function'); + }); + }); + + describe('orchestrate command', () => { + it('exports the correct command name', () => { + expect(orchestrateCommand.command).toStrictEqual('orchestrate'); + }); + + it('has a description', () => { + expect(orchestrateCommand.describe).toBeTruthy(); + }); + + it('has a builder function', () => { + expect(typeof orchestrateCommand.builder).toStrictEqual('function'); + }); + + it('has a handler function', () => { + expect(typeof orchestrateCommand.handler).toStrictEqual('function'); + }); + + it('defines key orchestrator flags', () => { + const { yargs, options } = createFakeYargs(); + + (orchestrateCommand.builder as Function)(yargs); + + expect(options['target-platform']).toBeDefined(); + expect(options['target-platform'].demandOption).toStrictEqual(true); + expect(options['provider-strategy']).toBeDefined(); + expect(options['provider-strategy'].default).toStrictEqual('aws'); + expect(options['aws-stack-name']).toBeDefined(); + expect(options['kube-config']).toBeDefined(); + expect(options['kube-volume']).toBeDefined(); + expect(options['cache-key']).toBeDefined(); + expect(options['watch-to-end']).toBeDefined(); + expect(options['clone-depth']).toBeDefined(); + }); + }); + + describe('cache command', () => { + it('exports the correct command name', () => { + expect(cacheCommand.command).toStrictEqual('cache '); + }); + + it('has a description', () => { + expect(cacheCommand.describe).toBeTruthy(); + }); + + it('has a builder function', () => { + expect(typeof cacheCommand.builder).toStrictEqual('function'); + }); + + it('has a handler function', () => { + expect(typeof cacheCommand.handler).toStrictEqual('function'); + }); + }); + + describe('status command', () => { + it('exports the correct command name', () => { + expect(statusCommand.command).toStrictEqual('status'); + }); + + it('has a description', () => { + expect(statusCommand.describe).toBeTruthy(); + }); + + it('has a handler function', () => { + expect(typeof statusCommand.handler).toStrictEqual('function'); + }); + }); + + describe('version command', () => { + it('exports the correct command name', () => { + expect(versionCommand.command).toStrictEqual('version'); + }); + + it('has a description', () => { + expect(versionCommand.describe).toBeTruthy(); + }); + + it('has a handler function', () => { + expect(typeof versionCommand.handler).toStrictEqual('function'); + }); + }); +}); diff --git a/src/cli/__tests__/input-mapper.test.ts b/src/cli/__tests__/input-mapper.test.ts new file mode 100644 index 00000000..5f3e7b61 --- /dev/null +++ b/src/cli/__tests__/input-mapper.test.ts @@ -0,0 +1,221 @@ +import { mapCliArgumentsToInput, CliArguments } from '../input-mapper'; +import { Cli } from '../../model/cli/cli'; +import GitHub from '../../model/github'; + +afterEach(() => { + jest.restoreAllMocks(); + Cli.options = undefined; +}); + +describe('mapCliArgumentsToInput', () => { + describe('basic mapping', () => { + it('populates Cli.options from CLI arguments', () => { + const cliArguments: CliArguments = { + targetPlatform: 'StandaloneLinux64', + unityVersion: '2022.3.56f1', + projectPath: './my-project', + }; + + mapCliArgumentsToInput(cliArguments); + + expect(Cli.options).toBeDefined(); + expect(Cli.options!['targetPlatform']).toStrictEqual('StandaloneLinux64'); + expect(Cli.options!['unityVersion']).toStrictEqual('2022.3.56f1'); + expect(Cli.options!['projectPath']).toStrictEqual('./my-project'); + }); + + it('disables GitHub Actions input reading', () => { + const cliArguments: CliArguments = { targetPlatform: 'WebGL' }; + + mapCliArgumentsToInput(cliArguments); + + expect(GitHub.githubInputEnabled).toStrictEqual(false); + }); + + it('sets mode to cli by default when not provided', () => { + const cliArguments: CliArguments = { targetPlatform: 'Android' }; + + mapCliArgumentsToInput(cliArguments); + + expect(Cli.options!['mode']).toStrictEqual('cli'); + }); + + it('preserves an explicitly provided mode', () => { + const cliArguments: CliArguments = { targetPlatform: 'Android', mode: 'custom-mode' }; + + mapCliArgumentsToInput(cliArguments); + + expect(Cli.options!['mode']).toStrictEqual('custom-mode'); + }); + }); + + describe('default values', () => { + it('omits undefined values from Cli.options', () => { + const cliArguments: CliArguments = { + targetPlatform: 'StandaloneLinux64', + unityVersion: undefined, + buildName: undefined, + }; + + mapCliArgumentsToInput(cliArguments); + + expect(Cli.options!['targetPlatform']).toStrictEqual('StandaloneLinux64'); + expect(Cli.options!).not.toHaveProperty('unityVersion'); + expect(Cli.options!).not.toHaveProperty('buildName'); + }); + }); + + describe('boolean conversion', () => { + it('converts boolean true to string "true"', () => { + const cliArguments: CliArguments = { manualExit: true }; + + mapCliArgumentsToInput(cliArguments); + + expect(Cli.options!['manualExit']).toStrictEqual('true'); + }); + + it('converts boolean false to string "false"', () => { + const cliArguments: CliArguments = { enableGpu: false }; + + mapCliArgumentsToInput(cliArguments); + + expect(Cli.options!['enableGpu']).toStrictEqual('false'); + }); + + it('converts allowDirtyBuild boolean to string', () => { + const cliArguments: CliArguments = { allowDirtyBuild: true }; + + mapCliArgumentsToInput(cliArguments); + + expect(Cli.options!['allowDirtyBuild']).toStrictEqual('true'); + }); + }); + + describe('yargs internal properties', () => { + it('filters out yargs _ property', () => { + const cliArguments: CliArguments = { + targetPlatform: 'iOS', + _: ['build'] as any, + }; + + mapCliArgumentsToInput(cliArguments); + + expect(Cli.options!).not.toHaveProperty('_'); + }); + + it('filters out yargs $0 property', () => { + const cliArguments: CliArguments = { + targetPlatform: 'iOS', + $0: 'game-ci' as any, + }; + + mapCliArgumentsToInput(cliArguments); + + expect(Cli.options!).not.toHaveProperty('$0'); + }); + }); + + describe('flag name conversion', () => { + it('passes camelCase keys through directly', () => { + const cliArguments: CliArguments = { + androidKeystoreName: 'my.keystore', + androidKeystorePass: 'secret', + dockerCpuLimit: '4', + dockerMemoryLimit: '8g', + }; + + mapCliArgumentsToInput(cliArguments); + + expect(Cli.options!['androidKeystoreName']).toStrictEqual('my.keystore'); + expect(Cli.options!['androidKeystorePass']).toStrictEqual('secret'); + expect(Cli.options!['dockerCpuLimit']).toStrictEqual('4'); + expect(Cli.options!['dockerMemoryLimit']).toStrictEqual('8g'); + }); + + it('maps all android-related arguments', () => { + const cliArguments: CliArguments = { + androidVersionCode: '42', + androidExportType: 'androidAppBundle', + androidKeystoreBase64: 'base64data', + androidKeyaliasName: 'myalias', + androidKeyaliasPass: 'aliaspass', + androidTargetSdkVersion: '33', + androidSymbolType: 'public', + }; + + mapCliArgumentsToInput(cliArguments); + + expect(Cli.options!['androidVersionCode']).toStrictEqual('42'); + expect(Cli.options!['androidExportType']).toStrictEqual('androidAppBundle'); + expect(Cli.options!['androidKeystoreBase64']).toStrictEqual('base64data'); + expect(Cli.options!['androidKeyaliasName']).toStrictEqual('myalias'); + expect(Cli.options!['androidKeyaliasPass']).toStrictEqual('aliaspass'); + expect(Cli.options!['androidTargetSdkVersion']).toStrictEqual('33'); + expect(Cli.options!['androidSymbolType']).toStrictEqual('public'); + }); + + it('maps docker and container arguments', () => { + const cliArguments: CliArguments = { + dockerIsolationMode: 'hyperv', + dockerWorkspacePath: '/custom/workspace', + containerRegistryRepository: 'custom/editor', + containerRegistryImageVersion: '5', + runAsHostUser: 'true', + chownFilesTo: 'root:root', + }; + + mapCliArgumentsToInput(cliArguments); + + expect(Cli.options!['dockerIsolationMode']).toStrictEqual('hyperv'); + expect(Cli.options!['dockerWorkspacePath']).toStrictEqual('/custom/workspace'); + expect(Cli.options!['containerRegistryRepository']).toStrictEqual('custom/editor'); + expect(Cli.options!['containerRegistryImageVersion']).toStrictEqual('5'); + expect(Cli.options!['runAsHostUser']).toStrictEqual('true'); + expect(Cli.options!['chownFilesTo']).toStrictEqual('root:root'); + }); + + it('maps orchestrator-related arguments', () => { + const cliArguments: CliArguments = { + providerStrategy: 'k8s', + awsStackName: 'my-stack', + kubeConfig: 'base64config', + kubeVolume: 'my-pvc', + kubeVolumeSize: '10Gi', + kubeStorageClass: 'gp3', + containerCpu: '2048', + containerMemory: '4096', + cacheKey: 'my-cache', + watchToEnd: 'false', + cloneDepth: '100', + }; + + mapCliArgumentsToInput(cliArguments); + + expect(Cli.options!['providerStrategy']).toStrictEqual('k8s'); + expect(Cli.options!['awsStackName']).toStrictEqual('my-stack'); + expect(Cli.options!['kubeConfig']).toStrictEqual('base64config'); + expect(Cli.options!['kubeVolume']).toStrictEqual('my-pvc'); + expect(Cli.options!['kubeVolumeSize']).toStrictEqual('10Gi'); + expect(Cli.options!['kubeStorageClass']).toStrictEqual('gp3'); + expect(Cli.options!['containerCpu']).toStrictEqual('2048'); + expect(Cli.options!['containerMemory']).toStrictEqual('4096'); + expect(Cli.options!['cacheKey']).toStrictEqual('my-cache'); + expect(Cli.options!['watchToEnd']).toStrictEqual('false'); + expect(Cli.options!['cloneDepth']).toStrictEqual('100'); + }); + }); + + describe('Cli.isCliMode integration', () => { + it('enables CLI mode after mapping', () => { + const cliArguments: CliArguments = { targetPlatform: 'WebGL' }; + + mapCliArgumentsToInput(cliArguments); + + expect(Cli.isCliMode).toStrictEqual(true); + }); + + it('is not in CLI mode before mapping', () => { + expect(Cli.isCliMode).toStrictEqual(false); + }); + }); +});