import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs/promises'; import * as cache from '@actions/cache'; import * as exec from '@actions/exec'; import { binaryNameFor, downloadCli, resolveLatestTag } from './download-cli'; vi.mock('@actions/exec'); vi.mock('@actions/cache'); vi.mock('node:fs/promises'); function mockInstallScriptFetch(): void { globalThis.fetch = vi.fn(async () => ({ ok: true, status: 200, text: async () => '#!/usr/bin/env bash\necho mock install.sh', })) as unknown as typeof fetch; } describe('binaryNameFor', () => { it('is game-ci.exe on win32', () => { expect(binaryNameFor('win32')).toBe('game-ci.exe'); }); it('is game-ci on every other platform', () => { expect(binaryNameFor('linux')).toBe('game-ci'); expect(binaryNameFor('darwin')).toBe('game-ci'); }); }); describe('resolveLatestTag', () => { // This is the piece that makes caching "latest" possible at all: caching // under the literal string "latest" would silently pin every job to // whatever version happened to be current on the first cache write. // Resolving to the concrete tag first means a real new release is a fresh // cache key (a miss by construction), never a stale hit. it('returns the tag_name from a successful GitHub API response', async () => { const fetchFn = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ tag_name: 'v0.1.15' }), })) as unknown as typeof fetch; const tag = await resolveLatestTag(fetchFn); expect(tag).toBe('v0.1.15'); expect(fetchFn).toHaveBeenCalledWith( 'https://api.github.com/repos/game-ci/cli/releases/latest', expect.objectContaining({ headers: expect.objectContaining({ Accept: 'application/vnd.github+json' }), }), ); }); it('sends no Authorization header when GITHUB_TOKEN/GH_TOKEN are unset', async () => { const originalGithub = process.env.GITHUB_TOKEN; const originalGh = process.env.GH_TOKEN; delete process.env.GITHUB_TOKEN; delete process.env.GH_TOKEN; const fetchFn = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ tag_name: 'v0.1.15' }), })) as unknown as typeof fetch; try { await resolveLatestTag(fetchFn); const [, init] = vi.mocked(fetchFn).mock.calls[0] as [ string, RequestInit & { headers: Record }, ]; expect(init.headers.Authorization).toBeUndefined(); } finally { if (originalGithub !== undefined) process.env.GITHUB_TOKEN = originalGithub; if (originalGh !== undefined) process.env.GH_TOKEN = originalGh; } }); // The githubToken parameter is the action's own `githubToken` input, // which defaults to `${{ github.token }}` - populated by GitHub Actions on // every run with no consumer action needed. process.env.GITHUB_TOKEN, by // contrast, is NOT auto-injected into a JS action's environment - a // calling workflow has to set it explicitly, which essentially none did. // Confirmed live via game-ci/unity-test-runner#328: a consumer's six- // version matrix failed simultaneously with "GitHub API returned 403" // despite every job having a real, usable token the whole time. it('sends an Authorization header from the githubToken parameter even when no env var is set', async () => { const fetchFn = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ tag_name: 'v0.1.15' }), })) as unknown as typeof fetch; await resolveLatestTag(fetchFn, 'gha-token-from-input'); expect(fetchFn).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer gha-token-from-input' }), }), ); }); it('sends an Authorization header from GITHUB_TOKEN when set, to avoid the unauthenticated rate limit', async () => { const original = process.env.GITHUB_TOKEN; process.env.GITHUB_TOKEN = 'test-token-123'; const fetchFn = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ tag_name: 'v0.1.15' }), })) as unknown as typeof fetch; try { await resolveLatestTag(fetchFn); expect(fetchFn).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer test-token-123' }), }), ); } finally { if (original === undefined) delete process.env.GITHUB_TOKEN; else process.env.GITHUB_TOKEN = original; } }); it('falls back to GH_TOKEN when GITHUB_TOKEN is unset', async () => { const originalGithub = process.env.GITHUB_TOKEN; const originalGh = process.env.GH_TOKEN; delete process.env.GITHUB_TOKEN; process.env.GH_TOKEN = 'gh-token-456'; const fetchFn = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ tag_name: 'v0.1.15' }), })) as unknown as typeof fetch; try { await resolveLatestTag(fetchFn); expect(fetchFn).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer gh-token-456' }), }), ); } finally { if (originalGithub !== undefined) process.env.GITHUB_TOKEN = originalGithub; if (originalGh === undefined) delete process.env.GH_TOKEN; else process.env.GH_TOKEN = originalGh; } }); it('prefers the explicit githubToken parameter over GITHUB_TOKEN/GH_TOKEN when both are present', async () => { const originalGithub = process.env.GITHUB_TOKEN; const originalGh = process.env.GH_TOKEN; process.env.GITHUB_TOKEN = 'env-github-token'; process.env.GH_TOKEN = 'env-gh-token'; const fetchFn = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ tag_name: 'v0.1.15' }), })) as unknown as typeof fetch; try { await resolveLatestTag(fetchFn, 'explicit-input-token'); expect(fetchFn).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer explicit-input-token' }), }), ); } finally { if (originalGithub === undefined) delete process.env.GITHUB_TOKEN; else process.env.GITHUB_TOKEN = originalGithub; if (originalGh === undefined) delete process.env.GH_TOKEN; else process.env.GH_TOKEN = originalGh; } }); it('throws with a clear message on a non-ok response', async () => { const fetchFn = vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}), })) as unknown as typeof fetch; await expect(resolveLatestTag(fetchFn)).rejects.toThrow(/404/); }); it('throws with a clear message when the response has no tag_name', async () => { const fetchFn = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), })) as unknown as typeof fetch; await expect(resolveLatestTag(fetchFn)).rejects.toThrow(/no tag_name/); }); }); describe('downloadCli', () => { const originalFetch = globalThis.fetch; beforeEach(() => { vi.mocked(cache.isFeatureAvailable).mockReturnValue(false); // Real chmod/mkdir/writeFile aren't under test here (that's install.sh's // job, and it isn't actually run - see mockInstallScriptFetch) and the // paths involved don't exist on disk in this test environment. Without // these, restoreFromCache's real fs.chmod call throws ENOENT on any // platform where that branch actually runs (linux/darwin - the // `process.platform !== 'win32'` guard means it's silently skipped, and // the bug masked, on a Windows dev machine) - restoreFromCache's own // try/catch then swallows that and returns null, so a cache-hit test // silently falls through to the real install path instead of catching // what it's meant to catch. vi.mocked(fs.chmod).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined); vi.mocked(fs.writeFile).mockResolvedValue(undefined); }); afterEach(() => { vi.resetAllMocks(); globalThis.fetch = originalFetch; }); // The actual install mechanics (platform detection, archive format, // extraction) live in game-ci/cli's own scripts/install.sh now, fetched // and run at the resolved version's tag - see game-ci/cli#187. This // wrapper's own job is just: fetch that script, run it correctly, and // take its stdout as the binary path. it('fetches and runs install.sh for the given version, returning its stdout as the binary path', async () => { mockInstallScriptFetch(); vi.mocked(exec.exec).mockImplementation(async (_cmd, _args, options) => { options?.listeners?.stdout?.(Buffer.from('/tmp/game-ci-cli-cache/v0.1.32/game-ci\n')); return 0; }); const binaryPath = await downloadCli('v0.1.32'); expect(binaryPath).toBe('/tmp/game-ci-cli-cache/v0.1.32/game-ci'); expect(globalThis.fetch).toHaveBeenCalledWith( 'https://raw.githubusercontent.com/game-ci/cli/v0.1.32/scripts/install.sh', ); expect(exec.exec).toHaveBeenCalledWith( 'bash', expect.arrayContaining(['v0.1.32']), expect.anything(), ); }); // downloadCli is the only caller of resolveLatestTag in production code, // so this is what actually exercises the wiring index.ts depends on - the // resolveLatestTag-level test above only proves the function accepts the // parameter, not that anything passes it one. it('forwards its githubToken parameter to resolveLatestTag when resolving "latest"', async () => { globalThis.fetch = vi.fn(async (url: string) => { if (url.includes('/releases/latest')) { return { ok: true, status: 200, json: async () => ({ tag_name: 'v0.1.33' }) }; } return { ok: true, status: 200, text: async () => 'echo mock install.sh' }; }) as unknown as typeof fetch; vi.mocked(exec.exec).mockImplementation(async (_cmd, _args, options) => { options?.listeners?.stdout?.(Buffer.from('/tmp/game-ci\n')); return 0; }); await downloadCli('latest', 'gha-token-from-input'); expect(globalThis.fetch).toHaveBeenCalledWith( 'https://api.github.com/repos/game-ci/cli/releases/latest', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer gha-token-from-input' }), }), ); }); it('resolves "latest" to a concrete tag before fetching install.sh', async () => { globalThis.fetch = vi.fn(async (url: string) => { if (url.includes('/releases/latest')) { return { ok: true, status: 200, json: async () => ({ tag_name: 'v0.1.33' }) }; } return { ok: true, status: 200, text: async () => 'echo mock install.sh' }; }) as unknown as typeof fetch; vi.mocked(exec.exec).mockImplementation(async (_cmd, _args, options) => { options?.listeners?.stdout?.(Buffer.from('/tmp/game-ci\n')); return 0; }); await downloadCli('latest'); expect(globalThis.fetch).toHaveBeenCalledWith( 'https://raw.githubusercontent.com/game-ci/cli/v0.1.33/scripts/install.sh', ); expect(exec.exec).toHaveBeenCalledWith( 'bash', expect.arrayContaining(['v0.1.33']), expect.anything(), ); }); it('throws a clear error when the install.sh fetch fails', async () => { globalThis.fetch = vi.fn(async () => ({ ok: false, status: 404, })) as unknown as typeof fetch; await expect(downloadCli('v0.1.32')).rejects.toThrow(/404/); expect(exec.exec).not.toHaveBeenCalled(); }); it('throws a clear error when install.sh produces no output', async () => { mockInstallScriptFetch(); vi.mocked(exec.exec).mockImplementation(async () => 0); await expect(downloadCli('v0.1.32')).rejects.toThrow(/produced no output/); }); it('restores from cache instead of fetching install.sh on a cache hit', async () => { vi.mocked(cache.isFeatureAvailable).mockReturnValue(true); vi.mocked(cache.restoreCache).mockResolvedValue('game-ci-cli-v0.1.32-key'); const binaryPath = await downloadCli('v0.1.32'); expect(binaryPath).toContain(binaryNameFor(process.platform)); expect(exec.exec).not.toHaveBeenCalled(); }); });