Files
setup-go/__tests__/cache-utils.test.ts
qmuntal ad9941188f feat: split Go module and build caches
Reuse downloaded modules across Go versions, runner architectures, and
runner images on the same OS instead of duplicating them in each build
cache archive.

Restore and save each entry independently, running operations in parallel
when safe. Serialize restores for overlapping or aliased paths, preserve
the original paths for the post step, and report cache-hit only when both
entries match their primary keys.

Update the documented manual restore keys and generated action bundles.
2026-10-05 15:05:38 +02:00

454 lines
13 KiB
TypeScript

import {
jest,
describe,
it,
expect,
beforeEach,
afterEach,
afterAll
} from '@jest/globals';
import fs from 'fs';
import path from 'path';
jest.unstable_mockModule('@actions/exec', () => ({
exec: jest.fn(),
getExecOutput: jest.fn()
}));
jest.unstable_mockModule('@actions/cache', () => ({
saveCache: jest.fn(),
restoreCache: jest.fn(),
isFeatureAvailable: jest.fn()
}));
jest.unstable_mockModule('@actions/core', () => ({
info: jest.fn(),
warning: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
notice: jest.fn(),
setFailed: jest.fn(),
setOutput: jest.fn(),
getInput: jest.fn(),
getBooleanInput: jest.fn(),
getMultilineInput: jest.fn(),
addPath: jest.fn(),
exportVariable: jest.fn(),
saveState: jest.fn(),
getState: jest.fn(),
setSecret: jest.fn(),
isDebug: jest.fn(() => false),
startGroup: jest.fn(),
endGroup: jest.fn(),
group: jest.fn((_name: string, fn: () => Promise<unknown>) => fn()),
toPlatformPath: jest.fn((p: string) => p),
toWin32Path: jest.fn((p: string) => p),
toPosixPath: jest.fn((p: string) => p)
}));
const exec = await import('@actions/exec');
const cache = await import('@actions/cache');
const core = await import('@actions/core');
const cacheUtils = await import('../src/cache-utils.js');
import type {PackageManagerInfo} from '../src/package-managers.js';
describe('getCommandOutput', () => {
//Arrange
const getExecOutputSpy = exec.getExecOutput as jest.Mock<
typeof exec.getExecOutput
>;
it('should return trimmed stdout in case of successful exit code', async () => {
//Arrange
const stdoutResult = ' stdout ';
const trimmedStdout = stdoutResult.trim();
getExecOutputSpy.mockImplementation(async (commandLine: string) => {
return {exitCode: 0, stdout: stdoutResult, stderr: ''};
});
//Act + Assert
return cacheUtils
.getCommandOutput('command')
.then(data => expect(data).toBe(trimmedStdout));
});
it('should return error in case of unsuccessful exit code', async () => {
//Arrange
const stderrResult = 'error message';
getExecOutputSpy.mockImplementation(async (commandLine: string) => {
return {exitCode: 10, stdout: '', stderr: stderrResult};
});
//Act + Assert
await expect(async () => {
await cacheUtils.getCommandOutput('command');
}).rejects.toThrow();
});
});
describe('getPackageManagerInfo', () => {
it('should return package manager info in case of valid package manager name', async () => {
//Arrange
const packageManagerName = 'default';
const expectedResult = {
dependencyFilePattern: 'go.mod',
cacheFolderCommandList: [
{kind: 'modules', command: 'go env GOMODCACHE'},
{kind: 'build', command: 'go env GOCACHE'}
]
};
//Act + Assert
return cacheUtils
.getPackageManagerInfo(packageManagerName)
.then(data => expect(data).toEqual(expectedResult));
});
it('should throw the error in case of invalid package manager name', async () => {
//Arrange
const packageManagerName = 'invalidName';
//Act + Assert
await expect(async () => {
await cacheUtils.getPackageManagerInfo(packageManagerName);
}).rejects.toThrow();
});
});
describe('getCacheDirectoryPath', () => {
//Arrange
const getExecOutputSpy = exec.getExecOutput as jest.Mock<
typeof exec.getExecOutput
>;
const validPackageManager: PackageManagerInfo = {
dependencyFilePattern: 'go.mod',
cacheFolderCommandList: [
{kind: 'modules', command: 'go env GOMODCACHE'},
{kind: 'build', command: 'go env GOCACHE'}
]
};
it('should return path to the cache folders which specified package manager uses', async () => {
//Arrange
getExecOutputSpy.mockImplementation(async (commandLine: string) => {
return {
exitCode: 0,
stdout:
commandLine === 'go env GOMODCACHE'
? 'path/to/modules'
: 'path/to/build',
stderr: ''
};
});
const expectedResult = [
{kind: 'modules', path: 'path/to/modules'},
{kind: 'build', path: 'path/to/build'}
];
//Act + Assert
return cacheUtils
.getCacheDirectoryPath(validPackageManager)
.then(data => expect(data).toEqual(expectedResult));
});
it.each(validPackageManager.cacheFolderCommandList)(
'preserves the $kind identity when the other command returns an empty path',
async ({kind, command}) => {
getExecOutputSpy.mockImplementation(async commandLine => ({
exitCode: 0,
stdout: commandLine === command ? 'path/to/cache/folder' : '',
stderr: ''
}));
await expect(
cacheUtils.getCacheDirectoryPath(validPackageManager)
).resolves.toEqual([{kind, path: 'path/to/cache/folder'}]);
}
);
it.each(validPackageManager.cacheFolderCommandList)(
'preserves the $kind identity when the other command fails',
async ({kind, command}) => {
getExecOutputSpy.mockImplementation(async commandLine => {
if (commandLine !== command) {
throw new Error('Could not run go env');
}
return {exitCode: 0, stdout: 'path/to/cache/folder', stderr: ''};
});
await expect(
cacheUtils.getCacheDirectoryPath(validPackageManager)
).resolves.toEqual([{kind, path: 'path/to/cache/folder'}]);
expect(core.info).toHaveBeenCalledWith(
expect.stringContaining('Could not run go env')
);
}
);
it('should throw if the both commands return empty str', async () => {
getExecOutputSpy.mockImplementation(async (commandLine: string) => {
return {exitCode: 0, stdout: '', stderr: ''};
});
//Act + Assert
await expect(async () => {
await cacheUtils.getCacheDirectoryPath(validPackageManager);
}).rejects.toThrow();
});
it('should throw if both commands fail', async () => {
getExecOutputSpy.mockImplementation(async (commandLine: string) => {
return {exitCode: 10, stdout: '', stderr: 'Error message'};
});
//Act + Assert
await expect(async () => {
await cacheUtils.getCacheDirectoryPath(validPackageManager);
}).rejects.toThrow();
});
});
describe('canRestoreCachesInParallel', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(
process,
'platform'
)!;
const directoryStat = fs.lstatSync(process.cwd());
beforeEach(() => {
Object.defineProperty(process, 'platform', {value: 'linux'});
jest.spyOn(fs, 'lstatSync').mockReturnValue(directoryStat);
jest
.spyOn(fs, 'realpathSync')
.mockImplementation(directory => path.resolve(directory.toString()));
});
afterEach(() => {
Object.defineProperty(process, 'platform', originalPlatform);
jest.restoreAllMocks();
});
it.each([
{modules: 'cache/modules', build: 'cache/build', parallel: true},
{modules: 'cache', build: 'cache-build', parallel: true},
{modules: 'cache', build: 'cache', parallel: false},
{modules: 'cache', build: 'cache/build', parallel: false},
{modules: 'cache/modules', build: 'cache', parallel: false},
{modules: 'cache', build: 'cache/../cache', parallel: false}
])(
'checks overlapping paths: $modules and $build',
({modules, build, parallel}) => {
expect(
cacheUtils.canRestoreCachesInParallel([
{kind: 'modules', path: modules},
{kind: 'build', path: build}
])
).toBe(parallel);
}
);
it('allows concurrent Windows restores with disjoint paths', () => {
Object.defineProperty(process, 'platform', {value: 'win32'});
expect(
cacheUtils.canRestoreCachesInParallel([
{kind: 'modules', path: 'cache/modules'},
{kind: 'build', path: 'cache/build'}
])
).toBe(true);
});
it.each(['darwin', 'win32'])(
'handles case-insensitive paths conservatively on %s',
platform => {
Object.defineProperty(process, 'platform', {value: platform});
expect(
cacheUtils.canRestoreCachesInParallel([
{kind: 'modules', path: 'cache'},
{kind: 'build', path: 'CACHE/build'}
])
).toBe(false);
}
);
it.each([true, false])(
'serializes symlink aliases with missing cache directories (overlap=%s)',
overlap => {
const alias = path.resolve('alias');
const target = path.resolve('target');
jest.spyOn(fs, 'lstatSync').mockImplementation(directory => {
if (directory !== alias && directory !== target) {
throw Object.assign(new Error('Missing path'), {code: 'ENOENT'});
}
return directoryStat;
});
jest.spyOn(fs, 'realpathSync').mockReturnValue(target);
expect(
cacheUtils.canRestoreCachesInParallel([
{kind: 'modules', path: path.join(alias, 'modules')},
{
kind: 'build',
path: path.join(target, overlap ? 'modules/build' : 'build')
}
])
).toBe(false);
}
);
it.each(['nested alias', 'alias through module cache'])(
'serializes restores when the %s resolves outside the module cache',
scenario => {
const modules = path.resolve('modules');
const target = path.resolve('external-build');
const alias = path.join(modules, 'build-link');
const build =
scenario === 'nested alias'
? path.join(alias, 'cache')
: path.resolve('build-alias', 'cache');
jest.spyOn(fs, 'realpathSync').mockImplementation(directory => {
const absolute = path.resolve(directory.toString());
if (absolute === build) {
return path.join(target, 'cache');
}
if (absolute === path.dirname(build)) {
return scenario === 'nested alias' ? target : alias;
}
return absolute;
});
expect(
cacheUtils.canRestoreCachesInParallel([
{kind: 'modules', path: modules},
{kind: 'build', path: build}
])
).toBe(false);
}
);
it('falls back to sequential restores when path resolution fails', () => {
jest.spyOn(fs, 'realpathSync').mockImplementation(() => {
throw new Error('Cannot resolve path');
});
expect(
cacheUtils.canRestoreCachesInParallel([
{kind: 'modules', path: 'cache/modules'},
{kind: 'build', path: 'cache/build'}
])
).toBe(false);
});
});
describe('isCacheFeatureAvailable', () => {
//Arrange
const isFeatureAvailableSpy = cache.isFeatureAvailable as jest.Mock<
typeof cache.isFeatureAvailable
>;
const warningSpy = core.warning as jest.Mock<typeof core.warning>;
it('should return true when cache feature is available', () => {
//Arrange
isFeatureAvailableSpy.mockImplementation(() => {
return true;
});
//Act
const functionResult = cacheUtils.isCacheFeatureAvailable();
//Assert
expect(functionResult).toBeTruthy();
});
it('should warn when cache feature is unavailable and GHES is not used', () => {
//Arrange
isFeatureAvailableSpy.mockImplementation(() => {
return false;
});
process.env['GITHUB_SERVER_URL'] = 'https://github.com';
const warningMessage =
'The runner was not able to contact the cache service. Caching will be skipped';
//Act
cacheUtils.isCacheFeatureAvailable();
//Assert
expect(warningSpy).toHaveBeenCalledWith(warningMessage);
});
it('should return false when cache feature is unavailable', () => {
//Arrange
isFeatureAvailableSpy.mockImplementation(() => {
return false;
});
process.env['GITHUB_SERVER_URL'] = 'https://github.com';
//Act
const functionResult = cacheUtils.isCacheFeatureAvailable();
//Assert
expect(functionResult).toBeFalsy();
});
it('should warn when cache feature is unavailable and GHES is used', () => {
//Arrange
isFeatureAvailableSpy.mockImplementation(() => {
return false;
});
process.env['GITHUB_SERVER_URL'] = 'https://nongithub.com';
const warningMessage =
'Cache action is only supported on GHES version >= 3.5. If you are on version >=3.5 Please check with GHES admin if Actions cache service is enabled or not.';
//Act + Assert
expect(cacheUtils.isCacheFeatureAvailable()).toBeFalsy();
expect(warningSpy).toHaveBeenCalledWith(warningMessage);
});
});
describe('isGhes', () => {
const pristineEnv = {...process.env};
beforeEach(() => {
process.env = {...pristineEnv};
});
afterAll(() => {
process.env = pristineEnv;
});
it('returns false when the GITHUB_SERVER_URL environment variable is not defined', async () => {
delete process.env['GITHUB_SERVER_URL'];
expect(cacheUtils.isGhes()).toBeFalsy();
});
it('returns false when the GITHUB_SERVER_URL environment variable is set to github.com', async () => {
process.env['GITHUB_SERVER_URL'] = 'https://github.com';
expect(cacheUtils.isGhes()).toBeFalsy();
});
it('returns false when the GITHUB_SERVER_URL environment variable is set to a GitHub Enterprise Cloud-style URL', async () => {
process.env['GITHUB_SERVER_URL'] = 'https://contoso.ghe.com';
expect(cacheUtils.isGhes()).toBeFalsy();
});
it('returns false when the GITHUB_SERVER_URL environment variable has a .localhost suffix', async () => {
process.env['GITHUB_SERVER_URL'] = 'https://mock-github.localhost';
expect(cacheUtils.isGhes()).toBeFalsy();
});
it('returns true when the GITHUB_SERVER_URL environment variable is set to some other URL', async () => {
process.env['GITHUB_SERVER_URL'] = 'https://src.onpremise.fabrikam.com';
expect(cacheUtils.isGhes()).toBeTruthy();
});
});