diff --git a/.env.example b/.env.example index a8b6c69..07b5f69 100644 --- a/.env.example +++ b/.env.example @@ -13,12 +13,6 @@ DEFAULT_GITHUB_RETRY_DELAY=1000 # Maximum number of retry attempts for GitHub API calls (e.g., 3) DEFAULT_GITHUB_MAX_RETRY=3 -# URL for retrieving Devicon assets (ensure it ends with a slash) -DEVICON_URL='https://raw.githubusercontent.com/devicons/devicon/master/icons/' - -# URL to fetch GitHub Linguist language definitions -LINGUIST_GITHUB='https://raw.githubusercontent.com/github/linguist/main/lib/linguist/languages.yml' - # Base URL for language mappings file location LANGUAGE_MAPPINGS_URL='https://raw.githubusercontent.com/teociaps/github-bubble-chart/main/' diff --git a/.github/config/labels.yml b/.github/config/labels.yml index 6e87793..3164175 100644 --- a/.github/config/labels.yml +++ b/.github/config/labels.yml @@ -19,3 +19,8 @@ configuration: - .prettierrc* - .editorconfig - .stylelintrc* + +theme: + - changed-files: + - any-glob-to-any-file: + - src/chart/themes.ts diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ddcb6b9..53ca086 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,15 +7,10 @@ Before submitting your PR, confirm the following: - [ ] Commit messages follow the [contribution guidelines](https://github.com/teociaps/github-bubble-chart/blob/main/CONTRIBUTING.md). - [ ] Tests are added or updated to cover all changes. - [ ] Documentation is updated (e.g., README, inline comments, external docs). -- [ ] CI checks pass successfully. - ---- ## **Summary** -Provide a concise summary of this PR, including its purpose, the problem it solves, and the key changes made. - ---- +_Provide a concise summary of this PR, including its purpose, the problem it solves, and the key changes made._ ## **Change Type** @@ -28,22 +23,16 @@ Select the type(s) of change included in this PR: - [ ] Maintenance / Chores - [ ] Breaking change -If this PR introduces a breaking change, describe the impact and required migration steps: - ---- +If this PR introduces a **breaking change**, describe the impact and required migration steps: ## **Linked Issues** -List any related issues or feature requests (e.g., Fixes #123). - ---- - -## **Screenshots/Logs (Optional)** +_List any related issues or feature requests (e.g., Fixes #123)._ -Include relevant screenshots, logs, or other visuals, if applicable. +## **Screenshots/Logs** ---- +_Include relevant screenshots, logs, or other visuals, if applicable._ ## **Additional Notes** -Provide any additional information or feedback requests for reviewers. +_Provide any additional information or feedback requests for reviewers._ diff --git a/.prettierrc b/.prettierrc index d909c93..94848b1 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,5 +4,6 @@ "trailingComma": "all", "printWidth": 80, "tabWidth": 2, - "useTabs": false + "useTabs": false, + "endOfLine": "auto" } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 586d0a9..017573b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,8 +11,6 @@ We are thrilled that you are considering contributing to this project! Your cont 5. [Style Guide](#style-guide) 6. [Need Help?](#need-help) ---- - ## Getting Started To get started, follow these steps: diff --git a/README.md b/README.md index 7ea8f5e..1f9d8e7 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,10 @@ Insert a bubble chart into your GitHub profile readme. This makes for a great [G You can insert the bubble chart into your GitHub profile readme by using the following URL format: ``` -https://github-bubble-chart.vercel.app?username= +https://github-bubble-chart.vercel.app?username=YOUR_GITHUB_USERNAME ``` -For detailed usage instructions, please refer to our [Wiki](https://github.com/teociaps/github-bubble-chart/wiki). +For detailed usage instructions, please refer to the [Wiki](https://github.com/teociaps/github-bubble-chart/wiki). ### Examples @@ -28,12 +28,16 @@ For detailed usage instructions, please refer to our [Wiki](https://github.com/t ![teociaps](https://github-bubble-chart.vercel.app?username=teociaps&theme=dark_dimmed&title-size=34&title-color=red&legend-align=left) +``` +https://github-bubble-chart.vercel.app?username=teociaps&theme=dark_dimmed&title-size=34&title-color=red&legend-align=left +``` + #### Custom Configuration Example -![Custom](https://github-bubble-chart.vercel.app/?username=teociaps&mode=custom-config&config-path=ghbc-my-tech-and-tools.json) +![Custom](https://github-bubble-chart.vercel.app?username=teociaps&mode=custom-config&config-path=ghbc-my-tech-and-tools.json) ``` -https://github-bubble-chart.vercel.app?username=teociap&mode=custom-config&config-path=ghbc-my-tech-and-tools.json +https://github-bubble-chart.vercel.app?username=teociaps&mode=custom-config&config-path=ghbc-my-tech-and-tools.json ``` ## Breaking Changes & Releases diff --git a/api/utils.ts b/api/utils.ts index e9edf33..1785efa 100644 --- a/api/utils.ts +++ b/api/utils.ts @@ -14,9 +14,10 @@ import { DisplayMode, } from '../src/chart/types/chartOptions.js'; import { CustomConfig, Mode } from '../src/chart/types/config.js'; +import { isDevEnvironment } from '../src/common/environment.js'; import { - isDevEnvironment, mapConfigToBubbleChartOptions, + convertImageToBase64, } from '../src/common/utils.js'; import { BaseError } from '../src/errors/base-error.js'; import { @@ -162,15 +163,40 @@ export async function fetchConfigFromRepo( filePath: string, branch?: string, ): Promise<{ options: BubbleChartOptions; data: BubbleData[] }> { - const processConfig = ( + const processConfig = async ( customConfig: CustomConfig, - ): { options: BubbleChartOptions; data: BubbleData[] } => { - const options = mapConfigToBubbleChartOptions(customConfig.options); + ): Promise<{ options: BubbleChartOptions; data: BubbleData[] }> => { + // First filter invalid data items customConfig.data = customConfig.data.filter( (dataItem) => typeof dataItem.value === 'number' && !isNaN(dataItem.value), ); - return { options: options, data: customConfig.data }; + + // Process icons in data items if they exist + for (const dataItem of customConfig.data) { + // Check if item has an icon property that's a URL string and not already base64 + if ( + dataItem.icon && + typeof dataItem.icon === 'string' && + !dataItem.icon.startsWith('data:') + ) { + try { + const base64Icon = await convertImageToBase64(dataItem.icon); + if (base64Icon) { + dataItem.icon = base64Icon; + } + } catch (error) { + logger.warn( + `Failed to convert icon to base64: ${dataItem.icon}`, + error, + ); + // Continue with original URL if conversion fails + } + } + } + + const options = mapConfigToBubbleChartOptions(customConfig.options); + return { options, data: customConfig.data }; }; if (isDevEnvironment()) { @@ -182,7 +208,7 @@ export async function fetchConfigFromRepo( const customConfig = JSON.parse( fs.readFileSync(localPath, 'utf-8'), ) as CustomConfig; - return processConfig(customConfig); + return await processConfig(customConfig); } catch (error) { throw new ValidationError( 'Failed to parse local JSON configuration.', @@ -219,7 +245,7 @@ export async function fetchConfigFromRepo( try { const customConfig = (await response.json()) as CustomConfig; - return processConfig(customConfig); + return await processConfig(customConfig); } catch (error) { throw new ValidationError( 'Failed to parse fetched JSON configuration.', @@ -240,3 +266,4 @@ export function handleErrorResponse( res.status(500).send({ error: 'An unexpected error occurred' }); } } +export { convertImageToBase64 }; diff --git a/codecov.yml b/codecov.yml index 4632b00..288460f 100644 --- a/codecov.yml +++ b/codecov.yml @@ -20,3 +20,5 @@ comment: require_changes: true layout: 'condensed_header, condensed_files, condensed_footer' hide_project_coverage: true + require_bundle_changes: 'bundle_increase' + bundle_change_threshold: '1Mb' diff --git a/config/consts.ts b/config/consts.ts index 6509d3d..a11bb85 100644 --- a/config/consts.ts +++ b/config/consts.ts @@ -14,8 +14,10 @@ const CONSTANTS = { DEFAULT_GITHUB_MAX_RETRY: parseInt(process.env.DEFAULT_GITHUB_MAX_RETRY!, 10), REVALIDATE_TIME: HOUR_IN_MILLISECONDS, LANGS_OUTPUT_FILE: 'src/languageMappings.json', - DEVICON_URL: process.env.DEVICON_URL!, - LINGUIST_GITHUB: process.env.LINGUIST_GITHUB!, + DEVICON_URL: + 'https://raw.githubusercontent.com/devicons/devicon/master/icons/', + LINGUIST_GITHUB: + 'https://raw.githubusercontent.com/github/linguist/main/lib/linguist/languages.yml', LANGUAGE_MAPPINGS_URL: process.env.LANGUAGE_MAPPINGS_URL!, DEFAULT_THEME: new DefaultTheme(), }; diff --git a/scripts/fetchLanguageMappings.ts b/scripts/fetchLanguageMappings.ts index 7b13438..850e284 100644 --- a/scripts/fetchLanguageMappings.ts +++ b/scripts/fetchLanguageMappings.ts @@ -1,8 +1,8 @@ /* eslint-disable no-console */ import fs from 'fs'; -import imageToBase64 from 'image-to-base64'; import { parse as yamlParse } from 'yaml'; import { CONSTANTS } from '../config/consts'; +import { convertImageToBase64 } from '../src/common/utils'; // Known language name discrepancies map (GitHub vs Devicon) const languageDiscrepancies: Record = { @@ -45,15 +45,6 @@ async function fetchLanguageColors(): Promise< } } -async function convertImageToBase64(url: string): Promise { - try { - const base64 = await imageToBase64(url); - return `data:image/svg+xml;base64,${base64}`; - } catch (error) { - console.error('Error converting image:', error); - } -} - const svgVersions = [ '-original', '-plain', diff --git a/src/common/environment.ts b/src/common/environment.ts new file mode 100644 index 0000000..1363516 --- /dev/null +++ b/src/common/environment.ts @@ -0,0 +1,15 @@ +/** + * Checks if the current environment is development + * @returns true if NODE_ENV is 'dev' + */ +export const isDevEnvironment = (): boolean => { + return process.env.NODE_ENV === 'dev'; +}; + +/** + * Checks if the current environment is production + * @returns true if NODE_ENV is 'prod' + */ +export const isProdEnvironment = (): boolean => { + return process.env.NODE_ENV === 'prod'; +}; diff --git a/src/common/utils.ts b/src/common/utils.ts index 292fc76..78beebf 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1,14 +1,8 @@ +import imageToBase64 from 'image-to-base64'; import { themeMap } from '../chart/themes.js'; import { BubbleChartOptions } from '../chart/types/chartOptions.js'; import { CustomConfigOptions } from '../chart/types/config.js'; - -export const isDevEnvironment = (): boolean => { - return process.env.NODE_ENV === 'dev'; -}; - -export const isProdEnvironment = (): boolean => { - return process.env.NODE_ENV === 'prod'; -}; +import logger from '../logger.js'; export function mapConfigToBubbleChartOptions( config: CustomConfigOptions, @@ -52,3 +46,66 @@ export function getPxValue(value: string): number { } return 0; } + +export async function convertImageToBase64( + url: string, + options?: { timeout?: number }, +): Promise { + if (!url || typeof url !== 'string') { + logger.error('Invalid URL provided to convertImageToBase64'); + return undefined; + } + + try { + new URL(url); + } catch { + logger.error('Invalid URL format', { url }); + return undefined; + } + + try { + const timeoutMs = options?.timeout || 10000; // 10 second timeout + + const base64 = await Promise.race([ + imageToBase64(url), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Image conversion timed out')), + timeoutMs, + ), + ), + ]); + + // MIME type detection + const mimeTypeMap: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + bmp: 'image/bmp', + svg: 'image/svg+xml', + ico: 'image/x-icon', + tiff: 'image/tiff', + tif: 'image/tiff', + avif: 'image/avif', + }; + + // Extract extension from URL, handling query params and fragments + const urlPath = new URL(url).pathname; + const extension = urlPath.split('.').pop()?.toLowerCase().split(/[?#]/)[0]; + + // Default to a generic image type if we can't determine the specific type + const mimeType = + extension && mimeTypeMap[extension] + ? mimeTypeMap[extension] + : 'image/png'; + + return `data:${mimeType};base64,${base64}`; + } catch (_error) { + const errorMessage = + _error instanceof Error ? _error.message : 'Unknown error'; + logger.error(`Error converting image to base64: ${errorMessage}`, _error); + return undefined; + } +} diff --git a/src/logger.ts b/src/logger.ts index 5d03eb5..8c87ddd 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,5 +1,5 @@ import { pino } from 'pino'; -import { isDevEnvironment } from './common/utils.js'; +import { isDevEnvironment } from './common/environment.js'; const isDev = isDevEnvironment(); diff --git a/tests/api/utils.test.ts b/tests/api/utils.test.ts index 95d018f..5472124 100644 --- a/tests/api/utils.test.ts +++ b/tests/api/utils.test.ts @@ -6,56 +6,107 @@ import { CustomURLSearchParams, parseParams, fetchConfigFromRepo, + convertImageToBase64, } from '../../api/utils'; import { LightTheme } from '../../src/chart/themes'; import { CustomConfig } from '../../src/chart/types/config'; -import { - isDevEnvironment, - mapConfigToBubbleChartOptions, -} from '../../src/common/utils'; +import { isDevEnvironment } from '../../src/common/environment'; +import { mapConfigToBubbleChartOptions } from '../../src/common/utils'; import { FetchError, ValidationError } from '../../src/errors/custom-errors'; import { GitHubNotFoundError, GitHubRateLimitError, } from '../../src/errors/github-errors'; +import logger from '../../src/logger'; describe('API Utils', () => { describe('CustomURLSearchParams', () => { - it('should return default string value if key is not present', () => { + const testCases = [ + { + name: 'string value', + param: 'key=value', + method: 'getStringValue', + key: 'key', + defaultVal: 'default', + expected: 'value', + }, + { + name: 'number value', + param: 'key=42', + method: 'getNumberValue', + key: 'key', + defaultVal: 0, + expected: 42, + }, + { + name: 'boolean value', + param: 'key=true', + method: 'getBooleanValue', + key: 'key', + defaultVal: false, + expected: true, + }, + { + name: 'title', + param: 'title=MyChart', + method: 'getStringValue', + key: 'title', + defaultVal: 'Bubble Chart', + expected: 'MyChart', + }, + { + name: 'legend alignment', + param: 'legend-align=right', + method: 'getStringValue', + key: 'legend-align', + defaultVal: 'center', + expected: 'right', + }, + { + name: 'title size', + param: 'title-size=30', + method: 'getNumberValue', + key: 'title-size', + defaultVal: 24, + expected: 30, + }, + { + name: 'title weight', + param: 'title-weight=normal', + method: 'getStringValue', + key: 'title-weight', + defaultVal: 'bold', + expected: 'normal', + }, + { + name: 'title color', + param: 'title-color=#ffffff', + method: 'getStringValue', + key: 'title-color', + defaultVal: '#000000', + expected: '#ffffff', + }, + ]; + + it('should return default values when keys are not present', () => { const params = new CustomURLSearchParams(''); expect(params.getStringValue('key', 'default')).toBe('default'); - }); - - it('should return string value if key is present', () => { - const params = new CustomURLSearchParams('key=value'); - expect(params.getStringValue('key', 'default')).toBe('value'); - }); - - it('should return default number value if key is not present', () => { - const params = new CustomURLSearchParams(''); expect(params.getNumberValue('key', 0)).toBe(0); - }); - - it('should return parsed number value if key is present', () => { - const params = new CustomURLSearchParams('key=42'); - expect(params.getNumberValue('key', 0)).toBe(42); - }); - - it('should return default boolean value if key is not present', () => { - const params = new CustomURLSearchParams(''); expect(params.getBooleanValue('key', true)).toBe(true); - }); - - it('should return parsed boolean value if key is present', () => { - const params = new CustomURLSearchParams('key=true'); - expect(params.getBooleanValue('key', false)).toBe(true); - }); - - it('should return default theme if key is not present', () => { - const params = new CustomURLSearchParams(''); expect(params.getTheme('theme', new LightTheme())).toBeInstanceOf( LightTheme, ); + expect(params.getTextAnchorValue('key', 'middle')).toBe('middle'); + expect(params.getLanguagesCount(5)).toBe(5); + expect(params.getMode()).toBe('top-langs'); + expect(params.getValuesDisplayOption('display-values')).toBe('legend'); + }); + + testCases.forEach(({ name, param, method, key, defaultVal, expected }) => { + it(`should return ${name} if key is present`, () => { + const params = new CustomURLSearchParams(param); + expect(params[method](key, defaultVal)).toBe(expected); + }); }); it('should return parsed theme if key is present', () => { @@ -75,24 +126,24 @@ describe('API Utils', () => { expect(params.getTextAnchorValue('key', 'middle')).toBe('middle'); }); - it('should return default languages count if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getLanguagesCount(5)).toBe(5); - }); + it('should handle languages count correctly', () => { + // Default + expect(new CustomURLSearchParams('').getLanguagesCount(5)).toBe(5); - it('should return parsed languages count if key is present', () => { - const params = new CustomURLSearchParams('langs-count=10'); - expect(params.getLanguagesCount(5)).toBe(10); - }); + // Valid value + expect( + new CustomURLSearchParams('langs-count=10').getLanguagesCount(5), + ).toBe(10); - it('should return minimum languages count if parsed value is less than 1', () => { - const params = new CustomURLSearchParams('langs-count=0'); - expect(params.getLanguagesCount(5)).toBe(1); - }); + // Too small (should return minimum) + expect( + new CustomURLSearchParams('langs-count=0').getLanguagesCount(5), + ).toBe(1); - it('should return maximum languages count if parsed value is greater than 20', () => { - const params = new CustomURLSearchParams('langs-count=21'); - expect(params.getLanguagesCount(5)).toBe(20); + // Too large (should return maximum) + expect( + new CustomURLSearchParams('langs-count=21').getLanguagesCount(5), + ).toBe(20); }); it('should parse title options correctly', () => { @@ -120,96 +171,41 @@ describe('API Utils', () => { }); }); - it('should return default mode if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getMode()).toBe('top-langs'); - }); + // Mode tests - consolidated + it('should handle mode correctly', () => { + // Default + expect(new CustomURLSearchParams('').getMode()).toBe('top-langs'); - it('should return parsed mode if key is present', () => { - const params = new CustomURLSearchParams('mode=custom-config'); - expect(params.getMode()).toBe('custom-config'); - }); - - it('should return default mode if parsed mode is invalid', () => { - const params = new CustomURLSearchParams('mode=invalid-mode'); - expect(params.getMode()).toBe('top-langs'); - }); - - it('should return default values display option if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getValuesDisplayOption('display-values')).toBe('legend'); - }); - - it('should return parsed values display option if key is present', () => { - const params = new CustomURLSearchParams('display-values=all'); - expect(params.getValuesDisplayOption('display-values')).toBe('all'); - }); - - it('should return default values display option if parsed value is invalid', () => { - const params = new CustomURLSearchParams('display-values=invalid'); - expect(params.getValuesDisplayOption('display-values')).toBe('legend'); - }); - - it('should return default title if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getStringValue('title', 'Bubble Chart')).toBe( - 'Bubble Chart', + // Valid mode + expect(new CustomURLSearchParams('mode=custom-config').getMode()).toBe( + 'custom-config', ); - }); - - it('should return parsed title if key is present', () => { - const params = new CustomURLSearchParams('title=MyChart'); - expect(params.getStringValue('title', 'Bubble Chart')).toBe('MyChart'); - }); - - it('should return default legend alignment if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getStringValue('legend-align', 'center')).toBe('center'); - }); - - it('should return parsed legend alignment if key is present', () => { - const params = new CustomURLSearchParams('legend-align=right'); - expect(params.getStringValue('legend-align', 'center')).toBe('right'); - }); - - it('should return default title size if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getNumberValue('title-size', 24)).toBe(24); - }); - - it('should return parsed title size if key is present', () => { - const params = new CustomURLSearchParams('title-size=30'); - expect(params.getNumberValue('title-size', 24)).toBe(30); - }); - - it('should return default title weight if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getStringValue('title-weight', 'bold')).toBe('bold'); - }); - - it('should return parsed title weight if key is present', () => { - const params = new CustomURLSearchParams('title-weight=normal'); - expect(params.getStringValue('title-weight', 'bold')).toBe('normal'); - }); - it('should return default title color if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getStringValue('title-color', '#000000')).toBe('#000000'); + // Invalid mode (should return default) + expect(new CustomURLSearchParams('mode=invalid-mode').getMode()).toBe( + 'top-langs', + ); }); - it('should return parsed title color if key is present', () => { - const params = new CustomURLSearchParams('title-color=#ffffff'); - expect(params.getStringValue('title-color', '#000000')).toBe('#ffffff'); - }); + it('should handle values display option correctly', () => { + // Default + expect( + new CustomURLSearchParams('').getValuesDisplayOption('display-values'), + ).toBe('legend'); - it('should return default title alignment if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getTextAnchorValue('title-align', 'middle')).toBe('middle'); - }); + // Valid option + expect( + new CustomURLSearchParams('display-values=all').getValuesDisplayOption( + 'display-values', + ), + ).toBe('all'); - it('should return parsed title alignment if key is present', () => { - const params = new CustomURLSearchParams('title-align=center'); - expect(params.getTextAnchorValue('title-align', 'middle')).toBe('middle'); + // Invalid option (should return default) + expect( + new CustomURLSearchParams( + 'display-values=invalid', + ).getValuesDisplayOption('display-values'), + ).toBe('legend'); }); }); @@ -230,13 +226,29 @@ describe('API Utils', () => { vi.mock('fs'); vi.mock('path'); vi.mock('../../src/common/utils', () => ({ - isDevEnvironment: vi.fn(), mapConfigToBubbleChartOptions: vi.fn().mockReturnValue({ titleOptions: { text: 'Test Chart' }, - } as unknown as CustomConfig), + }), + convertImageToBase64: vi.fn().mockImplementation(async (url) => { + if (url === 'https://example.com/icon.png') { + return 'data:image/png;base64,converted'; + } + return undefined; + }), + })); + vi.mock('../../src/common/environment', () => ({ + isDevEnvironment: vi.fn(), })); vi.stubGlobal('fetch', vi.fn()); + // logger mock to include the warn method + vi.mock('../../src/logger', () => ({ + default: { + error: vi.fn(), + warn: vi.fn(), + }, + })); + const mockConfig: CustomConfig = { options: { titleOptions: { text: 'Test Chart' }, @@ -354,5 +366,167 @@ describe('API Utils', () => { ValidationError, ); }); + + it('handles icon URL conversion cases correctly', async () => { + vi.mocked(isDevEnvironment).mockReturnValue(true); + const localPath = '/example-config.json'; + vi.mocked(path.resolve).mockReturnValue(localPath); + vi.mocked(fs.existsSync).mockReturnValue(true); + + // Case 1: Normal conversion + const configWithIconData = { + options: { titleOptions: { text: 'Test Chart' } }, + data: [ + { + name: 'Node.js', + value: 50, + color: '#68A063', + icon: 'https://example.com/icon.png', + }, + { name: 'Python', value: 30, color: '#3776AB' }, // No icon + ], + }; + + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(configWithIconData), + ); + let result = await fetchConfigFromRepo('username', 'filePath'); + expect(convertImageToBase64).toHaveBeenCalledWith( + 'https://example.com/icon.png', + ); + expect(result.data[0].icon).toBe('data:image/png;base64,converted'); + expect(result.data[1].icon).toBeUndefined(); + + vi.clearAllMocks(); + + // Case 2: Already base64 icon + const configWithBase64IconData = { + options: { titleOptions: { text: 'Test Chart' } }, + data: [ + { + name: 'Node.js', + value: 50, + color: '#68A063', + icon: 'data:image/png;base64,alreadyBase64', + }, + ], + }; + + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(configWithBase64IconData), + ); + result = await fetchConfigFromRepo('username', 'filePath'); + expect(convertImageToBase64).not.toHaveBeenCalled(); + expect(result.data[0].icon).toBe('data:image/png;base64,alreadyBase64'); + + vi.clearAllMocks(); + + // Case 3: Failed conversion + const configWithBadIconData = { + options: { titleOptions: { text: 'Test Chart' } }, + data: [ + { + name: 'Broken', + value: 50, + color: '#FF0000', + icon: 'https://example.com/bad-icon.png', + }, + ], + }; + + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(configWithBadIconData), + ); + result = await fetchConfigFromRepo('username', 'filePath'); + expect(convertImageToBase64).toHaveBeenCalledWith( + 'https://example.com/bad-icon.png', + ); + expect(result.data[0].icon).toBe('https://example.com/bad-icon.png'); + }); + + it('filters out data items with invalid values', async () => { + vi.mocked(isDevEnvironment).mockReturnValue(true); + const localPath = '/example-config.json'; + vi.mocked(path.resolve).mockReturnValue(localPath); + vi.mocked(fs.existsSync).mockReturnValue(true); + + const configWithInvalidData = { + options: { + titleOptions: { text: 'Test Chart' }, + }, + data: [ + { name: 'Valid Number', value: 50, color: '#68A063' }, // Valid + { name: 'String Value', value: '50', color: '#3776AB' }, // Invalid - string + { name: 'NaN Value', value: NaN, color: '#FF0000' }, // Invalid - NaN + { name: 'Undefined Value', value: undefined, color: '#00FF00' }, // Invalid - undefined + { name: 'Null Value', value: null, color: '#0000FF' }, // Invalid - null + { name: 'Object Value', value: {}, color: '#FFFF00' }, // Invalid - object + { name: 'Zero', value: 0, color: '#FF00FF' }, // Valid - zero is a valid number + ], + }; + + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(configWithInvalidData), + ); + + const result = await fetchConfigFromRepo('username', 'filePath'); + + // Only the valid numeric values should remain + expect(result.data.length).toBe(2); + expect(result.data[0].name).toBe('Valid Number'); + expect(result.data[1].name).toBe('Zero'); + + // Verify the invalid items were filtered out + const dataNames = result.data.map((item) => item.name); + expect(dataNames).not.toContain('String Value'); + expect(dataNames).not.toContain('NaN Value'); + expect(dataNames).not.toContain('Undefined Value'); + expect(dataNames).not.toContain('Null Value'); + expect(dataNames).not.toContain('Object Value'); + }); + + it('catches and logs exceptions during icon conversion', async () => { + vi.mocked(isDevEnvironment).mockReturnValue(true); + const localPath = '/example-config.json'; + vi.mocked(path.resolve).mockReturnValue(localPath); + vi.mocked(fs.existsSync).mockReturnValue(true); + + const configWithIconData = { + options: { + titleOptions: { text: 'Test Chart' }, + }, + data: [ + { + name: 'Node.js', + value: 50, + color: '#68A063', + icon: 'https://example.com/error-icon.png', + }, + ], + }; + + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(configWithIconData), + ); + + // Mock convertImageToBase64 to throw an exception + vi.mocked(convertImageToBase64).mockImplementation(async (url) => { + if (url === 'https://example.com/error-icon.png') { + throw new Error('Conversion error'); + } + return 'data:image/png;base64,converted'; + }); + + const result = await fetchConfigFromRepo('username', 'filePath'); + + // Verify the function caught the exception + expect(logger.warn).toHaveBeenCalledWith( + 'Failed to convert icon to base64: https://example.com/error-icon.png', + expect.any(Error), + ); + + // Verify the original URL is preserved + expect(result.data[0].icon).toBe('https://example.com/error-icon.png'); + }); }); }); diff --git a/tests/common/utils.test.ts b/tests/common/utils.test.ts index aed24be..cbbedbe 100644 --- a/tests/common/utils.test.ts +++ b/tests/common/utils.test.ts @@ -1,15 +1,36 @@ -import { describe, it, expect } from 'vitest'; +import imageToBase64 from 'image-to-base64'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { themeMap } from '../../src/chart/themes'; import { BubbleChartOptions } from '../../src/chart/types/chartOptions'; import { CustomConfigOptions } from '../../src/chart/types/config'; import { - getPxValue, isDevEnvironment, isProdEnvironment, +} from '../../src/common/environment'; +import { + getPxValue, mapConfigToBubbleChartOptions, truncateText, + convertImageToBase64, } from '../../src/common/utils'; +// Mock the imageToBase64 dependency +vi.mock('image-to-base64', () => { + return { + default: vi.fn(), + }; +}); + +// Mock logger to avoid actual logging during tests +vi.mock('../../src/logger', () => ({ + default: { + error: vi.fn(), + }, +})); + +// Import the mocked modules for direct access in tests +import logger from '../../src/logger'; + describe('Utils Tests', () => { it('isDevEnvironment should return true if NODE_ENV is dev', () => { process.env.NODE_ENV = 'dev'; @@ -185,4 +206,123 @@ describe('Utils Tests', () => { expect(getPxValue('3px solid red')).toBe(3); }); }); + + describe('convertImageToBase64', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(imageToBase64).mockResolvedValue('mockBase64Data'); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should convert PNG image URL to base64 data URL', async () => { + const pngUrl = + 'https://raw.githubusercontent.com/github/explore/80688e429a7d4ef2fca1e82350fe8e3517d3494d/topics/javascript/javascript.png'; + const result = await convertImageToBase64(pngUrl); + expect(imageToBase64).toHaveBeenCalledWith(pngUrl); + expect(result).toBe('data:image/png;base64,mockBase64Data'); + }); + + it('should convert JPG image URL to base64 data URL', async () => { + const jpgUrl = + 'https://www.nasa.gov/wp-content/uploads/2023/03/pia25447-10731.jpg'; + const result = await convertImageToBase64(jpgUrl); + expect(imageToBase64).toHaveBeenCalledWith(jpgUrl); + expect(result).toBe('data:image/jpeg;base64,mockBase64Data'); + }); + + it('should convert SVG image URL to base64 data URL', async () => { + const svgUrl = + 'https://dev.w3.org/SVG/tools/svgweb/samples/svg-files/android.svg'; + const result = await convertImageToBase64(svgUrl); + expect(imageToBase64).toHaveBeenCalledWith(svgUrl); + expect(result).toBe('data:image/svg+xml;base64,mockBase64Data'); + }); + + it('should convert GIF image URL to base64 data URL', async () => { + const gifUrl = + 'https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExbDUzOGk0M3lvejY5OHgwaHgwZTlrYTc3Z3lsOW12ejl1MTkxMmwwayZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/3o7TKSjRrfIPjeiVyM/giphy.gif'; + const result = await convertImageToBase64(gifUrl); + expect(imageToBase64).toHaveBeenCalledWith(gifUrl); + expect(result).toBe('data:image/gif;base64,mockBase64Data'); + }); + + it('should convert WebP image URL to base64 data URL', async () => { + const webpUrl = 'https://www.gstatic.com/webp/gallery/1.webp'; + const result = await convertImageToBase64(webpUrl); + expect(imageToBase64).toHaveBeenCalledWith(webpUrl); + expect(result).toBe('data:image/webp;base64,mockBase64Data'); + }); + + it('should handle URLs with query parameters correctly', async () => { + const imageWithParams = + 'https://avatars.githubusercontent.com/u/9919?s=200&v=4'; + const result = await convertImageToBase64(imageWithParams); + expect(imageToBase64).toHaveBeenCalledWith(imageWithParams); + expect(result).toBe('data:image/png;base64,mockBase64Data'); + }); + + it('should return undefined for invalid URLs', async () => { + const result = await convertImageToBase64('invalid-url'); + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Invalid URL format'), + expect.anything(), + ); + }); + + it('should return undefined for null or empty URLs', async () => { + // @ts-ignore - Testing invalid input + const result1 = await convertImageToBase64(null); + const result2 = await convertImageToBase64(''); + + expect(result1).toBeUndefined(); + expect(result2).toBeUndefined(); + expect(logger.error).toHaveBeenCalledTimes(2); + }); + + it('should handle conversion errors gracefully', async () => { + vi.mocked(imageToBase64).mockRejectedValue( + new Error('Conversion failed'), + ); + + const result = await convertImageToBase64( + 'https://raw.githubusercontent.com/github/explore/80688e429a7d4ef2fca1e82350fe8e3517d3494d/topics/nodejs/nodejs.png', + ); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Error converting image to base64'), + expect.anything(), + ); + }); + + it('should handle timeouts properly', async () => { + // Mock a delayed response that would trigger the timeout + vi.mocked(imageToBase64).mockImplementation(() => { + return new Promise((resolve) => { + setTimeout(() => resolve('delayed response'), 50); + }); + }); + + const result = await convertImageToBase64( + 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y', + { timeout: 10 }, + ); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Image conversion timed out'), + expect.anything(), + ); + }); + + it('should use default MIME type for unknown extensions', async () => { + const noExtensionUrl = 'https://github.githubassets.com/favicons/favicon'; + const result = await convertImageToBase64(noExtensionUrl); + expect(result).toBe('data:image/png;base64,mockBase64Data'); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 91851c5..ec916d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -500,6 +500,14 @@ "@octokit/types" "^13.6.2" universal-user-agent "^7.0.2" +"@octokit/endpoint@^10.1.3": + version "10.1.3" + resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-10.1.3.tgz#bfe8ff2ec213eb4216065e77654bfbba0fc6d4de" + integrity sha512-nBRBMpKPhQUxCsQQeW+rCJ/OPSMcj3g0nfHn01zGYZXuNDvvXudF/TYY6APj5THlurerpFN4a/dQAIAaM6BYhA== + dependencies: + "@octokit/types" "^13.6.2" + universal-user-agent "^7.0.2" + "@octokit/graphql@^8.1.2": version "8.1.2" resolved "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.1.2.tgz"