ask-ruby-harness 0.1.0 → 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: bd5a2b1d1916502e5eb5cc92220a4d06149817a05f2bf8ed00e8327630272881
4
- data.tar.gz: 847841b9d7fd758f8fb2c3da5d19e8b51414f3e3f2ee94c8e398e3231abe3b6a
3
+ metadata.gz: 411e139b4a5cfe5e017a748753c36a1f811a45f719e2245c7789267d4fe9f476
4
+ data.tar.gz: bd78de0520de347c8ffb675c7c704187c98fc7971b3b4a30770c227479adf7f3
5
5
  SHA512:
6
- metadata.gz: 604554da7aa7e84bd2d779cd68123645757d185000d62575694e51201ef1086e5419e48d24c70679546098a4352bf9c686184ff22725fec22d0995aafa6e02f2
7
- data.tar.gz: 9f4bdf2110e1a2dd490cf59c0d0af545726bec839dc64f59e7cdfe8e6438170750ebe6e2dcc1530cedf51586001b8d7a686897b2c46368eefd60c2a3a25a010f
6
+ metadata.gz: 4010a7f88a99c289dc68a1bbb40e7b8b829ac794b2a9daf609558128dad5b809f1a6900fae0d8d4c46d795ab69aa5802aa4cfde361256601c913d893f2551000
7
+ data.tar.gz: 6c0362992da2df7e54e0191fbc494c35c90641d538c9f2f7ea63e58b8a33a5d4a9248f32ee58def32d5db09547c83887dd598f7dbe524fc98b19a29c04a6f4bb
data/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ ## [0.2.1] — 2026-08-10
2
+
3
+ ### Fixed
4
+
5
+ - **Standalone DB connection guard** — `QueryDatabase` now checks whether a
6
+ connection spec is *defined* (pool presence) instead of `connected?`
7
+ (which stays false until the first checkout), so `establish_connection`
8
+ from `ASK_DATABASE_URL`/`database.yml` is actually honored.
9
+ - **Relative sqlite paths in `database.yml`** — resolved against the app
10
+ root (like Rails does) instead of the harness's cwd.
11
+
12
+ ## [0.2.0] — 2026-08-10
13
+
14
+ ### Added
15
+
16
+ - **Monorepo support in `run_tests`** — when the `file:` param points into a
17
+ subproject (a directory with its own Gemfile/Rakefile), the suite runs
18
+ there, so rake/rails resolve the subproject's tasks. Artifacts
19
+ (`tmp/test/.ask/`) live in the subproject too. (The MCP server for this
20
+ gem lives in the separate `ask-ruby-harness-mcp` gem.)
21
+
1
22
  ## [0.1.0] — 2026-08-10
2
23
 
3
24
  ### Added
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "active_support/notifications"
4
5
  require "active_support/core_ext/string/filters"
5
6
 
6
7
  module Ask
@@ -30,10 +30,10 @@ module Ask
30
30
  )
31
31
  end
32
32
 
33
- unless Ask::Ruby::Harness.database_connected?
33
+ unless Ask::Ruby::Harness.database_configured?
34
34
  Ask::Ruby::Harness.connect_database!
35
35
  end
36
- unless Ask::Ruby::Harness.database_connected?
36
+ unless Ask::Ruby::Harness.database_configured?
37
37
  return Ask::Result.failure(
38
38
  "Database not connected. Set ASK_DATABASE_URL or provide a config/database.yml."
39
39
  )
@@ -24,34 +24,38 @@ module Ask
24
24
 
25
25
  def execute(file: nil, name: nil, failed_only: false, timeout: DEFAULT_TIMEOUT)
26
26
  files = split_files(file)
27
- runner = detect_runner
27
+ # Monorepo support: a file inside a subproject (a dir with its
28
+ # own Gemfile/Rakefile) runs that project's suite.
29
+ run_root = resolve_run_root(files)
30
+ files = files.map { |f| rel_to_run_root(f, run_root) }
31
+ runner = detect_runner(run_root)
28
32
 
29
- failed_tests = failed_only ? load_failed_tests : nil
33
+ failed_tests = failed_only ? load_failed_tests(run_root) : nil
30
34
  if failed_only && failed_tests.empty?
31
35
  return Ask::Result.failure("No failed tests from the previous run to rerun.")
32
36
  end
33
37
 
34
- artifact_dir = app_root.join(*ARTIFACT_DIR).tap(&:mkpath)
38
+ artifact_dir = run_root.join(*ARTIFACT_DIR).tap(&:mkpath)
35
39
  log_path = artifact_dir.join("last-test.log")
36
40
  json_path = artifact_dir.join("last-test.json")
