ask-rails-harness 0.1.1 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fbfa4dd4bde386eb1600fd036a12cad723ad335365b46cfadd97e040707674e9
4
- data.tar.gz: a2af592dae8fec3fe8a6f4fefae6e252456b5dbbe510787fc86e7bc9066f60b2
3
+ metadata.gz: 6b7c1f9342c45ef3d4472772717fcc6ff302b35999e74806bd8964510f729f2f
4
+ data.tar.gz: 017c0f1b89629f435bde77c637c96e866caffb3be86f27d99adcf6431cc79cb3
5
5
  SHA512:
6
- metadata.gz: 3dfc9d467a70c966cb3f4400605c7625bb1792d4f5da096aaba508dae32cf09429e4da281b91b8022d390f5dfa54cd9c5dc495dd550e0aab0a3c3b5daa3ff32f
7
- data.tar.gz: 40589044b3cb0e691e08b036fdedc19ed8f3727f2e57af909a820e6ee2c442f2bde7c94313a8a18bd526a5b8ba9653e4acb8248342f81eed18477ba99a9c6624
6
+ metadata.gz: 4a3c0d99ae4b7f7725365827e4707fc56ca680bcee0fe7d0617c03bc7aa4e8c14039ee858016ad867648e4c5dec545acaec1b1b7934f6b3ed20d72d6a9d122db
7
+ data.tar.gz: 0b9c8b0b65b3baab555bde4c7e938c447b6b0527ea073a0abc3f9179d3ce8836e4f49221b601b20fd95446d33b40571f49f61bc25380baeba2cfe162a087b7bc
data/CHANGELOG.md CHANGED
@@ -1,3 +1,51 @@
1
+ ## [0.2.1] — 2026-08-10
2
+
3
+ ### Fixed
4
+
5
+ - **`run_tests` strips `RAILS_MAX_THREADS` for spawned test runs** — harness
6
+ servers may run with a deliberately small pool (e.g. `RAILS_MAX_THREADS=1`
7
+ in their MCP config). Without this, the cap leaked into `bin/rails test`
8
+ children, where it would serialize parallel tests on a single connection.
9
+ Test runs now always get the app's normal pool sizes.
10
+
11
+ ## [0.2.0] — 2026-08-10
12
+
13
+ ### Added
14
+
15
+ - **`run_tests` tool** — runs the app's test suite and returns structured
16
+ results (summary counts plus per-test file/line/message for failures)
17
+ instead of raw terminal output. Detects minitest vs rspec (`Gemfile.lock` +
18
+ `spec/`), supports `file:`/`name:` filters, `failed_only:` reruns persisted
19
+ from the previous run (`tmp/test/.ask/last-failures.json`), and a `timeout:`
20
+ that kills the run and reports `timed_out` with the artifact path. Minitest
21
+ gets machine-readable results via a bundled reporter/plugin; rspec uses its
22
+ built-in JSON formatter.
23
+ - **`MinitestJsonReporter` + minitest plugin** — `lib/minitest/ask_rails_harness_plugin.rb`
24
+ is auto-discovered by minitest and registers the JSON reporter only when
25
+ `ASK_TEST_JSON_PATH` is set (i.e. when the harness invoked the run), so
26
+ ordinary `bin/rails test` runs are untouched. Supports both the minitest 5
27
+ (`ask_rails_harness_plugin_init`) and minitest 6 (`plugin_ask_rails_harness_init`)
28
+ plugin init conventions. Full human output always lands at
29
+ `tmp/test/.ask/last-test.log`.
30
+
31
+ ### Fixed
32
+
33
+ - **`ReadLog` crashed with `NoMethodError: undefined method 'env' for module Ask::Rails`** —
34
+ `read_log.rb` used bare `Rails.env`, which resolves lexically to the
35
+ `Ask::Rails` module inside the gem's own namespace. Now `::Rails.env`
36
+ (matching `query_database.rb`).
37
+ - **Audit-log failure warnings were silently swallowed** — `audit_log.rb` used
38
+ the same bare-`Rails` lookup, so `defined?(Rails.logger)` was always false.
39
+ Now `::Rails.logger&.warn` with a nil-safe guard.
40
+
41
+ ## [0.1.1] — 2026-07-31
42
+
43
+ ### Changed
44
+
45
+ - Use `Ask::Agent::Policies::Permissions` for environment hooks (requires
46
+ ask-agent >= 0.28.0).
47
+ - Require `time` for time-stdlib methods (`iso8601`, `parse`).
48
+
1
49
  ## [0.1.0] — 2026-07-25
