feat: versioned endpoints on client (#63441)

This commit is contained in:
Mrugesh Mohapatra
2025-11-04 05:05:23 +05:30
committed by GitHub
parent 88fa4039b9
commit c801dcdbcb
9 changed files with 267 additions and 2 deletions

View File

@@ -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 }}'

View File

@@ -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<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => mockApiResponse
});
render(<VersionEndpoint />);
// 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<typeof vi.fn>).mockRejectedValueOnce(
new Error('Network error')
);
render(<VersionEndpoint />);
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<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 500
});
render(<VersionEndpoint />);
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');
});
});

View File

@@ -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<VersionData | null>(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 <pre>{JSON.stringify(versionData, null, 2)}</pre>;
};
export default VersionEndpoint;

View File

@@ -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');
});
});

View File

@@ -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() };
}

View File

@@ -63,6 +63,7 @@ if (FREECODECAMP_NODE_ENV !== 'development') {
'clientLocale',
'curriculumLocale',
'deploymentEnv',
'deploymentVersion',
'environment',
'showUpcomingChanges'
];

View File

@@ -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'
});

View File

@@ -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 = () => <div>Test</div>;
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);
});
});

View File

@@ -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 (
<DefaultLayout pathname={pathname} showFooter={true}>