diff --git a/src/logic/__tests__/helpers.js b/src/logic/__tests__/helpers.js index a3c6534ae1..1816e07d98 100644 --- a/src/logic/__tests__/helpers.js +++ b/src/logic/__tests__/helpers.js @@ -1,11 +1,8 @@ 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]); @@ -28,11 +25,3 @@ 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 6a9fdb7149..72cff0ed12 100644 --- a/src/logic/__tests__/startAccountMigration.test.js +++ b/src/logic/__tests__/startAccountMigration.test.js @@ -1,3 +1,5 @@ +import reduce from 'lodash-es/reduce'; + import { accountMigrationComplete, accountMigrationError, @@ -8,10 +10,11 @@ import { userAuthenticated, } from '../../actions/user'; import {migrateAccount} from '../../clients/firebase'; +import rootReducer from '../../reducers'; import {bugsnagClient} from '../../util/bugsnag'; import startAccountMigration from '../startAccountMigration'; -import {applyActions, makeTestLogic} from './helpers'; +import {makeTestLogic} from './helpers'; import { credentialFactory, @@ -108,3 +111,11 @@ 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 deleted file mode 100644 index d988410f8e..0000000000 --- a/src/logic/__tests__/validateProject.test.js +++ /dev/null @@ -1,78 +0,0 @@ -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 d923dc0be3..1801a0d7fb 100644 --- a/src/logic/index.js +++ b/src/logic/index.js @@ -6,16 +6,14 @@ 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, - projectSuccessfullySaved, - saveProject, startAccountMigration, unlinkGithubIdentity, - validateProject, + projectSuccessfullySaved, + saveProject, ]; diff --git a/src/logic/validateProject.js b/src/logic/validateProject.js deleted file mode 100644 index ebd6a617b3..0000000000 --- a/src/logic/validateProject.js +++ /dev/null @@ -1,79 +0,0 @@ -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 new file mode 100644 index 0000000000..806b71e94f --- /dev/null +++ b/src/sagas/errors.js @@ -0,0 +1,80 @@ +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 6f33946e53..1d261d02dd 100644 --- a/src/sagas/index.js +++ b/src/sagas/index.js @@ -1,6 +1,7 @@ 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'; @@ -9,6 +10,7 @@ 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 new file mode 100644 index 0000000000..5814930728 --- /dev/null +++ b/test/unit/sagas/errors.js @@ -0,0 +1,137 @@ +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(); +});