/// import {jest, describe, it, expect, beforeEach, afterEach} from '@jest/globals'; 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(), setFailed: jest.fn(), getInput: jest.fn(), getBooleanInput: jest.fn(), getState: jest.fn(), saveState: jest.fn() })); const realFs = (await import('fs')).default; const fsExports = {...realFs, existsSync: jest.fn()}; jest.unstable_mockModule('fs', () => ({...fsExports, default: fsExports})); const cache = await import('@actions/cache'); const core = await import('@actions/core'); const fs = (await import('fs')).default; const {run} = await import('../src/cache-save.js'); import type {CacheEntry} from '../src/cache-utils.js'; function deferred() { let resolve!: () => void; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; } describe('cache-save', () => { let entries: CacheEntry[]; let getBooleanInputSpy: jest.Mock; let getStateSpy: jest.Mock; let infoSpy: jest.Mock; let warningSpy: jest.Mock; let debugSpy: jest.Mock; let setFailedSpy: jest.Mock; let saveCacheSpy: jest.Mock; let existsSpy: jest.Mock; beforeEach(() => { entries = [ { kind: 'modules', path: 'module_cache_directory', primaryKey: 'modules-primary-key' }, { kind: 'build', path: 'build_cache_directory', primaryKey: 'build-primary-key' } ]; getBooleanInputSpy = core.getBooleanInput as jest.Mock< typeof core.getBooleanInput >; getBooleanInputSpy.mockReturnValue(true); getStateSpy = core.getState as jest.Mock; getStateSpy.mockImplementation(() => JSON.stringify(entries)); infoSpy = core.info as jest.Mock; infoSpy.mockImplementation(() => undefined); warningSpy = core.warning as jest.Mock; warningSpy.mockImplementation(() => undefined); debugSpy = core.debug as jest.Mock; debugSpy.mockImplementation(() => undefined); setFailedSpy = core.setFailed as jest.Mock; setFailedSpy.mockImplementation(() => undefined); saveCacheSpy = cache.saveCache as jest.Mock; saveCacheSpy.mockImplementation(() => Promise.resolve(0)); existsSpy = fs.existsSync as jest.Mock; existsSpy.mockImplementation(() => true); }); afterEach(() => { jest.restoreAllMocks(); jest.clearAllMocks(); }); it('does not save cache when the cache input is false', async () => { getBooleanInputSpy.mockReturnValue(false); await run(); expect(saveCacheSpy).not.toHaveBeenCalled(); expect(warningSpy).not.toHaveBeenCalled(); expect(setFailedSpy).not.toHaveBeenCalled(); }); it.each([false, true])( 'starts both saves concurrently and waits before exiting (first fails=%s)', async firstFails => { const started = [deferred(), deferred()]; const release = [deferred(), deferred()]; const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => { return undefined as never; }); saveCacheSpy.mockImplementation(async paths => { const index = paths[0] === entries[0].path ? 0 : 1; started[index].resolve(); await release[index].promise; if (index === 0 && firstFails) { throw new Error('Module cache unavailable'); } return 1; }); const pending = run(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(exitSpy).not.toHaveBeenCalled(); } finally { release.forEach(signal => signal.resolve()); await pending; } expect(exitSpy).toHaveBeenCalledTimes(1); expect(exitSpy).toHaveBeenCalledWith(0); expect(infoSpy).toHaveBeenCalledWith( `Cache saved with the key: ${entries[1].primaryKey}` ); expect(setFailedSpy).not.toHaveBeenCalled(); } ); it('does not save cache when there are no cache folders on the disk', async () => { existsSpy.mockImplementation(() => false); await run(); for (const {kind, path} of entries) { expect(infoSpy).toHaveBeenCalledWith( `[warning]${kind} cache folder doesn't exist on disk: ${path}` ); } expect(saveCacheSpy).not.toHaveBeenCalled(); expect(setFailedSpy).not.toHaveBeenCalled(); }); it.each(['modules', 'build'])( 'saves the other cache when the %s directory is missing', async kind => { const missing = entries.find(entry => entry.kind === kind)!; const existing = entries.find(entry => entry.kind !== kind)!; existsSpy.mockImplementation(path => path !== missing.path); await run(); expect(saveCacheSpy).toHaveBeenCalledTimes(1); expect(saveCacheSpy).toHaveBeenCalledWith( [existing.path], existing.primaryKey ); expect(setFailedSpy).not.toHaveBeenCalled(); } ); it('does not save cache when no cache entries were prepared', async () => { getStateSpy.mockReturnValue(''); await run(); expect(infoSpy).toHaveBeenCalledWith( 'Cache keys were not generated. Please check the log messages above for more errors or information' ); expect(saveCacheSpy).not.toHaveBeenCalled(); expect(setFailedSpy).not.toHaveBeenCalled(); }); it('does not fail the workflow if the saved state is invalid', async () => { getStateSpy.mockReturnValue('{'); await run(); expect(warningSpy).toHaveBeenCalledWith(expect.any(String)); expect(saveCacheSpy).not.toHaveBeenCalled(); expect(setFailedSpy).not.toHaveBeenCalled(); }); it('does not save either cache when both primary keys were hit', async () => { for (const entry of entries) { entry.matchedKey = entry.primaryKey; } await run(); expect(saveCacheSpy).not.toHaveBeenCalled(); expect(warningSpy).not.toHaveBeenCalled(); expect(setFailedSpy).not.toHaveBeenCalled(); }); it.each(['modules', 'build'])( 'skips only the %s cache when its primary key was hit', async kind => { const hit = entries.find(entry => entry.kind === kind)!; const miss = entries.find(entry => entry.kind !== kind)!; hit.matchedKey = hit.primaryKey; await run(); expect(saveCacheSpy).toHaveBeenCalledTimes(1); expect(saveCacheSpy).toHaveBeenCalledWith([miss.path], miss.primaryKey); expect(warningSpy).not.toHaveBeenCalled(); expect(setFailedSpy).not.toHaveBeenCalled(); } ); it('saves both cache directories under separate keys on a cold run', async () => { await run(); expect(saveCacheSpy).toHaveBeenCalledTimes(2); for (const {path, primaryKey} of entries) { expect(saveCacheSpy).toHaveBeenCalledWith([path], primaryKey); expect(infoSpy).toHaveBeenCalledWith( `Cache saved with the key: ${primaryKey}` ); } expect(warningSpy).not.toHaveBeenCalled(); expect(setFailedSpy).not.toHaveBeenCalled(); }); it('saves caches restored from non-primary matches', async () => { for (const entry of entries) { entry.matchedKey = `${entry.primaryKey}-other`; } await run(); expect(saveCacheSpy).toHaveBeenCalledTimes(2); expect(warningSpy).not.toHaveBeenCalled(); expect(setFailedSpy).not.toHaveBeenCalled(); }); it.each(['modules', 'build'])( 'continues saving when the %s cache reservation fails', async kind => { const failed = entries.find(entry => entry.kind === kind)!; const saved = entries.find(entry => entry.kind !== kind)!; saveCacheSpy.mockImplementation(async paths => paths[0] === failed.path ? -1 : 1 ); await run(); expect(saveCacheSpy).toHaveBeenCalledTimes(2); expect(debugSpy).toHaveBeenCalledWith( `Cache was not saved for the key: ${failed.primaryKey}` ); expect(infoSpy).not.toHaveBeenCalledWith( `Cache saved with the key: ${failed.primaryKey}` ); expect(infoSpy).toHaveBeenCalledWith( `Cache saved with the key: ${saved.primaryKey}` ); expect(warningSpy).not.toHaveBeenCalled(); expect(setFailedSpy).not.toHaveBeenCalled(); } ); it.each(['modules', 'build'])( 'continues saving when the %s cache upload throws', async kind => { const failed = entries.find(entry => entry.kind === kind)!; const saved = entries.find(entry => entry.kind !== kind)!; saveCacheSpy.mockImplementation(async paths => { if (paths[0] === failed.path) { throw new Error('Unable to reach the service'); } return 1; }); await run(); expect(saveCacheSpy).toHaveBeenCalledTimes(2); expect(warningSpy).toHaveBeenCalledWith( `Save ${kind} cache failed: Unable to reach the service` ); expect(infoSpy).toHaveBeenCalledWith( `Cache saved with the key: ${saved.primaryKey}` ); expect(setFailedSpy).not.toHaveBeenCalled(); } ); });