зеркало из https://github.com/mozilla/normandy.git
Merge pull request #144 from Osmose/actions
WIP: Move actions into service repo
This commit is contained in:
Коммит
15633b971e
|
@ -9,3 +9,4 @@ pip-cache
|
|||
site-packages
|
||||
venv
|
||||
webpack-stats.json
|
||||
webpack-stats-actions.json
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
db.sqlite3
|
||||
.DS_Store
|
||||
webpack-stats.json
|
||||
webpack-stats-actions.json
|
||||
__pycache__/
|
||||
/assets/
|
||||
/static/
|
||||
|
|
|
@ -0,0 +1,211 @@
|
|||
Driver API
|
||||
==========
|
||||
The Normandy driver is an object passed to all actions when they are created. It
|
||||
provides methods and attributes for performing operations that require more
|
||||
privileges than JavaScript is normally given. For example, the driver might
|
||||
provide a function for showing a notification bar within the Firefox UI,
|
||||
something normal JavaScript can't trigger.
|
||||
|
||||
Environments in which actions are run (such as the `Normandy system add-on`_)
|
||||
implement the driver and pass it to the actions before executing them.
|
||||
|
||||
.. _Normandy system add-on: https://github.com/mozilla/normandy-addon
|
||||
|
||||
Driver
|
||||
------
|
||||
The driver object contains the following attributes:
|
||||
|
||||
.. js:data:: testing
|
||||
|
||||
Boolean describing whether the action is being executed in "testing" mode.
|
||||
Testing mode is mainly used when previewing recipes within the Normandy
|
||||
admin interface.
|
||||
|
||||
.. js:data:: locale
|
||||
|
||||
String containing the locale code of the user's preferred language.
|
||||
|
||||
.. js:function:: log(message, level='debug')
|
||||
|
||||
Log a message to an appropriate location. It's up to the driver where these
|
||||
messages are stored; they could go to the browser console, or to a remote
|
||||
logging service, or somewhere else. If level is ``'debug'``, then messages
|
||||
should only be logged if ``testing`` is true.
|
||||
|
||||
:param message: Message to log
|
||||
:param level: Level to log message at, such as ``debug``, ``info``, or
|
||||
``error``. Defaults to ``debug``.
|
||||
|
||||
.. js:function:: showHeartbeat(options)
|
||||
|
||||
Displays a Heartbeat survey to the user. Appears as a notification bar with
|
||||
a 5-star rating input for users to vote with. Also contains a "Learn More"
|
||||
button on the side. After voting, users are shown a thanks message and
|
||||
optionally a new tab is opened to a specific URL.
|
||||
|
||||
:param message: Primary message to display alongside rating stars.
|
||||
:param engagementButtonLabel: Message to display on the engagement button.
|
||||
If specified, a button will be shown instead of the rating stars.
|
||||
:param thanksMessage: Message to show after user submits a rating.
|
||||
:param flowId: A UUID that should be unique to each call to this function.
|
||||
Used to track metrics related to this user interaction.
|
||||
:param postAnswerUrl: URL to show users after they submit a rating. If empty,
|
||||
the user won't be shown anything.
|
||||
:param learnMoreMessage: Text to show on the "Learn More" button.
|
||||
:param learnMoreUrl: URL to open when the "Learn More" button is clicked.
|
||||
:param extraTelemetryArgs: Object containing extra arguments to send to
|
||||
telemetry associated with this heartbeat call. Defaults to an empty
|
||||
object.
|
||||
:returns: A Promise that resolves with an event emitter.
|
||||
|
||||
The emitter returned by this function can be subscribed to using ``on``
|
||||
method. For example:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
let heartbeat = await Normandy.showHeartbeat(options);
|
||||
heartbeat.on('NotificationOffered', function(data) {
|
||||
// Do something!
|
||||
});
|
||||
|
||||
All events are given a data object with the following attributes:
|
||||
|
||||
flowId
|
||||
The ``flowId`` passed into ``showHeartbeat``.
|
||||
timestamp
|
||||
Timestamp (number of milliseconds since Unix epoch) of when the event
|
||||
being emitted occurred.
|
||||
|
||||
The events emitted by the emitter include:
|
||||
|
||||
NotificationOffered
|
||||
Emitted after the notification bar is shown to the user.
|
||||
NotificationClosed
|
||||
Emitted after the notification bar closes, either by being closed
|
||||
manually by the user, or automatically after voting.
|
||||
LearnMore
|
||||
Emitted when the user clicks the "Learn More" link.
|
||||
Voted
|
||||
Emitted when the user clicks the star rating bar and submits a rating.
|
||||
An extra ``score`` attribute is included on the data object for this
|
||||
event containing the rating the user submitted.
|
||||
TelemetrySent
|
||||
Emitted after Heartbeat has sent flow data to the Telemetry servers. Only
|
||||
available on Firefox 46 and higher.
|
||||
|
||||
.. js:function:: uuid()
|
||||
|
||||
Generates a v4 UUID. The UUID is randomly generated.
|
||||
|
||||
:returns: String containing the UUID.
|
||||
|
||||
.. js:function:: createStorage(keyPrefix)
|
||||
|
||||
Creates a storage object that can be used to store data on the client.
|
||||
|
||||
:param keyPrefix: Prefix to append to keys before storing them, to avoid
|
||||
collision with other actions using the storage.
|
||||
:returns: :js:class:`Storage`
|
||||
|
||||
.. js:function:: location()
|
||||
|
||||
Retrieves information about where the user is located.
|
||||
|
||||
:returns: A Promise that resolves with a location object.
|
||||
|
||||
The location object has the following fields:
|
||||
|
||||
countryCode
|
||||
ISO 3166-1 country code for the country the user has been geolocated to.
|
||||
|
||||
.. js:function:: saveHeartbeatFlow(data)
|
||||
|
||||
Sends flow data from Heartbeat to the Input server. See the
|
||||
`Input documentation`_ for details about the data expected.
|
||||
|
||||
:param data: Object containing Heartbeat flow data.
|
||||
|
||||
.. _Input Documentation: http://fjord.readthedocs.org/en/latest/hb_api.html
|
||||
|
||||
.. js:function:: client()
|
||||
|
||||
Retrieves information about the user's browser.
|
||||
|
||||
:returns: Promise that resolves with a client data object.
|
||||
|
||||
The client data object includes the following fields:
|
||||
|
||||
version
|
||||
String containing the Firefox version.
|
||||
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)
|
||||
|
||||
isDefaultBrowser
|
||||
Boolean specifying whether Firefox is set as the user's default browser.
|
||||
searchEngine
|
||||
String containing the user's default search engine identifier.
|
||||
syncSetup
|
||||
Boolean containing whether the user has set up Firefox Sync.
|
||||
plugins
|
||||
An object mapping of plugin names to :js:class:`Plugin` objects describing
|
||||
the plugins installed on the client.
|
||||
|
||||
Plugins
|
||||
-------
|
||||
.. js:class:: Plugin
|
||||
|
||||
A simple object describing a plugin installed on the client. This is **not**
|
||||
the same object as returned by ``navigator.plugins``, but it is similar.
|
||||
|
||||
.. js:data:: name
|
||||
|
||||
The name of the plugin.
|
||||
|
||||
.. js:data:: description
|
||||
|
||||
A human-readable description of the plugin.
|
||||
|
||||
.. js:data:: filename
|
||||
|
||||
The filename of the plugin file.
|
||||
|
||||
.. js:data:: version
|
||||
|
||||
The plugin's version number string.
|
||||
|
||||
Storage
|
||||
-------
|
||||
.. js:class:: Storage
|
||||
|
||||
Storage objects allow actions to store data locally on the client, using an
|
||||
API that is similar to localStorage, but is asynchronous.
|
||||
|
||||
.. js:function:: getItem(key)
|
||||
|
||||
Retrieves a value from storage.
|
||||
|
||||
:param key: Key to look up in storage.
|
||||
:returns: A Promise that resolves with the value found in storage, or
|
||||
``null`` if the key doesn't exist.
|
||||
|
||||
.. js:function:: setItem(key, value)
|
||||
|
||||
Inserts a value into storage under the given key.
|
||||
|
||||
:param key: Key to insert the value under.
|
||||
:param value: Value to store.
|
||||
:returns: A Promise that resolves when the value has been stored.
|
||||
|
||||
.. js:function:: removeItem(key)
|
||||
|
||||
Removes a value from storage.
|
||||
|
||||
:param key: Key to remove.
|
||||
:returns: A Promise that resolves when the value has been removed.
|
|
@ -57,11 +57,12 @@ Installation
|
|||
:ref:`pip-install-error`
|
||||
How to troubleshoot errors during ``pip install``.
|
||||
|
||||
4. Install frontend dependencies using npm:
|
||||
4. 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
|
||||
``normandy``:
|
||||
|
|
|
@ -43,6 +43,9 @@ steps, as they don't affect your setup if nothing has changed:
|
|||
# Add any new initial data (does not duplicate data).
|
||||
python manage.py initial_data
|
||||
|
||||
# Build frontend files
|
||||
./node_modules/.bin/webpack --config ./webpack.config.js --update-actions
|
||||
|
||||
Building the Documentation
|
||||
--------------------------
|
||||
You can build the documentation with the following command:
|
||||
|
@ -69,18 +72,33 @@ make adding these hashes easier.
|
|||
|
||||
.. _hashin: https://github.com/peterbe/hashin
|
||||
|
||||
Preprocessing Assets with webpack
|
||||
-----------------------
|
||||
.. _process-webpack:
|
||||
|
||||
We use webpack to create asset bundles of static resources. You can build an
|
||||
Preprocessing Assets with Webpack
|
||||
---------------------------------
|
||||
We use Webpack_ to create asset bundles of static resources. You can build an
|
||||
asset bundle by running:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
./node_modules/.bin/webpack --config ./webpack.config.js
|
||||
npm run build
|
||||
|
||||
Running the command with ``--watch`` will automatically rebuild your bundles as
|
||||
you make changes.
|
||||
You can also run the watch command to automatically rebuild your bundles as you
|
||||
make changes:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
npm run watch
|
||||
|
||||
Running the command with ``--update-actions`` will automatically call
|
||||
``manage.py update_actions`` when action code is built. Arguments are separated
|
||||
from the rest of the command by ``--``:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
npm run watch -- --update-actions
|
||||
|
||||
.. _Webpack: http://webpack.github.io/
|
||||
|
||||
Self-Repair Setup
|
||||
-----------------
|
||||
|
@ -124,3 +142,24 @@ To generate an API key for privillaged API access:
|
|||
4. Select the user account you wish to generate a key for in the user list
|
||||
dropdown and click the Save button.
|
||||
5. Retrieve the API token from the list view under the "Key" column.
|
||||
|
||||
Adding and Updating Actions
|
||||
---------------------------
|
||||
The code and argument schemas for Actions is stored on the filesystem, but must
|
||||
also be updated in the database to be used by the site.
|
||||
|
||||
To add a new action:
|
||||
|
||||
1. Create a new directory in ``normandy/recipes/static/actions`` containing a
|
||||
``package.json`` file for your action and the JavaScript code for it.
|
||||
2. Add the entry point for your action to ``webpack.config.js``.
|
||||
3. Add the action name and path to the ``ACTIONS`` setting in ``settings.py``.
|
||||
4. :ref:`Build the action code using Webpack <process-webpack>`.
|
||||
5. Update the database by running ``update_actions``:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
python manage.py update_actions
|
||||
|
||||
To update an existing action, follow steps 4 and 5 above after making your
|
||||
changes.
|
||||
|
|
|
@ -11,6 +11,7 @@ JavaScript to various clients based on certain rules.
|
|||
dev/install
|
||||
dev/workflow
|
||||
dev/architecture
|
||||
dev/driver
|
||||
dev/troubleshooting
|
||||
qa/docker
|
||||
ops/config
|
||||
|
|
|
@ -99,6 +99,8 @@ Running Normandy
|
|||
docker run -it --env-file=.env mozilla/normandy:latest ./manage.py createsuperuser
|
||||
# Load inital database data
|
||||
docker run -it --env-file=.env mozilla/normandy:latest ./manage.py initial_data
|
||||
# Update the actions
|
||||
docker run -it --env-file=.env mozilla/normandy:latest ./manage.py update_actions
|
||||
# Run the web server
|
||||
docker run -it -p 8000:8000 --env-file=.env mozilla/normandy:latest
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
// Karma configuration
|
||||
module.exports = function(config) {
|
||||
config.set({
|
||||
var karmaConfig = {
|
||||
// base path that will be used to resolve all patterns (eg. files, exclude)
|
||||
basePath: 'normandy/control/tests/',
|
||||
basePath: '',
|
||||
|
||||
// frameworks to use
|
||||
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
|
||||
|
@ -10,13 +10,17 @@ module.exports = function(config) {
|
|||
|
||||
// list of files / patterns to load in the browser
|
||||
files: [
|
||||
'index.js'
|
||||
'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',
|
||||
],
|
||||
|
||||
// preprocess matching files before serving them to the browser
|
||||
// available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
|
||||
preprocessors: {
|
||||
'index.js': ['webpack'],
|
||||
'normandy/control/tests/index.js': ['webpack', 'sourcemap'],
|
||||
'normandy/recipes/tests/actions/index.js': ['webpack', 'sourcemap'],
|
||||
'normandy/control/static/control/js/components/*.jsx': ['react-jsx'],
|
||||
},
|
||||
|
||||
|
@ -43,7 +47,7 @@ module.exports = function(config) {
|
|||
// test results reporter to use
|
||||
// possible values: 'dots', 'progress'
|
||||
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
|
||||
reporters: ['nyan'],
|
||||
reporters: ['spec'],
|
||||
|
||||
// web server port
|
||||
port: 9876,
|
||||
|
@ -69,5 +73,20 @@ module.exports = function(config) {
|
|||
// Concurrency level
|
||||
// how many browser should be started simultaneous
|
||||
concurrency: Infinity,
|
||||
});
|
||||
};
|
||||
|
||||
// Add JUnit reporting if we're running on CircleCI.
|
||||
var reportDir = process.env.CIRCLE_TEST_REPORTS;
|
||||
if (reportDir) {
|
||||
karmaConfig.reporters.push('junit');
|
||||
karmaConfig.junitReporter = {
|
||||
// results will be saved as $outputDir/$browserName.xml
|
||||
outputDir: reportDir,
|
||||
|
||||
// if included, results will be saved as $outputDir/$browserName/$outputFile
|
||||
outputFile: 'normandy-actions.xml',
|
||||
};
|
||||
}
|
||||
|
||||
config.set(karmaConfig);
|
||||
};
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import json
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from normandy.recipes.models import Action, ReleaseChannel
|
||||
from normandy.recipes.models import ReleaseChannel
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
@ -22,7 +19,6 @@ class Command(BaseCommand):
|
|||
|
||||
def handle(self, *args, **options):
|
||||
self.add_release_channels()
|
||||
self.add_default_action()
|
||||
|
||||
def add_release_channels(self):
|
||||
self.stdout.write('Adding Release Channels...', ending='')
|
||||
|
@ -36,31 +32,3 @@ class Command(BaseCommand):
|
|||
for slug, name in channels.items():
|
||||
ReleaseChannel.objects.get_or_create(slug=slug, defaults={'name': name})
|
||||
self.stdout.write('Done')
|
||||
|
||||
def add_default_action(self):
|
||||
self.stdout.write('Adding default Actions...', ending='')
|
||||
|
||||
with open(os.path.join(os.path.dirname(__file__), 'data', 'console-log.js')) as f:
|
||||
action_impl = f.read()
|
||||
arguments_schema = {
|
||||
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||
"title": "Log a message to the console",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"description": "Message to log to the console",
|
||||
"type": "string",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Action.objects.get_or_create(name='console-log', defaults={
|
||||
'implementation': action_impl,
|
||||
'arguments_schema_json': json.dumps(arguments_schema),
|
||||
})
|
||||
|
||||
self.stdout.write('Done')
|
||||
|
|
|
@ -8,6 +8,7 @@ class RecipePreview extends React.Component {
|
|||
super(props);
|
||||
|
||||
this.state = {
|
||||
recipeAttempted: false,
|
||||
recipeExecuted: false,
|
||||
errorRunningRecipe: null,
|
||||
};
|
||||
|
@ -23,9 +24,13 @@ class RecipePreview extends React.Component {
|
|||
|
||||
attemptPreview() {
|
||||
const {recipe} = this.props;
|
||||
const {recipeExecuted} = this.state;
|
||||
const {recipeAttempted} = this.state;
|
||||
|
||||
if (recipe && !recipeAttempted) {
|
||||
this.setState({
|
||||
recipeAttempted: true
|
||||
});
|
||||
|
||||
if (recipe && !recipeExecuted) {
|
||||
runRecipe(recipe, {testing: true}).then(res => {
|
||||
this.setState({
|
||||
recipeExecuted: true
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
from django.conf import settings
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
class NotInUse(permissions.BasePermission):
|
||||
"""Only allows editing of objects if object.in_use is False."""
|
||||
|
||||
message = 'Cannot edit this object while it is in use'
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
unsafe_method = request.method not in permissions.SAFE_METHODS
|
||||
if not settings.CAN_EDIT_ACTIONS_IN_USE and unsafe_method and obj.in_use:
|
||||
return False
|
||||
else:
|
||||
return True
|
|
@ -11,14 +11,12 @@ from normandy.recipes.models import (Action, Recipe, Approval, ApprovalRequest,
|
|||
|
||||
class ActionSerializer(serializers.ModelSerializer):
|
||||
arguments_schema = serializers.JSONField()
|
||||
implementation = serializers.CharField(write_only=True)
|
||||
implementation_url = ActionImplementationHyperlinkField()
|
||||
|
||||
class Meta:
|
||||
model = Action
|
||||
fields = [
|
||||
'name',
|
||||
'implementation',
|
||||
'implementation_url',
|
||||
'arguments_schema',
|
||||
]
|
||||
|
|
|
@ -15,7 +15,6 @@ from normandy.base.api.permissions import AdminEnabledOrReadOnly
|
|||
from normandy.base.api.renderers import JavaScriptRenderer
|
||||
from normandy.base.decorators import reversion_transaction
|
||||
from normandy.recipes.models import Action, Client, Recipe, ApprovalRequest, ApprovalRequestComment
|
||||
from normandy.recipes.api.permissions import NotInUse
|
||||
from normandy.recipes.api.serializers import (
|
||||
ActionSerializer,
|
||||
ClientSerializer,
|
||||
|
@ -26,27 +25,14 @@ from normandy.recipes.api.serializers import (
|
|||
)
|
||||
|
||||
|
||||
class ActionViewSet(UpdateOrCreateModelViewSet):
|
||||
"""Viewset for viewing and uploading recipe actions."""
|
||||
class ActionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""Viewset for viewing recipe actions."""
|
||||
queryset = Action.objects.all()
|
||||
serializer_class = ActionSerializer
|
||||
permission_classes = [
|
||||
permissions.DjangoModelPermissionsOrAnonReadOnly,
|
||||
NotInUse,
|
||||
AdminEnabledOrReadOnly,
|
||||
]
|
||||
|
||||
lookup_field = 'name'
|
||||
lookup_value_regex = r'[_\-\w]+'
|
||||
|
||||
@reversion_transaction
|
||||
def create(self, *args, **kwargs):
|
||||
return super().create(*args, **kwargs)
|
||||
|
||||
@reversion_transaction
|
||||
def update(self, request, *args, **kwargs):
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
|
||||
class ActionImplementationView(generics.RetrieveAPIView):
|
||||
"""
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
import json
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from reversion import revisions as reversion
|
||||
from webpack_loader.utils import get_loader
|
||||
|
||||
from normandy.recipes.models import Action, Recipe
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Updates the actions in the database with the latest built code.'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'action_name',
|
||||
nargs='*',
|
||||
type=str,
|
||||
help='Only update the specified actions'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-disable',
|
||||
action='store_true',
|
||||
dest='no-disable',
|
||||
default=False,
|
||||
help='Do not disable recipes using actions that are updated'
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
@reversion.create_revision()
|
||||
def handle(self, *args, **options):
|
||||
disabled_recipes = []
|
||||
|
||||
action_names = settings.ACTIONS.keys()
|
||||
if options['action_name']:
|
||||
action_names = [name for name in action_names if name in options['action_name']]
|
||||
|
||||
for name in action_names:
|
||||
self.stdout.write('Updating action {}...'.format(name), ending='')
|
||||
implementation = get_implementation(name)
|
||||
arguments_schema = get_arguments_schema(name)
|
||||
|
||||
# Create a new action or update the existing one.
|
||||
try:
|
||||
action = Action.objects.get(name=name)
|
||||
should_update = (
|
||||
action.implementation != implementation
|
||||
or action.arguments_schema != arguments_schema
|
||||
)
|
||||
|
||||
if should_update:
|
||||
action.implementation = implementation
|
||||
action.arguments_schema = arguments_schema
|
||||
action.save()
|
||||
|
||||
# As a precaution, disable any recipes that are
|
||||
# being used by an action that was just updated.
|
||||
if not options['no-disable']:
|
||||
recipes = Recipe.objects.filter(action=action, enabled=True)
|
||||
disabled_recipes += list(recipes)
|
||||
recipes.update(enabled=False)
|
||||
except Action.DoesNotExist:
|
||||
action = Action(
|
||||
name=name,
|
||||
implementation=implementation,
|
||||
arguments_schema=arguments_schema
|
||||
)
|
||||
action.save()
|
||||
|
||||
self.stdout.write('Done')
|
||||
|
||||
if disabled_recipes:
|
||||
self.stdout.write('\nThe following recipes were disabled while updating actions:')
|
||||
for recipe in disabled_recipes:
|
||||
self.stdout.write(recipe.name)
|
||||
|
||||
|
||||
def get_implementation(action_name):
|
||||
chunks = get_loader('ACTIONS').get_assets()['chunks']
|
||||
implementation_path = chunks[action_name][0]['path']
|
||||
with open(implementation_path) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def get_arguments_schema(action_name):
|
||||
action_directory = settings.ACTIONS[action_name]
|
||||
with open(os.path.join(action_directory, 'package.json')) as f:
|
||||
action_metadata = json.load(f)
|
||||
return action_metadata['normandy']['argumentsSchema']
|
|
@ -0,0 +1,9 @@
|
|||
import {Action, registerAction} from '../utils';
|
||||
|
||||
export default class ConsoleLogAction extends Action {
|
||||
async execute() {
|
||||
this.normandy.log(this.recipe.arguments.message, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
registerAction('console-log', ConsoleLogAction);
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "console-log",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"main": "./index.js",
|
||||
"normandy": {
|
||||
"driverVersion": "1.x",
|
||||
"argumentsSchema": {
|
||||
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||
"title": "Log a message to the console",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"description": "Message to log to the console",
|
||||
"type": "string",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,214 @@
|
|||
import {Action, registerAction, weightedChoose} from '../utils';
|
||||
|
||||
const VERSION = 52; // Increase when changed.
|
||||
const LAST_SHOWN_DELAY = 1000 * 60 * 60 * 24 * 7; // 7 days
|
||||
|
||||
|
||||
export class HeartbeatFlow {
|
||||
constructor(action) {
|
||||
this.action = action;
|
||||
let {normandy, recipe, survey, client, location} = action;
|
||||
|
||||
let flashPlugin = client.plugins['Shockwave Flash'];
|
||||
let plugins = {};
|
||||
for (let pluginName in client.plugins) {
|
||||
let plugin = client.plugins[pluginName];
|
||||
plugins[plugin.name] = plugin.version;
|
||||
}
|
||||
|
||||
this.data = {
|
||||
// Required fields
|
||||
response_version: 2,
|
||||
experiment_version: '-',
|
||||
person_id: 'NA',
|
||||
survey_id: recipe.arguments.surveyId,
|
||||
flow_id: normandy.uuid(),
|
||||
question_id: survey.message,
|
||||
updated_ts: Date.now(),
|
||||
question_text: survey.message,
|
||||
variation_id: recipe.revision_id.toString(),
|
||||
|
||||
// Optional fields
|
||||
score: null,
|
||||
max_score: 5,
|
||||
flow_began_ts: Date.now(),
|
||||
flow_offered_ts: 0,
|
||||
flow_voted_ts: 0,
|
||||
flow_engaged_ts: 0,
|
||||
platform: 'UNK',
|
||||
channel: client.channel,
|
||||
version: client.version,
|
||||
locale: normandy.locale,
|
||||
country: (location.countryCode || 'unknown').toLowerCase(),
|
||||
build_id: '-',
|
||||
partner_id: '-',
|
||||
profile_age: 0,
|
||||
profile_usage: {},
|
||||
addons: {
|
||||
addons: [],
|
||||
},
|
||||
extra: {
|
||||
crashes: {},
|
||||
engage: [],
|
||||
numflows: 0,
|
||||
searchEngine: client.searchEngine,
|
||||
syncSetup: client.syncSetup,
|
||||
defaultBrowser: client.isDefaultBrowser,
|
||||
plugins: plugins,
|
||||
flashVersion: flashPlugin ? flashPlugin.version : undefined,
|
||||
doNotTrack: navigator.doNotTrack === '1',
|
||||
},
|
||||
is_test: normandy.testing,
|
||||
};
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.data.flow_id;
|
||||
}
|
||||
|
||||
save() {
|
||||
this.data.updated_ts = Date.now();
|
||||
|
||||
let {normandy} = this.action;
|
||||
normandy.saveHeartbeatFlow(this.data);
|
||||
}
|
||||
|
||||
addLink(href, source) {
|
||||
this.data.extra.engage.push([Date.now(), href, source]);
|
||||
}
|
||||
|
||||
setPhaseTimestamp(phase, timestamp) {
|
||||
let key = `flow_${phase}_ts`;
|
||||
if (key in this.data && this.data[key] === 0) {
|
||||
this.data[key] = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
setScore(score) {
|
||||
this.data.score = score;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default class ShowHeartbeatAction extends Action {
|
||||
constructor(normandy, recipe) {
|
||||
super(normandy, recipe);
|
||||
this.storage = normandy.createStorage(recipe.id);
|
||||
}
|
||||
|
||||
async execute() {
|
||||
let {surveys, defaults, surveyId} = this.recipe.arguments;
|
||||
|
||||
let lastShown = await this.getLastShownDate();
|
||||
let shouldShowSurvey = (
|
||||
this.normandy.testing
|
||||
|| lastShown === null
|
||||
|| Date.now() - lastShown > LAST_SHOWN_DELAY
|
||||
);
|
||||
if (!shouldShowSurvey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.location = await this.normandy.location();
|
||||
this.client = await this.normandy.client();
|
||||
this.survey = this.chooseSurvey(surveys, defaults);
|
||||
|
||||
let flow = new HeartbeatFlow(this);
|
||||
flow.save();
|
||||
|
||||
let extraTelemetryArgs = {
|
||||
surveyId: surveyId,
|
||||
surveyVersion: this.recipe.revision_id,
|
||||
};
|
||||
if (this.normandy.testing) {
|
||||
extraTelemetryArgs.testing = 1;
|
||||
}
|
||||
|
||||
// A bit redundant but the action argument names shouldn't necessarily rely
|
||||
// on the argument names showHeartbeat takes.
|
||||
let heartbeat = await this.normandy.showHeartbeat({
|
||||
message: this.survey.message,
|
||||
engagementButtonLabel: this.survey.engagementButtonLabel,
|
||||
thanksMessage: this.survey.thanksMessage,
|
||||
flowId: flow.id,
|
||||
postAnswerUrl: await this.annotatePostAnswerUrl(this.survey.postAnswerUrl),
|
||||
learnMoreMessage: this.survey.learnMoreMessage,
|
||||
learnMoreUrl: this.survey.learnMoreUrl,
|
||||
extraTelemetryArgs: extraTelemetryArgs,
|
||||
});
|
||||
|
||||
heartbeat.on('NotificationOffered', data => {
|
||||
flow.setPhaseTimestamp('offered', data.timestamp);
|
||||
flow.save();
|
||||
});
|
||||
|
||||
heartbeat.on('LearnMore', () => {
|
||||
flow.addLink(this.survey.learnMoreUrl, 'notice');
|
||||
flow.save();
|
||||
});
|
||||
|
||||
heartbeat.on('Voted', data => {
|
||||
flow.setScore(data.score);
|
||||
flow.setPhaseTimestamp('voted', data.timestamp);
|
||||
flow.save();
|
||||
});
|
||||
|
||||
this.setLastShownDate();
|
||||
}
|
||||
|
||||
setLastShownDate() {
|
||||
// Returns a promise, but there's nothing to do if it fails.
|
||||
this.storage.setItem('lastShown', Date.now());
|
||||
}
|
||||
|
||||
async getLastShownDate() {
|
||||
let lastShown = Number.parseInt(await this.storage.getItem('lastShown'), 10);
|
||||
return Number.isNaN(lastShown) ? null : lastShown;
|
||||
}
|
||||
|
||||
async annotatePostAnswerUrl(url) {
|
||||
let args = [
|
||||
['source', 'heartbeat'],
|
||||
['surveyversion', VERSION],
|
||||
['updateChannel', this.client.channel],
|
||||
['fxVersion', this.client.version],
|
||||
];
|
||||
|
||||
// Append testing parameter if in testing mode.
|
||||
if (this.normandy.testing) {
|
||||
args.push(['testing', 1]);
|
||||
}
|
||||
|
||||
let params = args.map(([a, b]) => `${a}=${b}`).join('&');
|
||||
if (url.indexOf('?') !== -1) {
|
||||
url += '&' + params;
|
||||
} else {
|
||||
url += '?' + params;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* From the given list of surveys, choose one based on their relative
|
||||
* weights and return it.
|
||||
*
|
||||
* @param {array} surveys Array of weighted surveys from the arguments
|
||||
* object.
|
||||
* @param {object} defaults Default values for survey attributes if they aren't
|
||||
* specified.
|
||||
* @return {object} The chosen survey, with the defaults applied.
|
||||
*/
|
||||
chooseSurvey(surveys, defaults) {
|
||||
let finalSurvey = Object.assign({}, weightedChoose(surveys));
|
||||
for (let prop in defaults) {
|
||||
if (!finalSurvey[prop]) {
|
||||
finalSurvey[prop] = defaults[prop];
|
||||
}
|
||||
}
|
||||
|
||||
return finalSurvey;
|
||||
}
|
||||
}
|
||||
|
||||
registerAction('show-heartbeat', ShowHeartbeatAction);
|
|
@ -0,0 +1,131 @@
|
|||
{
|
||||
"name": "show-heartbeat",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"main": "./index.js",
|
||||
"normandy": {
|
||||
"driverVersion": "1.x",
|
||||
"argumentsSchema": {
|
||||
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||
"title": "Show a Heartbeat survey.",
|
||||
"description": "This action can show a single survey, or choose a single survey from multiple weighted ones.",
|
||||
|
||||
"type": "object",
|
||||
"required": [
|
||||
"surveyId",
|
||||
"surveys"
|
||||
],
|
||||
"properties": {
|
||||
"surveyId": {
|
||||
"type": "string",
|
||||
"description": "Slug uniquely identifying this survey in telemetry",
|
||||
"propertyOrder": 100
|
||||
},
|
||||
"defaults": {
|
||||
"$ref": "#/definitions/survey",
|
||||
"title": "Default values",
|
||||
"description": "Default values used for missing values in the list of surveys",
|
||||
"propertyOrder": 200
|
||||
},
|
||||
"surveys": {
|
||||
"type": "array",
|
||||
"format": "tabs",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/definitions/weightedSurvey",
|
||||
"headerTemplate": "Survey {{ self.title }}"
|
||||
},
|
||||
"propertyOrder": 300
|
||||
}
|
||||
},
|
||||
|
||||
"definitions": {
|
||||
"title": {
|
||||
"description": "Descriptive title. Not shown to users",
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"message": {
|
||||
"description": "Message to show to the user",
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"engagementButtonLabel": {
|
||||
"description": "Text for the engagement button. If specified, this button will be shown instead of rating stars.",
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"thanksMessage": {
|
||||
"description": "Thanks message to show to the user after they've rated Firefox",
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"postAnswerUrl": {
|
||||
"description": "URL to redirect the user to after rating Firefox or clicking the engagement button",
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"learnMoreMessage": {
|
||||
"description": "Message to show to the user to learn more",
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"learnMoreUrl": {
|
||||
"description": "URL to show to the user when they click Learn More",
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"survey": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"$ref": "#/definitions/message",
|
||||
"propertyOrder": 100
|
||||
},
|
||||
"engagementButtonLabel": {
|
||||
"$ref": "#/definitions/engagementButtonLabel",
|
||||
"propertyOrder": 200
|
||||
},
|
||||
"thanksMessage": {
|
||||
"$ref": "#/definitions/thanksMessage",
|
||||
"propertyOrder": 300
|
||||
},
|
||||
"postAnswerUrl": {
|
||||
"$ref": "#/definitions/postAnswerUrl",
|
||||
"propertyOrder": 400
|
||||
},
|
||||
"learnMoreMessage": {
|
||||
"$ref": "#/definitions/learnMoreMessage",
|
||||
"propertyOrder": 500
|
||||
},
|
||||
"learnMoreUrl": {
|
||||
"$ref": "#/definitions/learnMoreUrl",
|
||||
"propertyOrder": 600
|
||||
}
|
||||
}
|
||||
},
|
||||
"weightedSurvey": {
|
||||
"allOf": [
|
||||
{"$ref": "#/definitions/survey"},
|
||||
{
|
||||
"properties": {
|
||||
"title": {
|
||||
"$ref": "#/definitions/title",
|
||||
"propertyOrder": 50
|
||||
},
|
||||
"weight": {
|
||||
"type": "integer",
|
||||
"description": "Frequency relative to other surveys",
|
||||
"minimum": 1,
|
||||
"default": 1,
|
||||
"propertyOrder": 2000
|
||||
}
|
||||
},
|
||||
"required": ["weight"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
export class Action {
|
||||
constructor(normandy, recipe) {
|
||||
this.normandy = normandy;
|
||||
this.recipe = recipe;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* From the given list of objects, choose one based on their relative
|
||||
* weights and return it. Choices are assumed to be objects with a `weight`
|
||||
* property that is an integer.
|
||||
*
|
||||
* Weights define the probability a choices will be shown relative to other
|
||||
* weighted choices. If two choices have weights 10 and 20, the second one will
|
||||
* appear twice as often as the first.
|
||||
*
|
||||
* @param {array} choices Array of weighted choices.
|
||||
* @return {object} The chosen choice.
|
||||
*/
|
||||
export function weightedChoose(choices) {
|
||||
if (choices.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let maxWeight = choices.map(c => c.weight).reduce((a, b) => a + b, 0);
|
||||
let chosenWeight = Math.random() * maxWeight;
|
||||
for (let choice of choices) {
|
||||
chosenWeight -= choice.weight;
|
||||
if (chosenWeight <= 0) {
|
||||
return choice;
|
||||
}
|
||||
}
|
||||
|
||||
// We shouldn't hit this, but if we do, return the last choice.
|
||||
return choices[choices.length - 1];
|
||||
}
|
||||
|
||||
// Attempt to find the global registerAction, and fall back to a noop if it's
|
||||
// not available.
|
||||
export let registerAction = null;
|
||||
|
||||
try {
|
||||
registerAction = global.registerAction;
|
||||
} catch (err) {
|
||||
// Not running in Node.
|
||||
}
|
||||
|
||||
if (!registerAction) {
|
||||
try {
|
||||
registerAction = window.registerAction;
|
||||
} catch (err) {
|
||||
// Not running in a browser.
|
||||
}
|
||||
}
|
||||
|
||||
// If it still isn't found, just shim it.
|
||||
if (!registerAction) {
|
||||
registerAction = function() { };
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"env": {
|
||||
"jasmine": true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import {mockNormandy} from './utils';
|
||||
import ConsoleLogAction from '../../static/actions/console-log/index';
|
||||
|
||||
|
||||
describe('ConsoleLogAction', function() {
|
||||
beforeEach(function() {
|
||||
this.normandy = mockNormandy();
|
||||
});
|
||||
|
||||
it('should log a message to the console', async function() {
|
||||
let action = new ConsoleLogAction(this.normandy, {arguments: {message: 'test message'}});
|
||||
await action.execute();
|
||||
expect(this.normandy.log).toHaveBeenCalledWith('test message', 'info');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,2 @@
|
|||
var testsContext = require.context("./", false, /\.js$/);
|
||||
testsContext.keys().forEach(testsContext);
|
|
@ -0,0 +1,287 @@
|
|||
import {mockNormandy, pluginFactory} from './utils';
|
||||
import ShowHeartbeatAction from '../../static/actions/show-heartbeat/index';
|
||||
|
||||
|
||||
function surveyFactory(props={}) {
|
||||
return Object.assign({
|
||||
title: 'test survey',
|
||||
message: 'test message',
|
||||
engagementButtonLabel: '',
|
||||
thanksMessage: 'thanks!',
|
||||
postAnswerUrl: 'http://example.com',
|
||||
learnMoreMessage: 'Learn More',
|
||||
learnMoreUrl: 'http://example.com',
|
||||
weight: 100,
|
||||
}, props);
|
||||
}
|
||||
|
||||
|
||||
function recipeFactory(props={}) {
|
||||
return Object.assign({
|
||||
id: 1,
|
||||
revision_id: 1,
|
||||
arguments: {
|
||||
surveyId: 'mysurvey',
|
||||
defaults: {},
|
||||
surveys: [surveyFactory()],
|
||||
},
|
||||
}, props);
|
||||
}
|
||||
|
||||
|
||||
describe('ShowHeartbeatAction', function() {
|
||||
beforeEach(function() {
|
||||
this.normandy = mockNormandy();
|
||||
});
|
||||
|
||||
it('should run without errors', async function() {
|
||||
let action = new ShowHeartbeatAction(this.normandy, recipeFactory());
|
||||
await action.execute();
|
||||
});
|
||||
|
||||
it('should not show heartbeat if it has shown within the past 7 days', async function() {
|
||||
let recipe = recipeFactory();
|
||||
let action = new ShowHeartbeatAction(this.normandy, recipe);
|
||||
|
||||
this.normandy.mock.storage.data['lastShown'] = '100';
|
||||
spyOn(Date, 'now').and.returnValue(10);
|
||||
|
||||
await action.execute();
|
||||
expect(this.normandy.showHeartbeat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show heartbeat in testing mode regardless of when it was last shown', async function() {
|
||||
let recipe = recipeFactory();
|
||||
let action = new ShowHeartbeatAction(this.normandy, recipe);
|
||||
|
||||
this.normandy.testing = true;
|
||||
this.normandy.mock.storage.data['lastShown'] = '100';
|
||||
spyOn(Date, 'now').and.returnValue(10);
|
||||
|
||||
await action.execute();
|
||||
expect(this.normandy.showHeartbeat).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show heartbeat if it hasn't shown within the past 7 days", async function() {
|
||||
let recipe = recipeFactory();
|
||||
let action = new ShowHeartbeatAction(this.normandy, recipe);
|
||||
|
||||
this.normandy.mock.storage.data['lastShown'] = '100';
|
||||
spyOn(Date, 'now').and.returnValue(9999999999);
|
||||
|
||||
await action.execute();
|
||||
expect(this.normandy.showHeartbeat).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show heartbeat if the last-shown date cannot be parsed', async function() {
|
||||
let recipe = recipeFactory();
|
||||
let action = new ShowHeartbeatAction(this.normandy, recipe);
|
||||
|
||||
this.normandy.mock.storage.data['lastShown'] = 'bigo310s0baba';
|
||||
spyOn(Date, 'now').and.returnValue(10);
|
||||
|
||||
await action.execute();
|
||||
expect(this.normandy.showHeartbeat).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass the correct arguments to showHeartbeat', async function() {
|
||||
let showHeartbeatArgs = {
|
||||
message: 'test message',
|
||||
thanksMessage: 'thanks!',
|
||||
learnMoreMessage: 'Learn More',
|
||||
learnMoreUrl: 'http://example.com',
|
||||
};
|
||||
let recipe = recipeFactory(showHeartbeatArgs);
|
||||
let action = new ShowHeartbeatAction(this.normandy, recipe);
|
||||
|
||||
this.normandy.uuid.and.returnValue('fake-uuid');
|
||||
|
||||
await action.execute();
|
||||
expect(this.normandy.showHeartbeat).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining(showHeartbeatArgs)
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate a UUID and pass it to showHeartbeat', async function() {
|
||||
let recipe = recipeFactory();
|
||||
let action = new ShowHeartbeatAction(this.normandy, recipe);
|
||||
|
||||
this.normandy.uuid.and.returnValue('fake-uuid');
|
||||
|
||||
await action.execute();
|
||||
expect(this.normandy.showHeartbeat).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
flowId: 'fake-uuid',
|
||||
}));
|
||||
});
|
||||
|
||||
it('should annotate the post-answer URL with extra query args', async function() {
|
||||
let url = 'https://example.com';
|
||||
let recipe = recipeFactory();
|
||||
recipe.arguments.surveys[0].postAnswerUrl = url;
|
||||
let action = new ShowHeartbeatAction(this.normandy, recipe);
|
||||
|
||||
this.normandy.mock.client.version = '42.0.1';
|
||||
this.normandy.mock.client.channel = 'nightly';
|
||||
|
||||
await action.execute();
|
||||
expect(this.normandy.showHeartbeat).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
postAnswerUrl: (url + '?source=heartbeat&surveyversion=52' +
|
||||
'&updateChannel=nightly&fxVersion=42.0.1'),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should annotate the post-answer URL if it has an existing query string', async function() {
|
||||
let url = 'https://example.com?foo=bar';
|
||||
let recipe = recipeFactory();
|
||||
recipe.arguments.surveys[0].postAnswerUrl = url;
|
||||
let action = new ShowHeartbeatAction(this.normandy, recipe);
|
||||
|
||||
this.normandy.mock.client.version = '42.0.1';
|
||||
this.normandy.mock.client.channel = 'nightly';
|
||||
|
||||
await action.execute();
|
||||
expect(this.normandy.showHeartbeat).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
postAnswerUrl: (url + '&source=heartbeat&surveyversion=52' +
|
||||
'&updateChannel=nightly&fxVersion=42.0.1'),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should annotate the post-answer URL with a testing param in testing mode', async function() {
|
||||
let url = 'https://example.com';
|
||||
let recipe = recipeFactory();
|
||||
recipe.arguments.surveys[0].postAnswerUrl = url;
|
||||
let action = new ShowHeartbeatAction(this.normandy, recipe);
|
||||
|
||||
this.normandy.testing = true;
|
||||
this.normandy.mock.client.version = '42.0.1';
|
||||
this.normandy.mock.client.channel = 'nightly';
|
||||
|
||||
await action.execute();
|
||||
expect(this.normandy.showHeartbeat).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
postAnswerUrl: (url + '?source=heartbeat&surveyversion=52' +
|
||||
'&updateChannel=nightly&fxVersion=42.0.1&testing=1'),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should pass some extra telemetry arguments to showHeartbeat', async function() {
|
||||
let recipe = recipeFactory({revision_id: 42});
|
||||
recipe.arguments.surveyId = 'my-survey';
|
||||
let action = new ShowHeartbeatAction(this.normandy, recipe);
|
||||
|
||||
await action.execute();
|
||||
expect(this.normandy.showHeartbeat).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
extraTelemetryArgs: {
|
||||
surveyId: 'my-survey',
|
||||
surveyVersion: 42,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it('should include a testing argument in extraTelemetryArgs when in testing mode', async function() {
|
||||
let recipe = recipeFactory({revision_id: 42});
|
||||
recipe.arguments.surveyId = 'my-survey';
|
||||
let action = new ShowHeartbeatAction(this.normandy, recipe);
|
||||
|
||||
this.normandy.testing = true;
|
||||
|
||||
await action.execute();
|
||||
expect(this.normandy.showHeartbeat).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
extraTelemetryArgs: {
|
||||
surveyId: 'my-survey',
|
||||
surveyVersion: 42,
|
||||
testing: 1,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it('should set the last-shown date', async function() {
|
||||
let action = new ShowHeartbeatAction(this.normandy, recipeFactory());
|
||||
|
||||
spyOn(Date, 'now').and.returnValue(10);
|
||||
|
||||
expect(this.normandy.mock.storage.data['lastShown']).toBeUndefined();
|
||||
await action.execute();
|
||||
expect(this.normandy.mock.storage.data['lastShown']).toEqual('10');
|
||||
});
|
||||
|
||||
it('should choose a random survey based on the weights', async function() {
|
||||
// This test relies on the order of surveys passed in, which sucks.
|
||||
let survey20 = surveyFactory({message: 'survey20', weight: 20});
|
||||
let survey30 = surveyFactory({message: 'survey30', weight: 30});
|
||||
let survey50 = surveyFactory({message: 'survey50', weight: 50});
|
||||
let recipe = recipeFactory({arguments: {surveys: [survey20, survey30, survey50]}});
|
||||
|
||||
spyOn(Math, 'random').and.returnValues(0.1, 0.4);
|
||||
|
||||
let action = new ShowHeartbeatAction(this.normandy, recipe);
|
||||
await action.execute();
|
||||
expect(this.normandy.showHeartbeat).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
message: survey20.message,
|
||||
}));
|
||||
|
||||
// If the random number changes, return a different survey.
|
||||
this.normandy = mockNormandy();
|
||||
action = new ShowHeartbeatAction(this.normandy, recipe);
|
||||
await action.execute();
|
||||
expect(this.normandy.showHeartbeat).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
message: survey30.message,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should save flow data via normandy.saveHeartbeatFlow', async function() {
|
||||
let recipe = recipeFactory();
|
||||
let survey = recipe.arguments.surveys[0];
|
||||
let action = new ShowHeartbeatAction(this.normandy, recipe);
|
||||
|
||||
let client = this.normandy.mock.client;
|
||||
client.plugins = {
|
||||
'Shockwave Flash': pluginFactory({
|
||||
name: 'Shockwave Flash',
|
||||
version: '2.5.0',
|
||||
}),
|
||||
'otherplugin': pluginFactory({
|
||||
name: 'otherplugin',
|
||||
version: '7',
|
||||
}),
|
||||
};
|
||||
spyOn(Date, 'now').and.returnValue(10);
|
||||
this.normandy.testing = true;
|
||||
|
||||
await action.execute();
|
||||
|
||||
let emitter = this.normandy.mock.heartbeatEmitter;
|
||||
emitter.emit('NotificationOffered', {timestamp: 20});
|
||||
emitter.emit('LearnMore', {timestamp: 30});
|
||||
emitter.emit('Voted', {timestamp: 40, score: 3});
|
||||
|
||||
// Checking per field makes recognizing which field failed
|
||||
// _much_ easier.
|
||||
let flowData = this.normandy.saveHeartbeatFlow.calls.mostRecent().args[0];
|
||||
expect(flowData.response_version).toEqual(2);
|
||||
expect(flowData.survey_id).toEqual(recipe.arguments.surveyId);
|
||||
expect(flowData.question_id).toEqual(survey.message);
|
||||
expect(flowData.updated_ts).toEqual(10);
|
||||
expect(flowData.question_text).toEqual(survey.message);
|
||||
expect(flowData.variation_id).toEqual(recipe.revision_id.toString());
|
||||
expect(flowData.score).toEqual(3);
|
||||
expect(flowData.flow_began_ts).toEqual(10);
|
||||
expect(flowData.flow_offered_ts).toEqual(20);
|
||||
expect(flowData.flow_voted_ts).toEqual(40);
|
||||
expect(flowData.channel).toEqual(client.channel);
|
||||
expect(flowData.version).toEqual(client.version);
|
||||
expect(flowData.locale).toEqual(this.normandy.locale);
|
||||
expect(flowData.country).toEqual(this.normandy.mock.location.countryCode);
|
||||
expect(flowData.is_test).toEqual(true);
|
||||
expect(flowData.extra.plugins).toEqual({
|
||||
'Shockwave Flash': '2.5.0',
|
||||
'otherplugin': '7',
|
||||
});
|
||||
expect(flowData.extra.flashVersion).toEqual(client.plugins['Shockwave Flash'].version);
|
||||
expect(flowData.extra.engage).toEqual([
|
||||
[10, survey.learnMoreUrl, 'notice'],
|
||||
]);
|
||||
expect(flowData.extra.searchEngine).toEqual(client.searchEngine);
|
||||
expect(flowData.extra.syncSetup).toEqual(client.syncSetup);
|
||||
expect(flowData.extra.defaultBrowser).toEqual(client.isDefaultBrowser);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,93 @@
|
|||
import EventEmitter from 'wolfy87-eventemitter';
|
||||
|
||||
|
||||
export class MockStorage {
|
||||
constructor() {
|
||||
this.data = {};
|
||||
}
|
||||
|
||||
getItem(key) {
|
||||
let value = this.data[key];
|
||||
return Promise.resolve(value !== undefined ? value : null);
|
||||
}
|
||||
|
||||
setItem(key, value) {
|
||||
this.data[key] = String(value);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
removeItem(key) {
|
||||
delete this.data[key];
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function pluginFactory(props={}) {
|
||||
return Object.assign({
|
||||
name: 'Plugin',
|
||||
description: 'A plugin',
|
||||
filename: '/tmp/fake/path',
|
||||
version: 'v1.0',
|
||||
}, props);
|
||||
}
|
||||
|
||||
|
||||
export function mockNormandy() {
|
||||
let normandy = {
|
||||
mock: {
|
||||
storage: new MockStorage(),
|
||||
heartbeatEmitter: new EventEmitter(),
|
||||
location: {
|
||||
countryCode: 'us',
|
||||
},
|
||||
client: {
|
||||
version: '41.0.1',
|
||||
channel: 'release',
|
||||
isDefaultBrowser: true,
|
||||
searchEngine: 'google',
|
||||
syncSetup: true,
|
||||
plugins: {},
|
||||
},
|
||||
},
|
||||
|
||||
testing: false,
|
||||
locale: 'en-US',
|
||||
location() {
|
||||
return Promise.resolve(this.mock.location);
|
||||
},
|
||||
log() {
|
||||
return;
|
||||
},
|
||||
createStorage() {
|
||||
return this.mock.storage;
|
||||
},
|
||||
showHeartbeat() {
|
||||
return Promise.resolve(this.mock.heartbeatEmitter);
|
||||
},
|
||||
client() {
|
||||
return Promise.resolve(this.mock.client);
|
||||
},
|
||||
uuid() {
|
||||
return 'fake-uuid';
|
||||
},
|
||||
saveHeartbeatFlow() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
let toSpy = [
|
||||
'location',
|
||||
'log',
|
||||
'createStorage',
|
||||
'showHeartbeat',
|
||||
'client',
|
||||
'uuid',
|
||||
'saveHeartbeatFlow',
|
||||
];
|
||||
for (let method of toSpy) {
|
||||
spyOn(normandy, method).and.callThrough();
|
||||
}
|
||||
|
||||
return normandy;
|
||||
}
|
|
@ -12,8 +12,7 @@ from reversion.models import Version
|
|||
from normandy.base.api.permissions import AdminEnabledOrReadOnly
|
||||
from normandy.base.tests import Whatever, UserFactory
|
||||
from normandy.base.utils import aware_datetime
|
||||
from normandy.recipes.models import Action, Recipe, ApprovalRequest, ApprovalRequestComment
|
||||
from normandy.recipes.api.permissions import NotInUse
|
||||
from normandy.recipes.models import Recipe, ApprovalRequest, ApprovalRequestComment
|
||||
from normandy.recipes.tests import (
|
||||
ActionFactory,
|
||||
ApprovalRequestFactory,
|
||||
|
@ -50,135 +49,14 @@ class TestActionAPI(object):
|
|||
}
|
||||
]
|
||||
|
||||
def test_it_can_create_actions(self, api_client):
|
||||
res = api_client.post('/api/v1/action/', {
|
||||
'name': 'foo',
|
||||
'implementation': 'foobar',
|
||||
'arguments_schema': {'type': 'object'},
|
||||
})
|
||||
assert res.status_code == 201
|
||||
|
||||
action = Action.objects.all()[0]
|
||||
assert action.name == 'foo'
|
||||
assert action.implementation == 'foobar'
|
||||
assert action.arguments_schema == {'type': 'object'}
|
||||
|
||||
def test_it_can_edit_actions(self, api_client):
|
||||
ActionFactory(name='foo', implementation='original')
|
||||
|
||||
res = api_client.patch('/api/v1/action/foo/', {'implementation': 'changed'})
|
||||
assert res.status_code == 200
|
||||
|
||||
action = Action.objects.all()[0]
|
||||
assert action.name == 'foo'
|
||||
assert action.implementation == 'changed'
|
||||
|
||||
def test_put_creates_and_edits(self, api_client):
|
||||
"""
|
||||
PUT requests should create objects, or edit them if they already
|
||||
exist.
|
||||
"""
|
||||
res = api_client.put('/api/v1/action/foo/', {
|
||||
'name': 'foo',
|
||||
'implementation': 'original',
|
||||
'arguments_schema': {}
|
||||
})
|
||||
assert res.status_code == 201
|
||||
|
||||
action = Action.objects.all()[0]
|
||||
assert action.implementation == 'original'
|
||||
|
||||
res = api_client.put('/api/v1/action/foo/', {
|
||||
'name': 'foo',
|
||||
'implementation': 'changed',
|
||||
'arguments_schema': {}
|
||||
})
|
||||
assert res.status_code == 200
|
||||
|
||||
action.refresh_from_db()
|
||||
assert action.implementation == 'changed'
|
||||
|
||||
def test_it_can_delete_actions(self, api_client):
|
||||
ActionFactory(name='foo', implementation='foobar')
|
||||
assert Action.objects.exists()
|
||||
|
||||
res = api_client.delete('/api/v1/action/foo/')
|
||||
assert res.status_code == 204
|
||||
assert not Action.objects.exists()
|
||||
|
||||
def test_name_validation(self, api_client):
|
||||
"""Ensure the name field accepts _any_ valid slug."""
|
||||
# Slugs can contain alphanumerics plus _ and -.
|
||||
res = api_client.post('/api/v1/action/', {
|
||||
'name': 'foo-bar_baz2',
|
||||
'implementation': 'foobar',
|
||||
'arguments_schema': {'type': 'object'},
|
||||
})
|
||||
assert res.status_code == 201
|
||||
action = ActionFactory(name='foo-bar_baz2')
|
||||
|
||||
action = Action.objects.all()[0]
|
||||
assert action.name == 'foo-bar_baz2'
|
||||
assert action.implementation == 'foobar'
|
||||
assert action.arguments_schema == {'type': 'object'}
|
||||
|
||||
def test_it_cant_edit_actions_in_use(self, api_client, settings):
|
||||
RecipeFactory(action__name='active', enabled=True)
|
||||
settings.CAN_EDIT_ACTIONS_IN_USE = False
|
||||
|
||||
res = api_client.patch('/api/v1/action/active/', {'implementation': 'foobar'})
|
||||
assert res.status_code == 403
|
||||
assert res.data['detail'] == NotInUse.message
|
||||
|
||||
res = api_client.delete('/api/v1/action/active/')
|
||||
assert res.status_code == 403
|
||||
assert res.data['detail'] == NotInUse.message
|
||||
|
||||
def test_it_can_edit_actions_in_use_with_setting(self, api_client, settings):
|
||||
RecipeFactory(action__name='active', enabled=True)
|
||||
settings.CAN_EDIT_ACTIONS_IN_USE = True
|
||||
|
||||
res = api_client.patch('/api/v1/action/active/', {'implementation': 'foobar'})
|
||||
res = api_client.get('/api/v1/action/foo-bar_baz2/')
|
||||
assert res.status_code == 200
|
||||
|
||||
res = api_client.delete('/api/v1/action/active/')
|
||||
assert res.status_code == 204
|
||||
|
||||
def test_available_if_admin_enabled(self, api_client, settings):
|
||||
settings.ADMIN_ENABLED = True
|
||||
res = api_client.get('/api/v1/action/')
|
||||
assert res.status_code == 200
|
||||
assert res.data == []
|
||||
|
||||
def test_readable_if_admin_disabled(self, api_client, settings):
|
||||
settings.ADMIN_ENABLED = False
|
||||
res = api_client.get('/api/v1/action/')
|
||||
assert res.status_code == 200
|
||||
|
||||
def test_not_writable_if_admin_disabled(self, api_client, settings):
|
||||
settings.ADMIN_ENABLED = False
|
||||
res = api_client.post('/api/v1/action/')
|
||||
assert res.status_code == 403
|
||||
assert res.data['detail'] == AdminEnabledOrReadOnly.message
|
||||
|
||||
def test_it_creates_revisions_on_create(self, api_client):
|
||||
res = api_client.post('/api/v1/action/', {
|
||||
'name': 'foo',
|
||||
'implementation': 'foobar',
|
||||
'arguments_schema': {'type': 'object'},
|
||||
})
|
||||
assert res.status_code == 201
|
||||
|
||||
action = Action.objects.all()[0]
|
||||
assert len(reversion.get_for_object(action)) == 1
|
||||
|
||||
def test_it_creates_revisions_on_update(self, api_client):
|
||||
ActionFactory(name='foo', implementation='original')
|
||||
|
||||
res = api_client.patch('/api/v1/action/foo/', {'implementation': 'changed'})
|
||||
assert res.status_code == 200
|
||||
|
||||
action = Action.objects.all()[0]
|
||||
assert len(reversion.get_for_object(action)) == 1
|
||||
assert res.data['name'] == action.name
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
|
@ -0,0 +1,128 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from django.core.management import call_command
|
||||
|
||||
import pytest
|
||||
|
||||
from normandy.recipes.models import Action
|
||||
from normandy.recipes.tests import ActionFactory, RecipeFactory
|
||||
|
||||
|
||||
@pytest.yield_fixture
|
||||
def mock_action(settings):
|
||||
implementations = {}
|
||||
schemas = {}
|
||||
settings.ACTIONS = {}
|
||||
|
||||
impl_patch = patch(
|
||||
'normandy.recipes.management.commands.update_actions.get_implementation',
|
||||
lambda name: implementations[name]
|
||||
)
|
||||
schema_patch = patch(
|
||||
'normandy.recipes.management.commands.update_actions.get_arguments_schema',
|
||||
lambda name: schemas[name]
|
||||
)
|
||||
|
||||
def _mock_action(name, implementation, schema):
|
||||
settings.ACTIONS[name] = '/fake/path'
|
||||
implementations[name] = implementation
|
||||
schemas[name] = schema
|
||||
|
||||
with impl_patch, schema_patch:
|
||||
yield _mock_action
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUpdateActions(object):
|
||||
def test_it_works(self):
|
||||
"""
|
||||
Verify that the update_actions command doesn't throw an error.
|
||||
"""
|
||||
call_command('update_actions')
|
||||
|
||||
def test_it_creates_new_actions(self, mock_action):
|
||||
mock_action('test-action', 'console.log("foo");', {'type': 'int'})
|
||||
|
||||
call_command('update_actions')
|
||||
assert Action.objects.count() == 1
|
||||
|
||||
action = Action.objects.all()[0]
|
||||
assert action.name == 'test-action'
|
||||
assert action.implementation == 'console.log("foo");'
|
||||
assert action.arguments_schema == {'type': 'int'}
|
||||
|
||||
def test_it_updates_existing_actions(self, mock_action):
|
||||
action = ActionFactory(
|
||||
name='test-action',
|
||||
implementation='old_impl',
|
||||
arguments_schema={},
|
||||
)
|
||||
mock_action(action.name, 'new_impl', {'type': 'int'})
|
||||
|
||||
call_command('update_actions')
|
||||
assert Action.objects.count() == 1
|
||||
|
||||
action.refresh_from_db()
|
||||
assert action.implementation == 'new_impl'
|
||||
assert action.arguments_schema == {'type': 'int'}
|
||||
|
||||
def test_it_disables_recipes_when_updating(self, mock_action):
|
||||
recipe = RecipeFactory(
|
||||
action__name='test-action',
|
||||
action__implementation='old',
|
||||
enabled=True
|
||||
)
|
||||
action = recipe.action
|
||||
mock_action(action.name, 'impl', action.arguments_schema)
|
||||
|
||||
call_command('update_actions')
|
||||
recipe.refresh_from_db()
|
||||
assert not recipe.enabled
|
||||
|
||||
def test_it_doesnt_disable_recipes_if_action_doesnt_change(self, mock_action):
|
||||
recipe = RecipeFactory(
|
||||
action__name='test-action',
|
||||
action__implementation='impl',
|
||||
action__arguments_schema={},
|
||||
enabled=True,
|
||||
)
|
||||
action = recipe.action
|
||||
mock_action(action.name, action.implementation, action.arguments_schema)
|
||||
|
||||
call_command('update_actions')
|
||||
recipe.refresh_from_db()
|
||||
assert recipe.enabled
|
||||
|
||||
def test_it_only_updates_given_actions(self, mock_action):
|
||||
update_action = ActionFactory(name='update-action', implementation='old')
|
||||
dont_update_action = ActionFactory(name='dont-update-action', implementation='old')
|
||||
|
||||
mock_action(update_action.name, 'new', update_action.arguments_schema)
|
||||
mock_action(dont_update_action.name, 'new', dont_update_action.arguments_schema)
|
||||
|
||||
call_command('update_actions', 'update-action')
|
||||
update_action.refresh_from_db()
|
||||
assert update_action.implementation == 'new'
|
||||
dont_update_action.refresh_from_db()
|
||||
assert dont_update_action.implementation == 'old'
|
||||
|
||||
def test_it_ignores_missing_actions(self, mock_action):
|
||||
dont_update_action = ActionFactory(name='dont-update-action', implementation='old')
|
||||
mock_action(dont_update_action.name, 'new', dont_update_action.arguments_schema)
|
||||
|
||||
call_command('update_actions', 'missing-action')
|
||||
dont_update_action.refresh_from_db()
|
||||
assert dont_update_action.implementation == 'old'
|
||||
|
||||
def test_it_can_skip_disabling_recipes(self, mock_action):
|
||||
recipe = RecipeFactory(
|
||||
action__name='test-action',
|
||||
action__implementation='old',
|
||||
enabled=True
|
||||
)
|
||||
action = recipe.action
|
||||
mock_action(action.name, 'impl', action.arguments_schema)
|
||||
|
||||
call_command('update_actions', '--no-disable')
|
||||
recipe.refresh_from_db()
|
||||
assert recipe.enabled
|
|
@ -116,9 +116,19 @@ class Core(Configuration):
|
|||
'DEFAULT': {
|
||||
'BUNDLE_DIR_NAME': 'bundles/',
|
||||
'STATS_FILE': os.path.join(BASE_DIR, 'webpack-stats.json')
|
||||
},
|
||||
'ACTIONS': {
|
||||
'BUNDLE_DIR_NAME': 'bundles/',
|
||||
'STATS_FILE': os.path.join(BASE_DIR, 'webpack-stats-actions.json')
|
||||
}
|
||||
}
|
||||
|
||||
# Action names and the path they are located at.
|
||||
ACTIONS = {
|
||||
'console-log': os.path.join(BASE_DIR, 'normandy/recipes/static/actions/console-log'),
|
||||
'show-heartbeat': os.path.join(BASE_DIR, 'normandy/recipes/static/actions/show-heartbeat'),
|
||||
}
|
||||
|
||||
|
||||
class Base(Core):
|
||||
"""Settings that may change per-environment, some with defaults."""
|
||||
|
@ -190,7 +200,6 @@ class Base(Core):
|
|||
CDN_URL = values.URLValue(None)
|
||||
|
||||
# Normandy settings
|
||||
CAN_EDIT_ACTIONS_IN_USE = values.BooleanValue(False)
|
||||
ADMIN_ENABLED = values.BooleanValue(True)
|
||||
ACTION_IMPLEMENTATION_CACHE_TIME = values.IntegerValue(60 * 60 * 24 * 365)
|
||||
NUM_PROXIES = values.IntegerValue(0)
|
||||
|
|
14
package.json
14
package.json
|
@ -7,12 +7,15 @@
|
|||
"url": "git://github.com/mozilla/normandy.git"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "webpack --config ./webpack.config.js --watch"
|
||||
"watch": "webpack --config ./webpack.config.js --watch",
|
||||
"build": "webpack --config ./webpack.config.js",
|
||||
"test": "karma start"
|
||||
},
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"babel-polyfill": "^6.7.2",
|
||||
"babel-preset-stage-2": "^6.5.0",
|
||||
"babel-runtime": "^6.6.1",
|
||||
"classnames": "^2.2.5",
|
||||
"cssmin": "^0.4.3",
|
||||
"font-awesome": "^4.5.0",
|
||||
|
@ -38,6 +41,7 @@
|
|||
"babel": "^6.5.2",
|
||||
"babel-core": "^6.7.7",
|
||||
"babel-loader": "^6.2.4",
|
||||
"babel-plugin-transform-runtime": "^6.8.0",
|
||||
"babel-preset-es2015": "^6.6.0",
|
||||
"babel-preset-react": "^6.5.0",
|
||||
"css-loader": "^0.23.1",
|
||||
|
@ -46,11 +50,14 @@
|
|||
"file-loader": "^0.8.5",
|
||||
"imports-loader": "^0.6.5",
|
||||
"jasmine-core": "^2.4.1",
|
||||
"jasmine-promises": "^0.4.1",
|
||||
"karma": "^0.13.22",
|
||||
"karma-firefox-launcher": "^1.0.0",
|
||||
"karma-jasmine": "^1.0.2",
|
||||
"karma-nyan-reporter": "^0.2.4",
|
||||
"karma-junit-reporter": "^1.0.0",
|
||||
"karma-react-jsx-preprocessor": "^0.1.1",
|
||||
"karma-sourcemap-loader": "^0.3.7",
|
||||
"karma-spec-reporter": "0.0.26",
|
||||
"karma-webpack": "^1.7.0",
|
||||
"postcss-loader": "^0.9.1",
|
||||
"react-addons-test-utils": "^15.0.2",
|
||||
|
@ -60,6 +67,7 @@
|
|||
"sass-loader": "^3.2.0",
|
||||
"style-loader": "^0.13.1",
|
||||
"webpack": "^1.13.0",
|
||||
"webpack-bundle-tracker": "0.0.93"
|
||||
"webpack-bundle-tracker": "0.0.93",
|
||||
"yargs": "^4.7.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
[flake8]
|
||||
max-line-length=99
|
||||
ignore=W503
|
||||
|
|
|
@ -1,52 +1,117 @@
|
|||
var path = require('path')
|
||||
var webpack = require('webpack')
|
||||
var path = require('path');
|
||||
var webpack = require('webpack');
|
||||
var BundleTracker = require('webpack-bundle-tracker')
|
||||
var ExtractTextPlugin = require('extract-text-webpack-plugin');
|
||||
var argv = require('yargs').argv;
|
||||
var child_process = require('child_process');
|
||||
|
||||
module.exports = {
|
||||
context: __dirname,
|
||||
|
||||
entry: {
|
||||
selfrepair: './normandy/selfrepair/static/js/self_repair',
|
||||
control: [
|
||||
'./normandy/control/static/control/js/index',
|
||||
'./normandy/control/static/control/admin/sass/control.scss',
|
||||
'./node_modules/font-awesome/scss/font-awesome.scss',
|
||||
]
|
||||
},
|
||||
const BOLD = '\u001b[1m';
|
||||
const END_BOLD = '\u001b[39m\u001b[22m';
|
||||
|
||||
output: {
|
||||
path: path.resolve('./assets/bundles/'),
|
||||
filename: '[name]-[hash].js',
|
||||
chunkFilename: '[id].bundle.js'
|
||||
},
|
||||
|
||||
plugins: [
|
||||
new BundleTracker({ filename: './webpack-stats.json' }),
|
||||
new ExtractTextPlugin('[name]-[hash].css'),
|
||||
new webpack.ProvidePlugin({
|
||||
'fetch': 'exports?self.fetch!isomorphic-fetch'
|
||||
}),
|
||||
],
|
||||
module.exports = [
|
||||
{
|
||||
context: __dirname,
|
||||
|
||||
module: {
|
||||
loaders: [
|
||||
{
|
||||
test: /(\.|\/)(jsx|js)$/,
|
||||
exclude: /node_modules/,
|
||||
loader: 'babel',
|
||||
'query': {
|
||||
presets: ['es2015', 'react', 'stage-2']
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.scss$/,
|
||||
loader: ExtractTextPlugin.extract('style', 'css?sourceMap!postcss!sass?sourceMap')
|
||||
},
|
||||
{
|
||||
test: /\.(ttf|eot|svg|woff(2)?)(\?[a-z0-9=&.]+)?$/,
|
||||
loader: 'file-loader'
|
||||
}
|
||||
entry: {
|
||||
selfrepair: './normandy/selfrepair/static/js/self_repair',
|
||||
control: [
|
||||
'./normandy/control/static/control/js/index',
|
||||
'./normandy/control/static/control/admin/sass/control.scss',
|
||||
'./node_modules/font-awesome/scss/font-awesome.scss',
|
||||
]
|
||||
},
|
||||
|
||||
output: {
|
||||
path: path.resolve('./assets/bundles/'),
|
||||
filename: '[name]-[hash].js',
|
||||
chunkFilename: '[id].bundle.js'
|
||||
},
|
||||
|
||||
plugins: [
|
||||
new BundleTracker({ filename: './webpack-stats.json' }),
|
||||
new ExtractTextPlugin('[name]-[hash].css'),
|
||||
new webpack.ProvidePlugin({
|
||||
'fetch': 'exports?self.fetch!isomorphic-fetch'
|
||||
}),
|
||||
],
|
||||
|
||||
module: {
|
||||
loaders: [
|
||||
{
|
||||
test: /(\.|\/)(jsx|js)$/,
|
||||
exclude: /node_modules/,
|
||||
loader: 'babel',
|
||||
'query': {
|
||||
presets: ['es2015', 'react', 'stage-2']
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.scss$/,
|
||||
loader: ExtractTextPlugin.extract('style', 'css?sourceMap!postcss!sass?sourceMap')
|
||||
},
|
||||
{
|
||||
test: /\.(ttf|eot|svg|woff(2)?)(\?[a-z0-9=&.]+)?$/,
|
||||
loader: 'file-loader'
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
{
|
||||
entry: {
|
||||
'console-log': './normandy/recipes/static/actions/console-log/index',
|
||||
'show-heartbeat': './normandy/recipes/static/actions/show-heartbeat/index',
|
||||
},
|
||||
|
||||
plugins: [
|
||||
new BundleTracker({filename: './webpack-stats-actions.json'}),
|
||||
new webpack.optimize.DedupePlugin(),
|
||||
new webpack.optimize.OccurrenceOrderPlugin(),
|
||||
new webpack.optimize.UglifyJsPlugin({compress: {warnings: false}}),
|
||||
|
||||
// Small plugin to update the actions in the database if
|
||||
// --update-actions was passed.
|
||||
function updateActions() {
|
||||
this.plugin('done', function(stats) {
|
||||
if (argv['update-actions']) {
|
||||
// Don't disable actions since this is mostly for development.
|
||||
var cmd = 'python manage.py update_actions --no-disable';
|
||||
|
||||
child_process.exec(cmd, function(err, stdout, stderr) {
|
||||
console.log('\n' + BOLD + 'Updating Actions' + END_BOLD);
|
||||
console.log(stdout);
|
||||
if (stderr) {
|
||||
console.error(stderr);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
],
|
||||
|
||||
output: {
|
||||
path: path.resolve('./assets/bundles/'),
|
||||
filename: '[name]-[hash].js'
|
||||
},
|
||||
|
||||
module: {
|
||||
loaders: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
exclude: /node_modules/,
|
||||
loader: 'babel-loader',
|
||||
query: {
|
||||
presets: ['es2015', 'stage-2'],
|
||||
plugins: [
|
||||
['transform-runtime', {
|
||||
polyfill: false,
|
||||
regenerator: true
|
||||
}]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
Загрузка…
Ссылка в новой задаче