mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2025-12-19 18:18:27 -05:00
feat: versioned endpoints on client (#63441)
This commit is contained in:
committed by
GitHub
parent
88fa4039b9
commit
c801dcdbcb
7
.github/workflows/deploy-client.yml
vendored
7
.github/workflows/deploy-client.yml
vendored
@@ -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 }}'
|
||||
|
||||
109
client/src/pages/status/version.test.tsx
Normal file
109
client/src/pages/status/version.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
55
client/src/pages/status/version.tsx
Normal file
55
client/src/pages/status/version.tsx
Normal 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;
|
||||
38
client/src/utils/version.test.ts
Normal file
38
client/src/utils/version.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
28
client/src/utils/version.ts
Normal file
28
client/src/utils/version.ts
Normal 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() };
|
||||
}
|
||||
@@ -63,6 +63,7 @@ if (FREECODECAMP_NODE_ENV !== 'development') {
|
||||
'clientLocale',
|
||||
'curriculumLocale',
|
||||
'deploymentEnv',
|
||||
'deploymentVersion',
|
||||
'environment',
|
||||
'showUpcomingChanges'
|
||||
];
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user