FEATURE: Introduce theme/component QUnit tests (take 2) (#12661)

This commit allows themes and theme components to have QUnit tests. To add tests to your theme/component, create a top-level directory in your theme and name it `test`, and Discourse will save all the files in that directory (and its sub-directories) as "tests files" in the database. While tests files/directories are not required to be organized in a specific way, we recommend that you follow Discourse core's tests [structure](https://github.com/discourse/discourse/tree/master/app/assets/javascripts/discourse/tests).

Writing theme tests should be identical to writing plugins or core tests; all the `import` statements and APIs that you see in core (or plugins) to define/setup tests should just work in themes.

You do need a working Discourse install to run theme tests, and you have 2 ways to run theme tests:

* In the browser at the `/qunit` route. `/qunit` will run tests of all active themes/components as well as core and plugins. The `/qunit` now accepts a `theme_name` or `theme_url` params that you can use to run tests of a specific theme/component like so: `/qunit?theme_name=<your_theme_name>`.

* In the command line using the `themes:qunit` rake task. This take is meant to run tests of a single theme/component so you need to provide it with a theme name or URL like so: `bundle exec rake themes:qunit[name=<theme_name>]` or `bundle exec rake themes:qunit[url=<theme_url>]`.

There are some refactors to how Discourse processes JavaScript that comes with themes/components, and these refactors may break your JS customizations; see https://meta.discourse.org/t/upcoming-core-changes-that-may-break-some-themes-components-april-12/186252?u=osama for details on how you can check if your themes/components are affected and what you need to do to fix them.

This commit also improves theme error handling in Discourse. We will now be able to catch errors that occur when theme initializers are run and prevent them from breaking the site and other themes/components.
This commit is contained in:
Osama Sayegh 2021-04-12 15:02:58 +03:00 committed by GitHub
parent 18777d9108
commit cd24eff5d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 527 additions and 246 deletions

View File

@ -1,8 +1,10 @@
import Application from "@ember/application"; import Application from "@ember/application";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import { buildResolver } from "discourse-common/resolver"; import { buildResolver } from "discourse-common/resolver";
import { isTesting } from "discourse-common/config/environment";
const _pluginCallbacks = []; const _pluginCallbacks = [];
let _themeErrors = [];
const Discourse = Application.extend({ const Discourse = Application.extend({
rootElement: "#main", rootElement: "#main",
@ -19,14 +21,37 @@ const Discourse = Application.extend({
Resolver: buildResolver("discourse"), Resolver: buildResolver("discourse"),
_prepareInitializer(moduleName) { _prepareInitializer(moduleName) {
const module = requirejs(moduleName, null, null, true); const themeId = moduleThemeId(moduleName);
if (!module) { let module = null;
throw new Error(moduleName + " must export an initializer.");
try {
module = requirejs(moduleName, null, null, true);
if (!module) {
throw new Error(moduleName + " must export an initializer.");
}
} catch (err) {
if (!themeId || isTesting()) {
throw err;
}
_themeErrors.push([themeId, err]);
fireThemeErrorEvent();
return;
} }
const init = module.default; const init = module.default;
const oldInitialize = init.initialize; const oldInitialize = init.initialize;
init.initialize = (app) => oldInitialize.call(init, app.__container__, app); init.initialize = (app) => {
try {
return oldInitialize.call(init, app.__container__, app);
} catch (err) {
if (!themeId || isTesting()) {
throw err;
}
_themeErrors.push([themeId, err]);
fireThemeErrorEvent();
}
};
return init; return init;
}, },
@ -37,9 +62,15 @@ const Discourse = Application.extend({
Object.keys(requirejs._eak_seen).forEach((key) => { Object.keys(requirejs._eak_seen).forEach((key) => {
if (/\/pre\-initializers\//.test(key)) { if (/\/pre\-initializers\//.test(key)) {
this.initializer(this._prepareInitializer(key)); const initializer = this._prepareInitializer(key);
if (initializer) {
this.initializer(initializer);
}
} else if (/\/(api\-)?initializers\//.test(key)) { } else if (/\/(api\-)?initializers\//.test(key)) {
this.instanceInitializer(this._prepareInitializer(key)); const initializer = this._prepareInitializer(key);
if (initializer) {
this.instanceInitializer(initializer);
}
} }
}); });
@ -60,4 +91,22 @@ const Discourse = Application.extend({
}, },
}); });
function moduleThemeId(moduleName) {
const match = moduleName.match(/^discourse\/theme\-(\d+)\//);
if (match) {
return parseInt(match[1], 10);
}
}
function fireThemeErrorEvent() {
const event = new CustomEvent("discourse-theme-error");
document.dispatchEvent(event);
}
export function getAndClearThemeErrors() {
const copy = _themeErrors;
_themeErrors = [];
return copy;
}
export default Discourse; export default Discourse;

View File

@ -1,6 +1,7 @@
import { helperContext, registerUnbound } from "discourse-common/lib/helpers"; import { registerUnbound } from "discourse-common/lib/helpers";
import I18n from "I18n"; import I18n from "I18n";
import deprecated from "discourse-common/lib/deprecated"; import deprecated from "discourse-common/lib/deprecated";
import { getSetting as getThemeSetting } from "discourse/lib/theme-settings-store";
registerUnbound("theme-i18n", (themeId, key, params) => { registerUnbound("theme-i18n", (themeId, key, params) => {
return I18n.t(`theme_translations.${themeId}.${key}`, params); return I18n.t(`theme_translations.${themeId}.${key}`, params);
@ -18,5 +19,6 @@ registerUnbound("theme-setting", (themeId, key, hash) => {
{ since: "v2.2.0.beta8", dropFrom: "v2.3.0" } { since: "v2.2.0.beta8", dropFrom: "v2.3.0" }
); );
} }
return helperContext().themeSettings.getSetting(themeId, key);
return getThemeSetting(themeId, key);
}); });

View File

@ -19,7 +19,6 @@ export function autoLoadModules(container, registry) {
let context = { let context = {
siteSettings: container.lookup("site-settings:main"), siteSettings: container.lookup("site-settings:main"),
themeSettings: container.lookup("service:theme-settings"),
keyValueStore: container.lookup("key-value-store:main"), keyValueStore: container.lookup("key-value-store:main"),
capabilities: container.lookup("capabilities:main"), capabilities: container.lookup("capabilities:main"),
currentUser: container.lookup("current-user:main"), currentUser: container.lookup("current-user:main"),

View File

@ -0,0 +1,47 @@
import { get } from "@ember/object";
const originalSettings = {};
const settings = {};
export function registerSettings(
themeId,
settingsObject,
{ force = false } = {}
) {
if (settings[themeId] && !force) {
return;
}
originalSettings[themeId] = Object.assign({}, settingsObject);
const s = {};
Object.keys(settingsObject).forEach((key) => {
Object.defineProperty(s, key, {
enumerable: true,
get() {
return settingsObject[key];
},
set(newVal) {
settingsObject[key] = newVal;
},
});
});
settings[themeId] = s;
}
export function getSetting(themeId, settingKey) {
if (settings[themeId]) {
return get(settings[themeId], settingKey);
}
return null;
}
export function getObjectForTheme(themeId) {
return settings[themeId];
}
export function resetSettings() {
Object.keys(originalSettings).forEach((themeId) => {
Object.keys(originalSettings[themeId]).forEach((key) => {
settings[themeId][key] = originalSettings[themeId][key];
});
});
}

View File

@ -1,6 +1,5 @@
import getURL, { getURLWithCDN } from "discourse-common/lib/get-url"; import getURL, { getURLWithCDN } from "discourse-common/lib/get-url";
import Handlebars from "handlebars"; import Handlebars from "handlebars";
import I18n from "I18n";
import { deepMerge } from "discourse-common/lib/object"; import { deepMerge } from "discourse-common/lib/object";
import { escape } from "pretty-text/sanitizer"; import { escape } from "pretty-text/sanitizer";
import { helperContext } from "discourse-common/lib/helpers"; import { helperContext } from "discourse-common/lib/helpers";
@ -444,40 +443,6 @@ export function postRNWebviewMessage(prop, value) {
} }
} }
function reportToLogster(name, error) {
const data = {
message: `${name} theme/component is throwing errors`,
stacktrace: error.stack,
};
Ember.$.ajax(getURL("/logs/report_js_error"), {
data,
type: "POST",
cache: false,
});
}
// this function is used in lib/theme_javascript_compiler.rb
export function rescueThemeError(name, error, api) {
/* eslint-disable-next-line no-console */
console.error(`"${name}" error:`, error);
reportToLogster(name, error);
const currentUser = api.getCurrentUser();
if (!currentUser || !currentUser.admin) {
return;
}
const path = getURL(`/admin/customize/themes`);
const message = I18n.t("themes.broken_theme_alert", {
theme: name,
path: `<a href="${path}">${path}</a>`,
});
const alertDiv = document.createElement("div");
alertDiv.classList.add("broken-theme-alert");
alertDiv.innerHTML = `⚠️ ${message}`;
document.body.prepend(alertDiv);
}
const CODE_BLOCKS_REGEX = /^( |\t).*|`[^`]+`|^```[^]*?^```|\[code\][^]*?\[\/code\]/gm; const CODE_BLOCKS_REGEX = /^( |\t).*|`[^`]+`|^```[^]*?^```|\[code\][^]*?\[\/code\]/gm;
// | ^ | ^ | ^ | ^ | // | ^ | ^ | ^ | ^ |
// | | | | // | | | |

View File

@ -0,0 +1,56 @@
import { isTesting } from "discourse-common/config/environment";
import { getAndClearThemeErrors } from "discourse/app";
import PreloadStore from "discourse/lib/preload-store";
import getURL from "discourse-common/lib/get-url";
import I18n from "I18n";
export default {
name: "theme-errors-handler",
after: "inject-discourse-objects",
initialize(container) {
const currentUser = container.lookup("current-user:main");
if (isTesting()) {
return;
}
renderErrorNotices(currentUser);
document.addEventListener("discourse-theme-error", () =>
renderErrorNotices(currentUser)
);
},
};
function reportToLogster(name, error) {
const data = {
message: `${name} theme/component is throwing errors`,
stacktrace: error.stack,
};
Ember.$.ajax(getURL("/logs/report_js_error"), {
data,
type: "POST",
cache: false,
});
}
function renderErrorNotices(currentUser) {
getAndClearThemeErrors().forEach(([themeId, error]) => {
const name =
PreloadStore.get("activatedThemes")[themeId] || `(theme-id: ${themeId})`;
/* eslint-disable-next-line no-console */
console.error(`An error occurred in the "${name}" theme/component:`, error);
reportToLogster(name, error);
if (!currentUser || !currentUser.admin) {
return;
}
const path = getURL("/admin/customize/themes");
const message = I18n.t("themes.broken_theme_alert", {
theme: name,
path: `<a href="${path}">${path}</a>`,
});
const alertDiv = document.createElement("div");
alertDiv.classList.add("broken-theme-alert");
alertDiv.innerHTML = `⚠️ ${message}`;
document.body.prepend(alertDiv);
});
}

View File

@ -1,26 +0,0 @@
import Service from "@ember/service";
import { get } from "@ember/object";
export default Service.extend({
settings: null,
init() {
this._super(...arguments);
this._settings = {};
},
registerSettings(themeId, settingsObject) {
this._settings[themeId] = settingsObject;
},
getSetting(themeId, settingsKey) {
if (this._settings[themeId]) {
return get(this._settings[themeId], settingsKey);
}
return null;
},
getObjectForTheme(themeId) {
return this._settings[themeId];
},
});

View File

@ -17,6 +17,7 @@ import { setupS3CDN, setupURL } from "discourse-common/lib/get-url";
import Application from "../app"; import Application from "../app";
import MessageBus from "message-bus-client"; import MessageBus from "message-bus-client";
import PreloadStore from "discourse/lib/preload-store"; import PreloadStore from "discourse/lib/preload-store";
import { resetSettings as resetThemeSettings } from "discourse/lib/theme-settings-store";
import QUnit from "qunit"; import QUnit from "qunit";
import { ScrollingDOMMethods } from "discourse/mixins/scrolling"; import { ScrollingDOMMethods } from "discourse/mixins/scrolling";
import Session from "discourse/models/session"; import Session from "discourse/models/session";
@ -154,6 +155,7 @@ function setupTestsCommon(application, container, config) {
QUnit.testStart(function (ctx) { QUnit.testStart(function (ctx) {
bootbox.$body = $("#ember-testing"); bootbox.$body = $("#ember-testing");
let settings = resetSettings(); let settings = resetSettings();
resetThemeSettings();
if (config) { if (config) {
// Ember CLI testing environment // Ember CLI testing environment
@ -251,6 +253,8 @@ 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;
} }
@ -259,8 +263,20 @@ 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 && (!skipCore || isPlugin)) { if (!isTest) {
return;
}
if (themeOnly) {
if (isTheme) {
require(entry, null, null, true);
}
return;
}
if (!skipCore || isPlugin) {
require(entry, null, null, true); require(entry, null, null, true);
} }
}); });

View File

@ -41,13 +41,3 @@
//= require setup-tests //= require setup-tests
//= require test-shims //= require test-shims
//= require jquery.magnific-popup.min.js //= require jquery.magnific-popup.min.js
document.write(
'<div id="ember-testing-container"><div id="ember-testing"></div></div>'
);
document.write(
"<style>#ember-testing-container { position: fixed; background: white; bottom: 0; right: 0; width: 640px; height: 384px; overflow: auto; z-index: 9999; border: 1px solid #ccc; transform: translateZ(0)} #ember-testing { zoom: 50%; }</style>"
);
let setupTestsLegacy = require("discourse/tests/setup-tests").setupTestsLegacy;
setupTestsLegacy(window.Discourse);

View File

@ -0,0 +1,11 @@
// discourse-skip-module
document.write(
'<div id="ember-testing-container"><div id="ember-testing"></div></div>'
);
document.write(
"<style>#ember-testing-container { position: fixed; background: white; bottom: 0; right: 0; width: 640px; height: 384px; overflow: auto; z-index: 9999; border: 1px solid #ccc; transform: translateZ(0)} #ember-testing { zoom: 50%; }</style>"
);
let setupTestsLegacy = require("discourse/tests/setup-tests").setupTestsLegacy;
setupTestsLegacy(window.Discourse);

View File

@ -570,6 +570,7 @@ class ApplicationController < ActionController::Base
store_preloaded("banner", banner_json) store_preloaded("banner", banner_json)
store_preloaded("customEmoji", custom_emoji) store_preloaded("customEmoji", custom_emoji)
store_preloaded("isReadOnly", @readonly_mode.to_s) store_preloaded("isReadOnly", @readonly_mode.to_s)
store_preloaded("activatedThemes", activated_themes_json)
end end
def preload_current_user_data def preload_current_user_data
@ -890,4 +891,10 @@ class ApplicationController < ActionController::Base
end end
end end
def activated_themes_json
ids = @theme_ids&.compact
return "{}" if ids.blank?
ids = Theme.transform_ids(ids)
Theme.where(id: ids).pluck(:id, :name).to_h.to_json
end
end end

View File

@ -1,11 +1,26 @@
# frozen_string_literal: true # frozen_string_literal: true
class QunitController < ApplicationController class QunitController < ApplicationController
skip_before_action :check_xhr, :preload_json, :redirect_to_login_if_required skip_before_action *%i{
check_xhr
preload_json
redirect_to_login_if_required
}
layout false layout false
# 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
if theme.present?
request.env[:resolved_theme_ids] = [theme.id]
request.env[:skip_theme_ids_transformation] = true
end
end end
end end

View File

@ -8,7 +8,7 @@ class ThemeJavascriptsController < ApplicationController
:preload_json, :preload_json,
:redirect_to_login_if_required, :redirect_to_login_if_required,
:verify_authenticity_token, :verify_authenticity_token,
only: [:show] 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]
@ -34,6 +34,29 @@ class ThemeJavascriptsController < ApplicationController
send_file(cache_file, disposition: :inline) send_file(cache_file, disposition: :inline)
end end
def show_tests
raise Discourse::NotFound if Rails.env.production?
theme_id = params.require(:theme_id)
theme = Theme.find(theme_id)
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)
response.headers["Content-Length"] = content.size.to_s
response.headers["Last-Modified"] = Time.zone.now.httpdate
immutable_for(1.second)
send_data content, filename: "js-tests-theme-#{theme_id}.js", disposition: :inline
end
private private
def query def query

View File

@ -453,15 +453,30 @@ module ApplicationHelper
end end
def theme_lookup(name) def theme_lookup(name)
Theme.lookup_field(theme_ids, mobile_view? ? :mobile : :desktop, name) Theme.lookup_field(
theme_ids,
mobile_view? ? :mobile : :desktop,
name,
skip_transformation: request.env[:skip_theme_ids_transformation].present?
)
end end
def theme_translations_lookup def theme_translations_lookup
Theme.lookup_field(theme_ids, :translations, I18n.locale) Theme.lookup_field(
theme_ids,
:translations,
I18n.locale,
skip_transformation: request.env[:skip_theme_ids_transformation].present?
)
end end
def theme_js_lookup def theme_js_lookup
Theme.lookup_field(theme_ids, :extra_js, nil) Theme.lookup_field(
theme_ids,
:extra_js,
nil,
skip_transformation: request.env[:skip_theme_ids_transformation].present?
)
end end
def discourse_stylesheet_link_tag(name, opts = {}) def discourse_stylesheet_link_tag(name, opts = {})

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
module QunitHelper
def theme_tests
theme_ids = request.env[:resolved_theme_ids]
return "" if theme_ids.blank?
skip_transformation = request.env[:skip_theme_ids_transformation]
query = ThemeField
.joins(:theme)
.where(
target_id: Theme.targets[:tests_js],
theme_id: skip_transformation ? theme_ids : Theme.transform_ids(theme_ids)
)
.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

View File

@ -128,7 +128,7 @@ class Theme < ActiveRecord::Base
SvgSprite.expire_cache SvgSprite.expire_cache
end end
BASE_COMPILER_VERSION = 17 BASE_COMPILER_VERSION = 48
def self.compiler_version def self.compiler_version
get_set_cache "compiler_version" do get_set_cache "compiler_version" do
dependencies = [ dependencies = [
@ -262,11 +262,11 @@ class Theme < ActiveRecord::Base
end end
end end
def self.lookup_field(theme_ids, target, field) def self.lookup_field(theme_ids, target, field, skip_transformation: false)
return if theme_ids.blank? return if theme_ids.blank?
theme_ids = [theme_ids] unless Array === theme_ids theme_ids = [theme_ids] unless Array === theme_ids
theme_ids = transform_ids(theme_ids) theme_ids = transform_ids(theme_ids) if !skip_transformation
cache_key = "#{theme_ids.join(",")}:#{target}:#{field}:#{Theme.compiler_version}" cache_key = "#{theme_ids.join(",")}:#{target}:#{field}:#{Theme.compiler_version}"
lookup = @cache[cache_key] lookup = @cache[cache_key]
return lookup.html_safe if lookup return lookup.html_safe if lookup
@ -297,7 +297,7 @@ class Theme < ActiveRecord::Base
end end
def self.targets def self.targets
@targets ||= Enum.new(common: 0, desktop: 1, mobile: 2, settings: 3, translations: 4, extra_scss: 5, extra_js: 6) @targets ||= Enum.new(common: 0, desktop: 1, mobile: 2, settings: 3, translations: 4, extra_scss: 5, extra_js: 6, tests_js: 7)
end end
def self.lookup_target(target_id) def self.lookup_target(target_id)

View File

@ -94,10 +94,32 @@ class ThemeField < ActiveRecord::Base
node.remove node.remove
end end
doc.css('script[type="text/discourse-plugin"]').each do |node| doc.css('script[type="text/discourse-plugin"]').each_with_index do |node, index|
next unless node['version'].present? version = node['version']
next if version.blank?
initializer_name = "theme-field" +
"-#{self.id}" +
"-#{Theme.targets[self.target_id]}" +
"-#{ThemeField.types[self.type_id]}" +
"-script-#{index + 1}"
begin begin
js_compiler.append_plugin_script(node.inner_html, node['version']) js = <<~JS
import { withPluginApi } from "discourse/lib/plugin-api";
export default {
name: #{initializer_name.inspect},
after: "inject-objects",
initialize() {
withPluginApi(#{version.inspect}, (api) => {
#{node.inner_html}
});
}
};
JS
js_compiler.append_module(js, "discourse/initializers/#{initializer_name}", include_variables: true)
rescue ThemeJavascriptCompiler::CompileError => ex rescue ThemeJavascriptCompiler::CompileError => ex
errors << ex.message errors << ex.message
end end
@ -132,7 +154,7 @@ class ThemeField < ActiveRecord::Base
begin begin
case extension case extension
when "js.es6", "js" when "js.es6", "js"
js_compiler.append_module(content, filename) js_compiler.append_module(content, filename, include_variables: true)
when "hbs" when "hbs"
js_compiler.append_ember_template(filename.sub("discourse/templates/", ""), content) js_compiler.append_ember_template(filename.sub("discourse/templates/", ""), content)
when "hbr", "raw.hbs" when "hbr", "raw.hbs"
@ -285,6 +307,10 @@ class ThemeField < ActiveRecord::Base
Theme.targets[self.target_id] == :extra_js Theme.targets[self.target_id] == :extra_js
end end
def js_tests_field?
Theme.targets[self.target_id] == :tests_js
end
def basic_scss_field? def basic_scss_field?
ThemeField.basic_targets.include?(Theme.targets[self.target_id].to_s) && ThemeField.basic_targets.include?(Theme.targets[self.target_id].to_s) &&
ThemeField.scss_fields.include?(self.name) ThemeField.scss_fields.include?(self.name)
@ -315,7 +341,7 @@ class ThemeField < ActiveRecord::Base
self.error = nil unless self.error.present? self.error = nil unless self.error.present?
self.compiler_version = Theme.compiler_version self.compiler_version = Theme.compiler_version
DB.after_commit { CSP::Extension.clear_theme_extensions_cache! } DB.after_commit { CSP::Extension.clear_theme_extensions_cache! }
elsif extra_js_field? elsif extra_js_field? || js_tests_field?
self.value_baked, self.error = process_extra_js(self.value) self.value_baked, self.error = process_extra_js(self.value)
self.error = nil unless self.error.present? self.error = nil unless self.error.present?
self.compiler_version = Theme.compiler_version self.compiler_version = Theme.compiler_version
@ -422,7 +448,7 @@ class ThemeField < ActiveRecord::Base
hash = {} hash = {}
OPTIONS.each do |option| OPTIONS.each do |option|
plural = :"#{option}s" plural = :"#{option}s"
hash[option] = @allowed_values[plural][0] if @allowed_values[plural] && @allowed_values[plural].length == 1 hash[option] = @allowed_values[plural][0] if @allowed_values[plural]&.length == 1
hash[option] = match[option] if hash[option].nil? hash[option] = match[option] if hash[option].nil?
end end
hash hash
@ -457,6 +483,9 @@ class ThemeField < ActiveRecord::Base
ThemeFileMatcher.new(regex: /^javascripts\/(?<name>.+)$/, ThemeFileMatcher.new(regex: /^javascripts\/(?<name>.+)$/,
targets: :extra_js, names: nil, types: :js, targets: :extra_js, names: nil, types: :js,
canonical: -> (h) { "javascripts/#{h[:name]}" }), canonical: -> (h) { "javascripts/#{h[:name]}" }),
ThemeFileMatcher.new(regex: /^test\/(?<name>.+)$/,
targets: :tests_js, names: nil, types: :js,
canonical: -> (h) { "test/#{h[:name]}" }),
ThemeFileMatcher.new(regex: /^settings\.ya?ml$/, ThemeFileMatcher.new(regex: /^settings\.ya?ml$/,
names: "yaml", types: :yaml, targets: :settings, names: "yaml", types: :yaml, targets: :settings,
canonical: -> (h) { "settings.yml" }), canonical: -> (h) { "settings.yml" }),

View File

@ -5,6 +5,11 @@
<%= discourse_color_scheme_stylesheets %> <%= discourse_color_scheme_stylesheets %>
<%= stylesheet_link_tag "test_helper" %> <%= stylesheet_link_tag "test_helper" %>
<%= javascript_include_tag "test_helper" %> <%= javascript_include_tag "test_helper" %>
<%= theme_tests %>
<%= theme_translations_lookup %>
<%= theme_js_lookup %>
<%= 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

@ -527,6 +527,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"
post "uploads/lookup-metadata" => "uploads#metadata" post "uploads/lookup-metadata" => "uploads#metadata"
post "uploads" => "uploads#create" post "uploads" => "uploads#create"

View File

@ -61,7 +61,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}.each do |arg| %w{module filter qunit_skip_core qunit_single_plugin theme_name theme_url}.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

View File

@ -96,3 +96,24 @@ task "themes:audit" => :environment do
puts repo puts repo
end end
end end
desc "Run QUnit tests of a theme/component"
task "themes:qunit", :theme_name_or_url do |t, args|
name_or_url = args[:theme_name_or_url]
if name_or_url.blank?
raise "A theme name or URL must be provided."
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
Cannot parse passed argument #{name_or_url.inspect}.
Usage:
`bundle exec rake themes:unit[url=<theme_url>]`
OR
`bundle exec rake themes:unit[name=<theme_name>]`
MSG
end
end

View File

@ -151,6 +151,18 @@ 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)
@ -162,10 +174,8 @@ class ThemeJavascriptCompiler
def prepend_settings(settings_hash) def prepend_settings(settings_hash)
@content.prepend <<~JS @content.prepend <<~JS
(function() { (function() {
if ('Discourse' in window && Discourse.__container__) { if ('require' in window) {
Discourse.__container__ require("discourse/lib/theme-settings-store").registerSettings(#{@theme_id}, #{settings_hash.to_json});
.lookup("service:theme-settings")
.registerSettings(#{@theme_id}, #{settings_hash.to_json});
} }
})(); })();
JS JS
@ -173,8 +183,14 @@ class ThemeJavascriptCompiler
# TODO Error handling for handlebars templates # TODO Error handling for handlebars templates
def append_ember_template(name, hbs_template) def append_ember_template(name, hbs_template)
if !name.start_with?("javascripts/")
prefix = "javascripts"
prefix += "/" if !name.start_with?("/")
name = prefix + name
end
name = name.inspect name = name.inspect
compiled = EmberTemplatePrecompiler.new(@theme_id).compile(hbs_template) compiled = EmberTemplatePrecompiler.new(@theme_id).compile(hbs_template)
# the `'Ember' in window` check is needed for no_ember pages
content << <<~JS content << <<~JS
(function() { (function() {
if ('Ember' in window) { if ('Ember' in window) {
@ -204,18 +220,19 @@ class ThemeJavascriptCompiler
raise CompileError.new e.instance_variable_get(:@error) # e.message contains the entire template, which could be very long raise CompileError.new e.instance_variable_get(:@error) # e.message contains the entire template, which could be very long
end end
def append_plugin_script(script, api_version)
@content << transpile(script, api_version)
end
def append_raw_script(script) def append_raw_script(script)
@content << script + "\n" @content << script + "\n"
end end
def append_module(script, name, include_variables: true) def append_module(script, name, include_variables: true)
script = "#{theme_variables}#{script}" if include_variables name = "discourse/theme-#{@theme_id}/#{name.gsub(/^discourse\//, '')}"
script = "#{theme_settings}#{script}" if include_variables
transpiler = DiscourseJsProcessor::Transpiler.new transpiler = DiscourseJsProcessor::Transpiler.new
@content << transpiler.perform(script, "", name) @content << <<~JS
if ('define' in window) {
#{transpiler.perform(script, "", name).strip}
}
JS
rescue MiniRacer::RuntimeError => ex rescue MiniRacer::RuntimeError => ex
raise CompileError.new ex.message raise CompileError.new ex.message
end end
@ -226,36 +243,11 @@ class ThemeJavascriptCompiler
private private
def theme_variables def theme_settings
<<~JS <<~JS
const __theme_name__ = "#{@theme_name.gsub('"', "\\\"")}"; const settings = require("discourse/lib/theme-settings-store")
const settings = Discourse.__container__
.lookup("service:theme-settings")
.getObjectForTheme(#{@theme_id}); .getObjectForTheme(#{@theme_id});
const themePrefix = (key) => `theme_translations.#{@theme_id}.${key}`; const themePrefix = (key) => `theme_translations.#{@theme_id}.${key}`;
JS JS
end end
def transpile(es6_source, version)
transpiler = DiscourseJsProcessor::Transpiler.new(skip_module: true)
wrapped = <<~PLUGIN_API_JS
(function() {
if ('Discourse' in window && typeof Discourse._registerPluginCode === 'function') {
#{theme_variables}
Discourse._registerPluginCode('#{version}', api => {
try {
#{es6_source}
} catch(err) {
const rescue = require("discourse/lib/utilities").rescueThemeError;
rescue(__theme_name__, err, api);
}
});
}
})();
PLUGIN_API_JS
transpiler.perform(wrapped)
rescue MiniRacer::RuntimeError => ex
raise CompileError.new ex.message
end
end end

View File

@ -127,4 +127,18 @@ describe ThemeJavascriptCompiler do
expect(compiler.content.to_s).to include("addRawTemplate(\"#{name}.hbs\"") expect(compiler.content.to_s).to include("addRawTemplate(\"#{name}.hbs\"")
end end
end end
describe "#append_ember_template" do
let(:compiler) { ThemeJavascriptCompiler.new(1, 'marks') }
it 'prepends `javascripts/` to template name if it is not prepended' do
compiler.append_ember_template("/connectors/blah-1", "{{var}}")
expect(compiler.content.to_s).to include('Ember.TEMPLATES["javascripts/connectors/blah-1"]')
compiler.append_ember_template("connectors/blah-2", "{{var}}")
expect(compiler.content.to_s).to include('Ember.TEMPLATES["javascripts/connectors/blah-2"]')
compiler.append_ember_template("javascripts/connectors/blah-3", "{{var}}")
expect(compiler.content.to_s).to include('Ember.TEMPLATES["javascripts/connectors/blah-3"]')
end
end
end end

View File

@ -40,6 +40,7 @@ describe RemoteTheme do
"stylesheets/file.scss" => ".class1{color:red}", "stylesheets/file.scss" => ".class1{color:red}",
"stylesheets/empty.scss" => "", "stylesheets/empty.scss" => "",
"javascripts/discourse/controllers/test.js.es6" => "console.log('test');", "javascripts/discourse/controllers/test.js.es6" => "console.log('test');",
"test/acceptance/theme-test.js" => "assert.ok(true);",
"common/header.html" => "I AM HEADER", "common/header.html" => "I AM HEADER",
"common/random.html" => "I AM SILLY", "common/random.html" => "I AM SILLY",
"common/embedded.scss" => "EMBED", "common/embedded.scss" => "EMBED",
@ -74,7 +75,7 @@ describe RemoteTheme do
expect(@theme.theme_modifier_set.serialize_topic_excerpts).to eq(true) expect(@theme.theme_modifier_set.serialize_topic_excerpts).to eq(true)
expect(@theme.theme_fields.length).to eq(10) expect(@theme.theme_fields.length).to eq(11)
mapped = Hash[*@theme.theme_fields.map { |f| ["#{f.target_id}-#{f.name}", f.value] }.flatten] mapped = Hash[*@theme.theme_fields.map { |f| ["#{f.target_id}-#{f.name}", f.value] }.flatten]
@ -88,8 +89,9 @@ describe RemoteTheme do
expect(mapped["3-yaml"]).to eq("boolean_setting: true") expect(mapped["3-yaml"]).to eq("boolean_setting: true")
expect(mapped["4-en"]).to eq("sometranslations") expect(mapped["4-en"]).to eq("sometranslations")
expect(mapped["7-acceptance/theme-test.js"]).to eq("assert.ok(true);")
expect(mapped.length).to eq(10) expect(mapped.length).to eq(11)
expect(@theme.settings.length).to eq(1) expect(@theme.settings.length).to eq(1)
expect(@theme.settings.first.value).to eq(true) expect(@theme.settings.first.value).to eq(true)

View File

@ -192,34 +192,22 @@ HTML
unknown_field = theme.set_field(target: :extra_js, name: "discourse/controllers/discovery.blah", value: "this wont work") unknown_field = theme.set_field(target: :extra_js, name: "discourse/controllers/discovery.blah", value: "this wont work")
theme.save! theme.save!
expected_js = <<~JS js_field.reload
define("discourse/controllers/discovery", ["discourse/lib/ajax"], function (_ajax) { expect(js_field.value_baked).to include("if ('define' in window) {")
"use strict"; expect(js_field.value_baked).to include("define(\"discourse/theme-#{theme.id}/controllers/discovery\"")
expect(js_field.value_baked).to include("console.log('hello from .js.es6');")
var __theme_name__ = "#{theme.name}"; expect(hbs_field.reload.value_baked).to include('Ember.TEMPLATES["javascripts/discovery"]')
var settings = Discourse.__container__.lookup("service:theme-settings").getObjectForTheme(#{theme.id});
var themePrefix = function themePrefix(key) {
return "theme_translations.#{theme.id}.".concat(key);
};
console.log('hello from .js.es6');
});
JS
expect(js_field.reload.value_baked).to eq(expected_js.strip)
expect(hbs_field.reload.value_baked).to include('Ember.TEMPLATES["discovery"]')
expect(raw_hbs_field.reload.value_baked).to include('addRawTemplate("discovery"') expect(raw_hbs_field.reload.value_baked).to include('addRawTemplate("discovery"')
expect(hbr_field.reload.value_baked).to include('addRawTemplate("other_discovery"') expect(hbr_field.reload.value_baked).to include('addRawTemplate("other_discovery"')
expect(unknown_field.reload.value_baked).to eq("") expect(unknown_field.reload.value_baked).to eq("")
expect(unknown_field.reload.error).to eq(I18n.t("themes.compile_error.unrecognized_extension", extension: "blah")) expect(unknown_field.reload.error).to eq(I18n.t("themes.compile_error.unrecognized_extension", extension: "blah"))
# All together # All together
expect(theme.javascript_cache.content).to include('Ember.TEMPLATES["discovery"]') expect(theme.javascript_cache.content).to include('Ember.TEMPLATES["javascripts/discovery"]')
expect(theme.javascript_cache.content).to include('addRawTemplate("discovery"') expect(theme.javascript_cache.content).to include('addRawTemplate("discovery"')
expect(theme.javascript_cache.content).to include('define("discourse/controllers/discovery"') expect(theme.javascript_cache.content).to include("define(\"discourse/theme-#{theme.id}/controllers/discovery\"")
expect(theme.javascript_cache.content).to include('define("discourse/controllers/discovery-2"') expect(theme.javascript_cache.content).to include("define(\"discourse/theme-#{theme.id}/controllers/discovery-2\"")
expect(theme.javascript_cache.content).to include("var settings =") expect(theme.javascript_cache.content).to include("var settings =")
end end

View File

@ -126,24 +126,6 @@ describe Theme do
expect(Theme.lookup_field(theme.id, :desktop, "head_tag")).to eq("<b>I am bold</b>") expect(Theme.lookup_field(theme.id, :desktop, "head_tag")).to eq("<b>I am bold</b>")
end end
it "changing theme name should re-transpile HTML theme fields" do
theme.update!(name: "old_name")
html = <<~HTML
<script type='text/discourse-plugin' version='0.1'>
const x = 1;
</script>
HTML
theme.set_field(target: :common, name: "head_tag", value: html)
theme.save!
field = theme.theme_fields.where(value: html).first
old_value = field.value_baked
theme.update!(name: "new_name")
field.reload
new_value = field.value_baked
expect(old_value).not_to eq(new_value)
end
it 'should precompile fragments in body and head tags' do it 'should precompile fragments in body and head tags' do
with_template = <<HTML with_template = <<HTML
<script type='text/x-handlebars' name='template'> <script type='text/x-handlebars' name='template'>
@ -276,7 +258,7 @@ HTML
def transpile(html) def transpile(html)
f = ThemeField.create!(target_id: Theme.targets[:mobile], theme_id: 1, name: "after_header", value: html) f = ThemeField.create!(target_id: Theme.targets[:mobile], theme_id: 1, name: "after_header", value: html)
f.ensure_baked! f.ensure_baked!
[f.value_baked, f.javascript_cache] [f.value_baked, f.javascript_cache, f]
end end
it "transpiles ES6 code" do it "transpiles ES6 code" do
@ -286,10 +268,20 @@ HTML
</script> </script>
HTML HTML
baked, javascript_cache = transpile(html) baked, javascript_cache, field = transpile(html)
expect(baked).to include(javascript_cache.url) expect(baked).to include(javascript_cache.url)
expect(javascript_cache.content).to include('var x = 1;')
expect(javascript_cache.content).to include("_registerPluginCode('0.1'") expect(javascript_cache.content).to include("if ('define' in window) {")
expect(javascript_cache.content).to include(
"define(\"discourse/theme-#{field.theme_id}/initializers/theme-field-#{field.id}-mobile-html-script-1\""
)
expect(javascript_cache.content).to include(
"settings = require(\"discourse/lib/theme-settings-store\").getObjectForTheme(#{field.theme_id});"
)
expect(javascript_cache.content).to include("name: \"theme-field-#{field.id}-mobile-html-script-1\",")
expect(javascript_cache.content).to include("after: \"inject-objects\",")
expect(javascript_cache.content).to include("(0, _pluginApi.withPluginApi)(\"0.1\", function (api) {")
expect(javascript_cache.content).to include("var x = 1;")
end end
it "wraps constants calls in a readOnlyError function" do it "wraps constants calls in a readOnlyError function" do
@ -369,83 +361,31 @@ HTML
theme_field = theme.set_field(target: :common, name: :after_header, value: '<script type="text/discourse-plugin" version="1.0">alert(settings.name); let a = ()=>{};</script>') theme_field = theme.set_field(target: :common, name: :after_header, value: '<script type="text/discourse-plugin" version="1.0">alert(settings.name); let a = ()=>{};</script>')
theme.save! theme.save!
transpiled = <<~HTML
(function() {
if ('Discourse' in window && Discourse.__container__) {
Discourse.__container__
.lookup("service:theme-settings")
.registerSettings(#{theme.id}, {"name":"bob"});
}
})();
(function () {
if ('Discourse' in window && typeof Discourse._registerPluginCode === 'function') {
var __theme_name__ = "awesome theme\\\"";
var settings = Discourse.__container__.lookup("service:theme-settings").getObjectForTheme(#{theme.id});
var themePrefix = function themePrefix(key) {
return "theme_translations.#{theme.id}.".concat(key);
};
Discourse._registerPluginCode('1.0', function (api) {
try {
alert(settings.name);
var a = function a() {};
} catch (err) {
var rescue = require("discourse/lib/utilities").rescueThemeError;
rescue(__theme_name__, err, api);
}
});
}
})();
HTML
theme_field.reload theme_field.reload
expect(Theme.lookup_field(theme.id, :desktop, :after_header)).to include(theme_field.javascript_cache.url) expect(Theme.lookup_field(theme.id, :desktop, :after_header)).to include(theme_field.javascript_cache.url)
expect(theme_field.javascript_cache.content).to eq(transpiled.strip) expect(theme_field.javascript_cache.content).to include("if ('require' in window) {")
expect(theme_field.javascript_cache.content).to include(
"require(\"discourse/lib/theme-settings-store\").registerSettings(#{theme_field.theme.id}, {\"name\":\"bob\"});"
)
expect(theme_field.javascript_cache.content).to include("if ('define' in window) {")
expect(theme_field.javascript_cache.content).to include(
"define(\"discourse/theme-#{theme_field.theme.id}/initializers/theme-field-#{theme_field.id}-common-html-script-1\","
)
expect(theme_field.javascript_cache.content).to include("name: \"theme-field-#{theme_field.id}-common-html-script-1\",")
expect(theme_field.javascript_cache.content).to include("after: \"inject-objects\",")
expect(theme_field.javascript_cache.content).to include("(0, _pluginApi.withPluginApi)(\"1.0\", function (api)")
expect(theme_field.javascript_cache.content).to include("alert(settings.name)")
expect(theme_field.javascript_cache.content).to include("var a = function a() {}")
setting = theme.settings.find { |s| s.name == :name } setting = theme.settings.find { |s| s.name == :name }
setting.value = 'bill' setting.value = 'bill'
theme.save! theme.save!
transpiled = <<~HTML
(function() {
if ('Discourse' in window && Discourse.__container__) {
Discourse.__container__
.lookup("service:theme-settings")
.registerSettings(#{theme.id}, {"name":"bill"});
}
})();
(function () {
if ('Discourse' in window && typeof Discourse._registerPluginCode === 'function') {
var __theme_name__ = "awesome theme\\\"";
var settings = Discourse.__container__.lookup("service:theme-settings").getObjectForTheme(#{theme.id});
var themePrefix = function themePrefix(key) {
return "theme_translations.#{theme.id}.".concat(key);
};
Discourse._registerPluginCode('1.0', function (api) {
try {
alert(settings.name);
var a = function a() {};
} catch (err) {
var rescue = require("discourse/lib/utilities").rescueThemeError;
rescue(__theme_name__, err, api);
}
});
}
})();
HTML
theme_field.reload theme_field.reload
expect(theme_field.javascript_cache.content).to include(
"require(\"discourse/lib/theme-settings-store\").registerSettings(#{theme_field.theme.id}, {\"name\":\"bill\"});"
)
expect(Theme.lookup_field(theme.id, :desktop, :after_header)).to include(theme_field.javascript_cache.url) expect(Theme.lookup_field(theme.id, :desktop, :after_header)).to include(theme_field.javascript_cache.url)
expect(theme_field.javascript_cache.content).to eq(transpiled.strip)
end end
it 'is empty when the settings are invalid' do it 'is empty when the settings are invalid' do

View File

@ -0,0 +1,76 @@
# frozen_string_literal: true
require 'rails_helper'
describe QunitController do
let(:theme) { Fabricate(:theme, name: 'main-theme') }
let(:component) { Fabricate(:theme, component: true, name: 'enabled-component') }
let(:disabled_component) { Fabricate(:theme, component: true, enabled: false, name: 'disabled-component') }
before do
Theme.destroy_all
theme.set_default!
component.add_relative_theme!(:parent, theme)
disabled_component.add_relative_theme!(:parent, theme)
[theme, component, disabled_component].each do |t|
t.set_field(
target: :extra_js,
type: :js,
name: "discourse/initializers/my-#{t.id}-initializer.js",
value: "console.log(#{t.id});"
)
t.set_field(
target: :tests_js,
type: :js,
name: "acceptance/some-test-#{t.id}.js",
value: "assert.ok(#{t.id});"
)
t.save!
end
end
context "when no theme is specified" do
it "includes tests of enabled theme + components" do
get '/qunit'
js_urls = JavascriptCache.where(theme_id: [theme.id, component.id]).map(&:url)
expect(js_urls.size).to eq(2)
js_urls.each do |url|
expect(response.body).to include(url)
end
[theme, component].each do |t|
expect(response.body).to include("/theme-javascripts/tests/#{t.id}.js")
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
context "when a theme is specified" do
it "includes tests of the specified theme only" do
[theme, disabled_component].each do |t|
get "/qunit?theme_name=#{t.name}"
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
expect(response.body).to include("/theme-javascripts/tests/#{t.id}.js")
excluded = Theme.pluck(:id) - [t.id]
js_urls = JavascriptCache.where(theme_id: excluded).map(&:url)
expect(js_urls.size).to eq(2)
js_urls.each do |url|
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
end
end
end

View File

@ -54,4 +54,24 @@ describe ThemeJavascriptsController do
end end
end end
end end
describe "#show_tests" do
context "theme settings" do
let(:component) { Fabricate(:theme, component: true, name: 'enabled-component') }
it "forces default values" do
ThemeField.create!(
theme: component,
target_id: Theme.targets[:settings],
name: "yaml",
value: "num_setting: 5"
)
component.reload
component.update_setting(:num_setting, 643)
get "/theme-javascripts/tests/#{component.id}.js"
expect(response.body).to include("require(\"discourse/lib/theme-settings-store\").registerSettings(#{component.id}, {\"num_setting\":5}, { force: true });")
end
end
end
end end