Merge remote-tracking branch 'upstream/master' into signing

This commit is contained in:
Mike Cooper 2016-08-18 20:51:23 +00:00
Родитель 12bdbf1625 5030a10cb8
Коммит 9b5b5f5876
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 74AB8817639D69C1
115 изменённых файлов: 2004 добавлений и 962 удалений

8
.therapist.yml Normal file
Просмотреть файл

@ -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

22
bin/ci/contract-tests.sh Executable file
Просмотреть файл

@ -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

Просмотреть файл

44
bin/ci/docker-run.sh Executable file
Просмотреть файл

@ -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 )")"

Просмотреть файл

@ -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();

317
client/selfrepair/uitour.js Normal file
Просмотреть файл

@ -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

13
docs/dev/api-tests.rst Normal file
Просмотреть файл

@ -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']:

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше