discourse/spec/support/helpers.rb
Martin Brennan d4ac43e605
FEATURE: Upcoming changes part 1 (#34617)
This PR introduces part one of the "Upcoming changes" interface for
Discourse admins.

The upcoming changes feature is an enhancement around our existing
site-setting based feature flagging and experiments system. With some
light metadata, we can give admins a much better overview of the current
work we are doing, with ways for them to opt-out in early stages and
opt-in to things that we haven’t yet turned on by default for them.

This system, along with encouraging a more liberal use of site setting
flags for features, experiments, and refactors in the app, should
minimise the problem of breakages and disruptions for all Discourse
users. It is also our intent with this system for it to be easier for
designers to add and remove these changes.

Finally, it also gives us a kind of running changelog that we can use to
communicate with site owners before releases and “What’s new?” updates.

### FOR REVIEWERS

This initial PR is gated behind a hidden `enable_upcoming_changes` site
setting, because there is still more work to do before we reveal this to
admins.

To test the UI, you can add this metadata under any boolean-based site
setting, though upcoming change settings will specifically be hidden:

```
upcoming_change:
   status: "alpha" (see UpcomingChanges.statuses.keys)
   impact: "feature,staff" (feature|other for the first part, staff|admins|moderators|all_members|developers for the second part)
   learn_more_url: "https://some.url"
```

To test the images, add an image under `public/images/upcoming_changes`
with the file name as `SITE_SETTING_NAME.png`

### Interface

Admins can see the following in the interface for upcoming changes:

* The status of the change. Changes can progress along these statuses:
   * Pre-Alpha
   * Alpha
   * Beta
   * Stable
   * Permanent
* The impact of the change. This is split into Type and Role. Type can
be "Feature" or "Other" for now. Changes may affect the following roles:
   * Admins
   * Moderators
   * Staff
   * All members
* The plugin that is making the change
* The groups that are opted-in to the change. Admins can control these
groups for a gradual rollout. If a change is enabled, it is limited to
these groups.
* In some cases, an image related to the change, behind the "Preview"
link
* A link to learn more about the change

Admins can filter the changes by name, description, plugin, status,
impact type, and whether the change is enabled.

### Promotion system

For our hosted customers, we intend to have a status-based
auto-promotion system as changes progress.

For all sites, once a change reaches the Stable status, if an admin
opts-out of that change it will generate an admin problem message that
will be shown on the dashboard.

For self-hosted Discourse admins, changes will only be forcibly enabled
when they reach the Permanent state.

### Notification system

A notification system for upcoming changes so admins can stay informed
will be added in a followup PR.

---------

Co-authored-by: awesomerobot <kris.aubuchon@discourse.org>
2025-10-30 10:46:14 +10:00

360 lines
11 KiB
Ruby

# frozen_string_literal: true
GIT_INITIAL_BRANCH_SUPPORTED =
Gem::Version.new(`git --version`.match(/[\d\.]+/)[0]) >= Gem::Version.new("2.28.0")
module Helpers
extend ActiveSupport::Concern
class NotAThemeError < StandardError
end
class NotAComponentThemeError < StandardError
end
def self.next_seq
@next_seq = (@next_seq || 0) + 1
end
def log_in(fabricator = nil)
user = Fabricate(fabricator || :user)
log_in_user(user)
user
end
def log_in_user(user)
cookie_jar = ActionDispatch::Request.new(request.env).cookie_jar
provider = Discourse.current_user_provider.new(request.env)
provider.log_on_user(user, session, cookie_jar)
provider
end
def log_out_user(provider)
provider.log_off_user(session, cookies)
end
def fixture_file(filename)
return "" if filename.blank?
file_path = File.expand_path(Rails.root + "spec/fixtures/" + filename)
File.read(file_path)
end
def build(*args)
Fabricate.build(*args)
end
def create_topic(args = {})
args[:title] ||= "This is my title #{Helpers.next_seq}"
user = args.delete(:user)
user = Fabricate(:user, refresh_auto_groups: true) if !user
guardian = Guardian.new(user)
args[:category] = args[:category].id if args[:category].is_a?(Category)
TopicCreator.create(user, guardian, args)
end
def create_post(args = {})
# Pretty much all the tests with `create_post` will fail without this
# since allow_uncategorized_topics is now false by default
SiteSetting.allow_uncategorized_topics = true unless args[:allow_uncategorized_topics] == false
args[:title] ||= "This is my title #{Helpers.next_seq}"
args[:raw] ||= "This is the raw body of my post, it is cool #{Helpers.next_seq}"
args[:topic_id] = args[:topic].id if args[:topic]
user = args.delete(:user) || Fabricate(:user, refresh_auto_groups: true)
args[:category] = args[:category].id if args[:category].is_a?(Category)
creator = PostCreator.new(user, args)
post = creator.create
raise StandardError.new(creator.errors.full_messages.join(" ")) if creator.errors.present?
post
end
def stub_guardian(user)
guardian = Guardian.new(user)
yield(guardian) if block_given?
Guardian.stubs(new: guardian).with(user, anything)
end
def wait_for(on_fail: nil, timeout: 1, &blk)
i = 0
result = false
while !result && i < timeout * 1000
result = blk.call
i += 1
sleep 0.001
end
on_fail&.call
expect(result).to eq(true)
end
def email(email_name)
fixture_file("emails/#{email_name}.eml")
end
def create_staff_only_tags(tag_names)
create_limited_tags("Staff Tags", Group::AUTO_GROUPS[:staff], tag_names)
end
def create_admin_only_tags(tag_names)
create_limited_tags("Admin Tags", Group::AUTO_GROUPS[:admins], tag_names)
end
def create_limited_tags(tag_group_name, group_id, tag_names)
tag_group = Fabricate(:tag_group, name: tag_group_name)
TagGroupPermission.where(
tag_group: tag_group,
group_id: Group::AUTO_GROUPS[:everyone],
permission_type: TagGroupPermission.permission_types[:full],
).update(permission_type: TagGroupPermission.permission_types[:readonly])
TagGroupPermission.create!(
tag_group: tag_group,
group_id: group_id,
permission_type: TagGroupPermission.permission_types[:full],
)
tag_names.each do |name|
tag_group.tags << (Tag.where(name: name).first || Fabricate(:tag, name: name))
end
end
def create_hidden_tags(tag_names)
tag_group = Fabricate(:tag_group, name: "Hidden Tags", permissions: { staff: :full })
tag_names.each do |name|
tag_group.tags << (Tag.where(name: name).first || Fabricate(:tag, name: name))
end
end
def sorted_tag_names(tag_records)
tag_records.map { |t| t.is_a?(String) ? t : t.name }.sort
end
def expect_same_tag_names(a, b)
expect(sorted_tag_names(a)).to eq(sorted_tag_names(b))
end
def capture_output(output_name)
if ENV["RAILS_ENABLE_TEST_STDOUT"]
yield
return
end
previous_output = output_name == :stdout ? $stdout : $stderr
io = StringIO.new
output_name == :stdout ? $stdout = io : $stderr = io
yield
io.string
ensure
output_name == :stdout ? $stdout = previous_output : $stderr = previous_output
end
def capture_stdout(&block)
capture_output(:stdout, &block)
end
def capture_stderr(&block)
capture_output(:stderr, &block)
end
def set_subfolder(new_root)
global_setting :relative_url_root, new_root
old_root = ActionController::Base.config.relative_url_root
ActionController::Base.config.relative_url_root = new_root
Rails.application.routes.stubs(:relative_url_root).returns(new_root)
before_next_spec { ActionController::Base.config.relative_url_root = old_root }
if RSpec.current_example.metadata[:type] == :system
Capybara.app.map("/") { run lambda { |env| [404, {}, [""]] } }
Capybara.app.map(new_root) { run Rails.application }
before_next_spec do
Capybara.app.map(new_root) { run lambda { |env| [404, {}, [""]] } }
Capybara.app.map("/") { run Rails.application }
end
end
end
def setup_git_repo(files)
repo_dir = Dir.mktmpdir
`cd #{repo_dir} && git init . #{"--initial-branch=main" if GIT_INITIAL_BRANCH_SUPPORTED}`
`cd #{repo_dir} && git config user.email 'someone@cool.com'`
`cd #{repo_dir} && git config user.name 'The Cool One'`
`cd #{repo_dir} && git config commit.gpgsign 'false'`
files.each do |name, data|
FileUtils.mkdir_p(Pathname.new("#{repo_dir}/#{name}").dirname)
File.write("#{repo_dir}/#{name}", data)
`cd #{repo_dir} && git add #{name}`
end
`cd #{repo_dir} && git commit -am 'first commit'`
repo_dir
end
def add_to_git_repo(repo_dir, files)
files.each do |name, data|
FileUtils.mkdir_p(Pathname.new("#{repo_dir}/#{name}").dirname)
File.write("#{repo_dir}/#{name}", data)
`cd #{repo_dir} && git add #{name}`
end
`cd #{repo_dir} && git commit -am 'add #{files.size} files'`
repo_dir
end
def stub_const(target, const, value)
old = target.const_get(const)
target.send(:remove_const, const)
target.const_set(const, value)
yield
ensure
target.send(:remove_const, const)
target.const_set(const, old)
end
def track_sql_queries
queries = []
callback = ->(*, payload) do
queries << payload.fetch(:sql) if %w[CACHE SCHEMA].exclude?(payload.fetch(:name))
end
ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
ActiveSupport::Notifications.subscribed(callback, "sql.mini_sql") { yield }
end
queries
end
def stub_ip_lookup(stub_addr, ips)
Addrinfo
.stubs(:getaddrinfo)
.with { |addr, _| addr == stub_addr }
.returns(
ips.map { |ip| Addrinfo.new([IPAddr.new(ip).ipv6? ? "AF_INET6" : "AF_INET", 80, nil, ip]) },
)
end
def with_search_indexer_enabled
SearchIndexer.enable
yield
ensure
SearchIndexer.disable
end
# Uploads a theme from a directory.
#
# @param set_theme_as_default [Boolean] Whether to set the uploaded theme as the default theme for the site. Defaults to true.
#
# @return [Theme] The uploaded theme model given by `models/theme.rb`.
#
# @example Upload a theme and set it as default
# upload_theme("/path/to/theme")
def upload_theme(set_theme_as_default: true)
theme = RemoteTheme.import_theme_from_directory(directory_from_caller)
if theme.component
raise NotAThemeError,
"Uploaded theme is a theme component, please use the `upload_theme_component` method instead."
end
theme.set_default! if set_theme_as_default
theme
end
# Invokes a Rake task in a way that is safe for the test environment
def invoke_rake_task(task_name, *args)
Rake::Task[task_name].invoke(*args)
ensure
Rake::Task[task_name].reenable
end
# Uploads a theme component from a directory.
#
# @param parent_theme_id [Integer] The ID of the theme to add the theme component to. Defaults to `SiteSetting.default_theme_id`.
#
# @return [Theme] The uploaded theme model given by `models/theme.rb`.
#
# @example Upload a theme component
# upload_theme_component("/path/to/theme_component")
#
# @example Upload a theme component and add it to a specific theme
# upload_theme_component("/path/to/theme_component", parent_theme_id: 123)
def upload_theme_component(parent_theme_id: SiteSetting.default_theme_id)
theme = RemoteTheme.import_theme_from_directory(directory_from_caller)
if !theme.component
raise NotAComponentThemeError,
"Uploaded theme is not a theme component, please use the `upload_theme` method instead."
end
Theme.find(parent_theme_id).child_themes << theme
theme
end
def upload_theme_or_component
upload_theme
rescue NotAThemeError
upload_theme_component
end
# Runs named migration for a given theme.
#
# @params [Theme] theme The theme to run the migration for.
# @params [String] migration_name The name of the migration to run.
#
# @return [nil]
#
# @example
# run_theme_migration(theme, "0001-migrate-some-settings")
def run_theme_migration(theme, migration_name)
migration_theme_field = theme.theme_fields.find_by(name: migration_name)
theme.migrate_settings(fields: [migration_theme_field], allow_out_of_sequence_migration: true)
nil
end
def enable_current_plugin
plugin = Discourse.plugins_by_name[directory_from_caller.split("/").last]
return if plugin.enabled?
SiteSetting.public_send("#{plugin.enabled_site_setting}=", true)
end
def try_until_success(timeout: 3, frequency: 0.01)
start ||= Time.zone.now
backoff ||= frequency
yield
rescue RSpec::Expectations::ExpectationNotMetError
raise if Time.zone.now >= start + timeout.seconds
sleep backoff
backoff += frequency
retry
end
def mock_upcoming_change_metadata(metadata)
@original_upcoming_changes_metadata = SiteSetting.upcoming_change_metadata.dup
# We do this because upcoming changes are ephemeral in site settings,
# so we cannot rely on them for specs. Instead we can fake some metadata
# for an existing stable setting.
SiteSetting.instance_variable_set(
:@upcoming_change_metadata,
@original_upcoming_changes_metadata.merge(metadata),
)
end
def clear_mocked_upcoming_change_metadata
SiteSetting.instance_variable_set(
:@upcoming_change_metadata,
@original_upcoming_changes_metadata,
)
end
private
def directory_from_caller
caller.each do |line|
if (split = line.split(%r{/spec/*/.+_spec.rb})).length > 1
return split.first
end
end
end
end