зеркало из https://github.com/mozilla/normandy.git
Merge remote-tracking branch 'upstream/master' into signing
This commit is contained in:
Коммит
9b5b5f5876
|
@ -0,0 +1,8 @@
|
|||
actions:
|
||||
flake8:
|
||||
run: flake8 {files}
|
||||
include: "*.py"
|
||||
exclude: "docs/"
|
||||
eslint:
|
||||
run: ./node_modules/.bin/eslint {files}
|
||||
include: "*.js"
|
|
@ -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 \
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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 \
|
||||
"$@"
|
|
@ -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'],
|
|
@ -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 )")"
|
||||
|
|
42
circle.yml
42
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"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { mockNormandy } from './utils';
|
||||
import ConsoleLogAction from '../../static/actions/console-log/index';
|
||||
import ConsoleLogAction from '../console-log/';
|
||||
|
||||
|
||||
describe('ConsoleLogAction', () => {
|
|
@ -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 = {}) {
|
|
@ -95,6 +95,8 @@ const apiRequestMap = {
|
|||
settings: {
|
||||
method: 'DELETE',
|
||||
},
|
||||
successNotification: 'Recipe deleted.',
|
||||
errorNotification: 'Error deleting recipe.',
|
||||
};
|
||||
},
|
||||
};
|
|
@ -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) {
|
|
@ -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 (
|
||||
<div>
|
||||
<Notifications />
|
||||
<Header
|
||||
pageType={children.props.route}
|
||||
currentLocation={location.pathname}
|
||||
routes={routes}
|
||||
params={params}
|
||||
/>
|
||||
<div id="content" className="wrapper">
|
||||
{
|
||||
React.Children.map(children, child => React.cloneElement(child))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
ControlApp.propTypes = {
|
||||
children: pt.object.isRequired,
|
||||
location: pt.object.isRequired,
|
||||
routes: pt.object.isRequired,
|
||||
params: pt.object.isRequired,
|
||||
};
|
|
@ -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));
|
|
@ -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 (
|
||||
<div className="fluid-8 recipe-history">
|
||||
<h3>Viewing revision log for: <b>{recipe ? recipe.name : ''}</b></h3>
|
||||
<ul>
|
||||
{this.state.revisionLog.map(revision =>
|
||||
<HistoryItem
|
||||
key={revision.id}
|
||||
revision={revision}
|
||||
recipe={recipe}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
const { revisions } = this.state;
|
||||
return <HistoryList recipe={recipe} dispatch={dispatch} revisions={revisions} />;
|
||||
}
|
||||
}
|
||||
|
||||
class HistoryItem extends React.Component {
|
||||
export function HistoryList({ recipe, revisions, dispatch }) {
|
||||
return (
|
||||
<div className="fluid-8 recipe-history">
|
||||
<h3>Viewing revision log for: <b>{recipe ? recipe.name : ''}</b></h3>
|
||||
<table>
|
||||
<tbody>
|
||||
{revisions.map(revision =>
|
||||
<HistoryItem
|
||||
key={revision.id}
|
||||
revision={revision}
|
||||
recipe={recipe}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<li className="history-item" onClick={::this.handleClick}>
|
||||
<p className="revision-number">#{revision.recipe.revision_id}</p>
|
||||
<p className="revision-created">
|
||||
<tr className="history-item" onClick={::this.handleClick}>
|
||||
<td className="revision-number">#{revision.recipe.revision_id}</td>
|
||||
<td className="revision-created">
|
||||
<span className="label">Created On:</span>
|
||||
{moment(revision.date_created).format('MMM Do YYYY - h:mmA')}
|
||||
</p>
|
||||
{isCurrent && (
|
||||
<div className="revision-status status-indicator green">
|
||||
<i className="fa fa-circle pre" />
|
||||
Current Revision
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
</td>
|
||||
<td className="revision-comment">
|
||||
<span className="label">Comment:</span>
|
||||
{revision.comment || '--'}
|
||||
</td>
|
||||
<td>
|
||||
{isCurrent && (
|
||||
<div className="status-indicator green">
|
||||
<i className="fa fa-circle pre" />
|
||||
Current Revision
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
});
|
|
@ -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 (
|
||||
<li className={classNames({ active: isSelected })} onClick={onClick}>
|
||||
<li className={classNames({ active: isSelected, invalid: hasErrors })} onClick={onClick}>
|
||||
{survey.title.value || 'Untitled Survey'}
|
||||
<span
|
||||
title="Delete this survey"
|
||||
|
@ -43,6 +42,7 @@ SurveyListItem.propTypes = {
|
|||
survey: pt.object.isRequired,
|
||||
surveyIndex: pt.number.isRequired,
|
||||
isSelected: pt.bool.isRequired,
|
||||
hasErrors: pt.bool.isRequired,
|
||||
deleteSurvey: pt.func.isRequired,
|
||||
onClick: pt.func,
|
||||
};
|
||||
|
@ -51,9 +51,11 @@ const SurveyForm = props => {
|
|||
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 => {
|
|||
</span>
|
||||
}
|
||||
<h4>{headerText}</h4>
|
||||
{
|
||||
Object.keys(surveyObject).map(fieldName =>
|
||||
<FormField
|
||||
key={fieldName}
|
||||
label={formatLabel(fieldName)}
|
||||
type="text"
|
||||
field={surveyObject[fieldName]}
|
||||
/>
|
||||
)
|
||||
|
||||
{showAdditionalSurveyFields &&
|
||||
<FormField label="Title" field={surveyObject.title} />
|
||||
}
|
||||
|
||||
<FormField label="Message" field={surveyObject.message} />
|
||||
<FormField label="Engagement Button Label" field={surveyObject.engagementButtonLabel} />
|
||||
<FormField label="Thanks Message" field={surveyObject.thanksMessage} />
|
||||
<FormField label="Post Answer Url" field={surveyObject.postAnswerUrl} />
|
||||
<FormField label="Learn More Message" field={surveyObject.learnMoreMessage} />
|
||||
<FormField label="Learn More Url" field={surveyObject.learnMoreUrl} />
|
||||
|
||||
{showAdditionalSurveyFields &&
|
||||
<FormField label="Weight" type="number" min="1" field={surveyObject.weight} />
|
||||
}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
|
@ -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 = (<input type="text" field={field} {...field} />);
|
||||
break;
|
||||
case 'number':
|
||||
fieldType = (<NumberField {...props} />);
|
||||
break;
|
||||
case 'textarea':
|
||||
fieldType = (<textarea field={field} {...field} />);
|
||||
break;
|
||||
|
@ -48,5 +52,8 @@ FormField.propTypes = {
|
|||
field: pt.object.isRequired,
|
||||
containerClass: pt.string.isRequired,
|
||||
};
|
||||
FormField.defaultProps = {
|
||||
type: 'text',
|
||||
};
|
||||
|
||||
export default FormField;
|
|
@ -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 (
|
||||
<input
|
||||
type="number"
|
||||
{...field}
|
||||
onBlur={::this.handleBlur}
|
||||
onChange={::this.handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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('<ActionForm>', () => {
|
||||
let store;
|
||||
|
||||
beforeAll(() => {
|
||||
store = controlStore();
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
mount(<Provider store={store}><ActionForm name="console-log" /></Provider>);
|
||||
});
|
||||
|
||||
it('raises an exception with unknown action names', () => {
|
||||
expect(() => {
|
||||
mount(<Provider store={store}><ActionForm name="does-not-exist" /></Provider>);
|
||||
})
|
||||
.toThrow(new Error('Unexpected action name: "does-not-exist"'));
|
||||
});
|
||||
|
||||
it('should show the right action form', () => {
|
||||
const wrapper = mount(<Provider store={store}><ActionForm name="console-log" /></Provider>);
|
||||
expect(wrapper.contains('ConsoleLogForm'));
|
||||
});
|
||||
});
|
|
@ -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('<Notifications>', () => {
|
|
@ -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('<RecipeForm>', () => {
|
||||
beforeAll(() => {
|
||||
});
|
||||
|
||||
it('should work unconnected', () => {
|
||||
shallow(<RecipeForm
|
||||
dispatch={() => {}}
|
||||
fields={{}}
|
||||
formState={{}}
|
||||
recipeId={0}
|
||||
submitting={false}
|
||||
recipe={{}}
|
||||
handleSubmit={() => {}}
|
||||
viewingRevision={false}
|
||||
/>);
|
||||
});
|
||||
|
||||
it('should work connected', () => {
|
||||
const store = controlStore();
|
||||
mount(<Provider store={store}>
|
||||
<ConnectedRecipeForm params={{}} location={{ query: '' }} />
|
||||
</Provider>);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { HistoryItem, HistoryList } from '../../components/RecipeHistory.js';
|
||||
|
||||
describe('Recipe history components', () => {
|
||||
describe('<HistoryList>', () => {
|
||||
it('should render a <HistoryItem> for each revision', () => {
|
||||
const recipe = { revision_id: 2 };
|
||||
const revision1 = { id: 1 };
|
||||
const revision2 = { id: 2 };
|
||||
const dispatch = () => null;
|
||||
const wrapper = shallow(
|
||||
<HistoryList dispatch={dispatch} recipe={recipe} revisions={[revision1, revision2]} />
|
||||
);
|
||||
|
||||
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('<HistoryItem>', () => {
|
||||
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(
|
||||
<HistoryItem revision={revision} recipe={recipe} dispatch={dispatch} />
|
||||
);
|
||||
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(
|
||||
<HistoryItem revision={revision} recipe={recipe} dispatch={dispatch} />
|
||||
);
|
||||
expect(wrapper.find('.status-indicator').isEmpty()).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,14 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import HeartbeatForm from '../../../components/action_forms/HeartbeatForm.js';
|
||||
|
||||
|
||||
describe('<HeartbeatForm>', () => {
|
||||
it('should work', () => {
|
||||
const fields = {
|
||||
surveys: {},
|
||||
};
|
||||
shallow(<HeartbeatForm fields={fields} />);
|
||||
});
|
||||
});
|
|
@ -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', () => {
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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]);
|
||||
}
|
|
@ -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))
|
||||
));
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
|
@ -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;
|
||||
}
|
|
@ -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');
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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=<environment> contract-tests/
|
||||
|
||||
where ``<environment>`` is one of `dev`, `stage`, or `prod`
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,239 @@
|
|||
Filter Expressions
|
||||
==================
|
||||
Filter expressions describe which users a :ref:`recipe <recipes>` 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']
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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')
|
|
@ -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
|
||||
|
|
|
@ -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')
|
|
@ -1,22 +0,0 @@
|
|||
import React from 'react';
|
||||
import Header from './Header.js';
|
||||
import Notifications from './Notifications.js';
|
||||
|
||||
export default function ControlApp() {
|
||||
return (
|
||||
<div>
|
||||
<Notifications />
|
||||
<Header
|
||||
pageType={this.props.children.props.route}
|
||||
currentLocation={this.props.location.pathname}
|
||||
routes={this.props.routes}
|
||||
params={this.props.params}
|
||||
/>
|
||||
<div id="content" className="wrapper">
|
||||
{
|
||||
React.Children.map(this.props.children, child => React.cloneElement(child))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -26,11 +26,14 @@
|
|||
</div>
|
||||
|
||||
{% block page-header %}{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
<div id="page-container"></div>
|
||||
{% block content %}
|
||||
<div id="page-container"></div>
|
||||
{% endblock %}
|
||||
|
||||
</div>
|
||||
<!-- END Container -->
|
||||
{% render_bundle 'control' 'js' %}
|
||||
{% block javascript %}
|
||||
{% render_bundle 'control' 'js' %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -8,6 +8,9 @@
|
|||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{# Do not render the frontend app on the login page. #}
|
||||
{% block javascript %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content" class="wrapper">
|
||||
{% if form.errors and not form.non_field_errors %}
|
||||
|
|
|
@ -1,17 +1,30 @@
|
|||
from django.conf import settings
|
||||
from django.conf.urls import url, include
|
||||
|
||||
from normandy.control import views as control_views
|
||||
from django.contrib.auth.views import login, logout_then_login
|
||||
from django.core.urlresolvers import reverse_lazy
|
||||
|
||||
from normandy.control import views
|
||||
|
||||
|
||||
app_name = 'control'
|
||||
urlpatterns = []
|
||||
|
||||
|
||||
if settings.ADMIN_ENABLED:
|
||||
urlpatterns += [
|
||||
url(r'^control/', include([
|
||||
url('login', login, {'template_name': 'control/admin/login.html'}, name='login'),
|
||||
url('logout', logout_then_login, {'login_url': '/control/login.html'}, name='logout'),
|
||||
url(r'^.*$', control_views.IndexView, name='index'),
|
||||
url(
|
||||
'login',
|
||||
login,
|
||||
{'template_name': 'control/admin/login.html'},
|
||||
name='login'
|
||||
),
|
||||
url(
|
||||
'logout',
|
||||
logout_then_login,
|
||||
{'login_url': reverse_lazy('control:login')},
|
||||
name='logout'
|
||||
),
|
||||
url(r'^.*$', views.IndexView, name='index'),
|
||||
]))
|
||||
]
|
||||
|
|
|
@ -94,11 +94,11 @@ def heartbeat_level_to_text(level):
|
|||
|
||||
def heartbeat_check_detail(check):
|
||||
errors = check(app_configs=None)
|
||||
level = 0
|
||||
level = max([level] + [e.level for e in errors])
|
||||
errors = list(filter(lambda e: e.id not in settings.SILENCED_SYSTEM_CHECKS, errors))
|
||||
level = max([0] + [e.level for e in errors])
|
||||
|
||||
return {
|
||||
'status': heartbeat_level_to_text(level),
|
||||
'level': level,
|
||||
'messages': [e.msg for e in errors],
|
||||
'messages': {e.id: e.msg for e in errors},
|
||||
}
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
from django import forms
|
||||
from django.contrib.admin import widgets
|
||||
from django.utils import timezone
|
||||
|
||||
from normandy.recipes.models import Client, Country, Locale, ReleaseChannel
|
||||
|
||||
|
||||
class ClientForm(forms.Form):
|
||||
"""Form to specify client configurations for testing purposes."""
|
||||
def __init__(self, *args, request=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.request = request
|
||||
|
||||
locale = forms.ModelChoiceField(Locale.objects.all(), empty_label=None, to_field_name='code')
|
||||
release_channel = forms.ModelChoiceField(
|
||||
ReleaseChannel.objects.all(),
|
||||
initial='release',
|
||||
empty_label=None,
|
||||
to_field_name='slug'
|
||||
)
|
||||
country = forms.ModelChoiceField(
|
||||
Country.objects.all(),
|
||||
empty_label=None,
|
||||
to_field_name='code'
|
||||
)
|
||||
request_time = forms.SplitDateTimeField(required=False, widget=widgets.AdminSplitDateTime)
|
||||
|
||||
def save(self):
|
||||
return Client(
|
||||
request=self.request,
|
||||
locale=self.cleaned_data['locale'].code,
|
||||
country=self.cleaned_data['country'].code,
|
||||
request_time=self.cleaned_data.get('request_time', timezone.now()),
|
||||
release_channel=self.cleaned_data['release_channel'].slug,
|
||||
user_id=''
|
||||
)
|
|
@ -70,8 +70,3 @@ class ActionAdmin(VersionAdmin):
|
|||
"""
|
||||
return action.in_use
|
||||
in_use.boolean = True
|
||||
|
||||
|
||||
@admin.register(models.ReleaseChannel)
|
||||
class ReleaseChannelAdmin(admin.ModelAdmin):
|
||||
fields = ['name', 'slug']
|
||||
|
|
|
@ -7,6 +7,7 @@ from normandy.base.api.serializers import UserSerializer
|
|||
from normandy.recipes.api.fields import ActionImplementationHyperlinkField
|
||||
from normandy.recipes.models import (
|
||||
Action, Approval, ApprovalRequest, ApprovalRequestComment, Recipe, Signature)
|
||||
from normandy.recipes.validators import JSONSchemaValidator
|
||||
|
||||
|
||||
class ActionSerializer(serializers.ModelSerializer):
|
||||
|
@ -102,6 +103,47 @@ class RecipeSerializer(serializers.ModelSerializer):
|
|||
'is_approved',
|
||||
]
|
||||
|
||||
def validate_arguments(self, value):
|
||||
# Get the schema associated with the selected action
|
||||
try:
|
||||
schema = Action.objects.get(name=self.initial_data.get('action')).arguments_schema
|
||||
except:
|
||||
raise serializers.ValidationError('Could not find arguments schema.')
|
||||
|
||||
schemaValidator = JSONSchemaValidator(schema)
|
||||
errorResponse = {}
|
||||
errors = sorted(schemaValidator.iter_errors(value), key=lambda e: e.path)
|
||||
|
||||
# Loop through ValidationErrors returned by JSONSchema
|
||||
# Each error contains a message and a path attribute
|
||||
# message: string human-readable error explanation
|
||||
# path: list containing path to offending element
|
||||
for error in errors:
|
||||
currentLevel = errorResponse
|
||||
|
||||
# Loop through the path of the current error
|
||||
# e.g. ['surveys'][0]['weight']
|
||||
for index, path in enumerate(error.path):
|
||||
# If this key already exists in our error response, step into it
|
||||
if path in currentLevel:
|
||||
currentLevel = currentLevel[path]
|
||||
continue
|
||||
else:
|
||||
# If we haven't reached the end of the path, add this path
|
||||
# as a key in our error response object and step into it
|
||||
if index < len(error.path) - 1:
|
||||
currentLevel[path] = {}
|
||||
currentLevel = currentLevel[path]
|
||||
continue
|
||||
# If we've reached the final path, set the error message
|
||||
else:
|
||||
currentLevel[path] = error.message
|
||||
|
||||
if (errorResponse):
|
||||
raise serializers.ValidationError(errorResponse)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class ClientSerializer(serializers.Serializer):
|
||||
country = serializers.CharField()
|
||||
|
@ -110,6 +152,7 @@ class ClientSerializer(serializers.Serializer):
|
|||
|
||||
class RecipeVersionSerializer(serializers.ModelSerializer):
|
||||
date_created = serializers.DateTimeField(source='revision.date_created', read_only=True)
|
||||
comment = serializers.CharField(source='revision.comment', read_only=True)
|
||||
recipe = RecipeSerializer(source='_object_version.object', read_only=True)
|
||||
|
||||
class Meta:
|
||||
|
@ -118,6 +161,7 @@ class RecipeVersionSerializer(serializers.ModelSerializer):
|
|||
'id',
|
||||
'date_created',
|
||||
'recipe',
|
||||
'comment',
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -205,8 +205,7 @@ class RecipeVersionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
]
|
||||
|
||||
def get_queryset(self):
|
||||
content_type = ContentType.objects.get_for_model(Recipe)
|
||||
return Version.objects.filter(content_type=content_type)
|
||||
return Version.objects.get_for_model(Recipe)
|
||||
|
||||
|
||||
class ClassifyClient(views.APIView):
|
||||
|
|
|
@ -33,6 +33,7 @@ class Command(BaseCommand):
|
|||
@reversion.create_revision()
|
||||
def handle(self, *args, **options):
|
||||
disabled_recipes = []
|
||||
reversion.set_comment('Updating actions.')
|
||||
|
||||
action_names = settings.ACTIONS.keys()
|
||||
if options['action_name']:
|
||||
|
|
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Загрузка…
Ссылка в новой задаче