diff options
Diffstat (limited to 'fake-service')
28 files changed, 1335 insertions, 0 deletions
diff --git a/fake-service/app/__init__.py b/fake-service/app/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/fake-service/app/__init__.py diff --git a/fake-service/app/adapter/__init__.py b/fake-service/app/adapter/__init__.py new file mode 100644 index 00000000..55f91e08 --- /dev/null +++ b/fake-service/app/adapter/__init__.py @@ -0,0 +1,16 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +from mail_service import MailService diff --git a/fake-service/app/adapter/contacts.py b/fake-service/app/adapter/contacts.py new file mode 100644 index 00000000..30ff1253 --- /dev/null +++ b/fake-service/app/adapter/contacts.py @@ -0,0 +1,41 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +import re + + +class Contacts: + + def __init__(self): + self.contacts = [] + + def add(self, mbox_mail): + contact = mbox_mail.get('From') or mbox_mail.from_addr + self.contacts.append(Contact(contact)) + + def search(self, query): + contacts_query = re.compile(query) + return [ + contact.__dict__ + for contact in self.contacts + if contacts_query.match(contact.addresses[0]) + ] + + +class Contact: + + def __init__(self, contact): + self.addresses = [contact] + self.name = '' diff --git a/fake-service/app/adapter/mail.py b/fake-service/app/adapter/mail.py new file mode 100644 index 00000000..26c00277 --- /dev/null +++ b/fake-service/app/adapter/mail.py @@ -0,0 +1,91 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +from datetime import datetime +import random +import calendar +from dateutil import parser + +class Mail: + + NOW = calendar.timegm( + datetime.strptime( + datetime.now().isoformat(), + "%Y-%m-%dT%H:%M:%S.%f").timetuple()) + + @staticmethod + def from_json(mail_json): + mail = Mail() + mail.header = mail_json['header'] + mail.header['date'] = datetime.now().isoformat() + mail.ident = mail_json.get('ident', 0) + mail.body = mail_json['body'] + mail.tags = mail_json['tags'] + mail.security_casing = {} + mail.status = [] + mail.draft_reply_for = mail_json.get('draft_reply_for', 0) + return mail + + def __init__(self, mbox_mail=None, ident=None): + if mbox_mail: + self.header = self._get_headers(mbox_mail) + self.ident = ident + self.body = self._get_body(mbox_mail) + self.tags = self._get_tags(mbox_mail) + self.security_casing = {} + self.status = self._get_status() + self.draft_reply_for = -1 + + def _get_body(self, message): + if message.is_multipart(): + boundary = '--{boundary}'.format( + boundary=message.get_boundary().strip()) + body_parts = [x.as_string() for x in message.get_payload()] + + body = boundary + '\n' + body += '{boundary}\n'.format(boundary=boundary).join(body_parts) + body += '{boundary}--\n'.format(boundary=boundary) + + return body + else: + return message.get_payload() + + def _get_status(self): + status = [] + if 'sent' in self.tags: + status.append('read') + + return status + + def _get_headers(self, mbox_mail): + headers = {} + headers['from'] = mbox_mail.get('From') or mbox_mail.from_addr + headers['to'] = [mbox_mail.get('To')] + headers['subject'] = mbox_mail.get('Subject') + headers['date'] = parser.parse(mbox_mail['Date']).isoformat() + headers['content_type'] = mbox_mail.get('Content-Type') + + return headers + + def _get_tags(self, mbox_mail): + return filter(len, mbox_mail.get('X-TW-Pixelated-Tags').split(', ')) + + @property + def subject(self): + return self.header['subject'] + + @property + def date(self): + return self.header['date'] diff --git a/fake-service/app/adapter/mail_service.py b/fake-service/app/adapter/mail_service.py new file mode 100644 index 00000000..2825af9d --- /dev/null +++ b/fake-service/app/adapter/mail_service.py @@ -0,0 +1,128 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +import os +import re +import mailbox + +from tagsset import TagsSet +from mailset import MailSet +from contacts import Contacts +from mail import Mail + + +class MailService: + MAILSET_PATH = os.path.join(os.environ['HOME'], 'mailsets', 'mediumtagged') + + def __init__(self): + self.mailset = MailSet() + self.tagsset = TagsSet() + self.contacts = Contacts() + + def reset(self): + self.mailset = MailSet() + self.tagsset = TagsSet() + self.contacts = Contacts() + + def _read_file(self, filename): + with open(filename, 'r') as fd: + return fd.read() + + def _create_message_from_file(self, filename): + data = self._read_file(filename) + return self.create_message_from_string(data, filename) + + def create_message_from_string(self, data, filename=None): + if data.startswith('From '): + msg = mailbox.mboxMessage(data) + from_addr = re.sub(r"^From ", "", msg.get_unixfrom()) + msg.from_addr = from_addr + msg.set_from(from_addr) + else: + msg = mailbox.Message(data) + msg.from_addr = msg.get('From') + return msg + + def _create_message_from_string(self, data): + return mailbox.Message(data) + + def load_mailset(self): + mbox_filenames = [ + filename + for filename in os.listdir + (self.MAILSET_PATH) if filename.startswith('mbox')] + messages = (self._create_message_from_file(os.path.join(self.MAILSET_PATH, mbox)) + for mbox in mbox_filenames) + + self.index_messages(messages) + + def index_messages(self, messages): + for message in messages: + self.mailset.add(message) + self.tagsset.add(message) + self.contacts.add(message) + + def mails(self, query, page, window_size): + mails = self.mailset.values() + mails = [mail for mail in mails if query.test(mail)] + return sorted(mails, key=lambda mail: mail.date, reverse=True) + + def mail(self, mail_id): + return self.mailset.get(mail_id) + + def search_contacts(self, query): + return self.contacts.search(query) + + def mark_as_read(self, mail_id): + self.mailset.mark_as_read(mail_id) + self.tagsset.mark_as_read(self.mail(mail_id).tags) + + def delete_mail(self, mail_id): + purged = self.mailset.delete(mail_id) + if not purged: + self.tagsset.increment_tag_total_count('trash') + + def update_tags_for(self, mail_id, new_tags): + mail = self.mail(mail_id) + + new_tags_set = set(new_tags) + old_tags_set = set(mail.tags) + + increment_set = new_tags_set - old_tags_set + decrement_set = old_tags_set - new_tags_set + + map(lambda x: self.tagsset.increment_tag_total_count(x), increment_set) + map(lambda x: self.tagsset.decrement_tag_total_count(x), decrement_set) + + mail.tags = new_tags + + def send(self, mail): + mail = Mail.from_json(mail) + self.mailset.update(mail) + self.tagsset.increment_tag_total_count('sent') + self.tagsset.decrement_tag_total_count('drafts') + return mail.ident + + def save_draft(self, mail): + mail = self.mailset.add_draft(Mail.from_json(mail)) + return mail.ident + + def update_draft(self, mail): + mail = Mail.from_json(mail) + self.mailset.update(mail) + return mail.ident + + def draft_reply_for(self, mail_id): + return self.mailset.find(draft_reply_for=mail_id) diff --git a/fake-service/app/adapter/mailset.py b/fake-service/app/adapter/mailset.py new file mode 100644 index 00000000..bf7e8c67 --- /dev/null +++ b/fake-service/app/adapter/mailset.py @@ -0,0 +1,69 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +from mail import Mail + + +class MailSet: + + def __init__(self): + self.ident = 0 + self.mails = {} + + def add(self, mbox_mail): + self.mails[self.ident] = Mail(mbox_mail, self.ident) + self.ident += 1 + + def values(self): + return self.mails.values() + + def get(self, mail_id): + return self.mails.get(mail_id) + + def mark_as_read(self, mail_id): + mail = self.get(mail_id) + mail.status.append('read') + + def delete(self, mail_id): + """ + Returns True if the email got purged, + else returns False meaning the email got moved to trash + """ + + mail = self.get(mail_id) + if 'trash' in mail.tags: + del self.mails[mail_id] + return True + mail.tags.append('trash') + return False + + def update(self, mail): + self.mails[mail.ident] = mail + + def add_draft(self, mail): + mail.ident = self.ident + self.mails[mail.ident] = mail + self.ident += 1 + return mail + + def find(self, draft_reply_for): + match = [ + mail + for mail in self.mails.values + () if mail.draft_reply_for == draft_reply_for] + if len(match) == 0: + return None + else: + return match[0] diff --git a/fake-service/app/adapter/tag.py b/fake-service/app/adapter/tag.py new file mode 100644 index 00000000..b866d789 --- /dev/null +++ b/fake-service/app/adapter/tag.py @@ -0,0 +1,40 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + + +class Tag: + DEFAULT_TAGS = ["inbox", "sent", "trash", "drafts"] + + def __init__(self, name, ident): + self.counts = { + 'total': 0, + 'read': 0, + 'starred': 0, + 'reply': 0 + } + + self.ident = ident + self.name = name.lower() + self.default = name in self.DEFAULT_TAGS + + def increment_count(self): + self.counts['total'] += 1 + + def increment_read(self): + self.counts['read'] += 1 + + def decrement_count(self): + self.counts['total'] -= 1 diff --git a/fake-service/app/adapter/tagsset.py b/fake-service/app/adapter/tagsset.py new file mode 100644 index 00000000..8e0d7ca3 --- /dev/null +++ b/fake-service/app/adapter/tagsset.py @@ -0,0 +1,57 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +from tag import Tag + + +class TagsSet: + + DEFAULT_TAGS = ["inbox", "sent", "trash", "drafts"] + + def __init__(self): + self.ident = 0 + self.tags = {} + self.tags = {tag: self._create_new_tag(tag) for tag in self.DEFAULT_TAGS} + + def add(self, mbox_mail): + tags = filter(len, mbox_mail.get('X-TW-Pixelated-Tags').split(', ')) + for tag in tags: + tag = self._create_new_tag(tag) + tag.increment_count() + + def all_tags(self): + return self.tags.values() + + def mark_as_read(self, tags): + for tag in tags: + tag = tag.lower() + tag = self.tags.get(tag) + tag.increment_read() + + def increment_tag_total_count(self, tagname): + tag = self.tags.get(tagname) + if tag: + tag.increment_count() + else: + self._create_new_tag(tagname) + + def decrement_tag_total_count(self, tag): + self.tags.get(tag).decrement_count() + + def _create_new_tag(self, tag): + tag = Tag(tag, self.ident) + tag = self.tags.setdefault(tag.name, tag) + self.ident += 1 + return tag diff --git a/fake-service/app/pixelated_user_agent.py b/fake-service/app/pixelated_user_agent.py new file mode 100644 index 00000000..5931bce5 --- /dev/null +++ b/fake-service/app/pixelated_user_agent.py @@ -0,0 +1,196 @@ +# # Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +from flask import Flask, request, Response, redirect +import os +import csv +import json +import datetime +import mailbox +import StringIO +import requests +from adapter import MailService +from search import SearchQuery + +app = Flask(__name__, static_url_path='', static_folder='../../web-ui/app') +MEDIUM_TAGGED_URL = 'https://static.wazokazi.is/py-mediumtagged.tar.gz' +client = None +converter = None +account = None +autoload = os.environ.get('AUTOLOAD', False) +mail_service = MailService() + + +def respond_json(entity): + response = json.dumps(entity) + return Response(response=response, mimetype="application/json") + + +@app.route('/disabled_features') +def disabled_features(): + return respond_json([]) + + +@app.route('/mails', methods=['POST']) +def save_draft_or_send(): + mail = request.json + if mail['ident']: + ident = mail_service.send(mail) + else: + ident = mail_service.save_draft(mail) + + return respond_json({'ident': ident}) + + +@app.route('/mails', methods=['PUT']) +def update_draft(): + mail = request.json + ident = mail_service.update_draft(mail) + return respond_json({'ident': ident}) + + +@app.route('/mails') +def mails(): + query = SearchQuery.compile(request.args.get('q', '')) + page = request.args.get('p', '') + window_size = request.args.get('w', '') + fetched_mails = mail_service.mails(query, page, window_size) + + mails = [mail.__dict__ for mail in fetched_mails] + response = { + "stats": { + "total": len(mails), + "read": 0, + "starred": 0, + "replied": 0 + }, + "mails": mails + } + + return respond_json(response) + + +@app.route('/mail/<int:mail_id>', methods=['DELETE']) +def delete_mails(mail_id): + mail_service.delete_mail(mail_id) + return respond_json(None) + + +@app.route('/tags') +def tags(): + tags = mail_service.tagsset.all_tags() + return respond_json([tag.__dict__ for tag in tags]) + + +@app.route('/mail/<int:mail_id>') +def mail(mail_id): + return respond_json(mail_service.mail(mail_id).__dict__) + + +@app.route('/mail/<int:mail_id>/tags', methods=['POST']) +def mail_tags(mail_id): + new_tags = request.json['newtags'] + mail_service.update_tags_for(mail_id, new_tags) + return respond_json(request.json['newtags']) + + +@app.route('/mail/<int:mail_id>/read', methods=['POST']) +def mark_mail_as_read(mail_id): + mail_service.mark_as_read(mail_id) + return "" + + +@app.route('/contacts') +def contacts(): + contacts_query = request.args.get('q') + return respond_json( + {'contacts': mail_service.search_contacts(contacts_query)}) + + +@app.route('/draft_reply_for/<int:mail_id>') +def draft_reply_for(mail_id): + mail = mail_service.draft_reply_for(mail_id) + if mail: + return respond_json(mail.__dict__) + else: + return respond_json(None) + + +def utf_8_encoder(unicode_csv_data): + for line in unicode_csv_data: + yield line.encode('utf-8') + + +@app.route('/control/mailset/csv/load', methods=['POST']) +def load_mailset_from_csv(): + csv_data = request.form.keys()[0] + csv_string = StringIO.StringIO(csv_data) + csv_reader = csv.reader(utf_8_encoder(csv_string)) + headers = csv_reader.next() + messages = [] + for row in csv_reader: + mail = "" + row[3] = ', '.join(filter(len, row[3].split(' '))) + for header, value in zip(headers, row): + if header == 'Body': + mail += "\n" + else: + mail += header + ": " + mail += value + "\n" + messages.append(mailbox.mboxMessage(mail)) + mail_service.index_messages(messages) + return respond_json(None) + + +@app.route('/control/mailset/<mailset>/load', methods=['POST']) +def load_mailset(mailset): + import os + from tarfile import TarFile + from gzip import GzipFile + mbox_root = os.path.join(os.environ['HOME'], 'mailsets') + if not os.path.isdir(os.path.join(mbox_root)): + os.mkdir(mbox_root) + + if len(os.listdir(mbox_root)) == 0: + response = requests.get(MEDIUM_TAGGED_URL, verify=False) + mbox_archive_path = os.path.join(mbox_root, 'py-mediumtagged.tar.gz') + mbox_archive = open(mbox_archive_path, 'w') + mbox_archive.write(response.content) + mbox_archive.close() + gzippedfile = GzipFile(filename=mbox_archive_path) + tarfile = TarFile(fileobj=gzippedfile) + tarfile.extractall(path=mbox_root) + + mail_service.reset() + mail_service.load_mailset() + + return respond_json(None) + + +@app.route('/') +def index(): + global autoload + if autoload: + load_mailset('mediumtagged') + autoload = False + + return app.send_static_file('index.html') + + +def setup(): + app.run(host="0.0.0.0", debug=True, port=4567) + + +if __name__ == '__main__': + setup() diff --git a/fake-service/app/search/__init__.py b/fake-service/app/search/__init__.py new file mode 100644 index 00000000..d6d7b07c --- /dev/null +++ b/fake-service/app/search/__init__.py @@ -0,0 +1,16 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +from search_query import SearchQuery diff --git a/fake-service/app/search/search_query.py b/fake-service/app/search/search_query.py new file mode 100644 index 00000000..34e68601 --- /dev/null +++ b/fake-service/app/search/search_query.py @@ -0,0 +1,86 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +from scanner import StringScanner, StringRegexp +import re + + +def _next_token(): + return StringRegexp('[^\s]+') + + +def _separators(): + return StringRegexp('[\s&]+') + + +def _compile_tag(compiled, token): + tag = token.split(":").pop() + if token[0] == "-": + compiled["not_tags"].append(tag) + else: + compiled["tags"].append(tag) + return compiled + + +class SearchQuery: + + @staticmethod + def compile(query): + compiled = {"tags": [], "not_tags": [], "general": []} + + scanner = StringScanner(query.encode('utf8').replace("\"", "")) + first_token = True + while not scanner.is_eos: + token = scanner.scan(_next_token()) + + if not token: + scanner.skip(_separators()) + continue + + if ":" in token: + compiled = _compile_tag(compiled, token) + elif first_token: + compiled["general"].append(token) + + if not first_token: + first_token = True + + compiled["general"] = ' '.join(compiled["general"]) + return SearchQuery(compiled) + + def __init__(self, compiled): + self.compiled = compiled + + def test(self, mail): + if 'all' in self.compiled.get('tags'): + return True + + if set(self.compiled.get('not_tags')).intersection(set(mail.tags)): + return False + + if set(self.compiled.get('tags')).intersection(set(mail.tags)): + return True + + if self.compiled.get('general'): + search_terms = re.compile( + self.compiled['general'], + flags=re.IGNORECASE) + if search_terms.search(mail.subject+' '+mail.body): + return True + + if not [v for v in self.compiled.values() if v]: + return True + + return False diff --git a/fake-service/fake-user-agent-daemon b/fake-service/fake-user-agent-daemon new file mode 100755 index 00000000..621f463e --- /dev/null +++ b/fake-service/fake-user-agent-daemon @@ -0,0 +1,33 @@ +#!/bin/bash +export PYTHONPATH=`pwd`/app:$PYTHONPATH + +USER_AGENT_PORT=4567 + +function do_stop() { + test -e gunicorn.pid && (kill -9 $(cat gunicorn.pid) && rm gunicorn.pid && echo "Stopped User Agent") || echo "User Agent is not running" +} + +function do_start() { + echo "gunicorn pixelated_user_agent:app -b 0.0.0.0:$USER_AGENT_PORT -D -p gunicorn.pid --log-file=gunicorn.log" + gunicorn pixelated_user_agent:app -b 0.0.0.0:$USER_AGENT_PORT -D -p gunicorn.pid --log-file=gunicorn.log +} + +case $1 in + restart) + do_stop && do_start + ;; + start) + do_start + ;; + stop) + do_stop + ;; + killall) + pgrep -f gunicorn | xargs kill -9 + rm -rf gunicorn.pid + ;; + *) + echo "usage: start|stop|restart|killall" + ;; +esac + diff --git a/fake-service/features/compose_save_draft_and_send.feature b/fake-service/features/compose_save_draft_and_send.feature new file mode 100644 index 00000000..7be7de0b --- /dev/null +++ b/fake-service/features/compose_save_draft_and_send.feature @@ -0,0 +1,30 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + +Feature: compose mail, save draft and send mail + + @wip + Scenario: user composes and email, save the draft, later sends the draft and checks the sent message + Given I compose a message with + | subject | body | + | Pixelated rocks! | You should definitely use it. Cheers, User. | + And for the 'To' field I type 'ab' and chose the first contact that shows + And I save the draft + When I open the saved draft and send it + Then I see that mail under the 'sent' tag + When I open that mail + Then I see that the subject reads 'Pixelated rocks!' + And I see that the body reads 'You should definitely use it. Cheers, User.' diff --git a/fake-service/features/environment.py b/fake-service/features/environment.py new file mode 100644 index 00000000..415391d6 --- /dev/null +++ b/fake-service/features/environment.py @@ -0,0 +1,38 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +from selenium import webdriver + + +def before_feature(context, feature): + #context.browser = webdriver.Firefox() + context.browser = webdriver.PhantomJS() + context.browser.set_window_size(1280, 1024) + context.browser.implicitly_wait(5) + context.browser.set_page_load_timeout(60) # wait for data + context.browser.get('http://localhost:4567/') + + +def after_feature(context, feature): + context.browser.quit() + + +def take_screenshot(context): + context.browser.save_screenshot('/tmp/screenshot.jpeg') + + +def save_source(context): + with open('/tmp/source.html', 'w') as out: + out.write(context.browser.page_source.encode('utf8')) diff --git a/fake-service/features/forward_trash_archive.feature b/fake-service/features/forward_trash_archive.feature new file mode 100644 index 00000000..fd7c6f6e --- /dev/null +++ b/fake-service/features/forward_trash_archive.feature @@ -0,0 +1,28 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + +Feature: forward_trash_archive + Scenario: User forwards a mail, add CC and BCC address, later trash and archive the mail + When I open the first mail in the 'inbox' + Then I choose to forward this mail + And for the 'CC' field I type 'ab' and chose the first contact that shows + And for the 'Bcc' field I type 'fr' and chose the first contact that shows + And I forward this mail + When I open the first mail in the 'sent' + Then I see the mail has a cc and a bcc recipient + And I remove all tags + And I choose to trash + Then I see that mail under the 'trash' tag diff --git a/fake-service/features/search_and_destroy.feature b/fake-service/features/search_and_destroy.feature new file mode 100644 index 00000000..5b0d550d --- /dev/null +++ b/fake-service/features/search_and_destroy.feature @@ -0,0 +1,27 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + +Feature: search html mail and destroy + + Scenario: User searches for a mail and deletes it + When I search for a mail with the words "this is a html mail" + When I open the first mail in the mail list + Then I see one or more mails in the search results + Then I see if the mail has html content + When I try to delete the first mail + # Then I learn that the mail was deleted + When I select the tag 'trash' + Then the deleted mail is there diff --git a/fake-service/features/steps/__init__.py b/fake-service/features/steps/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/fake-service/features/steps/__init__.py diff --git a/fake-service/features/steps/common.py b/fake-service/features/steps/common.py new file mode 100644 index 00000000..ae46b04a --- /dev/null +++ b/fake-service/features/steps/common.py @@ -0,0 +1,83 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.wait import WebDriverWait +from selenium.common.exceptions import NoSuchElementException +from hamcrest import * + +def wait_until_element_is_invisible_by_locator(context, locator_tuple): + wait = WebDriverWait(context.browser, 10) + wait.until(EC.invisibility_of_element_located(locator_tuple)) + +def wait_for_user_alert_to_disapear(context): + wait_until_element_is_invisible_by_locator(context, (By.ID, 'user-alerts')) + +def wait_until_element_is_visible_by_locator(context, locator_tuple): + wait = WebDriverWait(context.browser, 10) + wait.until(EC.visibility_of_element_located(locator_tuple)) + + +def fill_by_xpath(context, xpath, text): + field = context.browser.find_element_by_xpath(xpath) + field.send_keys(text) + +def take_screenshot(context, filename): + context.browser.save_screenshot(filename) + +def dump_source_to(context, filename): + with open(filename, 'w') as out: + out.write(context.browser.page_source.encode('utf8')) + +def page_has_css(context, css): + try: + find_element_by_css_selector(context, css) + return True + except NoSuchElementException: + return False + +def find_element_by_xpath(context, xpath): + return context.browser.find_element_by_xpath(xpath) + +def find_element_by_css_selector(context, css_selector): + return context.browser.find_element_by_css_selector(css_selector) + +def find_elements_by_css_selector(context, css_selector): + return context.browser.find_elements_by_css_selector(css_selector) + +def find_element_containing_text(context, text, element_type='*'): + return context.browser.find_element_by_xpath("//%s[contains(.,'%s')]" % (element_type, text)) + +def element_should_have_content(context, css_selector, content): + e = find_element_by_css_selector(context, css_selector) + assert_that(e.text, equal_to(content)) + +def wait_until_button_is_visible(context, title): + wait = WebDriverWait(context.browser, 10) + locator_tuple = (By.XPATH, ("//%s[contains(.,'%s')]" % ('button', title))) + wait.until(EC.visibility_of_element_located(locator_tuple)) + +def click_button(context, title): + button = find_element_containing_text(context, title, element_type='button') + button.click() + +def mail_subject(context): + e = find_element_by_css_selector(context, '#mail-view .subject') + return e.text + +def reply_subject(context): + e = find_element_by_css_selector(context, '#reply-subject') + return e.text diff --git a/fake-service/features/steps/compose.py b/fake-service/features/steps/compose.py new file mode 100644 index 00000000..24c2e679 --- /dev/null +++ b/fake-service/features/steps/compose.py @@ -0,0 +1,73 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +from behave import given, when +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.wait import WebDriverWait +from time import sleep +from common import * +from hamcrest import * + + +@given('I compose a message with') +def impl(context): + take_screenshot(context, '/tmp/screenshot.jpeg') + toggle = context.browser.find_element_by_id('compose-mails-trigger') + toggle.click() + + for row in context.table: + fill_by_xpath(context, '//*[@id="subject"]', row['subject']) + fill_by_xpath(context, '//*[@id="text-box"]', row['body']) + +@given("for the '{recipients_field}' field I type '{to_type}' and chose the first contact that shows") +def choose_impl(context, recipients_field, to_type): + browser = context.browser + browser.find_element_by_css_selector( + '#recipients-to-area span input.tt-input' + ).click() + recipients_field = recipients_field.lower() + css_selector = '#recipients-%s-area' % recipients_field + recipients_element = browser.find_element_by_css_selector(css_selector) + recipients_element.find_element_by_css_selector( + '.tt-input' + ).send_keys(to_type) + wait_until_element_is_visible_by_locator(context, (By.CSS_SELECTOR, '.tt-dropdown-menu div div')) + browser.find_element_by_css_selector('.tt-dropdown-menu div div').click() + +@then("for the '{recipients_field}' field I type '{to_type}' and chose the first contact that shows") +def choose_impl(context, recipients_field, to_type): + recipients_field = recipients_field.lower() + browser = context.browser + field = browser.find_element_by_css_selector( + '#recipients-%s-area .tt-input' % recipients_field + ) + field.send_keys(to_type) + sleep(1) + find_element_by_css_selector(context, '.tt-dropdown-menu div div').click() + +@given('I save the draft') +def save_impl(context): + context.browser.find_element_by_id('draft-button').click() + + +@when('I open the saved draft and send it') +def send_impl(context): + context.execute_steps(u"when I select the tag 'drafts'") + context.execute_steps(u"when I open the first mail in the mail list") + assert_that(is_not(page_has_css(context, '#send-button[disabled]'))) + click_button(context, 'Send') + element_should_have_content(context, '#user-alerts', 'Your message was sent!') + diff --git a/fake-service/features/steps/mail_list.py b/fake-service/features/steps/mail_list.py new file mode 100644 index 00000000..2ea90c2b --- /dev/null +++ b/fake-service/features/steps/mail_list.py @@ -0,0 +1,64 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +import re +from behave import * +from common import * + +def find_current_mail(context): + return find_element_by_xpath(context, '//*[@id="mail-list"]/li[@id="mail-%s"]//a' % context.current_mail_id) + + +def check_current_mail_is_visible(context): + find_current_mail(context) + +def open_current_mail(context): + e = find_current_mail(context) + e.click() + +@then('I see that mail under the \'{tag}\' tag') +def impl(context, tag): + context.execute_steps("when I select the tag '%s'" % tag) + check_current_mail_is_visible(context) + +@when('I open that mail') +def impl(context): + open_current_mail(context) + +@when('I open the first mail in the mail list') +def impl(context): + elements = context.browser.find_elements_by_xpath('//*[@id="mail-list"]//a') + context.current_mail_id = elements[0].get_attribute('href').split('/')[-1] + elements[0].click() + +@when('I open the first mail in the \'{tag}\'') +def impl(context, tag): + context.browser.execute_script('window.scrollBy(0, -200)') + context.execute_steps(u"When I select the tag '%s'" % tag) + context.execute_steps(u'When I open the first mail in the mail list') + +@then('I open the mail I previously tagged') +def impl(context): + open_current_mail(context) + +@then('I see the mail I sent') +def impl(context): + src = context.browser.page_source + assert_that(src, contains_string(context.reply_subject)) + +@then('the deleted mail is there') +def impl(context): + check_current_mail_is_visible(context) + diff --git a/fake-service/features/steps/mail_view.py b/fake-service/features/steps/mail_view.py new file mode 100644 index 00000000..96208acc --- /dev/null +++ b/fake-service/features/steps/mail_view.py @@ -0,0 +1,96 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +import re +from selenium.webdriver.common.keys import Keys +from behave import * +from common import * +from hamcrest import * +from time import sleep + +@then('I see that the subject reads \'{subject}\'') +def impl(context, subject): + e = find_element_by_css_selector(context, '#mail-view .subject') + assert_that(e.text, equal_to(subject)) + +@then('I see that the body reads \'{expected_body}\'') +def impl(context, expected_body): + e = find_element_by_css_selector(context, '#mail-view .bodyArea') + assert_that(e.text, equal_to(expected_body)) + +@then('that email has the \'{tag}\' tag') +def impl(context, tag): + elements = find_elements_by_css_selector(context, '#mail-view .tagsArea .tag') + tags = [e.text for e in elements] + assert_that(tags, has_item(tag.upper())) + +@when('I add the tag \'{tag}\' to that mail') +def impl(context, tag): + context.browser.execute_script("$('#new-tag-button').click();") + context.browser.execute_script("$('#new-tag-input').val('%s');" % tag) + e = find_element_by_css_selector(context, '#new-tag-input') + e.send_keys(Keys.ENTER) + +@then('I reply to it') +def impl(context): + click_button(context, 'Reply') + click_button(context, 'Send') + context.reply_subject = reply_subject(context) + +@then('I see if the mail has html content') +def impl(context): + e = find_element_by_css_selector(context, '#mail-view .bodyArea') + h2 = e.find_element_by_css_selector("h2[style*='color: #3f4944']") + assert_that(h2.text, contains_string('cborim')) + +@when('I try to delete the first mail') +def impl(context): + context.execute_steps(u"When I open the first mail in the mail list") + find_element_by_css_selector(context, '#mail-view #view-more-actions').click() + context.browser.execute_script("$('#delete-button-top').click();") + + e = find_element_by_css_selector(context, '#user-alerts') + assert_that(e.text, equal_to('Your message was moved to trash!')) + +@then('I choose to forward this mail') +def impl(context): + wait_until_button_is_visible(context, 'Forward') + click_button(context, 'Forward') + +@then('I forward this mail') +def impl(context): + wait_until_button_is_visible(context, 'Send') + click_button(context, 'Send') + +@then('I remove all tags') +def impl(context): + e = find_element_by_css_selector(context, '.tagsArea') + tags = e.find_elements_by_css_selector('.tag') + assert_that(len(tags), greater_than(0)) + for tag in tags: + tag.click() + +@then('I choose to trash') +def impl(context): + wait_until_button_is_visible(context, 'Trash message') + click_button(context, 'Trash message') + +@then('I see the mail has a cc and a bcc recipient') +def impl(context): + cc = find_element_by_css_selector(context, '.msg-header .cc') + bcc = find_element_by_css_selector(context, '.msg-header .bcc') + + assert_that(cc.text, matches_regexp('[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+')) + diff --git a/fake-service/features/steps/search.py b/fake-service/features/steps/search.py new file mode 100644 index 00000000..9377bc5f --- /dev/null +++ b/fake-service/features/steps/search.py @@ -0,0 +1,33 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +from selenium.webdriver.common.keys import Keys +from behave import * +from common import * +from hamcrest import * +from time import sleep + +@when('I search for a mail with the words "{search_term}"') +def impl(context, search_term): + search_field = find_element_by_css_selector(context, '#search-trigger input[type="search"]') + search_field.send_keys(search_term) + search_field.send_keys(Keys.ENTER) + sleep(1) + +@then('I see one or more mails in the search results') +def impl(context): + lis = find_elements_by_css_selector(context, '#mail-list li') + assert_that(len(lis), greater_than_or_equal_to(1)) + diff --git a/fake-service/features/steps/tag_list.py b/fake-service/features/steps/tag_list.py new file mode 100644 index 00000000..f62c390e --- /dev/null +++ b/fake-service/features/steps/tag_list.py @@ -0,0 +1,33 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +from behave import * +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.wait import WebDriverWait +from common import * + +def click_first_element_with_class(context, classname): + elements = context.browser.find_elements_by_class_name(classname) + elements[0].click() + + +@when('I select the tag \'{tag}\'') +def impl(context, tag): + wait_for_user_alert_to_disapear(context) + click_first_element_with_class(context, 'left-off-canvas-toggle') + context.browser.execute_script("window.scrollBy(0, -200)") + e = context.browser.find_element_by_xpath('//*[@id="tag-list"]/ul/li[contains(translate(., "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"), "%s")]' % tag) + e.click() diff --git a/fake-service/features/tag_and_reply.feature b/fake-service/features/tag_and_reply.feature new file mode 100644 index 00000000..5e28827f --- /dev/null +++ b/fake-service/features/tag_and_reply.feature @@ -0,0 +1,28 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + +Feature: tagging and replying + Scenario: User tags a mail, replies to it then checks that mail is in the right tag + When I open the first mail in the 'inbox' + Then that email has the 'inbox' tag + When I add the tag 'website' to that mail + Then I see that mail under the 'website' tag + And I open the mail I previously tagged + And I reply to it + When I select the tag 'sent' + Then I see the mail I sent + + diff --git a/fake-service/go b/fake-service/go new file mode 100755 index 00000000..d0d83096 --- /dev/null +++ b/fake-service/go @@ -0,0 +1,6 @@ +#!/usr/bin/env python +from app.pixelated_user_agent import setup +import os +os.environ['PIXELATED_UA_CFG']='../config/pixelated_ua.cfg' +setup() + diff --git a/fake-service/mailset.csv.example b/fake-service/mailset.csv.example new file mode 100644 index 00000000..25a78e31 --- /dev/null +++ b/fake-service/mailset.csv.example @@ -0,0 +1,14 @@ +From,To,Date,X-TW-Pixelated-Tags,Subject,Body +Nelson Hines,me,2014-09-01,football inbox,European Champions League predictions, +Rodrigo Vargas,me,2014-08-29,ux cryptography,A new secure email experience,"Hey there! Have you checked Pixelated already? " +Anita Foster,me,2014-08-29,philosophy,The importance of privacy, +Rufus Reynolds,me,2014-08-29,art,Gallery opening in Hamburg, +Natasha Carr,me,2014-08-28,travel,What to do in Tokyo?, +Linda Smith,me,2014-08-27,,Happy birthday!, +Geraldine Walsh,me,2014-08-27,ux,Lean UX workshop, +Clyde Fox,me,2014-08-27,cryptography software,Cryptoparty Porto Alegre, +Mark Garcia,me,2014-08-26,politics,Migrant workers Photography Exhibition, +Leona Payne,me,2014-08-25,,Online Philosophy Course, +Natalia Jones,me,2014-08-25,family,Grandma's gift, +Linda Smith,me,2014-08-25,ux,UX Book Club - September Meeting, +Anita Foster,me,2014-06-24,,Open source conferences in Africa, diff --git a/fake-service/requirements.txt b/fake-service/requirements.txt new file mode 100644 index 00000000..5dd57828 --- /dev/null +++ b/fake-service/requirements.txt @@ -0,0 +1,5 @@ +flask==0.10.1 +scanner==0.0.5 +requests==2.3.0 +gunicorn==19.1.1 +python-dateutil diff --git a/fake-service/test-requirements.txt b/fake-service/test-requirements.txt new file mode 100644 index 00000000..021c68bb --- /dev/null +++ b/fake-service/test-requirements.txt @@ -0,0 +1,4 @@ +PyHamcrest==1.8.0 +behave==1.2.4 +selenium==2.42.1 + |