Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BD-26][EDUCATOR-5791, EDUCATOR-5767] Add handling of exam API errors and support for js workers from provider #7

78 changes: 48 additions & 30 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"description": "Special exams lib",
"main": "dist/index.js",
"release": {
"branches": ["main"]
"branches": [
"main"
]
},
"exports": {
"import": "./dist/index.js"
Expand Down Expand Up @@ -65,6 +67,7 @@
"@edx/frontend-build": "^5.6.11",
"@edx/frontend-platform": "1.8.4",
"@edx/paragon": "13.17.0",
"@testing-library/dom": "7.16.3",
"@testing-library/jest-dom": "5.10.1",
"@testing-library/react": "10.3.0",
"axios-mock-adapter": "1.18.2",
Expand Down
3 changes: 3 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
/* eslint-disable import/prefer-default-export */
export const ExamStatus = Object.freeze({
CREATED: 'created',
STARTED: 'started',
READY_TO_SUBMIT: 'ready_to_submit',
SUBMITTED: 'submitted',
TIMED_OUT: 'timed_out',
VERIFIED: 'verified',
REJECTED: 'rejected',
});

export const IS_STARTED_STATUS = (status) => [ExamStatus.STARTED, ExamStatus.READY_TO_SUBMIT].includes(status);
Expand Down
11 changes: 9 additions & 2 deletions src/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ export async function pollExamAttempt(url) {
return data;
}

export async function createExamAttempt(examId) {
export async function createExamAttempt(examId, startClock = true, attemptProctored = false) {
const url = new URL(`${getConfig().LMS_BASE_URL}${BASE_API_URL}`);
const payload = {
exam_id: examId,
start_clock: 'true',
start_clock: startClock.toString(),
attempt_proctored: attemptProctored.toString(),
};
const { data } = await getAuthenticatedHttpClient().post(url.href, payload);
return data;
Expand Down Expand Up @@ -52,3 +53,9 @@ export async function submitAttempt(attemptId) {
export async function endExamWithFailure(attemptId, error) {
return updateAttemptStatus(attemptId, ExamAction.ERROR, error);
}

export async function fetchProctoringSettings(examId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/proctored_exam/settings/exam_id/${examId}/`);
const { data } = await getAuthenticatedHttpClient().get(url.href);
return data;
}
3 changes: 3 additions & 0 deletions src/data/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
export {
getExamAttemptsData,
getProctoringSettings,
startExam,
startProctoringExam,
stopExam,
continueExam,
submitExam,
expireExam,
pollAttempt,
pingAttempt,
} from './thunks';

export { default as store } from './store';
Expand Down
25 changes: 25 additions & 0 deletions src/data/messages/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const SUBMIT_MAP = Object.freeze({
promptEventName: 'endExamAttempt',
successEventName: 'examAttemptEnded',
failureEventName: 'examAttemptEndFailed',
});

const START_MAP = Object.freeze({
promptEventName: 'startExamAttempt',
successEventName: 'examAttemptStarted',
failureEventName: 'examAttemptStartFailed',
});

const PING_MAP = Object.freeze({
promptEventName: 'ping',
successEventName: 'echo',
failureEventName: 'pingFailed',
});

const actionToMessageTypesMap = Object.freeze({
submit: SUBMIT_MAP,
start: START_MAP,
ping: PING_MAP,
});

export default actionToMessageTypesMap;
44 changes: 44 additions & 0 deletions src/data/messages/handlers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import actionToMessageTypesMap from './constants';

function createWorker(url) {
const blob = new Blob([`importScripts('${url}');`], { type: 'application/javascript' });
const blobUrl = window.URL.createObjectURL(blob);
return new Worker(blobUrl);
}

function workerTimeoutPromise(timeoutMilliseconds) {
const message = `worker failed to respond after ${timeoutMilliseconds} ms`;
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(Error(message));
}, timeoutMilliseconds);
});
}

export function workerPromiseForEventNames(eventNames, workerUrl) {
return (timeout) => {
const proctoringBackendWorker = createWorker(workerUrl);
return new Promise((resolve, reject) => {
const responseHandler = (e) => {
if (e.data.type === eventNames.successEventName) {
proctoringBackendWorker.removeEventListener('message', responseHandler);
proctoringBackendWorker.terminate();
resolve();
} else {
reject(e.data.error);
}
};
proctoringBackendWorker.addEventListener('message', responseHandler);
proctoringBackendWorker.postMessage({ type: eventNames.promptEventName, timeout });
});
};
}

export function pingApplication(timeoutInSeconds, workerUrl) {
const TIMEOUT_BUFFER_SECONDS = 10;
const workerPingTimeout = timeoutInSeconds - TIMEOUT_BUFFER_SECONDS; // 10s buffer for worker to respond
return Promise.race([
workerPromiseForEventNames(actionToMessageTypesMap.ping, workerUrl)(workerPingTimeout * 1000),
workerTimeoutPromise(timeoutInSeconds * 1000),
]);
}
15 changes: 10 additions & 5 deletions src/data/slice.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ export const examSlice = createSlice({
isLoading: true,
timeIsOver: false,
activeAttempt: null,
proctoringSettings: {},
exam: {},
apiErrorMsg: '',
},
reducers: {
setIsLoading: (state, { payload }) => {
Expand All @@ -19,21 +21,24 @@ export const examSlice = createSlice({
},
setActiveAttempt: (state, { payload }) => {
state.activeAttempt = payload.activeAttempt;
const examAttempt = state.exam.attempt;
if (examAttempt && examAttempt.attempt_id === payload.activeAttempt.attempt_id) {
state.exam.attempt = payload.activeAttempt;
}
state.apiErrorMsg = '';
},
setProctoringSettings: (state, { payload }) => {
state.proctoringSettings = payload.proctoringSettings;
},
expireExamAttempt: (state) => {
state.timeIsOver = true;
},
getExamId: (state) => state.examId,
setApiError: (state, { payload }) => {
state.apiErrorMsg = payload.errorMsg;
},
},
});

export const {
setIsLoading, setExamState, getExamId, expireExamAttempt,
setActiveAttempt,
setActiveAttempt, setProctoringSettings, setApiError,
} = examSlice.actions;

export default examSlice.reducer;
Loading