diff --git a/src/logic/__tests__/helpers.js b/src/logic/__tests__/helpers.js index 1816e07d98..a3c6534ae1 100644 --- a/src/logic/__tests__/helpers.js +++ b/src/logic/__tests__/helpers.js @@ -1,8 +1,11 @@ import noop from 'lodash-es/noop'; +import reduce from 'lodash-es/reduce'; import {createLogicMiddleware} from 'redux-logic'; import configureStore from 'redux-mock-store'; import {first} from 'rxjs/operators'; +import rootReducer from '../../reducers'; + export function makeTestLogic(logic) { return async (action, {state = {}, afterDispatch = noop} = {}) => { const logicMiddleware = createLogicMiddleware([logic]); @@ -25,3 +28,11 @@ export function makeTestLogic(logic) { return dispatch; }; } + +export function applyActions(...actions) { + return reduce( + actions, + (state, action) => rootReducer(state, action), + undefined, + ); +} diff --git a/src/logic/__tests__/startAccountMigration.test.js b/src/logic/__tests__/startAccountMigration.test.js index 72cff0ed12..6a9fdb7149 100644 --- a/src/logic/__tests__/startAccountMigration.test.js +++ b/src/logic/__tests__/startAccountMigration.test.js @@ -1,5 +1,3 @@ -import reduce from 'lodash-es/reduce'; - import { accountMigrationComplete, accountMigrationError, @@ -10,11 +8,10 @@ import { userAuthenticated, } from '../../actions/user'; import {migrateAccount} from '../../clients/firebase'; -import rootReducer from '../../reducers'; import {bugsnagClient} from '../../util/bugsnag'; import startAccountMigration from '../startAccountMigration'; -import {makeTestLogic} from './helpers'; +import {applyActions, makeTestLogic} from './helpers'; import { credentialFactory, @@ -111,11 +108,3 @@ describe('startAccountMigration', () => { expect(migrateAccount).not.toHaveBeenCalledWith(mockCredential); }); }); - -function applyActions(...actions) { - return reduce( - actions, - (state, action) => rootReducer(state, action), - undefined, - ); -} diff --git a/src/logic/__tests__/validateProject.test.js b/src/logic/__tests__/validateProject.test.js new file mode 100644 index 0000000000..d988410f8e --- /dev/null +++ b/src/logic/__tests__/validateProject.test.js @@ -0,0 +1,78 @@ +import { + projectRestoredFromLastSession as projectRestoredFromLastSessionAction, + snapshotImported as snapshotImportedAction, +} from '../../actions/clients'; +import {validatedSource} from '../../actions/errors'; +import { + changeCurrentProject as changeCurrentProjectAction, + gistImported as gistImportedAction, + toggleLibrary as toggleLibraryAction, + updateProjectSource as updateProjectSourceAction, +} from '../../actions/projects'; +import validateProject from '../validateProject'; + +import {applyActions, makeTestLogic} from './helpers'; + +import {firebaseProjectFactory} from '@factories/data/firebase'; +import {consoleErrorFactory} from '@factories/validations/errors'; + +jest.mock('../../analyzers'); + +const mockCssValidationErrors = [ + consoleErrorFactory.build({ + text: 'You have a starting { but no ending } to go with it.', + }), +]; + +const mockHtmlValidationErrors = [ + consoleErrorFactory.build({ + text: 'Closing tag missing', + }), +]; + +jest.mock('../../validations', () => ({ + css: jest.fn(() => mockCssValidationErrors), + html: jest.fn(() => mockHtmlValidationErrors), + javascript: jest.fn(() => []), +})); + +const testLogic = makeTestLogic(validateProject); +for (const action of [ + changeCurrentProjectAction, + gistImportedAction, + snapshotImportedAction, + projectRestoredFromLastSessionAction, + toggleLibraryAction, +]) { + test(`validates current project on ${action}`, async () => { + const mockProject = firebaseProjectFactory.build(); + const state = applyActions( + projectRestoredFromLastSessionAction(mockProject), + ); + + const dispatch = await testLogic(action(mockProject.projectKey), { + state, + }); + + expect(dispatch).toHaveBeenCalledWith( + validatedSource('html', mockHtmlValidationErrors), + ); + expect(dispatch).toHaveBeenCalledWith( + validatedSource('css', mockCssValidationErrors), + ); + }); +} + +test('UPDATE_PROJECT_SOURCE should validate newSource', async () => { + const mockProject = firebaseProjectFactory.build(); + const state = applyActions(projectRestoredFromLastSessionAction(mockProject)); + + const dispatch = await testLogic( + updateProjectSourceAction(mockProject.projectKey, 'css', 'div {'), + {state}, + ); + + expect(dispatch).toHaveBeenCalledWith( + validatedSource('css', mockCssValidationErrors), + ); +}); diff --git a/src/logic/index.js b/src/logic/index.js index 1801a0d7fb..d923dc0be3 100644 --- a/src/logic/index.js +++ b/src/logic/index.js @@ -6,14 +6,16 @@ import projectSuccessfullySaved from './projectSuccessfullySaved'; import saveProject from './saveProject'; import startAccountMigration from './startAccountMigration'; import unlinkGithubIdentity from './unlinkGithubIdentity'; +import validateProject from './validateProject'; export default [ instrumentApplicationLoaded, instrumentEnvironmentReady, linkGithubIdentity, logout, - startAccountMigration, - unlinkGithubIdentity, projectSuccessfullySaved, saveProject, + startAccountMigration, + unlinkGithubIdentity, + validateProject, ]; diff --git a/src/logic/validateProject.js b/src/logic/validateProject.js new file mode 100644 index 0000000000..ebd6a617b3 --- /dev/null +++ b/src/logic/validateProject.js @@ -0,0 +1,79 @@ +import map from 'lodash-es/map'; +import {createLogic} from 'redux-logic'; + +import {validatedSource} from '../actions/errors'; +import Analyzer from '../analyzers'; +import {getCurrentProject} from '../selectors'; +import retryingFailedImports from '../util/retryingFailedImports'; + +function importValidations() { + return retryingFailedImports(() => + import( + /* webpackChunkName: "mainAsync" */ + '../validations' + ), + ); +} + +async function validateSource({language, source, projectAttributes}, dispatch) { + const validations = await importValidations(); + const validate = validations[language]; + const validationErrors = await validate(source, projectAttributes); + dispatch(validatedSource(language, validationErrors)); +} + +async function validateSources({sources, projectAttributes}, dispatch) { + const validatePromises = map(Reflect.ownKeys(sources), language => + validateSource( + { + language, + source: sources[language], + projectAttributes, + }, + dispatch, + ), + ); + + await Promise.all(validatePromises); +} + +export default createLogic({ + type: [ + 'CHANGE_CURRENT_PROJECT', + 'GIST_IMPORTED', + 'SNAPSHOT_IMPORTED', + 'PROJECT_RESTORED_FROM_LAST_SESSION', + 'TOGGLE_LIBRARY', + 'UPDATE_PROJECT_SOURCE', + ], + latest: true, + async process( + { + getState, + action: { + type, + payload: {language, newValue}, + }, + }, + dispatch, + done, + ) { + const state = getState(); + const currentProject = getCurrentProject(state); + const projectAttributes = new Analyzer(currentProject); + + if (type === 'UPDATE_PROJECT_SOURCE') { + await validateSource( + {language, source: newValue, projectAttributes}, + dispatch, + ); + } else { + await validateSources( + {sources: currentProject.sources, projectAttributes}, + dispatch, + ); + } + + done(); + }, +}); diff --git a/src/sagas/errors.js b/src/sagas/errors.js deleted file mode 100644 index 806b71e94f..0000000000 --- a/src/sagas/errors.js +++ /dev/null @@ -1,80 +0,0 @@ -import { - all, - call, - cancel, - fork, - join, - put, - select, - takeEvery, -} from 'redux-saga/effects'; -import Analyzer from '../analyzers'; -import {getCurrentProject} from '../selectors'; -import {validatedSource} from '../actions/errors'; -import retryingFailedImports from '../util/retryingFailedImports'; - -export async function importValidations() { - return retryingFailedImports(() => - import( - /* webpackChunkName: "mainAsync" */ - '../validations' - ), - ); -} - -export function* toggleLibrary(tasks) { - yield call(validateCurrentProject, tasks); -} - -export function* updateProjectSource(tasks, {payload: {language, newValue}}) { - const state = yield select(); - const analyzer = new Analyzer(getCurrentProject(state)); - yield call(validateSource, tasks, { - payload: {language, source: newValue, projectAttributes: analyzer}, - }); -} - -export function* validateCurrentProject(tasks) { - const state = yield select(); - const currentProject = getCurrentProject(state); - const analyzer = new Analyzer(currentProject); - - for (const language of Reflect.ownKeys(currentProject.sources)) { - const source = currentProject.sources[language]; - yield fork(validateSource, tasks, { - payload: {language, source, projectAttributes: analyzer}, - }); - } -} - -export function* validateSource( - tasks, - {payload: {language, source, projectAttributes}}, -) { - if (tasks.has(language)) { - yield cancel(tasks.get(language)); - } - const validations = yield call(importValidations); - const task = yield fork(validations[language], source, projectAttributes); - tasks.set(language, task); - const validationErrors = yield join(task); - tasks.delete(language); - yield put(validatedSource(language, validationErrors)); -} - -export default function* errors() { - const tasks = new Map(); - - yield all([ - takeEvery('CHANGE_CURRENT_PROJECT', validateCurrentProject, tasks), - takeEvery('GIST_IMPORTED', validateCurrentProject, tasks), - takeEvery('SNAPSHOT_IMPORTED', validateCurrentProject, tasks), - takeEvery( - 'PROJECT_RESTORED_FROM_LAST_SESSION', - validateCurrentProject, - tasks, - ), - takeEvery('UPDATE_PROJECT_SOURCE', updateProjectSource, tasks), - takeEvery('TOGGLE_LIBRARY', toggleLibrary, tasks), - ]); -} diff --git a/src/sagas/index.js b/src/sagas/index.js index 1d261d02dd..6f33946e53 100644 --- a/src/sagas/index.js +++ b/src/sagas/index.js @@ -1,7 +1,6 @@ import {all} from 'redux-saga/effects'; import manageUserState from './manageUserState'; -import watchErrors from './errors'; import watchProjects from './projects'; import watchUi from './ui'; import watchClients from './clients'; @@ -10,7 +9,6 @@ import watchCompiledProjects from './compiledProjects'; export default function* rootSaga() { yield all([ manageUserState(), - watchErrors(), watchProjects(), watchUi(), watchClients(), diff --git a/test/unit/sagas/errors.js b/test/unit/sagas/errors.js deleted file mode 100644 index 5814930728..0000000000 --- a/test/unit/sagas/errors.js +++ /dev/null @@ -1,137 +0,0 @@ -import test from 'tape-catch'; -import isEqual from 'lodash-es/isEqual'; -import {createMockTask} from '@redux-saga/testing-utils'; -import {testSaga} from 'redux-saga-test-plan'; -import Scenario from '../../helpers/Scenario'; -import {javascript} from '../../../src/validations'; -import { - toggleLibrary, - updateProjectSource, -} from '../../../src/actions/projects'; -import {validatedSource} from '../../../src/actions/errors'; -import { - importValidations, - toggleLibrary as toggleLibrarySaga, - updateProjectSource as updateProjectSourceSaga, - validateCurrentProject as validateCurrentProjectSaga, - validateSource as validateSourceSaga, -} from '../../../src/sagas/errors'; - -test('validateCurrentProject()', assert => { - const tasks = new Map(); - const scenario = new Scenario(); - let selector; - assert.ok(isEqual(scenario.analyzer, scenario.analyzer)); - const saga = testSaga(validateCurrentProjectSaga, tasks) - .next() - .inspect(effect => { - assert.equals(effect.type, 'SELECT', 'invokes select effect'); - ({selector} = effect.payload); - }); - - const args = [selector(scenario.state)]; - for (const language of ['html', 'css', 'javascript']) { - saga.next(args.shift()).fork(validateSourceSaga, tasks, { - payload: { - language, - source: scenario.project.getIn(['sources', language]), - projectAttributes: scenario.analyzer, - }, - }); - } - saga.next().isDone(); - - assert.end(); -}); - -test('validateSource()', t => { - const projectAttributes = {containsExternalScript: false}; - const language = 'javascript'; - const source = 'alert("hi");'; - const errors = [{error: 'test'}]; - const action = {payload: {language, source, projectAttributes}}; - - t.test('validation completes', assert => { - const tasks = new Map(); - const task = createMockTask(); - testSaga(validateSourceSaga, tasks, action) - .next() - .call(importValidations) - .next({javascript}) - .fork(javascript, source, projectAttributes) - .next(task) - .join(task) - .next(errors) - .put(validatedSource(language, errors)) - .next() - .isDone(); - assert.end(); - }); - - t.test('another validation initiated', assert => { - const tasks = new Map(); - const firstTask = createMockTask(); - const secondTask = createMockTask(); - testSaga(validateSourceSaga, tasks, action) - .next() - .call(importValidations) - .next({javascript}) - .fork(javascript, source, projectAttributes) - .next(firstTask) - .join(firstTask); - - testSaga(validateSourceSaga, tasks, action) - .next() - .cancel(firstTask) - .next() - .call(importValidations) - .next({javascript}) - .fork(javascript, source, projectAttributes) - .next(secondTask) - .join(secondTask) - .next(errors) - .put(validatedSource(language, errors)) - .next() - .isDone(); - - assert.end(); - }); -}); - -test('updateProjectSource()', assert => { - const tasks = new Map(); - const scenario = new Scenario(); - const language = 'javascript'; - const source = 'alert("hi");'; - testSaga( - updateProjectSourceSaga, - tasks, - updateProjectSource(scenario.projectKey, language, source), - ) - .next() - .select() - .next(scenario.state) - .call(validateSourceSaga, tasks, { - payload: {language, source, projectAttributes: scenario.analyzer}, - }) - .next() - .isDone(); - - assert.end(); -}); - -test('toggleLibrary()', assert => { - const tasks = new Map(); - const scenario = new Scenario(); - testSaga( - toggleLibrarySaga, - tasks, - toggleLibrary(scenario.projectKey, 'jquery'), - ) - .next() - .call(validateCurrentProjectSaga, tasks) - .next() - .isDone(); - - assert.end(); -});