discourse/app/models/remote_theme.rb
Alan Guo Xiang Tan e0ef88abca
DEV: Run QUnit tests for official Discourse themes (#24405)
Why this change?

As the number of themes which the Discourse team supports officially
grows, we want to ensure that changes made to Discourse core do not
break the plugins. As such, we are adding a step to our Github actions
test job to run the QUnit tests for all official themes.

What does this change do?

This change adds a new job to our tests Github actions workflow to run the QUnit
tests for all official plugins. This is achieved with the following
changes:

1. Update `testem.js` to rely on the `THEME_TEST_PAGES` env variable to set the
   `test_page` option when running theme QUnit tests with testem. The
   `test_page` option [allows an array to be specified](https://github.com/testem/testem#multiple-test-pages) such that tests for
   multiple pages can be run at the same time. We are relying on a ENV variable
   because  the `testem` CLI does not support passing a list of pages
   to the `--test_page` option.

2. Support a `/testem-theme-qunit/:testem_id/theme-qunit` Rails route in the development environment. This
   is done because testem prefixes the path with a unique ID to the configured `test_page` URL.
   This is problematic for us because we proxy all testem requests to the
   Rails server and testem's proxy configuration option does not allow us
   to easily rewrite the URL to remove the prefix. Therefore, we configure a proxy in testem to prefix `theme-qunit` requests with
  `/testem-theme-qunit` which can then be easily identified by the Rails server and routed accordingly. 

3. Update `qunit:test` to support a `THEME_IDS` environment variable
   which will allow it to run QUnit tests for multiple themes at the
   same time.

4. Support `bin/rake themes:qunit[ids,"<theme_id>|<theme_id>"]` to run
   the QUnit tests for multiple themes at the same time.

5. Adds a `themes:qunit_all_official` Rake task which runs the QUnit
   tests for all the official themes.
2023-11-17 07:17:32 +08:00

476 lines
14 KiB
Ruby

# frozen_string_literal: true
class RemoteTheme < ActiveRecord::Base
METADATA_PROPERTIES = %i[
license_url
about_url
authors
theme_version
minimum_discourse_version
maximum_discourse_version
]
class ImportError < StandardError
end
ALLOWED_FIELDS = %w[
scss
embedded_scss
embedded_header
head_tag
header
after_header
body_tag
footer
]
GITHUB_REGEXP = %r{\Ahttps?://github\.com/}
GITHUB_SSH_REGEXP = %r{\Assh://git@github\.com:}
MAX_METADATA_FILE_SIZE = Discourse::MAX_METADATA_FILE_SIZE
MAX_ASSET_FILE_SIZE = 8.megabytes
MAX_THEME_FILE_COUNT = 1024
MAX_THEME_SIZE = 256.megabytes
has_one :theme, autosave: false
scope :joined_remotes,
-> {
joins("JOIN themes ON themes.remote_theme_id = remote_themes.id").where.not(
remote_url: "",
)
}
validates_format_of :minimum_discourse_version,
:maximum_discourse_version,
with: Discourse::VERSION_REGEXP,
allow_nil: true
def self.extract_theme_info(importer)
if importer.file_size("about.json") > MAX_METADATA_FILE_SIZE
raise ImportError.new I18n.t(
"themes.import_error.about_json_too_big",
limit:
ActiveSupport::NumberHelper.number_to_human_size(
MAX_METADATA_FILE_SIZE,
),
)
end
begin
json = JSON.parse(importer["about.json"])
json.fetch("name")
json
rescue TypeError, JSON::ParserError, KeyError
raise ImportError.new I18n.t("themes.import_error.about_json")
end
end
def self.update_zipped_theme(
filename,
original_filename,
user: Discourse.system_user,
theme_id: nil,
update_components: nil
)
update_theme(
ThemeStore::ZipImporter.new(filename, original_filename),
user:,
theme_id:,
update_components:,
)
end
# This is only used in the development and test environment and is currently not supported for other environments
if Rails.env.test? || Rails.env.development?
def self.import_theme_from_directory(directory)
update_theme(ThemeStore::DirectoryImporter.new(directory))
end
end
def self.update_theme(
importer,
user: Discourse.system_user,
theme_id: nil,
update_components: nil
)
importer.import!
theme_info = RemoteTheme.extract_theme_info(importer)
theme = Theme.find_by(id: theme_id) if theme_id # New theme CLI method
existing = true
if theme.blank?
theme = Theme.new(user_id: user&.id || -1, name: theme_info["name"], auto_update: false)
existing = false
end
theme.component = theme_info["component"].to_s == "true"
theme.child_components = child_components = theme_info["components"].presence || []
remote_theme = new
remote_theme.theme = theme
remote_theme.remote_url = ""
do_update_child_components = false
theme.transaction do
remote_theme.update_from_remote(importer, skip_update: true, already_in_transaction: true)
if existing && update_components.present? && update_components != "none"
child_components = child_components.map { |url| ThemeStore::GitImporter.new(url.strip).url }
if update_components == "sync"
ChildTheme
.joins(child_theme: :remote_theme)
.where("remote_themes.remote_url NOT IN (?)", child_components)
.delete_all
end
child_components -=
theme
.child_themes
.joins(:remote_theme)
.where("remote_themes.remote_url IN (?)", child_components)
.pluck("remote_themes.remote_url")
theme.child_components = child_components
do_update_child_components = true
end
end
theme.update_child_components if do_update_child_components
theme
ensure
begin
importer.cleanup!
rescue => e
Rails.logger.warn("Failed cleanup remote path #{e}")
end
end
private_class_method :update_theme
def self.import_theme(url, user = Discourse.system_user, private_key: nil, branch: nil)
importer = ThemeStore::GitImporter.new(url.strip, private_key: private_key, branch: branch)
importer.import!
theme_info = RemoteTheme.extract_theme_info(importer)
component = [true, "true"].include?(theme_info["component"])
theme = Theme.new(user_id: user&.id || -1, name: theme_info["name"], component: component)
theme.child_components = theme_info["components"].presence || []
remote_theme = new
theme.remote_theme = remote_theme
remote_theme.private_key = private_key
remote_theme.branch = branch
remote_theme.remote_url = importer.url
remote_theme.update_from_remote(importer)
theme
ensure
begin
importer.cleanup!
rescue => e
Rails.logger.warn("Failed cleanup remote git #{e}")
end
end
def self.out_of_date_themes
self
.joined_remotes
.where("commits_behind > 0 OR remote_version <> local_version")
.where(themes: { enabled: true })
.pluck("themes.name", "themes.id")
end
def self.unreachable_themes
self.joined_remotes.where("last_error_text IS NOT NULL").pluck("themes.name", "themes.id")
end
def out_of_date?
commits_behind > 0 || remote_version != local_version
end
def update_remote_version
return unless is_git?
importer = ThemeStore::GitImporter.new(remote_url, private_key: private_key, branch: branch)
begin
importer.import!
rescue RemoteTheme::ImportError => err
self.last_error_text = err.message
else
self.updated_at = Time.zone.now
self.remote_version, self.commits_behind = importer.commits_since(local_version)
self.last_error_text = nil
ensure
self.save!
begin
importer.cleanup!
rescue => e
Rails.logger.warn("Failed cleanup remote git #{e}")
end
end
end
def update_from_remote(
importer = nil,
skip_update: false,
raise_if_theme_save_fails: true,
already_in_transaction: false
)
cleanup = false
unless importer
cleanup = true
importer = ThemeStore::GitImporter.new(remote_url, private_key: private_key, branch: branch)
begin
importer.import!
rescue RemoteTheme::ImportError => err
self.last_error_text = err.message
self.save!
return self
else
self.last_error_text = nil
end
end
theme_info = RemoteTheme.extract_theme_info(importer)
updated_fields = []
theme_info["assets"]&.each do |name, relative_path|
if path = importer.real_path(relative_path)
new_path = "#{File.dirname(path)}/#{SecureRandom.hex}#{File.extname(path)}"
File.rename(path, new_path) # OptimizedImage has strict file name restrictions, so rename temporarily
upload =
UploadCreator.new(
File.open(new_path),
File.basename(relative_path),
for_theme: true,
).create_for(theme.user_id)
if !upload.errors.empty?
raise ImportError,
I18n.t(
"themes.import_error.upload",
name: name,
errors: upload.errors.full_messages.join(","),
)
end
updated_fields << theme.set_field(
target: :common,
name: name,
type: :theme_upload_var,
upload_id: upload.id,
)
end
end
# Update all theme attributes if this is just a placeholder
if self.remote_url.present? && !self.local_version && !self.commits_behind
self.theme.name = theme_info["name"]
self.theme.component = [true, "true"].include?(theme_info["component"])
self.theme.child_components = theme_info["components"].presence || []
end
METADATA_PROPERTIES.each do |property|
self.public_send(:"#{property}=", theme_info[property.to_s])
end
if !self.valid?
raise ImportError,
I18n.t(
"themes.import_error.about_json_values",
errors: self.errors.full_messages.join(","),
)
end
ThemeModifierSet.modifiers.keys.each do |modifier_name|
theme.theme_modifier_set.public_send(
:"#{modifier_name}=",
theme_info.dig("modifiers", modifier_name.to_s),
)
end
if !theme.theme_modifier_set.valid?
raise ImportError,
I18n.t(
"themes.import_error.modifier_values",
errors: theme.theme_modifier_set.errors.full_messages.join(","),
)
end
all_files = importer.all_files
if all_files.size > MAX_THEME_FILE_COUNT
raise ImportError,
I18n.t(
"themes.import_error.too_many_files",
count: all_files.size,
limit: MAX_THEME_FILE_COUNT,
)
end
theme_size = 0
all_files.each do |filename|
next unless opts = ThemeField.opts_from_file_path(filename)
file_size = importer.file_size(filename)
if file_size > MAX_ASSET_FILE_SIZE
raise ImportError,
I18n.t(
"themes.import_error.asset_too_big",
filename: filename,
limit: ActiveSupport::NumberHelper.number_to_human_size(MAX_ASSET_FILE_SIZE),
)
end
theme_size += file_size
if theme_size > MAX_THEME_SIZE
raise ImportError,
I18n.t(
"themes.import_error.theme_too_big",
limit: ActiveSupport::NumberHelper.number_to_human_size(MAX_THEME_SIZE),
)
end
value = importer[filename]
updated_fields << theme.set_field(**opts.merge(value: value))
end
if !skip_update
self.remote_updated_at = Time.zone.now
self.remote_version = importer.version
self.local_version = importer.version
self.commits_behind = 0
end
transaction_block = -> do
# Destroy fields that no longer exist in the remote theme
field_ids_to_destroy = theme.theme_fields.pluck(:id) - updated_fields.map { |tf| tf&.id }
ThemeField.where(id: field_ids_to_destroy).destroy_all
update_theme_color_schemes(theme, theme_info["color_schemes"]) unless theme.component
self.save!
if raise_if_theme_save_fails
theme.save!
else
raise ActiveRecord::Rollback if !theme.save
end
theme.migrate_settings(start_transaction: false)
end
if already_in_transaction
transaction_block.call
else
self.transaction(&transaction_block)
end
self
ensure
begin
importer.cleanup! if cleanup
rescue => e
Rails.logger.warn("Failed cleanup remote git #{e}")
end
end
def normalize_override(hex)
return unless hex
override = hex.downcase
override = nil if override !~ /\A[0-9a-f]{6}\z/
override
end
def update_theme_color_schemes(theme, schemes)
missing_scheme_names = Hash[*theme.color_schemes.pluck(:name, :id).flatten]
ordered_schemes = []
schemes&.each do |name, colors|
missing_scheme_names.delete(name)
scheme = theme.color_schemes.find_by(name: name) || theme.color_schemes.build(name: name)
# Update main colors
ColorScheme.base.colors_hashes.each do |color|
override = normalize_override(colors[color[:name]])
color_scheme_color =
scheme.color_scheme_colors.to_a.find { |c| c.name == color[:name] } ||
scheme.color_scheme_colors.build(name: color[:name])
color_scheme_color.hex = override || color[:hex]
theme.notify_color_change(color_scheme_color) if color_scheme_color.hex_changed?
end
# Update advanced colors
ColorScheme.color_transformation_variables.each do |variable_name|
override = normalize_override(colors[variable_name])
color_scheme_color = scheme.color_scheme_colors.to_a.find { |c| c.name == variable_name }
if override
color_scheme_color ||= scheme.color_scheme_colors.build(name: variable_name)
color_scheme_color.hex = override
theme.notify_color_change(color_scheme_color) if color_scheme_color.hex_changed?
elsif color_scheme_color # No longer specified in about.json, delete record
scheme.color_scheme_colors.delete(color_scheme_color)
theme.notify_color_change(nil, scheme: scheme)
end
end
ordered_schemes << scheme
end
if missing_scheme_names.length > 0
ColorScheme.where(id: missing_scheme_names.values).delete_all
# we may have stuff pointed at the incorrect scheme?
end
theme.color_scheme = ordered_schemes.first if theme.new_record?
end
def github_diff_link
if github_repo_url.present? && local_version != remote_version
"#{github_repo_url.gsub(/\.git\z/, "")}/compare/#{local_version}...#{remote_version}"
end
end
def github_repo_url
url = remote_url.strip
return url if url.match?(GITHUB_REGEXP)
if url.match?(GITHUB_SSH_REGEXP)
org_repo = url.gsub(GITHUB_SSH_REGEXP, "")
"https://github.com/#{org_repo}"
end
end
def is_git?
remote_url.present?
end
end
# == Schema Information
#
# Table name: remote_themes
#
# id :integer not null, primary key
# remote_url :string not null
# remote_version :string
# local_version :string
# about_url :string
# license_url :string
# commits_behind :integer
# remote_updated_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
# private_key :text
# branch :string
# last_error_text :text
# authors :string
# theme_version :string
# minimum_discourse_version :string
# maximum_discourse_version :string
#