2
50
 
3
51
  ### Changed
@@ -166,8 +166,10 @@ module Ask
166
166
  VALUES (#{values.join(', ')})"
167
167
  )
168
168
  rescue StandardError => e
169
- # Silently fail — audit log should never crash the caller
170
- Rails.logger.warn("[ask-rails-harness] Audit log write failed: #{e.message}") if defined?(Rails.logger)
169
+ # Silently fail — audit log should never crash the caller.
170
+ # ::Rails avoids the bare-`Rails` constant resolving to Ask::Rails;
171
+ # `&.` guards against Rails.logger returning nil (no app booted).
172
+ ::Rails.logger&.warn("[ask-rails-harness] Audit log write failed: #{e.message}") if defined?(::Rails.logger)
171
173
  end
172
174
 
173
175
  # Reset cached table check (useful in tests)
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest"
4
+ require "json"
5
+
6
+ module Ask
7
+ module Rails
8
+ module Harness
9
+ # Writes a machine-readable JSON summary of a minitest run.
10
+ #
11
+ # Registered by the ask_rails_harness minitest plugin when the
12
+ # ASK_TEST_JSON_PATH env var is set (see the run_tests tool). Produces
13
+ # stable structured results — test name, klass, source file/line, status,
14
+ # and a message head — so the harness never has to parse terminal output.
15
+ #
16
+ # The reporter is inert unless ASK_TEST_JSON_PATH is set, so ordinary
17
+ # `bin/rails test` runs in apps that ship the harness are unaffected.
18
+ class MinitestJsonReporter < Minitest::AbstractReporter
19
+ def initialize(path = ENV["ASK_TEST_JSON_PATH"])
20
+ super()
21
+ @path = path
22
+ @results = []
23
+ end
24
+
25
+ def record(result)
26
+ @results << result
27
+ end
28
+
29
+ def report
30
+ return if @path.nil? || @path.to_s.empty?
31
+ File.write(@path, JSON.pretty_generate(build_report))
32
+ end
33
+
34
+ # Never influences the process exit status — pass/fail is decided by
35
+ # minitest's own summary reporter.
36
+ def passed?
37
+ true
38
+ end
39
+
40
+ private
41
+
42
+ def build_report
43
+ tests = @results.map { |result| test_entry(result) }
44
+ {
45
+ "framework" => "minitest",
46
+ "run" => tests.size,
47
+ "failures" => tests.count { |t| t["status"] == "failed" },
48
+ "errors" => tests.count { |t| t["status"] == "error" },
49
+ "skips" => tests.count { |t| t["status"] == "skipped" },
50
+ "tests" => tests
51
+ }
52
+ end
53
+
54
+ def test_entry(result)
55
+ file, line = result.source_location
56
+ failure = result.failure
57
+ {
58
+ "name" => result.name,
59
+ "klass" => result.klass,
60
+ "file" => file,
61
+ "line" => line,
62
+ "time" => result.time,
63
+ "status" => status_of(result),
64
+ "message" => failure ? message_of(failure) : nil
65
+ }
66
+ end
67
+
68
+ def status_of(result)
69
+ return "skipped" if result.skipped?
70
+ return "error" if result.error?
71
+ return "passed" if result.passed?
72
+ "failed"
73
+ end
74
+
75
+ # First lines of the failure message — enough for an agent to act on
76
+ # without dumping full backtraces into the report.
77
+ def message_of(failure)
78
+ failure.message.to_s.lines.first(3).map(&:strip).reject(&:empty?).join("\n")
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -50,7 +50,9 @@ module Ask
50
50
 
51
51
  def resolve_log_path(custom_path)
