2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-03-03 22:05:52 +08:00
discourse/bin/qunit
David Taylor e3abc79046
DEV: Refactor plugin JS handling (#37763)
Extracted from #35477. This moves all of the `<script>` tag generation
from ember-cli to Rails, and makes Rails serve the assets in test mode.

No behaviour change, but will make it easier for us to feature-flag the
new plugin compiler.
2026-02-19 12:24:04 +00:00

640 lines
17 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 --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?
puts "Warning: No test files found in directory: #{path}" if @verbose
else
expanded.concat(test_files)
end
elsif File.file?(path)
expanded << path
else
puts "Warning: 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)
unicorn_env = build_unicorn_environment(@rails_port)
unicorn_pid = nil
begin
if @dry_run
puts "[dry-run] skipping unicorn startup"
else
unicorn_pid =
Process.spawn(unicorn_env, File.join(rails_root, "bin", "pitchfork"), pgroup: true)
end
wait_for_rails_server(@rails_port)
yield
ensure
stop_unicorn(unicorn_pid, unicorn_env["UNICORN_PID_PATH"]) if unicorn_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_unicorn_environment(unicorn_port)
pid_path = File.join(rails_root, "tmp/pids", "unicorn_test_#{unicorn_port}.pid")
env = {
"RAILS_ENV" => @rails_env || "test",
"SKIP_ENFORCE_HOSTNAME" => "1",
"UNICORN_PID_PATH" => pid_path,
"UNICORN_PORT" => unicorn_port.to_s,
"UNICORN_SIDEKIQS" => "0",
"DISCOURSE_SKIP_CSS_WATCHER" => "1",
"UNICORN_LISTENER" => "127.0.0.1:#{unicorn_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 for themes"
return
end
system("pnpm", "ember", "build", chdir: frontend_dir, 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_unicorn(pid, pid_path)
if @dry_run
puts "[dry-run] skipping unicorn 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