FEATURE: Allow theme tests to be run in production (take 2) (#12845)

This commit allows site admins to run theme tests in production via a new `/theme-qunit` route. When you visit `/theme-qunit`, you'll see a list of the themes/components installed on your site that have tests, and from there you can select a theme or component that you run its tests.

We also have a new rake task `themes:install_and_test` that can be used to install a list of themes/components on a temporary database and run the tests of the themes/components that are installed. This rake task can be useful when upgrading/deploying a Discourse instance to make sure that the installed themes/components are compatible with the new Discourse version being deployed, and if the tests fail you can abort the build/deploy process so you don't end up with a broken site.
This commit is contained in:
Osama Sayegh 2021-04-28 23:12:08 +03:00 committed by GitHub
parent e832088edf
commit 4f88f2eb15
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 24525 additions and 212 deletions

View File

@ -0,0 +1,4 @@
//= require_tree ./acceptance
//= require_tree ./integration
//= require_tree ./unit
//= require ./plugin_tests

View File

@ -1,5 +1,6 @@
import Pretender from "pretender"; import Pretender from "pretender";
import User from "discourse/models/user"; import User from "discourse/models/user";
import getURL from "discourse-common/lib/get-url";
export function parsePostData(query) { export function parsePostData(query) {
const result = {}; const result = {};
@ -39,7 +40,15 @@ const helpers = { response, success, parsePostData };
export let fixturesByUrl; export let fixturesByUrl;
export default new Pretender(); const instance = new Pretender();
const oldRegister = instance.register;
instance.register = (...args) => {
args[1] = getURL(args[1]);
return oldRegister.call(instance, ...args);
};
export default instance;
export function pretenderHelpers() { export function pretenderHelpers() {
return { parsePostData, response, success }; return { parsePostData, response, success };

View File

@ -152,6 +152,12 @@ function setupTestsCommon(application, container, config) {
}, },
}); });
let setupData;
const setupDataElement = document.getElementById("data-discourse-setup");
if (setupDataElement) {
setupData = setupDataElement.dataset;
setupDataElement.remove();
}
QUnit.testStart(function (ctx) { QUnit.testStart(function (ctx) {
bootbox.$body = $("#ember-testing"); bootbox.$body = $("#ember-testing");
let settings = resetSettings(); let settings = resetSettings();
@ -162,6 +168,15 @@ function setupTestsCommon(application, container, config) {
app = createApplication(config, settings); app = createApplication(config, settings);
} }
const cdn = setupData ? setupData.cdn : null;
const baseUri = setupData ? setupData.baseUri : "";
setupURL(cdn, "http://localhost:3000", baseUri);
if (setupData && setupData.s3BaseUrl) {
setupS3CDN(setupData.s3BaseUrl, setupData.s3Cdn);
} else {
setupS3CDN(null, null);
}
server = createPretender; server = createPretender;
server.handlers = []; server.handlers = [];
applyDefaultHandlers(server); applyDefaultHandlers(server);
@ -199,10 +214,12 @@ function setupTestsCommon(application, container, config) {
applyPretender(ctx.module, server, pretenderHelpers()); applyPretender(ctx.module, server, pretenderHelpers());
setupURL(null, "http://localhost:3000", "");
setupS3CDN(null, null);
Session.resetCurrent(); Session.resetCurrent();
if (setupData) {
const session = Session.current();
session.markdownItURL = setupData.markdownItUrl;
session.highlightJsPath = setupData.highlightJsPath;
}
User.resetCurrent(); User.resetCurrent();
let site = resetSite(settings); let site = resetSite(settings);
createHelperContext({ createHelperContext({
@ -253,7 +270,6 @@ function setupTestsCommon(application, container, config) {
let pluginPath = getUrlParameter("qunit_single_plugin") let pluginPath = getUrlParameter("qunit_single_plugin")
? "/" + getUrlParameter("qunit_single_plugin") + "/" ? "/" + getUrlParameter("qunit_single_plugin") + "/"
: "/plugins/"; : "/plugins/";
let themeOnly = getUrlParameter("theme_name") || getUrlParameter("theme_url");
if (getUrlParameter("qunit_disable_auto_start") === "1") { if (getUrlParameter("qunit_disable_auto_start") === "1") {
QUnit.config.autostart = false; QUnit.config.autostart = false;
@ -263,19 +279,11 @@ function setupTestsCommon(application, container, config) {
let isTest = /\-test/.test(entry); let isTest = /\-test/.test(entry);
let regex = new RegExp(pluginPath); let regex = new RegExp(pluginPath);
let isPlugin = regex.test(entry); let isPlugin = regex.test(entry);
let isTheme = /^discourse\/theme\-\d+\/.+/.test(entry);
if (!isTest) { if (!isTest) {
return; return;
} }
if (themeOnly) {
if (isTheme) {
require(entry, null, null, true);
}
return;
}
if (!skipCore || isPlugin) { if (!skipCore || isPlugin) {
require(entry, null, null, true); require(entry, null, null, true);
} }

View File

@ -5,11 +5,11 @@
//= require jquery.ui.widget //= require jquery.ui.widget
//= require ember.debug //= require ember.debug
//= require message-bus //= require message-bus
//= require qunit/qunit/qunit //= require qunit
//= require ember-qunit //= require ember-qunit
//= require fake_xml_http_request //= require fake_xml_http_request
//= require route-recognizer //= require route-recognizer
//= require pretender/pretender //= require pretender
//= require locales/i18n //= require locales/i18n
//= require locales/en //= require locales/en
//= require discourse-loader //= require discourse-loader
@ -28,16 +28,12 @@
//= require ember-template-compiler //= require ember-template-compiler
// Test helpers // Test helpers
//= require sinon/pkg/sinon //= require sinon
//= require_tree ./helpers //= require_tree ./helpers
//= require break_string //= require break_string
// Finally, the tests themselves
//= require_tree ./fixtures //= require_tree ./fixtures
//= require_tree ./acceptance
//= require_tree ./integration //= require ./setup-tests
//= require_tree ./unit
//= require plugin_tests
//= require setup-tests
//= require test-shims //= require test-shims
//= require jquery.magnific-popup.min.js //= require jquery.magnific-popup.min.js

View File

@ -0,0 +1,6 @@
// discourse-skip-module
//= require_tree ./helpers
//= require_tree ./fixtures
//= require ./setup-tests
//= require test-shims

View File

@ -0,0 +1,19 @@
// discourse-skip-module
//= require env
//= require jquery.debug
//= require ember.debug
//= require qunit
//= require ember-qunit
//= require fake_xml_http_request
//= require route-recognizer
//= require pretender
//= require discourse-loader
// These are not loaded in prod or development
// But we need them for testing handlebars templates in qunit
//= require handlebars
//= require ember-template-compiler
//= require sinon
//= require break_string

View File

@ -6,10 +6,10 @@
//= require ember.debug //= require ember.debug
//= require locales/i18n //= require locales/i18n
//= require locales/en //= require locales/en
//= require route-recognizer/dist/route-recognizer //= require route-recognizer
//= require fake_xml_http_request //= require fake_xml_http_request
//= require pretender/pretender //= require pretender
//= require qunit/qunit/qunit //= require qunit
//= require ember-qunit //= require ember-qunit
//= require discourse-loader //= require discourse-loader
//= require jquery.debug //= require jquery.debug

View File

@ -1,5 +1,4 @@
@import '/stylesheets/desktop.css'; @import "vendor/qunit";
@import 'qunit/qunit/qunit.css';
.modal-backdrop { .modal-backdrop {
display: none; display: none;

436
app/assets/stylesheets/vendor/qunit.css vendored Normal file
View File

@ -0,0 +1,436 @@
/*!
* QUnit 2.8.0
* https://qunitjs.com/
*
* Copyright jQuery Foundation and other contributors
* Released under the MIT license
* https://jquery.org/license
*
* Date: 2018-11-02T16:17Z
*/
/** Font Family and Sizes */
#qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-filteredTest, #qunit-userAgent, #qunit-testresult {
font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif;
}
#qunit-testrunner-toolbar, #qunit-filteredTest, #qunit-userAgent, #qunit-testresult, #qunit-tests li { font-size: small; }
#qunit-tests { font-size: smaller; }
/** Resets */
#qunit-tests, #qunit-header, #qunit-banner, #qunit-filteredTest, #qunit-userAgent, #qunit-testresult, #qunit-modulefilter {
margin: 0;
padding: 0;
}
/** Header (excluding toolbar) */
#qunit-header {
padding: 0.5em 0 0.5em 1em;
color: #8699A4;
background-color: #0D3349;
font-size: 1.5em;
line-height: 1em;
font-weight: 400;
border-radius: 5px 5px 0 0;
}
#qunit-header a {
text-decoration: none;
color: #C2CCD1;
}
#qunit-header a:hover,
#qunit-header a:focus {
color: #FFF;
}
#qunit-banner {
height: 5px;
}
#qunit-filteredTest {
padding: 0.5em 1em 0.5em 1em;
color: #366097;
background-color: #F4FF77;
}
#qunit-userAgent {
padding: 0.5em 1em 0.5em 1em;
color: #FFF;
background-color: #2B81AF;
text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px;
}
/** Toolbar */
#qunit-testrunner-toolbar {
padding: 0.5em 1em 0.5em 1em;
color: #5E740B;
background-color: #EEE;
}
#qunit-testrunner-toolbar .clearfix {
height: 0;
clear: both;
}
#qunit-testrunner-toolbar label {
display: inline-block;
}
#qunit-testrunner-toolbar input[type=checkbox],
#qunit-testrunner-toolbar input[type=radio] {
margin: 3px;
vertical-align: -2px;
}
#qunit-testrunner-toolbar input[type=text] {
box-sizing: border-box;
height: 1.6em;
}
.qunit-url-config,
.qunit-filter,
#qunit-modulefilter {
display: inline-block;
line-height: 2.1em;
}
.qunit-filter,
#qunit-modulefilter {
float: right;
position: relative;
margin-left: 1em;
}
.qunit-url-config label {
margin-right: 0.5em;
}
#qunit-modulefilter-search {
box-sizing: border-box;
width: 400px;
}
#qunit-modulefilter-search-container:after {
position: absolute;
right: 0.3em;
content: "\25bc";
color: black;
}
#qunit-modulefilter-dropdown {
/* align with #qunit-modulefilter-search */
box-sizing: border-box;
width: 400px;
position: absolute;
right: 0;
top: 50%;
margin-top: 0.8em;
border: 1px solid #D3D3D3;
border-top: none;
border-radius: 0 0 .25em .25em;
color: #000;
background-color: #F5F5F5;
z-index: 99;
}
#qunit-modulefilter-dropdown a {
color: inherit;
text-decoration: none;
}
#qunit-modulefilter-dropdown .clickable.checked {
font-weight: bold;
color: #000;
background-color: #D2E0E6;
}
#qunit-modulefilter-dropdown .clickable:hover {
color: #FFF;
background-color: #0D3349;
}
#qunit-modulefilter-actions {
display: block;
overflow: auto;
/* align with #qunit-modulefilter-dropdown-list */
font: smaller/1.5em sans-serif;
}
#qunit-modulefilter-dropdown #qunit-modulefilter-actions > * {
box-sizing: border-box;
max-height: 2.8em;
display: block;
padding: 0.4em;
}
#qunit-modulefilter-dropdown #qunit-modulefilter-actions > button {
float: right;
font: inherit;
}
#qunit-modulefilter-dropdown #qunit-modulefilter-actions > :last-child {
/* insert padding to align with checkbox margins */
padding-left: 3px;
}
#qunit-modulefilter-dropdown-list {
max-height: 200px;
overflow-y: auto;
margin: 0;
border-top: 2px groove threedhighlight;
padding: 0.4em 0 0;
font: smaller/1.5em sans-serif;
}
#qunit-modulefilter-dropdown-list li {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#qunit-modulefilter-dropdown-list .clickable {
display: block;
padding-left: 0.15em;
}
/** Tests: Pass/Fail */
#qunit-tests {
list-style-position: inside;
}
#qunit-tests li {
padding: 0.4em 1em 0.4em 1em;
border-bottom: 1px solid #FFF;
list-style-position: inside;
}
#qunit-tests > li {
display: none;
}
#qunit-tests li.running,
#qunit-tests li.pass,
#qunit-tests li.fail,
#qunit-tests li.skipped,
#qunit-tests li.aborted {
display: list-item;
}
#qunit-tests.hidepass {
position: relative;
}
#qunit-tests.hidepass li.running,
#qunit-tests.hidepass li.pass:not(.todo) {
visibility: hidden;
position: absolute;
width: 0;
height: 0;
padding: 0;
border: 0;
margin: 0;
}
#qunit-tests li strong {
cursor: pointer;
}
#qunit-tests li.skipped strong {
cursor: default;
}
#qunit-tests li a {
padding: 0.5em;
color: #C2CCD1;
text-decoration: none;
}
#qunit-tests li p a {
padding: 0.25em;
color: #6B6464;
}
#qunit-tests li a:hover,
#qunit-tests li a:focus {
color: #000;
}
#qunit-tests li .runtime {
float: right;
font-size: smaller;
}
.qunit-assert-list {
margin-top: 0.5em;
padding: 0.5em;
background-color: #FFF;
border-radius: 5px;
}
.qunit-source {
margin: 0.6em 0 0.3em;
}
.qunit-collapsed {
display: none;
}
#qunit-tests table {
border-collapse: collapse;
margin-top: 0.2em;
}
#qunit-tests th {
text-align: right;
vertical-align: top;
padding: 0 0.5em 0 0;
}
#qunit-tests td {
vertical-align: top;
}
#qunit-tests pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
#qunit-tests del {
color: #374E0C;
background-color: #E0F2BE;
text-decoration: none;
}
#qunit-tests ins {
color: #500;
background-color: #FFCACA;
text-decoration: none;
}
/*** Test Counts */
#qunit-tests b.counts { color: #000; }
#qunit-tests b.passed { color: #5E740B; }
#qunit-tests b.failed { color: #710909; }
#qunit-tests li li {
padding: 5px;
background-color: #FFF;
border-bottom: none;
list-style-position: inside;
}
/*** Passing Styles */
#qunit-tests li li.pass {
color: #3C510C;
background-color: #FFF;
border-left: 10px solid #C6E746;
}
#qunit-tests .pass { color: #528CE0; background-color: #D2E0E6; }
#qunit-tests .pass .test-name { color: #366097; }
#qunit-tests .pass .test-actual,
#qunit-tests .pass .test-expected { color: #999; }
#qunit-banner.qunit-pass { background-color: #C6E746; }
/*** Failing Styles */
#qunit-tests li li.fail {
color: #710909;
background-color: #FFF;
border-left: 10px solid #EE5757;
white-space: pre;
}
#qunit-tests > li:last-child {
border-radius: 0 0 5px 5px;
}
#qunit-tests .fail { color: #000; background-color: #EE5757; }
#qunit-tests .fail .test-name,
#qunit-tests .fail .module-name { color: #000; }
#qunit-tests .fail .test-actual { color: #EE5757; }
#qunit-tests .fail .test-expected { color: #008000; }
#qunit-banner.qunit-fail { background-color: #EE5757; }
/*** Aborted tests */
#qunit-tests .aborted { color: #000; background-color: orange; }
/*** Skipped tests */
#qunit-tests .skipped {
background-color: #EBECE9;
}
#qunit-tests .qunit-todo-label,
#qunit-tests .qunit-skipped-label {
background-color: #F4FF77;
display: inline-block;
font-style: normal;
color: #366097;
line-height: 1.8em;
padding: 0 0.5em;
margin: -0.4em 0.4em -0.4em 0;
}
#qunit-tests .qunit-todo-label {
background-color: #EEE;
}
/** Result */
#qunit-testresult {
color: #2B81AF;
background-color: #D2E0E6;
border-bottom: 1px solid #FFF;
}
#qunit-testresult .clearfix {
height: 0;
clear: both;
}
#qunit-testresult .module-name {
font-weight: 700;
}
#qunit-testresult-display {
padding: 0.5em 1em 0.5em 1em;
width: 85%;
float:left;
}
#qunit-testresult-controls {
padding: 0.5em 1em 0.5em 1em;
width: 10%;
float:left;
}
/** Fixture */
#qunit-fixture {
position: absolute;
top: -10000px;
left: -10000px;
width: 1000px;
height: 1000px;
}

