Skip to content

Commit

Permalink
feat: cache latest fuels version (#3350)
Browse files Browse the repository at this point in the history
Co-authored-by: Peter Smith <[email protected]>
Co-authored-by: Chad Nehemiah <[email protected]>
  • Loading branch information
3 people authored Oct 30, 2024
1 parent e55aaca commit f1500e4
Show file tree
Hide file tree
Showing 16 changed files with 260 additions and 36 deletions.
5 changes: 5 additions & 0 deletions .changeset/kind-chairs-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"fuels": patch
---

feat: cache latest `fuels` version
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,5 @@ Forc.lock
/playwright-report/
/blob-report/
/playwright/.cache/

FUELS_VERSION
3 changes: 3 additions & 0 deletions packages/fuels/src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -77,6 +79,7 @@ describe('cli.js', () => {
.mockReturnValue(Promise.resolve(command));

const configureCli = vi.spyOn(cliMod, 'configureCli').mockImplementation(() => new Command());
mockCheckForUpdates();

await run([]);

Expand Down
5 changes: 5 additions & 0 deletions packages/fuels/src/cli/config/loadConfig.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFileSync } from 'fs';
import { resolve } from 'path';

import { mockCheckForUpdates } from '../../../test/utils/mockCheckForUpdates';
import {
runInit,
bootstrapProject,
Expand All @@ -19,6 +20,10 @@ import { loadConfig } from './loadConfig';
describe('loadConfig', () => {
const paths = bootstrapProject(__filename);

beforeEach(() => {
mockCheckForUpdates();
});

afterEach(() => {
resetConfigAndMocks(paths.fuelsConfigPath);
});
Expand Down
54 changes: 31 additions & 23 deletions packages/fuels/src/cli/utils/checkForAndDisplayUpdates.test.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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,
Expand All @@ -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`);
});
});
14 changes: 2 additions & 12 deletions packages/fuels/src/cli/utils/checkForAndDisplayUpdates.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined>([
new Promise((resolve) => {
setTimeout(resolve, 3000);
}),
getLatestFuelsVersion(),
]);
const latestFuelsVersion = await getLatestFuelsVersion();

if (!latestFuelsVersion) {
log(`\n Unable to fetch latest fuels version. Skipping...\n`);
Expand Down
82 changes: 82 additions & 0 deletions packages/fuels/src/cli/utils/fuelsVersionCache.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
31 changes: 31 additions & 0 deletions packages/fuels/src/cli/utils/fuelsVersionCache.ts
Original file line number Diff line number Diff line change
@@ -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;
};
50 changes: 50 additions & 0 deletions packages/fuels/src/cli/utils/getLatestFuelsVersion.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
26 changes: 26 additions & 0 deletions packages/fuels/src/cli/utils/getLatestFuelsVersion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { checkAndLoadCache, saveToCache } from './fuelsVersionCache';

export const getLatestFuelsVersion = async (): Promise<string | undefined> => {
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;
};
3 changes: 2 additions & 1 deletion packages/fuels/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
5 changes: 5 additions & 0 deletions packages/fuels/test/features/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,6 +18,10 @@ import {
describe('build', { timeout: 180000 }, () => {
const paths = bootstrapProject(__filename);

beforeEach(() => {
mockCheckForUpdates();
});

afterEach(() => {
resetConfigAndMocks(paths.fuelsConfigPath);
});
Expand Down
2 changes: 2 additions & 0 deletions packages/fuels/test/features/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -41,6 +42,7 @@ describe('deploy', { timeout: 180000 }, () => {
resetConfigAndMocks(paths.fuelsConfigPath);
resetDiskAndMocks(paths.root);
paths = bootstrapProject(__filename);
mockCheckForUpdates();
});

afterEach(() => {
Expand Down
Loading

0 comments on commit f1500e4

Please sign in to comment.