52
52
  return rails_root.join(custom_path) if custom_path
53
- rails_root.join("log", "#{Rails.env}.log")
53
+ # Use ::Rails (not Rails) — bare `Rails` inside the Ask::Rails::*
54
+ # namespace resolves to the Ask::Rails module itself.
55
+ rails_root.join("log", "#{::Rails.env}.log")
54
56
  end
55
57
 
56
58
  # Read from rotated archives too: log/production.log, .1, .2.gz, etc.
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "timeout"
5
+
6
+ module Ask
7
+ module Rails
8
+ module Harness
9
+ module Tools
10
+ class RunTests < Ask::Rails::Harness::Tool
11
+ description "Run the app's test suite and return structured results — summary counts " \
12
+ "plus per-test file/line/message for failures, never raw terminal output. " \
13
+ "Minitest gets a JSON reporter via the bundled minitest plugin; rspec uses " \
14
+ "its built-in JSON formatter. Rerun only the previous run's failures with " \
15
+ "failed_only."
16
+
17
+ param :file, type: :string, desc: "Test file path(s) relative to the app root (comma-separated for multiple)", required: false
18
+ param :name, type: :string, desc: "Test name pattern: minitest --name (string or /regex/), rspec -e", required: false
19
+ param :failed_only, type: :boolean, desc: "Rerun only the tests that failed in the previous run", required: false
20
+ param :timeout, type: :integer, desc: "Max seconds to wait before killing the run (default 300)", required: false
21
+
22
+ DEFAULT_TIMEOUT = 300
23
+ ARTIFACT_DIR = %w[tmp test .ask].freeze
24
+
25
+ def execute(file: nil, name: nil, failed_only: false, timeout: DEFAULT_TIMEOUT)
26
+ files = split_files(file)
27
+ framework = detect_framework
28
+
29
+ failed_tests = failed_only ? load_failed_tests : nil
30
+ if failed_only && failed_tests.empty?
31
+ return Ask::Result.failure("No failed tests from the previous run to rerun.")
32
+ end
33
+
34
+ artifact_dir = rails_root.join(*ARTIFACT_DIR).tap(&:mkpath)
35
+ log_path = artifact_dir.join("last-test.log")
36
+ json_path = artifact_dir.join("last-test.json")
37
+ status_path = artifact_dir.join("last-failures.json")
38
+
39
+ command, env = build_command(framework, files, name, failed_tests, json_path)
40
+ outcome = run(command, env, log_path, timeout)
41
+
42
+ results = parse_results(framework, json_path)
43
+ unless results
44
+ # A killed run can't produce results — report it structurally
45
+ # instead of failing, so the agent still gets the artifact path.
46
+ return Ask::Result.ok(data: timed_out_report(framework, command, env, outcome, status_path, log_path)) if outcome[:timed_out]
47
+
48
+ return Ask::Result.failure(
49
+ "Test run finished without machine-readable results (#{framework}); " \
50
+ "full output at #{rel(log_path)}"
51
+ )
52
+ end
53
+
54
+ report = build_report(framework, command, env, outcome, results, status_path, log_path)
55
+ Ask::Result.ok(data: report)
56
+ end
57
+
58
+ private
59
+
60
+ def split_files(file)
61
+ return [] if file.nil? || file.to_s.strip.empty?
62
+ file.split(",").map(&:strip).reject(&:empty?)
63
+ end
64
+
65
+ # Prefer rspec when it's in the bundle AND a spec/ dir exists;
66
+ # otherwise assume minitest (the Rails default).
67
+ def detect_framework
68
+ lockfile = rails_root.join("Gemfile.lock")
69
+ rspec = lockfile.exist? && lockfile.read.include?("rspec-rails")
70
+ rspec && rails_root.join("spec").directory? ? :rspec : :minitest
71
+ end
72
+
73
+ def build_command(framework, files, name, failed_tests, json_path)
74
+ if framework == :rspec
75
+ args = ["bundle", "exec", "rspec"]
76
+ args.concat(files)
77
+ args.concat(failed_tests.map { |t| "#{t[:file]}:#{t[:line]}" }) if failed_tests
78
+ args.concat(["-e", name]) if name
79
+ args.concat(["--format", "json", "--out", json_path.to_s])
80
+ [args, {}]
81
+ else
82
+ # Rails' `rails test` passes CLI args straight to minitest (files
83
+ # are required by Rails' runner, options parsed by minitest). The
84
+ # JSON reporter is injected via RUBYOPT: minitest 6 dropped both
85
+ # plugin auto-discovery and the -r option, so the plugin file is
86
+ # required by absolute path at process start (it pushes its
87
+ # extension; init_plugins registers the reporter later).
88
+ args = ["bin/rails", "test"]
89
+ args.concat(files)
90
+ args.concat(["-n", name]) if name
91
+ args.concat(["-n", name_pattern(failed_tests)]) if failed_tests
92
+ # minitest 6 dropped both plugin auto-discovery and the -r
93
+ # option. Activate the app's bundle first, then require the
94
+ # plugin by absolute path — it pushes its extension, and
95
+ # init_plugins registers the JSON reporter later.
96
+ env = {
97
+ "ASK_TEST_JSON_PATH" => json_path.to_s,
98
+ "RUBYOPT" => "-rbundler/setup -r#{minitest_plugin_path}"
99
+ }
100
+ [args, env]
101
+ end
102
+ end
103
+
104
+ def minitest_plugin_path
105
+ spec = Gem.loaded_specs["ask-rails-harness"]
106
+ spec ||= Gem::Specification.find_by_name("ask-rails-harness")
107
+ File.join(spec.full_gem_path, "lib", "minitest", "ask_rails_harness_plugin.rb")
108
+ end
109
+
110
+ # Minitest --name accepts a regexp; alternation runs exactly the
111
+ # failed tests.
112
+ def name_pattern(failed_tests)
113
+ escaped = failed_tests.map { |t| Regexp.escape(t[:test_name]) }
114
+ "/#{escaped.join('|')}/"
115
+ end
116
+
117
+ def run(command, env, log_path, timeout)
118
+ # The harness server may run with a deliberately small pool
119
+ # (e.g. RAILS_MAX_THREADS=1 in its MCP config). Test runs are a
120
+ # separate concern — let them use the app's normal pool sizes.
121
+ child_env = env.merge("RAILS_MAX_THREADS" => nil)
122
+ pid = Process.spawn(child_env, *command, chdir: rails_root.to_s,
123
+ out: [log_path.to_s, "w"], err: [:child, :out])
124
+ status = nil
125
+ timed_out = false
126
+ begin
127
+ Timeout.timeout(timeout) { status = Process.wait2(pid).last }
128
+ rescue Timeout::Error
129
+ timed_out = true
130
+ begin
131
+ Process.kill("TERM", pid)
132
+ sleep 0.2
133
+ Process.kill("KILL", pid)
134
+ rescue Errno::ESRCH, Errno::EPERM
135
+ # Process already gone — nothing to kill.
136
+ end
137
+ status = Process.wait2(pid).last rescue nil
138
+ end
139
+ { exit_status: status&.exitstatus, timed_out: timed_out }
140
+ end
141
+
142
+ def build_report(framework, command, env, outcome, results, status_path, log_path)
143
+ summary = results[:summary]
144
+ failed_tests = results[:failed_tests]
145
+ persist_failed_tests(framework, failed_tests, status_path)
146
+
147
+ {
148
+ framework: framework.to_s,
149
+ command: full_command(command, env),
150
+ exit_status: outcome[:exit_status],
151
+ timed_out: outcome[:timed_out],
152
+ summary: summary,
153
+ failed_tests: failed_tests,
154
+ artifact: rel(log_path),
155
+ next: summary[:failures] + summary[:errors] > 0 ? "run_tests(failed_only: true)" : nil
156
+ }
157
+ end
158
+
159
+ def timed_out_report(framework, command, env, outcome, status_path, log_path)
160
+ persist_failed_tests(framework, [], status_path)
161
+ {
162
+ framework: framework.to_s,
163
+ command: full_command(command, env),
164
+ exit_status: outcome[:exit_status],
165
+ timed_out: true,
166
+ summary: nil,
167
+ failed_tests: nil,
168
+ artifact: rel(log_path),
169
+ next: nil
170
+ }
171
+ end
172
+
173
+ # The minitest options are part of the command itself (Rails passes
174
+ # CLI args through to minitest), so the reported command is the full
175
+ # invocation.
176
+ def full_command(command, env)
177
+ command.join(" ")
178
+ end
179
+
180
+ def parse_results(framework, json_path)
181
+ return nil unless json_path.exist?
182
+ payload = JSON.parse(json_path.read)
183
+ framework == :rspec ? parse_rspec(payload) : parse_minitest(payload)
184
+ rescue JSON::ParserError
185
+ nil
186
+ end
187
+
188
+ def parse_minitest(payload)
189
+ tests = payload["tests"] || []
190
+ summary = {
191
+ run: payload.fetch("run", tests.size),
192
+ failures: payload.fetch("failures", 0),
193
+ errors: payload.fetch("errors", 0),
194
+ skips: payload.fetch("skips", 0)
195
+ }
196
+ failed_tests = tests.filter_map do |t|
197
+ next unless %w[failed error].include?(t["status"])
198
+ { file: t["file"], test_name: t["name"], line: t["line"], message: t["message"] }
199
+ end
200
+ { summary: summary, failed_tests: failed_tests }
201
+ end
202
+
203
+ def parse_rspec(payload)
204
+ examples = payload["examples"] || []
205
+ summary_payload = payload["summary"] || {}
206
+ failed_examples = examples.select { |e| e["status"] == "failed" }
207
+ pending = examples.count { |e| e["status"] == "pending" }
208
+ summary = {
209
+ run: summary_payload.fetch("example_count", examples.size),
210
+ failures: summary_payload.fetch("failure_count", failed_examples.size),
211
+ errors: 0,
212
+ skips: summary_payload.fetch("pending_count", pending)
213
+ }
214
+ failed_tests = failed_examples.map do |e|
215
+ exception = e["exception"] || {}
216
+ {
217
+ file: e["file_path"],
218
+ test_name: e["full_description"],
219
+ line: e["line_number"],
220
+ message: exception["message"]
221
+ }
222
+ end
223
+ { summary: summary, failed_tests: failed_tests }
224
+ end
225
+
226
+ def persist_failed_tests(framework, failed_tests, status_path)
227
+ status_path.write(JSON.pretty_generate(framework: framework.to_s, failed_tests: failed_tests))
228
+ end
229
+
230
+ def load_failed_tests
231
+ path = rails_root.join(*ARTIFACT_DIR, "last-failures.json")
232
+ return [] unless path.exist?
233
+ JSON.parse(path.read).fetch("failed_tests", []).map { |t| symbolize_keys(t) }
234
+ rescue JSON::ParserError
235
+ []
236
+ end
237
+
238
+ def symbolize_keys(hash)
239
+ hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
240
+ end
241
+
242
+ def rel(path)
243
+ path.relative_path_from(rails_root).to_s
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
@@ -3,7 +3,7 @@
3
3
  module Ask
4
4
  module Rails
5
5
  module Harness
6
- VERSION = "0.1.1"
6
+ VERSION = "0.2.1"
7
7
  end
8
8
  end
9
9
  end
@@ -174,6 +174,8 @@ require_relative "harness/tools/read_model"
174
174
  require_relative "harness/tools/read_log"
175
175
  require_relative "harness/tools/schema_graph"
176
176
  require_relative "harness/tools/route_inspector"
177
+ require_relative "harness/tools/run_tests"
178
+ require_relative "harness/minitest_json_reporter"
177
179
 
178
180
  # Railtie is loaded only when Rails is fully available
179
181
  if defined?(::Rails::Railtie)
@@ -186,5 +188,5 @@ Ask::Rails::Harness::CORE_RAILS_TOOLS = [
186
188
  Ask::Rails::Harness::Tools::SearchCodebase, Ask::Rails::Harness::Tools::ReadRoutes,
187
189
  Ask::Rails::Harness::Tools::QueryDatabase, Ask::Rails::Harness::Tools::ReadModel,
188
190
  Ask::Rails::Harness::Tools::ReadLog, Ask::Rails::Harness::Tools::SchemaGraph,
189
- Ask::Rails::Harness::Tools::RouteInspector
191
+ Ask::Rails::Harness::Tools::RouteInspector, Ask::Rails::Harness::Tools::RunTests
190
192
  ].freeze
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Minitest plugin for ask-rails-harness.
4
+ #
5
+ # Registers the JSON reporter when a run was started by the harness's
6
+ # run_tests tool (ASK_TEST_JSON_PATH set); ordinary test runs are untouched.
7
+ #
8
+ # Loading differs by minitest version:
9
+ # - minitest 5 auto-discovers `minitest/*_plugin.rb` files and calls
10
+ # init_plugins with `#{name}_plugin_init`.
11
+ # - minitest 6 dropped auto-discovery (and the -r option); the run_tests
12
+ # tool injects this file via RUBYOPT="-r <absolute path>" and
13
+ # init_plugins dispatches `plugin_#{name}_init` instead.
14
+ # Either way, pushing the extension below (idempotent) makes init_plugins
15
+ # dispatch to the matching init method after the composite reporter exists —
16
+ # the only reliable point to append a reporter.
17
+ # __dir__-based requires keep the file loadable before Bundler.setup.
18
+ require File.expand_path("../ask/rails/harness/minitest_json_reporter", __dir__)
19
+
20
+ module Minitest
21
+ # Minitest 6 convention: init_plugins calls plugin_#{name}_init.
22
+ def self.plugin_ask_rails_harness_init(options)
23
+ register_ask_rails_harness_json_reporter(options)
24
+ end
25
+
26
+ # Minitest 5 convention: init_plugins calls #{name}_plugin_init.
27
+ def self.ask_rails_harness_plugin_init(options)
28
+ register_ask_rails_harness_json_reporter(options)
29
+ end
30
+
31
+ def self.register_ask_rails_harness_json_reporter(_options)
32
+ path = ENV["ASK_TEST_JSON_PATH"]
33
+ return if path.nil? || path.to_s.empty?
34
+
35
+ Minitest.reporter << Ask::Rails::Harness::MinitestJsonReporter.new(path)
36
+ end
37
+ end
38
+
39
+ Minitest.extensions << "ask_rails_harness" unless Minitest.extensions.include?("ask_rails_harness")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-rails-harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -160,6 +160,7 @@ files:
160
160
  - lib/ask/rails/harness/configuration.rb