View File

@ -11,16 +11,52 @@ class QunitController < ApplicationController
# only used in test / dev # only used in test / dev
def index def index
raise Discourse::InvalidAccess.new if Rails.env.production? raise Discourse::InvalidAccess.new if Rails.env.production?
if (theme_name = params[:theme_name]).present?
theme = Theme.find_by(name: theme_name)
raise Discourse::NotFound if theme.blank?
elsif (theme_url = params[:theme_url]).present?
theme = RemoteTheme.find_by(remote_url: theme_url)
raise Discourse::NotFound if theme.blank?
end end
if theme.present?
def theme
raise Discourse::NotFound.new if !can_see_theme_qunit?
param_key = nil
@suggested_themes = nil
if (id = get_param(:id)).present?
theme = Theme.find_by(id: id.to_i)
param_key = :id
elsif (name = get_param(:name)).present?
theme = Theme.find_by(name: name)
param_key = :name
elsif (url = get_param(:url)).present?
theme = RemoteTheme.find_by(remote_url: url)&.theme
param_key = :url
end
if param_key && theme.blank?
return render plain: "Can't find theme with #{param_key} #{params[param_key].inspect}", status: :not_found
end
if !param_key
@suggested_themes = Theme
.where(
id: ThemeField.where(target_id: Theme.targets[:tests_js]).distinct.pluck(:theme_id)
)
.order(updated_at: :desc)
.pluck(:id, :name)
return
end
request.env[:resolved_theme_ids] = [theme.id] request.env[:resolved_theme_ids] = [theme.id]
request.env[:skip_theme_ids_transformation] = true request.env[:skip_theme_ids_transformation] = true
end end
protected
def can_see_theme_qunit?
return true if !Rails.env.production?
current_user&.admin?
end
private
def get_param(key)
params[:"theme_#{key}"] || params[key]
end end
end end

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class ThemeJavascriptsController < ApplicationController class ThemeJavascriptsController < ApplicationController
DISK_CACHE_PATH = "#{Rails.root}/tmp/javascript-cache" DISK_CACHE_PATH = "#{Rails.root}/tmp/javascript-cache"
TESTS_DISK_CACHE_PATH = "#{Rails.root}/tmp/javascript-cache/tests"
skip_before_action( skip_before_action(
:check_xhr, :check_xhr,
@ -11,7 +12,7 @@ class ThemeJavascriptsController < ApplicationController
only: [:show, :show_tests] only: [:show, :show_tests]
) )
before_action :is_asset_path, :no_cookies, :apply_cdn_headers, only: [:show] before_action :is_asset_path, :no_cookies, :apply_cdn_headers, only: [:show, :show_tests]
def show def show
raise Discourse::NotFound unless last_modified.present? raise Discourse::NotFound unless last_modified.present?
@ -35,26 +36,26 @@ class ThemeJavascriptsController < ApplicationController
end end
def show_tests def show_tests
raise Discourse::NotFound if Rails.env.production? digest = params[:digest]
raise Discourse::NotFound if !digest.match?(/^\h{40}$/)
theme_id = params.require(:theme_id) theme = Theme.find_by(id: params[:theme_id])
theme = Theme.find(theme_id) raise Discourse::NotFound if theme.blank?
content = ThemeField
.where(
theme_id: theme_id,
target_id: Theme.targets[:tests_js]
)
.each(&:ensure_baked!)
.map(&:value_baked)
.join("\n")
ThemeJavascriptCompiler.force_default_settings(content, theme) content, content_digest = theme.baked_js_tests_with_digest
raise Discourse::NotFound if content.blank? || content_digest != digest
response.headers["Content-Length"] = content.size.to_s @cache_file = "#{TESTS_DISK_CACHE_PATH}/#{digest}.js"
response.headers["Last-Modified"] = Time.zone.now.httpdate return render body: nil, status: 304 if not_modified?
immutable_for(1.second)
send_data content, filename: "js-tests-theme-#{theme_id}.js", disposition: :inline if !File.exist?(@cache_file)
FileUtils.mkdir_p(TESTS_DISK_CACHE_PATH)
File.write(@cache_file, content)
end
response.headers["Content-Length"] = File.size(@cache_file).to_s
set_cache_control_headers
send_file(@cache_file, disposition: :inline)
end end
private private
@ -64,7 +65,13 @@ class ThemeJavascriptsController < ApplicationController
end end
def last_modified def last_modified
@last_modified ||= query.pluck_first(:updated_at) @last_modified ||= begin
if params[:action].to_s == "show_tests"
File.exist?(@cache_file) ? File.ctime(@cache_file) : nil
else
query.pluck_first(:updated_at)
end
end
end end
def not_modified? def not_modified?

