coatepec 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +4 -0
- data/.rubocop.yml +28 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +164 -0
- data/Rakefile +24 -0
- data/exe/coatepec +7 -0
- data/exe/coatepec-worker +12 -0
- data/lib/coatepec/cli.rb +40 -0
- data/lib/coatepec/errors.rb +15 -0
- data/lib/coatepec/introspection/model.rb +140 -0
- data/lib/coatepec/introspection/routes.rb +61 -0
- data/lib/coatepec/introspection/safe_options.rb +46 -0
- data/lib/coatepec/mcp/response.rb +40 -0
- data/lib/coatepec/mcp/tools.rb +139 -0
- data/lib/coatepec/mcp.rb +22 -0
- data/lib/coatepec/project.rb +30 -0
- data/lib/coatepec/project_config.rb +44 -0
- data/lib/coatepec/protocol.rb +30 -0
- data/lib/coatepec/spec/fork_strategy.rb +39 -0
- data/lib/coatepec/spec/guarded_fork_strategy.rb +116 -0
- data/lib/coatepec/spec/path_policy.rb +92 -0
- data/lib/coatepec/spec/process_strategy.rb +107 -0
- data/lib/coatepec/spec/result.rb +79 -0
- data/lib/coatepec/spec/runner.rb +62 -0
- data/lib/coatepec/spec/spawn_strategy.rb +19 -0
- data/lib/coatepec/version.rb +5 -0
- data/lib/coatepec/worker/change_detector.rb +44 -0
- data/lib/coatepec/worker/client.rb +101 -0
- data/lib/coatepec/worker/rails_runtime.rb +140 -0
- data/lib/coatepec/worker/server.rb +65 -0
- data/lib/coatepec/worker_manager.rb +87 -0
- data/lib/coatepec.rb +28 -0
- data/sig/coatepec.rbs +4 -0
- metadata +116 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Coatepec
|
|
6
|
+
module Spec
|
|
7
|
+
# Turns a finished RSpec child process's exit status, captured
|
|
8
|
+
# stdout/stderr (each capped at MAX_OUTPUT_BYTES), and RSpec's own JSON
|
|
9
|
+
# formatter output into the flat result hash rails_spec_run returns.
|
|
10
|
+
module Result
|
|
11
|
+
MAX_OUTPUT_BYTES = 256 * 1024
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def build(pid:, status:, out_r:, err_r:, json_path:)
|
|
16
|
+
stdout_result = read_bounded(out_r)
|
|
17
|
+
stderr_result = read_bounded(err_r)
|
|
18
|
+
summary = read_summary(json_path)
|
|
19
|
+
|
|
20
|
+
base(pid, status, stdout_result, stderr_result).merge(
|
|
21
|
+
summary: summary && summary_fields(summary),
|
|
22
|
+
examples: (summary&.fetch("examples", []) || []).first(500).map { |e| example_fields(e) }
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# rubocop:disable Metrics/MethodLength -- one flat hash literal mapping
|
|
27
|
+
# Process::Status/captured-output fields to the result payload's own
|
|
28
|
+
# field names; splitting it would scatter that 1:1 mapping across
|
|
29
|
+
# methods for no readability gain.
|
|
30
|
+
def base(pid, status, stdout_result, stderr_result)
|
|
31
|
+
{
|
|
32
|
+
status: status.exited? && status.exitstatus.zero? ? "passed" : "failed",
|
|
33
|
+
exit_code: status.exitstatus,
|
|
34
|
+
child_pid: pid,
|
|
35
|
+
signaled: status.signaled?,
|
|
36
|
+
termsig: status.termsig,
|
|
37
|
+
stopsig: status.stopsig,
|
|
38
|
+
coredump: status.respond_to?(:coredump?) ? status.coredump? : false,
|
|
39
|
+
stdout: stdout_result[:text],
|
|
40
|
+
stdout_truncated: stdout_result[:truncated],
|
|
41
|
+
stderr: stderr_result[:text],
|
|
42
|
+
stderr_truncated: stderr_result[:truncated]
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
# rubocop:enable Metrics/MethodLength
|
|
46
|
+
|
|
47
|
+
def read_summary(json_path)
|
|
48
|
+
return nil unless File.exist?(json_path) && !File.empty?(json_path)
|
|
49
|
+
|
|
50
|
+
JSON.parse(File.read(json_path))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def summary_fields(summary)
|
|
54
|
+
{
|
|
55
|
+
example_count: summary.dig("summary", "example_count"),
|
|
56
|
+
failure_count: summary.dig("summary", "failure_count"),
|
|
57
|
+
duration: summary.dig("summary", "duration")
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def example_fields(example)
|
|
62
|
+
{
|
|
63
|
+
id: example["id"],
|
|
64
|
+
description: example["full_description"],
|
|
65
|
+
status: example["status"],
|
|
66
|
+
file_path: example["file_path"],
|
|
67
|
+
line_number: example["line_number"]
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def read_bounded(io)
|
|
72
|
+
data = io.read.to_s
|
|
73
|
+
truncated = data.bytesize > MAX_OUTPUT_BYTES
|
|
74
|
+
data = data.byteslice(-MAX_OUTPUT_BYTES, MAX_OUTPUT_BYTES) if truncated
|
|
75
|
+
{ text: data, truncated: truncated }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coatepec
|
|
4
|
+
module Spec
|
|
5
|
+
# Validates a `rails_spec_run` request's paths, builds the RSpec CLI
|
|
6
|
+
# args, and delegates to the platform-appropriate process strategy
|
|
7
|
+
# (fork on Linux, spawn on macOS, or a guarded fork on macOS when the
|
|
8
|
+
# project opts in via .coatepec.yml).
|
|
9
|
+
class Runner
|
|
10
|
+
DEFAULT_TIMEOUT = 120
|
|
11
|
+
|
|
12
|
+
def initialize(project_root, rails_runtime: nil)
|
|
13
|
+
@project_root = project_root
|
|
14
|
+
@project = Project.new(project_root)
|
|
15
|
+
@path_policy = PathPolicy.new(@project)
|
|
16
|
+
@rails_runtime = rails_runtime
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run(paths:, example: nil, seed: nil, fail_fast: false, timeout_seconds: DEFAULT_TIMEOUT)
|
|
20
|
+
require_rspec!
|
|
21
|
+
selectors = @path_policy.validate!(paths)
|
|
22
|
+
args = build_args(selectors, example, seed, fail_fast)
|
|
23
|
+
|
|
24
|
+
strategy_class.new(@project_root, project: @project, rails_runtime: @rails_runtime).run(args, timeout_seconds)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def strategy_class
|
|
30
|
+
case RbConfig::CONFIG["host_os"]
|
|
31
|
+
when /linux/ then ForkStrategy
|
|
32
|
+
# The macos_fork config key governs this whole branch, BSD included --
|
|
33
|
+
# the name tracks the documented macOS incident, not the platform set.
|
|
34
|
+
when /darwin|bsd/ then macos_strategy_class
|
|
35
|
+
else raise Coatepec::Error.new(:unsupported_platform, "Coatepec supports macOS and Linux only")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Reading @project.config here means an invalid .coatepec.yml only
|
|
40
|
+
# raises :invalid_config on macOS -- the Linux branch never touches it.
|
|
41
|
+
# Accepted asymmetry: the file exists to configure this branch.
|
|
42
|
+
def macos_strategy_class
|
|
43
|
+
@project.config.macos_fork? ? GuardedForkStrategy : SpawnStrategy
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def require_rspec!
|
|
47
|
+
require "rspec/core"
|
|
48
|
+
rescue LoadError
|
|
49
|
+
raise Coatepec::Error.new(:unsupported_test_framework, "rspec-rails must be in the application's test group")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build_args(selectors, example, seed, fail_fast)
|
|
53
|
+
args = selectors.dup
|
|
54
|
+
args += ["-e", example] if example
|
|
55
|
+
args += ["--seed", seed.to_s] if seed
|
|
56
|
+
args << "--fail-fast" if fail_fast
|
|
57
|
+
|
|
58
|
+
args
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coatepec
|
|
4
|
+
module Spec
|
|
5
|
+
# Runs RSpec in a freshly `Process.spawn`ed `bundle exec rspec` (macOS
|
|
6
|
+
# and any platform without a working fork): slower per run since Rails
|
|
7
|
+
# boots from scratch, but avoids fork-safety pitfalls.
|
|
8
|
+
class SpawnStrategy < ProcessStrategy
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def start(full_args, out_w, err_w)
|
|
12
|
+
Process.spawn(
|
|
13
|
+
{ "RAILS_ENV" => "test" }, "bundle", "exec", "rspec", *full_args,
|
|
14
|
+
chdir: @project_root, out: out_w, err: err_w, pgroup: true
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coatepec
|
|
4
|
+
module Worker
|
|
5
|
+
# Fingerprints Gemfile/Gemfile.lock and boot-relevant files (mtime +
|
|
6
|
+
# size) so WorkerManager can decide whether a bundle change requires the
|
|
7
|
+
# whole sidecar to restart, or a boot-file change just the worker.
|
|
8
|
+
class ChangeDetector
|
|
9
|
+
BUNDLE_FILES = %w[Gemfile Gemfile.lock].freeze
|
|
10
|
+
BOOT_FILES = %w[config/boot.rb config/application.rb config/environment.rb config/environments/test.rb].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(project_root)
|
|
13
|
+
@project_root = project_root
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def snapshot
|
|
17
|
+
{ bundle: fingerprint(BUNDLE_FILES), boot: fingerprint(BOOT_FILES + initializer_files) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def restart_reason(previous_snapshot)
|
|
21
|
+
current = snapshot
|
|
22
|
+
return :sidecar_restart_required if current[:bundle] != previous_snapshot[:bundle]
|
|
23
|
+
return :worker_restart_required if current[:boot] != previous_snapshot[:boot]
|
|
24
|
+
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def initializer_files
|
|
31
|
+
Dir.glob(File.join(@project_root, "config/initializers/**/*.rb"))
|
|
32
|
+
.sort
|
|
33
|
+
.map { |f| f.delete_prefix("#{@project_root}/") }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def fingerprint(relative_paths)
|
|
37
|
+
relative_paths.each_with_object({}) do |relative_path, acc|
|
|
38
|
+
full_path = File.join(@project_root, relative_path)
|
|
39
|
+
acc[relative_path] = File.exist?(full_path) ? [File.mtime(full_path).to_f, File.size(full_path)] : nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler"
|
|
4
|
+
|
|
5
|
+
module Coatepec
|
|
6
|
+
module Worker
|
|
7
|
+
# The parent-process handle to a spawned test worker: owns its pipes,
|
|
8
|
+
# sends NDJSON requests with a response timeout, and detects when the
|
|
9
|
+
# worker has died.
|
|
10
|
+
class Client
|
|
11
|
+
class DisconnectedError < StandardError; end
|
|
12
|
+
|
|
13
|
+
def self.spawn(project_root)
|
|
14
|
+
new(project_root).tap(&:start)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(project_root)
|
|
18
|
+
@project_root = project_root
|
|
19
|
+
@next_id = 0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def start
|
|
23
|
+
to_worker_r, @to_worker_write = IO.pipe
|
|
24
|
+
@from_worker_read, from_worker_w = IO.pipe
|
|
25
|
+
|
|
26
|
+
@pid = spawn_worker(to_worker_r, from_worker_w)
|
|
27
|
+
to_worker_r.close
|
|
28
|
+
from_worker_w.close
|
|
29
|
+
@protocol = Protocol.new(input: @from_worker_read, output: @to_worker_write)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def alive?
|
|
33
|
+
return false unless @pid
|
|
34
|
+
|
|
35
|
+
Process.kill(0, @pid)
|
|
36
|
+
true
|
|
37
|
+
rescue Errno::ESRCH
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def request(command, args, timeout: 30)
|
|
42
|
+
raise DisconnectedError, "Worker is not running" unless alive?
|
|
43
|
+
|
|
44
|
+
id = (@next_id += 1)
|
|
45
|
+
@protocol.write(id: id, command: command, args: args)
|
|
46
|
+
message = read_response(id, timeout)
|
|
47
|
+
|
|
48
|
+
message[:ok] ? message[:data] : raise_worker_error(message[:error])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def stop
|
|
52
|
+
return unless @pid
|
|
53
|
+
|
|
54
|
+
Process.kill("TERM", @pid)
|
|
55
|
+
Process.wait(@pid)
|
|
56
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
57
|
+
nil
|
|
58
|
+
ensure
|
|
59
|
+
[@to_worker_write, @from_worker_read].each { |io| io && !io.closed? && io.close }
|
|
60
|
+
@pid = nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def spawn_worker(to_worker_r, from_worker_w)
|
|
66
|
+
worker_exe = File.expand_path("../../../exe/coatepec-worker", __dir__)
|
|
67
|
+
lib_path = File.expand_path("../..", __dir__)
|
|
68
|
+
# If this process is itself running under a Bundler context (e.g. the
|
|
69
|
+
# host app's own deployment-mode bundle), Bundler.setup has already
|
|
70
|
+
# narrowed GEM_PATH/RUBYOPT to that bundle's install location. Without
|
|
71
|
+
# stripping that, the worker -- which needs the target app's own
|
|
72
|
+
# separately-installed gems -- inherits a GEM_PATH that can't see them
|
|
73
|
+
# and fails with a spurious Bundler::GemNotFound. with_unbundled_env
|
|
74
|
+
# also resets RUBYLIB to its pre-Bundler snapshot, so it must be set
|
|
75
|
+
# explicitly here rather than relying on the caller's environment.
|
|
76
|
+
Bundler.with_unbundled_env do
|
|
77
|
+
Process.spawn(
|
|
78
|
+
{ "BUNDLE_GEMFILE" => File.join(@project_root, "Gemfile"), "RUBYLIB" => lib_path },
|
|
79
|
+
RbConfig.ruby, worker_exe, @project_root,
|
|
80
|
+
in: to_worker_r, out: from_worker_w, err: :err, chdir: @project_root
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def read_response(id, timeout)
|
|
86
|
+
ready = IO.select([@from_worker_read], nil, nil, timeout)
|
|
87
|
+
raise DisconnectedError, "Worker timed out responding" unless ready
|
|
88
|
+
|
|
89
|
+
message = @protocol.read
|
|
90
|
+
raise DisconnectedError, "Worker closed the connection" if message.nil?
|
|
91
|
+
raise DisconnectedError, "Unexpected response id #{message[:id]} for request #{id}" unless message[:id] == id
|
|
92
|
+
|
|
93
|
+
message
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def raise_worker_error(error)
|
|
97
|
+
raise Coatepec::Error.new(error[:code].to_sym, error[:message], details: error[:details] || {})
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Coatepec
|
|
6
|
+
module Worker
|
|
7
|
+
# Boots the fixture/target Rails app under RAILS_ENV=test exactly once
|
|
8
|
+
# per worker process and reports its identity (pid, boot_id, versions,
|
|
9
|
+
# lifecycle state) for rails_runtime_status.
|
|
10
|
+
class RailsRuntime
|
|
11
|
+
attr_reader :pid, :boot_id, :boot_duration_ms, :ruby_version, :rails_version, :post_boot_thread_count
|
|
12
|
+
|
|
13
|
+
def initialize(project_root)
|
|
14
|
+
@project_root = project_root
|
|
15
|
+
@booted = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def booted?
|
|
19
|
+
@booted
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def boot!
|
|
23
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
24
|
+
ENV["RAILS_ENV"] = "test"
|
|
25
|
+
force_reloading!
|
|
26
|
+
require File.join(@project_root, "config/environment")
|
|
27
|
+
record_boot!(started_at)
|
|
28
|
+
rescue Coatepec::Error
|
|
29
|
+
raise
|
|
30
|
+
rescue StandardError, LoadError => e
|
|
31
|
+
raise Coatepec::Error.new(:worker_failure, "Rails failed to boot: #{e.message}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def status
|
|
35
|
+
{
|
|
36
|
+
pid: pid,
|
|
37
|
+
boot_id: boot_id,
|
|
38
|
+
ruby_version: ruby_version,
|
|
39
|
+
rails_version: rails_version,
|
|
40
|
+
boot_duration_ms: boot_duration_ms,
|
|
41
|
+
environment: "test",
|
|
42
|
+
coatepec_version: Coatepec::VERSION,
|
|
43
|
+
lifecycle_state: booted? ? "ready" : "not_started"
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Loaded gem names for GuardedForkStrategy's denylist check. RailsRuntime
|
|
48
|
+
# itself has no notion of macOS or forking -- it only exposes the raw
|
|
49
|
+
# fact of what's loaded.
|
|
50
|
+
def loaded_gem_names
|
|
51
|
+
Gem.loaded_specs.keys.map(&:to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Rails' test environment disables reloading by default, and Zeitwerk
|
|
57
|
+
# refuses to enable reloading on a loader that's already had #setup
|
|
58
|
+
# called on it -- which happens automatically, deep inside
|
|
59
|
+
# Rails.application.initialize!, before any of our own code can run.
|
|
60
|
+
# The only window to influence this is before the target app's own
|
|
61
|
+
# config/environment.rb executes: intercept the config setter itself,
|
|
62
|
+
# so whatever the app's test.rb assigns, reloading still ends up on.
|
|
63
|
+
#
|
|
64
|
+
# `require "bundler/setup"` here (before `require "rails"`) is
|
|
65
|
+
# necessary, not optional: without it, `require "rails"` resolves
|
|
66
|
+
# whatever Rails version RubyGems activates by default -- not
|
|
67
|
+
# necessarily the one pinned by the target app's own Gemfile.lock --
|
|
68
|
+
# and once the wrong version's gems are activated process-wide, the
|
|
69
|
+
# target app's own later `require "bundler/setup"` (in its
|
|
70
|
+
# config/boot.rb) raises a Gem::LoadError version conflict instead of
|
|
71
|
+
# silently no-oping. BUNDLE_GEMFILE is already set to the target app's
|
|
72
|
+
# Gemfile by the caller (Worker::Client's spawn env) before this
|
|
73
|
+
# process starts, so this activates the correct version and the app's
|
|
74
|
+
# own later Bundler.setup call is simply a no-op.
|
|
75
|
+
def force_reloading!
|
|
76
|
+
require "bundler/setup"
|
|
77
|
+
require "rails"
|
|
78
|
+
return if Rails::Application::Configuration.method_defined?(:coatepec_forces_reloading?)
|
|
79
|
+
|
|
80
|
+
Rails::Application::Configuration.prepend(reload_forcing_module)
|
|
81
|
+
Rails::Application::Configuration.prepend(file_watcher_forcing_module)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# `enable_reloading=` is overridden as the modern, documented setter.
|
|
85
|
+
# `cache_classes=` is also overridden because Rails' own
|
|
86
|
+
# `enable_reloading=` is implemented as `self.cache_classes = !value`
|
|
87
|
+
# (see railties' application/configuration.rb) with `cache_classes`
|
|
88
|
+
# remaining a plain attr_accessor underneath -- so a target app's
|
|
89
|
+
# config/environments/test.rb using the legacy `config.cache_classes =
|
|
90
|
+
# true` form would otherwise write that flag directly, bypassing the
|
|
91
|
+
# `enable_reloading=` override entirely and silently leaving reloading
|
|
92
|
+
# off.
|
|
93
|
+
def reload_forcing_module
|
|
94
|
+
Module.new do
|
|
95
|
+
def coatepec_forces_reloading? = true
|
|
96
|
+
|
|
97
|
+
def enable_reloading=(_value)
|
|
98
|
+
super(true)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def cache_classes=(_value)
|
|
102
|
+
super(false)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Consequence of the override above, and prepended alongside it: with
|
|
108
|
+
# reloading on, Rails' own finisher now instantiates
|
|
109
|
+
# `config.file_watcher` -- something that never happened in this process
|
|
110
|
+
# while the test env kept reloading off. The default
|
|
111
|
+
# `ActiveSupport::FileUpdateChecker` polls on the calling thread and is
|
|
112
|
+
# fork-safe, but a target app configuring
|
|
113
|
+
# `ActiveSupport::EventedFileUpdateChecker` would instead spawn `listen`
|
|
114
|
+
# background threads *during boot* in the warm worker. On macOS those
|
|
115
|
+
# are exactly the ObjC-initializing threads GuardedForkStrategy exists
|
|
116
|
+
# to keep out of a fork -- and because they'd start before
|
|
117
|
+
# #post_boot_thread_count is sampled, they'd be baked into the guard's
|
|
118
|
+
# own baseline and sail through its thread-count check unnoticed. So the
|
|
119
|
+
# watcher is pinned to the polling implementation regardless of what the
|
|
120
|
+
# app asks for.
|
|
121
|
+
def file_watcher_forcing_module
|
|
122
|
+
Module.new do
|
|
123
|
+
def file_watcher=(_value)
|
|
124
|
+
super(::ActiveSupport::FileUpdateChecker)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def record_boot!(started_at)
|
|
130
|
+
@pid = Process.pid
|
|
131
|
+
@boot_id = SecureRandom.hex(8)
|
|
132
|
+
@ruby_version = RUBY_VERSION
|
|
133
|
+
@rails_version = Rails.version
|
|
134
|
+
@boot_duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
135
|
+
@post_boot_thread_count = Thread.list.count
|
|
136
|
+
@booted = true
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coatepec
|
|
4
|
+
module Worker
|
|
5
|
+
# The test worker's message loop: reads NDJSON requests from its parent
|
|
6
|
+
# over Protocol, lazily boots Rails on first use, dispatches `status`/
|
|
7
|
+
# `spec_run`, and writes back structured ok/error responses.
|
|
8
|
+
class Server
|
|
9
|
+
def initialize(project_root, input:, protocol_output:)
|
|
10
|
+
@protocol = Protocol.new(input: input, output: protocol_output)
|
|
11
|
+
@runtime = RailsRuntime.new(project_root)
|
|
12
|
+
@project_root = project_root
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
loop do
|
|
17
|
+
message = @protocol.read
|
|
18
|
+
break if message.nil?
|
|
19
|
+
|
|
20
|
+
handle(message)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def handle(message)
|
|
27
|
+
@runtime.boot! unless @runtime.booted?
|
|
28
|
+
|
|
29
|
+
data = dispatch(message[:command], message[:args] || {})
|
|
30
|
+
@protocol.write(id: message[:id], ok: true, data: data)
|
|
31
|
+
rescue Coatepec::Error => e
|
|
32
|
+
@protocol.write(id: message[:id], ok: false, error: { code: e.code, message: e.message, details: e.details })
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
@protocol.write(id: message[:id], ok: false, error: { code: :internal_error, message: e.message, details: {} })
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def dispatch(command, args)
|
|
38
|
+
Rails.application.reloader.wrap { execute_command(command, args) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def execute_command(command, args)
|
|
42
|
+
case command
|
|
43
|
+
when "status" then @runtime.status
|
|
44
|
+
when "spec_run" then handle_spec_run(args)
|
|
45
|
+
when "routes" then handle_routes(args)
|
|
46
|
+
when "model" then handle_model(args)
|
|
47
|
+
else
|
|
48
|
+
raise Coatepec::Error.new(:internal_error, "Unknown command #{command}")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def handle_spec_run(args)
|
|
53
|
+
Spec::Runner.new(@project_root, rails_runtime: @runtime).run(**args.transform_keys(&:to_sym))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def handle_routes(args)
|
|
57
|
+
Introspection::Routes.new(**args.transform_keys(&:to_sym)).call
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def handle_model(args)
|
|
61
|
+
Introspection::Model.new(args.transform_keys(&:to_sym)[:name]).call
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Coatepec
|
|
6
|
+
# Owns the single warm Worker::Client for a project: lazily starts it,
|
|
7
|
+
# restarts it when ChangeDetector flags a boot-file change, escalates a
|
|
8
|
+
# Gemfile change to :sidecar_restart_required, and retries a request once
|
|
9
|
+
# if the worker had died since the last dispatch.
|
|
10
|
+
class WorkerManager
|
|
11
|
+
def initialize(project)
|
|
12
|
+
@project = project
|
|
13
|
+
@change_detector = Worker::ChangeDetector.new(project.root)
|
|
14
|
+
@client = nil
|
|
15
|
+
@snapshot = nil
|
|
16
|
+
@lock = Monitor.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def status
|
|
20
|
+
dispatch("status", {}, timeout: 30)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run_spec(paths:, example:, seed:, fail_fast:, timeout_seconds:)
|
|
24
|
+
dispatch(
|
|
25
|
+
"spec_run",
|
|
26
|
+
{ paths: paths, example: example, seed: seed, fail_fast: fail_fast, timeout_seconds: timeout_seconds },
|
|
27
|
+
timeout: timeout_seconds + 10
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def routes(query: nil, limit: 50, offset: 0)
|
|
32
|
+
dispatch("routes", { query: query, limit: limit, offset: offset }, timeout: 30)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def model(name:)
|
|
36
|
+
dispatch("model", { name: name }, timeout: 30)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def stop
|
|
40
|
+
@lock.synchronize { @client&.stop }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def dispatch(command, args, timeout:, retried: false)
|
|
46
|
+
@lock.synchronize do
|
|
47
|
+
# The restart check must run against the *pre-existing* snapshot, before
|
|
48
|
+
# ensure_worker! can re-baseline it by booting a fresh worker: a dead
|
|
49
|
+
# worker plus a changed Gemfile must still surface
|
|
50
|
+
# :sidecar_restart_required rather than silently adopting the new bundle.
|
|
51
|
+
# There is nothing to compare against on the very first dispatch.
|
|
52
|
+
check_for_restart! if @snapshot
|
|
53
|
+
ensure_worker!
|
|
54
|
+
perform(command, args, timeout: timeout, retried: retried)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def perform(command, args, timeout:, retried:)
|
|
59
|
+
@client.request(command, args, timeout: timeout)
|
|
60
|
+
rescue Worker::Client::DisconnectedError
|
|
61
|
+
raise Coatepec::Error.new(:worker_disconnected, "Worker disconnected") if retried
|
|
62
|
+
|
|
63
|
+
restart_worker!
|
|
64
|
+
dispatch(command, args, timeout: timeout, retried: true)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def check_for_restart!
|
|
68
|
+
reason = @change_detector.restart_reason(@snapshot)
|
|
69
|
+
case reason
|
|
70
|
+
when :sidecar_restart_required
|
|
71
|
+
raise Coatepec::Error.new(:sidecar_restart_required, "Gemfile changed; restart Coatepec")
|
|
72
|
+
when :worker_restart_required
|
|
73
|
+
restart_worker!
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def ensure_worker!
|
|
78
|
+
restart_worker! unless @client&.alive?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def restart_worker!
|
|
82
|
+
@client&.stop
|
|
83
|
+
@client = Worker::Client.spawn(@project.root)
|
|
84
|
+
@snapshot = @change_detector.snapshot
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/coatepec.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "coatepec/version"
|
|
4
|
+
require_relative "coatepec/errors"
|
|
5
|
+
require_relative "coatepec/project_config"
|
|
6
|
+
require_relative "coatepec/project"
|
|
7
|
+
require_relative "coatepec/spec/path_policy"
|
|
8
|
+
require_relative "coatepec/protocol"
|
|
9
|
+
require_relative "coatepec/worker/change_detector"
|
|
10
|
+
require_relative "coatepec/worker/client"
|
|
11
|
+
require_relative "coatepec/worker/rails_runtime"
|
|
12
|
+
require_relative "coatepec/introspection/routes"
|
|
13
|
+
require_relative "coatepec/introspection/safe_options"
|
|
14
|
+
require_relative "coatepec/introspection/model"
|
|
15
|
+
require_relative "coatepec/spec/result"
|
|
16
|
+
require_relative "coatepec/spec/process_strategy"
|
|
17
|
+
require_relative "coatepec/spec/fork_strategy"
|
|
18
|
+
require_relative "coatepec/spec/spawn_strategy"
|
|
19
|
+
require_relative "coatepec/spec/guarded_fork_strategy"
|
|
20
|
+
require_relative "coatepec/spec/runner"
|
|
21
|
+
require_relative "coatepec/worker/server"
|
|
22
|
+
require_relative "coatepec/worker_manager"
|
|
23
|
+
|
|
24
|
+
# Coatepec is a local stdio MCP sidecar that keeps an isolated Rails test
|
|
25
|
+
# worker warm so coding agents can run targeted RSpec examples quickly,
|
|
26
|
+
# without exposing a general Rails console.
|
|
27
|
+
module Coatepec
|
|
28
|
+
end
|
data/sig/coatepec.rbs
ADDED