/// import {jest, describe, it, expect, beforeEach, afterEach} from '@jest/globals'; import fs from 'fs'; import path from 'path'; jest.unstable_mockModule('@actions/cache', () => ({ saveCache: jest.fn(), restoreCache: jest.fn(), isFeatureAvailable: jest.fn() })); jest.unstable_mockModule('@actions/glob', () => ({ create: jest.fn(), hashFiles: 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) => fn()), toPlatformPath: jest.fn((p: string) => p), toWin32Path: jest.fn((p: string) => p), toPosixPath: jest.fn((p: string) => p) })); // Import real cache-utils (with mocked @actions) before mocking it const realCacheUtils = await import('../src/cache-utils.js'); jest.unstable_mockModule('../src/cache-utils.js', () => ({ ...realCacheUtils, getCacheDirectoryPath: jest.fn() })); const cache = await import('@actions/cache'); const core = await import('@actions/core'); const glob = await import('@actions/glob'); const cacheRestore = await import('../src/cache-restore.js'); const cacheUtils = await import('../src/cache-utils.js'); const {run: saveCaches} = await import('../src/cache-save.js'); import type {CacheDirectory} from '../src/cache-utils.js'; function deferred() { let resolve!: () => void; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; } describe('restoreCache', () => { const hashFilesSpy = glob.hashFiles as jest.Mock; const getCacheDirectoryPathSpy = cacheUtils.getCacheDirectoryPath as jest.Mock< typeof cacheUtils.getCacheDirectoryPath >; const restoreCacheSpy = cache.restoreCache as jest.Mock< typeof cache.restoreCache >; const saveCacheSpy = cache.saveCache as jest.Mock; const infoSpy = core.info as jest.Mock; const setOutputSpy = core.setOutput as jest.Mock; const versionSpec = '1.13.1'; const packageManager = 'default'; const cacheDependencyPath = 'path'; const modulePath = 'module_cache_directory'; const buildPath = 'build_cache_directory'; const directories: CacheDirectory[] = [ {kind: 'modules', path: modulePath}, {kind: 'build', path: buildPath} ]; const originalEnv = {...process.env}; const originalArch = Object.getOwnPropertyDescriptor(process, 'arch')!; const originalPlatform = Object.getOwnPropertyDescriptor( process, 'platform' )!; beforeEach(() => { jest.resetAllMocks(); process.env = { ...originalEnv, GITHUB_WORKSPACE: '/test/workspace', RUNNER_OS: 'Linux', ImageOS: 'ubuntu24' }; Object.defineProperty(process, 'arch', {value: 'x64'}); Object.defineProperty(process, 'platform', {value: 'linux'}); hashFilesSpy.mockResolvedValue('file_hash'); getCacheDirectoryPathSpy.mockResolvedValue(directories); restoreCacheSpy.mockResolvedValue(undefined); saveCacheSpy.mockResolvedValue(1); }); afterEach(() => { process.env = {...originalEnv}; Object.defineProperty(process, 'arch', originalArch); Object.defineProperty(process, 'platform', originalPlatform); jest.restoreAllMocks(); }); const restore = (version = versionSpec) => cacheRestore.restoreCache(version, packageManager, cacheDependencyPath); const requestedKeys = () => { const moduleCall = restoreCacheSpy.mock.calls.find( ([paths]) => paths[0] === modulePath ); const buildCall = restoreCacheSpy.mock.calls.find( ([paths]) => paths[0] === buildPath ); expect(moduleCall).toBeDefined(); expect(buildCall).toBeDefined(); return {modules: moduleCall![1], build: buildCall![1]}; }; it('should throw if dependency file path is not valid', async () => { hashFilesSpy.mockResolvedValue(''); await expect(restore()).rejects.toThrow( 'Some specified paths were not resolved, unable to cache dependencies.' ); expect(restoreCacheSpy).not.toHaveBeenCalled(); expect(setOutputSpy).toHaveBeenLastCalledWith('cache-hit', false); }); it('restores modules and build outputs as separate cache entries', async () => { await restore(); const keys = requestedKeys(); expect(keys.modules).not.toBe(keys.build); expect(restoreCacheSpy).toHaveBeenCalledTimes(2); expect(restoreCacheSpy).toHaveBeenCalledWith([modulePath], keys.modules); expect(restoreCacheSpy).toHaveBeenCalledWith([buildPath], keys.build); expect(infoSpy).toHaveBeenCalledWith('modules cache is not found'); expect(infoSpy).toHaveBeenCalledWith('build cache is not found'); }); it.each([ {platform: 'linux', firstFails: false}, {platform: 'linux', firstFails: true}, {platform: 'win32', firstFails: false}, {platform: 'win32', firstFails: true} ])( 'starts both restores concurrently on $platform and waits for both (first fails=$firstFails)', async ({platform, firstFails}) => { Object.defineProperty(process, 'platform', {value: platform}); const started = [deferred(), deferred()]; const release = [deferred(), deferred()]; let completed = false; restoreCacheSpy.mockImplementation(async (paths, key) => { const index = paths[0] === modulePath ? 0 : 1; started[index].resolve(); await release[index].promise; if (index === 0 && firstFails) { throw new Error('Module cache unavailable'); } return key; }); const pending = restore().then(() => { completed = true; }); try { await Promise.all(started.map(signal => signal.promise)); release[0].resolve(); // Let the first completion propagate while the second stays blocked. await new Promise(resolve => setImmediate(resolve)); expect(completed).toBe(false); expect(core.saveState).not.toHaveBeenCalled(); expect(setOutputSpy).not.toHaveBeenCalledWith('cache-hit', true); } finally { release.forEach(signal => signal.resolve()); await pending; } expect(setOutputSpy).toHaveBeenLastCalledWith('cache-hit', !firstFails); expect(core.saveState).toHaveBeenCalledTimes(1); } ); it.each(['linux', 'win32'])( 'restores overlapping directories sequentially on %s', async platform => { Object.defineProperty(process, 'platform', {value: platform}); getCacheDirectoryPathSpy.mockResolvedValue([ directories[0], {kind: 'build', path: path.join(modulePath, 'build')} ]); const firstStarted = deferred(); const releaseFirst = deferred(); restoreCacheSpy.mockImplementation(async (paths, key) => { if (paths[0] === modulePath) { firstStarted.resolve(); await releaseFirst.promise; } return key; }); const pending = restore(); try { await firstStarted.promise; expect(restoreCacheSpy).toHaveBeenCalledTimes(1); } finally { releaseFirst.resolve(); await pending; } expect(restoreCacheSpy).toHaveBeenCalledTimes(2); expect(setOutputSpy).toHaveBeenLastCalledWith('cache-hit', true); } ); it.each([ {modules: false, build: false}, {modules: true, build: false}, {modules: false, build: true}, {modules: true, build: true} ])( 'reports hits and saves misses independently (modules=$modules, build=$build)', async hits => { const state = new Map(); (core.saveState as jest.Mock).mockImplementation( (name, value) => { state.set(name, JSON.stringify(value)); } ); (core.getState as jest.Mock).mockImplementation( name => state.get(name) || '' ); ( core.getBooleanInput as jest.Mock ).mockReturnValue(true); jest.spyOn(fs, 'existsSync').mockReturnValue(true); restoreCacheSpy.mockImplementation(async (paths, key) => hits[paths[0] === modulePath ? 'modules' : 'build'] ? key : undefined ); await restore(); const keys = requestedKeys(); expect(setOutputSpy).toHaveBeenLastCalledWith( 'cache-hit', hits.modules && hits.build ); // Changing the Go environment later must not change the saved paths. process.env.GOMODCACHE = 'different_module_cache'; process.env.GOCACHE = 'different_build_cache'; await saveCaches(); for (const {kind, path} of directories) { if (hits[kind]) { expect(saveCacheSpy).not.toHaveBeenCalledWith([path], keys[kind]); } else { expect(saveCacheSpy).toHaveBeenCalledWith([path], keys[kind]); } } expect(saveCacheSpy).toHaveBeenCalledTimes( Number(!hits.modules) + Number(!hits.build) ); } ); it('does not report a full hit for a non-primary cache match', async () => { restoreCacheSpy.mockImplementation(async (_paths, key) => `${key}-other`); await restore(); expect(setOutputSpy).toHaveBeenLastCalledWith('cache-hit', false); }); it.each(directories)( 'continues restoring when the $kind cache fails', async ({kind, path}) => { restoreCacheSpy.mockImplementation(async (paths, key) => { if (paths[0] === path) { throw new Error('Unable to reach the service'); } return key; }); await restore(); expect(restoreCacheSpy).toHaveBeenCalledTimes(2); expect(core.warning).toHaveBeenCalledWith( `Restore ${kind} cache failed: Unable to reach the service` ); expect(setOutputSpy).toHaveBeenLastCalledWith('cache-hit', false); } ); it.each(directories)( 'restores only $kind when the other directory cannot be resolved', async directory => { getCacheDirectoryPathSpy.mockResolvedValue([directory]); restoreCacheSpy.mockImplementation(async (_paths, key) => key); await restore(); expect(restoreCacheSpy).toHaveBeenCalledTimes(1); expect(restoreCacheSpy).toHaveBeenCalledWith( [directory.path], expect.any(String) ); expect(setOutputSpy).toHaveBeenLastCalledWith('cache-hit', false); } ); it('reuses modules across Go versions without reusing build outputs', async () => { await restore(); const before = requestedKeys(); restoreCacheSpy.mockClear(); await restore('1.26.1'); const after = requestedKeys(); expect(after.modules).toBe(before.modules); expect(after.build).not.toBe(before.build); }); it.each([ { name: 'runner architecture', change: () => Object.defineProperty(process, 'arch', {value: 'arm64'}) }, { name: 'runner image', change: () => { process.env.ImageOS = 'ubuntu26'; } } ])('reuses only modules when the $name changes', async ({change}) => { await restore(); const before = requestedKeys(); restoreCacheSpy.mockClear(); change(); await restore(); const after = requestedKeys(); expect(after.modules).toBe(before.modules); expect(after.build).not.toBe(before.build); }); it('keeps both cache entries isolated by runner OS', async () => { await restore(); const before = requestedKeys(); restoreCacheSpy.mockClear(); process.env.RUNNER_OS = 'Windows'; await restore(); const after = requestedKeys(); expect(after.modules).not.toBe(before.modules); expect(after.build).not.toBe(before.build); }); it('invalidates both cache entries when the dependency hash changes', async () => { await restore(); const before = requestedKeys(); restoreCacheSpy.mockClear(); hashFilesSpy.mockResolvedValue('different_file_hash'); await restore(); const after = requestedKeys(); expect(after.modules).not.toBe(before.modules); expect(after.build).not.toBe(before.build); }); it('hashes the configured dependency paths for both cache entries', async () => { const dependencyPaths = 'go.sum\ntools/go.sum'; await cacheRestore.restoreCache( versionSpec, packageManager, dependencyPaths ); expect(hashFilesSpy).toHaveBeenCalledWith(dependencyPaths); expect(restoreCacheSpy).toHaveBeenCalledTimes(2); }); it('should throw if dependency file is not found in workspace', async () => { jest.spyOn(fs, 'readdirSync').mockReturnValue(['main.go'] as any); await expect( cacheRestore.restoreCache( versionSpec, packageManager // No cacheDependencyPath ) ).rejects.toThrow( 'Dependencies file is not found in /test/workspace. Supported file pattern: go.mod' ); }); });