View File

@ -2,23 +2,14 @@
module QunitHelper module QunitHelper
def theme_tests def theme_tests
theme_ids = request.env[:resolved_theme_ids] theme = Theme.find_by(id: request.env[:resolved_theme_ids]&.first)
return "" if theme_ids.blank? return "" if theme.blank?
skip_transformation = request.env[:skip_theme_ids_transformation] _, digest = theme.baked_js_tests_with_digest
query = ThemeField src = "#{GlobalSetting.cdn_url}" \
.joins(:theme) "#{Discourse.base_path}" \
.where( "/theme-javascripts/tests/#{theme.id}-#{digest}.js" \
target_id: Theme.targets[:tests_js], "?__ws=#{Discourse.current_hostname}"
theme_id: skip_transformation ? theme_ids : Theme.transform_ids(theme_ids) "<script src='#{src}'></script>".html_safe
)
.pluck(:theme_id)
.uniq
.map do |theme_id|
src = "#{GlobalSetting.cdn_url}#{Discourse.base_path}/theme-javascripts/tests/#{theme_id}.js"
"<script src='#{src}'></script>"
end
.join("\n")
.html_safe
end end
end end

View File

@ -498,6 +498,16 @@ class Theme < ActiveRecord::Base
end end
end end
def cached_default_settings
Discourse.cache.fetch("default_settings_for_theme_#{self.id}", expires_in: 30.minutes) do
settings_hash = {}
self.settings.each do |setting|
settings_hash[setting.name] = setting.default
end
settings_hash
end
end
def build_settings_hash def build_settings_hash
hash = {} hash = {}
self.settings.each do |setting| self.settings.each do |setting|
@ -516,6 +526,7 @@ class Theme < ActiveRecord::Base
def clear_cached_settings! def clear_cached_settings!
DB.after_commit do DB.after_commit do
Discourse.cache.delete("settings_for_theme_#{self.id}") Discourse.cache.delete("settings_for_theme_#{self.id}")
Discourse.cache.delete("default_settings_for_theme_#{self.id}")
end end
end end
@ -676,6 +687,23 @@ class Theme < ActiveRecord::Base
setting_row.save! setting_row.save!
end end
def baked_js_tests_with_digest
content = theme_fields
.where(target_id: Theme.targets[:tests_js])
.each(&:ensure_baked!)
.map(&:value_baked)
.join("\n")
return [nil, nil] if content.blank?
content = <<~JS + content
(function() {
require("discourse/lib/theme-settings-store").registerSettings(#{self.id}, #{cached_default_settings.to_json}, { force: true });
})();
JS
[content, Digest::SHA1.hexdigest(content)]
end
private private
def to_scss_variable(name, value) def to_scss_variable(name, value)

View File

@ -3,13 +3,11 @@
<head> <head>
<title>QUnit Test Runner</title> <title>QUnit Test Runner</title>
<%= discourse_color_scheme_stylesheets %> <%= discourse_color_scheme_stylesheets %>
<%= stylesheet_link_tag "test_helper" %> <%= discourse_stylesheet_link_tag(:desktop, theme_ids: nil) %>
<%= javascript_include_tag "test_helper" %> <%= discourse_stylesheet_link_tag(:test_helper, theme_ids: nil) %>
<%= theme_tests %> <%= preload_script "discourse/tests/test_helper" %>
<%= theme_translations_lookup %> <%= preload_script "discourse/tests/core_plugins_tests" %>
<%= theme_js_lookup %> <%= preload_script "discourse/tests/test_starter" %>
<%= theme_lookup("head_tag") %>
<%= javascript_include_tag "test_starter" %>
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<script src="<%= ExtraLocalesController.url('admin') %>"></script> <script src="<%= ExtraLocalesController.url('admin') %>"></script>
<meta property="og:title" content=""> <meta property="og:title" content="">

View File

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html>
<head>
<title>Theme QUnit Test Runner</title>
<%= discourse_color_scheme_stylesheets %>
<%- if !@suggested_themes %>
<%= discourse_stylesheet_link_tag(:desktop, theme_ids: nil) %>
<%= discourse_stylesheet_link_tag(:test_helper, theme_ids: nil) %>
<%= preload_script "locales/en" %>
<%= preload_script "discourse/tests/theme_test_vendor" %>
<%= preload_script "vendor" %>
<%= preload_script "pretty-text-bundle" %>
<%= preload_script "markdown-it-bundle" %>
<%= preload_script "application" %>
<%= preload_script "admin" %>
<%= preload_script "discourse/tests/theme_test_helper" %>
<%= theme_translations_lookup %>
<%= theme_js_lookup %>
<%= theme_lookup("head_tag") %>
<%= theme_tests %>
<%= preload_script_url ExtraLocalesController.url('admin') %>
<%= tag.meta id: 'data-discourse-setup', data: client_side_setup_data %>
<meta property="og:title" content="">
<meta property="og:url" content="">
<%= preload_script "discourse/tests/test_starter" %>
<%- else %>
<style>
html {
font-family: Arial;
}
a {
display: block;
margin-bottom: 10px;
}
</style>
<%- end %>
</head>
<body>
<%- if !@suggested_themes %>
<div id="qunit"></div>
<div id="qunit-fixture"></div>
<%- else %>
<h3>Select a theme/component:</h3>
<%- @suggested_themes.each do |(id, name)| %>
<%= link_to name, theme_qunit_url(id: id) %>
<%- end %>
<%- end %>
</body>
</html>

View File

@ -2,9 +2,9 @@
<html> <html>
<head> <head>
<title>QUnit Test Runner</title> <title>QUnit Test Runner</title>
<%= stylesheet_link_tag "test_helper" %> <%= discourse_stylesheet_link_tag(:test_helper, theme_ids: nil) %>
<%= discourse_stylesheet_link_tag :wizard, theme_ids: nil %> <%= discourse_stylesheet_link_tag :wizard, theme_ids: nil %>
<%= javascript_include_tag "wizard/test/test_helper" %> <%= preload_script "wizard/test/test_helper" %>
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<script src="<%= ExtraLocalesController.url("wizard") %>"></script> <script src="<%= ExtraLocalesController.url("wizard") %>"></script>
</head> </head>

View File

@ -126,11 +126,6 @@ module Discourse
config.assets.paths += %W(#{config.root}/config/locales #{config.root}/public/javascripts) config.assets.paths += %W(#{config.root}/config/locales #{config.root}/public/javascripts)
if Rails.env == "development" || Rails.env == "test"
config.assets.paths << "#{config.root}/app/assets/javascripts/discourse/tests"
config.assets.paths << "#{config.root}/node_modules"
end
# Allows us to skip minifincation on some files # Allows us to skip minifincation on some files
config.assets.skip_minification = [] config.assets.skip_minification = []
@ -166,6 +161,9 @@ module Discourse
confirm-new-email/bootstrap.js confirm-new-email/bootstrap.js
onpopstate-handler.js onpopstate-handler.js
embed-application.js embed-application.js
discourse/tests/theme_test_helper.js
discourse/tests/theme_test_vendor.js
discourse/tests/test_starter.js
} }
# Precompile all available locales # Precompile all available locales

