From f1500e42132dd3c27706ffaacab723107c6b90a8 Mon Sep 17 00:00:00 2001 From: Dhaiwat Date: Wed, 30 Oct 2024 14:47:30 +0530 Subject: [PATCH] feat: cache latest `fuels` version (#3350) Co-authored-by: Peter Smith Co-authored-by: Chad Nehemiah --- .changeset/kind-chairs-move.md | 5 ++ .gitignore | 2 + packages/fuels/src/cli.test.ts | 3 + .../fuels/src/cli/config/loadConfig.test.ts | 5 ++ .../utils/checkForAndDisplayUpdates.test.ts | 54 ++++++------ .../cli/utils/checkForAndDisplayUpdates.ts | 14 +--- .../src/cli/utils/fuelsVersionCache.test.ts | 82 +++++++++++++++++++ .../fuels/src/cli/utils/fuelsVersionCache.ts | 31 +++++++ .../cli/utils/getLatestFuelsVersion.test.ts | 50 +++++++++++ .../src/cli/utils/getLatestFuelsVersion.ts | 26 ++++++ packages/fuels/src/run.ts | 3 +- packages/fuels/test/features/build.test.ts | 5 ++ packages/fuels/test/features/deploy.test.ts | 2 + packages/fuels/test/features/dev.test.ts | 5 ++ packages/fuels/test/features/init.test.ts | 2 + .../fuels/test/utils/mockCheckForUpdates.ts | 7 ++ 16 files changed, 260 insertions(+), 36 deletions(-) create mode 100644 .changeset/kind-chairs-move.md create mode 100644 packages/fuels/src/cli/utils/fuelsVersionCache.test.ts create mode 100644 packages/fuels/src/cli/utils/fuelsVersionCache.ts create mode 100644 packages/fuels/src/cli/utils/getLatestFuelsVersion.test.ts create mode 100644 packages/fuels/src/cli/utils/getLatestFuelsVersion.ts create mode 100644 packages/fuels/test/utils/mockCheckForUpdates.ts diff --git a/.changeset/kind-chairs-move.md b/.changeset/kind-chairs-move.md new file mode 100644 index 00000000000..e0e855e1dbd --- /dev/null +++ b/.changeset/kind-chairs-move.md @@ -0,0 +1,5 @@ +--- +"fuels": patch +--- + +feat: cache latest `fuels` version diff --git a/.gitignore b/.gitignore index c59f29d4e35..92f937b71d7 100644 --- a/.gitignore +++ b/.gitignore @@ -168,3 +168,5 @@ Forc.lock /playwright-report/ /blob-report/ /playwright/.cache/ + +FUELS_VERSION \ No newline at end of file diff --git a/packages/fuels/src/cli.test.ts b/packages/fuels/src/cli.test.ts index 129769e12b9..257f33886d6 100644 --- a/packages/fuels/src/cli.test.ts +++ b/packages/fuels/src/cli.test.ts @@ -1,5 +1,7 @@ import { Command } from 'commander'; +import { mockCheckForUpdates } from '../test/utils/mockCheckForUpdates'; + import * as cliMod from './cli'; import { Commands } from './cli/types'; import * as loggingMod from './cli/utils/logger'; @@ -77,6 +79,7 @@ describe('cli.js', () => { .mockReturnValue(Promise.resolve(command)); const configureCli = vi.spyOn(cliMod, 'configureCli').mockImplementation(() => new Command()); + mockCheckForUpdates(); await run([]); diff --git a/packages/fuels/src/cli/config/loadConfig.test.ts b/packages/fuels/src/cli/config/loadConfig.test.ts index 857f2b6e8df..47e5fd84800 100644 --- a/packages/fuels/src/cli/config/loadConfig.test.ts +++ b/packages/fuels/src/cli/config/loadConfig.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'fs'; import { resolve } from 'path'; +import { mockCheckForUpdates } from '../../../test/utils/mockCheckForUpdates'; import { runInit, bootstrapProject, @@ -19,6 +20,10 @@ import { loadConfig } from './loadConfig'; describe('loadConfig', () => { const paths = bootstrapProject(__filename); + beforeEach(() => { + mockCheckForUpdates(); + }); + afterEach(() => { resetConfigAndMocks(paths.fuelsConfigPath); }); diff --git a/packages/fuels/src/cli/utils/checkForAndDisplayUpdates.test.ts b/packages/fuels/src/cli/utils/checkForAndDisplayUpdates.test.ts index 107027e9d59..113f70f096d 100644 --- a/packages/fuels/src/cli/utils/checkForAndDisplayUpdates.test.ts +++ b/packages/fuels/src/cli/utils/checkForAndDisplayUpdates.test.ts @@ -1,6 +1,7 @@ import * as versionsMod from '@fuel-ts/versions'; import * as checkForAndDisplayUpdatesMod from './checkForAndDisplayUpdates'; +import * as getLatestFuelsVersionMod from './getLatestFuelsVersion'; import * as loggerMod from './logger'; /** @@ -15,9 +16,9 @@ describe('checkForAndDisplayUpdates', () => { vi.restoreAllMocks(); }); - const mockDeps = (params: { latestVersion: string; userVersion: string }) => { + const mockDeps = (params: { latestVersion?: string; userVersion: string }) => { const { latestVersion, userVersion } = params; - vi.spyOn(Promise, 'race').mockReturnValue(Promise.resolve(latestVersion)); + vi.spyOn(getLatestFuelsVersionMod, 'getLatestFuelsVersion').mockResolvedValue(latestVersion); vi.spyOn(versionsMod, 'versions', 'get').mockReturnValue({ FUELS: userVersion, @@ -31,38 +32,45 @@ describe('checkForAndDisplayUpdates', () => { return { log, warn }; }; - test('should fail gracefully if the fetch fails', async () => { - vi.spyOn(global, 'fetch').mockImplementation(() => - Promise.reject(new Error('Failed to fetch')) - ); - const log = vi.spyOn(loggerMod, 'log'); - await expect(checkForAndDisplayUpdatesMod.checkForAndDisplayUpdates()).resolves.not.toThrow(); - expect(log).toHaveBeenCalledWith('\n Unable to fetch latest fuels version. Skipping...\n'); + test('unable to fetch latest fuels version', async () => { + const { log } = mockDeps({ latestVersion: undefined, userVersion: '0.1.0' }); + + await checkForAndDisplayUpdatesMod.checkForAndDisplayUpdates(); + + expect(log).toHaveBeenCalledWith(`\n Unable to fetch latest fuels version. Skipping...\n`); }); - test('should log a warning if the version is outdated', async () => { - const { warn } = mockDeps({ latestVersion: '1.0.1', userVersion: '1.0.0' }); + test('user fuels version outdated', async () => { + const latestVersion = '1.0.1'; + const userVersion = '1.0.0'; + const { warn } = mockDeps({ latestVersion, userVersion }); + await checkForAndDisplayUpdatesMod.checkForAndDisplayUpdates(); + expect(warn).toHaveBeenCalledWith( - '\n⚠️ There is a newer version of fuels available: 1.0.1. Your version is: 1.0.0\n' + `\n⚠️ There is a newer version of fuels available: ${latestVersion}. Your version is: ${userVersion}\n` ); }); - test('should log a success message if the version is up to date', async () => { - const { log } = mockDeps({ latestVersion: '1.0.0', userVersion: '1.0.0' }); + test('user fuels version up to date', async () => { + const latestVersion = '1.0.0'; + const userVersion = '1.0.0'; + const { log } = mockDeps({ latestVersion, userVersion }); + await checkForAndDisplayUpdatesMod.checkForAndDisplayUpdates(); - expect(log).toHaveBeenCalledWith('\n✅ Your fuels version is up to date: 1.0.0\n'); + + expect(log).toHaveBeenCalledWith(`\n✅ Your fuels version is up to date: ${userVersion}\n`); }); - test('should handle fetch timing out', async () => { - vi.spyOn(global, 'fetch').mockImplementation( - () => - new Promise((resolve) => { - setTimeout(resolve, 5000); - }) + test('getLatestFuelsVersion throws', async () => { + vi.spyOn(getLatestFuelsVersionMod, 'getLatestFuelsVersion').mockRejectedValue( + new Error('Failed to fetch') ); + const log = vi.spyOn(loggerMod, 'log'); - await expect(checkForAndDisplayUpdatesMod.checkForAndDisplayUpdates()).resolves.not.toThrow(); - expect(log).toHaveBeenCalledWith('\n Unable to fetch latest fuels version. Skipping...\n'); + + await checkForAndDisplayUpdatesMod.checkForAndDisplayUpdates(); + + expect(log).toHaveBeenCalledWith(`\n Unable to fetch latest fuels version. Skipping...\n`); }); }); diff --git a/packages/fuels/src/cli/utils/checkForAndDisplayUpdates.ts b/packages/fuels/src/cli/utils/checkForAndDisplayUpdates.ts index 32294045b2e..5abb67681d0 100644 --- a/packages/fuels/src/cli/utils/checkForAndDisplayUpdates.ts +++ b/packages/fuels/src/cli/utils/checkForAndDisplayUpdates.ts @@ -1,23 +1,13 @@ import { versions, gt, eq } from '@fuel-ts/versions'; +import { getLatestFuelsVersion } from './getLatestFuelsVersion'; import { warn, log } from './logger'; -export const getLatestFuelsVersion = async () => { - const response = await fetch('https://registry.npmjs.org/fuels/latest'); - const data = await response.json(); - return data.version as string; -}; - export const checkForAndDisplayUpdates = async () => { try { const { FUELS: userFuelsVersion } = versions; - const latestFuelsVersion = await Promise.race([ - new Promise((resolve) => { - setTimeout(resolve, 3000); - }), - getLatestFuelsVersion(), - ]); + const latestFuelsVersion = await getLatestFuelsVersion(); if (!latestFuelsVersion) { log(`\n Unable to fetch latest fuels version. Skipping...\n`); diff --git a/packages/fuels/src/cli/utils/fuelsVersionCache.test.ts b/packages/fuels/src/cli/utils/fuelsVersionCache.test.ts new file mode 100644 index 00000000000..8b0b0ae1e31 --- /dev/null +++ b/packages/fuels/src/cli/utils/fuelsVersionCache.test.ts @@ -0,0 +1,82 @@ +import fs from 'fs'; + +import { + checkAndLoadCache, + FUELS_VERSION_CACHE_FILE, + FUELS_VERSION_CACHE_TTL, + saveToCache, +} from './fuelsVersionCache'; + +const mockWriteFile = () => vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); + +const mockFileAge = (createdAtMs: number) => + // @ts-expect-error type mismatch for mtimeMs + vi.spyOn(fs, 'statSync').mockReturnValue({ mtimeMs: createdAtMs }); + +const mockReadFile = (content: string) => vi.spyOn(fs, 'readFileSync').mockReturnValue(content); + +const mockFileExists = (exists: boolean) => vi.spyOn(fs, 'existsSync').mockReturnValue(exists); + +/** + * @group node + */ +describe('fuelsVersionCache', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('saveToCache', () => { + const version = '0.1.0'; + + const writeFileMock = mockWriteFile(); + + saveToCache(version); + + // Assert that writeFileSync was called + expect(writeFileMock).toHaveBeenCalledWith(FUELS_VERSION_CACHE_FILE, version, 'utf-8'); + }); + + test('checkAndLoadCache - when cache exists', () => { + mockFileExists(true); + const version = '0.1.0'; + + const readFileMock = mockReadFile(version); + + mockFileAge(Date.now() - 120000); // 2 minutes ago + + const result = checkAndLoadCache(); + + expect(readFileMock).toHaveBeenCalledWith(FUELS_VERSION_CACHE_FILE, 'utf-8'); + expect(result).toEqual(version); + }); + + test('checkAndLoadCache - when cache file does not exist', () => { + mockFileExists(false); + + const result = checkAndLoadCache(); + + expect(result).toBeNull(); + }); + + test('checkAndLoadCache - when cache file is empty', () => { + mockFileExists(true); + mockReadFile(''); + + const result = checkAndLoadCache(); + + expect(result).toBeNull(); + }); + + test('checkAndLoadCache - when cache is too old', () => { + mockFileExists(true); + const version = '0.1.0'; + const readFileMock = mockReadFile(version); + + mockFileAge(Date.now() - FUELS_VERSION_CACHE_TTL - 1); + + const result = checkAndLoadCache(); + + expect(readFileMock).toHaveBeenCalledWith(FUELS_VERSION_CACHE_FILE, 'utf-8'); + expect(result).toBeNull(); + }); +}); diff --git a/packages/fuels/src/cli/utils/fuelsVersionCache.ts b/packages/fuels/src/cli/utils/fuelsVersionCache.ts new file mode 100644 index 00000000000..cab29d62af5 --- /dev/null +++ b/packages/fuels/src/cli/utils/fuelsVersionCache.ts @@ -0,0 +1,31 @@ +import fs from 'fs'; +import path from 'path'; + +export const FUELS_VERSION_CACHE_FILE = path.join(__dirname, 'FUELS_VERSION'); + +export type FuelsVersionCache = string; + +export const saveToCache = (cache: FuelsVersionCache) => { + fs.writeFileSync(FUELS_VERSION_CACHE_FILE, cache, 'utf-8'); +}; + +export const FUELS_VERSION_CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours in milliseconds + +export const checkAndLoadCache = (): FuelsVersionCache | null => { + const doesVersionCacheExist = fs.existsSync(FUELS_VERSION_CACHE_FILE); + + if (doesVersionCacheExist) { + const cachedVersion = fs.readFileSync(FUELS_VERSION_CACHE_FILE, 'utf-8').trim(); + + if (!cachedVersion) { + return null; + } + + const { mtimeMs: cacheTimestamp } = fs.statSync(FUELS_VERSION_CACHE_FILE); + const hasCacheExpired = Date.now() - cacheTimestamp > FUELS_VERSION_CACHE_TTL; + + return hasCacheExpired ? null : cachedVersion; + } + + return null; +}; diff --git a/packages/fuels/src/cli/utils/getLatestFuelsVersion.test.ts b/packages/fuels/src/cli/utils/getLatestFuelsVersion.test.ts new file mode 100644 index 00000000000..4a7c9dd909b --- /dev/null +++ b/packages/fuels/src/cli/utils/getLatestFuelsVersion.test.ts @@ -0,0 +1,50 @@ +import * as cacheMod from './fuelsVersionCache'; +import { getLatestFuelsVersion } from './getLatestFuelsVersion'; + +/** + * @group node + */ +describe('getLatestFuelsVersion', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should fail if fetch fails', async () => { + vi.spyOn(global, 'fetch').mockImplementation(() => + Promise.reject(new Error('Failed to fetch')) + ); + await expect(getLatestFuelsVersion()).rejects.toThrowError('Failed to fetch'); + }); + + it('should throw if fetch times out', async () => { + vi.spyOn(global, 'fetch').mockImplementation( + () => + new Promise((resolve) => { + setTimeout(resolve, 5000); + }) + ); + await expect(getLatestFuelsVersion()).rejects.toThrow(); + }); + + it('should return cached version if it exists', async () => { + const cachedVersion = '1.0.0'; + vi.spyOn(cacheMod, 'checkAndLoadCache').mockReturnValue(cachedVersion); + const result = await getLatestFuelsVersion(); + expect(result).toEqual('1.0.0'); + }); + + it('should fetch if there is no cache or the cache is expired', async () => { + const mockResponse = new Response(JSON.stringify({ version: '1.0.0' })); + const fetchSpy = vi.spyOn(global, 'fetch').mockReturnValue(Promise.resolve(mockResponse)); + const saveCacheSpy = vi.spyOn(cacheMod, 'saveToCache').mockImplementation(() => {}); + vi.spyOn(cacheMod, 'checkAndLoadCache').mockReturnValue(null); + const version = await getLatestFuelsVersion(); + expect(fetchSpy).toHaveBeenCalled(); + expect(version).toEqual('1.0.0'); + expect(saveCacheSpy).toHaveBeenCalledWith('1.0.0'); + }); +}); diff --git a/packages/fuels/src/cli/utils/getLatestFuelsVersion.ts b/packages/fuels/src/cli/utils/getLatestFuelsVersion.ts new file mode 100644 index 00000000000..ba2837d1910 --- /dev/null +++ b/packages/fuels/src/cli/utils/getLatestFuelsVersion.ts @@ -0,0 +1,26 @@ +import { checkAndLoadCache, saveToCache } from './fuelsVersionCache'; + +export const getLatestFuelsVersion = async (): Promise => { + const cachedVersion = checkAndLoadCache(); + if (cachedVersion) { + return cachedVersion; + } + + const data: { version: string } | null = await Promise.race([ + new Promise((_, reject) => { + // eslint-disable-next-line prefer-promise-reject-errors + setTimeout(() => reject(null), 3000); + }), + fetch('https://registry.npmjs.org/fuels/latest').then((response) => response.json()), + ]); + + if (!data) { + throw new Error('Failed to fetch latest fuels version.'); + } + + const version = data.version as string; + + saveToCache(version); + + return version; +}; diff --git a/packages/fuels/src/run.ts b/packages/fuels/src/run.ts index 2fadc25ad0f..387080ebcd9 100644 --- a/packages/fuels/src/run.ts +++ b/packages/fuels/src/run.ts @@ -3,6 +3,7 @@ import { checkForAndDisplayUpdates } from './cli/utils/checkForAndDisplayUpdates import { error } from './cli/utils/logger'; export const run = async (argv: string[]) => { + await checkForAndDisplayUpdates().catch(error); const program = configureCli(); - return Promise.all([await checkForAndDisplayUpdates().catch(error), program.parseAsync(argv)]); + return program.parseAsync(argv); }; diff --git a/packages/fuels/test/features/build.test.ts b/packages/fuels/test/features/build.test.ts index 98af3bd6d38..07ba32c8f40 100644 --- a/packages/fuels/test/features/build.test.ts +++ b/packages/fuels/test/features/build.test.ts @@ -3,6 +3,7 @@ import { join } from 'path'; import * as deployMod from '../../src/cli/commands/deploy/index'; import { mockStartFuelCore } from '../utils/mockAutoStartFuelCore'; +import { mockCheckForUpdates } from '../utils/mockCheckForUpdates'; import { bootstrapProject, resetConfigAndMocks, @@ -17,6 +18,10 @@ import { describe('build', { timeout: 180000 }, () => { const paths = bootstrapProject(__filename); + beforeEach(() => { + mockCheckForUpdates(); + }); + afterEach(() => { resetConfigAndMocks(paths.fuelsConfigPath); }); diff --git a/packages/fuels/test/features/deploy.test.ts b/packages/fuels/test/features/deploy.test.ts index a9e57186a06..c40dec080a1 100644 --- a/packages/fuels/test/features/deploy.test.ts +++ b/packages/fuels/test/features/deploy.test.ts @@ -6,6 +6,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; import { launchTestNode } from '../../src/test-utils'; +import { mockCheckForUpdates } from '../utils/mockCheckForUpdates'; import { resetDiskAndMocks } from '../utils/resetDiskAndMocks'; import { bootstrapProject, @@ -41,6 +42,7 @@ describe('deploy', { timeout: 180000 }, () => { resetConfigAndMocks(paths.fuelsConfigPath); resetDiskAndMocks(paths.root); paths = bootstrapProject(__filename); + mockCheckForUpdates(); }); afterEach(() => { diff --git a/packages/fuels/test/features/dev.test.ts b/packages/fuels/test/features/dev.test.ts index 832e7917432..b8aa63231f6 100644 --- a/packages/fuels/test/features/dev.test.ts +++ b/packages/fuels/test/features/dev.test.ts @@ -3,6 +3,7 @@ import * as chokidar from 'chokidar'; import * as buildMod from '../../src/cli/commands/build/index'; import * as deployMod from '../../src/cli/commands/deploy/index'; import { mockStartFuelCore } from '../utils/mockAutoStartFuelCore'; +import { mockCheckForUpdates } from '../utils/mockCheckForUpdates'; import { mockLogger } from '../utils/mockLogger'; import { resetDiskAndMocks } from '../utils/resetDiskAndMocks'; import { runInit, runDev, bootstrapProject, resetConfigAndMocks } from '../utils/runCommands'; @@ -21,6 +22,10 @@ vi.mock('chokidar', async () => { describe('dev', () => { const paths = bootstrapProject(__filename); + beforeEach(() => { + mockCheckForUpdates(); + }); + afterEach(() => { resetConfigAndMocks(paths.fuelsConfigPath); }); diff --git a/packages/fuels/test/features/init.test.ts b/packages/fuels/test/features/init.test.ts index 05095c9a9f3..93b9fd72b7e 100644 --- a/packages/fuels/test/features/init.test.ts +++ b/packages/fuels/test/features/init.test.ts @@ -2,6 +2,7 @@ import chalk from 'chalk'; import { existsSync, readFileSync } from 'fs'; import { Commands } from '../../src'; +import { mockCheckForUpdates } from '../utils/mockCheckForUpdates'; import { mockLogger } from '../utils/mockLogger'; import { bootstrapProject, @@ -19,6 +20,7 @@ describe('init', () => { beforeEach(() => { mockLogger(); + mockCheckForUpdates(); }); afterEach(() => { diff --git a/packages/fuels/test/utils/mockCheckForUpdates.ts b/packages/fuels/test/utils/mockCheckForUpdates.ts new file mode 100644 index 00000000000..23f0afd0a98 --- /dev/null +++ b/packages/fuels/test/utils/mockCheckForUpdates.ts @@ -0,0 +1,7 @@ +import * as checkForUpdatesMod from '../../src/cli/utils/checkForAndDisplayUpdates'; + +export const mockCheckForUpdates = () => { + vi.spyOn(checkForUpdatesMod, 'checkForAndDisplayUpdates').mockImplementation(() => + Promise.resolve() + ); +};