feat(client): exam environment download page (#57325)

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
Sem Bauke
2025-07-02 17:18:23 +02:00
committed by GitHub
parent f6107116e8
commit 1f76ac71a4
7 changed files with 252 additions and 8 deletions
+12 -1
View File
@@ -38,6 +38,7 @@
"news": "News",
"donate": "Donate",
"supporters": "Supporters",
"exam-app": "Exam App",
"go-to-supporters": "Go to Supporters Page",
"update-settings": "Update my account settings",
"sign-me-out": "Sign me out of freeCodeCamp",
@@ -114,7 +115,9 @@
"closed-caption": "Closed caption",
"share-on-x": "Share on X",
"share-on-bluesky": "Share on BlueSky",
"share-on-threads": "Share on Threads"
"share-on-threads": "Share on Threads",
"play-scene": "Press Play",
"download-latest-version": "Download the Latest Version"
},
"landing": {
"big-heading-1": "Learn to code — for free.",
@@ -345,6 +348,14 @@
"p7": "As an academic institution that grants achievement-based certifications, we take academic honesty very seriously. If you have any questions about this policy, or suspect that someone has violated it, you can email <0>{{email}}</0> and we will investigate."
}
},
"exam": {
"download-header": "Download the freeCodeCamp Exam Environment App",
"explanation": "To earn a certification, you must take an exam to test your understanding of the material you have learned. Taking the exam is absolutely free of charge.",
"version": "The latest version of our app is: {{version}}.",
"download-details": "Manually download the app",
"unable-to-detect-os": "We were unable to detect your operating system. Please manually download the app below.",
"download-trouble": "If you have trouble downloading the correct version, do not hesitate to contact support:"
},
"profile": {
"you-change-privacy": "You need to change your privacy setting in order for your portfolio to be seen by others. This is a preview of how your portfolio will look when made public.",
"username-change-privacy": "{{username}} needs to change their privacy setting in order for you to view their portfolio.",
@@ -0,0 +1,146 @@
import React, { useEffect, useState } from 'react';
import { Button, Spacer } from '@freecodecamp/ui';
import { isEmpty } from 'lodash';
import { useTranslation } from 'react-i18next';
import { FullWidthRow } from '../../../components/helpers';
import useDetectOS from '../utils/use-detect-os';
interface GitProps {
tag_name: string;
assets: {
browser_download_url: string;
}[];
}
function ShowExamDownload(): JSX.Element {
const [latestVersion, setLatestVersion] = useState<string | null>(null);
const [downloadLink, setDownloadLink] = useState<string | undefined>('');
const [downloadLinks, setDownloadLinks] = useState<string[]>([]);
const os = useDetectOS();
const { t } = useTranslation();
const handleDownloadLink = (downloadLinks: string[]) => {
const win = downloadLinks.find(link => link.match(/\.msi/));
const macARM = downloadLinks.find(
link => link.match(/aarch64/) && link.match(/\.dmg/)
);
const macX64 = downloadLinks.find(
link => link.match(/x64/) && link.match(/\.dmg/)
);
const linuxARM = downloadLinks.find(
link => link.match(/aarch64/) && link.match(/tar\.gz/)
);
const linuxX64 = downloadLinks.find(
link => link.match(/amd64/) && link.match(/AppImage/)
);
if (os.os === 'WIN') {
if (isEmpty(win)) return '';
return win;
}
if (os.os === 'MAC') {
if (os.architecture.toLowerCase() === 'arm') {
if (isEmpty(macARM)) return '';
return macARM;
} else {
if (isEmpty(macX64)) return '';
return macX64;
}
}
if (os.os === 'LINUX') {
if (os.architecture.toLowerCase() === 'arm') {
if (isEmpty(linuxARM)) return '';
return linuxARM;
} else {
if (isEmpty(linuxX64)) return '';
return linuxX64;
}
}
return '';
};
useEffect(() => {
const checkLatestVersion = async () => {
return await fetch(
'https://api.github.com/repos/freeCodeCamp/exam-env/releases/latest'
);
};
checkLatestVersion()
.then(response => {
if (response.ok) {
void response.json().then(data => {
const { tag_name, assets } = data as GitProps;
setLatestVersion(tag_name);
setDownloadLink(
handleDownloadLink(
assets.map(links => links.browser_download_url)
)
);
setDownloadLinks(assets.map(links => links.browser_download_url));
});
}
})
.catch(() => {
setLatestVersion('...');
});
});
return (
<FullWidthRow>
<Spacer size='l' />
<h2>{t('exam.download-header')}</h2>
<p>{t('exam.explanation')}</p>
<p>
{t('exam.version', {
version: latestVersion || '...'
})}
</p>
<Button
disabled={!downloadLink}
aria-disabled={!downloadLink}
href={downloadLink}
download={downloadLink}
>
{t('buttons.download-latest-version')}
</Button>
{!downloadLink && <strong>{t('exam.unable-to-detect-os')}</strong>}
<Spacer size='l' />
<details>
<summary>{t('exam.download-details')}</summary>
<ul>
{downloadLinks
.filter(link => !link.match(/\.sig|\.json/))
.map((link, index) => {
return (
<li key={index} style={{ listStyle: 'none' }}>
<a href={link} download={link}>
{link}
</a>
</li>
);
})}
</ul>
</details>
<Spacer size='l' />
<strong>{t('exam.download-trouble')}</strong>
<a href='mailto: support@freecodecamp.org'>support@freecodecamp.org</a>
</FullWidthRow>
);
}
export default ShowExamDownload;
@@ -0,0 +1,76 @@
import { useEffect, useState } from 'react';
type UserOS = 'MAC' | 'WIN' | 'LINUX' | 'AIX' | 'OTHER' | 'LOADING';
type UserOSState = {
os: UserOS;
architecture: string;
};
interface NavigatorUAData {
getHighEntropyValues(hints: string[]): Promise<Record<string, string>>;
}
interface Navigator {
userAgentData?: NavigatorUAData;
}
const getArchitecture = async (): Promise<string> => {
if ((navigator as Navigator).userAgentData?.getHighEntropyValues) {
try {
const { architecture } =
(await (navigator as Navigator).userAgentData?.getHighEntropyValues([
'architecture'
])) ?? {};
if (architecture) {
return architecture;
}
} catch (error) {
console.error(
'Error fetching architecture via User-Agent Client Hints:',
error
);
}
}
const userAgent = navigator.userAgent;
if (/x86_64|Win64|WOW64|amd64/.test(userAgent)) {
return 'x86_64';
} else if (/i686|x86|Win32/.test(userAgent)) {
return 'x86';
} else if (/arm|aarch64/.test(userAgent)) {
return /aarch64/.test(userAgent) ? 'ARM64' : 'ARM';
}
return 'Unknown';
};
export const detectOsInUserAgent = (userAgent: string | undefined): UserOS => {
const osMatch = userAgent?.match(/(Win|Mac|Linux)/);
return osMatch ? (osMatch[1].toUpperCase() as UserOS) : 'OTHER';
};
export const detectOS = (): UserOS => detectOsInUserAgent(navigator?.userAgent);
const useDetectOS = () => {
const [userOS, setUserOS] = useState<UserOSState>({
os: 'LOADING',
architecture: 'LOADING'
});
useEffect(() => {
Promise.all([getArchitecture()])
.then(([arch]) => {
setUserOS({
os: detectOS(),
architecture: arch ? arch : ''
});
})
.catch(console.error);
}, []);
return userOS;
};
export default useDetectOS;
@@ -50,6 +50,11 @@ const generic = path.resolve(
'../../src/templates/Challenges/generic/show.tsx'
);
const examDownload = path.resolve(
__dirname,
'../../src/templates/Challenges/exam-download/show.tsx'
);
const views = {
backend,
classic,
@@ -60,7 +65,8 @@ const views = {
exam,
msTrophy,
fillInTheBlank,
generic
generic,
examDownload
};
function getIsFirstStepInBlock(id, edges) {
@@ -1,7 +1,7 @@
---
id: 645147516c245de4d11eb7ba
title: Certified Full Stack Developer Exam
challengeType: 24
challengeType: 30
dashedName: exam-certified-full-stack-developer
---
+1 -1
View File
@@ -159,7 +159,7 @@ const schema = Joi.object()
otherwise: Joi.optional()
}),
certification: Joi.string().regex(slugWithSlashRE),
challengeType: Joi.number().min(0).max(29).required(),
challengeType: Joi.number().min(0).max(30).required(),
checksum: Joi.number(),
// TODO: require this only for normal challenges, not certs
dashedName: Joi.string().regex(slugRE),
+9 -4
View File
@@ -29,6 +29,7 @@ const jsLab = 26;
const pyLab = 27;
const dailyChallengeJs = 28;
const dailyChallengePy = 29;
const examDownload = 30;
export const challengeTypes = {
html,
@@ -61,7 +62,8 @@ export const challengeTypes = {
jsLab,
pyLab,
dailyChallengeJs,
dailyChallengePy
dailyChallengePy,
examDownload
};
export const hasNoSolution = (challengeType: number): boolean => {
@@ -84,7 +86,8 @@ export const hasNoSolution = (challengeType: number): boolean => {
multipleChoice,
dialogue,
fillInTheBlank,
generic
generic,
examDownload
];
return noSolutions.includes(challengeType);
@@ -120,7 +123,8 @@ export const viewTypes = {
[jsLab]: 'classic',
[pyLab]: 'classic',
[dailyChallengeJs]: 'classic',
[dailyChallengePy]: 'classic'
[dailyChallengePy]: 'classic',
[examDownload]: 'examDownload'
};
// determine the type of submit function to use for the challenge on completion
@@ -157,7 +161,8 @@ export const submitTypes = {
[jsLab]: 'tests',
[pyLab]: 'tests',
[dailyChallengeJs]: 'tests',
[dailyChallengePy]: 'tests'
[dailyChallengePy]: 'tests',
[examDownload]: 'examDownload'
};
export const canSaveToDB = (challengeType: number): boolean =>