diff --git a/client/src/templates/Challenges/utils/frame.js b/client/src/templates/Challenges/utils/frame.js deleted file mode 100644 index 5da71c9c669..00000000000 --- a/client/src/templates/Challenges/utils/frame.js +++ /dev/null @@ -1,222 +0,0 @@ -import { toString, flow } from 'lodash-es'; -import { format } from '../../../utils/format'; - -// we use two different frames to make them all essentially pure functions -// main iframe is responsible rendering the preview and is where we proxy the -export const mainPreviewId = 'fcc-main-frame'; -// the test frame is responsible for running the assert tests -const testId = 'fcc-test-frame'; -// the project preview frame demos the finished project -export const projectPreviewId = 'fcc-project-preview-frame'; - -const DOCUMENT_NOT_FOUND_ERROR = 'document not found'; - -// base tag here will force relative links -// within iframe to point to '' instead of -// append to the current challenge url -// this also allows in-page anchors to work properly -// rather than load another instance of the learn - -// window.onerror is added here to report any errors thrown during the building -// of the frame. React dom errors already appear in the console, so onerror -// does not need to pass them on to the default error handler. -const createHeader = (id = mainPreviewId) => ` - - -`; - -export const runTestInTestFrame = async function (document, test, timeout) { - const { contentDocument: frame } = document.getElementById(testId); - return await Promise.race([ - new Promise((_, reject) => setTimeout(() => reject('timeout'), timeout)), - frame.__runTest(test) - ]); -}; - -const createFrame = (document, id, title) => ctx => { - const frame = document.createElement('iframe'); - frame.id = id; - if (typeof title === 'string') { - frame.title = title; - } - return { - ...ctx, - element: frame - }; -}; - -const hiddenFrameClassName = 'hide-test-frame'; -const mountFrame = - (document, id) => - ({ element, ...rest }) => { - const oldFrame = document.getElementById(element.id); - if (oldFrame) { - element.className = oldFrame.className || hiddenFrameClassName; - oldFrame.parentNode.replaceChild(element, oldFrame); - // only test frames can be added (and hidden) here, other frames must be - // added by react - } else if (id === testId) { - element.className = hiddenFrameClassName; - document.body.appendChild(element); - } - return { - ...rest, - element, - document: element.contentDocument, - window: element.contentWindow - }; - }; - -const buildProxyConsole = proxyLogger => ctx => { - // window does not exist if the preview is hidden, so we have to check. - if (proxyLogger && ctx?.window) { - const oldLog = ctx.window.console.log.bind(ctx.window.console); - ctx.window.console.log = function proxyConsole(...args) { - proxyLogger(args.map(arg => format(arg)).join(' ')); - return oldLog(...args); - }; - } - return ctx; -}; - -const initTestFrame = frameReady => ctx => { - waitForFrame(ctx) - .then(async () => { - const { sources, loadEnzyme } = ctx; - // provide the file name and get the original source - const getUserInput = fileName => toString(sources[fileName]); - await ctx.document.__initTestFrame({ - code: sources, - getUserInput, - loadEnzyme - }); - frameReady(); - }) - .catch(handleDocumentNotFound); - return ctx; -}; - -const initMainFrame = (_, proxyLogger) => ctx => { - waitForFrame(ctx) - .then(() => { - // Overwriting the onerror added by createHeader to catch any errors thrown - // after the frame is ready. It has to be overwritten, as proxyLogger cannot - // be added as part of createHeader. - ctx.window.onerror = function (msg) { - var string = msg.toLowerCase(); - if (string.includes('script error')) { - msg = 'Error, open your browser console to learn more.'; - } - if (proxyLogger) { - proxyLogger(msg); - } - // let the error propagate so it appears in the browser console, otherwise - // an error from a cross origin script just appears as 'Script error.' - return false; - }; - }) - .catch(handleDocumentNotFound); - return ctx; -}; - -function handleDocumentNotFound(err) { - if (err !== DOCUMENT_NOT_FOUND_ERROR) { - console.log(err); - } -} - -const initPreviewFrame = () => ctx => ctx; - -const waitForFrame = ctx => { - return new Promise((resolve, reject) => { - if (!ctx.document) { - reject(DOCUMENT_NOT_FOUND_ERROR); - } else if (ctx.document.readyState === 'loading') { - ctx.document.addEventListener('DOMContentLoaded', resolve); - } else { - resolve(); - } - }); -}; - -function writeToFrame(content, frame) { - // it's possible, if the preview is rapidly opened and closed, for the frame - // to be null at this point. - if (frame) { - frame.open(); - frame.write(content); - frame.close(); - } -} - -const writeContentToFrame = ctx => { - writeToFrame(createHeader(ctx.element.id) + ctx.build, ctx.document); - return ctx; -}; - -export const createMainPreviewFramer = (document, proxyLogger) => - createFramer( - document, - mainPreviewId, - initMainFrame, - proxyLogger, - undefined, - 'preview' - ); - -export const createProjectPreviewFramer = (document, frameTitle) => - createFramer( - document, - projectPreviewId, - initPreviewFrame, - undefined, - undefined, - frameTitle - ); - -export const createTestFramer = (document, proxyLogger, frameReady) => - createFramer(document, testId, initTestFrame, proxyLogger, frameReady); - -const createFramer = ( - document, - id, - init, - proxyLogger, - frameReady, - frameTitle -) => - flow( - createFrame(document, id, frameTitle), - mountFrame(document, id), - buildProxyConsole(proxyLogger), - writeContentToFrame, - init(frameReady, proxyLogger) - ); diff --git a/client/src/templates/Challenges/utils/frame.ts b/client/src/templates/Challenges/utils/frame.ts new file mode 100644 index 00000000000..b9c405768c8 --- /dev/null +++ b/client/src/templates/Challenges/utils/frame.ts @@ -0,0 +1,279 @@ +import { toString, flow } from 'lodash-es'; +import { format } from '../../../utils/format'; + +const utilsFormat: (x: T) => string = format; + +declare global { + interface Window { + console: { + log: () => void; + }; + } +} + +interface Context { + window: Window; + document: Document; + element: HTMLIFrameElement; + build: string; + sources: { + contents?: string; + editableContents?: string; + original?: { [id: string]: string }; + }; + loadEnzyme?: () => void; +} + +type ProxyLogger = (msg: string) => void; + +type InitFrame = ( + arg1?: () => unknown, + arg2?: ProxyLogger +) => (ctx: Context) => Context; + +// we use two different frames to make them all essentially pure functions +// main iframe is responsible rendering the preview and is where we proxy the +export const mainPreviewId = 'fcc-main-frame'; +// the test frame is responsible for running the assert tests +const testId = 'fcc-test-frame'; +// the project preview frame demos the finished project +export const projectPreviewId = 'fcc-project-preview-frame'; + +const DOCUMENT_NOT_FOUND_ERROR = 'document not found'; + +// base tag here will force relative links +// within iframe to point to '' instead of +// append to the current challenge url +// this also allows in-page anchors to work properly +// rather than load another instance of the learn + +// window.onerror is added here to report any errors thrown during the building +// of the frame. React dom errors already appear in the console, so onerror +// does not need to pass them on to the default error handler. +const createHeader = (id = mainPreviewId) => ` + + +`; + +export const runTestInTestFrame = async function ( + document: Document, + test: string, + timeout: number +) { + const { contentDocument: frame } = document.getElementById( + testId + ) as HTMLIFrameElement; + if (frame !== null) { + return await Promise.race([ + new Promise< + { pass: boolean } | { err: { message: string; stack?: string } } + >((_, reject) => setTimeout(() => reject('timeout'), timeout)), + frame.__runTest(test) + ]); + } +}; + +const createFrame = + (document: Document, id: string, title?: string) => (ctx: Context) => { + const frame = document.createElement('iframe'); + frame.id = id; + if (typeof title === 'string') { + frame.title = title; + } + return { + ...ctx, + element: frame + }; + }; + +const hiddenFrameClassName = 'hide-test-frame'; +const mountFrame = (document: Document, id: string) => (ctx: Context) => { + const { element }: { element: HTMLIFrameElement } = ctx; + const oldFrame = document.getElementById(element.id) as HTMLIFrameElement; + if (oldFrame) { + element.className = oldFrame.className || hiddenFrameClassName; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + oldFrame.parentNode!.replaceChild(element, oldFrame); + // only test frames can be added (and hidden) here, other frames must be + // added by react + } else if (id === testId) { + element.className = hiddenFrameClassName; + document.body.appendChild(element); + } + return { + ...ctx, + element, + document: element.contentDocument, + window: element.contentWindow + }; +}; + +const buildProxyConsole = (proxyLogger?: ProxyLogger) => (ctx: Context) => { + // window does not exist if the preview is hidden, so we have to check. + if (proxyLogger && ctx?.window) { + const oldLog = ctx.window.console.log.bind(ctx.window.console); + ctx.window.console.log = function proxyConsole(...args: string[]) { + proxyLogger(args.map((arg: string) => utilsFormat(arg)).join(' ')); + return oldLog(...(args as [])); + }; + } + return ctx; +}; + +const initTestFrame = (frameReady?: () => void) => (ctx: Context) => { + waitForFrame(ctx) + .then(async () => { + const { sources, loadEnzyme } = ctx; + // provide the file name and get the original source + const getUserInput = (fileName: string) => + toString(sources[fileName as keyof typeof sources]); + await ctx.document.__initTestFrame({ + code: sources, + getUserInput, + loadEnzyme + }); + if (frameReady) { + frameReady(); + } + }) + .catch(handleDocumentNotFound); + return ctx; +}; + +const initMainFrame = + (_: unknown, proxyLogger?: ProxyLogger) => (ctx: Context) => { + waitForFrame(ctx) + .then(() => { + // Overwriting the onerror added by createHeader to catch any errors thrown + // after the frame is ready. It has to be overwritten, as proxyLogger cannot + // be added as part of createHeader. + + ctx.window.onerror = function (msg) { + if (typeof msg === 'string') { + const string = msg.toLowerCase(); + if (string.includes('script error')) { + msg = 'Error, open your browser console to learn more.'; + } + if (proxyLogger) { + proxyLogger(msg); + } + } + // let the error propagate so it appears in the browser console, otherwise + // an error from a cross origin script just appears as 'Script error.' + return false; + }; + }) + .catch(handleDocumentNotFound); + return ctx; + }; + +function handleDocumentNotFound(err: string) { + if (err !== DOCUMENT_NOT_FOUND_ERROR) { + console.log(err); + } +} + +const initPreviewFrame = () => (ctx: Context) => ctx; + +const waitForFrame = (ctx: Context) => { + return new Promise((resolve, reject) => { + if (!ctx.document) { + reject(DOCUMENT_NOT_FOUND_ERROR); + } else if (ctx.document.readyState === 'loading') { + ctx.document.addEventListener('DOMContentLoaded', resolve); + } else { + resolve(null); + } + }); +}; + +function writeToFrame(content: string, frame: Document | null) { + // it's possible, if the preview is rapidly opened and closed, for the frame + // to be null at this point. + if (frame) { + frame.open(); + frame.write(content); + frame.close(); + } +} + +const writeContentToFrame = (ctx: Context) => { + writeToFrame(createHeader(ctx.element.id) + ctx.build, ctx.document); + return ctx; +}; + +export const createMainPreviewFramer = ( + document: Document, + proxyLogger: ProxyLogger +) => + createFramer( + document, + mainPreviewId, + initMainFrame, + proxyLogger, + undefined, + 'preview' + ); + +export const createProjectPreviewFramer = ( + document: Document, + frameTitle: string +) => + createFramer( + document, + projectPreviewId, + initPreviewFrame, + undefined, + undefined, + frameTitle + ); + +export const createTestFramer = ( + document: Document, + proxyLogger: ProxyLogger, + frameReady: () => void +) => createFramer(document, testId, initTestFrame, proxyLogger, frameReady); + +const createFramer = ( + document: Document, + id: string, + init: InitFrame, + proxyLogger?: ProxyLogger, + frameReady?: () => void, + frameTitle?: string +) => + flow( + createFrame(document, id, frameTitle), + mountFrame(document, id), + buildProxyConsole(proxyLogger), + writeContentToFrame, + init(frameReady, proxyLogger) + ); diff --git a/curriculum/package.json b/curriculum/package.json index 7dad6bb3c8c..9341d50a6ea 100644 --- a/curriculum/package.json +++ b/curriculum/package.json @@ -25,7 +25,7 @@ "delete-step": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/delete-step", "lint": "ts-node --project ../tsconfig.json lint-localized", "update-step-titles": "cross-env CALLING_DIR=$INIT_CWD ts-node --project ../tsconfig.json ../tools/challenge-helper-scripts/update-step-titles", - "test": "mocha --delay --exit --reporter progress --bail", + "test": "ts-node ../node_modules/mocha/bin/mocha --delay --exit --reporter progress --bail", "test:full-output": "cross-env FULL_OUTPUT=true mocha --delay --reporter progress" }, "devDependencies": {