diff --git a/.therapist.yml b/.therapist.yml
new file mode 100644
index 00000000..5e8a26cb
--- /dev/null
+++ b/.therapist.yml
@@ -0,0 +1,8 @@
+actions:
+ flake8:
+ run: flake8 {files}
+ include: "*.py"
+ exclude: "docs/"
+ eslint:
+ run: ./node_modules/.bin/eslint {files}
+ include: "*.js"
diff --git a/Dockerfile b/Dockerfile
index 8ab52af0..e86e45ce 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,8 +23,7 @@ RUN ./node_modules/.bin/webpack && \
mkdir -p __version__ && \
mkdir -p /test_artifacts && \
chmod 777 /test_artifacts && \
- git rev-parse HEAD > __version__/commit && \
- rm -rf .git
+ git rev-parse HEAD > __version__/commit
USER app
ENV DJANGO_SETTINGS_MODULE=normandy.settings \
diff --git a/README.md b/README.md
index 4a75f938..aa263559 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Normandy
+# Normandy [![CircleCI](https://img.shields.io/circleci/project/mozilla/normandy.svg)](https://circleci.com/gh/mozilla/normandy/tree/master)
Normandy is the server-side implementation of the [Recipe Server][]. It serves
bundles of JavaScript to various clients (Firefox browsers) based on certain
diff --git a/bin/ci/contract-tests.sh b/bin/ci/contract-tests.sh
new file mode 100755
index 00000000..ba5b7179
--- /dev/null
+++ b/bin/ci/contract-tests.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+set -u
+BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+echo $BASE_DIR
+cd $BASE_DIR
+
+echo "Setting up Normandy"
+createdb normandy
+./docker-run.sh -i -t ./manage.py migrate
+./docker-run.sh -i -t ./manage.py update_actions
+echo "Starting Normandy server"
+SERVER_ID=$(./docker-run.sh -e DJANGO_CONFIGURATION=ProductionInsecure -d)
+
+docker run --net host -e CHECK_PORT=8000 -e CHECK_HOST=localhost giorgos/takis
+echo "Running acceptance tests"
+./docker-run.sh py.test contract-tests/ \
+ --server http://localhost:8000 \
+ --junitxml=/test_artifacts/pytest.xml
+STATUS=$?
+
+docker kill $SERVER_ID
+exit $STATUS
diff --git a/bin/deploy/dockerhub.sh b/bin/ci/deploy-dockerhub.sh
similarity index 100%
rename from bin/deploy/dockerhub.sh
rename to bin/ci/deploy-dockerhub.sh
diff --git a/bin/ci/docker-run.sh b/bin/ci/docker-run.sh
new file mode 100755
index 00000000..c18d91c6
--- /dev/null
+++ b/bin/ci/docker-run.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+
+set -x
+
+DOCKER_ARGS=( )
+
+if [[ -v CIRCLE_TEST_REPORTS ]]; then
+ DOCKER_ARGS+=(--volume $CIRCLE_TEST_REPORTS:/test_artifacts)
+fi
+
+if [[ -f ~/cache/GeoLite2-Country.mmdb ]]; then
+ DOCKER_ARGS+=(--volume ~/cache/GeoLite2-Country.mmdb:/app/GeoLite2-Country.mmdb)
+fi
+
+# Parse out known command flags to give to `docker run` instead of the command
+# After hitting the first unrecognized argument, assume everything else it the
+# command to run
+while [ $# -ge 1 ]; do
+ case $1 in
+ --)
+ shift
+ break
+ ;;
+ -d|-i|-t)
+ DOCKER_ARGS+=($1)
+ shift
+ ;;
+ -p|-e)
+ DOCKER_ARGS+=($1 $2)
+ shift
+ shift
+ ;;
+ *)
+ break
+ ;;
+ esac
+done
+
+docker run \
+ --env DJANGO_CONFIGURATION=Test \
+ --net host \
+ "${DOCKER_ARGS[@]}" \
+ normandy:build \
+ "$@"
diff --git a/bin/karma-ci.js b/bin/ci/karma-ci.js
similarity index 79%
rename from bin/karma-ci.js
rename to bin/ci/karma-ci.js
index 2ab10741..57d76085 100644
--- a/bin/karma-ci.js
+++ b/bin/ci/karma-ci.js
@@ -1,7 +1,8 @@
+/* eslint-disable no-var, prefer-template */
var karma = require('karma');
var karmaConfig = require('karma/lib/config');
-var config = karmaConfig.parseConfig(__dirname + '/../karma.conf.js', {
+var config = karmaConfig.parseConfig(__dirname + '/../../karma.conf.js', {
browsers: [],
oneShot: true,
reporters: ['spec', 'junit'],
diff --git a/bin/runsslserver.sh b/bin/runsslserver.sh
index 75030f63..d75ae7d6 100755
--- a/bin/runsslserver.sh
+++ b/bin/runsslserver.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
# This script starts an SSL development server so that you don't have to
# pass annoying arguments to manage.py all the time.
BASE_DIR="$(dirname "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )")"
diff --git a/circle.yml b/circle.yml
index 9d15cd7e..1f7aa83a 100644
--- a/circle.yml
+++ b/circle.yml
@@ -13,6 +13,8 @@ dependencies:
- if [ -e ~/cache/docker/image.tar ]; then echo "Loading image.tar"; docker load -i ~/cache/docker/image.tar || rm ~/cache/docker/image.tar; fi
# build image
- docker build -t normandy:build .
+ # write the sha256 sum to an artifact to make image verification easier
+ - docker inspect -f '{{.Config.Image}}' normandy:build | tee $CIRCLE_ARTIFACTS/docker-image-shasum256.txt
# Get MaxMind GeoIP database
- cd ~/cache/ && ~/normandy/bin/download_geolite2.sh
@@ -20,39 +22,29 @@ test:
pre:
- chmod -R 777 $CIRCLE_TEST_REPORTS
override:
- # Run Python lint checks
- - >
- docker run --net host
- -v $CIRCLE_TEST_REPORTS:/test_artifacts
- normandy:build flake8 --output-file /test_artifacts/flake8.txt normandy
- # Run JS lint checks
- - >
- docker run --net host
- -v $CIRCLE_TEST_REPORTS:/test_artifacts
- normandy:build node_modules/.bin/eslint normandy --format junit --output-file /test_artifacts/eslint.xml
+ # Run lint checks
+ - bin/ci/docker-run.sh therapist run --use-tracked-files
# Run Python tests
- - >
- docker run --net host -e DJANGO_CONFIGURATION=Test
- -v $CIRCLE_TEST_REPORTS:/test_artifacts
- -v ~/cache/GeoLite2-Country.mmdb:/app/GeoLite2-Country.mmdb
- normandy:build py.test --junitxml=/test_artifacts/pytest.xml
+ - bin/ci/docker-run.sh py.test --junitxml=/test_artifacts/pytest.xml normandy/
# Start Karma test server, and run them in Firefox
- >
(
+ echo Waiting for Karma server to start;
docker run --net host -e CHECK_PORT=9876 -e CHECK_HOST=localhost giorgos/takis;
echo Starting Firefox;
firefox localhost:9876
) &
- docker run --net host -p 9876:9876
- -v $CIRCLE_TEST_REPORTS:/test_artifacts
- normandy:build node bin/karma-ci.js
+ bin/ci/docker-run.sh -p 9876:9876 node bin/ci/karma-ci.js
+ # Start the app, and run acceptance tests
+ - bin/ci/contract-tests.sh
+
post:
- # Save test artifacts for Python and Lint tests
- - >
- docker run -v $CIRCLE_TEST_REPORTS:/test_artifacts
- normandy:build flake8_junit /test_artifacts/flake8.txt /test_artifacts/flake8.xml
# Clean up old image and save the new one
- - mkdir -p ~/cache/docker; test '!' -e ~/cache/docker/image.tar || rm ~/cache/docker/image.tar; docker save normandy:build > ~/cache/docker/image.tar; ls -l ~/cache/docker
+ - >
+ mkdir -p ~/cache/docker;
+ rm -f ~/cache/docker/image.tar;
+ docker save normandy:build > ~/cache/docker/image.tar;
+ ls -l ~/cache/docker
# appropriately tag and push the container to dockerhub
deployment:
@@ -61,7 +53,7 @@ deployment:
commands:
# set DOCKER_DEPLOY=true in Circle UI to do deploys
- "${DOCKER_DEPLOY:-false}"
- - bin/deploy/dockerhub.sh latest
+ - bin/ci/deploy-dockerhub.sh latest
tags:
# push all tags
@@ -69,4 +61,4 @@ deployment:
commands:
# set DOCKER_DEPLOY=true in Circle UI to do deploys
- "${DOCKER_DEPLOY:-false}"
- - bin/deploy/dockerhub.sh "$CIRCLE_TAG"
+ - bin/ci/deploy-dockerhub.sh "$CIRCLE_TAG"
diff --git a/normandy/recipes/static/actions/console-log/index.js b/client/actions/console-log/index.js
similarity index 100%
rename from normandy/recipes/static/actions/console-log/index.js
rename to client/actions/console-log/index.js
diff --git a/normandy/recipes/static/actions/console-log/package.json b/client/actions/console-log/package.json
similarity index 100%
rename from normandy/recipes/static/actions/console-log/package.json
rename to client/actions/console-log/package.json
diff --git a/normandy/recipes/static/actions/show-heartbeat/index.js b/client/actions/show-heartbeat/index.js
similarity index 100%
rename from normandy/recipes/static/actions/show-heartbeat/index.js
rename to client/actions/show-heartbeat/index.js
diff --git a/normandy/recipes/static/actions/show-heartbeat/package.json b/client/actions/show-heartbeat/package.json
similarity index 100%
rename from normandy/recipes/static/actions/show-heartbeat/package.json
rename to client/actions/show-heartbeat/package.json
diff --git a/normandy/control/tests/.eslintrc b/client/actions/tests/.eslintrc
similarity index 100%
rename from normandy/control/tests/.eslintrc
rename to client/actions/tests/.eslintrc
diff --git a/normandy/recipes/tests/actions/console-log.js b/client/actions/tests/console-log.js
similarity index 85%
rename from normandy/recipes/tests/actions/console-log.js
rename to client/actions/tests/console-log.js
index 725b2c29..4848c6dc 100644
--- a/normandy/recipes/tests/actions/console-log.js
+++ b/client/actions/tests/console-log.js
@@ -1,5 +1,5 @@
import { mockNormandy } from './utils';
-import ConsoleLogAction from '../../static/actions/console-log/index';
+import ConsoleLogAction from '../console-log/';
describe('ConsoleLogAction', () => {
diff --git a/normandy/recipes/tests/actions/index.js b/client/actions/tests/index.js
similarity index 100%
rename from normandy/recipes/tests/actions/index.js
rename to client/actions/tests/index.js
diff --git a/normandy/recipes/tests/actions/show-heartbeat.js b/client/actions/tests/show-heartbeat.js
similarity index 99%
rename from normandy/recipes/tests/actions/show-heartbeat.js
rename to client/actions/tests/show-heartbeat.js
index fa70f60b..9edadf51 100644
--- a/normandy/recipes/tests/actions/show-heartbeat.js
+++ b/client/actions/tests/show-heartbeat.js
@@ -1,5 +1,5 @@
import { mockNormandy, pluginFactory } from './utils';
-import ShowHeartbeatAction from '../../static/actions/show-heartbeat/index';
+import ShowHeartbeatAction from '../show-heartbeat/';
function surveyFactory(props = {}) {
diff --git a/normandy/recipes/tests/actions/utils.js b/client/actions/tests/utils.js
similarity index 100%
rename from normandy/recipes/tests/actions/utils.js
rename to client/actions/tests/utils.js
diff --git a/normandy/recipes/static/actions/utils.js b/client/actions/utils.js
similarity index 100%
rename from normandy/recipes/static/actions/utils.js
rename to client/actions/utils.js
diff --git a/normandy/control/static/control/js/actions/ControlActions.js b/client/control/actions/ControlActions.js
similarity index 98%
rename from normandy/control/static/control/js/actions/ControlActions.js
rename to client/control/actions/ControlActions.js
index f093c52b..544b7e86 100644
--- a/normandy/control/static/control/js/actions/ControlActions.js
+++ b/client/control/actions/ControlActions.js
@@ -95,6 +95,8 @@ const apiRequestMap = {
settings: {
method: 'DELETE',
},
+ successNotification: 'Recipe deleted.',
+ errorNotification: 'Error deleting recipe.',
};
},
};
diff --git a/normandy/control/static/control/js/app.js b/client/control/app.js
similarity index 100%
rename from normandy/control/static/control/js/app.js
rename to client/control/app.js
diff --git a/normandy/control/static/control/js/components/ActionForm.js b/client/control/components/ActionForm.js
similarity index 94%
rename from normandy/control/static/control/js/components/ActionForm.js
rename to client/control/components/ActionForm.js
index ee31b0bf..acf04094 100644
--- a/normandy/control/static/control/js/components/ActionForm.js
+++ b/client/control/components/ActionForm.js
@@ -35,7 +35,7 @@ export default reduxForm({
break;
default:
- throw new Error(`Unexpected action name: "${name}"`);
+ throw new Error(`Unexpected action name: "${props.name}"`);
}
if (props.recipe && props.recipe.action === props.name) {
diff --git a/client/control/components/ControlApp.js b/client/control/components/ControlApp.js
new file mode 100644
index 00000000..c05367e4
--- /dev/null
+++ b/client/control/components/ControlApp.js
@@ -0,0 +1,28 @@
+import React, { PropTypes as pt } from 'react';
+import Header from './Header.js';
+import Notifications from './Notifications.js';
+
+export default function ControlApp({ children, location, routes, params }) {
+ return (
+
+
+
+
+ {
+ React.Children.map(children, child => React.cloneElement(child))
+ }
+
+
+ );
+}
+ControlApp.propTypes = {
+ children: pt.object.isRequired,
+ location: pt.object.isRequired,
+ routes: pt.object.isRequired,
+ params: pt.object.isRequired,
+};
diff --git a/normandy/control/static/control/js/components/DeleteRecipe.js b/client/control/components/DeleteRecipe.js
similarity index 100%
rename from normandy/control/static/control/js/components/DeleteRecipe.js
rename to client/control/components/DeleteRecipe.js
diff --git a/normandy/control/static/control/js/components/Header.js b/client/control/components/Header.js
similarity index 100%
rename from normandy/control/static/control/js/components/Header.js
rename to client/control/components/Header.js
diff --git a/normandy/control/static/control/js/components/NoMatch.js b/client/control/components/NoMatch.js
similarity index 100%
rename from normandy/control/static/control/js/components/NoMatch.js
rename to client/control/components/NoMatch.js
diff --git a/normandy/control/static/control/js/components/Notifications.js b/client/control/components/Notifications.js
similarity index 100%
rename from normandy/control/static/control/js/components/Notifications.js
rename to client/control/components/Notifications.js
diff --git a/normandy/control/static/control/js/components/RecipeContainer.js b/client/control/components/RecipeContainer.js
similarity index 100%
rename from normandy/control/static/control/js/components/RecipeContainer.js
rename to client/control/components/RecipeContainer.js
diff --git a/normandy/control/static/control/js/components/RecipeForm.js b/client/control/components/RecipeForm.js
similarity index 73%
rename from normandy/control/static/control/js/components/RecipeForm.js
rename to client/control/components/RecipeForm.js
index 0a60e5fc..72801c71 100644
--- a/normandy/control/static/control/js/components/RecipeForm.js
+++ b/client/control/components/RecipeForm.js
@@ -1,18 +1,21 @@
import React, { PropTypes as pt } from 'react';
import { Link } from 'react-router';
import { push } from 'react-router-redux';
-import { destroy, reduxForm, getValues } from 'redux-form';
-import jexl from 'jexl';
+import { destroy, stopSubmit, reduxForm, getValues } from 'redux-form';
+import { _ } from 'underscore';
-import { makeApiRequest, recipeUpdated, recipeAdded } from '../actions/ControlActions.js';
+import { makeApiRequest, recipeUpdated, recipeAdded, showNotification }
+ from '../actions/ControlActions.js';
import composeRecipeContainer from './RecipeContainer.js';
import ActionForm from './ActionForm.js';
import CheckboxField from './form_fields/CheckboxField.js';
import FormField from './form_fields/FormFieldWrapper.js';
+import JexlEnvironment from '../../selfrepair/JexlEnvironment.js';
export class RecipeForm extends React.Component {
- propTypes = {
+ static propTypes = {
dispatch: pt.func.isRequired,
+ fields: pt.object.isRequired,
formState: pt.object.isRequired,
recipeId: pt.number.isRequired,
submitting: pt.bool.isRequired,
@@ -52,7 +55,8 @@ export class RecipeForm extends React.Component {
validateForm(formValues) {
const jexlExpression = formValues.filter_expression;
- return jexl.eval(jexlExpression, {});
+ const jexlEnv = new JexlEnvironment({});
+ return jexlEnv.eval(jexlExpression, {});
}
submitForm() {
@@ -65,6 +69,10 @@ export class RecipeForm extends React.Component {
return this.validateForm(combinedFormValues)
.catch(() => {
+ dispatch(showNotification({
+ messageType: 'error',
+ message: 'Recipe cannot be saved. Please correct any errors listed in the form below.',
+ }));
throw {
filter_expression: 'Invalid Expression',
};
@@ -136,7 +144,6 @@ export class RecipeForm extends React.Component {
}
}
RecipeForm.propTypes = {
- fields: React.PropTypes.object.isRequired,
};
export default composeRecipeContainer(reduxForm({
@@ -147,10 +154,43 @@ export default composeRecipeContainer(reduxForm({
? props.location.state.selectedRevision
: null;
+ const formatErrors = payload => {
+ let errors = payload;
+
+ /* If our payload is an object, process each error in the object
+ Otherwise, it is a string and will be returned immediately */
+ if (_.isObject(payload)) {
+ const invalidFields = Object.keys(payload);
+ if (invalidFields.length > 0) {
+ /* If our error keys are integers, it means they correspond
+ to an array field and we want to present errors as an array
+ e.g. { surveys: {0: {title: 'err'}}, {2: {weight: 'err'}} }
+ => { surveys: [{title: 'err'}, null, {weight: 'err'}] } */
+ errors = isNaN(invalidFields[0]) ? {} : [];
+
+ invalidFields.forEach(fieldName => {
+ errors[fieldName] = formatErrors(payload[fieldName]);
+ });
+ }
+ }
+
+ return errors;
+ };
+
+ const onSubmitFail = errors => {
+ const { dispatch } = props;
+ const actionFormErrors = errors.arguments;
+
+ if (actionFormErrors) {
+ dispatch(stopSubmit('action', formatErrors(actionFormErrors)));
+ }
+ };
+
return {
fields,
initialValues: selectedRecipeRevision || props.recipe,
viewingRevision: selectedRecipeRevision || props.location.query.revisionId,
formState: state.form,
+ onSubmitFail,
};
})(RecipeForm));
diff --git a/normandy/control/static/control/js/components/RecipeHistory.js b/client/control/components/RecipeHistory.js
similarity index 56%
rename from normandy/control/static/control/js/components/RecipeHistory.js
rename to client/control/components/RecipeHistory.js
index e2c54a75..c7b0cdd6 100644
--- a/normandy/control/static/control/js/components/RecipeHistory.js
+++ b/client/control/components/RecipeHistory.js
@@ -4,8 +4,8 @@ import moment from 'moment';
import composeRecipeContainer from './RecipeContainer.js';
import { makeApiRequest } from '../actions/ControlActions.js';
-class RecipeHistory extends React.Component {
- propTypes = {
+export class RecipeHistory extends React.Component {
+ static propTypes = {
dispatch: pt.func.isRequired,
recipe: pt.object.isRequired,
recipeId: pt.number.isRequired,
@@ -14,7 +14,7 @@ class RecipeHistory extends React.Component {
constructor(props) {
super(props);
this.state = {
- revisionLog: [],
+ revisions: [],
};
}
@@ -29,32 +29,44 @@ class RecipeHistory extends React.Component {
dispatch(makeApiRequest('fetchRecipeHistory', { recipeId }))
.then(history => {
this.setState({
- revisionLog: history,
+ revisions: history,
});
});
}
render() {
const { recipe, dispatch } = this.props;
- return (
-
-
Viewing revision log for: {recipe ? recipe.name : ''}
-
- {this.state.revisionLog.map(revision =>
-
- )}
-
-
- );
+ const { revisions } = this.state;
+ return ;
}
}
-class HistoryItem extends React.Component {
+export function HistoryList({ recipe, revisions, dispatch }) {
+ return (
+
+
Viewing revision log for: {recipe ? recipe.name : ''}
+
+
+ {revisions.map(revision =>
+
+ )}
+
+
+
+ );
+}
+HistoryList.propTypes = {
+ dispatch: pt.func.isRequired,
+ recipe: pt.object.isRequired,
+ revisions: pt.arrayOf(pt.object).isRequired,
+};
+
+export class HistoryItem extends React.Component {
static propTypes = {
dispatch: pt.func.isRequired,
revision: pt.shape({
@@ -62,6 +74,7 @@ class HistoryItem extends React.Component {
revision_id: pt.number.isRequired,
}).isRequired,
date_created: pt.string.isRequired,
+ comment: pt.string.isRequired,
}).isRequired,
recipe: pt.shape({
revision_id: pt.number.isRequired,
@@ -93,19 +106,25 @@ class HistoryItem extends React.Component {
const isCurrent = revision.recipe.revision_id === recipe.revision_id;
return (
-
- #{revision.recipe.revision_id}
-
+
+ #{revision.recipe.revision_id} |
+
Created On:
{moment(revision.date_created).format('MMM Do YYYY - h:mmA')}
-
- {isCurrent && (
-
-
- Current Revision
-
- )}
-
+ |
+
+ Comment:
+ {revision.comment || '--'}
+ |
+
+ {isCurrent && (
+
+
+ Current Revision
+
+ )}
+ |
+
);
}
}
diff --git a/normandy/control/static/control/js/components/RecipeList.js b/client/control/components/RecipeList.js
similarity index 100%
rename from normandy/control/static/control/js/components/RecipeList.js
rename to client/control/components/RecipeList.js
diff --git a/normandy/control/static/control/js/components/RecipePreview.js b/client/control/components/RecipePreview.js
similarity index 87%
rename from normandy/control/static/control/js/components/RecipePreview.js
rename to client/control/components/RecipePreview.js
index 3302d14b..3abde8b7 100644
--- a/normandy/control/static/control/js/components/RecipePreview.js
+++ b/client/control/components/RecipePreview.js
@@ -1,7 +1,8 @@
import React, { PropTypes as pt } from 'react';
import classNames from 'classnames';
import composeRecipeContainer from './RecipeContainer.js';
-import { runRecipe } from '../../../../../selfrepair/static/js/self_repair_runner.js';
+import { runRecipe } from '../../selfrepair/self_repair_runner.js';
+import NormandyDriver from '../../selfrepair/normandy_driver.js';
class RecipePreview extends React.Component {
propTypes = {
@@ -29,13 +30,15 @@ class RecipePreview extends React.Component {
attemptPreview() {
const { recipe } = this.props;
const { recipeAttempted } = this.state;
+ const driver = new NormandyDriver();
+ driver.registerCallbacks();
if (recipe && !recipeAttempted) {
this.setState({
recipeAttempted: true,
});
- runRecipe(recipe, { testing: true }).then(() => {
+ runRecipe(recipe, driver, { testing: true }).then(() => {
this.setState({
recipeExecuted: true,
});
diff --git a/normandy/control/static/control/js/components/action_forms/ConsoleLogForm.js b/client/control/components/action_forms/ConsoleLogForm.js
similarity index 100%
rename from normandy/control/static/control/js/components/action_forms/ConsoleLogForm.js
rename to client/control/components/action_forms/ConsoleLogForm.js
diff --git a/normandy/control/static/control/js/components/action_forms/HeartbeatForm.js b/client/control/components/action_forms/HeartbeatForm.js
similarity index 77%
rename from normandy/control/static/control/js/components/action_forms/HeartbeatForm.js
rename to client/control/components/action_forms/HeartbeatForm.js
index 485d9b86..e4fd1ea6 100644
--- a/normandy/control/static/control/js/components/action_forms/HeartbeatForm.js
+++ b/client/control/components/action_forms/HeartbeatForm.js
@@ -21,12 +21,11 @@ export const HeartbeatFormFields = [
'surveys[].weight',
];
-const formatLabel = labelName => labelName.replace(/([A-Z])/g, ' $1').toLowerCase();
-
const SurveyListItem = props => {
- const { survey, surveyIndex, isSelected, deleteSurvey, onClick } = props;
+ const { survey, surveyIndex, isSelected, hasErrors, deleteSurvey, onClick } = props;
+
return (
-
+
{survey.title.value || 'Untitled Survey'}
{
const { selectedSurvey, fields, showDefaults } = props;
const surveyObject = selectedSurvey || fields.defaults;
let headerText = 'Default Survey Values';
+ let showAdditionalSurveyFields = false;
let containerClasses = classNames('fluid-8', { active: selectedSurvey });
if (selectedSurvey) {
+ showAdditionalSurveyFields = true;
headerText = selectedSurvey.title.initialValue || 'New survey';
}
@@ -65,16 +67,22 @@ const SurveyForm = props => {
}
{headerText}
- {
- Object.keys(surveyObject).map(fieldName =>
-
- )
+
+ {showAdditionalSurveyFields &&
+
}
+
+
+
+
+
+
+
+
+ {showAdditionalSurveyFields &&
+
+ }
+
);
};
@@ -84,8 +92,8 @@ SurveyForm.propTypes = {
showDefaults: pt.func,
};
-class HeartbeatForm extends React.Component {
- propTypes = {
+export default class HeartbeatForm extends React.Component {
+ static propTypes = {
fields: pt.object.isRequired,
}
@@ -141,6 +149,7 @@ class HeartbeatForm extends React.Component {
survey={survey}
surveyIndex={index}
isSelected={_.isEqual(survey, selectedSurvey)}
+ hasErrors={_.some(survey, field => field.invalid)}
onClick={() => ::this.setSelectedSurvey(survey)}
deleteSurvey={::this.deleteSurvey}
/>
@@ -161,5 +170,3 @@ class HeartbeatForm extends React.Component {
);
}
}
-
-export default HeartbeatForm;
diff --git a/normandy/control/static/control/js/components/form_fields/CheckboxField.js b/client/control/components/form_fields/CheckboxField.js
similarity index 100%
rename from normandy/control/static/control/js/components/form_fields/CheckboxField.js
rename to client/control/components/form_fields/CheckboxField.js
diff --git a/normandy/control/static/control/js/components/form_fields/FormFieldWrapper.js b/client/control/components/form_fields/FormFieldWrapper.js
similarity index 88%
rename from normandy/control/static/control/js/components/form_fields/FormFieldWrapper.js
rename to client/control/components/form_fields/FormFieldWrapper.js
index 0440ba05..d4ee08de 100644
--- a/normandy/control/static/control/js/components/form_fields/FormFieldWrapper.js
+++ b/client/control/components/form_fields/FormFieldWrapper.js
@@ -1,4 +1,5 @@
import React, { PropTypes as pt } from 'react';
+import NumberField from './NumberField.js';
const SelectMenu = props => {
const { options, onChange, field } = props;
@@ -26,6 +27,9 @@ const FormField = props => {
case 'text':
fieldType = ();
break;
+ case 'number':
+ fieldType = ();
+ break;
case 'textarea':
fieldType = ();
break;
@@ -48,5 +52,8 @@ FormField.propTypes = {
field: pt.object.isRequired,
containerClass: pt.string.isRequired,
};
+FormField.defaultProps = {
+ type: 'text',
+};
export default FormField;
diff --git a/client/control/components/form_fields/NumberField.js b/client/control/components/form_fields/NumberField.js
new file mode 100644
index 00000000..5feb4607
--- /dev/null
+++ b/client/control/components/form_fields/NumberField.js
@@ -0,0 +1,42 @@
+import React, { PropTypes as pt } from 'react';
+
+export default class NumberField extends React.Component {
+ static propTypes = {
+ field: pt.object.isRequired,
+ normalize: pt.func,
+ onBlur: pt.func,
+ onChange: pt.func,
+ }
+
+ static defaultProps = {
+ normalize: value => value && parseInt(value, 10),
+ }
+
+ /* Swallow redux-form's onBlur() so it doesn't reset value to string */
+ handleBlur() {
+ if (this.props.onBlur) {
+ this.props.onBlur();
+ }
+ }
+
+ /* Trigger redux-form's onChange() after parsing value to integer */
+ handleChange(event) {
+ const { normalize, field } = this.props;
+ const value = event.target.value;
+
+ field.onChange(normalize(value));
+ }
+
+ render() {
+ const { field } = this.props;
+
+ return (
+
+ );
+ }
+}
diff --git a/normandy/control/static/control/fonts/OpenSans-Bold.woff b/client/control/fonts/OpenSans-Bold.woff
similarity index 100%
rename from normandy/control/static/control/fonts/OpenSans-Bold.woff
rename to client/control/fonts/OpenSans-Bold.woff
diff --git a/normandy/control/static/control/fonts/OpenSans-Light.woff b/client/control/fonts/OpenSans-Light.woff
similarity index 100%
rename from normandy/control/static/control/fonts/OpenSans-Light.woff
rename to client/control/fonts/OpenSans-Light.woff
diff --git a/normandy/control/static/control/fonts/OpenSans-Regular.woff b/client/control/fonts/OpenSans-Regular.woff
similarity index 100%
rename from normandy/control/static/control/fonts/OpenSans-Regular.woff
rename to client/control/fonts/OpenSans-Regular.woff
diff --git a/normandy/control/static/control/fonts/SourceSansPro-Bold.woff b/client/control/fonts/SourceSansPro-Bold.woff
similarity index 100%
rename from normandy/control/static/control/fonts/SourceSansPro-Bold.woff
rename to client/control/fonts/SourceSansPro-Bold.woff
diff --git a/normandy/control/static/control/fonts/SourceSansPro-Light.woff b/client/control/fonts/SourceSansPro-Light.woff
similarity index 100%
rename from normandy/control/static/control/fonts/SourceSansPro-Light.woff
rename to client/control/fonts/SourceSansPro-Light.woff
diff --git a/normandy/control/static/control/fonts/SourceSansPro-Regular.woff b/client/control/fonts/SourceSansPro-Regular.woff
similarity index 100%
rename from normandy/control/static/control/fonts/SourceSansPro-Regular.woff
rename to client/control/fonts/SourceSansPro-Regular.woff
diff --git a/normandy/control/static/control/js/index.js b/client/control/index.js
similarity index 100%
rename from normandy/control/static/control/js/index.js
rename to client/control/index.js
diff --git a/normandy/control/static/control/js/reducers/ControlAppReducer.js b/client/control/reducers/ControlAppReducer.js
similarity index 100%
rename from normandy/control/static/control/js/reducers/ControlAppReducer.js
rename to client/control/reducers/ControlAppReducer.js
diff --git a/normandy/control/static/control/js/routes.js b/client/control/routes.js
similarity index 100%
rename from normandy/control/static/control/js/routes.js
rename to client/control/routes.js
diff --git a/normandy/control/static/control/admin/sass/control.scss b/client/control/sass/control.scss
similarity index 90%
rename from normandy/control/static/control/admin/sass/control.scss
rename to client/control/sass/control.scss
index afd7a52b..f9e5df23 100644
--- a/normandy/control/static/control/admin/sass/control.scss
+++ b/client/control/sass/control.scss
@@ -276,6 +276,20 @@
padding: 5px 3em;
}
+ &.invalid {
+ color: $red;
+ font-weight: 700;
+
+ &:before {
+ border: none;
+ color: $red;
+ content: "\f071"; // error icon, fa-exclamation-triangle
+ font: normal normal 400 11px/1 FontAwesome;
+ left: -1.5em;
+ margin-right: 4px;
+ }
+ }
+
&:before {
border-color: transparent $darkBrown;
border-style: solid;
@@ -316,33 +330,24 @@
/* Recipe History */
.recipe-history {
- .history-item {
- display: flex;
- flex-direction: row;
+ td {
+ text-align: left;
+ }
- .revision-number,
- .revision-created {
- line-height: 20px;
- margin: 0 20px;
- }
+ .revision-number {
+ font-weight: 600;
- .revision-number {
- flex: 0;
- font-weight: 600;
- }
+ /* Force table cell to smallest width */
+ white-space: nowrap;
+ width: 1px;
+ }
- .revision-created {
- flex: 1;
- }
+ .status-indicator {
+ margin: 0 20px; /* Reset .status-indicator margin */
+ }
- .revision-status {
- flex: 1;
- margin: 0 20px; /* Reset .status-indicator margin */
- }
-
- .label {
- padding: 0px;
- }
+ .label {
+ padding: 0px;
}
}
diff --git a/normandy/control/static/control/admin/sass/partials/_colors.scss b/client/control/sass/partials/_colors.scss
similarity index 100%
rename from normandy/control/static/control/admin/sass/partials/_colors.scss
rename to client/control/sass/partials/_colors.scss
diff --git a/normandy/control/static/control/admin/sass/partials/_common.scss b/client/control/sass/partials/_common.scss
similarity index 100%
rename from normandy/control/static/control/admin/sass/partials/_common.scss
rename to client/control/sass/partials/_common.scss
diff --git a/normandy/control/static/control/admin/sass/partials/_fonts.scss b/client/control/sass/partials/_fonts.scss
similarity index 63%
rename from normandy/control/static/control/admin/sass/partials/_fonts.scss
rename to client/control/sass/partials/_fonts.scss
index 2f2ccfe6..37fad3a5 100644
--- a/normandy/control/static/control/admin/sass/partials/_fonts.scss
+++ b/client/control/sass/partials/_fonts.scss
@@ -1,37 +1,37 @@
@font-face {
font-family: 'OpenSans';
font-weight: 300;
- src: url('../../fonts/OpenSans-Light.woff');
+ src: url('../fonts/OpenSans-Light.woff');
}
@font-face {
font-family: 'OpenSans';
font-weight: 400;
- src: url('../../fonts/OpenSans-Regular.woff');
+ src: url('../fonts/OpenSans-Regular.woff');
}
@font-face {
font-family: 'OpenSans';
font-weight: 600;
- src: url('../../fonts/OpenSans-Bold.woff');
+ src: url('../fonts/OpenSans-Bold.woff');
}
@font-face {
font-family: 'Source Sans Pro';
font-weight: 300;
- src: url('../../fonts/SourceSansPro-Light.woff');
+ src: url('../fonts/SourceSansPro-Light.woff');
}
@font-face {
font-family: 'Source Sans Pro';
font-weight: 400;
- src: url('../../fonts/SourceSansPro-Regular.woff');
+ src: url('../fonts/SourceSansPro-Regular.woff');
}
@font-face {
font-family: 'Source Sans Pro';
font-weight: 600;
- src: url('../../fonts/SourceSansPro-Bold.woff');
+ src: url('../fonts/SourceSansPro-Bold.woff');
}
$OpenSans: 'OpenSans', Verdana, Arial, sans-serif;
diff --git a/normandy/control/static/control/admin/sass/partials/_grid.scss b/client/control/sass/partials/_grid.scss
similarity index 100%
rename from normandy/control/static/control/admin/sass/partials/_grid.scss
rename to client/control/sass/partials/_grid.scss
diff --git a/normandy/control/static/control/js/stores/ControlStore.js b/client/control/stores/ControlStore.js
similarity index 100%
rename from normandy/control/static/control/js/stores/ControlStore.js
rename to client/control/stores/ControlStore.js
diff --git a/normandy/recipes/tests/actions/.eslintrc b/client/control/tests/.eslintrc
similarity index 100%
rename from normandy/recipes/tests/actions/.eslintrc
rename to client/control/tests/.eslintrc
diff --git a/normandy/control/tests/actions/controlActions.js b/client/control/tests/actions/controlActions.js
similarity index 85%
rename from normandy/control/tests/actions/controlActions.js
rename to client/control/tests/actions/controlActions.js
index 6e1510f6..c14b32b2 100644
--- a/normandy/control/tests/actions/controlActions.js
+++ b/client/control/tests/actions/controlActions.js
@@ -2,8 +2,8 @@ import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'fetch-mock';
-import { fixtureRecipes, initialState } from '../fixtures/fixtures';
-import * as actionTypes from '../../static/control/js/actions/ControlActions';
+import { fixtureRecipes, initialState } from '../fixtures.js';
+import * as actionTypes from '../../actions/ControlActions.js';
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
@@ -124,4 +124,21 @@ describe('controlApp Actions', () => {
expect(fetchMock.calls('/api/v1/recipe/1/').length).toEqual(1);
});
});
+
+
+ describe('showNotification', () => {
+ it('automatically dismisses notifications after 10 seconds', async () => {
+ jasmine.clock().install();
+
+ const notification = { messageType: 'success', message: 'message' };
+ await store.dispatch(actionTypes.showNotification(notification));
+
+ const dismissAction = actionTypes.dismissNotification(notification.id);
+ expect(store.getActions()).not.toContain(dismissAction);
+ jasmine.clock().tick(10001);
+ expect(store.getActions()).toContain(dismissAction);
+
+ jasmine.clock().uninstall();
+ });
+ });
});
diff --git a/client/control/tests/components/ActionForm.js b/client/control/tests/components/ActionForm.js
new file mode 100644
index 00000000..a1268a1f
--- /dev/null
+++ b/client/control/tests/components/ActionForm.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { Provider } from 'react-redux';
+import controlStore from '../../stores/ControlStore.js';
+
+import ActionForm from '../../components/ActionForm.js';
+
+describe('', () => {
+ let store;
+
+ beforeAll(() => {
+ store = controlStore();
+ });
+
+ it('should work', () => {
+ mount();
+ });
+
+ it('raises an exception with unknown action names', () => {
+ expect(() => {
+ mount();
+ })
+ .toThrow(new Error('Unexpected action name: "does-not-exist"'));
+ });
+
+ it('should show the right action form', () => {
+ const wrapper = mount();
+ expect(wrapper.contains('ConsoleLogForm'));
+ });
+});
diff --git a/normandy/control/tests/components/Notifications.js b/client/control/tests/components/Notifications.js
similarity index 91%
rename from normandy/control/tests/components/Notifications.js
rename to client/control/tests/components/Notifications.js
index 42db46c7..783fb164 100644
--- a/normandy/control/tests/components/Notifications.js
+++ b/client/control/tests/components/Notifications.js
@@ -1,6 +1,6 @@
import React from 'react';
import { shallow } from 'enzyme';
-import { Notifications, Notification } from '../../static/control/js/components/Notifications.js';
+import { Notifications, Notification } from '../../components/Notifications.js';
describe('Notification components', () => {
describe('', () => {
diff --git a/client/control/tests/components/RecipeForm.js b/client/control/tests/components/RecipeForm.js
new file mode 100644
index 00000000..bd123077
--- /dev/null
+++ b/client/control/tests/components/RecipeForm.js
@@ -0,0 +1,31 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import { shallow, mount } from 'enzyme';
+
+import ConnectedRecipeForm, { RecipeForm } from '../../components/RecipeForm.js';
+import controlStore from '../../stores/ControlStore.js';
+
+describe('', () => {
+ beforeAll(() => {
+ });
+
+ it('should work unconnected', () => {
+ shallow( {}}
+ fields={{}}
+ formState={{}}
+ recipeId={0}
+ submitting={false}
+ recipe={{}}
+ handleSubmit={() => {}}
+ viewingRevision={false}
+ />);
+ });
+
+ it('should work connected', () => {
+ const store = controlStore();
+ mount(
+
+ );
+ });
+});
diff --git a/client/control/tests/components/RecipeHistory.js b/client/control/tests/components/RecipeHistory.js
new file mode 100644
index 00000000..32782f94
--- /dev/null
+++ b/client/control/tests/components/RecipeHistory.js
@@ -0,0 +1,58 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { HistoryItem, HistoryList } from '../../components/RecipeHistory.js';
+
+describe('Recipe history components', () => {
+ describe('', () => {
+ it('should render a for each revision', () => {
+ const recipe = { revision_id: 2 };
+ const revision1 = { id: 1 };
+ const revision2 = { id: 2 };
+ const dispatch = () => null;
+ const wrapper = shallow(
+
+ );
+
+ const items = wrapper.find(HistoryItem);
+ expect(items.length).toEqual(2);
+ expect(items.get(0).props.revision).toEqual(revision1);
+ expect(items.get(1).props.revision).toEqual(revision2);
+ });
+ });
+
+ describe('', () => {
+ it('should render the revision info', () => {
+ const dispatch = () => null;
+ const recipe = { revision_id: 1 };
+ const revision = {
+ id: 2,
+ recipe,
+ date_created: '2016-08-10T04:16:58.440Z+00:00',
+ comment: 'test comment',
+ };
+
+ const wrapper = shallow(
+
+ );
+ expect(wrapper.find('.revision-number').text()).toContain(revision.recipe.revision_id);
+ expect(wrapper.find('.revision-comment').text()).toContain(revision.comment);
+ expect(wrapper.find('.status-indicator').text()).toContain('Current Revision');
+ });
+
+ it('should not render the status indicator if the revision is not current', () => {
+ const dispatch = () => null;
+ const recipe = { revision_id: 2 };
+ const revision = {
+ id: 3,
+ recipe: { revision_id: 1 },
+ date_created: '2016-08-10T04:16:58.440Z+00:00',
+ comment: 'test comment',
+ };
+
+ const wrapper = shallow(
+
+ );
+ expect(wrapper.find('.status-indicator').isEmpty()).toEqual(true);
+ });
+ });
+});
diff --git a/client/control/tests/components/action_forms/HeartbeatForm.js b/client/control/tests/components/action_forms/HeartbeatForm.js
new file mode 100644
index 00000000..c2a59118
--- /dev/null
+++ b/client/control/tests/components/action_forms/HeartbeatForm.js
@@ -0,0 +1,14 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import HeartbeatForm from '../../../components/action_forms/HeartbeatForm.js';
+
+
+describe('', () => {
+ it('should work', () => {
+ const fields = {
+ surveys: {},
+ };
+ shallow();
+ });
+});
diff --git a/normandy/control/tests/fixtures/fixtures.js b/client/control/tests/fixtures.js
similarity index 100%
rename from normandy/control/tests/fixtures/fixtures.js
rename to client/control/tests/fixtures.js
diff --git a/normandy/control/tests/index.js b/client/control/tests/index.js
similarity index 100%
rename from normandy/control/tests/index.js
rename to client/control/tests/index.js
diff --git a/normandy/control/tests/reducers/controlAppReducer.js b/client/control/tests/reducers/controlAppReducer.js
similarity index 94%
rename from normandy/control/tests/reducers/controlAppReducer.js
rename to client/control/tests/reducers/controlAppReducer.js
index 3d4acd94..ee9a2e79 100644
--- a/normandy/control/tests/reducers/controlAppReducer.js
+++ b/client/control/tests/reducers/controlAppReducer.js
@@ -1,6 +1,6 @@
-import controlAppReducer from '../../static/control/js/reducers/ControlAppReducer';
-import * as actions from '../../static/control/js/actions/ControlActions';
-import { fixtureRecipes, initialState } from '../fixtures/fixtures';
+import controlAppReducer from '../../reducers/ControlAppReducer';
+import * as actions from '../../actions/ControlActions';
+import { fixtureRecipes, initialState } from '../fixtures.js';
describe('controlApp reducer', () => {
it('should return initial state by default', () => {
diff --git a/normandy/control/tests/routes.js b/client/control/tests/routes.js
similarity index 76%
rename from normandy/control/tests/routes.js
rename to client/control/tests/routes.js
index 6f8bf258..8772474d 100644
--- a/normandy/control/tests/routes.js
+++ b/client/control/tests/routes.js
@@ -1,8 +1,8 @@
import { push } from 'react-router-redux';
import { shallow } from 'enzyme';
-import { createApp } from '../static/control/js/app.js';
-import NoMatch from '../static/control/js/components/NoMatch.js';
+import { createApp } from '../app.js';
+import NoMatch from '../components/NoMatch.js';
describe('Control routes', () => {
let app;
diff --git a/client/selfrepair/JexlEnvironment.js b/client/selfrepair/JexlEnvironment.js
new file mode 100644
index 00000000..450ecfe2
--- /dev/null
+++ b/client/selfrepair/JexlEnvironment.js
@@ -0,0 +1,16 @@
+import { Jexl } from 'jexl';
+import { stableSample } from './utils.js';
+
+export default class JexlEnvironment {
+ constructor(context) {
+ this.context = context;
+ this.jexl = new Jexl();
+ this.jexl.addTransform('date', value => new Date(value));
+ this.jexl.addTransform('stableSample', stableSample);
+ }
+
+ eval(expr) {
+ const oneLineExpr = expr.replace(/\r?\n|\r/g, ' ');
+ return this.jexl.eval(oneLineExpr, this.context);
+ }
+}
diff --git a/normandy/selfrepair/static/js/normandy_driver.js b/client/selfrepair/normandy_driver.js
similarity index 61%
rename from normandy/selfrepair/static/js/normandy_driver.js
rename to client/selfrepair/normandy_driver.js
index fe985338..ff49e9bb 100644
--- a/normandy/selfrepair/static/js/normandy_driver.js
+++ b/client/selfrepair/normandy_driver.js
@@ -4,57 +4,69 @@ import uuid from 'node-uuid';
/**
* Storage class that uses window.localStorage as it's backing store.
- * @param {string} prefix Prefix to append to all incoming keys.
*/
-function LocalStorage(prefix) {
- this.prefix = prefix;
-}
+class LocalStorage {
+ /**
+ * @param {string} prefix Prefix to append to all incoming keys.
+ */
+ constructor(prefix) {
+ this.prefix = prefix;
+ }
-Object.assign(LocalStorage.prototype, {
_makeKey(key) {
return `${this.prefix}-${key}`;
- },
+ }
- getItem(key) {
- return new Promise(resolve => {
- resolve(localStorage.getItem(this._makeKey(key)));
- });
- },
+ async getItem(key) {
+ return localStorage.getItem(this._makeKey(key));
+ }
- setItem(key, value) {
- return new Promise(resolve => {
- localStorage.setItem(this._makeKey(key), value);
- resolve();
- });
- },
-
- removeItem(key) {
- return new Promise(resolve => {
- localStorage.removeItem(this._makeKey(key));
- resolve();
- });
- },
-});
+ async setItem(key, value) {
+ return localStorage.setItem(this._makeKey(key), value);
+ }
+ async removeItem(key) {
+ return localStorage.removeItem(this._makeKey(key));
+ }
+}
/**
* Implementation of the Normandy driver.
*/
-const Normandy = {
- locale: document.documentElement.dataset.locale || navigator.language,
+export default class NormandyDriver {
+ constructor(uitour = Mozilla.UITour) {
+ this._uitour = uitour;
+ }
- _testingOverride: false,
+ _heartbeatCallbacks = [];
+ registerCallbacks() {
+ // Trigger heartbeat callbacks when the UITour tells us that Heartbeat
+ // happened.
+ this._uitour.observe((eventName, data) => {
+ if (eventName.startsWith('Heartbeat:')) {
+ const flowId = data.flowId;
+ const croppedEventName = eventName.slice(10); // Chop off "Heartbeat:"
+ if (flowId in this._heartbeatCallbacks) {
+ this._heartbeatCallbacks[flowId](croppedEventName, data);
+ }
+ }
+ });
+ }
+
+ locale = document.documentElement.dataset.locale || navigator.language;
+
+ _testingOverride = false;
get testing() {
return this._testingOverride || new URL(window.location.href).searchParams.has('testing');
- },
+ }
set testing(value) {
this._testingOverride = value;
- },
+ }
- _location: { countryCode: null },
+ _location = { countryCode: null };
location() {
return Promise.resolve(this._location);
- },
+ }
log(message, level = 'debug') {
if (level === 'debug' && !this.testing) {
@@ -64,15 +76,15 @@ const Normandy = {
} else {
console.log(message);
}
- },
+ }
uuid() {
return uuid.v4();
- },
+ }
createStorage(prefix) {
return new LocalStorage(prefix);
- },
+ }
client() {
return new Promise(resolve => {
@@ -80,7 +92,7 @@ const Normandy = {
plugins: {},
};
- // Populate plugin info.
+ // Populate plugin info.
for (const plugin of navigator.plugins) {
client.plugins[plugin.name] = {
name: plugin.name,
@@ -109,7 +121,7 @@ const Normandy = {
let retrievedConfigs = 0;
const wantedConfigNames = Object.keys(wantedConfigs);
wantedConfigNames.forEach(configName => {
- Mozilla.UITour.getConfiguration(configName, data => {
+ this._uitour.getConfiguration(configName, data => {
wantedConfigs[configName](data);
retrievedConfigs++;
if (retrievedConfigs >= wantedConfigNames.length) {
@@ -118,7 +130,7 @@ const Normandy = {
});
});
});
- },
+ }
saveHeartbeatFlow(data) {
if (this.testing) {
@@ -134,46 +146,32 @@ const Normandy = {
'Content-Type': 'application/json',
},
});
- },
+ }
- heartbeatCallbacks: [],
+ heartbeatCallbacks = [];
showHeartbeat(options) {
return new Promise(resolve => {
const emitter = new EventEmitter();
this.heartbeatCallbacks[options.flowId] = (eventName, data) => emitter.emit(eventName, data);
- // Positional arguments are overridden by the final options
- // argument, but they're still required so we pass them anyway.
- Mozilla.UITour.showHeartbeat(
- options.message,
- options.thanksMessage,
- options.flowId,
- options.postAnswerUrl,
- options.learnMoreMessage,
- options.learnMoreUrl,
+ // Positional arguments are overridden by the final options
+ // argument, but they're still required so we pass them anyway.
+ this._uitour.showHeartbeat(
+ options.message,
+ options.thanksMessage,
+ options.flowId,
+ options.postAnswerUrl,
+ options.learnMoreMessage,
+ options.learnMoreUrl,
{
+ engagementButtonLabel: options.engagementButtonLabel,
surveyId: options.surveyId,
surveyVersion: options.surveyVersion,
testing: options.testing,
}
- );
+ );
resolve(emitter);
});
- },
-};
-
-
-// Trigger heartbeat callbacks when the UITour tells us that Heartbeat
-// happened.
-Mozilla.UITour.observe((eventName, data) => {
- if (eventName.startsWith('Heartbeat:')) {
- const flowId = data.flowId;
- const croppedEventName = eventName.slice(10); // Chop off "Heartbeat:"
- if (flowId in Normandy.heartbeatCallbacks) {
- Normandy.heartbeatCallbacks[flowId](croppedEventName, data);
- }
}
-});
-
-export default Normandy;
+}
diff --git a/normandy/selfrepair/static/js/self_repair.js b/client/selfrepair/self_repair.js
similarity index 63%
rename from normandy/selfrepair/static/js/self_repair.js
rename to client/selfrepair/self_repair.js
index e54c869a..c6319b68 100644
--- a/normandy/selfrepair/static/js/self_repair.js
+++ b/client/selfrepair/self_repair.js
@@ -1,16 +1,19 @@
-import Normandy from './normandy_driver.js';
+import NormandyDriver from './normandy_driver.js';
import { fetchRecipes, filterContext, doesRecipeMatch, runRecipe } from './self_repair_runner.js';
+const driver = new NormandyDriver();
+driver.registerCallbacks();
+
// Actually fetch and run the recipes.
fetchRecipes().then(recipes => {
- filterContext().then(context => {
+ filterContext(driver).then(context => {
// Update Normandy driver with user's country.
- Normandy._location.countryCode = context.normandy.country;
+ driver._location.countryCode = context.normandy.country;
for (const recipe of recipes) {
doesRecipeMatch(recipe, context).then(([, match]) => {
if (match) {
- runRecipe(recipe).catch(err => {
+ runRecipe(recipe, driver).catch(err => {
console.error(err);
});
}
diff --git a/normandy/selfrepair/static/js/self_repair_runner.js b/client/selfrepair/self_repair_runner.js
similarity index 68%
rename from normandy/selfrepair/static/js/self_repair_runner.js
rename to client/selfrepair/self_repair_runner.js
index 5b2d7fb7..682d1db0 100644
--- a/normandy/selfrepair/static/js/self_repair_runner.js
+++ b/client/selfrepair/self_repair_runner.js
@@ -1,10 +1,6 @@
-import Normandy from './normandy_driver.js';
import uuid from 'node-uuid';
-import jexl from 'jexl';
-
-
-jexl.addTransform('date', value => new Date(value));
+import JexlEnvironment from './JexlEnvironment.js';
const registeredActions = {};
window.registerAction = (name, ActionClass) => {
@@ -66,34 +62,27 @@ export function getUserId() {
export function fetchRecipes() {
const { recipeUrl } = document.documentElement.dataset;
const headers = { Accept: 'application/json' };
- const data = { enabled: 'True' };
- return fetch(recipeUrl, { headers, data })
- .then(response => response.json())
- .then(recipes => recipes);
+ return fetch(`${recipeUrl}?enabled=true`, { headers })
+ .then(response => response.json());
}
/**
- * Fetch client information from the Normandy server and the driver.
+ * Fetch client information from the Normandy server.
* @promise Resolves with an object containing client info.
*/
-function classifyClient() {
+export function classifyClient() {
const { classifyUrl } = document.documentElement.dataset;
const headers = { Accept: 'application/json' };
- const classifyXhr = fetch(classifyUrl, { headers })
- .then(response => response.json())
- .then(client => client);
- return Promise.all([classifyXhr, Normandy.client()])
- .then(([classification, client]) => {
- // Parse request time
- classification.request_time = new Date(classification.request_time);
-
- return Object.assign({
- locale: Normandy.locale,
- }, classification, client);
- });
+ return fetch(classifyUrl, { headers })
+ .then(response => response.json())
+ .then(classification => {
+ // Parse request time
+ classification.request_time = new Date(classification.request_time);
+ return classification;
+ });
}
@@ -103,13 +92,13 @@ function classifyClient() {
* @param {Recipe} recipe - Recipe retrieved from the server.
* @promise Resolves once the action has executed.
*/
-export function runRecipe(recipe, options = {}) {
+export function runRecipe(recipe, driver, options = {}) {
return loadAction(recipe).then(Action => {
if (options.testing !== undefined) {
- Normandy.testing = options.testing;
+ driver.testing = options.testing;
}
- return new Action(Normandy, recipe).execute();
+ return new Action(driver, recipe).execute();
});
}
@@ -118,11 +107,18 @@ export function runRecipe(recipe, options = {}) {
* Generate a context object for JEXL filter expressions.
* @return {object}
*/
-export function filterContext() {
- return classifyClient()
- .then(classifiedClient => ({
- normandy: classifiedClient,
- }));
+export async function filterContext(driver) {
+ const classification = await classifyClient();
+ const client = await driver.client();
+
+ return {
+ normandy: {
+ locale: driver.locale,
+ userId: getUserId(),
+ ...client,
+ ...classification,
+ },
+ };
}
@@ -134,9 +130,6 @@ export function filterContext() {
* signifying if the filter passed or failed.
*/
export function doesRecipeMatch(recipe, context) {
- // Remove newlines, which are invalid in JEXL
- const filterExpression = recipe.filterExpression.replace(/\r?\n|\r/g, '');
-
- return jexl.eval(filterExpression, context)
- .then(value => [recipe, !!value]);
+ const jexlEnv = new JexlEnvironment({ recipe, ...context });
+ return jexlEnv.eval(recipe.filter_expression).then(value => [recipe, !!value]);
}
diff --git a/normandy/selfrepair/tests/.eslintrc b/client/selfrepair/tests/.eslintrc
similarity index 100%
rename from normandy/selfrepair/tests/.eslintrc
rename to client/selfrepair/tests/.eslintrc
diff --git a/normandy/selfrepair/tests/index.js b/client/selfrepair/tests/index.js
similarity index 100%
rename from normandy/selfrepair/tests/index.js
rename to client/selfrepair/tests/index.js
diff --git a/client/selfrepair/tests/test_JexlEnvironment.js b/client/selfrepair/tests/test_JexlEnvironment.js
new file mode 100644
index 00000000..72a0b519
--- /dev/null
+++ b/client/selfrepair/tests/test_JexlEnvironment.js
@@ -0,0 +1,49 @@
+import JexlEnvironment from '../JexlEnvironment.js';
+
+describe('JexlEnvironment', () => {
+ let jexlEnv;
+
+ beforeAll(() => {
+ jexlEnv = new JexlEnvironment();
+ });
+
+ it('should pull values from the context', () => {
+ const marker = Symbol();
+ const context = { data: { marker } };
+ jexlEnv = new JexlEnvironment(context);
+ return jexlEnv.eval('data.marker')
+ .then(val => expect(val).toEqual(marker));
+ });
+
+ it('should execute simple expressions', () => (
+ jexlEnv.eval('2+2')
+ .then(val => expect(val).toEqual(4))
+ ));
+
+ it('should execute multiline statements', () => (
+ jexlEnv.eval('1 + 5 *\n8 + 1')
+ .then(val => expect(val).toEqual(42))
+ ));
+
+ it('should have a date filter', () => (
+ jexlEnv.eval('"2016-07-12T00:00:00"|date')
+ .then(val => expect(val).toEqual(new Date(2016, 6, 12)))
+ ));
+
+ describe('stable sample filter', () => {
+ it('should have a stableSample filter', () => (
+ // Expect to not fail
+ jexlEnv.eval('"test"|stableSample(0.5)')
+ ));
+
+ it('should return true for matching samples', () => (
+ jexlEnv.eval('"test"|stableSample(1.0)')
+ .then(val => expect(val).toEqual(true))
+ ));
+
+ it('should return false for matching samples', () => (
+ jexlEnv.eval('"test"|stableSample(0.0)')
+ .then(val => expect(val).toEqual(false))
+ ));
+ });
+});
diff --git a/client/selfrepair/tests/test_normandy_driver.js b/client/selfrepair/tests/test_normandy_driver.js
new file mode 100644
index 00000000..1376ab84
--- /dev/null
+++ b/client/selfrepair/tests/test_normandy_driver.js
@@ -0,0 +1,38 @@
+import NormandyDriver from '../normandy_driver.js';
+
+describe('Normandy Driver', () => {
+ describe('showHeartbeat', () => {
+ it('should pass all the required arguments to the UITour helper', async () => {
+ const uitour = jasmine.createSpyObj('uitour', ['showHeartbeat']);
+ const driver = new NormandyDriver(uitour);
+ const options = {
+ message: 'testMessage',
+ thanksMessage: 'testThanks',
+ flowId: 'testFlowId',
+ postAnswerUrl: 'testPostAnswerUrl',
+ learnMoreMessage: 'testLearnMoreMessage',
+ learnMoreUrl: 'testLearnMoreUrl',
+ engagementButtonLabel: 'testEngagementButtonLabel',
+ surveyId: 'testSurveyId',
+ surveyVersion: 'testSurveyVersion',
+ testing: true,
+ };
+
+ await driver.showHeartbeat(options);
+ expect(uitour.showHeartbeat).toHaveBeenCalledWith(
+ options.message,
+ options.thanksMessage,
+ options.flowId,
+ options.postAnswerUrl,
+ options.learnMoreMessage,
+ options.learnMoreUrl,
+ jasmine.objectContaining({
+ engagementButtonLabel: options.engagementButtonLabel,
+ surveyId: options.surveyId,
+ surveyVersion: options.surveyVersion,
+ testing: options.testing,
+ }),
+ );
+ });
+ });
+});
diff --git a/client/selfrepair/tests/test_self_repair_runner.js b/client/selfrepair/tests/test_self_repair_runner.js
new file mode 100644
index 00000000..21368cb4
--- /dev/null
+++ b/client/selfrepair/tests/test_self_repair_runner.js
@@ -0,0 +1,64 @@
+import fetchMock from 'fetch-mock';
+
+import { mockNormandy } from '../../actions/tests/utils.js';
+import { classifyClient, doesRecipeMatch, filterContext } from '../self_repair_runner.js';
+
+
+const UUID_ISH_REGEX = /^[a-f0-9-]{36}$/;
+
+
+describe('Self-Repair Runner', () => {
+ afterEach(() => {
+ fetchMock.restore();
+ });
+
+ describe('classifyClient', () => {
+ it('should make a request to the normandy server', async () => {
+ const url = '/api/v1/classify/';
+ const requestTime = '2016-01-01';
+ document.documentElement.dataset.classifyUrl = url;
+ fetchMock.mock(url, 'GET', {
+ request_time: requestTime,
+ country: 'US',
+ });
+
+ expect(await classifyClient()).toEqual(jasmine.objectContaining({
+ request_time: new Date(requestTime),
+ country: 'US',
+ }));
+ expect(fetchMock.lastUrl()).toEqual(url);
+ });
+ });
+
+ describe('filterContext', () => {
+ it('should contain a valid user ID', async () => {
+ document.documentElement.dataset.classifyUrl = '/api/v1/classify/';
+ fetchMock.mock('/api/v1/classify/', 'GET', {
+ request_time: '2016-01-01',
+ country: 'US',
+ });
+
+ const driver = mockNormandy();
+ const context = await filterContext(driver);
+ expect(context.normandy.userId).toBeDefined();
+ expect(UUID_ISH_REGEX.test(context.normandy.userId)).toBe(true);
+ });
+ });
+
+ describe('doesRecipeMatch', () => {
+ it('should include the recipe in the filter expression context', async () => {
+ const recipe = {
+ filter_expression: 'recipe.shouldPass',
+ shouldPass: true,
+ };
+
+ let match = await doesRecipeMatch(recipe, {});
+ expect(match[1]).toBe(true);
+
+ // If shouldPass changes, so should the filter expression's result
+ recipe.shouldPass = false;
+ match = await doesRecipeMatch(recipe, {});
+ expect(match[1]).toBe(false);
+ });
+ });
+});
diff --git a/normandy/selfrepair/tests/test_utils.js b/client/selfrepair/tests/test_utils.js
similarity index 75%
rename from normandy/selfrepair/tests/test_utils.js
rename to client/selfrepair/tests/test_utils.js
index 6b393808..026b89d0 100644
--- a/normandy/selfrepair/tests/test_utils.js
+++ b/client/selfrepair/tests/test_utils.js
@@ -1,4 +1,4 @@
-import { fractionToKey, stableSample } from '../static/js/utils';
+import { fractionToKey, stableSample } from '../utils';
function repeatString(char, times) {
let acc = '';
@@ -10,26 +10,26 @@ function repeatString(char, times) {
describe('fractionToKey', () => {
it('should match some known values', () => {
- // quick range check
+ // quick range check
expect(fractionToKey(0 / 4)).toEqual(repeatString('0', 64));
expect(fractionToKey(1 / 4)).toEqual(`4${repeatString('0', 63)}`);
expect(fractionToKey(2 / 4)).toEqual(`8${repeatString('0', 63)}`);
expect(fractionToKey(3 / 4)).toEqual(`c${repeatString('0', 63)}`);
expect(fractionToKey(4 / 4)).toEqual(repeatString('f', 64));
- // Tests leading zeroes
+ // Tests leading zeroes
expect(fractionToKey(1 / 32)).toEqual(`08${repeatString('0', 62)}`);
- // The expected output here is 0.00001 * 2^256, in hex.
+ // The expected output here is 0.00001 * 2^256, in hex.
expect(fractionToKey(0.00001))
- .toEqual('0000a7c5ac471b47880000000000000000000000000000000000000000000000');
+ .toEqual('0000a7c5ac471b47880000000000000000000000000000000000000000000000');
});
it('handles error cases', () => {
const cases = [-1, -0.5, 1.5, 2];
for (const val of cases) {
expect(() => fractionToKey(val))
- .toThrowError(`frac must be between 0 and 1 inclusive (got ${val})`);
+ .toThrowError(`frac must be between 0 and 1 inclusive (got ${val})`);
}
});
@@ -44,7 +44,7 @@ describe('fractionToKey', () => {
describe('stableSample', () => {
it('should match at about the right rate', () => {
- // This test could in theory fail randomly, but I think the probability is pretty good.
+ // This test could in theory fail randomly, but I think the probability is pretty good.
const trials = 1000;
const rate = Math.random();
let hits = 0;
@@ -53,12 +53,12 @@ describe('stableSample', () => {
hits += 1;
}
}
- // 95% accurate
+ // 95% accurate
expect(Math.abs((hits / trials) - rate) < 0.05).toEqual(true);
});
it('should be stable', () => {
- // Make sure that the stable sample returns the same value repeatedly.
+ // Make sure that the stable sample returns the same value repeatedly.
for (let i = 0; i < 100; i++) {
const rate = Math.random();
const val = Math.random();
diff --git a/client/selfrepair/uitour.js b/client/selfrepair/uitour.js
new file mode 100644
index 00000000..7f717fea
--- /dev/null
+++ b/client/selfrepair/uitour.js
@@ -0,0 +1,317 @@
+/* eslint-disable */
+// TODO: Can we pull this from npm?
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// create namespace
+if (typeof Mozilla == 'undefined') {
+ var Mozilla = {};
+}
+
+(function ($) {
+ 'use strict';
+
+ // create namespace
+ if (typeof Mozilla.UITour == 'undefined') {
+ Mozilla.UITour = {};
+ }
+
+ var themeIntervalId = null;
+ function _stopCyclingThemes() {
+ if (themeIntervalId) {
+ clearInterval(themeIntervalId);
+ themeIntervalId = null;
+ }
+ }
+
+ function _sendEvent(action, data) {
+ var event = new CustomEvent('mozUITour', {
+ bubbles: true,
+ detail: {
+ action,
+ data: data || {},
+ },
+ });
+
+ document.dispatchEvent(event);
+ }
+
+ function _generateCallbackID() {
+ return Math.random().toString(36).replace(/[^a-z]+/g, '');
+ }
+
+ function _waitForCallback(callback) {
+ var id = _generateCallbackID();
+
+ function listener(event) {
+ if (typeof event.detail != 'object')
+ return;
+ if (event.detail.callbackID != id)
+ return;
+
+ document.removeEventListener('mozUITourResponse', listener);
+ callback(event.detail.data);
+ }
+ document.addEventListener('mozUITourResponse', listener);
+
+ return id;
+ }
+
+ var notificationListener = null;
+ function _notificationListener(event) {
+ if (typeof event.detail != 'object')
+ return;
+ if (typeof notificationListener != 'function')
+ return;
+
+ notificationListener(event.detail.event, event.detail.params);
+ }
+
+ Mozilla.UITour.DEFAULT_THEME_CYCLE_DELAY = 10 * 1000;
+
+ Mozilla.UITour.CONFIGNAME_SYNC = 'sync';
+ Mozilla.UITour.CONFIGNAME_AVAILABLETARGETS = 'availableTargets';
+
+ Mozilla.UITour.ping = function (callback) {
+ var data = {};
+ if (callback) {
+ data.callbackID = _waitForCallback(callback);
+ }
+ _sendEvent('ping', data);
+ };
+
+ Mozilla.UITour.observe = function (listener, callback) {
+ notificationListener = listener;
+
+ if (listener) {
+ document.addEventListener('mozUITourNotification',
+ _notificationListener);
+ Mozilla.UITour.ping(callback);
+ } else {
+ document.removeEventListener('mozUITourNotification',
+ _notificationListener);
+ }
+ };
+
+ Mozilla.UITour.registerPageID = function (pageID) {
+ _sendEvent('registerPageID', {
+ pageID,
+ });
+ };
+
+ Mozilla.UITour.showHeartbeat = function (message, thankyouMessage, flowId, engagementURL,
+ learnMoreLabel, learnMoreURL, options) {
+ var args = {
+ message,
+ thankyouMessage,
+ flowId,
+ engagementURL,
+ learnMoreLabel,
+ learnMoreURL,
+ };
+
+ if (options) {
+ for (var option in options) {
+ if (!options.hasOwnProperty(option)) {
+ continue;
+ }
+ args[option] = options[option];
+ }
+ }
+
+ _sendEvent('showHeartbeat', args);
+ };
+
+ Mozilla.UITour.showHighlight = function (target, effect) {
+ _sendEvent('showHighlight', {
+ target,
+ effect,
+ });
+ };
+
+ Mozilla.UITour.hideHighlight = function () {
+ _sendEvent('hideHighlight');
+ };
+
+ Mozilla.UITour.showInfo = function (target, title, text, icon, buttons, options) {
+ var buttonData = [];
+ if (Array.isArray(buttons)) {
+ for (var i = 0; i < buttons.length; i++) {
+ buttonData.push({
+ label: buttons[i].label,
+ icon: buttons[i].icon,
+ style: buttons[i].style,
+ callbackID: _waitForCallback(buttons[i].callback),
+ });
+ }
+ }
+
+ var closeButtonCallbackID, targetCallbackID;
+ if (options && options.closeButtonCallback)
+ closeButtonCallbackID = _waitForCallback(options.closeButtonCallback);
+ if (options && options.targetCallback)
+ targetCallbackID = _waitForCallback(options.targetCallback);
+
+ _sendEvent('showInfo', {
+ target,
+ title,
+ text,
+ icon,
+ buttons: buttonData,
+ closeButtonCallbackID,
+ targetCallbackID,
+ });
+ };
+
+ Mozilla.UITour.hideInfo = function () {
+ _sendEvent('hideInfo');
+ };
+
+ Mozilla.UITour.previewTheme = function (theme) {
+ _stopCyclingThemes();
+
+ _sendEvent('previewTheme', {
+ theme: JSON.stringify(theme),
+ });
+ };
+
+ Mozilla.UITour.resetTheme = function () {
+ _stopCyclingThemes();
+
+ _sendEvent('resetTheme');
+ };
+
+ Mozilla.UITour.cycleThemes = function (themes, delay, callback) {
+ _stopCyclingThemes();
+
+ if (!delay) {
+ delay = Mozilla.UITour.DEFAULT_THEME_CYCLE_DELAY;
+ }
+
+ function nextTheme() {
+ var theme = themes.shift();
+ themes.push(theme);
+
+ _sendEvent('previewTheme', {
+ theme: JSON.stringify(theme),
+ state: true,
+ });
+
+ callback(theme);
+ }
+
+ themeIntervalId = setInterval(nextTheme, delay);
+ nextTheme();
+ };
+
+ Mozilla.UITour.showMenu = function (name, callback) {
+ var showCallbackID;
+ if (callback)
+ showCallbackID = _waitForCallback(callback);
+
+ _sendEvent('showMenu', {
+ name,
+ showCallbackID,
+ });
+ };
+
+ Mozilla.UITour.hideMenu = function (name) {
+ _sendEvent('hideMenu', {
+ name,
+ });
+ };
+
+ Mozilla.UITour.getConfiguration = function (configName, callback) {
+ _sendEvent('getConfiguration', {
+ callbackID: _waitForCallback(callback),
+ configuration: configName,
+ });
+ };
+
+ Mozilla.UITour.setConfiguration = function (configName, configValue) {
+ _sendEvent('setConfiguration', {
+ configuration: configName,
+ value: configValue,
+ });
+ };
+
+ Mozilla.UITour.showFirefoxAccounts = function () {
+ _sendEvent('showFirefoxAccounts');
+ };
+
+ Mozilla.UITour.resetFirefox = function () {
+ _sendEvent('resetFirefox');
+ };
+
+ Mozilla.UITour.addNavBarWidget = function (name, callback) {
+ _sendEvent('addNavBarWidget', {
+ name,
+ callbackID: _waitForCallback(callback),
+ });
+ };
+
+ Mozilla.UITour.setDefaultSearchEngine = function (identifier) {
+ _sendEvent('setDefaultSearchEngine', {
+ identifier,
+ });
+ };
+
+ Mozilla.UITour.setTreatmentTag = function (name, value) {
+ _sendEvent('setTreatmentTag', {
+ name,
+ value,
+ });
+ };
+
+ Mozilla.UITour.getTreatmentTag = function (name, callback) {
+ _sendEvent('getTreatmentTag', {
+ name,
+ callbackID: _waitForCallback(callback),
+ });
+ };
+
+ Mozilla.UITour.setSearchTerm = function (term) {
+ _sendEvent('setSearchTerm', {
+ term,
+ });
+ };
+
+ Mozilla.UITour.openSearchPanel = function (callback) {
+ _sendEvent('openSearchPanel', {
+ callbackID: _waitForCallback(callback),
+ });
+ };
+
+ Mozilla.UITour.forceShowReaderIcon = function () {
+ _sendEvent('forceShowReaderIcon');
+ };
+
+ Mozilla.UITour.toggleReaderMode = function () {
+ _sendEvent('toggleReaderMode');
+ };
+
+ Mozilla.UITour.openPreferences = function (pane) {
+ _sendEvent('openPreferences', {
+ pane,
+ });
+ };
+
+ /**
+ * Closes the tab where this code is running. As usual, if the tab is in the
+ * foreground, the tab that was displayed before is selected.
+ *
+ * The last tab in the current window will never be closed, in which case
+ * this call will have no effect. The calling code is expected to take an
+ * action after a small timeout in order to handle this case, for example by
+ * displaying a goodbye message or a button to restart the tour.
+ */
+ Mozilla.UITour.closeTab = function () {
+ _sendEvent('closeTab');
+ };
+})();
+
+// Make this library Require-able.
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = Mozilla;
+}
diff --git a/normandy/selfrepair/static/js/utils.js b/client/selfrepair/utils.js
similarity index 77%
rename from normandy/selfrepair/static/js/utils.js
rename to client/selfrepair/utils.js
index dbdc6ad1..cd975a68 100644
--- a/normandy/selfrepair/static/js/utils.js
+++ b/client/selfrepair/utils.js
@@ -7,8 +7,8 @@ import Sha256 from 'sha.js/sha256';
* characters.
*/
export function fractionToKey(frac) {
- // SHA 256 hashes are 64-digit hexadecimal numbers. The largest possible SHA
- // 256 hash is 2^256 - 1.
+ // SHA 256 hashes are 64-digit hexadecimal numbers. The largest possible SHA
+ // 256 hash is 2^256 - 1.
if (frac < 0 || frac > 1) {
throw new Error(`frac must be between 0 and 1 inclusive (got ${frac})`);
@@ -17,14 +17,15 @@ export function fractionToKey(frac) {
const mult = 2 ** 256 - 1;
const inDecimal = Math.floor(frac * mult);
let hexDigits = inDecimal.toString(16);
+
+ // Left pad with zeroes
+ // If N zeroes are needed, generate an array of nulls N+1 elements long,
+ // and inserts zeroes between each null.
if (hexDigits.length < 64) {
- // Left pad with zeroes
- // If N zeroes are needed, generate an array of nulls N+1 elements long,
- // and inserts zeroes between each null.
hexDigits = Array(64 - hexDigits.length + 1).join('0') + hexDigits;
}
- // Saturate at 2**256 - 1
+ // Saturate at 2**256 - 1
if (hexDigits.length > 64) {
hexDigits = Array(65).join('f');
}
diff --git a/contract-tests/conftest.py b/contract-tests/conftest.py
new file mode 100644
index 00000000..570daee3
--- /dev/null
+++ b/contract-tests/conftest.py
@@ -0,0 +1,31 @@
+# Configuration file for running contract-tests
+import pytest
+import requests
+
+
+def pytest_addoption(parser):
+ parser.addoption(
+ "--server",
+ dest="server",
+ default="http://localhost:8000",
+ help="Server to run tests against"
+ )
+ parser.addoption(
+ "--no-verify",
+ action="store_false",
+ dest="verify",
+ default=None,
+ help="Don't verify SSL certs"
+ )
+
+
+@pytest.fixture
+def conf(request):
+ return request.config
+
+
+@pytest.fixture
+def requests_session(conf):
+ session = requests.Session()
+ session.verify = conf.getoption('verify')
+ return session
diff --git a/contract-tests/test_api.py b/contract-tests/test_api.py
new file mode 100644
index 00000000..71d45154
--- /dev/null
+++ b/contract-tests/test_api.py
@@ -0,0 +1,75 @@
+import jsonschema
+import pytest
+
+
+def test_expected_action_types(conf, requests_session):
+ r = requests_session.get(conf.getoption('server') + '/api/v1/action/')
+ response = r.json()
+
+ # Verify we have at least one response and then grab the first record
+ assert len(response) >= 1
+
+ # Make sure we only have expected action types
+ expected_records = ['console-log', 'show-heartbeat']
+
+ for record in response:
+ assert record['name'] in expected_records
+
+
+def test_console_log(conf, requests_session):
+ r = requests_session.get(conf.getoption('server') + '/api/v1/action/')
+ response = r.json()
+
+ # Verify we have at least one response and then grab the first record
+ assert len(response) >= 1
+
+ # Look for any console-log actions
+ cl_records = [record for record in response if record['name'] == 'console-log']
+
+ if len(cl_records) == 0:
+ pytest.skip('No console-log actions found')
+ return
+
+ record = cl_records[0]
+ # Does an 'action' have all the required fields?
+ expected_action_fields = [
+ 'name',
+ 'implementation_url',
+ 'arguments_schema'
+ ]
+ for field in record:
+ assert field in expected_action_fields
+
+ # Do we have a valid schema for 'arguments_schema'?
+ r = requests_session.get(record['arguments_schema']['$schema'])
+ schema = r.json()
+ assert jsonschema.validate(record['arguments_schema'], schema) is None
+
+
+def test_show_heartbeat(conf, requests_session):
+ r = requests_session.get(conf.getoption('server') + '/api/v1/action')
+ response = r.json()
+
+ # Verify we have at least one response and then grab the first record
+ assert len(response) >= 1
+
+ # Let's find at least one record that is a 'show-heartbeat'
+ sh_records = [record for record in response if record['name'] == 'show-heartbeat']
+
+ if len(sh_records) == 0:
+ pytest.skip('No show-heartbeat actions found')
+ return
+
+ record = sh_records[0]
+ expected_action_fields = [
+ 'name',
+ 'implementation_url',
+ 'arguments_schema'
+ ]
+ for field in record:
+ assert field in expected_action_fields
+
+ # Do we have a valid schema for 'arguments_schema'?
+ r = requests_session.get(record['arguments_schema']['$schema'])
+ schema = r.json()
+ assert jsonschema.validate(record['arguments_schema'], schema) is None
diff --git a/docs/dev/api-tests.rst b/docs/dev/api-tests.rst
new file mode 100644
index 00000000..87ef1b37
--- /dev/null
+++ b/docs/dev/api-tests.rst
@@ -0,0 +1,13 @@
+API Contract Tests
+==================
+
+These tests are designed to look for changes to the recipe server API that are
+not expected.
+
+To run these tests, use the following command from the root project directory.
+
+.. code-block:: bash
+
+ py.test --env= contract-tests/
+
+where ```` is one of `dev`, `stage`, or `prod`
diff --git a/docs/dev/install.rst b/docs/dev/install.rst
index f548234e..8cbc185b 100644
--- a/docs/dev/install.rst
+++ b/docs/dev/install.rst
@@ -57,14 +57,20 @@ Installation
:ref:`pip-install-error`
How to troubleshoot errors during ``pip install``.
-4. Install frontend dependencies and build the frontend code using npm:
+4. Install pre-commit tools (optional)
+
+ .. code-block:: bash
+
+ therapist install
+
+5. Install frontend dependencies and build the frontend code using npm:
.. code-block:: bash
npm install
npm run build
-5. Create a Postgres database for Normandy. By default it is assumed to be named
+6. Create a Postgres database for Normandy. By default it is assumed to be named
``normandy``:
.. code-block:: bash
@@ -82,25 +88,18 @@ Installation
DATABASE_URL=postgres://username:password@server_addr/database_name
-6. Initialize your database by running the migrations:
+7. Initialize your database by running the migrations:
.. code-block:: bash
python manage.py migrate
-7. Create a new superuser account:
+8. Create a new superuser account:
.. code-block:: bash
python manage.py createsuperuser
-8. Pull the latest data on Firefox releases and supported locales with the
- ``update_product_details`` command:
-
- .. code-block:: bash
-
- python manage.py update_product_details
-
9. Pull the latest geolocation database using the ``download_geolite2.sh``
script:
@@ -108,12 +107,11 @@ Installation
./bin/download_geolite2.sh
-10. Add some useful initial data to your database using the ``initial_data``
- command:
+10. Load actions into the database:
.. code-block:: bash
- python manage.py initial_data
+ python manage.py update_actions
Once you've finished these steps, you should be able to start the site by
running:
diff --git a/docs/dev/workflow.rst b/docs/dev/workflow.rst
index 9afb862c..06befbe1 100644
--- a/docs/dev/workflow.rst
+++ b/docs/dev/workflow.rst
@@ -40,8 +40,8 @@ steps, as they don't affect your setup if nothing has changed:
# Run database migrations.
python manage.py migrate
- # Add any new initial data (does not duplicate data).
- python manage.py initial_data
+ # Add any new action data (does not duplicate data).
+ python manage.py update_actions
# Build frontend files
./node_modules/.bin/webpack --config ./webpack.config.js --update-actions
diff --git a/docs/index.rst b/docs/index.rst
index fbcd68d1..0a078af4 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -5,15 +5,36 @@ JavaScript to various clients based on certain rules.
.. _SHIELD: https://wiki.mozilla.org/Firefox/SHIELD
+User Documentation
+------------------
+.. toctree::
+ :maxdepth: 2
+
+ user/filter_expressions
+
+Developer Documentation
+-----------------------
.. toctree::
:maxdepth: 2
dev/install
dev/workflow
dev/concepts
- qa/example
dev/self-repair
dev/driver
dev/troubleshooting
- qa/docker
+
+QA Documentation
+----------------
+.. toctree::
+ :maxdepth: 2
+
+ qa/example
+ qa/setup
+
+Operations Documentation
+------------------------
+.. toctree::
+ :maxdepth: 2
+
ops/config
diff --git a/docs/qa/setup.rst b/docs/qa/setup.rst
index 1d5cc85b..cd0c86cd 100644
--- a/docs/qa/setup.rst
+++ b/docs/qa/setup.rst
@@ -47,7 +47,7 @@ Starting the server, and initial setup
.. code-block:: shell
$ docker-compose run normandy ./manage.py migrate
- $ docker-compose run normandy ./manage.py initial_data
+ $ docker-compose run normandy ./manage.py update_actions
$ docker-compose run normandy ./manage.py createsuperuser
4. Open the site. If you are using Docker Machine, get your VM's IP with
diff --git a/docs/user/filter_expressions.rst b/docs/user/filter_expressions.rst
new file mode 100644
index 00000000..d2e9063a
--- /dev/null
+++ b/docs/user/filter_expressions.rst
@@ -0,0 +1,239 @@
+Filter Expressions
+==================
+Filter expressions describe which users a :ref:`recipe ` should be
+executed for. They're executed locally in the client's browser and, if they
+pass, the corresponding recipe is executed. Filter expressions have access to
+information about the user, such as their location, locale, and Firefox version.
+
+Filter expressions are written using a language called JEXL_. JEXL is an
+open-source expression language that is given a context (in this case,
+information about the user's browser) and evaluates a statement using that
+context. JEXL stands for "JavaScript Expression Language" and uses JavaScript
+syntax for several (but not all) of its features.
+
+.. note:: The rest of this document includes examples of JEXL syntax that has
+ comments inline with the expressions. JEXL does **not** have any support for
+ comments in statements, but we're using them to make understanding our
+ examples easier.
+
+.. _JEXL: https://github.com/TechnologyAdvice/Jexl
+
+JEXL Basics
+-----------
+The `JEXL Readme`_ describes the syntax of the language in detail; the following
+section covers the basics of writing valid JEXL expressions.
+
+.. note:: Normally, JEXL doesn't allow newlines or other whitespace besides
+ spaces in expressions, but filter expressions in Normandy allow arbitrary
+ whitespace.
+
+A JEXL expression evaluates down to a single value. JEXL supports several basic
+types, such as numbers, strings (single or double quoted), and booleans. JEXL
+also supports several operators for combining values, such as arithmetic,
+boolean operators, comparisons, and string concatenation.
+
+.. code-block:: javascript
+
+ // Arithmetic
+ 2 + 2 - 3 // == 1
+
+ // Numerical comparisons
+ 5 > 7 // == false
+
+ // Boolean operators
+ false || 5 > 4 // == true
+
+ // String concatenation
+ "Mozilla" + " " + "Firefox" // == "Mozilla Firefox"
+
+Expressions can be grouped using parenthesis:
+
+.. code-block:: javascript
+
+ ((2 + 3) * 3) - 3 // == 7
+
+JEXL also supports lists and objects (known as dictionaries in other languages)
+as well as attribute access:
+
+.. code-block:: javascript
+
+ [1, 2, 1].length // == 3
+ {foo: 1, bar: 2}.foo // == 1
+
+Unlike JavaScript, JEXL supports an ``in`` operator for checking if a substring
+is in a string or if an element is in an array:
+
+.. code-block:: javascript
+
+ "bar" in "foobarbaz" // == true
+ 3 in [1, 2, 3, 4] // == true
+
+The context passed to JEXL can be expressed using identifiers, which also
+support attribute access:
+
+.. code-block:: javascript
+
+ normandy.locale == 'en-US' // == true if the client's locale is en-US
+
+Another unique feature of JEXL is transforms, which modify the value given to
+them. Transforms are applied to a value using the ``|`` operator, and may take
+additional arguments passed in the expression:
+
+.. code-block:: javascript
+
+ '1980-01-07'|date // == a date object
+
+.. _JEXL Readme: https://github.com/TechnologyAdvice/Jexl#jexl---
+
+Context
+-------
+This section defines the context passed to filter expressions when they are
+evaluated. In other words, this is the client information available within
+filter expressions.
+
+.. js:data:: normandy
+
+ The ``normandy`` object contains general information about the client.
+
+.. js:attribute:: normandy.version
+
+ **Example:** ``'47.0.1'``
+
+ String containing the user's Firefox version.
+
+.. js:attribute:: normandy.channel
+
+ String containing the update channel. Valid values include, but are not
+ limited to:
+
+ * ``'release'``
+ * ``'aurora'``
+ * ``'beta'``
+ * ``'nightly'``
+ * ``'default'`` (self-built or automated testing builds)
+
+.. js:attribute:: normandy.isDefaultBrowser
+
+ Boolean specifying whether Firefox is set as the user's default browser.
+
+.. js:attribute:: normandy.searchEngine
+
+ **Example:** ``'google'``
+
+ String containing the user's default search engine identifier.
+
+.. js:attribute:: normandy.syncSetup
+
+ Boolean containing whether the user has set up Firefox Sync.
+
+.. js:attribute:: normandy.plugins
+
+ An object mapping of plugin names to :js:class:`Plugin` objects describing
+ the plugins installed on the client.
+
+.. js:attribute:: normandy.locale
+
+ **Example:** ``'en-US'``
+
+ String containing the user's locale.
+
+.. js:attribute:: normandy.country
+
+ **Example:** ``'US'``
+
+ `ISO 3166-1 alpha-2`_ country code for the country that the user is located
+ in. This is determined via IP-based geolocation.
+
+ .. _ISO 3166-1 alpha-2: https://en.wikipedia.org/wiki/ISO_3166-1
+
+.. js:attribute:: normandy.request_time
+
+ Date object set to the time and date that the user requested recipes from
+ Normandy. Useful for comparing against date ranges that a recipe is valid
+ for.
+
+ .. code-block:: javascript
+
+ // Do not run recipe after January 1st.
+ normandy.request_time < '2011-01-01'|date
+
+Transforms
+----------
+This section describes the transforms available to filter expressions, and what
+they do. They're documented as functions, and the first parameter to each
+function is the value being transformed.
+
+.. js:function:: stableSample(input, rate)
+
+ Randomly returns ``true`` or ``false`` based on the given sample rate. Used
+ to sample over the set of matched users.
+
+ Sampling with this transform is stable over the input, meaning that the same
+ input and sample rate will always result in the same return value. The most
+ common use is to pass in a unique user ID and a recipe ID as the input; this
+ means that each user will consistently run or not run a recipe.
+
+ Without stable sampling, a user might execute a recipe on Monday, and then
+ not execute it on Tuesday. In addition, without stable sampling, a recipe
+ would be seen by a different percentage of users each day, and over time this
+ would add up such that the recipe is seen by more than the percent sampled.
+
+ :param input:
+ A value for the sample to be stable over.
+ :param number rate:
+ A number between ``0`` and ``1`` with the sample rate. For example,
+ ``0.5`` would be a 50% sample rate.
+
+ .. code-block:: javascript
+
+ // True 50% of the time, stable per-user per-recipe.
+ [normandy.userId, normandy.recipe.id]|stableSample(0.5)
+
+.. js:function:: date(dateString)
+
+ Parses a string as a date and returns a Date object. Date strings should be
+ in `ISO 8601`_ format.
+
+ :param string dateString:
+ String to parse as a date.
+
+ .. code-block:: javascript
+
+ '2011-10-10T14:48:00'|date // == Date object matching the given date
+
+ .. _ISO 8601: https://www.w3.org/TR/NOTE-datetime
+
+Examples
+--------
+This section lists some examples of commonly-used filter expressions.
+
+.. code-block:: javascript
+
+ // Match users using the en-US locale while located in India
+ normandy.locale == 'en-US' && normandy.country == 'IN'
+
+ // Match 10% of users in the fr locale.
+ (
+ normandy.locale == 'fr'
+ && [normandy.userId, normandy.recipe.id]|stableSample(0.1)
+ )
+
+ // Match users in any English locale using Firefox Beta
+ (
+ normandy.locale in ['en-US', 'en-AU', 'en-CA', 'en-GB', 'en-NZ', 'en-ZA']
+ && normandy.channel == 'beta'
+ )
+
+ // Only run the recipe between January 1st, 2011 and January 7th, 2011
+ (
+ normandy.request_time > '2011-01-01T00:00:00+00:00'|date
+ && normandy.request_time < '2011-01-07T00:00:00+00:00'|date
+ )
+
+ // Match users located in the US who have Firefox as their default browser
+ normandy.country == 'US' && normandy.isDefaultBrowser
+
+ // Match users with the Flash plugin installed. If Flash is missing, the
+ // plugin list returns `undefined`, which is a falsy value in JavaScript and
+ // fails the match. Otherwise, it returns a plugin object, which is truthy.
+ normandy.plugins['Shockwave Flash']
diff --git a/karma.conf.js b/karma.conf.js
index 011646e9..a4e0eab6 100644
--- a/karma.conf.js
+++ b/karma.conf.js
@@ -1,29 +1,31 @@
+/* eslint-env node */
+/* eslint-disable no-var, func-names, prefer-arrow-callback, prefer-template */
// Karma configuration
module.exports = function (config) {
var karmaConfig = {
- // base path that will be used to resolve all patterns (eg. files, exclude)
+ // base path that will be used to resolve all patterns (eg. files, exclude)
basePath: '',
- // frameworks to use
- // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
+ // frameworks to use
+ // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
frameworks: ['jasmine'],
- // list of files / patterns to load in the browser
+ // list of files / patterns to load in the browser
files: [
'node_modules/babel-polyfill/dist/polyfill.js',
'node_modules/jasmine-promises/dist/jasmine-promises.js',
- 'normandy/control/tests/index.js',
- 'normandy/recipes/tests/actions/index.js',
- 'normandy/selfrepair/tests/index.js',
+ 'client/control/tests/index.js',
+ 'client/actions/tests/index.js',
+ 'client/selfrepair/tests/index.js',
],
- // preprocess matching files before serving them to the browser
- // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
+ // preprocess matching files before serving them to the browser
+ // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
preprocessors: {
- 'normandy/control/tests/index.js': ['webpack', 'sourcemap'],
- 'normandy/recipes/tests/actions/index.js': ['webpack', 'sourcemap'],
- 'normandy/selfrepair/tests/index.js': ['webpack', 'sourcemap'],
- 'normandy/control/static/control/js/components/*.js': ['react-jsx'],
+ 'client/control/tests/index.js': ['webpack', 'sourcemap'],
+ 'client/selfrepair/tests/index.js': ['webpack', 'sourcemap'],
+ 'client/control/components/*.js': ['react-jsx'],
+ 'client/actions/tests/index.js': ['webpack', 'sourcemap'],
},
webpack: {
@@ -44,37 +46,37 @@ module.exports = function (config) {
},
webpackServer: {
- quiet: true, // Suppress all webpack messages, except errors
+ quiet: true, // Suppress *all* webpack messages, including errors
},
- // test results reporter to use
- // possible values: 'dots', 'progress'
- // available reporters: https://npmjs.org/browse/keyword/karma-reporter
+ // test results reporter to use
+ // possible values: 'dots', 'progress'
+ // available reporters: https://npmjs.org/browse/keyword/karma-reporter
reporters: ['spec'],
- // web server port
+ // web server port
port: 9876,
- // enable / disable colors in the output (reporters and logs)
+ // enable / disable colors in the output (reporters and logs)
colors: true,
- // level of logging
- // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
+ // possible values: config.LOG_DISABLE, config.LOG_ERROR, config.LOG_WARN,
+ // config.LOG_INFO, or config.LOG_DEBUG.
logLevel: config.LOG_INFO,
- // enable / disable watching file and executing tests whenever any file changes
+ // enable / disable watching file and executing tests whenever any file changes
autoWatch: true,
- // start these browsers
- // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
+ // start these browsers
+ // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
browsers: ['Firefox'],
- // Continuous Integration mode
- // if true, Karma captures browsers, runs the tests and exits
+ // Continuous Integration mode
+ // if true, Karma captures browsers, runs the tests and exits
singleRun: false,
- // Concurrency level
- // how many browser should be started simultaneous
+ // Concurrency level
+ // how many browser should be started simultaneous
concurrency: Infinity,
};
diff --git a/normandy/base/management/commands/initial_data.py b/normandy/base/management/commands/initial_data.py
deleted file mode 100644
index 2714f70e..00000000
--- a/normandy/base/management/commands/initial_data.py
+++ /dev/null
@@ -1,34 +0,0 @@
-from django.core.management.base import BaseCommand
-
-from normandy.recipes.models import ReleaseChannel
-
-
-class Command(BaseCommand):
- """
- Adds some helpful initial data to the site's database. If matching
- data already exists, it should _not_ be overwritten, making this
- safe to run multiple times.
-
- This exists instead of data migrations so that test runs do not load
- this data into the test database.
-
- If this file grows too big, we should consider finding a library or
- coming up with a more robust way of adding this data.
- """
- help = 'Adds initial data to database'
-
- def handle(self, *args, **options):
- self.add_release_channels()
-
- def add_release_channels(self):
- self.stdout.write('Adding Release Channels...', ending='')
- channels = {
- 'release': 'Release',
- 'beta': 'Beta',
- 'aurora': 'Developer Edition',
- 'nightly': 'Nightly'
- }
-
- for slug, name in channels.items():
- ReleaseChannel.objects.get_or_create(slug=slug, defaults={'name': name})
- self.stdout.write('Done')
diff --git a/normandy/base/middleware.py b/normandy/base/middleware.py
index fd50c831..289fb261 100644
--- a/normandy/base/middleware.py
+++ b/normandy/base/middleware.py
@@ -1,25 +1,6 @@
-from django.core.urlresolvers import Resolver404, resolve
from django.utils import timezone
-class ShortCircuitMiddleware(object):
- """
- Middleware that skips remaining middleware when a view is marked with
- normandy.base.decorators.short_circuit_middlewares
- """
-
- def process_request(self, request):
- try:
- result = resolve(request.path)
- except Resolver404:
- return
-
- if getattr(result.func, 'short_circuit_middlewares', False):
- return result.func(request, *result.args, **result.kwargs)
- else:
- return None
-
-
class RequestReceivedAtMiddleware(object):
"""
Adds a 'received_at' property to requests with a datetime showing
diff --git a/normandy/base/tests/test_commands.py b/normandy/base/tests/test_commands.py
deleted file mode 100644
index e0352fc9..00000000
--- a/normandy/base/tests/test_commands.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from django.core.management import call_command
-
-import pytest
-
-
-@pytest.mark.django_db
-def test_initial_data():
- """Verify that the initial_data command doesn't throw an error."""
- call_command('initial_data')
diff --git a/normandy/control/__init__.py b/normandy/control/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/normandy/control/migrations/__init__.py b/normandy/control/migrations/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/normandy/control/models.py b/normandy/control/models.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/normandy/control/static/control/js/components/ControlApp.js b/normandy/control/static/control/js/components/ControlApp.js
deleted file mode 100644
index ada389ed..00000000
--- a/normandy/control/static/control/js/components/ControlApp.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import React from 'react';
-import Header from './Header.js';
-import Notifications from './Notifications.js';
-
-export default function ControlApp() {
- return (
-
-
-
-
- {
- React.Children.map(this.props.children, child => React.cloneElement(child))
- }
-
-
- );
-}
diff --git a/normandy/control/templates/control/admin/base.html b/normandy/control/templates/control/admin/base.html
index 6ab06625..24b89566 100644
--- a/normandy/control/templates/control/admin/base.html
+++ b/normandy/control/templates/control/admin/base.html
@@ -26,11 +26,14 @@
{% block page-header %}{% endblock %}
- {% block content %}{% endblock %}
-
+ {% block content %}
+
+ {% endblock %}
- {% render_bundle 'control' 'js' %}
+ {% block javascript %}
+ {% render_bundle 'control' 'js' %}
+ {% endblock %}