discourse/spec/models/theme_spec.rb
David Taylor 351d3d4eb9
FIX: Make theme live-reload safer (#34133)
In production, reloading CSS files before loading new JS is risky, and
can lead to surprising UI flickering/breakage. This commit updates theme
CSS 'file-change' notifications to trigger a reload on next navigation,
just like regular core updates.

In development mode, CSS will still be refreshed 'live'.

Also adds the 'common' target to the list of refreshed CSS bundles,
which will improve the experience in development mode.

---------

Co-authored-by: Jarek Radosz <jradosz@gmail.com>
2025-08-07 13:10:42 +01:00

1773 lines
54 KiB
Ruby

# frozen_string_literal: true
RSpec.describe Theme do
fab!(:user)
fab!(:theme) { Fabricate(:theme, user: user) }
let(:guardian) { Guardian.new(user) }
let(:child) { Fabricate(:theme, user: user, component: true) }
let(:foundation_theme) { Theme.foundation_theme }
before { ThemeJavascriptCompiler.disable_terser! }
after do
Theme.clear_cache!
ThemeJavascriptCompiler.enable_terser!
end
it "can properly clean up color schemes" do
scheme = ColorScheme.create!(theme_id: theme.id, name: "test")
scheme2 = ColorScheme.create!(theme_id: theme.id, name: "test2")
Fabricate(:theme, color_scheme_id: scheme2.id)
theme.destroy!
scheme2.reload
expect(scheme2).not_to eq(nil)
expect(scheme2.theme_id).to eq(nil)
expect(ColorScheme.find_by(id: scheme.id)).to eq(nil)
end
it "can support child themes" do
child.set_field(target: :common, name: "header", value: "World")
child.set_field(target: :desktop, name: "header", value: "Desktop")
child.set_field(target: :mobile, name: "header", value: "Mobile")
child.save!
expect(Theme.lookup_field(child.id, :desktop, "header")).to eq("World\nDesktop")
expect(Theme.lookup_field(child.id, "mobile", :header)).to eq("World\nMobile")
child.set_field(target: :common, name: "header", value: "Worldie")
child.save!
expect(Theme.lookup_field(child.id, :mobile, :header)).to eq("Worldie\nMobile")
parent = Fabricate(:theme, user: user)
parent.set_field(target: :common, name: "header", value: "Common Parent")
parent.set_field(target: :mobile, name: "header", value: "Mobile Parent")
parent.save!
parent.add_relative_theme!(:child, child)
expect(Theme.lookup_field(parent.id, :mobile, "header")).to eq(
"Common Parent\nMobile Parent\nWorldie\nMobile",
)
end
it "can support parent themes" do
child.add_relative_theme!(:parent, theme)
expect(child.parent_themes).to eq([theme])
end
it "can automatically disable for mismatching version" do
theme.create_remote_theme!(remote_url: "", minimum_discourse_version: "99.99.99")
theme.save!
expect(Theme.transform_ids(theme.id)).to eq([])
end
it "#transform_ids works with nil values" do
# Used in safe mode
expect(Theme.transform_ids(nil)).to eq([])
end
it "#transform_ids filters out disabled components" do
theme.add_relative_theme!(:child, child)
expect(Theme.transform_ids(theme.id)).to eq([theme.id, child.id])
child.update!(enabled: false)
expect(Theme.transform_ids(theme.id)).to eq([theme.id])
end
it "doesn't allow multi-level theme components" do
grandchild = Fabricate(:theme, user: user)
grandparent = Fabricate(:theme, user: user)
expect do child.add_relative_theme!(:child, grandchild) end.to raise_error(
Discourse::InvalidParameters,
I18n.t("themes.errors.no_multilevels_components"),
)
expect do grandparent.add_relative_theme!(:child, theme) end.to raise_error(
Discourse::InvalidParameters,
I18n.t("themes.errors.no_multilevels_components"),
)
end
it "doesn't allow a child to be user selectable" do
child.update(user_selectable: true)
expect(child.errors.full_messages).to contain_exactly(
I18n.t("themes.errors.component_no_user_selectable"),
)
end
it "doesn't allow a child to be set as the default theme" do
expect do child.set_default! end.to raise_error(
Discourse::InvalidParameters,
I18n.t("themes.errors.component_no_default"),
)
end
it "doesn't allow a component to have color scheme" do
scheme = ColorScheme.create!(name: "test")
child.update(color_scheme: scheme)
expect(child.errors.full_messages).to contain_exactly(
I18n.t("themes.errors.component_no_color_scheme"),
)
end
it "should correct bad html in body_tag_baked and head_tag_baked" do
theme.set_field(target: :common, name: "head_tag", value: "<b>I am bold")
theme.save!
expect(Theme.lookup_field(theme.id, :desktop, "head_tag")).to eq("<b>I am bold</b>")
end
it "should precompile fragments in body and head tags" do
with_template = <<HTML
<script type='text/x-handlebars' name='template'>
{{hello}}
</script>
<script type='text/x-handlebars' data-template-name='raw_template.raw'>
{{hello}}
</script>
HTML
theme.set_field(target: :common, name: "header", value: with_template)
theme.save!
field = theme.theme_fields.find_by(target_id: Theme.targets[:common], name: "header")
baked = Theme.lookup_field(theme.id, :mobile, "header")
expect(baked).to include(field.javascript_cache.url)
expect(field.javascript_cache.content).to include("@ember/template-factory")
expect(field.javascript_cache.content).to include("Raw templates are no longer supported")
end
it "can destroy unbaked theme without errors" do
with_template = <<HTML
<script type='text/x-handlebars' name='template'>
{{hello}}
</script>
<script type='text/x-handlebars' data-template-name='raw_template.raw'>
{{hello}}
</script>
HTML
theme.set_field(target: :common, name: "header", value: with_template)
theme.save!
field = theme.theme_fields.find_by(target_id: Theme.targets[:common], name: "header")
baked = Theme.lookup_field(theme.id, :mobile, "header")
ThemeField.where(id: field.id).update_all(compiler_version: 0) # update_all to avoid callbacks
field.reload.destroy!
end
it "should create body_tag_baked on demand if needed" do
theme.set_field(target: :common, name: :body_tag, value: "<b>test")
theme.save
ThemeField.update_all(value_baked: nil)
expect(Theme.lookup_field(theme.id, :desktop, :body_tag)).to match(%r{<b>test</b>})
end
describe "#switch_to_component!" do
it "correctly converts a theme to component" do
theme.add_relative_theme!(:child, child)
scheme = ColorScheme.create!(name: "test")
theme.update!(color_scheme_id: scheme.id, user_selectable: true)
theme.set_default!
theme.switch_to_component!
theme.reload
expect(theme.component).to eq(true)
expect(theme.user_selectable).to eq(false)
expect(theme.default?).to eq(false)
expect(theme.color_scheme_id).to eq(nil)
expect(ChildTheme.where(parent_theme: theme).exists?).to eq(false)
end
end
describe "#switch_to_theme!" do
it "correctly converts a component to theme" do
theme.add_relative_theme!(:child, child)
child.switch_to_theme!
theme.reload
child.reload
expect(child.component).to eq(false)
expect(ChildTheme.where(child_theme: child).exists?).to eq(false)
end
end
describe ".transform_ids" do
let!(:orphan1) { Fabricate(:theme, component: true) }
let!(:child) { Fabricate(:theme, component: true) }
let!(:child2) { Fabricate(:theme, component: true) }
let!(:orphan2) { Fabricate(:theme, component: true) }
let!(:orphan3) { Fabricate(:theme, component: true) }
let!(:orphan4) { Fabricate(:theme, component: true) }
before do
theme.add_relative_theme!(:child, child)
theme.add_relative_theme!(:child, child2)
end
it "returns an empty array if no ids are passed" do
expect(Theme.transform_ids(nil)).to eq([])
end
it "adds the child themes of the parent" do
sorted = [child.id, child2.id].sort
expect(Theme.transform_ids(theme.id)).to eq([theme.id, *sorted])
end
end
describe "plugin api" do
def transpile(html)
f =
ThemeField.create!(
target_id: Theme.targets[:mobile],
theme_id: -1,
name: "after_header",
value: html,
)
f.ensure_baked!
[f.value_baked, f.javascript_cache, f]
end
it "transpiles ES6 code" do
html = <<HTML
<script type='text/discourse-plugin' version='0.1'>
const x = 1;
console.log(x, settings.foo);
</script>
HTML
baked, javascript_cache, field = transpile(html)
expect(baked).to include(javascript_cache.url)
expect(javascript_cache.content).to include(
"themeCompatModules[\"discourse/initializers/theme-field-#{field.id}-mobile-html-script-1\"]",
)
expect(javascript_cache.content).to include("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("withPluginApi(\"0.1\", api =>")
expect(javascript_cache.content).to include("const x = 1;")
end
end
describe "theme upload vars" do
let :image do
file_from_fixtures("logo.png")
end
it "can handle uploads based of ThemeField" do
upload = UploadCreator.new(image, "logo.png").create_for(-1)
theme.set_field(target: :common, name: :logo, upload_id: upload.id, type: :theme_upload_var)
theme.set_field(target: :common, name: :scss, value: "body {background-image: url($logo)}")
theme.save!
# make sure we do not nuke it
freeze_time (SiteSetting.clean_orphan_uploads_grace_period_hours + 1).hours.from_now
Jobs::CleanUpUploads.new.execute(nil)
expect(Upload.where(id: upload.id)).to be_exists
# no error for theme field
theme.reload
expect(theme.theme_fields.find_by(name: :scss).error).to eq(nil)
manager = Stylesheet::Manager.new(theme_id: theme.id)
scss, _map =
Stylesheet::Manager::Builder.new(
target: :common_theme,
theme: theme,
manager: manager,
).compile(force: true)
expect(scss).to include(upload.url)
end
end
describe "theme settings" do
it "allows values to be used in scss" do
theme.set_field(
target: :settings,
name: :yaml,
value: "background_color: red\nfont_size: 25px",
)
theme.set_field(
target: :common,
name: :scss,
value: "body {background-color: $background_color; font-size: $font-size}",
)
theme.save!
manager = Stylesheet::Manager.new(theme_id: theme.id)
scss, _map =
Stylesheet::Manager::Builder.new(
target: :common_theme,
theme: theme,
manager: manager,
).compile(force: true)
expect(scss).to include("background-color:red")
expect(scss).to include("font-size:25px")
setting = theme.settings[:font_size]
setting.value = "30px"
theme.save!
scss, _map =
Stylesheet::Manager::Builder.new(
target: :common_theme,
theme: theme,
manager: manager,
).compile(force: true)
expect(scss).to include("font-size:30px")
# Escapes correctly. If not, compiling this would throw an exception
setting.value = <<~SCSS
\#{$fakeinterpolatedvariable}
andanothervalue 'withquotes'; margin: 0;
SCSS
theme.set_field(target: :common, name: :scss, value: "body {font-size: quote($font-size)}")
theme.save!
scss, _map =
Stylesheet::Manager::Builder.new(
target: :common_theme,
theme: theme,
manager: manager,
).compile(force: true)
expect(scss).to include(
'font-size:"#{$fakeinterpolatedvariable}\a andanothervalue \'withquotes\'; margin: 0;\a"',
)
end
it "can use a setting straight away after introducing it" do
theme.set_field(target: :common, name: :scss, value: "body {background-color: red;}")
theme.save!
theme.reload
theme.set_field(
target: :settings,
name: :yaml,
value: "background_color: red\nfont_size: 25px",
)
theme.set_field(
target: :common,
name: :scss,
value: "body {background-color: $background_color;}",
)
theme.save!
expect(
theme.theme_fields.find_by(target_id: Theme.targets[:common], name: "scss").error,
).to eq(nil)
end
it "allows values to be used in JS" do
theme.name = 'awesome theme"'
theme.set_field(target: :settings, name: :yaml, value: "name: bob")
theme_field =
theme.set_field(
target: :common,
name: :after_header,
value:
'<script type="text/discourse-plugin" version="1.0">alert(settings.name); let a = ()=>{}; console.log(a);</script>',
)
theme.save!
theme_field.reload
expect(Theme.lookup_field(theme.id, :desktop, :after_header)).to include(
theme_field.javascript_cache.url,
)
expect(theme_field.javascript_cache.content).to include <<~JS
registerSettings(#{theme_field.theme.id}, {
"name": "bob"
});
JS
expect(theme_field.javascript_cache.content).to include(
"themeCompatModules[\"discourse/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("withPluginApi(\"1.0\", api =>")
expect(theme_field.javascript_cache.content).to include("alert(settings.name)")
expect(theme_field.javascript_cache.content).to include("let a = () => {}")
setting = theme.settings[:name]
setting.value = "bill"
theme.save!
theme_field.reload
expect(theme_field.javascript_cache.content).to include <<~JS
registerSettings(#{theme_field.theme.id}, {
"name": "bill"
});
JS
expect(Theme.lookup_field(theme.id, :desktop, :after_header)).to include(
theme_field.javascript_cache.url,
)
end
it "is empty when the settings are invalid" do
theme.set_field(target: :settings, name: :yaml, value: "nil_setting: ")
theme.save!
expect(theme.settings).to be_empty
end
end
it "correctly caches theme ids" do
Theme.where.not(id: theme.id).delete_all
theme2 = Fabricate(:theme)
expect(Theme.theme_ids).to contain_exactly(theme.id, theme2.id)
expect(Theme.user_theme_ids).to eq([])
theme.update!(user_selectable: true)
expect(Theme.user_theme_ids).to contain_exactly(theme.id)
theme2.update!(user_selectable: true)
expect(Theme.user_theme_ids).to contain_exactly(theme.id, theme2.id)
theme.update!(user_selectable: false)
theme2.update!(user_selectable: false)
theme.set_default!
expect(Theme.user_theme_ids).to contain_exactly(theme.id)
theme.destroy
theme2.destroy
expect(Theme.theme_ids).to eq([])
expect(Theme.user_theme_ids).to eq([])
end
it "correctly caches enabled_theme_and_component_ids" do
Theme.delete_all
theme2 = Fabricate(:theme)
expect(Theme.enabled_theme_and_component_ids).to eq([])
theme2.update!(user_selectable: true)
expect(Theme.enabled_theme_and_component_ids).to contain_exactly(theme2.id)
theme2.update!(user_selectable: false)
theme2.set_default!
expect(Theme.enabled_theme_and_component_ids).to contain_exactly(theme2.id)
child2 = Fabricate(:theme, component: true)
theme2.add_relative_theme!(:child, child2)
expect(Theme.enabled_theme_and_component_ids).to contain_exactly(theme2.id, child2.id)
child2.update!(enabled: false)
expect(Theme.enabled_theme_and_component_ids).to contain_exactly(theme2.id)
theme3 = Fabricate(:theme, user_selectable: true)
child2.update!(enabled: true)
expect(Theme.enabled_theme_and_component_ids).to contain_exactly(
theme2.id,
child2.id,
theme3.id,
)
theme3.update!(enabled: false)
expect(Theme.enabled_theme_and_component_ids).to contain_exactly(theme2.id, child2.id)
theme2.destroy
theme3.destroy
expect(Theme.enabled_theme_and_component_ids).to eq([])
end
it "correctly caches user_themes template" do
Theme.delete_all
json = Site.json_for(guardian)
user_themes = JSON.parse(json)["user_themes"]
expect(user_themes).to eq([])
theme = Fabricate(:theme, name: "bob", user_selectable: true)
theme.save!
json = Site.json_for(guardian)
user_themes = JSON.parse(json)["user_themes"].map { |t| t["name"] }
expect(user_themes).to eq(["bob"])
theme.name = "sam"
theme.save!
json = Site.json_for(guardian)
user_themes = JSON.parse(json)["user_themes"].map { |t| t["name"] }
expect(user_themes).to eq(["sam"])
Theme.destroy_all
json = Site.json_for(guardian)
user_themes = JSON.parse(json)["user_themes"]
expect(user_themes).to eq([])
end
def cached_settings(id)
Theme.find_by(id: id).cached_settings.to_json
end
def included_settings(id)
Theme.find_by(id: id).included_settings.to_json
end
it "clears color scheme cache correctly" do
Theme.delete_all
cs =
Fabricate(
:color_scheme,
name: "Fancy",
color_scheme_colors: [
Fabricate(:color_scheme_color, name: "header_primary", hex: "F0F0F0"),
Fabricate(:color_scheme_color, name: "header_background", hex: "1E1E1E"),
Fabricate(:color_scheme_color, name: "tertiary", hex: "858585"),
],
)
theme =
Fabricate(:theme, user_selectable: true, user: Fabricate(:admin), color_scheme_id: cs.id)
theme.set_default!
expect(ColorScheme.hex_for_name("header_primary")).to eq("F0F0F0")
Theme.clear_default!
expect(ColorScheme.hex_for_name("header_primary")).to eq("333")
end
it "correctly notifies about theme changes" do
cs1 = Fabricate(:color_scheme)
cs2 = Fabricate(:color_scheme)
theme = Fabricate(:theme, user_selectable: true, user: user, color_scheme_id: cs1.id)
messages =
MessageBus
.track_publish do
theme.set_field(target: :common, name: :scss, value: "body { color: red; }")
theme.save!
end
.filter { |m| m.channel == "/file-change" }
expect(messages.count).to eq(1)
expect(messages.first.data.map { |d| d[:target] }).to contain_exactly(:common_theme)
# With color scheme change:
messages =
MessageBus
.track_publish do
theme.color_scheme_id = cs2.id
theme.save!
end
.filter { |m| m.channel == "/file-change" }
expect(messages.count).to eq(1)
expect(messages.first.data.map { |d| d[:target] }).to contain_exactly(
:common,
:admin,
:desktop,
:mobile,
:common_theme,
)
end
it "includes theme_uploads in settings" do
Theme.where.not(id: theme.id).delete_all
upload = UploadCreator.new(file_from_fixtures("logo.png"), "logo.png").create_for(-1)
theme.set_field(type: :theme_upload_var, target: :common, name: "bob", upload_id: upload.id)
theme.save!
json = JSON.parse(cached_settings(theme.id))
expect(json["theme_uploads"]["bob"]).to eq(upload.url)
end
it "does not break on missing uploads in settings" do
Theme.where.not(id: theme.id).delete_all
upload = UploadCreator.new(file_from_fixtures("logo.png"), "logo.png").create_for(-1)
theme.set_field(type: :theme_upload_var, target: :common, name: "bob", upload_id: upload.id)
theme.save!
Upload.find(upload.id).destroy
theme.remove_from_cache!
json = JSON.parse(cached_settings(theme.id))
expect(json).to be_empty
end
it "uses CDN url for theme_uploads in settings" do
set_cdn_url("http://cdn.localhost")
Theme.where.not(id: theme.id).delete_all
upload = UploadCreator.new(file_from_fixtures("logo.png"), "logo.png").create_for(-1)
theme.set_field(type: :theme_upload_var, target: :common, name: "bob", upload_id: upload.id)
theme.save!
json = JSON.parse(cached_settings(theme.id))
expect(json["theme_uploads"]["bob"]).to eq("http://cdn.localhost#{upload.url}")
end
it "uses CDN url for settings of type upload" do
set_cdn_url("http://cdn.localhost")
Theme.where.not(id: theme.id).delete_all
upload = UploadCreator.new(file_from_fixtures("logo.png"), "logo.png").create_for(-1)
theme.set_field(target: :settings, name: "yaml", value: <<~YAML)
my_upload:
type: upload
default: ""
YAML
ThemeSetting.create!(
theme: theme,
data_type: ThemeSetting.types[:upload],
value: upload.id.to_s,
name: "my_upload",
)
theme.save!
json = JSON.parse(cached_settings(theme.id))
expect(json["my_upload"]).to eq("http://cdn.localhost#{upload.url}")
end
describe "theme translations" do
it "can list working theme_translation_manager objects" do
en_translation =
ThemeField.create!(
theme_id: theme.id,
name: "en",
type_id: ThemeField.types[:yaml],
target_id: Theme.targets[:translations],
value: <<~YAML,
en:
theme_metadata:
description: "Description of my theme"
group_of_translations:
translation1: en test1
translation2: en test2
base_translation1: en test3
base_translation2: en test4
YAML
)
fr_translation =
ThemeField.create!(
theme_id: theme.id,
name: "fr",
type_id: ThemeField.types[:yaml],
target_id: Theme.targets[:translations],
value: <<~YAML,
fr:
group_of_translations:
translation2: fr test2
base_translation2: fr test4
base_translation3: fr test5
YAML
)
I18n.locale = :fr
theme.update_translation("group_of_translations.translation1", "overriddentest1")
translations = theme.translations
theme.reload
expect(translations.map(&:key)).to eq(
%w[
group_of_translations.translation1
group_of_translations.translation2
base_translation1
base_translation2
base_translation3
],
)
expect(translations.map(&:default)).to eq(
["en test1", "fr test2", "en test3", "fr test4", "fr test5"],
)
expect(translations.map(&:value)).to eq(
["overriddentest1", "fr test2", "en test3", "fr test4", "fr test5"],
)
end
it "can list internal theme_translation_manager objects" do
en_translation =
ThemeField.create!(
theme_id: theme.id,
name: "en",
type_id: ThemeField.types[:yaml],
target_id: Theme.targets[:translations],
value: <<~YAML,
en:
theme_metadata:
description: "Description of my theme"
another_translation: en test4
YAML
)
translations = theme.internal_translations
expect(translations.map(&:key)).to contain_exactly("theme_metadata.description")
expect(translations.map(&:value)).to contain_exactly("Description of my theme")
end
it "can create a hash of overridden values" do
en_translation =
ThemeField.create!(
theme_id: theme.id,
name: "en",
type_id: ThemeField.types[:yaml],
target_id: Theme.targets[:translations],
value: <<~YAML,
en:
group_of_translations:
translation1: en test1
YAML
)
theme.update_translation("group_of_translations.translation1", "overriddentest1")
I18n.locale = :fr
theme.update_translation("group_of_translations.translation1", "overriddentest2")
theme.reload
expect(theme.translation_override_hash).to eq(
"en" => {
"group_of_translations" => {
"translation1" => "overriddentest1",
},
},
"fr" => {
"group_of_translations" => {
"translation1" => "overriddentest2",
},
},
)
end
it "fall back when listing baked field" do
theme2 = Fabricate(:theme)
en_translation =
ThemeField.create!(
theme_id: theme.id,
name: "en",
type_id: ThemeField.types[:yaml],
target_id: Theme.targets[:translations],
value: "",
)
fr_translation =
ThemeField.create!(
theme_id: theme.id,
name: "fr",
type_id: ThemeField.types[:yaml],
target_id: Theme.targets[:translations],
value: "",
)
en_translation2 =
ThemeField.create!(
theme_id: theme2.id,
name: "en",
type_id: ThemeField.types[:yaml],
target_id: Theme.targets[:translations],
value: "",
)
expect(
Theme.list_baked_fields([theme.id, theme2.id], :translations, "fr").map(&:id),
).to contain_exactly(fr_translation.id, en_translation2.id)
end
end
describe "automatic recompile" do
it "must recompile after bumping theme_field version" do
child.set_field(target: :common, name: "header", value: "World")
child.set_field(target: :extra_js, name: "test.js.es6", value: "const hello = 'world';")
child.save!
first_common_value = Theme.lookup_field(child.id, :desktop, "header")
first_extra_js_value = Theme.lookup_field(child.id, :extra_js, nil)
Theme
.stubs(:compiler_version)
.returns("SOME_NEW_HASH") do
second_common_value = Theme.lookup_field(child.id, :desktop, "header")
second_extra_js_value = Theme.lookup_field(child.id, :extra_js, nil)
new_common_compiler_version =
ThemeField.find_by(theme_id: child.id, name: "header").compiler_version
new_extra_js_compiler_version =
ThemeField.find_by(theme_id: child.id, name: "test.js.es6").compiler_version
expect(first_common_value).to eq(second_common_value)
expect(first_extra_js_value).to eq(second_extra_js_value)
expect(new_common_compiler_version).to eq("SOME_NEW_HASH")
expect(new_extra_js_compiler_version).to eq("SOME_NEW_HASH")
end
end
it "recompiles when the hostname changes" do
theme.set_field(target: :settings, name: :yaml, value: "name: bob")
theme_field =
theme.set_field(
target: :common,
name: :after_header,
value:
'<script type="text/discourse-plugin" version="0.1">console.log("hello world");</script>',
)
theme.save!
expect(Theme.lookup_field(theme.id, :common, :after_header)).to include(
"_ws=#{Discourse.current_hostname}",
)
SiteSetting.force_hostname = "someotherhostname.com"
Theme.clear_cache!
expect(Theme.lookup_field(theme.id, :common, :after_header)).to include(
"_ws=someotherhostname.com",
)
end
end
describe "extra_scss" do
let(:scss) { "body { background: red}" }
let(:second_file_scss) { "p { color: blue};" }
let(:child_scss) { "body { background: green}" }
let(:theme) do
Fabricate(:theme).tap do |t|
t.set_field(target: :extra_scss, name: "my_files/magic", value: scss)
t.set_field(target: :extra_scss, name: "my_files/magic2", value: second_file_scss)
t.save!
end
end
let(:child_theme) do
Fabricate(:theme).tap do |t|
t.component = true
t.set_field(target: :extra_scss, name: "my_files/moremagic", value: child_scss)
t.save!
theme.add_relative_theme!(:child, t)
end
end
let(:compiler) do
manager = Stylesheet::Manager.new(theme_id: theme.id)
builder =
Stylesheet::Manager::Builder.new(target: :common_theme, theme: theme, manager: manager)
builder.compile(force: true)
end
it "works when importing file by path" do
theme.set_field(target: :common, name: :scss, value: '@import "my_files/magic";')
theme.save!
css, _map = compiler
expect(css).to include("body{background:red}")
end
it "works when importing multiple files" do
theme.set_field(
target: :common,
name: :scss,
value: '@import "my_files/magic"; @import "my_files/magic2"',
)
theme.save!
css, _map = compiler
expect(css).to include("body{background:red}")
expect(css).to include("p{color:blue}")
end
it "works for child themes" do
child_theme.set_field(target: :common, name: :scss, value: '@import "my_files/moremagic"')
child_theme.save!
manager = Stylesheet::Manager.new(theme_id: child_theme.id)
builder =
Stylesheet::Manager::Builder.new(
target: :common_theme,
theme: child_theme,
manager: manager,
)
css, _map = builder.compile(force: true)
expect(css).to include("body{background:green}")
end
end
describe "scss_variables" do
it "is empty by default" do
expect(theme.scss_variables).to eq(nil)
end
it "includes settings and uploads when set" do
theme.set_field(
target: :settings,
name: :yaml,
value: "background_color: red\nfont_size: 25px",
)
upload = UploadCreator.new(file_from_fixtures("logo.png"), "logo.png").create_for(-1)
theme.set_field(type: :theme_upload_var, target: :common, name: "bobby", upload_id: upload.id)
theme.save!
expect(theme.scss_variables).to include("$background_color: unquote(\"red\")")
expect(theme.scss_variables).to include("$font_size: unquote(\"25px\")")
expect(theme.scss_variables).to include("$bobby: ")
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 "includes theme's migrations theme fields" do
theme.set_field(
target: :migrations,
type: :js,
name: "0001-some-migration",
value: "export default function migrate(settings) { return settings; }",
)
theme.save!
content, _digest = theme.baked_js_tests_with_digest
expect(content).to include("function migrate(settings)")
end
end
describe "get_setting" do
before do
theme.set_field(target: :settings, name: "yaml", value: <<~YAML)
enabled:
type: bool
default: false
some_value:
type: string
default: "hello"
YAML
ThemeSetting.create!(
theme: theme,
data_type: ThemeSetting.types[:bool],
name: "super_feature_enabled",
)
theme.save!
end
it "returns the value of the setting when given a string represeting the setting name" do
expect(theme.get_setting("enabled")).to eq(false)
expect(theme.get_setting("some_value")).to eq("hello")
end
it "returns the value of the setting when given a symbol represeting the setting name" do
expect(theme.get_setting(:enabled)).to eq(false)
expect(theme.get_setting(:some_value)).to eq("hello")
end
end
describe "#update_setting" do
it "requests clients to refresh if `refresh: true`" do
theme.set_field(target: :settings, name: "yaml", value: <<~YAML)
super_feature_enabled:
type: bool
default: false
refresh: true
YAML
ThemeSetting.create!(
theme: theme,
data_type: ThemeSetting.types[:bool],
name: "super_feature_enabled",
)
theme.save!
messages =
MessageBus
.track_publish do
theme.update_setting(:super_feature_enabled, true)
theme.save!
end
.filter { |m| m.channel == "/global/asset-version" }
expect(messages.count).to eq(1)
end
it "does not request clients to refresh if `refresh: false`" do
theme.set_field(target: :settings, name: "yaml", value: <<~YAML)
super_feature_enabled:
type: bool
default: false
refresh: false
YAML
ThemeSetting.create!(
theme: theme,
data_type: ThemeSetting.types[:bool],
name: "super_feature_enabled",
)
theme.save!
messages =
MessageBus
.track_publish do
theme.update_setting(:super_feature_enabled, true)
theme.save!
end
.filter { |m| m.channel == "/global/asset-version" }
expect(messages.count).to eq(0)
end
end
describe "#migrate_settings" do
fab!(:settings_field) { Fabricate(:settings_theme_field, theme: theme, value: <<~YAML) }
integer_setting: 1
list_setting: "aa,bb"
YAML
fab!(:migration_field) { Fabricate(:migration_theme_field, theme: theme, version: 1) }
it "persists the results of the last pending migration to the database" do
migration_field.update!(value: <<~JS)
export default function migrate(settings) {
settings.set("integer_setting", 1033);
settings.set("list_setting", "cc,dd");
return settings;
}
JS
Fabricate(:migration_theme_field, theme: theme, value: <<~JS, version: 2)
export default function migrate(settings) {
settings.set("integer_setting", 9909);
settings.set("list_setting", "ee,ff");
return settings;
}
JS
theme.migrate_settings
expect(theme.get_setting("integer_setting")).to eq(9909)
expect(theme.get_setting("list_setting")).to eq("ee,ff")
end
it "doesn't allow arbitrary settings to be saved in the database" do
migration_field.update!(value: <<~JS)
export default function migrate(settings) {
settings.set("unknown_setting", 8834);
return settings;
}
JS
expect do theme.migrate_settings end.to raise_error(
Theme::SettingsMigrationError,
I18n.t(
"themes.import_error.migrations.unknown_setting_returned_by_migration",
name: "0001-some-name",
setting_name: "unknown_setting",
),
)
end
it "updates the theme's javascript cache after running migration" do
theme.set_field(target: :extra_js, name: "test.js.es6", value: "const hello = 'world';")
theme.save!
expect(theme.javascript_cache.content).to include('"list_setting": "aa,bb"')
settings_field.update!(value: <<~YAML)
integer_setting: 1
list_setting:
default: aa|bb
type: list
YAML
migration_field.update!(value: <<~JS)
export default function migrate(settings) {
settings.set("list_setting", "zz|aa");
return settings;
}
JS
theme.reload
theme.migrate_settings
setting_record = theme.theme_settings.where(name: "list_setting").first
expect(setting_record.data_type).to eq(ThemeSetting.types[:list])
expect(setting_record.value).to eq("zz|aa")
expect(theme.javascript_cache.content).to include('"list_setting": "zz|aa"')
end
it "allows changing a setting's type" do
theme.update_setting(:list_setting, "zz,aa")
theme.save!
setting_record = theme.theme_settings.where(name: "list_setting").first
expect(setting_record.data_type).to eq(ThemeSetting.types[:string])
expect(setting_record.value).to eq("zz,aa")
settings_field.update!(value: <<~YAML)
integer_setting: 1
list_setting:
default: aa|bb
type: list
YAML
migration_field.update!(value: <<~JS)
export default function migrate(settings) {
settings.set("list_setting", "zz|aa");
return settings;
}
JS
theme.reload
theme.migrate_settings
expect(theme.theme_settings.where(name: "list_setting").count).to eq(1)
setting_record = theme.theme_settings.where(name: "list_setting").first
expect(setting_record.data_type).to eq(ThemeSetting.types[:list])
expect(setting_record.value).to eq("zz|aa")
expect(
theme.theme_settings_migrations.where(theme_field_id: migration_field.id).first.diff,
).to eq(
"additions" => [{ "key" => "list_setting", "val" => "zz|aa" }],
"deletions" => [{ "key" => "list_setting", "val" => "zz,aa" }],
)
end
it "allows renaming a setting" do
theme.update_setting(:integer_setting, 11)
theme.save!
setting_record = theme.theme_settings.where(name: "integer_setting").first
expect(setting_record.value).to eq("11")
settings_field.update!(value: <<~YAML)
integer_setting_updated: 1
list_setting: "aa,bb"
YAML
migration_field.update!(value: <<~JS)
export default function migrate(settings) {
settings.set("integer_setting_updated", settings.get("integer_setting"));
return settings;
}
JS
theme.reload
theme.migrate_settings
expect(theme.theme_settings.where(name: "integer_setting").exists?).to eq(false)
setting_record = theme.theme_settings.where(name: "integer_setting_updated").first
expect(setting_record.value).to eq("11")
expect(
theme.theme_settings_migrations.where(theme_field_id: migration_field.id).first.diff,
).to eq(
"additions" => [{ "key" => "integer_setting_updated", "val" => 11 }],
"deletions" => [{ "key" => "integer_setting", "val" => 11 }],
)
end
it "creates a ThemeSettingsMigration record for each migration" do
migration_field.update!(value: <<~JS)
export default function migrate(settings) {
settings.set("integer_setting", 2);
settings.set("list_setting", "cc,dd");
return settings;
}
JS
second_migration_field =
Fabricate(:migration_theme_field, theme: theme, value: <<~JS, version: 2)
export default function migrate(settings) {
settings.set("integer_setting", 3);
settings.set("list_setting", "ee,ff");
return settings;
}
JS
third_migration_field =
Fabricate(:migration_theme_field, theme: theme, value: <<~JS, version: 3)
export default function migrate(settings) {
settings.set("integer_setting", 4);
settings.set("list_setting", "gg,hh");
return settings;
}
JS
theme.migrate_settings
records = theme.theme_settings_migrations.order(:version)
expect(records.count).to eq(3)
expect(records[0].version).to eq(1)
expect(records[0].name).to eq("some-name")
expect(records[0].theme_field_id).to eq(migration_field.id)
expect(records[0].diff).to eq(
"additions" => [
{ "key" => "integer_setting", "val" => 2 },
{ "key" => "list_setting", "val" => "cc,dd" },
],
"deletions" => [],
)
expect(records[1].version).to eq(2)
expect(records[1].name).to eq("some-name")
expect(records[1].theme_field_id).to eq(second_migration_field.id)
expect(records[1].diff).to eq(
"additions" => [
{ "key" => "integer_setting", "val" => 3 },
{ "key" => "list_setting", "val" => "ee,ff" },
],
"deletions" => [
{ "key" => "integer_setting", "val" => 2 },
{ "key" => "list_setting", "val" => "cc,dd" },
],
)
expect(records[2].version).to eq(3)
expect(records[2].name).to eq("some-name")
expect(records[2].theme_field_id).to eq(third_migration_field.id)
expect(records[2].diff).to eq(
"additions" => [
{ "key" => "integer_setting", "val" => 4 },
{ "key" => "list_setting", "val" => "gg,hh" },
],
"deletions" => [
{ "key" => "integer_setting", "val" => 3 },
{ "key" => "list_setting", "val" => "ee,ff" },
],
)
end
it "allows removing an old setting that no longer exists" do
settings_field.update!(value: <<~YAML)
setting_that_will_be_removed: 1
YAML
theme.update_setting(:setting_that_will_be_removed, 1023)
theme.save!
settings_field.update!(value: <<~YAML)
new_setting: 1
YAML
migration_field.update!(value: <<~JS)
export default function migrate(settings) {
if (settings.get("setting_that_will_be_removed") !== 1023) {
throw new Error(`expected setting_that_will_be_removed to be 1023, but it was instead ${settings.get("setting_that_will_be_removed")}.`);
}
settings.delete("setting_that_will_be_removed");
return settings;
}
JS
theme.reload
theme.migrate_settings
theme.reload
expect(theme.theme_settings.count).to eq(0)
records = theme.theme_settings_migrations
expect(records.size).to eq(1)
expect(records[0].diff).to eq(
"additions" => [],
"deletions" => [{ "key" => "setting_that_will_be_removed", "val" => 1023 }],
)
end
it "does not raise an out of sequence error and does not create `ThemeSettingsMigration` record for out of sequence migration when `allow_out_of_sequence_migration` kwarg is set to true" do
second_migration_field =
Fabricate(
:migration_theme_field,
name: "0001-some-other-migration-name",
theme: theme,
value: <<~JS,
export default function migrate(settings) {
settings.set("integer_setting", 3);
return settings;
}
JS
version: 1,
)
expect do theme.migrate_settings end.to raise_error(
Theme::SettingsMigrationError,
/'0001-some-other-migration-name' is out of sequence/,
)
expect(theme.get_setting("integer_setting")).to eq(1)
theme.migrate_settings(allow_out_of_sequence_migration: true)
expect(theme.theme_settings_migrations.count).to eq(1)
expect(theme.theme_settings_migrations.first.theme_field_id).to eq(migration_field.id)
expect(theme.get_setting("integer_setting")).to eq(3)
end
it "allows custom migration fields to be run by specifing the `fields` kwarg" do
expect do theme.migrate_settings(fields: []) end.not_to change {
theme.theme_settings_migrations.count
}
second_migration_field =
Fabricate(:migration_theme_field, theme: theme, value: <<~JS, version: 2)
export default function migrate(settings) {
settings.set("integer_setting", 3);
return settings;
}
JS
theme.migrate_settings(fields: [second_migration_field])
expect(theme.theme_settings_migrations.count).to eq(1)
expect(theme.theme_settings_migrations.first.theme_field_id).to eq(second_migration_field.id)
expect(theme.get_setting("integer_setting")).to eq(3)
end
end
describe "development experience" do
it "sends 'development-mode-theme-changed event when non-css fields are updated" do
Theme.any_instance.stubs(:should_refresh_development_clients?).returns(true)
theme.set_field(target: :common, name: :scss, value: "body {background: green;}")
messages =
MessageBus
.track_publish { theme.save! }
.filter { |m| m.channel == "/file-change" }
.map(&:data)
expect(messages).not_to include("development-mode-theme-changed")
theme.set_field(target: :common, name: :header, value: "<p>Hello world</p>")
messages =
MessageBus
.track_publish { theme.save! }
.filter { |m| m.channel == "/file-change" }
.map(&:data)
expect(messages).to include(["development-mode-theme-changed"])
end
end
describe "#lookup_field when a theme component is used in multiple themes" do
fab!(:theme_1) { Fabricate(:theme, user: user) }
fab!(:theme_2) { Fabricate(:theme, user: user) }
fab!(:child) { Fabricate(:theme, user: user, component: true) }
before_all do
theme_1.add_relative_theme!(:child, child)
theme_2.add_relative_theme!(:child, child)
end
it "efficiently caches fields of theme component by only caching the fields once across multiple themes" do
child.set_field(target: :common, name: "header", value: "World")
child.save!
expect(Theme.lookup_field(theme_1.id, :desktop, "header")).to eq("World")
expect(Theme.lookup_field(theme_2.id, :desktop, "header")).to eq("World")
expect(
Theme.cache.defer_get_set("#{child.id}:common:header:#{Theme.compiler_version}") { raise },
).to eq(["World"])
expect(
Theme.cache.defer_get_set("#{child.id}:desktop:header:#{Theme.compiler_version}") { raise },
).to eq(nil)
expect(
Theme
.cache
.defer_get_set("#{theme_1.id}:common:header:#{Theme.compiler_version}") { raise },
).to eq(nil)
expect(
Theme
.cache
.defer_get_set("#{theme_1.id}:desktop:header:#{Theme.compiler_version}") { raise },
).to eq(nil)
expect(
Theme
.cache
.defer_get_set("#{theme_2.id}:common:header:#{Theme.compiler_version}") { raise },
).to eq(nil)
expect(
Theme
.cache
.defer_get_set("#{theme_2.id}:desktop:header:#{Theme.compiler_version}") { raise },
).to eq(nil)
end
it "puts the parent value ahead of the child" do
theme_1.set_field(target: :common, name: "header", value: "theme_1")
theme_1.save!
child.set_field(target: :common, name: "header", value: "child")
child.save!
expect(Theme.lookup_field(theme_1.id, :desktop, "header")).to eq("theme_1\nchild")
end
it "puts parent translations ahead of child translations" do
theme_1.set_field(target: :translations, name: "en", value: <<~YAML)
en:
theme_1: "test"
YAML
theme_1.save!
theme_field = ThemeField.order(:id).last
child.set_field(target: :translations, name: "en", value: <<~YAML)
en:
child: "test"
YAML
child.save!
child_field = ThemeField.order(:id).last
expect(theme_field.value_baked).not_to eq(child_field.value_baked)
expect(Theme.lookup_field(theme_1.id, :translations, :en)).to eq(
[theme_field, child_field].map(&:value_baked).join("\n"),
)
end
it "prioritizes a locale over its fallback" do
theme_1.set_field(target: :translations, name: "en", value: <<~YAML)
en:
theme_1: "hello"
YAML
theme_1.save!
en_field = ThemeField.order(:id).last
theme_1.set_field(target: :translations, name: "es", value: <<~YAML)
es:
theme_1: "hola"
YAML
theme_1.save!
es_field = ThemeField.order(:id).last
expect(es_field.value_baked).not_to eq(en_field.value_baked)
expect(Theme.lookup_field(theme_1.id, :translations, :en)).to eq(en_field.value_baked)
expect(Theme.lookup_field(theme_1.id, :translations, :es)).to eq(es_field.value_baked)
expect(Theme.lookup_field(theme_1.id, :translations, :fr)).to eq(en_field.value_baked)
end
end
describe "#repository_url" do
subject(:repository_url) { theme.repository_url }
context "when theme is not a remote one" do
it "returns nothing" do
expect(repository_url).to be_blank
end
end
context "when theme is a remote one" do
let!(:remote_theme) { theme.create_remote_theme(remote_url: remote_url) }
context "when URL is a SSH one" do
let(:remote_url) { "git@github.com:discourse/graceful.git" }
it "normalizes it" do
expect(repository_url).to eq "github.com/discourse/graceful"
end
end
context "when URL is a HTTPS one" do
let(:remote_url) { "https://github.com/discourse/graceful.git" }
it "normalizes it" do
expect(repository_url).to eq "github.com/discourse/graceful"
end
end
context "when URL is a HTTP one" do
let(:remote_url) { "http://github.com/discourse/graceful" }
it "normalizes it" do
expect(repository_url).to eq "github.com/discourse/graceful"
end
end
context "when URL contains query params" do
let(:remote_url) { "http://github.com/discourse/graceful.git?param_id=1" }
it "keeps the query params" do
expect(repository_url).to eq "github.com/discourse/graceful?param_id=1"
end
end
end
end
describe "#user_selectable_count" do
subject(:count) { theme.user_selectable_count }
let!(:users) { Fabricate.times(5, :user) }
let!(:another_theme) { Fabricate(:theme) }
before do
users.take(3).each { _1.user_option.update!(theme_ids: [theme.id]) }
users.slice(3..4).each { _1.user_option.update!(theme_ids: [another_theme.id]) }
end
it "returns how many users are currently using the theme" do
expect(count).to eq 3
end
end
describe "#owned_color_scheme" do
it "is destroyed when the theme is destroyed" do
scheme = Fabricate(:color_scheme, owning_theme: theme)
theme.destroy!
expect(ThemeColorScheme.exists?(color_scheme_id: scheme.id)).to eq(false)
expect(ColorScheme.unscoped.exists?(id: scheme.id)).to eq(false)
end
end
describe ".include_basic_relations" do
fab!(:parent_theme_1) do
Fabricate(
:theme,
theme_fields: [
ThemeField.new(
name: "en",
type_id: ThemeField.types[:yaml],
target_id: Theme.targets[:translations],
value: <<~YAML,
en:
theme_metadata:
description: "Description of my theme"
YAML
),
],
)
end
fab!(:parent_theme_2) do
Fabricate(
:theme,
theme_fields: [
ThemeField.new(
name: "en",
type_id: ThemeField.types[:yaml],
target_id: Theme.targets[:translations],
value: <<~YAML,
en:
theme_metadata:
description: "Description of my theme 2"
YAML
),
],
)
end
fab!(:component_1) do
Fabricate(
:theme,
component: true,
parent_themes: [parent_theme_1],
theme_fields: [
ThemeField.new(
name: "en",
type_id: ThemeField.types[:yaml],
target_id: Theme.targets[:translations],
value: <<~YAML,
en:
theme_metadata:
description: "Description of my component"
YAML
),
],
)
end
fab!(:component_2) do
Fabricate(
:theme,
component: true,
parent_themes: [parent_theme_2],
theme_fields: [
ThemeField.new(
name: "en",
type_id: ThemeField.types[:yaml],
target_id: Theme.targets[:translations],
value: <<~YAML,
en:
theme_metadata:
description: "Description of my component 2"
YAML
),
],
)
end
it "doesn't result in N+1 queries for descriptions" do
components = Theme.include_basic_relations.where(component: true, id: component_1.id)
queries_for_one =
track_sql_queries do
components.each do |component|
ComponentIndexSerializer.new(component, root: false).as_json
end
end
components =
Theme.include_basic_relations.where(component: true, id: [component_1.id, component_2.id])
queries_for_two =
track_sql_queries do
components.each do |component|
ComponentIndexSerializer.new(component, root: false).as_json
end
end
expect(queries_for_two.size).to eq(queries_for_one.size)
end
end
describe "#screenshot_url" do
it "returns nil when no screenshot is set" do
expect(theme.screenshot_url).to be_nil
end
it "returns the upload URL when screenshot is set" do
upload = UploadCreator.new(file_from_fixtures("logo.png"), "logo.png").create_for(-1)
theme.set_field(
target: :common,
name: "screenshot",
upload_id: upload.id,
type: :theme_screenshot_upload_var,
)
theme.save!
expect(theme.screenshot_url).to eq(upload.url)
end
end
describe "#find_or_create_owned_color_palette" do
it "correctly associates a theme with its owned color palette" do
palette = theme.find_or_create_owned_color_palette
expect(palette.owning_theme).to eq(theme)
expect(theme.reload.owned_color_palette).to eq(palette)
end
it "ensures owned color palette is not user selectable" do
palette = theme.find_or_create_owned_color_palette
expect(palette.user_selectable).to eq(false)
end
it "copies colors from base or theme color scheme" do
theme_without_scheme = Fabricate(:theme, color_scheme: nil)
base_palette = theme_without_scheme.find_or_create_owned_color_palette
expect(base_palette.colors.length).to be > 0
expect(base_palette.colors.map(&:name).sort).to eq(ColorScheme.base.colors.map(&:name).sort)
custom_palette =
Fabricate(
:color_scheme,
colors: [ColorSchemeColor.new(name: "custom", hex: "11ccff", dark_hex: "ee9955")],
)
theme_with_scheme = Fabricate(:theme, color_scheme: custom_palette)
custom_palette = theme_with_scheme.find_or_create_owned_color_palette
expect(custom_palette.colors.length).to be > 0
expect(custom_palette.colors.map(&:name).sort).to eq(custom_palette.colors.map(&:name).sort)
end
it "returns the existing palette if a race condition occurs and a theme-owned palette is created while it's executing" do
expect(theme.owned_color_palette).to eq(nil)
palette = Fabricate(:color_scheme)
ThemeColorScheme.create!(theme_id: theme.id, color_scheme_id: palette.id)
expect(theme.owned_color_palette).to eq(nil)
expect(theme.find_or_create_owned_color_palette.id).to eq(palette.id)
expect(theme.owned_color_palette).to eq(palette)
end
end
it "checks if fields can be updated for system themes" do
foundation_theme.update!(user_selectable: true)
expect(foundation_theme.user_selectable).to be true
expect { foundation_theme.update!(name: "edited system name") }.to raise_error(
Discourse::InvalidParameters,
)
expect { theme.update!(name: "edited name") }.not_to raise_error
end
it "does not allow system themes to be deleted" do
expect { foundation_theme.destroy! }.to raise_error(Discourse::InvalidParameters)
expect { theme.destroy! }.not_to raise_error
end
describe "#system?" do
it "returns system true for Horizon and Foundation themes" do
expect(foundation_theme.system?).to be true
expect(theme.system?).to be false
end
end
end