View File

@ -528,7 +528,7 @@ Discourse::Application.routes.draw do
get "stylesheets/:name.css" => "stylesheets#show", constraints: { name: /[-a-z0-9_]+/ } get "stylesheets/:name.css" => "stylesheets#show", constraints: { name: /[-a-z0-9_]+/ }
get "color-scheme-stylesheet/:id(/:theme_id)" => "stylesheets#color_scheme", constraints: { format: :json } get "color-scheme-stylesheet/:id(/:theme_id)" => "stylesheets#color_scheme", constraints: { format: :json }
get "theme-javascripts/:digest.js" => "theme_javascripts#show", constraints: { digest: /\h{40}/ } get "theme-javascripts/:digest.js" => "theme_javascripts#show", constraints: { digest: /\h{40}/ }
get "theme-javascripts/tests/:theme_id.js" => "theme_javascripts#show_tests" get "theme-javascripts/tests/:theme_id-:digest.js" => "theme_javascripts#show_tests"
post "uploads/lookup-metadata" => "uploads#metadata" post "uploads/lookup-metadata" => "uploads#metadata"
post "uploads" => "uploads#create" post "uploads" => "uploads#create"
@ -960,6 +960,7 @@ Discourse::Application.routes.draw do
get "/qunit" => "qunit#index" get "/qunit" => "qunit#index"
get "/wizard/qunit" => "wizard#qunit" get "/wizard/qunit" => "wizard#qunit"
end end
get "/theme-qunit" => "qunit#theme"
post "/push_notifications/subscribe" => "push_notification#subscribe" post "/push_notifications/subscribe" => "push_notification#subscribe"
post "/push_notifications/unsubscribe" => "push_notification#unsubscribe" post "/push_notifications/unsubscribe" => "push_notification#unsubscribe"

View File

@ -10,6 +10,7 @@ class ContentSecurityPolicy
def path_specific_extension(path_info) def path_specific_extension(path_info)
{}.tap do |obj| {}.tap do |obj|
for_qunit_route = !Rails.env.production? && ["/qunit", "/wizard/qunit"].include?(path_info) for_qunit_route = !Rails.env.production? && ["/qunit", "/wizard/qunit"].include?(path_info)
for_qunit_route ||= "/theme-qunit" == path_info
obj[:script_src] = :unsafe_eval if for_qunit_route obj[:script_src] = :unsafe_eval if for_qunit_route
end end
end end

View File

@ -329,26 +329,7 @@ task 'db:validate_indexes', [:arg] => ['db:ensure_post_migrations', 'environment
db = TemporaryDb.new db = TemporaryDb.new
db.start db.start
db.migrate
ActiveRecord::Base.establish_connection(
adapter: 'postgresql',
database: 'discourse',
port: db.pg_port,
host: 'localhost'
)
puts "Running migrations on blank database!"
old_stdout = $stdout.clone
old_stderr = $stderr.clone
$stdout.reopen(File.new('/dev/null', 'w'))
$stderr.reopen(File.new('/dev/null', 'w'))
SeedFu.quiet = true
Rake::Task["db:migrate"].invoke
$stdout.reopen(old_stdout)
$stderr.reopen(old_stderr)
ActiveRecord::Base.establish_connection( ActiveRecord::Base.establish_connection(
adapter: 'postgresql', adapter: 'postgresql',

View File

@ -185,6 +185,15 @@ def dependencies
source: 'route-recognizer/dist/route-recognizer.js.map', source: 'route-recognizer/dist/route-recognizer.js.map',
public_root: true public_root: true
}, },
{
source: 'qunit/qunit/qunit.js'
},
{
source: 'pretender/pretender.js'
},
{
source: 'sinon/pkg/sinon.js'
},
] ]
end end

