diff --git a/.github/workflows/deploy-client.yml b/.github/workflows/deploy-client.yml index cefea69c75e..a9324dd199f 100644 --- a/.github/workflows/deploy-client.yml +++ b/.github/workflows/deploy-client.yml @@ -198,6 +198,13 @@ jobs: echo "CLIENT_LOCALE=${{ matrix.lang-name-full }}" >> $GITHUB_ENV echo "CURRICULUM_LOCALE=${{ matrix.lang-name-full }}" >> $GITHUB_ENV + - name: Create deployment version + id: deployment-version + run: | + DEPLOYMENT_VERSION=$(git rev-parse --short HEAD)-$(date +%Y%m%d)-$(date +%H%M) + echo "DEPLOYMENT_VERSION=$DEPLOYMENT_VERSION" >> $GITHUB_ENV + echo "DEPLOYMENT_VERSION=$DEPLOYMENT_VERSION" >> $GITHUB_OUTPUT + - name: Install and Build env: API_LOCATION: 'https://api.freecodecamp.${{ needs.setup-jobs.outputs.site_tld }}' diff --git a/client/src/pages/status/version.test.tsx b/client/src/pages/status/version.test.tsx new file mode 100644 index 00000000000..68c08b843c8 --- /dev/null +++ b/client/src/pages/status/version.test.tsx @@ -0,0 +1,109 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import VersionEndpoint from './version'; + +interface VersionData { + client: { + version: string; + }; + api: { + version: string; + error?: string; + }; +} + +// Mock the version utility +vi.mock('../../utils/version', () => ({ + getVersionObject: vi.fn(() => ({ version: 'client-1.0.0' })) +})); + +// Mock the env config +vi.mock('../../../config/env.json', () => ({ + default: { + apiLocation: 'http://localhost:3000', + deploymentVersion: 'client-1.0.0' + } +})); + +// Mock fetch +global.fetch = vi.fn(); + +describe('VersionEndpoint', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render plain JSON output without HTML wrapper', async () => { + const mockApiResponse = { version: '1.2.3' }; + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => mockApiResponse + }); + + render(); + + // Wait for async data to load + await vi.waitFor(() => { + expect(screen.getByText(/"client"/)).toBeInTheDocument(); + }); + + // Should only contain a pre element with JSON + const preElement = screen.getByText(/"client"/); + expect(preElement).toBeInTheDocument(); + + // Should not contain any layout elements + expect(screen.queryByRole('banner')).not.toBeInTheDocument(); + expect(screen.queryByRole('contentinfo')).not.toBeInTheDocument(); + expect(screen.queryByRole('navigation')).not.toBeInTheDocument(); + + // The pre element should contain valid JSON + const jsonText = preElement?.textContent; + expect(jsonText).toBeTruthy(); + + const parsed = JSON.parse(jsonText!) as VersionData; + expect(parsed).toHaveProperty('client'); + expect(parsed).toHaveProperty('api'); + expect(parsed.client).toHaveProperty('version'); + expect(parsed.api).toHaveProperty('version'); + }); + + it('should include API error in JSON when fetch fails', async () => { + (global.fetch as ReturnType).mockRejectedValueOnce( + new Error('Network error') + ); + + render(); + + await vi.waitFor(() => { + expect(screen.getByText(/"client"/)).toBeInTheDocument(); + }); + + const preElement = screen.getByText(/"client"/); + const jsonText = preElement?.textContent; + const parsed = JSON.parse(jsonText!) as VersionData; + + expect(parsed.api.error).toBe('Network error'); + }); + + it('should include HTTP status error when API returns non-OK status', async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + render(); + + await vi.waitFor(() => { + expect(screen.getByText(/"client"/)).toBeInTheDocument(); + }); + + const preElement = screen.getByText(/"client"/); + const jsonText = preElement?.textContent; + const parsed = JSON.parse(jsonText!) as VersionData; + + expect(parsed.api.error).toBe('HTTP 500'); + }); +}); diff --git a/client/src/pages/status/version.tsx b/client/src/pages/status/version.tsx new file mode 100644 index 00000000000..d8bdc0891bc --- /dev/null +++ b/client/src/pages/status/version.tsx @@ -0,0 +1,55 @@ +import React, { useEffect, useState } from 'react'; +import { getVersionObject } from '../../utils/version'; +import envData from '../../../config/env.json'; + +interface VersionData { + client: { + version: string; + }; + api: { + version: string; + error?: string; + }; +} + +const VersionEndpoint = () => { + const [versionData, setVersionData] = useState(null); + + useEffect(() => { + const fetchVersions = async () => { + const clientVersion = getVersionObject(); + + let apiVersion = { version: 'unknown', error: '' }; + try { + const response = await fetch(`${envData.apiLocation}/status/version`); + if (response.ok) { + apiVersion = (await response.json()) as { + version: string; + error: string; + }; + } else { + apiVersion.error = `HTTP ${response.status}`; + } + } catch (error) { + apiVersion.error = + error instanceof Error ? error.message : 'Failed to fetch'; + } + + setVersionData({ + client: clientVersion, + api: apiVersion + }); + }; + + void fetchVersions(); + }, []); + + // Return plain JSON + if (!versionData) { + return null; + } + + return
{JSON.stringify(versionData, null, 2)}
; +}; + +export default VersionEndpoint; diff --git a/client/src/utils/version.test.ts b/client/src/utils/version.test.ts new file mode 100644 index 00000000000..ecd3565e263 --- /dev/null +++ b/client/src/utils/version.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import envData from '../../config/env.json'; +import { getVersion, getVersionObject } from './version'; + +describe('version utility', () => { + it('should return version string', () => { + const version = getVersion(); + expect(typeof version).toBe('string'); + expect(version).toBe(envData.deploymentVersion); + }); + + it('should return version object with correct shape', () => { + const result = getVersionObject(); + expect(result).toHaveProperty('version'); + expect(typeof result.version).toBe('string'); + expect(result.version).toBe(envData.deploymentVersion); + }); + + it('should return consistent version between getVersion() and getVersionObject()', () => { + const versionString = getVersion(); + const versionObject = getVersionObject(); + expect(versionObject.version).toBe(versionString); + }); + + it('should match format from env.json', () => { + const version = getVersion(); + // Version should either be a valid string or the default "unknown" + expect(version).toBeTruthy(); + expect(version.length).toBeGreaterThan(0); + }); + + it('should return version object in API-compatible format', () => { + const result = getVersionObject(); + // Should match the API's /status/version response format + expect(Object.keys(result)).toEqual(['version']); + expect(typeof result.version).toBe('string'); + }); +}); diff --git a/client/src/utils/version.ts b/client/src/utils/version.ts new file mode 100644 index 00000000000..90d302ca288 --- /dev/null +++ b/client/src/utils/version.ts @@ -0,0 +1,28 @@ +/** + * Version information object matching API's /status/version format + */ +export interface VersionInfo { + version: string; +} + +import envData from '../../config/env.json'; + +/** + * Get the client deployment version as a string + * + * @returns The deployment version or "unknown" if not set + * + */ +export function getVersion(): string { + return envData.deploymentVersion; +} + +/** + * Get the client deployment version as an object matching API format + * + * @returns Object with version field + * + */ +export function getVersionObject(): VersionInfo { + return { version: getVersion() }; +} diff --git a/client/tools/create-env.ts b/client/tools/create-env.ts index 60d7cd81a94..42acfe3a899 100644 --- a/client/tools/create-env.ts +++ b/client/tools/create-env.ts @@ -63,6 +63,7 @@ if (FREECODECAMP_NODE_ENV !== 'development') { 'clientLocale', 'curriculumLocale', 'deploymentEnv', + 'deploymentVersion', 'environment', 'showUpcomingChanges' ]; diff --git a/client/tools/read-env.ts b/client/tools/read-env.ts index 85407e5271f..239e45ce8b9 100644 --- a/client/tools/read-env.ts +++ b/client/tools/read-env.ts @@ -32,7 +32,8 @@ const { PATREON_CLIENT_ID: patreonClientId, DEPLOYMENT_ENV: deploymentEnv, SHOW_UPCOMING_CHANGES: showUpcomingChanges, - GROWTHBOOK_URI: growthbookUri + GROWTHBOOK_URI: growthbookUri, + DEPLOYMENT_VERSION: deploymentVersion } = process.env; const locations = { @@ -74,5 +75,6 @@ export default Object.assign(locations, { growthbookUri: !growthbookUri || growthbookUri === 'api_URI_from_Growthbook_dashboard' ? null - : growthbookUri + : growthbookUri, + deploymentVersion: deploymentVersion || 'unknown' }); diff --git a/client/utils/gatsby/layout-selector.test.tsx b/client/utils/gatsby/layout-selector.test.tsx index 23a6b413998..6e272b0c761 100644 --- a/client/utils/gatsby/layout-selector.test.tsx +++ b/client/utils/gatsby/layout-selector.test.tsx @@ -119,4 +119,24 @@ describe('Layout selector', () => { const componentObj = getComponentNameAndProps(Certification, challengePath); expect(componentObj.name).toEqual('CertificationLayout'); }); + + test('Status paths should return raw element without layout', () => { + const TestComponent = () =>
Test
; + const statusPath = '/status/version'; + + const result = layoutSelector({ + element: { type: TestComponent, props: {}, key: '' }, + props: { + data: {}, + location: { + pathname: statusPath + }, + params: { '*': '' }, + path: '' + } + }); + + // The result should be the element directly, not wrapped in a layout + expect(result.type).toBe(TestComponent); + }); }); diff --git a/client/utils/gatsby/layout-selector.tsx b/client/utils/gatsby/layout-selector.tsx index b2d6f44588e..b2c02dc325c 100644 --- a/client/utils/gatsby/layout-selector.tsx +++ b/client/utils/gatsby/layout-selector.tsx @@ -27,6 +27,11 @@ export default function layoutSelector({ const isChallenge = !!props.pageContext?.challengeMeta || isDailyChallenge; + // Return raw element for status endpoints without any layout + if (/^\/status\//.test(pathname)) { + return element; + } + if (element.type === FourOhFourPage) { return (