diff --git a/client/frame-runner.js b/client/frame-runner.js index f00150bddc..e44fa6a034 100644 --- a/client/frame-runner.js +++ b/client/frame-runner.js @@ -58,6 +58,11 @@ document.addEventListener('DOMContentLoaded', function() { const newTest = { text, testString }; let test; let __result; + + // uncomment the following line to inspect + // the framerunner as it runs tests + // make sure the dev tools console is open + // debugger; try { /* eslint-disable no-eval */ // eval test string to actual JavaScript diff --git a/client/rechallenge/builders.js b/client/rechallenge/builders.js new file mode 100644 index 0000000000..63efadd504 --- /dev/null +++ b/client/rechallenge/builders.js @@ -0,0 +1,99 @@ +import { Observable } from 'rx'; +import cond from 'lodash/cond'; +import flow from 'lodash/flow'; +import identity from 'lodash/identity'; +import matchesProperty from 'lodash/matchesProperty'; +import partial from 'lodash/partial'; +import stubTrue from 'lodash/stubTrue'; + +import { + compileHeadTail, + setExt, + transformContents +} from '../../common/utils/polyvinyl'; +import { + fetchScript, + fetchLink +} from '../utils/fetch-and-cache.js'; + +const htmlCatch = '\n\n'; +const jsCatch = '\n;/*fcc*/\n'; + +const wrapInScript = partial(transformContents, (content) => ( + `${htmlCatch}` +)); +const wrapInStyle = partial(transformContents, (content) => ( + `${htmlCatch}` +)); +const setExtToHTML = partial(setExt, 'html'); +const padContentWithJsCatch = partial(compileHeadTail, jsCatch); +const padContentWithHTMLCatch = partial(compileHeadTail, htmlCatch); + +export const jsToHtml = cond([ + [ + matchesProperty('ext', 'js'), + flow(padContentWithJsCatch, wrapInScript, setExtToHTML) + ], + [ stubTrue, identity ] +]); + +export const cssToHtml = cond([ + [ + matchesProperty('ext', 'css'), + flow(padContentWithHTMLCatch, wrapInStyle, setExtToHTML) + ], + [ stubTrue, identity ] +]); + +// FileStream::concactHtml( +// required: [ ...Object ] +// ) => Observable[{ build: String, sources: Dictionary }] +export function concactHtml(required) { + const source = this.shareReplay(); + const sourceMap = source + .flatMap(files => files.reduce((sources, file) => { + sources[file.name] = file.source || file.contents; + return sources; + }, {})); + + const head = Observable.from(required) + .flatMap(required => { + if (required.src) { + return fetchScript(required); + } + if (required.link) { + return fetchLink(required); + } + return Observable.just(''); + }) + .reduce((head, required) => head + required, '') + .map(head => `
${head}`); + + const body = source + .flatMap(file => file.reduce((body, file) => { + return body + file.contents + htmlCatch; + }, '')) + .map(source => ` + + + ${source} + + + `); + + return Observable + .combineLatest( + head, + body, + fetchScript({ + src: '/js/frame-runner.js', + crossDomain: false, + cacheBreaker: true + }), + sourceMap, + (head, body, frameRunner, sources) => ({ + build: head + body + frameRunner, + sources + }) + ); +} diff --git a/client/rechallenge/throwers.js b/client/rechallenge/throwers.js index b8a7a38c20..946acaa6fc 100644 --- a/client/rechallenge/throwers.js +++ b/client/rechallenge/throwers.js @@ -1,99 +1,124 @@ -import { helpers, Observable } from 'rx'; +import { Observable } from 'rx'; +import cond from 'lodash/cond'; +import identity from 'lodash/identity'; +import stubTrue from 'lodash/stubTrue'; +import conforms from 'lodash/conforms'; -const throwForJsHtml = { - ext: /js|html/, - throwers: [ - { - name: 'multiline-comment', - description: 'Detect if a JS multi-line comment is left open', - thrower: function checkForComments({ contents }) { - const openingComments = contents.match(/\/\*/gi); - const closingComments = contents.match(/\*\//gi); - if ( - openingComments && - (!closingComments || openingComments.length > closingComments.length) - ) { - throw new Error('SyntaxError: Unfinished multi-line comment'); - } - } - }, { - name: 'nested-jQuery', - description: 'Nested dollar sign calls breaks browsers', - detectUnsafeJQ: /\$\s*?\(\s*?\$\s*?\)/gi, - thrower: function checkForNestedJquery({ contents }) { - if (contents.match(this.detectUnsafeJQ)) { - throw new Error('Unsafe $($)'); - } - } - }, { - name: 'unfinished-function', - description: 'lonely function keywords breaks browsers', - detectFunctionCall: /function\s*?\(|function\s+\w+\s*?\(/gi, - thrower: function checkForUnfinishedFunction({ contents }) { - if ( - contents.match(/function/g) && - !contents.match(this.detectFunctionCall) - ) { - throw new Error( - 'SyntaxError: Unsafe or unfinished function declaration' - ); - } - } - }, { - name: 'unsafe console call', - description: 'console call stops tests scripts from running', - detectUnsafeConsoleCall: /if\s\(null\)\sconsole\.log\(1\);/gi, - thrower: function checkForUnsafeConsole({ contents }) { - if (contents.match(this.detectUnsafeConsoleCall)) { - throw new Error('Invalid if (null) console.log(1); detected'); - } - } - }, { - name: 'glitch in code', - description: 'Code with the URL glitch.com or glitch.me' + - 'should not be allowed to run', - detectGlitchInCode: /glitch\.(com|me)/gi, - thrower: function checkForGlitch({ contents }) { - if (contents.match(this.detectGlitchInCode)) { - throw new Error('Glitch.com or Glitch.me should not be in the code'); - } +import castToObservable from '../../common/app/utils/cast-to-observable.js'; + +const HTML$JSReg = /html|js/; + +const testHTMLJS = conforms({ ext: (ext) => HTML$JSReg.test(ext) }); +// const testJS = matchesProperty('ext', 'js'); +const passToNext = [ stubTrue, identity ]; + +// Detect if a JS multi-line comment is left open +const throwIfOpenComments = cond([ + [ + testHTMLJS, + function _checkForComments({ contents }) { + const openingComments = contents.match(/\/\*/gi); + const closingComments = contents.match(/\*\//gi); + if ( + openingComments && + (!closingComments || openingComments.length > closingComments.length) + ) { + throw new SyntaxError('Unfinished multi-line comment'); } } - ] -}; + ], + passToNext +]); -export default function throwers() { - const source = this; - return source.map(file$ => file$.flatMap(file => { - if (!throwForJsHtml.ext.test(file.ext)) { + +// Nested dollar sign calls breaks browsers +const nestedJQCallReg = /\$\s*?\(\s*?\$\s*?\)/gi; +const throwIfNestedJquery = cond([ + [ + testHTMLJS, + function({ contents }) { + if (nestedJQCallReg.test(contents)) { + throw new SyntaxError('Nested jQuery calls breaks browsers'); + } + } + ], + passToNext +]); + +const functionReg = /function/g; +const functionCallReg = /function\s*?\(|function\s+\w+\s*?\(/gi; +// lonely function keywords breaks browsers +const ThrowIfUnfinishedFunction = cond([ + + [ + testHTMLJS, + function({ contents }) { + if ( + functionReg.test(contents) && + !functionCallReg.test(contents) + ) { + throw new SyntaxError( + 'Unsafe or unfinished function declaration' + ); + } + } + ], + passToNext +]); + + +// console call stops tests scripts from running +const unsafeConsoleCallReg = /if\s\(null\)\sconsole\.log\(1\);/gi; +const throwIfUnsafeConsoleCall = cond([ + [ + testHTMLJS, + function({ contents }) { + if (unsafeConsoleCallReg.test(contents)) { + throw new SyntaxError( + '`if (null) console.log(1)` detected. This will break tests' + ); + } + } + ], + passToNext +]); + +// Code with the URL hyperdev.com should not be allowed to run, +const goMixReg = /glitch\.(com|me)/gi; +const throwIfGomixDetected = cond([ + [ + testHTMLJS, + function({ contents }) { + if (goMixReg.test(contents)) { + throw new Error('Glitch.com or Glitch.me should not be in the code'); + } + } + ], + passToNext +]); + +const validators = [ + throwIfOpenComments, + throwIfGomixDetected, + throwIfNestedJquery, + ThrowIfUnfinishedFunction, + throwIfUnsafeConsoleCall +]; + +export default function validate(file) { + return validators.reduce((obs, validator) => obs.flatMap(file => { + try { + return castToObservable(validator(file)); + } catch (err) { + return Observable.throw(err); + } + }), Observable.of(file)) + // if no error has occured map to the original file + .map(() => file) + // if err add it to the file + // and return file + .catch(err => { + file.error = err; return Observable.just(file); - } - return Observable.from(throwForJsHtml.throwers) - .flatMap(context => { - try { - let finalObs; - const maybeObservableOrPromise = context.thrower(file); - if (helpers.isPromise(maybeObservableOrPromise)) { - finalObs = Observable.fromPromise(maybeObservableOrPromise); - } else if (Observable.isObservable(maybeObservableOrPromise)) { - finalObs = maybeObservableOrPromise; - } else { - finalObs = Observable.just(maybeObservableOrPromise); - } - return finalObs; - } catch (err) { - return Observable.throw(err); - } - }) - // if none of the throwers throw, wait for last one - .last({ defaultValue: null }) - // then map to the original file - .map(file) - // if err add it to the file - // and return file - .catch(err => { - file.error = err; - return Observable.just(file); - }); - })); + }); } diff --git a/client/rechallenge/transformers.js b/client/rechallenge/transformers.js index ab578f7ec9..7f7c944e7e 100644 --- a/client/rechallenge/transformers.js +++ b/client/rechallenge/transformers.js @@ -1,3 +1,9 @@ +import cond from 'lodash/cond'; +import identity from 'lodash/identity'; +import matchesProperty from 'lodash/matchesProperty'; +import stubTrue from 'lodash/stubTrue'; +import conforms from 'lodash/conforms'; + import * as babel from 'babel-core'; import presetEs2015 from 'babel-preset-es2015'; import presetReact from 'babel-preset-react'; @@ -6,7 +12,11 @@ import { Observable } from 'rx'; import loopProtect from 'loop-protect'; /* eslint-enable import/no-unresolved */ -import { updateContents } from '../../common/utils/polyvinyl'; +import { + transformHeadTailAndContents, + setContent +} from '../../common/utils/polyvinyl.js'; +import castToObservable from '../../common/app/utils/cast-to-observable.js'; const babelOptions = { presets: [ presetEs2015, presetReact ] }; loopProtect.hit = function hit(line) { @@ -18,67 +28,81 @@ loopProtect.hit = function hit(line) { throw new Error(err); }; -const transformersForHtmlJS = { - ext: /html|js/, - transformers: [ - { - name: 'add-loop-protect', - transformer: function addLoopProtect(file) { - const _contents = file.contents.toLowerCase(); - if (file.ext === 'html' && _contents.indexOf('`) - .shareReplay(); - - scriptCache.set(src, script$); - return script$; -} - -const linkCache = new Map(); -export function cacheLink({ link } = {}, crossDomain = true) { - if (!link) { - return Observable.throw(new Error('No source provided for link')); - } - if (linkCache.has(link)) { - return linkCache.get(link); - } - const link$ = ajax$({ url: link, crossDomain }) - .doOnNext(res => { - if (res.status !== 200) { - throw new Error('Request errror: ' + res.status); - } - }) - .map(({ response }) => response) - .map(script => ``) - .catch(() => Observable.just('')) - .shareReplay(); - - linkCache.set(link, link$); - return link$; -} - -const htmlCatch = '\n'; -const jsCatch = '\n;/*fcc*/\n'; -// we add a cache breaker to prevent browser from caching ajax request -const frameRunner = cacheScript({ - src: `/js/frame-runner.js?cacheBreaker=${Math.random()}` }, - false -); - export function buildClassic(files, required, shouldProxyConsole) { const finalRequires = [...globalRequires, ...required ]; return createFileStream(files) - ::throwers() - ::transformers() - // createbuild - .flatMap(file$ => file$.reduce((build, file) => { - let finalFile; - const finalContents = [ - file.head, - file.contents, - file.tail - ].map( - // if shouldProxyConsole then we change instances of console log - // to `window.__console.log` - // this let's us tap into logging into the console. - // currently we only do this to the main window and not the test window - source => shouldProxyConsole ? useConsoleLogProxy(source) : source - ); - if (file.ext === 'js') { - finalFile = setExt('html', updateContents( - ``, - file - )); - } else if (file.ext === 'css') { - finalFile = setExt('html', updateContents( - ``, - file - )); - } else { - finalFile = file; - } - return build + finalFile.contents + htmlCatch; - }, '')) - // add required scripts and links here - .flatMap(source => { - const head$ = Observable.from(finalRequires) - .flatMap(required => { - if (required.src) { - return cacheScript(required, required.crossDomain); - } - // css files with `url(...` may not work in style tags - // so we put them in raw links - if (required.link && required.raw) { - return Observable.just( - `` - ); - } - if (required.link) { - return cacheLink(required, required.crossDomain); - } - return Observable.just(''); - }) - .reduce((head, required) => head + required, '') - .map(head => `${head}`); - - return Observable.combineLatest(head$, frameRunner) - .map(([ head, frameRunner ]) => { - const body = ` - - - ${source} - - `; - return { - build: head + body + frameRunner, - source, - head - }; - }); - }); + ::pipe(throwers) + ::pipe(applyTransformers) + ::pipe(shouldProxyConsole ? proxyLoggerTransformer : identity) + ::pipe(jsToHtml) + ::pipe(cssToHtml) + ::concactHtml(finalRequires, frameRunner); } export function buildBackendChallenge(state) { const { solution: url } = getValues(state.form.BackEndChallenge); - return Observable.combineLatest(frameRunner, cacheScript(jQuery)) + return Observable.combineLatest( + fetchScript(frameRunner), + fetchScript(jQuery) + ) .map(([ frameRunner, jQuery ]) => ({ build: jQuery + frameRunner, source: { url }, diff --git a/client/utils/fetch-and-cache.js b/client/utils/fetch-and-cache.js new file mode 100644 index 0000000000..5bc6ce1039 --- /dev/null +++ b/client/utils/fetch-and-cache.js @@ -0,0 +1,74 @@ +import { Observable } from 'rx'; +import { ajax$ } from '../../common/utils/ajax-stream'; + +// value used to break browser ajax caching +const cacheBreakerValue = Math.random(); + +export function _fetchScript( + { + src, + cacheBreaker = false, + crossDomain = true + } = {}, +) { + if (!src) { + throw new Error('No source provided for script'); + } + if (this.cache.has(src)) { + return this.cache.get(src); + } + const url = cacheBreaker ? + `${src}?cacheBreaker=${cacheBreakerValue}` : + src; + const script = ajax$({ url, crossDomain }) + .doOnNext(res => { + if (res.status !== 200) { + throw new Error('Request errror: ' + res.status); + } + }) + .map(({ response }) => response) + .map(script => ``) + .shareReplay(); + + this.cache.set(src, script); + return script; +} +export const fetchScript = _fetchScript.bind({ cache: new Map() }); + +export function _fetchLink( + { + link: href, + raw = false, + crossDomain = true + } = {}, +) { + if (!href) { + return Observable.throw(new Error('No source provided for link')); + } + if (this.cache.has(href)) { + return this.cache.get(href); + } + // css files with `url(...` may not work in style tags + // so we put them in raw links + if (raw) { + const link = Observable.just(``) + .shareReplay(); + this.cache.set(href, link); + return link; + } + const link = ajax$({ url: href, crossDomain }) + .doOnNext(res => { + if (res.status !== 200) { + throw new Error('Request error: ' + res.status); + } + }) + .map(({ response }) => response) + .map(script => ``) + .catch(() => Observable.just('')) + .shareReplay(); + + this.cache.set(href, link); + return link; +} + +export const fetchLink = _fetchLink.bind({ cache: new Map() }); diff --git a/common/app/routes/challenges/redux/actions.js b/common/app/routes/challenges/redux/actions.js index 1b98cb6ba8..ca411382b3 100644 --- a/common/app/routes/challenges/redux/actions.js +++ b/common/app/routes/challenges/redux/actions.js @@ -1,5 +1,5 @@ import { createAction } from 'redux-actions'; -import { updateContents } from '../../../../utils/polyvinyl'; +import { setContent } from '../../../../utils/polyvinyl'; import { getMouse, loggerToStr } from '../utils'; import types from './types'; @@ -65,7 +65,7 @@ export const clearFilter = createAction(types.clearFilter); // files export const updateFile = createAction( types.updateFile, - (content, file) => updateContents(content, file) + (content, file) => setContent(content, file) ); export const updateFiles = createAction(types.updateFiles); diff --git a/common/app/utils/cast-to-observable.js b/common/app/utils/cast-to-observable.js new file mode 100644 index 0000000000..46e0e6063b --- /dev/null +++ b/common/app/utils/cast-to-observable.js @@ -0,0 +1,11 @@ +import { Observable, helpers } from 'rx'; + +export default function castToObservable(maybe) { + if (Observable.isObservable(maybe)) { + return maybe; + } + if (helpers.isPromise(maybe)) { + return Observable.fromPromise(maybe); + } + return Observable.of(maybe); +} diff --git a/common/utils/polyvinyl.js b/common/utils/polyvinyl.js index c47e56e17c..68fdf8f723 100644 --- a/common/utils/polyvinyl.js +++ b/common/utils/polyvinyl.js @@ -1,7 +1,32 @@ -// originally base off of https://github.com/gulpjs/vinyl +// originally based off of https://github.com/gulpjs/vinyl import invariant from 'invariant'; +import { Observable } from 'rx'; +import castToObservable from '../app/utils/cast-to-observable.js'; + + +// createFileStream( +// files: Dictionary[Path, PolyVinyl] +// ) => Observable[...Observable[...PolyVinyl]] +export function createFileStream(files = {}) { + return Observable.of( + Observable.from(Object.keys(files).map(key => files[key])) + ); +} + +// Observable::pipe( +// project( +// file: PolyVinyl +// ) => PolyVinyl|Observable[PolyVinyl]|Promise[PolyVinyl] +// ) => Observable[...Observable[...PolyVinyl]] +export function pipe(project) { + const source = this; + return source.map( + files => files.flatMap(file => castToObservable(project(file))) + ); +} // interface PolyVinyl { +// source: String, // contents: String, // name: String, // ext: String, @@ -10,9 +35,9 @@ import invariant from 'invariant'; // head: String, // tail: String, // history: [...String], -// error: Null|Object +// error: Null|Object|Error // } -// + // createPoly({ // name: String, // ext: String, @@ -80,15 +105,18 @@ export function isEmpty(poly) { return !!poly.contents; } -// updateContents(contents: String, poly: PolyVinyl) => PolyVinyl -export function updateContents(contents, poly) { +// setContent(contents: String, poly: PolyVinyl) => PolyVinyl +// setContent will loose source if set +export function setContent(contents, poly) { checkPoly(poly); return { ...poly, - contents + contents, + source: null }; } +// setExt(contents: String, poly: PolyVinyl) => PolyVinyl export function setExt(ext, poly) { checkPoly(poly); const newPoly = { @@ -101,6 +129,7 @@ export function setExt(ext, poly) { return newPoly; } +// setName(contents: String, poly: PolyVinyl) => PolyVinyl export function setName(name, poly) { checkPoly(poly); const newPoly = { @@ -113,6 +142,7 @@ export function setName(name, poly) { return newPoly; } +// setError(contents: String, poly: PolyVinyl) => PolyVinyl export function setError(error, poly) { invariant( typeof error === 'object', @@ -125,3 +155,54 @@ export function setError(error, poly) { error }; } + +// clearHeadTail(poly: PolyVinyl) => PolyVinyl +export function clearHeadTail(poly) { + checkPoly(poly); + return { + ...poly, + head: '', + tail: '' + }; +} + +// compileHeadTail(contents: String, poly: PolyVinyl) => PolyVinyl +export function compileHeadTail(padding = '', poly) { + return clearHeadTail(setContent( + [ poly.head, poly.contents, poly.tail ].join(padding), + poly + )); +} + +// transformContents( +// wrap: (contents: String) => String, +// poly: PolyVinyl +// ) => PolyVinyl +// transformContents will keep a copy of the original +// code in the `source` property. If the original polyvinyl +// already contains a source, this version will continue as +// the source property +export function transformContents(wrap, poly) { + const newPoly = setContent( + wrap(poly.contents), + poly + ); + // if no source exist, set the original contents as source + newPoly.source = poly.contents || poly.contents; + return newPoly; +} + +// transformHeadTailAndContents( +// wrap: (source: String) => String, +// poly: PolyVinyl +// ) => PolyVinyl +export function transformHeadTailAndContents(wrap, poly) { + return { + ...setContent( + wrap(poly.contents), + poly + ), + head: wrap(poly.head), + tail: wrap(poly.tail) + }; +}