View File

@ -45,13 +45,14 @@ task "qunit:test", [:timeout, :qunit_path] do |_, args|
pid = Process.spawn( pid = Process.spawn(
{ {
"RAILS_ENV" => "test", "RAILS_ENV" => ENV["QUNIT_RAILS_ENV"] || "test",
"SKIP_ENFORCE_HOSTNAME" => "1", "SKIP_ENFORCE_HOSTNAME" => "1",
"UNICORN_PID_PATH" => "#{Rails.root}/tmp/pids/unicorn_test_#{port}.pid", # So this can run alongside development "UNICORN_PID_PATH" => "#{Rails.root}/tmp/pids/unicorn_test_#{port}.pid", # So this can run alongside development
"UNICORN_PORT" => port.to_s, "UNICORN_PORT" => port.to_s,
"UNICORN_SIDEKIQS" => "0" "UNICORN_SIDEKIQS" => "0"
}, },
"#{Rails.root}/bin/unicorn -c config/unicorn.conf.rb" "#{Rails.root}/bin/unicorn -c config/unicorn.conf.rb",
pgroup: true
) )
begin begin
@ -61,7 +62,7 @@ task "qunit:test", [:timeout, :qunit_path] do |_, args|
cmd = "node #{test_path}/run-qunit.js http://localhost:#{port}#{qunit_path}" cmd = "node #{test_path}/run-qunit.js http://localhost:#{port}#{qunit_path}"
options = { seed: (ENV["QUNIT_SEED"] || Random.new.seed), hidepassed: 1 } options = { seed: (ENV["QUNIT_SEED"] || Random.new.seed), hidepassed: 1 }
%w{module filter qunit_skip_core qunit_single_plugin theme_name theme_url}.each do |arg| %w{module filter qunit_skip_core qunit_single_plugin theme_name theme_url theme_id}.each do |arg|
options[arg] = ENV[arg.upcase] if ENV[arg.upcase].present? options[arg] = ENV[arg.upcase] if ENV[arg.upcase].present?
end end
@ -108,7 +109,7 @@ task "qunit:test", [:timeout, :qunit_path] do |_, args|
ensure ensure
# was having issues with HUP # was having issues with HUP
Process.kill "KILL", pid Process.kill "-KILL", pid
FileUtils.rm("#{Rails.root}/tmp/pids/unicorn_test_#{port}.pid") FileUtils.rm("#{Rails.root}/tmp/pids/unicorn_test_#{port}.pid")
end end

View File

@ -98,22 +98,59 @@ task "themes:audit" => :environment do
end end
desc "Run QUnit tests of a theme/component" desc "Run QUnit tests of a theme/component"
task "themes:qunit", :theme_name_or_url do |t, args| task "themes:qunit", :type, :value do |t, args|
name_or_url = args[:theme_name_or_url] type = args[:type]
if name_or_url.blank? value = args[:value]
raise "A theme name or URL must be provided." if !%w(name url id).include?(type) || value.blank?
end
if name_or_url =~ /^(url|name)=(.+)/
cmd = "THEME_#{Regexp.last_match(1).upcase}=#{Regexp.last_match(2)} "
cmd += `which rake`.strip + " qunit:test"
sh cmd
else
raise <<~MSG raise <<~MSG
Cannot parse passed argument #{name_or_url.inspect}. Wrong arguments type:#{type.inspect}, value:#{value.inspect}"
Usage: Usage:
`bundle exec rake themes:unit[url=<theme_url>]` `bundle exec rake themes:unit[url,<theme_url>]`
OR OR
`bundle exec rake themes:unit[name=<theme_name>]` `bundle exec rake themes:unit[name,<theme_name>]`
OR
`bundle exec rake themes:unit[id,<theme_id>]`
MSG MSG
end end
ENV["THEME_#{type.upcase}"] = value.to_s
Rake::Task["qunit:test"].reenable
Rake::Task["qunit:test"].invoke(1200000, "/theme-qunit")
end
desc "Install a theme/component on a temporary DB and run QUnit tests"
task "themes:install_and_test" => :environment do |t, args|
db = TemporaryDb.new
db.start
db.migrate
ActiveRecord::Base.establish_connection(
adapter: 'postgresql',
database: 'discourse',
port: db.pg_port,
host: 'localhost'
)
seeded_themes = Theme.pluck(:id)
Rake::Task["themes:install"].invoke
themes = Theme.pluck(:name, :id)
ENV["PGPORT"] = db.pg_port.to_s
ENV["PGHOST"] = "localhost"
ENV["QUNIT_RAILS_ENV"] = "development"
ENV["DISCOURSE_DEV_DB"] = "discourse"
count = 0
themes.each do |(name, id)|
if seeded_themes.include?(id)
puts "Skipping seeded theme #{name} (id: #{id})"
next
end
puts "Running tests for theme #{name} (id: #{id})..."
Rake::Task["themes:qunit"].reenable
Rake::Task["themes:qunit"].invoke("id", id)
count += 1
end
raise "Error: No themes were installed" if count == 0
ensure
db&.stop
db&.remove
end end

View File

@ -86,6 +86,7 @@ class TemporaryDb
while !`#{pg_ctl_path} -D '#{PG_TEMP_PATH}' status`.include?('server is running') while !`#{pg_ctl_path} -D '#{PG_TEMP_PATH}' status`.include?('server is running')
sleep 0.1 sleep 0.1
end end
@started = true
`createuser -h localhost -p #{pg_port} -s -D -w discourse 2> /dev/null` `createuser -h localhost -p #{pg_port} -s -D -w discourse 2> /dev/null`
`createdb -h localhost -p #{pg_port} discourse` `createdb -h localhost -p #{pg_port} discourse`
@ -94,7 +95,37 @@ class TemporaryDb
end end
def stop def stop
@started = false
`#{pg_ctl_path} -D '#{PG_TEMP_PATH}' stop` `#{pg_ctl_path} -D '#{PG_TEMP_PATH}' stop`
end end
def remove
raise "Error: the database must be stopped before it can be removed" if @started
FileUtils.rm_rf PG_TEMP_PATH
end
def migrate
if !@started
raise "Error: the database must be started before it can be migrated."
end
ActiveRecord::Base.establish_connection(
adapter: 'postgresql',
database: 'discourse',
port: pg_port,
host: 'localhost'
)
puts "Running migrations on blank database!"
old_stdout = $stdout.clone
old_stderr = $stderr.clone
$stdout.reopen(File.new('/dev/null', 'w'))
$stderr.reopen(File.new('/dev/null', 'w'))
SeedFu.quiet = true
Rake::Task["db:migrate"].invoke
ensure
$stdout.reopen(old_stdout) if old_stdout
$stderr.reopen(old_stderr) if old_stderr
end
end end

View File

