mirror of
https://github.com/discourse/discourse.git
synced 2026-08-04 10:39:43 +08:00
`bin/qunit` could wait indefinitely for a headless browser that connected but then stopped making test progress, hanging the whole run with no useful diagnostics. This is most painful when the run was launched in an automated way, for example by an agent or in CI: a wedged headless Chrome blocks the caller with no signal, instead of returning a fast, diagnosable failure it can act on. This adds a browser watchdog that fails fast and identifies the reason instead of hanging. testem's native start timeout handles a browser that never connects, and a small testem patch handles one that connects but then goes idle outside an active test, reporting `Browser made no test progress for N seconds` on stderr and through the test result. There is deliberately no retry logic: on a stall the run exits non-zero so the caller that launched it can decide to retry the whole run. The watchdog is on by default and its two timeouts are configurable through flags and matching environment variables: `--browser-watchdog` / `QUNIT_BROWSER_WATCHDOG`, `--browser-start-timeout` / `QUNIT_BROWSER_START_TIMEOUT` (default 45s), and `--browser-inactivity-timeout` / `QUNIT_BROWSER_INACTIVITY_TIMEOUT` (default 30s). The CLI flag and environment wiring, including precedence over the environment, is covered by RSpec. The testem patch itself is not unit-tested, to avoid standing up a separate node test harness just to exercise another test harness.
714 lines
20 KiB
Ruby
Executable file
Vendored
714 lines
20 KiB
Ruby
Executable file
Vendored
#!/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
|
|
DEFAULT_BROWSER_START_TIMEOUT = 45
|
|
DEFAULT_BROWSER_INACTIVITY_TIMEOUT = 30
|
|
|
|
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
|
|
@browser_watchdog = boolean_env("QUNIT_BROWSER_WATCHDOG", default: true)
|
|
@browser_start_timeout =
|
|
positive_integer_env("QUNIT_BROWSER_START_TIMEOUT", DEFAULT_BROWSER_START_TIMEOUT)
|
|
@browser_inactivity_timeout =
|
|
positive_integer_env("QUNIT_BROWSER_INACTIVITY_TIMEOUT", DEFAULT_BROWSER_INACTIVITY_TIMEOUT)
|
|
|
|
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 against \"module: test name\" (case-insensitive substring, or a slash-wrapped regex like /foo/i)",
|
|
) { |filter| @filter = filter }
|
|
|
|
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(
|
|
"--[no-]browser-watchdog",
|
|
"Fail fast if the browser stalls on startup or between tests (env: QUNIT_BROWSER_WATCHDOG)",
|
|
) { |enabled| @browser_watchdog = enabled }
|
|
|
|
opts.on(
|
|
"--browser-start-timeout SECONDS",
|
|
Integer,
|
|
"Browser connection timeout (default: 45; env: QUNIT_BROWSER_START_TIMEOUT)",
|
|
) do |seconds|
|
|
@browser_start_timeout = positive_integer(seconds, "--browser-start-timeout")
|
|
end
|
|
|
|
opts.on(
|
|
"--browser-inactivity-timeout SECONDS",
|
|
Integer,
|
|
"Browser inactivity timeout outside active tests (default: 30; env: QUNIT_BROWSER_INACTIVITY_TIMEOUT)",
|
|
) do |seconds|
|
|
@browser_inactivity_timeout = positive_integer(seconds, "--browser-inactivity-timeout")
|
|
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"
|
|
bin/qunit --standalone --filter "/DButton|DIconGridPicker/i"
|
|
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
|
|
env["QUNIT_BROWSER_WATCHDOG"] = @browser_watchdog ? "1" : "0"
|
|
env["QUNIT_BROWSER_START_TIMEOUT"] = @browser_start_timeout.to_s
|
|
env["QUNIT_BROWSER_INACTIVITY_TIMEOUT"] = @browser_inactivity_timeout.to_s
|
|
|
|
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/tests/").last
|
|
elsif path.include?("/tests/")
|
|
return path.split("/tests/").last
|
|
end
|
|
|
|
parts = path.split("/")
|
|
if idx = parts.index("tests")
|
|
return parts[(idx + 1)..-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(4_566)
|
|
server_env = build_server_environment(@rails_port)
|
|
server_pid = nil
|
|
log_path = File.join(rails_root, "tmp", "test_server_#{@rails_port}.log")
|
|
|
|
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,
|
|
out: log_path,
|
|
err: %i[child out],
|
|
)
|
|
end
|
|
|
|
wait_for_rails_server(@rails_port, log_path)
|
|
|
|
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, log_path)
|
|
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!"
|
|
puts File.read(log_path) if File.exist?(log_path)
|
|
puts "\n--- Sockets on port #{port} ---"
|
|
system("ss -tlnp 'sport = :#{port}' 2>/dev/null || ss -tlnp | grep ':#{port} '")
|
|
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}&id=#{theme_id}" }
|
|
else
|
|
["#{@qunit_path}?#{query}"]
|
|
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
|
|
params["testem"] = "1"
|
|
|
|
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 boolean_env(name, default:)
|
|
value = ENV[name]
|
|
return default if value.nil?
|
|
return true if %w[1 true].include?(value.downcase)
|
|
return false if %w[0 false].include?(value.downcase)
|
|
|
|
abort "Error: #{name} must be one of: 1, 0, true, false"
|
|
end
|
|
|
|
def positive_integer_env(name, default)
|
|
value = ENV[name]
|
|
return default if value.nil?
|
|
|
|
positive_integer(Integer(value, exception: false), name)
|
|
end
|
|
|
|
def positive_integer(value, name)
|
|
abort "Error: #{name} must be greater than 0" if !value || value <= 0
|
|
|
|
value
|
|
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
|