From d3cc85c784beb3c4390e762155c336791978e516 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 31 Jul 2014 14:51:10 -0400 Subject: [PATCH] TESTS: Use Pretender in test mode for more flexible server responses --- .jshintrc | 1 + .../discourse/controllers/login.js.es6 | 9 +- .../modal/create_account.js.handlebars | 2 +- test/javascripts/fixtures/session_fixtures.js | 2 - .../fixtures/site_settings_fixtures.js | 2 +- .../helpers/create-pretender.js.es6 | 52 ++ .../integration/header-anonymous-test.js.es6 | 2 - .../integration/header-test-staff.js.es6 | 3 - .../integration/sign-in-test.js.es6 | 54 +- test/javascripts/test_helper.js | 40 +- .../javascripts/fake_xml_http_request.js | 480 +++++++++++ vendor/assets/javascripts/pretender.js | 128 +++ vendor/assets/javascripts/route-recognizer.js | 778 ++++++++++++++++++ 13 files changed, 1515 insertions(+), 38 deletions(-) delete mode 100644 test/javascripts/fixtures/session_fixtures.js create mode 100644 test/javascripts/helpers/create-pretender.js.es6 create mode 100644 vendor/assets/javascripts/fake_xml_http_request.js create mode 100644 vendor/assets/javascripts/pretender.js create mode 100644 vendor/assets/javascripts/route-recognizer.js diff --git a/.jshintrc b/.jshintrc index f7f699b5ebd..a8d17196ecc 100644 --- a/.jshintrc +++ b/.jshintrc @@ -13,6 +13,7 @@ "module", "moduleFor", "moduleForComponent", + "Pretender", "sandbox", "integration", "controllerFor", diff --git a/app/assets/javascripts/discourse/controllers/login.js.es6 b/app/assets/javascripts/discourse/controllers/login.js.es6 index c96a836d188..2fbf865c5e2 100644 --- a/app/assets/javascripts/discourse/controllers/login.js.es6 +++ b/app/assets/javascripts/discourse/controllers/login.js.es6 @@ -11,10 +11,12 @@ export default Discourse.Controller.extend(Discourse.ModalFunctionality, { needs: ['modal', 'createAccount'], authenticate: null, loggingIn: false, + loggedIn: false, resetForm: function() { this.set('authenticate', null); this.set('loggingIn', false); + this.set('loggedIn', false); }, site: function() { @@ -32,9 +34,7 @@ export default Discourse.Controller.extend(Discourse.ModalFunctionality, { return this.get('loggingIn') ? I18n.t('login.logging_in') : I18n.t('login.title'); }.property('loggingIn'), - loginDisabled: function() { - return this.get('loggingIn'); - }.property('loggingIn'), + loginDisabled: Em.computed.or('loggingIn', 'loggedIn'), showSignupLink: function() { return !Discourse.SiteSettings.invite_only && @@ -68,6 +68,7 @@ export default Discourse.Controller.extend(Discourse.ModalFunctionality, { } self.flash(result.error, 'error'); } else { + self.set('loggedIn', true); // Trigger the browser's password manager using the hidden static login form: var $hidden_login_form = $('#hidden-login-form'); $hidden_login_form.find('input[name=username]').val(self.get('loginName')); @@ -76,7 +77,7 @@ export default Discourse.Controller.extend(Discourse.ModalFunctionality, { $hidden_login_form.submit(); } - }, function() { + }, function(e) { // Failed to login if (self.blank('loginName') || self.blank('loginPassword')) { self.flash(I18n.t('login.blank_username_or_password'), 'error'); diff --git a/app/assets/javascripts/discourse/templates/modal/create_account.js.handlebars b/app/assets/javascripts/discourse/templates/modal/create_account.js.handlebars index 43c07ef7631..7e675b3a339 100644 --- a/app/assets/javascripts/discourse/templates/modal/create_account.js.handlebars +++ b/app/assets/javascripts/discourse/templates/modal/create_account.js.handlebars @@ -31,7 +31,7 @@ {{input value=accountUsername id="new-account-username" maxlength=Discourse.SiteSettings.max_username_length}} -  {{input-tip validation=usernameValidation}} +  {{input-tip validation=usernameValidation id="username-validation"}} diff --git a/test/javascripts/fixtures/session_fixtures.js b/test/javascripts/fixtures/session_fixtures.js deleted file mode 100644 index e7db28f8656..00000000000 --- a/test/javascripts/fixtures/session_fixtures.js +++ /dev/null @@ -1,2 +0,0 @@ -/*jshint maxlen:10000000 */ -Discourse.URL_FIXTURES["/session"] = [ { error: "Incorrect username, email or password" } ]; diff --git a/test/javascripts/fixtures/site_settings_fixtures.js b/test/javascripts/fixtures/site_settings_fixtures.js index 50a4ba61362..d1ce7347d3e 100644 --- a/test/javascripts/fixtures/site_settings_fixtures.js +++ b/test/javascripts/fixtures/site_settings_fixtures.js @@ -1,3 +1,3 @@ /*jshint maxlen:10000000 */ -Discourse.SiteSettingsOriginal = {"title":"Discourse Meta","logo_url":"/assets/logo.png","logo_small_url":"/assets/logo-single.png","traditional_markdown_linebreaks":false,"top_menu":"latest|new|unread|read|starred|categories","post_menu":"like|edit|flag|delete|share|bookmark|admin|reply","share_links":"twitter|facebook|google+|email","track_external_right_clicks":false,"must_approve_users":false,"ga_tracking_code":"UA-33736483-2","ga_domain_name":"","enable_long_polling":true,"polling_interval":3000,"anon_polling_interval":30000,"min_post_length":20,"max_post_length":16000,"min_topic_title_length":15,"max_topic_title_length":255,"min_private_message_title_length":2,"allow_uncategorized_topics":true,"min_search_term_length":3,"flush_timings_secs":5,"suppress_reply_directly_below":true,"email_domains_blacklist":"mailinator.com","email_domains_whitelist":null,"version_checks":true,"min_title_similar_length":10,"min_body_similar_length":15,"category_colors":"BF1E2E|F1592A|F7941D|9EB83B|3AB54A|12A89D|25AAE2|0E76BD|652D90|92278F|ED207B|8C6238|231F20|808281|B3B5B4|283890","max_upload_size_kb":1024,"category_featured_topics":6,"favicon_url":"/assets/favicon.ico","dynamic_favicon":false,"uncategorized_name":"uncategorized","uncategorized_color":"AB9364","uncategorized_text_color":"FFFFFF","invite_only":false,"login_required":false,"min_password_length":8,"enable_local_logins":true,"enable_google_logins":false,"enable_google_oauth2_logins":false,"enable_yahoo_logins":false,"enable_twitter_logins":false,"enable_facebook_logins":false,"enable_cas_logins":false,"enable_github_logins":false,"educate_until_posts":2,"topic_views_heat_low":1000,"topic_views_heat_medium":2000,"topic_views_heat_high":5000,"min_private_message_post_length":5,"faq_url":"","tos_url":"","privacy_policy_url":"","authorized_extensions":".jpg|.jpeg|.png|.gif|.txt","relative_date_duration":14,"delete_removed_posts_after":24,"delete_user_max_post_age":7, "default_code_lang": "lang-auto", "suppress_uncategorized_badge": true, "min_username_length": 3, "max_username_length": 20}; +Discourse.SiteSettingsOriginal = {"title":"Discourse Meta","logo_url":"/assets/logo.png","logo_small_url":"/assets/logo-single.png","mobile_logo_url":"","favicon_url":"//meta.discourse.org/uploads/default/2499/79d53726406d87af.ico","allow_user_locale":false,"suggested_topics":7,"track_external_right_clicks":false,"ga_universal_tracking_code":"","ga_universal_domain_name":"auto","ga_tracking_code":"UA-33736483-2","ga_domain_name":"","top_menu":"latest|new|unread|starred|categories|top","post_menu":"like|share|flag|edit|bookmark|delete|admin|reply","post_menu_hidden_items":"edit|delete|admin","share_links":"twitter|facebook|google+|email","category_colors":"BF1E2E|F1592A|F7941D|9EB83B|3AB54A|12A89D|25AAE2|0E76BD|652D90|92278F|ED207B|8C6238|231F20|808281|B3B5B4|283890","enable_mobile_theme":true,"relative_date_duration":14,"category_featured_topics":4,"fixed_category_positions":false,"show_subcategory_list":false,"posts_per_page":20,"enable_badges":true,"invite_only":false,"login_required":false,"must_approve_users":false,"enable_local_logins":true,"allow_new_registrations":true,"enable_google_logins":true,"enable_google_oauth2_logins":false,"enable_yahoo_logins":true,"enable_twitter_logins":true,"enable_facebook_logins":true,"enable_github_logins":true,"enable_sso":false,"min_username_length":3,"max_username_length":20,"min_password_length":8,"enable_names":true,"invites_shown":30,"delete_user_max_post_age":60,"delete_all_posts_max":15,"min_post_length":20,"min_private_message_post_length":10,"max_post_length":32000,"min_topic_title_length":15,"max_topic_title_length":255,"min_private_message_title_length":2,"allow_uncategorized_topics":true,"min_title_similar_length":10,"min_body_similar_length":15,"edit_history_visible_to_public":true,"delete_removed_posts_after":24,"traditional_markdown_linebreaks":false,"suppress_reply_directly_below":true,"suppress_reply_directly_above":true,"newuser_max_images":0,"newuser_max_attachments":0,"display_name_on_posts":true,"short_progress_text_threshold":10000,"default_code_lang":"lang-auto","autohighlight_all_code":false,"email_in":false,"max_image_size_kb":3072,"max_attachment_size_kb":1024,"authorized_extensions":".jpg|.jpeg|.png|.gif|.svg|.txt|.ico|.yml","max_image_width":690,"max_image_height":500,"allow_profile_backgrounds":true,"allow_uploaded_avatars":true,"allow_animated_avatars":false,"basic_requires_read_posts":30,"enable_long_polling":true,"polling_interval":3000,"anon_polling_interval":30000,"flush_timings_secs":5,"tos_url":"","privacy_policy_url":"","tos_accept_required":false,"faq_url":"","allow_restore":false,"maximum_backups":7,"version_checks":true,"suppress_uncategorized_badge":true,"min_search_term_length":3,"topic_views_heat_low":1000,"topic_views_heat_medium":2000,"topic_views_heat_high":5000,"global_notice":"","show_create_topics_notice":true,"available_locales":"cs|da|de|en|es|fr|he|id|it|ja|ko|nb_NO|nl|pl_PL|pt|pt_BR|ru|sv|uk|zh_CN|zh_TW"}; Discourse.SiteSettings = jQuery.extend(true, {}, Discourse.SiteSettingsOriginal); diff --git a/test/javascripts/helpers/create-pretender.js.es6 b/test/javascripts/helpers/create-pretender.js.es6 new file mode 100644 index 00000000000..af3d1a0da2e --- /dev/null +++ b/test/javascripts/helpers/create-pretender.js.es6 @@ -0,0 +1,52 @@ +/* global console */ + +function parsePostData(query) { + var result = {}; + query.split("&").forEach(function(part) { + var item = part.split("="); + result[item[0]] = decodeURIComponent(item[1]); + }); + return result; +} + +function json(code, obj) { + if (typeof code === "object") { + obj = code; + code = 200; + } + return [code, {"Content-Type": "application/json"}, JSON.stringify(obj)]; +} + +export default function() { + var server = new Pretender(function() { + this.post('/session', function(request) { + var data = parsePostData(request.requestBody); + + if (data.password === 'correct') { + return json({username: 'eviltrout'}); + } + return json(400, {error: 'invalid login'}); + }); + + this.get('/users/hp.json', function() { + return json({"value":"32faff1b1ef1ac3","challenge":"61a3de0ccf086fb9604b76e884d75801"}); + }); + + this.get('/users/check_username', function(request) { + if (request.queryParams.username === 'taken') { + return json({available: false, suggestion: 'nottaken'}); + } + return json({available: true}); + }); + + this.post('/users', function(request) { + return json({success: true}); + }); + }); + + server.unhandledRequest = function(verb, path) { + console.error('Unhandled request in test environment: ' + path + ' (' + verb + ')'); + }; + + return server; +} diff --git a/test/javascripts/integration/header-anonymous-test.js.es6 b/test/javascripts/integration/header-anonymous-test.js.es6 index a8009c6e576..3a9d37912d1 100644 --- a/test/javascripts/integration/header-anonymous-test.js.es6 +++ b/test/javascripts/integration/header-anonymous-test.js.es6 @@ -1,8 +1,6 @@ integration("Header (Anonymous)"); test("header", function() { - expect(14); - visit("/"); andThen(function() { ok(exists("header"), "is rendered"); diff --git a/test/javascripts/integration/header-test-staff.js.es6 b/test/javascripts/integration/header-test-staff.js.es6 index c9caaef6225..d1adcf6c9ab 100644 --- a/test/javascripts/integration/header-test-staff.js.es6 +++ b/test/javascripts/integration/header-test-staff.js.es6 @@ -5,8 +5,6 @@ integration("Header (Staff)", { }); test("header", function() { - expect(6); - visit("/"); // Notifications @@ -30,5 +28,4 @@ test("header", function() { ok(exists("#user-dropdown:visible"), "is lazily rendered after user opens it"); ok(exists("#user-dropdown .user-dropdown-links"), "has showing / hiding user-dropdown links correctly bound"); }); - }); diff --git a/test/javascripts/integration/sign-in-test.js.es6 b/test/javascripts/integration/sign-in-test.js.es6 index 4e86b2ffcef..d706cdfbf57 100644 --- a/test/javascripts/integration/sign-in-test.js.es6 +++ b/test/javascripts/integration/sign-in-test.js.es6 @@ -1,17 +1,61 @@ integration("Signing In"); -test("sign in with incorrect credentials", function() { +test("sign in", function() { visit("/"); click("header .login-button"); andThen(function() { ok(exists('.login-modal'), "it shows the login modal"); }); - fillIn('#login-account-name', 'eviltrout'); - fillIn('#login-account-password', 'where da plankton at?'); - // The fixture is set to invalid login + // Test invalid password first + fillIn('#login-account-name', 'eviltrout'); + fillIn('#login-account-password', 'incorrect'); click('.modal-footer .btn-primary'); andThen(function() { - // ok(exists('#modal-alert:visible', 'it displays the login error')); + ok(exists('#modal-alert:visible', 'it displays the login error')); + not(exists('.modal-footer .btn-primary:disabled'), "enables the login button"); + }); + + // Use the correct password + fillIn('#login-account-password', 'correct'); + click('.modal-footer .btn-primary'); + andThen(function() { + ok(exists('.modal-footer .btn-primary:disabled'), "disables the login button"); }); }); + +test("create account", function() { + visit("/"); + click("header .login-button"); + click('#new-account-link'); + + andThen(function() { + ok(exists('.create-account'), "it shows the create account modal"); + ok(exists('.modal-footer .btn-primary:disabled'), 'create account is disabled at first'); + }); + + fillIn('#new-account-name', 'Dr. Good Tuna'); + fillIn('#new-account-password', 'cool password bro'); + + // Check username + fillIn('#new-account-email', 'good.tuna@test.com'); + fillIn('#new-account-username', 'taken'); + andThen(function() { + ok(exists('#username-validation.bad'), 'the username validation is bad'); + ok(exists('.modal-footer .btn-primary:disabled'), 'create account is still disabled'); + }); + + fillIn('#new-account-username', 'goodtuna'); + andThen(function() { + ok(exists('#username-validation.good'), 'the username validation is good'); + not(exists('.modal-footer .btn-primary:disabled'), 'create account is enabled'); + }); + + click('.modal-footer .btn-primary'); + andThen(function() { + not(exists('.modal-body'), 'it hides the body when finished'); + }); + +}); + + diff --git a/test/javascripts/test_helper.js b/test/javascripts/test_helper.js index 830d4417a61..542ac57178d 100644 --- a/test/javascripts/test_helper.js +++ b/test/javascripts/test_helper.js @@ -1,20 +1,23 @@ /*jshint maxlen:250 */ -/*global document, sinon, console, QUnit, Logster */ +/*global document, sinon, QUnit, Logster */ //= require env -//= require ../../app/assets/javascripts/preload_store.js +//= require ../../app/assets/javascripts/preload_store // probe framework first -//= require ../../app/assets/javascripts/discourse/lib/probes.js +//= require ../../app/assets/javascripts/discourse/lib/probes // Externals we need to load first -//= require development/jquery-2.1.1.js -//= require jquery.ui.widget.js -//= require handlebars.js -//= require development/ember.js -//= require message-bus.js -//= require ember-qunit.js +//= require development/jquery-2.1.1 +//= require jquery.ui.widget +//= require handlebars +//= require development/ember +//= require message-bus +//= require ember-qunit +//= require fake_xml_http_request +//= require route-recognizer +//= require pretender //= require ../../app/assets/javascripts/locales/i18n //= require ../../app/assets/javascripts/discourse/helpers/i18n_helpers @@ -60,16 +63,6 @@ sinon.config = { window.assetPath = function() { return null; }; -var oldAjax = $.ajax; -$.ajax = function() { - try { - this.undef(); - } catch(e) { - console.error("Discourse.Ajax called in test environment (" + arguments[0] + ")\n caller: " + e.stack.split("\n").slice(2).join("\n")); - } - return oldAjax.apply(this, arguments); -}; - // Stop the message bus so we don't get ajax calls Discourse.MessageBus.stop(); @@ -92,8 +85,13 @@ if (window.Logster) { window.Logster = { enabled: false }; } -var origDebounce = Ember.run.debounce; +var origDebounce = Ember.run.debounce, + createPretendServer = require('helpers/create-pretender', null, null, false).default, + server; + QUnit.testStart(function(ctx) { + server = createPretendServer(); + // Allow our tests to change site settings and have them reset before the next test Discourse.SiteSettings = jQuery.extend(true, {}, Discourse.SiteSettingsOriginal); Discourse.BaseUri = "/"; @@ -118,6 +116,8 @@ QUnit.testDone(function() { // Destroy any modals $('.modal-backdrop').remove(); + + server.shutdown(); }); // Load ES6 tests diff --git a/vendor/assets/javascripts/fake_xml_http_request.js b/vendor/assets/javascripts/fake_xml_http_request.js new file mode 100644 index 00000000000..0284c65fca9 --- /dev/null +++ b/vendor/assets/javascripts/fake_xml_http_request.js @@ -0,0 +1,480 @@ +(function(undefined){ +/** + * Minimal Event interface implementation + * + * Original implementation by Sven Fuchs: https://gist.github.com/995028 + * Modifications and tests by Christian Johansen. + * + * @author Sven Fuchs (svenfuchs@artweb-design.de) + * @author Christian Johansen (christian@cjohansen.no) + * @license BSD + * + * Copyright (c) 2011 Sven Fuchs, Christian Johansen + */ + +var _Event = function Event(type, bubbles, cancelable, target) { + this.type = type; + this.bubbles = bubbles; + this.cancelable = cancelable; + this.target = target; +}; + +_Event.prototype = { + stopPropagation: function () {}, + preventDefault: function () { + this.defaultPrevented = true; + } +}; + +/* + Used to set the statusText property of an xhr object +*/ +var httpStatusCodes = { + 100: "Continue", + 101: "Switching Protocols", + 200: "OK", + 201: "Created", + 202: "Accepted", + 203: "Non-Authoritative Information", + 204: "No Content", + 205: "Reset Content", + 206: "Partial Content", + 300: "Multiple Choice", + 301: "Moved Permanently", + 302: "Found", + 303: "See Other", + 304: "Not Modified", + 305: "Use Proxy", + 307: "Temporary Redirect", + 400: "Bad Request", + 401: "Unauthorized", + 402: "Payment Required", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 407: "Proxy Authentication Required", + 408: "Request Timeout", + 409: "Conflict", + 410: "Gone", + 411: "Length Required", + 412: "Precondition Failed", + 413: "Request Entity Too Large", + 414: "Request-URI Too Long", + 415: "Unsupported Media Type", + 416: "Requested Range Not Satisfiable", + 417: "Expectation Failed", + 422: "Unprocessable Entity", + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", + 505: "HTTP Version Not Supported" +}; + + +/* + Cross-browser XML parsing. Used to turn + XML responses into Document objects + Borrowed from JSpec +*/ +function parseXML(text) { + var xmlDoc; + + if (typeof DOMParser != "undefined") { + var parser = new DOMParser(); + xmlDoc = parser.parseFromString(text, "text/xml"); + } else { + xmlDoc = new ActiveXObject("Microsoft.XMLDOM"); + xmlDoc.async = "false"; + xmlDoc.loadXML(text); + } + + return xmlDoc; +} + +/* + Without mocking, the native XMLHttpRequest object will throw + an error when attempting to set these headers. We match this behavior. +*/ +var unsafeHeaders = { + "Accept-Charset": true, + "Accept-Encoding": true, + "Connection": true, + "Content-Length": true, + "Cookie": true, + "Cookie2": true, + "Content-Transfer-Encoding": true, + "Date": true, + "Expect": true, + "Host": true, + "Keep-Alive": true, + "Referer": true, + "TE": true, + "Trailer": true, + "Transfer-Encoding": true, + "Upgrade": true, + "User-Agent": true, + "Via": true +}; + +/* + Adds an "event" onto the fake xhr object + that just calls the same-named method. This is + in case a library adds callbacks for these events. +*/ +function _addEventListener(eventName, xhr){ + xhr.addEventListener(eventName, function (event) { + var listener = xhr["on" + eventName]; + + if (listener && typeof listener == "function") { + listener(event); + } + }); +} + +/* + Constructor for a fake window.XMLHttpRequest +*/ +function FakeXMLHttpRequest() { + this.readyState = FakeXMLHttpRequest.UNSENT; + this.requestHeaders = {}; + this.requestBody = null; + this.status = 0; + this.statusText = ""; + + this._eventListeners = {}; + var events = ["loadstart", "load", "abort", "loadend"]; + for (var i = events.length - 1; i >= 0; i--) { + _addEventListener(events[i], this); + } +} + + +// These status codes are available on the native XMLHttpRequest +// object, so we match that here in case a library is relying on them. +FakeXMLHttpRequest.UNSENT = 0; +FakeXMLHttpRequest.OPENED = 1; +FakeXMLHttpRequest.HEADERS_RECEIVED = 2; +FakeXMLHttpRequest.LOADING = 3; +FakeXMLHttpRequest.DONE = 4; + +FakeXMLHttpRequest.prototype = { + UNSENT: 0, + OPENED: 1, + HEADERS_RECEIVED: 2, + LOADING: 3, + DONE: 4, + async: true, + + /* + Duplicates the behavior of native XMLHttpRequest's open function + */ + open: function open(method, url, async, username, password) { + this.method = method; + this.url = url; + this.async = typeof async == "boolean" ? async : true; + this.username = username; + this.password = password; + this.responseText = null; + this.responseXML = null; + this.requestHeaders = {}; + this.sendFlag = false; + this._readyStateChange(FakeXMLHttpRequest.OPENED); + }, + + /* + Duplicates the behavior of native XMLHttpRequest's addEventListener function + */ + addEventListener: function addEventListener(event, listener) { + this._eventListeners[event] = this._eventListeners[event] || []; + this._eventListeners[event].push(listener); + }, + + /* + Duplicates the behavior of native XMLHttpRequest's removeEventListener function + */ + removeEventListener: function removeEventListener(event, listener) { + var listeners = this._eventListeners[event] || []; + + for (var i = 0, l = listeners.length; i < l; ++i) { + if (listeners[i] == listener) { + return listeners.splice(i, 1); + } + } + }, + + /* + Duplicates the behavior of native XMLHttpRequest's dispatchEvent function + */ + dispatchEvent: function dispatchEvent(event) { + var type = event.type; + var listeners = this._eventListeners[type] || []; + + for (var i = 0; i < listeners.length; i++) { + if (typeof listeners[i] == "function") { + listeners[i].call(this, event); + } else { + listeners[i].handleEvent(event); + } + } + + return !!event.defaultPrevented; + }, + + /* + Duplicates the behavior of native XMLHttpRequest's setRequestHeader function + */ + setRequestHeader: function setRequestHeader(header, value) { + verifyState(this); + + if (unsafeHeaders[header] || /^(Sec-|Proxy-)/.test(header)) { + throw new Error("Refused to set unsafe header \"" + header + "\""); + } + + if (this.requestHeaders[header]) { + this.requestHeaders[header] += "," + value; + } else { + this.requestHeaders[header] = value; + } + }, + + /* + Duplicates the behavior of native XMLHttpRequest's send function + */ + send: function send(data) { + verifyState(this); + + if (!/^(get|head)$/i.test(this.method)) { + if (this.requestHeaders["Content-Type"]) { + var value = this.requestHeaders["Content-Type"].split(";"); + this.requestHeaders["Content-Type"] = value[0] + ";charset=utf-8"; + } else { + this.requestHeaders["Content-Type"] = "text/plain;charset=utf-8"; + } + + this.requestBody = data; + } + + this.errorFlag = false; + this.sendFlag = this.async; + this._readyStateChange(FakeXMLHttpRequest.OPENED); + + if (typeof this.onSend == "function") { + this.onSend(this); + } + + this.dispatchEvent(new _Event("loadstart", false, false, this)); + }, + + /* + Duplicates the behavior of native XMLHttpRequest's abort function + */ + abort: function abort() { + this.aborted = true; + this.responseText = null; + this.errorFlag = true; + this.requestHeaders = {}; + + if (this.readyState > FakeXMLHttpRequest.UNSENT && this.sendFlag) { + this._readyStateChange(FakeXMLHttpRequest.DONE); + this.sendFlag = false; + } + + this.readyState = FakeXMLHttpRequest.UNSENT; + + this.dispatchEvent(new _Event("abort", false, false, this)); + if (typeof this.onerror === "function") { + this.onerror(); + } + }, + + /* + Duplicates the behavior of native XMLHttpRequest's getResponseHeader function + */ + getResponseHeader: function getResponseHeader(header) { + if (this.readyState < FakeXMLHttpRequest.HEADERS_RECEIVED) { + return null; + } + + if (/^Set-Cookie2?$/i.test(header)) { + return null; + } + + header = header.toLowerCase(); + + for (var h in this.responseHeaders) { + if (h.toLowerCase() == header) { + return this.responseHeaders[h]; + } + } + + return null; + }, + + /* + Duplicates the behavior of native XMLHttpRequest's getAllResponseHeaders function + */ + getAllResponseHeaders: function getAllResponseHeaders() { + if (this.readyState < FakeXMLHttpRequest.HEADERS_RECEIVED) { + return ""; + } + + var headers = ""; + + for (var header in this.responseHeaders) { + if (this.responseHeaders.hasOwnProperty(header) && !/^Set-Cookie2?$/i.test(header)) { + headers += header + ": " + this.responseHeaders[header] + "\r\n"; + } + } + + return headers; + }, + + /* + Places a FakeXMLHttpRequest object into the passed + state. + */ + _readyStateChange: function _readyStateChange(state) { + this.readyState = state; + + if (typeof this.onreadystatechange == "function") { + this.onreadystatechange(); + } + + this.dispatchEvent(new _Event("readystatechange")); + + if (this.readyState == FakeXMLHttpRequest.DONE) { + this.dispatchEvent(new _Event("load", false, false, this)); + this.dispatchEvent(new _Event("loadend", false, false, this)); + } + }, + + + /* + Sets the FakeXMLHttpRequest object's response headers and + places the object into readyState 2 + */ + _setResponseHeaders: function _setResponseHeaders(headers) { + this.responseHeaders = {}; + + for (var header in headers) { + if (headers.hasOwnProperty(header)) { + this.responseHeaders[header] = headers[header]; + } + } + + if (this.async) { + this._readyStateChange(FakeXMLHttpRequest.HEADERS_RECEIVED); + } else { + this.readyState = FakeXMLHttpRequest.HEADERS_RECEIVED; + } + }, + + + + /* + Sets the FakeXMLHttpRequest object's response body and + if body text is XML, sets responseXML to parsed document + object + */ + _setResponseBody: function _setResponseBody(body) { + verifyRequestSent(this); + verifyHeadersReceived(this); + verifyResponseBodyType(body); + + var chunkSize = this.chunkSize || 10; + var index = 0; + this.responseText = ""; + + do { + if (this.async) { + this._readyStateChange(FakeXMLHttpRequest.LOADING); + } + + this.responseText += body.substring(index, index + chunkSize); + index += chunkSize; + } while (index < body.length); + + var type = this.getResponseHeader("Content-Type"); + + if (this.responseText && (!type || /(text\/xml)|(application\/xml)|(\+xml)/.test(type))) { + try { + this.responseXML = parseXML(this.responseText); + } catch (e) { + // Unable to parse XML - no biggie + } + } + + if (this.async) { + this._readyStateChange(FakeXMLHttpRequest.DONE); + } else { + this.readyState = FakeXMLHttpRequest.DONE; + } + }, + + /* + Forces a response on to the FakeXMLHttpRequest object. + + This is the public API for faking responses. This function + takes a number status, headers object, and string body: + + ``` + xhr.respond(404, {Content-Type: 'text/plain'}, "Sorry. This object was not found.") + + ``` + */ + respond: function respond(status, headers, body) { + this._setResponseHeaders(headers || {}); + this.status = typeof status == "number" ? status : 200; + this.statusText = httpStatusCodes[this.status]; + this._setResponseBody(body || ""); + if (typeof this.onload === "function"){ + this.onload(); + } + } +}; + +function verifyState(xhr) { + if (xhr.readyState !== FakeXMLHttpRequest.OPENED) { + throw new Error("INVALID_STATE_ERR"); + } + + if (xhr.sendFlag) { + throw new Error("INVALID_STATE_ERR"); + } +} + + +function verifyRequestSent(xhr) { + if (xhr.readyState == FakeXMLHttpRequest.DONE) { + throw new Error("Request done"); + } +} + +function verifyHeadersReceived(xhr) { + if (xhr.async && xhr.readyState != FakeXMLHttpRequest.HEADERS_RECEIVED) { + throw new Error("No headers received"); + } +} + +function verifyResponseBodyType(body) { + if (typeof body != "string") { + var error = new Error("Attempted to respond to fake XMLHttpRequest with " + + body + ", which is not a string."); + error.name = "InvalidBodyException"; + throw error; + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = FakeXMLHttpRequest; +} else if (typeof define === 'function' && define.amd) { + define(function() { return FakeXMLHttpRequest; }); +} else if (typeof window !== 'undefined') { + window.FakeXMLHttpRequest = FakeXMLHttpRequest; +} else if (this) { + this.FakeXMLHttpRequest = FakeXMLHttpRequest; +} +})(); diff --git a/vendor/assets/javascripts/pretender.js b/vendor/assets/javascripts/pretender.js new file mode 100644 index 00000000000..b416deb6a4c --- /dev/null +++ b/vendor/assets/javascripts/pretender.js @@ -0,0 +1,128 @@ +(function(window){ + +var isNode = typeof process !== 'undefined' && process.toString() === '[object process]'; +var RouteRecognizer = isNode ? require('route-recognizer')['default'] : window.RouteRecognizer; +var FakeXMLHttpRequest = isNode ? require('./bower_components/FakeXMLHttpRequest/fake_xml_http_request') : window.FakeXMLHttpRequest; + +function Pretender(maps){ + maps = maps || function(){}; + // Herein we keep track of RouteRecognizer instances + // keyed by HTTP method. Feel free to add more as needed. + this.registry = { + GET: new RouteRecognizer(), + PUT: new RouteRecognizer(), + POST: new RouteRecognizer(), + DELETE: new RouteRecognizer(), + PATCH: new RouteRecognizer(), + HEAD: new RouteRecognizer() + }; + + this.handlers = []; + this.handledRequests = []; + this.unhandledRequests = []; + + // reference the native XMLHttpRequest object so + // it can be restored later + this._nativeXMLHttpRequest = window.XMLHttpRequest; + + // capture xhr requests, channeling them into + // the route map. + window.XMLHttpRequest = interceptor(this); + + // trigger the route map DSL. + maps.call(this); +} + +function interceptor(pretender) { + function FakeRequest(){ + // super() + FakeXMLHttpRequest.call(this); + } + // extend + var proto = new FakeXMLHttpRequest(); + proto.send = function send(){ + FakeXMLHttpRequest.prototype.send.apply(this, arguments); + pretender.handleRequest(this); + }; + + FakeRequest.prototype = proto; + return FakeRequest; +} + +function verbify(verb){ + return function(path, handler){ + this.register(verb, path, handler); + }; +} + +Pretender.prototype = { + get: verbify('GET'), + post: verbify('POST'), + put: verbify('PUT'), + 'delete': verbify('DELETE'), + patch: verbify('PATCH'), + head: verbify('HEAD'), + register: function register(verb, path, handler){ + handler.numberOfCalls = 0; + this.handlers.push(handler); + + var registry = this.registry[verb]; + registry.add([{path: path, handler: handler}]); + }, + handleRequest: function handleRequest(request){ + var verb = request.method.toUpperCase(); + var path = request.url; + var handler = this._handlerFor(verb, path, request); + + if (handler) { + handler.handler.numberOfCalls++; + this.handledRequests.push(request); + + try { + var statusHeadersAndBody = handler.handler(request), + status = statusHeadersAndBody[0], + headers = statusHeadersAndBody[1], + body = this.prepareBody(statusHeadersAndBody[2]); + request.respond(status, headers, body); + this.handledRequest(verb, path, request); + } catch (error) { + this.erroredRequest(verb, path, request, error); + } + } else { + this.unhandledRequests.push(request); + this.unhandledRequest(verb, path, request); + } + }, + prepareBody: function(body){ return body; }, + handledRequest: function(verb, path, request){/* no-op */}, + unhandledRequest: function(verb, path, request) { + throw new Error("Pretender intercepted "+verb+" "+path+" but no handler was defined for this type of request"); + }, + erroredRequest: function(verb, path, request, error){ + error.message = "Pretender intercepted "+verb+" "+path+" but encountered an error: " + error.message; + throw error; + }, + _handlerFor: function(verb, path, request){ + var registry = this.registry[verb]; + var matches = registry.recognize(path); + + var match = matches ? matches[0] : null; + if (match) { + request.params = match.params; + request.queryParams = matches.queryParams; + } + + return match; + }, + shutdown: function shutdown(){ + window.XMLHttpRequest = this._nativeXMLHttpRequest; + } +}; + +if (isNode) { + module.exports = Pretender; +} else { + window.Pretender = Pretender; +} + +})(window); diff --git a/vendor/assets/javascripts/route-recognizer.js b/vendor/assets/javascripts/route-recognizer.js new file mode 100644 index 00000000000..eb35d24aab7 --- /dev/null +++ b/vendor/assets/javascripts/route-recognizer.js @@ -0,0 +1,778 @@ +(function(global) { +var define, requireModule, require, requirejs; + +(function() { + + var _isArray; + if (!Array.isArray) { + _isArray = function (x) { + return Object.prototype.toString.call(x) === "[object Array]"; + }; + } else { + _isArray = Array.isArray; + } + + var registry = {}, seen = {}; + var FAILED = false; + + var uuid = 0; + + function tryFinally(tryable, finalizer) { + try { + return tryable(); + } finally { + finalizer(); + } + } + + + function Module(name, deps, callback, exports) { + var defaultDeps = ['require', 'exports', 'module']; + + this.id = uuid++; + this.name = name; + this.deps = !deps.length && callback.length ? defaultDeps : deps; + this.exports = exports || { }; + this.callback = callback; + this.state = undefined; + } + + define = function(name, deps, callback) { + if (!_isArray(deps)) { + callback = deps; + deps = []; + } + + registry[name] = new Module(name, deps, callback); + }; + + define.amd = {}; + + function reify(mod, name, seen) { + var deps = mod.deps; + var length = deps.length; + var reified = new Array(length); + var dep; + // TODO: new Module + // TODO: seen refactor + var module = { }; + + for (var i = 0, l = length; i < l; i++) { + dep = deps[i]; + if (dep === 'exports') { + module.exports = reified[i] = seen; + } else if (dep === 'require') { + reified[i] = require; + } else if (dep === 'module') { + mod.exports = seen; + module = reified[i] = mod; + } else { + reified[i] = require(resolve(dep, name)); + } + } + + return { + deps: reified, + module: module + }; + } + + requirejs = require = requireModule = function(name) { + var mod = registry[name]; + if (!mod) { + throw new Error('Could not find module ' + name); + } + + if (mod.state !== FAILED && + seen.hasOwnProperty(name)) { + return seen[name]; + } + + var reified; + var module; + var loaded = false; + + seen[name] = { }; // placeholder for run-time cycles + + tryFinally(function() { + reified = reify(mod, name, seen[name]); + module = mod.callback.apply(this, reified.deps); + loaded = true; + }, function() { + if (!loaded) { + mod.state = FAILED; + } + }); + + if (module === undefined && reified.module.exports) { + return (seen[name] = reified.module.exports); + } else { + return (seen[name] = module); + } + }; + + function resolve(child, name) { + if (child.charAt(0) !== '.') { return child; } + + var parts = child.split('/'); + var nameParts = name.split('/'); + var parentBase = nameParts.slice(0, -1); + + for (var i = 0, l = parts.length; i < l; i++) { + var part = parts[i]; + + if (part === '..') { parentBase.pop(); } + else if (part === '.') { continue; } + else { parentBase.push(part); } + } + + return parentBase.join('/'); + } + + requirejs.entries = requirejs._eak_seen = registry; + requirejs.clear = function(){ + requirejs.entries = requirejs._eak_seen = registry = {}; + seen = state = {}; + }; +})(); + +define("route-recognizer", + ["route-recognizer/dsl","exports"], + function(__dependency1__, __exports__) { + "use strict"; + var map = __dependency1__["default"]; + + var specials = [ + '/', '.', '*', '+', '?', '|', + '(', ')', '[', ']', '{', '}', '\\' + ]; + + var escapeRegex = new RegExp('(\\' + specials.join('|\\') + ')', 'g'); + + function isArray(test) { + return Object.prototype.toString.call(test) === "[object Array]"; + } + + // A Segment represents a segment in the original route description. + // Each Segment type provides an `eachChar` and `regex` method. + // + // The `eachChar` method invokes the callback with one or more character + // specifications. A character specification consumes one or more input + // characters. + // + // The `regex` method returns a regex fragment for the segment. If the + // segment is a dynamic of star segment, the regex fragment also includes + // a capture. + // + // A character specification contains: + // + // * `validChars`: a String with a list of all valid characters, or + // * `invalidChars`: a String with a list of all invalid characters + // * `repeat`: true if the character specification can repeat + + function StaticSegment(string) { this.string = string; } + StaticSegment.prototype = { + eachChar: function(callback) { + var string = this.string, ch; + + for (var i=0, l=string.length; i " + n.nextStates.map(function(s) { return s.debug() }).join(" or ") + " )"; + }).join(", ") + } + END IF **/ + + // This is a somewhat naive strategy, but should work in a lot of cases + // A better strategy would properly resolve /posts/:id/new and /posts/edit/:id. + // + // This strategy generally prefers more static and less dynamic matching. + // Specifically, it + // + // * prefers fewer stars to more, then + // * prefers using stars for less of the match to more, then + // * prefers fewer dynamic segments to more, then + // * prefers more static segments to more + function sortSolutions(states) { + return states.sort(function(a, b) { + if (a.types.stars !== b.types.stars) { return a.types.stars - b.types.stars; } + + if (a.types.stars) { + if (a.types.statics !== b.types.statics) { return b.types.statics - a.types.statics; } + if (a.types.dynamics !== b.types.dynamics) { return b.types.dynamics - a.types.dynamics; } + } + + if (a.types.dynamics !== b.types.dynamics) { return a.types.dynamics - b.types.dynamics; } + if (a.types.statics !== b.types.statics) { return b.types.statics - a.types.statics; } + + return 0; + }); + } + + function recognizeChar(states, ch) { + var nextStates = []; + + for (var i=0, l=states.length; i 2 && key.slice(keyLength -2) === '[]') { + isArray = true; + key = key.slice(0, keyLength - 2); + if(!queryParams[key]) { + queryParams[key] = []; + } + } + value = pair[1] ? decodeURIComponent(pair[1]) : ''; + } + if (isArray) { + queryParams[key].push(value); + } else { + queryParams[key] = value; + } + } + return queryParams; + }, + + recognize: function(path) { + var states = [ this.rootState ], + pathLen, i, l, queryStart, queryParams = {}, + isSlashDropped = false; + + path = decodeURI(path); + + queryStart = path.indexOf('?'); + if (queryStart !== -1) { + var queryString = path.substr(queryStart + 1, path.length); + path = path.substr(0, queryStart); + queryParams = this.parseQueryString(queryString); + } + + // DEBUG GROUP path + + if (path.charAt(0) !== "/") { path = "/" + path; } + + pathLen = path.length; + if (pathLen > 1 && path.charAt(pathLen - 1) === "/") { + path = path.substr(0, pathLen - 1); + isSlashDropped = true; + } + + for (i=0, l=path.length; i