mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-08 00:38:03 +08:00
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)
646 lines
18 KiB
Ruby
Executable file
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
|