37
41
  status_path = artifact_dir.join("last-failures.json")
38
42
 
39
43
  command, env = build_command(runner, files, name, failed_tests, json_path)
40
- outcome = run(command, env, log_path, timeout)
44
+ outcome = run(command, env, log_path, timeout, run_root)
41
45
 
42
46
  results = parse_results(runner, json_path)
43
47
  unless results
44
48
  # A killed run can't produce results — report it structurally
45
49
  # instead of failing, so the agent still gets the artifact path.
46
- return Ask::Result.ok(data: timed_out_report(runner, command, env, outcome, status_path, log_path)) if outcome[:timed_out]
50
+ return Ask::Result.ok(data: timed_out_report(runner, command, env, outcome, status_path, log_path, run_root)) if outcome[:timed_out]
47
51
 
48
52
  return Ask::Result.failure(
49
53
  "Test run finished without machine-readable results (#{runner}); " \
50
- "full output at #{rel(log_path)}"
54
+ "full output at #{rel(log_path, run_root)}"
51
55
  )
52
56
  end
53
57
 
54
- report = build_report(runner, command, env, outcome, results, status_path, log_path)
58
+ report = build_report(runner, command, env, outcome, results, status_path, log_path, run_root)
55
59
  Ask::Result.ok(data: report)
56
60
  end
57
61
 
@@ -64,14 +68,39 @@ module Ask
64
68
 
65
69
  # Rails app → bin/rails test; rspec in the bundle with a spec/ dir →
66
70
  # rspec; anything else → minitest via rake test.
67
- def detect_runner
68
- return :rails if app_root.join("bin", "rails").exist?
69
- lockfile = app_root.join("Gemfile.lock")
71
+ def detect_runner(run_root)
72
+ return :rails if run_root.join("bin", "rails").exist?
73
+ lockfile = run_root.join("Gemfile.lock")
70
74
  rspec = lockfile.exist? && lockfile.read.include?("rspec")
71
- return :rspec if rspec && app_root.join("spec").directory?
75
+ return :rspec if rspec && run_root.join("spec").directory?
72
76
  :minitest
73
77
  end
74
78
 
79
+ # --- monorepo support -------------------------------------------
80
+
81
+ # For a file inside a subproject of a monorepo (a dir with its own
82
+ # Gemfile/Rakefile), run the suite there so rake/rails resolve the
83
+ # subproject's tasks. Only when ALL files share the same subproject;
84
+ # otherwise (or at the app root itself) fall back to app_root.
85
+ def resolve_run_root(files)
86
+ roots = files.map { |f| project_root_for(f) }.uniq
87
+ roots.size == 1 && !roots.first.nil? ? roots.first : app_root
88
+ end
89
+
90
+ def project_root_for(file)
91
+ dir = File.expand_path(File.dirname(file), app_root)
92
+ root = app_root.to_s
93
+ while dir != root && dir.start_with?(root + File::SEPARATOR)
94
+ return Pathname.new(dir) if %w[Gemfile Rakefile].any? { |n| File.exist?(File.join(dir, n)) }
95
+ dir = File.dirname(dir)
96
+ end
97
+ nil
98
+ end
99
+
100
+ def rel_to_run_root(file, run_root)
101
+ Pathname.new(File.expand_path(file, app_root)).relative_path_from(run_root).to_s
102
+ end
103
+
75
104
  def build_command(runner, files, name, failed_tests, json_path)
76
105
  case runner
77
106
  when :rspec
@@ -129,7 +158,7 @@ module Ask
129
158
  "/#{escaped.join('|')}/"
130
159
  end
131
160
 
132
- def run(command, env, log_path, timeout)
161
+ def run(command, env, log_path, timeout, run_root)
133
162
  # The harness server may run with a deliberately small pool
134
163
  # (e.g. RAILS_MAX_THREADS=1 in its MCP config). Test runs are a
135
164
  # separate concern — let them use the app's normal pool sizes and
@@ -139,7 +168,7 @@ module Ask
139
168
  # would otherwise hijack it).
140
169
  child_env = env.merge("RAILS_MAX_THREADS" => nil)
141
170
  ENV.each_key { |k| child_env[k] = nil if k.start_with?("BUNDLE") }