161
161
  - lib/ask/rails/harness/engine.rb
162
162
  - lib/ask/rails/harness/environment_permissions.rb
163
+ - lib/ask/rails/harness/minitest_json_reporter.rb
163
164
  - lib/ask/rails/harness/persistence.rb
164
165
  - lib/ask/rails/harness/railtie.rb
165
166
  - lib/ask/rails/harness/service_discovery.rb
@@ -171,6 +172,7 @@ files:
171
172
  - lib/ask/rails/harness/tools/read_routes.rb
172
173
  - lib/ask/rails/harness/tools/route_inspector.rb
173
174
  - lib/ask/rails/harness/tools/run_command.rb
175
+ - lib/ask/rails/harness/tools/run_tests.rb
174
176
  - lib/ask/rails/harness/tools/schema_graph.rb
175
177
  - lib/ask/rails/harness/tools/search_codebase.rb
176
178
  - lib/ask/rails/harness/version.rb
@@ -181,6 +183,7 @@ files:
181
183
  - lib/generators/ask/rails/harness/install/templates/audit_log_migration.rb
182
184
  - lib/generators/ask/rails/harness/install/templates/initializer.rb
183
185
  - lib/generators/ask/rails/harness/install/templates/migration.rb
186
+ - lib/minitest/ask_rails_harness_plugin.rb
184
187
  homepage: https://github.com/ask-rb/ask-rails-harness
185
188
  licenses:
186
189
  - MIT