Skip to content

Commit 5bdcf12

Browse files
frostebiteclaude
andcommitted
feat(cli): add npm publish workflow and CLI tests
Add .github/workflows/publish-cli.yml for publishing the CLI to npm on release or via manual workflow_dispatch with dry-run support. Add comprehensive test coverage for the CLI: - input-mapper.test.ts: 16 tests covering argument mapping, boolean conversion, yargs internal property filtering, and Cli.options population - commands.test.ts: 26 tests verifying command exports, builder flags, default values, and camelCase aliases for all six commands - cli-integration.test.ts: 8 integration tests spawning the CLI process to verify help output, version info, and error handling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5a42214 commit 5bdcf12

File tree

4 files changed

+602
-0
lines changed

4 files changed

+602
-0
lines changed

.github/workflows/publish-cli.yml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
name: Publish CLI
2+
3+
on:
4+
release:
5+
types: [published]
6+
workflow_dispatch:
7+
inputs:
8+
dry-run:
9+
description: 'Dry run (no actual publish)'
10+
required: false
11+
default: 'true'
12+
type: boolean
13+
14+
concurrency:
15+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
16+
cancel-in-progress: true
17+
18+
jobs:
19+
publish:
20+
name: Publish to npm
21+
runs-on: ubuntu-latest
22+
permissions:
23+
contents: read
24+
id-token: write
25+
steps:
26+
- uses: actions/checkout@v4
27+
28+
- uses: actions/setup-node@v4
29+
with:
30+
node-version: '20'
31+
registry-url: 'https://registry.npmjs.org'
32+
33+
- name: Install dependencies
34+
run: yarn install --frozen-lockfile
35+
36+
- name: Build
37+
run: yarn build
38+
39+
- name: Run tests
40+
run: yarn test
41+
42+
- name: Verify CLI
43+
run: |
44+
node lib/cli.js version
45+
node lib/cli.js --help
46+
47+
- name: Publish (dry run)
48+
if: github.event_name == 'workflow_dispatch' && inputs.dry-run == 'true'
49+
run: npm publish --dry-run
50+
env:
51+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
52+
53+
- name: Publish
54+
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.dry-run == 'false')
55+
run: npm publish --provenance --access public
56+
env:
57+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { execFile } from 'node:child_process';
2+
import path from 'node:path';
3+
4+
/**
5+
* Integration tests that spawn the CLI as a child process and verify
6+
* exit codes and output. Uses node with --require ts-node/register to
7+
* run the TypeScript entry point directly so no build step is required.
8+
*/
9+
10+
const CLI_ENTRY = path.resolve(__dirname, '..', '..', 'cli.ts');
11+
12+
function runCli(cliArguments: string[]): Promise<{ code: number | null; stdout: string; stderr: string }> {
13+
return new Promise((resolve) => {
14+
execFile(
15+
process.execPath,
16+
['--require', 'ts-node/register/transpile-only', CLI_ENTRY, ...cliArguments],
17+
{ timeout: 30_000, cwd: path.resolve(__dirname, '..', '..', '..') },
18+
(error, stdout, stderr) => {
19+
resolve({
20+
code: error ? error.code ?? 1 : 0,
21+
stdout: stdout.toString(),
22+
stderr: stderr.toString(),
23+
});
24+
},
25+
);
26+
});
27+
}
28+
29+
// Integration tests spawn child processes which need more time than the default 5s
30+
jest.setTimeout(30_000);
31+
32+
describe('CLI integration', () => {
33+
it('exits 0 and shows all commands for --help', async () => {
34+
const result = await runCli(['--help']);
35+
36+
expect(result.code).toStrictEqual(0);
37+
expect(result.stdout).toContain('game-ci');
38+
expect(result.stdout).toContain('build');
39+
expect(result.stdout).toContain('activate');
40+
expect(result.stdout).toContain('orchestrate');
41+
expect(result.stdout).toContain('cache');
42+
expect(result.stdout).toContain('status');
43+
expect(result.stdout).toContain('version');
44+
});
45+
46+
it('exits 0 and shows version info for version command', async () => {
47+
const result = await runCli(['version']);
48+
49+
expect(result.code).toStrictEqual(0);
50+
expect(result.stdout).toContain('unity-builder');
51+
});
52+
53+
it('exits 0 and shows build flags for build --help', async () => {
54+
const result = await runCli(['build', '--help']);
55+
56+
expect(result.code).toStrictEqual(0);
57+
expect(result.stdout).toContain('--target-platform');
58+
expect(result.stdout).toContain('--unity-version');
59+
expect(result.stdout).toContain('--project-path');
60+
expect(result.stdout).toContain('--build-name');
61+
expect(result.stdout).toContain('--builds-path');
62+
expect(result.stdout).toContain('--build-method');
63+
expect(result.stdout).toContain('--custom-parameters');
64+
expect(result.stdout).toContain('--provider-strategy');
65+
});
66+
67+
it('exits non-zero for an unknown command', async () => {
68+
const result = await runCli(['nonexistent']);
69+
70+
expect(result.code).not.toStrictEqual(0);
71+
});
72+
73+
it('exits non-zero when no command is provided', async () => {
74+
const result = await runCli([]);
75+
76+
expect(result.code).not.toStrictEqual(0);
77+
});
78+
79+
it('exits 0 for orchestrate --help', async () => {
80+
const result = await runCli(['orchestrate', '--help']);
81+
82+
expect(result.code).toStrictEqual(0);
83+
expect(result.stdout).toContain('--target-platform');
84+
expect(result.stdout).toContain('--provider-strategy');
85+
});
86+
87+
it('exits 0 for activate --help', async () => {
88+
const result = await runCli(['activate', '--help']);
89+
90+
expect(result.code).toStrictEqual(0);
91+
expect(result.stdout).toContain('activate');
92+
});
93+
94+
it('exits 0 for cache --help', async () => {
95+
const result = await runCli(['cache', '--help']);
96+
97+
expect(result.code).toStrictEqual(0);
98+
expect(result.stdout).toContain('cache');
99+
});
100+
});

