discourse/bin/qunit
Loïc Guitaut 6b243ffdfd
DEV: Remove Unicorn web server in favor of Pitchfork (#39032)
Pitchfork has been the default web server for some time now. This
removes Unicorn entirely to simplify the codebase and unblock future
improvements (like Rack 3).

Notable changes beyond the straightforward removal:

- `Discourse.after_unicorn_worker_fork` →
`Discourse.apply_worker_db_variables_overrides`: renamed and wired into
pitchfork.conf.rb's `after_worker_fork`. This actually *fixes*
per-worker DB variable overrides (`unicorn_worker_db_variables_*`) which
were never called under Pitchfork.
- `bin/ember-cli`: `--unicorn` flag renamed to `--server` (`-u` kept).
- `lib/demon/sidekiq.rb`: removed Unicorn-specific USR1/USR2 signal
handlers and `reopen_logs` (called `Unicorn::Util.reopen_logs`), which
were already dead code under Pitchfork.

Intentionally kept unchanged:
- `config/unicorn_launcher` (used by Docker images, separate effort)
- `docker_manager` plugin (separate repo)
- `UNICORN_*` env vars (renaming deferred)
- Rack < 3 constraint (separate PR)
2026-04-01 15:04:59 +02:00

646 lines
18 KiB
Ruby
Executable file

#!/usr/bin/env ruby
# frozen_string_literal: true
require "optparse"
require "shellwords"
require "json"
require "fileutils"
require "socket"
require "uri"
require "net/http"
require "securerandom"
require_relative "../lib/chrome_installed_checker"
class QunitRunner
def initialize(args)
@file_paths = []
@verbose = ENV["ACTIONS_STEP_DEBUG"] == "true"
@dry_run = false
@rails_port = ENV["UNICORN_PORT"]&.to_i || 3000
@standalone_mode = false
@parallel = ENV["QUNIT_PARALLEL"]
@seed = ENV["QUNIT_SEED"]
@theme_name = ENV["THEME_NAME"]
@theme_url = ENV["THEME_URL"]
@theme_id = ENV["THEME_ID"]
@qunit_path = nil
@rails_env = ENV["QUNIT_RAILS_ENV"] || "test"
@plugin_targets = []
@headless = true
parse_options(args)
end
def run
if @standalone_mode
with_standalone_rails { run_tests }
else
run_tests
end
end
private
def parse_options(args)
parser =
OptionParser.new do |opts|
opts.banner = <<~BANNER
Usage: bin/qunit [options] [files|directories]
Runs the Discourse QUnit suite. By default, tests will be run against the running Rails server and existing JS assets.
Add --standalone to spin up an isolated Rails server and run a standalone asset build.
BANNER
opts.separator ""
opts.separator "Options:"
opts.on("-f", "--filter FILTER", "Run tests matching this filter") do |filter|
@filter = filter
end
opts.on("--dry-run", "Show what would run without executing") { @dry_run = true }
opts.on(
"--rails-port PORT",
Integer,
"Rails server port (default: 3000) (env: UNICORN_PORT)",
) { |port| @rails_port = port }
opts.on("--standalone", "Use standalone mode (spins up own test server)") do
@standalone_mode = true
end
opts.on(
"--parallel N",
Integer,
"Run tests in parallel (N workers) (env: QUNIT_PARALLEL)",
) { |n| @parallel = n.to_s }
opts.on("--seed SEED", "Random seed for test order (env: QUNIT_SEED)") do |seed|
@seed = seed
end
opts.on(
"--target TARGET",
"Target: 'core', 'plugins' (all plugins), or comma separated plugin names",
) { |target| @target = target }
opts.on("--module MODULE", "Run specific module") { |mod| @module = mod }
opts.on("--theme-name NAME", "Theme name for theme tests (env: THEME_NAME)") do |name|
@theme_name = name
end
opts.on("--theme-url URL", "Theme URL for theme tests (env: THEME_URL)") do
@theme_url = url
end
opts.on("--theme-id ID", "Theme ID for theme tests (env: THEME_ID)") { |id| @theme_id = id }
opts.on("--qunit-path PATH", "QUnit path (e.g., /theme-qunit)") do |path|
@qunit_path = path
end
opts.on("--rails-env ENV", "Rails environment (default: test) (env: RAILS_ENV)") do |env|
@rails_env = env
end
opts.on("--report-requests", "Report HTTP requests during tests") do
@report_requests = true
end
opts.on("--[no-]headless", "Run tests in headless mode (env: QUNIT_HEADLESS)") do |h|
@headless = h
end
opts.on("-v", "--verbose", "Verbose output") { @verbose = true }
opts.on("-h", "--help", "Show this help message") do
puts opts
exit 0
end
opts.separator <<~NOTES
\nExamples:
bin/qunit frontend/discourse/tests/unit/models/user-test.js
bin/qunit frontend/discourse/tests/unit/ # Run all tests in directory
bin/qunit --standalone --target plugins # Run all plugin tests
bin/qunit --standalone --target chat # Run single plugin tests
bin/qunit --standalone plugins/chat/test/javascripts/unit/lib/chat-audio-test.js
bin/qunit --standalone --filter "Acceptance: Emoji"
NOTES
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
warn "Error: #{e.message}"
puts ""
puts parser
exit 1
end
ensure_supported_target!
@file_paths = args
detect_plugin_targets!
expand_directory_paths!
end
def detect_plugin_targets!
if resolved_target.include?(",")
@plugin_targets = resolved_target.split(",").map(&:strip)
puts "Running tests for #{@plugin_targets.join(", ")}" if @verbose
elsif resolved_target == "plugins"
@plugin_targets = find_all_plugins_with_tests
puts "Discovered #{@plugin_targets.length} plugins with tests" if @verbose
end
end
def find_all_plugins_with_tests
plugins_dir = File.expand_path("../plugins", __dir__)
return [] unless Dir.exist?(plugins_dir)
Dir
.children(plugins_dir)
.sort
.select do |plugin_dir_name|
plugin_path = File.join(plugins_dir, plugin_dir_name)
next false unless File.directory?(plugin_path)
# Check if plugin has tests
test_dirs = %w[test/javascripts test].map { |subdir| File.join(plugin_path, subdir) }
test_dirs.any? do |test_dir|
Dir.exist?(test_dir) && Dir.glob(File.join(test_dir, "**", "*-test.{js,gjs}")).any?
end
end
.map { |plugin_dir_name| resolve_plugin_namespace(plugin_dir_name) }
end
def expand_directory_paths!
expanded = []
@file_paths.each do |path|
if File.directory?(path)
test_files = Dir.glob(File.join(path, "**", "*-test.{js,gjs}"))
if test_files.empty?
abort "Error: No test files found in directory: #{path}"
else
expanded.concat(test_files)
end
elsif File.file?(path)
expanded << path
else
abort "Error: Path not found: #{path}"
end
end
@file_paths = expanded.uniq
end
def run_tests
ensure_file_paths_exist!
if @file_paths && @verbose
puts "Files to test: #{@file_paths.length}"
puts " #{@file_paths.join("\n ")}"
end
target = resolved_target
puts "Target: #{target}" if @verbose
file_path_filter = build_file_path_filter(@file_paths)
run_ember_exam(file_path_filter: file_path_filter, filter: @filter)
end
def run_ember_exam(filter: nil, file_path_filter: nil)
check_dev_environment!
puts "\nRunning tests against Rails on localhost:#{@rails_port}"
puts " Using existing build in frontend/discourse/dist" unless @standalone_mode
if @plugin_targets.any?
puts " Plugins: #{@plugin_targets.join(", ")}"
elsif file_path_filter
puts " Filtered by file path" if file_path_filter
end
puts " Filter: #{filter}" if filter
puts " Load plugins: #{should_load_plugins?}"
puts ""
dir = frontend_dir
unless Dir.exist?(dir)
puts "Error: frontend/discourse directory not found at #{dir}"
exit 1
end
env = {}
env["LOAD_PLUGINS"] = should_load_plugins? ? "1" : "0"
env["REPORT_REQUESTS"] = "1" if @report_requests
env["UNICORN_PORT"] = @rails_port.to_s
env["TESTEM_DEFAULT_BROWSER"] = ENV["TESTEM_DEFAULT_BROWSER"] || detect_browser
env["PLUGIN_TARGETS"] = @plugin_targets.join(",") if @plugin_targets.any?
env["QUNIT_HEADLESS"] = "0" if !@headless
parallel = !@plugin_targets.any? && present_string(@parallel)
reuse_build = ENV["QUNIT_REUSE_BUILD"] == "1" || !@standalone_mode
query = build_query
args = []
run_ember_build unless reuse_build
if @qunit_path
env["THEME_TEST_PAGES"] = build_theme_test_pages(query)
args += %w[pnpm testem ci -f testem.js]
args += ["--parallel", parallel.to_s] if parallel
else
args = %w[pnpm ember exam]
args += ["--query", query] if query && !query.empty?
args += ["--filter", filter] if filter
args += ["--file-path", file_path_filter] if file_path_filter
args += ["--load-balance", "--parallel", parallel.to_s] if parallel
args += ["--random", resolved_seed]
args += %w[--path dist]
args << "--write-execution-file" if ENV["QUNIT_WRITE_EXECUTION_FILE"]
end
puts <<~MESSAGE if @verbose || @dry_run
Executing: #{JSON.pretty_generate(args)}
with env: #{JSON.pretty_generate(env)}
MESSAGE
if @dry_run
puts "[dry-run] tests not executed"
exit 0
end
system(env, *args, chdir: dir)
exit $?.exitstatus
end
def convert_to_ember_relative_path(file_path)
path = File.expand_path(file_path)
if path.match?(%r{(?:^|/)plugins/})
relative = path.split("/plugins/").last
plugin_dir, remainder = relative.split("/", 2)
plugin_dir ||= relative
namespaced_plugin = resolve_plugin_namespace(plugin_dir)
remainder_path = normalize_plugin_test_path(remainder)
parts = ["discourse", "plugins", namespaced_plugin]
parts << remainder_path unless remainder_path.empty?
return parts.join("/")
end
if path.include?("/frontend/discourse/tests/")
return path.split("/frontend/discourse/").last
elsif path.include?("/tests/")
return path.split("/tests/").last.prepend("tests/")
end
parts = path.split("/")
if idx = parts.index("tests")
return parts[idx..-1].join("/")
end
file_path
end
def ensure_file_paths_exist!
@file_paths.each do |file_path|
next if File.exist?(file_path)
puts "Error: File not found: #{file_path}"
exit 1
end
end
def check_dev_environment!
if @dry_run
puts "[dry-run] skipping dev environment check"
return
end
rails_ok = check_server(@rails_port, "Rails")
unless rails_ok
puts "\n? Dev environment not ready!\n"
puts "Expected Rails to be running on #{@rails_port}, but it is not responding."
puts
puts "Start with: bin/rails server, or use --standalone mode to spin up automatically"
puts ""
exit 1
end
end
def check_server(port, name)
uri = URI("http://localhost:#{port}")
http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = 5
http.read_timeout = 5
begin
response = http.get("/")
if response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
puts "? #{name} server responding on :#{port}" if @verbose
true
else
puts "? #{name} server on :#{port} returned #{response.code}"
false
end
rescue Errno::ECONNREFUSED
puts "? #{name} server not responding on :#{port} (connection refused)"
false
rescue => e
puts "? #{name} server on :#{port}: #{e.message}" if @verbose
false
end
end
def with_standalone_rails(&block)
puts "Running in standalone mode (spinning up test server)..." if @verbose
ensure_pnpm_installed!
install_node_dependencies!
@rails_port = next_available_port(60_098)
server_env = build_server_environment(@rails_port)
server_pid = nil
begin
if @dry_run
puts "[dry-run] skipping server startup"
else
server_pid =
Process.spawn(server_env, File.join(rails_root, "bin", "pitchfork"), pgroup: true)
end
wait_for_rails_server(@rails_port)
yield
ensure
stop_server(server_pid, server_env["UNICORN_PID_PATH"]) if server_pid
end
end
def ensure_pnpm_installed!
return if system("command -v pnpm >/dev/null")
abort "pnpm is not installed. See https://pnpm.io/installation"
end
def install_node_dependencies!
if @dry_run
puts "[dry-run] skipping pnpm install"
return
end
node_modules_outdated =
begin
File.read("node_modules/.pnpm/lock.yaml") != File.read("pnpm-lock.yaml")
rescue Errno::ENOENT
true
end
system("pnpm", "install", exception: true) if node_modules_outdated
end
def detect_browser
ChromeInstalledChecker.run
rescue ChromeInstalledChecker::ChromeError => err
abort err.message
end
def next_available_port(start_port)
port = (ENV["TEST_SERVER_PORT"] || start_port).to_i
port += 1 while !port_available?(port)
port
end
def port_available?(port)
server = TCPServer.open(port)
server.close
true
rescue Errno::EADDRINUSE
false
end
def build_server_environment(server_port)
pid_path = File.join(rails_root, "tmp/pids", "test_server_#{server_port}.pid")
env = {
"RAILS_ENV" => @rails_env || "test",
"SKIP_ENFORCE_HOSTNAME" => "1",
"UNICORN_PID_PATH" => pid_path,
"UNICORN_PORT" => server_port.to_s,
"UNICORN_SIDEKIQS" => "0",
"DISCOURSE_SKIP_CSS_WATCHER" => "1",
"UNICORN_LISTENER" => "127.0.0.1:#{server_port}",
"LOGSTASH_UNICORN_URI" => nil,
"UNICORN_WORKERS" => "1",
"UNICORN_TIMEOUT" => "90",
}
env["LOAD_PLUGINS"] = "1" if should_load_plugins?
env
end
def wait_for_rails_server(port)
if @dry_run
puts "[dry-run] skipping Rails server warm-up"
return
end
uri = URI("http://localhost:#{port}/srv/status")
puts "Warming up Rails server"
deadline = Time.now + 60
begin
Net::HTTP.get(uri)
rescue Errno::ECONNREFUSED,
Errno::EADDRNOTAVAIL,
Net::ReadTimeout,
Net::HTTPBadResponse,
EOFError
if Time.now <= deadline
sleep 1
retry
end
puts "Timed out. Cannot connect to forked server!"
exit 1
end
puts "Rails server is warmed up"
end
def run_ember_build
if @dry_run
puts "[dry-run] skipping ember build"
return
end
system(
{ "LOAD_PLUGINS" => should_load_plugins? ? "1" : "0" },
"script/assemble_ember_build.rb",
chdir: rails_root,
exception: true,
)
end
def build_theme_test_pages(query)
pages =
if ENV["THEME_IDS"] && !ENV["THEME_IDS"].empty?
ENV["THEME_IDS"]
.split("|")
.map { |theme_id| "#{@qunit_path}?#{query}&testem=1&id=#{theme_id}" }
else
["#{@qunit_path}?#{query}&testem=1"]
end
pages.shuffle.join(",")
end
def build_file_path_filter(paths)
return nil if paths.empty?
relative_paths = paths.map { |path| convert_to_ember_relative_path(path) }
relative_paths.join(",")
end
def build_query
params = {}
if @qunit_path
# Only include seed manually if we're running without ember exam
params["seed"] = resolved_seed
end
params["module"] = @module if @module
params["theme_name"] = @theme_name if @theme_name
params["theme_url"] = @theme_url if @theme_url
params["theme_id"] = @theme_id if @theme_id
params["target"] = resolved_target if resolved_target && !@plugin_targets.any?
params["report_requests"] = "1" if @report_requests
encode_query_string(params)
end
def stop_server(pid, pid_path)
if @dry_run
puts "[dry-run] skipping server shutdown"
return
end
Process.kill("-KILL", pid)
rescue Errno::ESRCH
ensure
FileUtils.rm_f(pid_path) if pid_path
end
def rails_root
@rails_root ||= File.expand_path("..", __dir__)
end
def frontend_dir
@frontend_dir ||= File.expand_path("../frontend/discourse", __dir__)
end
def resolved_target
@resolved_target ||=
begin
implicit_target = (target_from_paths(@file_paths) if @file_paths.any?)
if @target && implicit_target && @target != implicit_target
puts "Files are for '#{implicit_target}', but target was configured as '#{@target}'"
exit 1
end
implicit_target || @target || "core"
end
end
def resolved_seed
@resolved_seed ||= present_string(@seed) || SecureRandom.alphanumeric(8)
end
def should_load_plugins?
resolved_target != "core"
end
def target_from_paths(paths)
targets = paths.map { |path| target_from_path(path) }.uniq
if targets.length > 1
puts "Error: Cannot mix multiple plugin/core targets when running specific files"
exit 1
end
targets.first
end
def target_from_path(path)
match = path.match(%r{(?:^|/)plugins/([^/]+)/})
return "core" unless match
resolve_plugin_namespace(match[1])
end
def plugin_path?(path)
path.match?(%r{(?:^|/)plugins/})
end
def encode_query_string(params = {})
cleaned = params.compact
return nil if cleaned.empty?
URI.encode_www_form(cleaned)
end
def present_string(value)
return nil if value.nil?
str = value.to_s
str.empty? ? nil : str
end
def resolve_plugin_namespace(directory_name)
@plugin_namespace_cache ||= {}
return @plugin_namespace_cache[directory_name] if @plugin_namespace_cache.key?(directory_name)
plugin_rb = File.expand_path("../plugins/#{directory_name}/plugin.rb", __dir__)
if File.exist?(plugin_rb)
File.foreach(plugin_rb) do |line|
next unless line.start_with?("#")
attribute, value = line[1..].split(":", 2)
next unless attribute&.strip == "name"
plugin_name = value&.strip
if plugin_name && !plugin_name.empty?
@plugin_namespace_cache[directory_name] = plugin_name
return plugin_name
end
end
end
@plugin_namespace_cache[directory_name] = directory_name
end
def normalize_plugin_test_path(path)
path.sub(%r{\Atests?/javascripts/}, "")
end
def plugin_names_from_filter(filter)
return [] unless filter
filter.scan(%r{plugins/([^/]+)/}).flatten.uniq.map { |dir| resolve_plugin_namespace(dir) }
end
def ensure_supported_target!
value = present_string(@target)
return unless value
normalized = value.downcase
if normalized == "all"
abort "Error: Target 'all' is no longer supported. Use 'core' or 'plugins'."
end
end
end
QunitRunner.new(ARGV).run