@ -151,18 +151,6 @@ class ThemeJavascriptCompiler
class CompileError < StandardError class CompileError < StandardError
end end
def self.force_default_settings(content, theme)
settings_hash = {}
theme.settings.each do |setting|
settings_hash[setting.name] = setting.default
end
content.prepend <<~JS
(function() {
require("discourse/lib/theme-settings-store").registerSettings(#{theme.id}, #{settings_hash.to_json}, { force: true });
})();
JS
end
attr_accessor :content attr_accessor :content
def initialize(theme_id, theme_name) def initialize(theme_id, theme_name)

View File

@ -865,4 +865,41 @@ HTML
end end
end end
describe "#baked_js_tests_with_digest" do
before do
ThemeField.create!(
theme_id: theme.id,
target_id: Theme.targets[:settings],
name: "yaml",
value: "some_number: 1"
)
theme.set_field(
target: :tests_js,
type: :js,
name: "acceptance/some-test.js",
value: "assert.ok(true);"
)
theme.save!
end
it 'returns nil for content and digest if theme does not have tests' do
ThemeField.destroy_all
expect(theme.baked_js_tests_with_digest).to eq([nil, nil])
end
it 'digest does not change when settings are changed' do
content, digest = theme.baked_js_tests_with_digest
expect(content).to be_present
expect(digest).to be_present
expect(content).to include("assert.ok(true);")
theme.update_setting(:some_number, 55)
theme.save!
expect(theme.build_settings_hash[:some_number]).to eq(55)
new_content, new_digest = theme.baked_js_tests_with_digest
expect(new_content).to eq(content)
expect(new_digest).to eq(digest)
end
end
end end

View File

@ -3,9 +3,11 @@
require 'rails_helper' require 'rails_helper'
describe QunitController do describe QunitController do
describe "#theme" do
let(:theme) { Fabricate(:theme, name: 'main-theme') } let(:theme) { Fabricate(:theme, name: 'main-theme') }
let(:component) { Fabricate(:theme, component: true, name: 'enabled-component') } let(:component) { Fabricate(:theme, component: true, name: 'enabled-component') }
let(:disabled_component) { Fabricate(:theme, component: true, enabled: false, name: 'disabled-component') } let(:disabled_component) { Fabricate(:theme, component: true, enabled: false, name: 'disabled-component') }
let(:theme_without_tests) { Fabricate(:theme, name: 'no-tests-guy') }
before do before do
Theme.destroy_all Theme.destroy_all
@ -29,47 +31,73 @@ describe QunitController do
end end
end end
context "non-admin users on production" do
before do
Rails.env.stubs(:production?).returns(true)
end
it "anons cannot see the page" do
get '/theme-qunit'
expect(response.status).to eq(404)
end
it "regular users cannot see the page" do
sign_in(Fabricate(:user))
get '/theme-qunit'
expect(response.status).to eq(404)
end
end
context "admin users" do
before do
sign_in(Fabricate(:admin))
end
context "when no theme is specified" do context "when no theme is specified" do
it "includes tests of enabled theme + components" do it "renders a list of themes and components that have tests" do
get '/qunit' get '/theme-qunit'
js_urls = JavascriptCache.where(theme_id: [theme.id, component.id]).map(&:url) expect(response.status).to eq(200)
expect(js_urls.size).to eq(2) [theme, component, disabled_component].each do |t|
js_urls.each do |url| expect(response.body).to include(t.name)
expect(response.body).to include(url) expect(response.body).to include("/theme-qunit?id=#{t.id}")
end end
[theme, component].each do |t| expect(response.body).not_to include(theme_without_tests.name)
expect(response.body).to include("/theme-javascripts/tests/#{t.id}.js") expect(response.body).not_to include("/theme-qunit?id=#{theme_without_tests.id}")
end
js_urls = JavascriptCache.where(theme_id: disabled_component).map(&:url)
expect(js_urls.size).to eq(1)
js_urls.each do |url|
expect(response.body).not_to include(url)
end
expect(response.body).not_to include("/theme-javascripts/tests/#{disabled_component.id}.js")
end end
end end
context "when a theme is specified" do it "can specify theme by id" do
it "includes tests of the specified theme only" do get "/theme-qunit?id=#{theme.id}"
[theme, disabled_component].each do |t| expect(response.status).to eq(200)
get "/qunit?theme_name=#{t.name}" expect(response.body).to include("/theme-javascripts/tests/#{theme.id}-")
js_urls = JavascriptCache.where(theme_id: t.id).map(&:url)
expect(js_urls.size).to eq(1)
js_urls.each do |url|
expect(response.body).to include(url)
end end
expect(response.body).to include("/theme-javascripts/tests/#{t.id}.js")
excluded = Theme.pluck(:id) - [t.id] it "can specify theme by name" do
js_urls = JavascriptCache.where(theme_id: excluded).map(&:url) get "/theme-qunit?name=#{theme.name}"
expect(js_urls.size).to eq(2) expect(response.status).to eq(200)
js_urls.each do |url| expect(response.body).to include("/theme-javascripts/tests/#{theme.id}-")
expect(response.body).not_to include(url)
end
excluded.each do |id|
expect(response.body).not_to include("/theme-javascripts/tests/#{id}.js")
end end
it "can specify theme by url" do
theme.build_remote_theme(remote_url: "git@github.com:discourse/discourse.git").save!
theme.save!
get "/theme-qunit?url=#{theme.remote_theme.remote_url}"
expect(response.status).to eq(200)
expect(response.body).to include("/theme-javascripts/tests/#{theme.id}-")
end
it "themes qunit page includes all the JS/CSS it needs" do
get "/theme-qunit?id=#{theme.id}"
expect(response.status).to eq(200)
expect(response.body).to include("/stylesheets/color_definitions_base_")
expect(response.body).to include("/stylesheets/desktop_")
expect(response.body).to include("/stylesheets/test_helper_")
expect(response.body).to include("/assets/discourse/tests/theme_test_helper.js")
expect(response.body).to include("/assets/discourse/tests/theme_test_vendor.js")
expect(response.body).to match(/\/theme-javascripts\/\h{40}\.js/)
expect(response.body).to include("/theme-javascripts/tests/#{theme.id}-")
expect(response.body).to include("/assets/discourse/tests/test_starter.js")
expect(response.body).to include("/extra-locales/admin")
end end
end end
end end

View File

@ -2,9 +2,19 @@
require 'rails_helper' require 'rails_helper'
describe ThemeJavascriptsController do describe ThemeJavascriptsController do
include ActiveSupport::Testing::TimeHelpers
def clear_disk_cache
if Dir.exist?(ThemeJavascriptsController::DISK_CACHE_PATH)
`rm -rf #{ThemeJavascriptsController::DISK_CACHE_PATH}`
end
end
let!(:theme) { Fabricate(:theme) } let!(:theme) { Fabricate(:theme) }
let(:theme_field) { ThemeField.create!(theme: theme, target_id: 0, name: "header", value: "<a>html</a>") } let(:theme_field) { ThemeField.create!(theme: theme, target_id: 0, name: "header", value: "<a>html</a>") }
let(:javascript_cache) { JavascriptCache.create!(content: 'console.log("hello");', theme_field: theme_field) } let(:javascript_cache) { JavascriptCache.create!(content: 'console.log("hello");', theme_field: theme_field) }
before { clear_disk_cache }
after { clear_disk_cache }
describe '#show' do describe '#show' do
def update_digest_and_get(digest) def update_digest_and_get(digest)
@ -47,31 +57,83 @@ describe ThemeJavascriptsController do
get "/theme-javascripts/#{javascript_cache.digest}.js" get "/theme-javascripts/#{javascript_cache.digest}.js"
expect(response.status).to eq(404) expect(response.status).to eq(404)
end end
def clear_disk_cache
if Dir.exist?(ThemeJavascriptsController::DISK_CACHE_PATH)
`rm #{ThemeJavascriptsController::DISK_CACHE_PATH}/*`
end
end
end end
describe "#show_tests" do describe "#show_tests" do
context "theme settings" do
let(:component) { Fabricate(:theme, component: true, name: 'enabled-component') } let(:component) { Fabricate(:theme, component: true, name: 'enabled-component') }
let!(:tests_field) do
field = component.set_field(
target: :tests_js,
type: :js,
name: "acceptance/some-test.js",
value: "assert.ok(true);"
)
component.save!
field
end
it "forces default values" do before do
ThemeField.create!( ThemeField.create!(
theme: component, theme: component,
target_id: Theme.targets[:settings], target_id: Theme.targets[:settings],
name: "yaml", name: "yaml",
value: "num_setting: 5" value: "num_setting: 5"
) )
component.reload component.save!
component.update_setting(:num_setting, 643) end
get "/theme-javascripts/tests/#{component.id}.js" it "forces theme settings default values" do
component.update_setting(:num_setting, 643)
_, digest = component.baked_js_tests_with_digest
get "/theme-javascripts/tests/#{component.id}-#{digest}.js"
expect(response.body).to include("require(\"discourse/lib/theme-settings-store\").registerSettings(#{component.id}, {\"num_setting\":5}, { force: true });") expect(response.body).to include("require(\"discourse/lib/theme-settings-store\").registerSettings(#{component.id}, {\"num_setting\":5}, { force: true });")
end expect(response.body).to include("assert.ok(true);")
end
it "responds with 404 if digest is not a 40 chars hex" do
digest = Rack::Utils.escape('../../../../../../../../../../etc/passwd').gsub('.', '%2E')
get "/theme-javascripts/tests/#{component.id}-#{digest}.js"
expect(response.status).to eq(404)
get "/theme-javascripts/tests/#{component.id}-abc123.js"
expect(response.status).to eq(404)
end
it "responds with 404 if theme does not exist" do
get "/theme-javascripts/tests/#{Theme.maximum(:id) + 1}-#{SecureRandom.hex(20)}.js"
expect(response.status).to eq(404)
end
it "responds with 304 if tests digest has not changed" do
content, digest = component.baked_js_tests_with_digest
get "/theme-javascripts/tests/#{component.id}-#{digest}.js"
last_modified = Time.rfc2822(response.headers["Last-Modified"])
expect(response.status).to eq(200)
expect(response.headers["Content-Length"].to_i).to eq(content.size)
get "/theme-javascripts/tests/#{component.id}-#{digest}.js",
headers: { "If-Modified-Since" => (last_modified + 10.seconds).rfc2822 }
expect(response.status).to eq(304)
end
it "responds with 404 to requests with old digests" do
_, old_digest = component.baked_js_tests_with_digest
get "/theme-javascripts/tests/#{component.id}-#{old_digest}.js"
expect(response.status).to eq(200)
expect(response.body).to include("assert.ok(true);")
tests_field.update!(value: "assert.ok(343434);")
tests_field.invalidate_baked!
_, digest = component.baked_js_tests_with_digest
expect(old_digest).not_to eq(digest)
get "/theme-javascripts/tests/#{component.id}-#{old_digest}.js"
expect(response.status).to eq(404)
get "/theme-javascripts/tests/#{component.id}-#{digest}.js"
expect(response.status).to eq(200)
expect(response.body).to include("assert.ok(343434);")
end end
end end
end end

487
vendor/assets/javascripts/pretender.js vendored Normal file
View File

@ -0,0 +1,487 @@
(function(self) {
'use strict';
var appearsBrowserified = typeof self !== 'undefined' &&
typeof process !== 'undefined' &&
Object.prototype.toString.call(process) === '[object Object]';
var RouteRecognizer = appearsBrowserified ? require('route-recognizer') : self.RouteRecognizer;
var FakeXMLHttpRequest = appearsBrowserified ? require('fake-xml-http-request') : self.FakeXMLHttpRequest;
/**
* parseURL - decompose a URL into its parts
* @param {String} url a URL
* @return {Object} parts of the URL, including the following
*
* 'https://www.yahoo.com:1234/mypage?test=yes#abc'
*
* {
* host: 'www.yahoo.com:1234',
* protocol: 'https:',
* search: '?test=yes',
* hash: '#abc',
* href: 'https://www.yahoo.com:1234/mypage?test=yes#abc',
* pathname: '/mypage',
* fullpath: '/mypage?test=yes'
* }
*/
function parseURL(url) {
// TODO: something for when document isn't present... #yolo
var anchor = document.createElement('a');
anchor.href = url;
if (!anchor.host) {
anchor.href = anchor.href; // IE: load the host and protocol
}
var pathname = anchor.pathname;
if (pathname.charAt(0) !== '/') {
pathname = '/' + pathname; // IE: prepend leading slash
}
var host = anchor.host;
if (anchor.port === '80' || anchor.port === '443') {
host = anchor.hostname; // IE: remove default port
}
return {
host: host,
protocol: anchor.protocol,
search: anchor.search,
hash: anchor.hash,
href: anchor.href,
pathname: pathname,
fullpath: pathname + (anchor.search || '') + (anchor.hash || '')
};
}
/**
* Registry
*
* A registry is a map of HTTP verbs to route recognizers.
*/
function Registry(/* host */) {
// Herein we keep track of RouteRecognizer instances
// keyed by HTTP method. Feel free to add more as needed.
this.verbs = {
GET: new RouteRecognizer(),
PUT: new RouteRecognizer(),
POST: new RouteRecognizer(),
DELETE: new RouteRecognizer(),
PATCH: new RouteRecognizer(),
HEAD: new RouteRecognizer(),
OPTIONS: new RouteRecognizer()
};
}
/**
* Hosts
*
* a map of hosts to Registries, ultimately allowing
* a per-host-and-port, per HTTP verb lookup of RouteRecognizers
*/
function Hosts() {
this._registries = {};
}
/**
* Hosts#forURL - retrieve a map of HTTP verbs to RouteRecognizers
* for a given URL
*
* @param {String} url a URL
* @return {Registry} a map of HTTP verbs to RouteRecognizers
* corresponding to the provided URL's
* hostname and port
*/
Hosts.prototype.forURL = function(url) {
var host = parseURL(url).host;
var registry = this._registries[host];
if (registry === undefined) {
registry = (this._registries[host] = new Registry(host));
}
return registry.verbs;
};
function Pretender(/* routeMap1, routeMap2, ..., options*/) {
this.hosts = new Hosts();
var lastArg = arguments[arguments.length - 1];
var options = typeof lastArg === 'object' ? lastArg : null;
var shouldNotTrack = options && (options.trackRequests === false);
var noopArray = { push: function() {}, length: 0 };
this.handlers = [];
this.handledRequests = shouldNotTrack ? noopArray: [];
this.passthroughRequests = shouldNotTrack ? noopArray: [];
this.unhandledRequests = shouldNotTrack ? noopArray: [];
this.requestReferences = [];
this.forcePassthrough = options && (options.forcePassthrough === true);
this.disableUnhandled = options && (options.disableUnhandled === true);
// reference the native XMLHttpRequest object so
// it can be restored later
this._nativeXMLHttpRequest = self.XMLHttpRequest;
this.running = false;
var ctx = { pretender: this };
this.ctx = ctx;
// capture xhr requests, channeling them into
// the route map.
self.XMLHttpRequest = interceptor(ctx);
// 'start' the server
this.running = true;
// trigger the route map DSL.
var argLength = options ? arguments.length - 1 : arguments.length;
for (var i = 0; i < argLength; i++) {
this.map(arguments[i]);
}
}
function interceptor(ctx) {
function FakeRequest() {
// super()
FakeXMLHttpRequest.call(this);
}
FakeRequest.prototype = Object.create(FakeXMLHttpRequest.prototype);
FakeRequest.prototype.constructor = FakeRequest;
// extend
FakeRequest.prototype.send = function send() {
if (!ctx.pretender.running) {
throw new Error('You shut down a Pretender instance while there was a pending request. ' +
'That request just tried to complete. Check to see if you accidentally shut down ' +
'a pretender earlier than you intended to');
}
FakeXMLHttpRequest.prototype.send.apply(this, arguments);
if (ctx.pretender.checkPassthrough(this)) {
var xhr = createPassthrough(this);
xhr.send.apply(xhr, arguments);
} else {
ctx.pretender.handleRequest(this);
}
};
function createPassthrough(fakeXHR) {
// event types to handle on the xhr
var evts = ['error', 'timeout', 'abort', 'readystatechange'];
// event types to handle on the xhr.upload
var uploadEvents = [];
// properties to copy from the native xhr to fake xhr
var lifecycleProps = ['readyState', 'responseText', 'responseXML', 'status', 'statusText'];
var xhr = fakeXHR._passthroughRequest = new ctx.pretender._nativeXMLHttpRequest();
xhr.open(fakeXHR.method, fakeXHR.url, fakeXHR.async, fakeXHR.username, fakeXHR.password);
if (fakeXHR.responseType === 'arraybuffer') {
lifecycleProps = ['readyState', 'response', 'status', 'statusText'];
xhr.responseType = fakeXHR.responseType;
}
// use onload if the browser supports it
if ('onload' in xhr) {
evts.push('load');
}
// add progress event for async calls
// avoid using progress events for sync calls, they will hang https://bugs.webkit.org/show_bug.cgi?id=40996.
if (fakeXHR.async && fakeXHR.responseType !== 'arraybuffer') {
evts.push('progress');
uploadEvents.push('progress');
}
// update `propertyNames` properties from `fromXHR` to `toXHR`
function copyLifecycleProperties(propertyNames, fromXHR, toXHR) {
for (var i = 0; i < propertyNames.length; i++) {
var prop = propertyNames[i];
if (prop in fromXHR) {
toXHR[prop] = fromXHR[prop];
}
}
}
// fire fake event on `eventable`
function dispatchEvent(eventable, eventType, event) {
eventable.dispatchEvent(event);
if (eventable['on' + eventType]) {
eventable['on' + eventType](event);
}
}
// set the on- handler on the native xhr for the given eventType
function createHandler(eventType) {
xhr['on' + eventType] = function(event) {
copyLifecycleProperties(lifecycleProps, xhr, fakeXHR);
dispatchEvent(fakeXHR, eventType, event);
};
}
// set the on- handler on the native xhr's `upload` property for
// the given eventType
function createUploadHandler(eventType) {
if (xhr.upload) {
xhr.upload['on' + eventType] = function(event) {
dispatchEvent(fakeXHR.upload, eventType, event);
};
}
}
var i;
for (i = 0; i < evts.length; i++) {
createHandler(evts[i]);
}
for (i = 0; i < uploadEvents.length; i++) {
createUploadHandler(uploadEvents[i]);
}
if (fakeXHR.async) {
xhr.timeout = fakeXHR.timeout;
xhr.withCredentials = fakeXHR.withCredentials;
}
for (var h in fakeXHR.requestHeaders) {
xhr.setRequestHeader(h, fakeXHR.requestHeaders[h]);
}
return xhr;
}
FakeRequest.prototype._passthroughCheck = function(method, args) {
if (this._passthroughRequest) {
return this._passthroughRequest[method].apply(this._passthroughRequest, args);
}
return FakeXMLHttpRequest.prototype[method].apply(this, args);
};
FakeRequest.prototype.abort = function abort() {
return this._passthroughCheck('abort', arguments);
};
FakeRequest.prototype.getResponseHeader = function getResponseHeader() {
return this._passthroughCheck('getResponseHeader', arguments);
};
FakeRequest.prototype.getAllResponseHeaders = function getAllResponseHeaders() {
return this._passthroughCheck('getAllResponseHeaders', arguments);
};
if (ctx.pretender._nativeXMLHttpRequest.prototype._passthroughCheck) {
console.warn('You created a second Pretender instance while there was already one running. ' +
'Running two Pretender servers at once will lead to unexpected results and will ' +
'be removed entirely in a future major version.' +
'Please call .shutdown() on your instances when you no longer need them to respond.');
}
return FakeRequest;
}
function verbify(verb) {
return function(path, handler, async) {
return this.register(verb, path, handler, async);
};
}
function scheduleProgressEvent(request, startTime, totalTime) {
setTimeout(function() {
if (!request.aborted && !request.status) {
var ellapsedTime = new Date().getTime() - startTime.getTime();
request.upload._progress(true, ellapsedTime, totalTime);
request._progress(true, ellapsedTime, totalTime);
scheduleProgressEvent(request, startTime, totalTime);
}
}, 50);
}
function isArray(array) {
return Object.prototype.toString.call(array) === '[object Array]';
}
var PASSTHROUGH = {};
Pretender.prototype = {
get: verbify('GET'),
post: verbify('POST'),
put: verbify('PUT'),
'delete': verbify('DELETE'),
patch: verbify('PATCH'),
head: verbify('HEAD'),
options: verbify('OPTIONS'),
map: function(maps) {
maps.call(this);
},
register: function register(verb, url, handler, async) {
if (!handler) {
throw new Error('The function you tried passing to Pretender to handle ' +
verb + ' ' + url + ' is undefined or missing.');
}
handler.numberOfCalls = 0;
handler.async = async;
this.handlers.push(handler);
var registry = this.hosts.forURL(url)[verb];
registry.add([{
path: parseURL(url).fullpath,
handler: handler
}]);
return handler;
},
passthrough: PASSTHROUGH,
checkPassthrough: function checkPassthrough(request) {
var verb = request.method.toUpperCase();
var path = parseURL(request.url).fullpath;
var recognized = this.hosts.forURL(request.url)[verb].recognize(path);
var match = recognized && recognized[0];
if ((match && match.handler === PASSTHROUGH) || this.forcePassthrough) {
this.passthroughRequests.push(request);
this.passthroughRequest(verb, path, request);
return true;
}
return false;
},
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++;
var async = handler.handler.async;
this.handledRequests.push(request);
var pretender = this;
var _handleRequest = function(statusHeadersAndBody) {
if (!isArray(statusHeadersAndBody)) {
var note = 'Remember to `return [status, headers, body];` in your route handler.';
throw new Error('Nothing returned by handler for ' + path + '. ' + note);
}
var status = statusHeadersAndBody[0],
headers = pretender.prepareHeaders(statusHeadersAndBody[1]),
body = pretender.prepareBody(statusHeadersAndBody[2], headers);
pretender.handleResponse(request, async, function() {
request.respond(status, headers, body);
pretender.handledRequest(verb, path, request);
});
};
try {
var result = handler.handler(request);
if (result && typeof result.then === 'function') {
// `result` is a promise, resolve it
result.then(function(resolvedResult) {
_handleRequest(resolvedResult);
});
} else {
_handleRequest(result);
}
} catch (error) {
this.erroredRequest(verb, path, request, error);
this.resolve(request);
}
} else {
if (!this.disableUnhandled) {
this.unhandledRequests.push(request);
this.unhandledRequest(verb, path, request);
}
}
},
handleResponse: function handleResponse(request, strategy, callback) {
var delay = typeof strategy === 'function' ? strategy() : strategy;
delay = typeof delay === 'boolean' || typeof delay === 'number' ? delay : 0;
if (delay === false) {
callback();
} else {
var pretender = this;
pretender.requestReferences.push({
request: request,
callback: callback
});
if (delay !== true) {
scheduleProgressEvent(request, new Date(), delay);
setTimeout(function() {
pretender.resolve(request);
}, delay);
}
}
},
resolve: function resolve(request) {
for (var i = 0, len = this.requestReferences.length; i < len; i++) {
var res = this.requestReferences[i];
if (res.request === request) {
res.callback();
this.requestReferences.splice(i, 1);
break;
}
}
},
requiresManualResolution: function(verb, path) {
var handler = this._handlerFor(verb.toUpperCase(), path, {});
if (!handler) { return false; }
var async = handler.handler.async;
return typeof async === 'function' ? async() === true : async === true;
},
prepareBody: function(body) { return body; },
prepareHeaders: function(headers) { return headers; },
handledRequest: function(/* verb, path, request */) { /* no-op */},
passthroughRequest: 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, url, request) {
var registry = this.hosts.forURL(url)[verb];
var matches = registry.recognize(parseURL(url).fullpath);
var match = matches ? matches[0] : null;
if (match) {
request.params = match.params;
request.queryParams = matches.queryParams;
}
return match;
},
shutdown: function shutdown() {
self.XMLHttpRequest = this._nativeXMLHttpRequest;
this.ctx.pretender = undefined;
// 'stop' the server
this.running = false;
}
};
Pretender.parseURL = parseURL;
Pretender.Hosts = Hosts;
Pretender.Registry = Registry;
if (typeof module === 'object') {
module.exports = Pretender;
} else if (typeof define !== 'undefined') {
define('pretender', [], function() {
return Pretender;
});
}
self.Pretender = Pretender;
}(self));

6566
vendor/assets/javascripts/qunit.js vendored Normal file

File diff suppressed because it is too large Load Diff

16500
vendor/assets/javascripts/sinon.js vendored Normal file

File diff suppressed because one or more lines are too long