src/cli/__tests__/commands.test.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import buildCommand from '../commands/build';
2+
import activateCommand from '../commands/activate';
3+
import orchestrateCommand from '../commands/orchestrate';
4+
import cacheCommand from '../commands/cache';
5+
import statusCommand from '../commands/status';
6+
import versionCommand from '../commands/version';
7+
8+
function createFakeYargs(): { yargs: any; options: Record<string, any> } {
9+
const options: Record<string, any> = {};
10+
const yargs: any = {
11+
option: jest.fn(),
12+
positional: jest.fn(),
13+
example: jest.fn(),
14+
env: jest.fn(),
15+
};
16+
17+
yargs.option.mockImplementation((name: string, config: any) => {
18+
options[name] = config;
19+
20+
return yargs;
21+
});
22+
yargs.positional.mockImplementation((name: string, config: any) => {
23+
options[name] = config;
24+
25+
return yargs;
26+
});
27+
yargs.example.mockReturnValue(yargs);
28+
yargs.env.mockReturnValue(yargs);
29+
30+
return { yargs, options };
31+
}
32+
33+
describe('CLI commands', () => {
34+
describe('build command', () => {
35+
it('exports the correct command name', () => {
36+
expect(buildCommand.command).toStrictEqual('build');
37+
});
38+
39+
it('has a description', () => {
40+
expect(buildCommand.describe).toBeTruthy();
41+
});
42+
43+
it('has a builder function', () => {
44+
expect(typeof buildCommand.builder).toStrictEqual('function');
45+
});
46+
47+
it('has a handler function', () => {
48+
expect(typeof buildCommand.handler).toStrictEqual('function');
49+
});
50+
51+
it('defines all expected build flags via builder', () => {
52+
const { yargs, options } = createFakeYargs();
53+
54+
(buildCommand.builder as Function)(yargs);
55+
56+
// Core build flags
57+
expect(options['target-platform']).toBeDefined();
58+
expect(options['target-platform'].demandOption).toStrictEqual(true);
59+
expect(options['unity-version']).toBeDefined();
60+
expect(options['project-path']).toBeDefined();
61+
expect(options['build-profile']).toBeDefined();
62+
expect(options['build-name']).toBeDefined();
63+
expect(options['builds-path']).toBeDefined();
64+
expect(options['build-method']).toBeDefined();
65+
expect(options['custom-parameters']).toBeDefined();
66+
expect(options['versioning']).toBeDefined();
67+
expect(options['version']).toBeDefined();
68+
expect(options['custom-image']).toBeDefined();
69+
expect(options['manual-exit']).toBeDefined();
70+
expect(options['enable-gpu']).toBeDefined();
71+
72+
// Android flags
73+
expect(options['android-version-code']).toBeDefined();
74+
expect(options['android-export-type']).toBeDefined();
75+
expect(options['android-keystore-name']).toBeDefined();
76+
expect(options['android-keystore-base64']).toBeDefined();
77+
expect(options['android-keystore-pass']).toBeDefined();
78+
expect(options['android-keyalias-name']).toBeDefined();
79+
expect(options['android-keyalias-pass']).toBeDefined();
80+
expect(options['android-target-sdk-version']).toBeDefined();
81+
expect(options['android-symbol-type']).toBeDefined();
82+
83+
// Docker flags
84+
expect(options['docker-cpu-limit']).toBeDefined();
85+
expect(options['docker-memory-limit']).toBeDefined();
86+
expect(options['docker-workspace-path']).toBeDefined();
87+
expect(options['run-as-host-user']).toBeDefined();
88+
expect(options['chown-files-to']).toBeDefined();
89+
90+
// Provider flags
91+
expect(options['provider-strategy']).toBeDefined();
92+
expect(options['skip-activation']).toBeDefined();
93+
expect(options['unity-licensing-server']).toBeDefined();
94+
});
95+
96+
it('sets correct default values', () => {
97+
const { yargs, options } = createFakeYargs();
98+
99+
(buildCommand.builder as Function)(yargs);
100+
101+
expect(options['unity-version'].default).toStrictEqual('auto');
102+
expect(options['project-path'].default).toStrictEqual('.');
103+
expect(options['builds-path'].default).toStrictEqual('build');
104+
expect(options['versioning'].default).toStrictEqual('Semantic');
105+
expect(options['manual-exit'].default).toStrictEqual(false);
106+
expect(options['enable-gpu'].default).toStrictEqual(false);
107+
expect(options['android-export-type'].default).toStrictEqual('androidPackage');
108+
expect(options['android-symbol-type'].default).toStrictEqual('none');
109+
expect(options['provider-strategy'].default).toStrictEqual('local');
110+
});
111+
112+
it('provides camelCase aliases for kebab-case options', () => {
113+
const { yargs, options } = createFakeYargs();
114+
115+
(buildCommand.builder as Function)(yargs);
116+
117+
expect(options['target-platform'].alias).toStrictEqual('targetPlatform');
118+
expect(options['unity-version'].alias).toStrictEqual('unityVersion');
119+
expect(options['project-path'].alias).toStrictEqual('projectPath');
120+
expect(options['build-name'].alias).toStrictEqual('buildName');
121+
expect(options['builds-path'].alias).toStrictEqual('buildsPath');
122+
expect(options['build-method'].alias).toStrictEqual('buildMethod');
123+
});
124+
});
125+
126+
describe('activate command', () => {
127+
it('exports the correct command name', () => {
128+
expect(activateCommand.command).toStrictEqual('activate');
129+
});
130+
131+
it('has a description', () => {
132+
expect(activateCommand.describe).toBeTruthy();
133+
});
134+
135+
it('has a builder function', () => {
136+
expect(typeof activateCommand.builder).toStrictEqual('function');
137+
});
138+
139+
it('has a handler function', () => {
140+
expect(typeof activateCommand.handler).toStrictEqual('function');
141+
});
142+
});
143+
144+
describe('orchestrate command', () => {
145+
it('exports the correct command name', () => {
146+
expect(orchestrateCommand.command).toStrictEqual('orchestrate');
147+
});
148+
149+
it('has a description', () => {
150+
expect(orchestrateCommand.describe).toBeTruthy();
151+
});
152+
153+
it('has a builder function', () => {
154+
expect(typeof orchestrateCommand.builder).toStrictEqual('function');
155+
});
156+
157+
it('has a handler function', () => {
158+
expect(typeof orchestrateCommand.handler).toStrictEqual('function');
159+
});
160+
161+
it('defines key orchestrator flags', () => {
162+
const { yargs, options } = createFakeYargs();
163+
164+
(orchestrateCommand.builder as Function)(yargs);
165+
166+
expect(options['target-platform']).toBeDefined();
167+
expect(options['target-platform'].demandOption).toStrictEqual(true);
168+
expect(options['provider-strategy']).toBeDefined();
169+
expect(options['provider-strategy'].default).toStrictEqual('aws');
170+
expect(options['aws-stack-name']).toBeDefined();
171+
expect(options['kube-config']).toBeDefined();
172+
expect(options['kube-volume']).toBeDefined();
173+
expect(options['cache-key']).toBeDefined();
174+
expect(options['watch-to-end']).toBeDefined();
175+
expect(options['clone-depth']).toBeDefined();
176+
});
177+
});
178+
179+
describe('cache command', () => {
180+
it('exports the correct command name', () => {
181+
expect(cacheCommand.command).toStrictEqual('cache <action>');
182+
});
183+
184+
it('has a description', () => {
185+
expect(cacheCommand.describe).toBeTruthy();
186+
});
187+
188+
it('has a builder function', () => {
189+
expect(typeof cacheCommand.builder).toStrictEqual('function');
190+
});
191+
192+
it('has a handler function', () => {
193+
expect(typeof cacheCommand.handler).toStrictEqual('function');
194+
});
195+
});
196+
197+
describe('status command', () => {
198+
it('exports the correct command name', () => {
199+
expect(statusCommand.command).toStrictEqual('status');
200+
});
201+
202+
it('has a description', () => {
203+
expect(statusCommand.describe).toBeTruthy();
204+
});
205+
206+
it('has a handler function', () => {
207+
expect(typeof statusCommand.handler).toStrictEqual('function');
208+
});
209+
});
210+
211+
describe('version command', () => {
212+
it('exports the correct command name', () => {
213+
expect(versionCommand.command).toStrictEqual('version');
214+
});
215+
216+
it('has a description', () => {
217+
expect(versionCommand.describe).toBeTruthy();
218+
});
219+
220+
it('has a handler function', () => {
221+
expect(typeof versionCommand.handler).toStrictEqual('function');
222+
});
223+
});
224+
});

0 commit comments

Comments
 (0)