142
- pid = Process.spawn(child_env, *command, chdir: app_root.to_s,
171
+ pid = Process.spawn(child_env, *command, chdir: run_root.to_s,
143
172
  out: [log_path.to_s, "w"], err: [:child, :out])
144
173
  status = nil
145
174
  timed_out = false
@@ -159,7 +188,7 @@ module Ask
159
188
  { exit_status: status&.exitstatus, timed_out: timed_out }
160
189
  end
161
190
 
162
- def build_report(runner, command, env, outcome, results, status_path, log_path)
191
+ def build_report(runner, command, env, outcome, results, status_path, log_path, run_root)
163
192
  summary = results[:summary]
164
193
  failed_tests = results[:failed_tests]
165
194
  persist_failed_tests(runner, failed_tests, status_path)
@@ -171,12 +200,12 @@ module Ask
171
200
  timed_out: outcome[:timed_out],
172
201
  summary: summary,
173
202
  failed_tests: failed_tests,
174
- artifact: rel(log_path),
203
+ artifact: rel(log_path, run_root),
175
204
  next: summary[:failures] + summary[:errors] > 0 ? "run_tests(failed_only: true)" : nil
176
205
  }
177
206
  end
178
207
 
179
- def timed_out_report(runner, command, env, outcome, status_path, log_path)
208
+ def timed_out_report(runner, command, env, outcome, status_path, log_path, run_root)
180
209
  persist_failed_tests(runner, [], status_path)
181
210
  {
182
211
  framework: runner.to_s,
@@ -185,7 +214,7 @@ module Ask
185
214
  timed_out: true,
186
215
  summary: nil,
187
216
  failed_tests: nil,
188
- artifact: rel(log_path),
217
+ artifact: rel(log_path, run_root),
189
218
  next: nil
190
219
  }
191
220
  end
@@ -240,8 +269,8 @@ module Ask
240
269
  status_path.write(JSON.pretty_generate(framework: runner.to_s, failed_tests: failed_tests))
241
270
  end
242
271
 
243
- def load_failed_tests
244
- path = app_root.join(*ARTIFACT_DIR, "last-failures.json")
272
+ def load_failed_tests(run_root)
273
+ path = run_root.join(*ARTIFACT_DIR, "last-failures.json")
245
274
  return [] unless path.exist?
246
275
  JSON.parse(path.read).fetch("failed_tests", []).map { |t| symbolize_keys(t) }
247
276
  rescue JSON::ParserError
@@ -252,8 +281,8 @@ module Ask
252
281
  hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
253
282
  end
254
283
 
255
- def rel(path)
256
- path.relative_path_from(app_root).to_s
284
+ def rel(path, base = app_root)
285
+ path.relative_path_from(base).to_s
257
286
  end
258
287
  end
259
288
  end
@@ -3,7 +3,7 @@
3
3
  module Ask
4
4
  module Ruby
5
5
  module Harness
6
- VERSION = "0.1.0"
6
+ VERSION = "0.2.1"
7
7
  end
8
8
  end
9
9
  end
@@ -72,7 +72,7 @@ module Ask
72
72
  #
73
73
  # Connection sources, in order: ASK_DATABASE_URL, config/database.yml.
74
74
  def connect_database!
75
- return if database_connected?
75
+ return if database_configured?
76
76
 
77
77
  config = ENV["ASK_DATABASE_URL"] || database_config_from_yaml
78
78
  ActiveRecord::Base.establish_connection(config) if config
@@ -80,8 +80,17 @@ module Ask
80
80
  warn "[ask-ruby-harness] database connect failed: #{e.message}"
81
81
  end
82
82
 
83
- def database_connected?
84
- defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
83
+ # Whether a connection spec is defined. Uses the pool presence, not
84
+ # `connected?` ActiveRecord's connected? stays false until a
85
+ # connection is actually checked out, while pool.with_connection
86
+ # establishes lazily on first use.
87
+ def database_configured?
88
+ defined?(ActiveRecord::Base) &&
89
+ ActiveRecord::Base.connection_handler.retrieve_connection_pool(
90
+ ActiveRecord::Base.connection_specification_name
91
+ )
92
+ rescue StandardError
93
+ false
85
94
  end
86
95
 
87
96
  private
@@ -178,7 +187,14 @@ module Ask
178
187
  section = section["primary"] if section.is_a?(Hash) && section["primary"].is_a?(Hash)
179
188
  return nil unless section.is_a?(Hash)
180
189
 
181
- section.slice(*Configuration::DATABASE_CONFIG_KEYS).presence
190
+ config = section.slice(*Configuration::DATABASE_CONFIG_KEYS)
191
+ # Resolve relative sqlite paths against app_root, like Rails does —
192
+ # the harness may run with a different cwd than the project root.
193
+ if config["adapter"].to_s.include?("sqlite") &&
194
+ config["database"] && !config["database"].start_with?("/", ":")
195
+ config["database"] = app_root.join(config["database"]).to_s
196
+ end
197
+ config.presence
182
198
  rescue Psych::Exception
183
199
  nil
184
200
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-ruby-harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto