ask-rails-harness 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +42 -70
- data/lib/ask/rails/harness/audit_log.rb +4 -2
- data/lib/ask/rails/harness/minitest_json_reporter.rb +83 -0
- data/lib/ask/rails/harness/tools/query_database.rb +1 -0
- data/lib/ask/rails/harness/tools/read_log.rb +3 -1
- data/lib/ask/rails/harness/tools/run_tests.rb +245 -0
- data/lib/ask/rails/harness/version.rb +1 -1
- data/lib/ask/rails/harness.rb +5 -2
- data/lib/minitest/ask_rails_harness_plugin.rb +39 -0
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3215cf3ae063b9066a65877e5eb501b3daec422ac1a6639a9286b384b2af3e8b
|
|
4
|
+
data.tar.gz: ba4b4347b86fd7dd944bfc5ac9b2031140081717721737cb9fcdce51badea17b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0eac35130da6e2a11713105cf07856c381f540487a3654b7385e1414920671831e568de01eafd8437286bb9537573aa42ca1287a2bfd2c2114c5cbde9ca9a9da
|
|
7
|
+
data.tar.gz: 7bb18027c9b38131db8d706fbdb19601449de9444b870441cd31684cb962063b08ddc78fd4f7dcbbcdbab0b977f8f81f5fe94f39ec15d0efd2e240da76278236
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
+
## [0.2.0] — 2026-08-10
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **`run_tests` tool** — runs the app's test suite and returns structured
|
|
6
|
+
results (summary counts plus per-test file/line/message for failures)
|
|
7
|
+
instead of raw terminal output. Detects minitest vs rspec (`Gemfile.lock` +
|
|
8
|
+
`spec/`), supports `file:`/`name:` filters, `failed_only:` reruns persisted
|
|
9
|
+
from the previous run (`tmp/test/.ask/last-failures.json`), and a `timeout:`
|
|
10
|
+
that kills the run and reports `timed_out` with the artifact path. Minitest
|
|
11
|
+
gets machine-readable results via a bundled reporter/plugin; rspec uses its
|
|
12
|
+
built-in JSON formatter.
|
|
13
|
+
- **`MinitestJsonReporter` + minitest plugin** — `lib/minitest/ask_rails_harness_plugin.rb`
|
|
14
|
+
is auto-discovered by minitest and registers the JSON reporter only when
|
|
15
|
+
`ASK_TEST_JSON_PATH` is set (i.e. when the harness invoked the run), so
|
|
16
|
+
ordinary `bin/rails test` runs are untouched. Supports both the minitest 5
|
|
17
|
+
(`ask_rails_harness_plugin_init`) and minitest 6 (`plugin_ask_rails_harness_init`)
|
|
18
|
+
plugin init conventions. Full human output always lands at
|
|
19
|
+
`tmp/test/.ask/last-test.log`.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **`ReadLog` crashed with `NoMethodError: undefined method 'env' for module Ask::Rails`** —
|
|
24
|
+
`read_log.rb` used bare `Rails.env`, which resolves lexically to the
|
|
25
|
+
`Ask::Rails` module inside the gem's own namespace. Now `::Rails.env`
|
|
26
|
+
(matching `query_database.rb`).
|
|
27
|
+
- **Audit-log failure warnings were silently swallowed** — `audit_log.rb` used
|
|
28
|
+
the same bare-`Rails` lookup, so `defined?(Rails.logger)` was always false.
|
|
29
|
+
Now `::Rails.logger&.warn` with a nil-safe guard.
|
|
30
|
+
|
|
31
|
+
## [0.1.1] — 2026-07-31
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- Use `Ask::Agent::Policies::Permissions` for environment hooks (requires
|
|
36
|
+
ask-agent >= 0.28.0).
|
|
37
|
+
- Require `time` for time-stdlib methods (`iso8601`, `parse`).
|
|
38
|
+
|
|
1
39
|
## [0.1.0] — 2026-07-25
|
|
2
40
|
|
|
3
41
|
### Changed
|
data/README.md
CHANGED
|
@@ -1,24 +1,10 @@
|
|
|
1
1
|
# ask-rails-harness
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://badge.fury.io/rb/ask-rails-harness)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Admin AI copilot for Rails apps. Mounts a Rails Engine at `/ask` with an admin chat UI and 9 Rails-aware tools that inspect your code, query your database, read logs, and help you debug. For internal/admin use only: the agent has direct access to your database, file system, and shell.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
- **Rails developers** who want an AI co-pilot that understands their app's codebase, schema, routes, and logs
|
|
10
|
-
- **Internal/Admin use only** — the agent has direct access to your database, file system, and shell. Not for external/customer-facing use.
|
|
11
|
-
|
|
12
|
-
For building customer-facing AI agents, use `ask-agent` directly with your own tools and UI.
|
|
13
|
-
|
|
14
|
-
## What it gives you
|
|
15
|
-
|
|
16
|
-
- **9 Rails-aware tools**: `ReadFile`, `QueryDatabase`, `ReadRoutes`, `ReadModel`, `ReadLog`, `RunCommand`, `SearchCodebase`, `SchemaGraph`, `RouteInspector`
|
|
17
|
-
- **Admin chat UI**: Mount the engine, get a working chat at `/ask` with SSE streaming
|
|
18
|
-
- **Auth integration**: Protect `/ask` behind your existing Devise/authentication
|
|
19
|
-
- **AR persistence**: Agent sessions survive server restarts
|
|
20
|
-
- **Service discovery**: Auto-detects installed ask-\* service gems
|
|
21
|
-
- **Skills**: Built-in guides for Rails debugging, deployment, and database performance
|
|
7
|
+
Previously released as `ask-rails` (v0.1.0-0.11.1) and renamed to `ask-rails-harness`. For customer-facing agents, use [ask-agent](https://github.com/ask-rb/ask-agent) directly.
|
|
22
8
|
|
|
23
9
|
## Installation
|
|
24
10
|
|
|
@@ -27,36 +13,21 @@ bundle add ask-rails-harness
|
|
|
27
13
|
rails generate ask_rails_harness:install
|
|
28
14
|
```
|
|
29
15
|
|
|
30
|
-
|
|
16
|
+
The generator creates `config/initializers/ask_rails_harness.rb`, the `ask_sessions` and `ask_audit_logs` migrations, and an `app/tools/` directory. Requires Rails 7.1+.
|
|
31
17
|
|
|
32
|
-
|
|
18
|
+
Run the migration, then mount the engine in `config/routes.rb`:
|
|
33
19
|
|
|
34
20
|
```ruby
|
|
35
|
-
# config/routes.rb
|
|
36
21
|
Rails.application.routes.draw do
|
|
37
|
-
# ... your routes ...
|
|
38
|
-
|
|
39
22
|
authenticate :user, ->(u) { u.admin? } do
|
|
40
23
|
mount Ask::Rails::Harness::Engine, at: "/ask"
|
|
41
24
|
end
|
|
42
25
|
end
|
|
43
26
|
```
|
|
44
27
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
## Usage
|
|
48
|
-
|
|
49
|
-
### Configuration
|
|
50
|
-
|
|
51
|
-
```ruby
|
|
52
|
-
# config/initializers/ask_rails_harness.rb
|
|
53
|
-
Ask::Rails::Harness.configure do |c|
|
|
54
|
-
c.default_model = "claude-sonnet-4"
|
|
55
|
-
c.max_turns = 50
|
|
56
|
-
end
|
|
57
|
-
```
|
|
28
|
+
Visit `/ask` in your browser.
|
|
58
29
|
|
|
59
|
-
|
|
30
|
+
## Quick Start
|
|
60
31
|
|
|
61
32
|
```ruby
|
|
62
33
|
# From any controller, view, or job
|
|
@@ -64,58 +35,59 @@ session = Ask::Rails::Harness.agent_session
|
|
|
64
35
|
session.run("Find all open issues labeled 'bug' in our repo")
|
|
65
36
|
```
|
|
66
37
|
|
|
67
|
-
|
|
38
|
+
## Configuration
|
|
68
39
|
|
|
69
40
|
```ruby
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
41
|
+
# config/initializers/ask_rails_harness.rb
|
|
42
|
+
Ask::Rails::Harness.configure do |c|
|
|
43
|
+
c.default_model = "claude-sonnet-4"
|
|
44
|
+
c.max_turns = 50
|
|
45
|
+
c.tool_concurrency = 5
|
|
46
|
+
c.allowed_commands = [/^rails /]
|
|
47
|
+
c.denied_commands = [/rm/, /dropdb/]
|
|
48
|
+
c.max_session_age = 7.days
|
|
49
|
+
c.max_sessions = 100
|
|
50
|
+
c.environment :production do |env|
|
|
51
|
+
env.mode = :read_only
|
|
52
|
+
end
|
|
53
|
+
end
|
|
73
54
|
```
|
|
74
55
|
|
|
75
|
-
|
|
56
|
+
Available settings: `default_model`, `max_turns`, `tool_concurrency`, `system_prompt`, `persistence_adapter`, `current_user`, `allowed_commands`, `denied_commands`, `max_session_age`, `max_sessions`, and per-environment permissions via `environment(name) { |env| env.mode = ... }`.
|
|
76
57
|
|
|
77
|
-
|
|
58
|
+
## Essential API
|
|
78
59
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
Ask::Rails::Harness
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
60
|
+
- `Ask::Rails::Harness.agent_session`: build an agent session with the configured tools and prompt
|
|
61
|
+
- `Ask::Rails::Harness.configure { |c| ... }`: configuration (see above)
|
|
62
|
+
- `Ask::Rails::Harness.cleanup!`: prune old sessions and audit logs; also available as `rails ask_rails_harness:cleanup`
|
|
63
|
+
- `Ask::Rails::Harness::Persistence`: ActiveRecord session persistence backed by the `ask_sessions` table
|
|
64
|
+
- `Ask::Rails::Harness::Auth.check = -> { ... }`: auth proc evaluated in the controller context; the engine routes are unprotected by default
|
|
65
|
+
- Audit log: every tool call is recorded in `ask_audit_logs` and broadcast as the `audit_log.ask_rails_harness` ActiveSupport notification
|
|
85
66
|
|
|
86
67
|
## Tools
|
|
87
68
|
|
|
88
69
|
| Tool | What it does |
|
|
89
70
|
|---|---|
|
|
90
|
-
| `ReadFile` | Read any file
|
|
91
|
-
| `QueryDatabase` |
|
|
92
|
-
| `ReadModel` | Inspect
|
|
93
|
-
| `ReadRoutes` |
|
|
71
|
+
| `ReadFile` | Read any file relative to `Rails.root` |
|
|
72
|
+
| `QueryDatabase` | Read-only SQL (non-SELECT rejected in production) |
|
|
73
|
+
| `ReadModel` | Inspect an ActiveRecord model's columns, associations, validations |
|
|
74
|
+
| `ReadRoutes` | Read `config/routes.rb` |
|
|
94
75
|
| `ReadLog` | Read log files with level/search filtering |
|
|
95
76
|
| `RunCommand` | Run shell commands in the app root |
|
|
96
|
-
| `SearchCodebase` | Grep the codebase
|
|
97
|
-
| `SchemaGraph` | Full schema introspection
|
|
77
|
+
| `SearchCodebase` | Grep the codebase |
|
|
78
|
+
| `SchemaGraph` | Full schema introspection: models, tables, columns, associations |
|
|
98
79
|
| `RouteInspector` | Parsed route table with filters |
|
|
99
80
|
|
|
100
|
-
##
|
|
81
|
+
## Full documentation
|
|
101
82
|
|
|
102
|
-
|
|
103
|
-
GET /ask → Chat UI
|
|
104
|
-
POST /ask/sessions → Create new session
|
|
105
|
-
POST /ask/sessions/:id/messages → Send message (SSE streamed response)
|
|
106
|
-
GET /ask/sessions/:id/messages → Get message history
|
|
107
|
-
GET /ask/sessions/:id/stream → SSE stream for existing session
|
|
108
|
-
```
|
|
83
|
+
The full ask-rb documentation lives at https://ask-rb.github.io/ask-docs. [Rails setup](https://ask-rb.github.io/ask-docs/rails/setup) covers ask-rails-harness in depth. API reference: https://ask-rb.github.io/ask-docs/reference/api.
|
|
109
84
|
|
|
110
|
-
##
|
|
85
|
+
## Development
|
|
111
86
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
| Bring your own UI | Ships an admin chat UI |
|
|
117
|
-
| Any Ruby app | Rails apps only |
|
|
118
|
-
| General purpose | Development, debugging, ops |
|
|
87
|
+
```bash
|
|
88
|
+
bundle install
|
|
89
|
+
bundle exec rake test
|
|
90
|
+
```
|
|
119
91
|
|
|
120
92
|
## License
|
|
121
93
|
|
|
@@ -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
|
|
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
|
-
|
|
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,245 @@
|
|
|
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
|
+
pid = Process.spawn(env, *command, chdir: rails_root.to_s,
|
|
119
|
+
out: [log_path.to_s, "w"], err: [:child, :out])
|
|
120
|
+
status = nil
|
|
121
|
+
timed_out = false
|
|
122
|
+
begin
|
|
123
|
+
Timeout.timeout(timeout) { status = Process.wait2(pid).last }
|
|
124
|
+
rescue Timeout::Error
|
|
125
|
+
timed_out = true
|
|
126
|
+
begin
|
|
127
|
+
Process.kill("TERM", pid)
|
|
128
|
+
sleep 0.2
|
|
129
|
+
Process.kill("KILL", pid)
|
|
130
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
131
|
+
# Process already gone — nothing to kill.
|
|
132
|
+
end
|
|
133
|
+
status = Process.wait2(pid).last rescue nil
|
|
134
|
+
end
|
|
135
|
+
{ exit_status: status&.exitstatus, timed_out: timed_out }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def build_report(framework, command, env, outcome, results, status_path, log_path)
|
|
139
|
+
summary = results[:summary]
|
|
140
|
+
failed_tests = results[:failed_tests]
|
|
141
|
+
persist_failed_tests(framework, failed_tests, status_path)
|
|
142
|
+
|
|
143
|
+
{
|
|
144
|
+
framework: framework.to_s,
|
|
145
|
+
command: full_command(command, env),
|
|
146
|
+
exit_status: outcome[:exit_status],
|
|
147
|
+
timed_out: outcome[:timed_out],
|
|
148
|
+
summary: summary,
|
|
149
|
+
failed_tests: failed_tests,
|
|
150
|
+
artifact: rel(log_path),
|
|
151
|
+
next: summary[:failures] + summary[:errors] > 0 ? "run_tests(failed_only: true)" : nil
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def timed_out_report(framework, command, env, outcome, status_path, log_path)
|
|
156
|
+
persist_failed_tests(framework, [], status_path)
|
|
157
|
+
{
|
|
158
|
+
framework: framework.to_s,
|
|
159
|
+
command: full_command(command, env),
|
|
160
|
+
exit_status: outcome[:exit_status],
|
|
161
|
+
timed_out: true,
|
|
162
|
+
summary: nil,
|
|
163
|
+
failed_tests: nil,
|
|
164
|
+
artifact: rel(log_path),
|
|
165
|
+
next: nil
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The minitest options are part of the command itself (Rails passes
|
|
170
|
+
# CLI args through to minitest), so the reported command is the full
|
|
171
|
+
# invocation.
|
|
172
|
+
def full_command(command, env)
|
|
173
|
+
command.join(" ")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def parse_results(framework, json_path)
|
|
177
|
+
return nil unless json_path.exist?
|
|
178
|
+
payload = JSON.parse(json_path.read)
|
|
179
|
+
framework == :rspec ? parse_rspec(payload) : parse_minitest(payload)
|
|
180
|
+
rescue JSON::ParserError
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def parse_minitest(payload)
|
|
185
|
+
tests = payload["tests"] || []
|
|
186
|
+
summary = {
|
|
187
|
+
run: payload.fetch("run", tests.size),
|
|
188
|
+
failures: payload.fetch("failures", 0),
|
|
189
|
+
errors: payload.fetch("errors", 0),
|
|
190
|
+
skips: payload.fetch("skips", 0)
|
|
191
|
+
}
|
|
192
|
+
failed_tests = tests.filter_map do |t|
|
|
193
|
+
next unless %w[failed error].include?(t["status"])
|
|
194
|
+
{ file: t["file"], test_name: t["name"], line: t["line"], message: t["message"] }
|
|
195
|
+
end
|
|
196
|
+
{ summary: summary, failed_tests: failed_tests }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def parse_rspec(payload)
|
|
200
|
+
examples = payload["examples"] || []
|
|
201
|
+
summary_payload = payload["summary"] || {}
|
|
202
|
+
failed_examples = examples.select { |e| e["status"] == "failed" }
|
|
203
|
+
pending = examples.count { |e| e["status"] == "pending" }
|
|
204
|
+
summary = {
|
|
205
|
+
run: summary_payload.fetch("example_count", examples.size),
|
|
206
|
+
failures: summary_payload.fetch("failure_count", failed_examples.size),
|
|
207
|
+
errors: 0,
|
|
208
|
+
skips: summary_payload.fetch("pending_count", pending)
|
|
209
|
+
}
|
|
210
|
+
failed_tests = failed_examples.map do |e|
|
|
211
|
+
exception = e["exception"] || {}
|
|
212
|
+
{
|
|
213
|
+
file: e["file_path"],
|
|
214
|
+
test_name: e["full_description"],
|
|
215
|
+
line: e["line_number"],
|
|
216
|
+
message: exception["message"]
|
|
217
|
+
}
|
|
218
|
+
end
|
|
219
|
+
{ summary: summary, failed_tests: failed_tests }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def persist_failed_tests(framework, failed_tests, status_path)
|
|
223
|
+
status_path.write(JSON.pretty_generate(framework: framework.to_s, failed_tests: failed_tests))
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def load_failed_tests
|
|
227
|
+
path = rails_root.join(*ARTIFACT_DIR, "last-failures.json")
|
|
228
|
+
return [] unless path.exist?
|
|
229
|
+
JSON.parse(path.read).fetch("failed_tests", []).map { |t| symbolize_keys(t) }
|
|
230
|
+
rescue JSON::ParserError
|
|
231
|
+
[]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def symbolize_keys(hash)
|
|
235
|
+
hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def rel(path)
|
|
239
|
+
path.relative_path_from(rails_root).to_s
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
data/lib/ask/rails/harness.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "rails"
|
|
4
4
|
require "ask/agent"
|
|
5
5
|
require "ask/auth"
|
|
6
|
+
require "time"
|
|
6
7
|
|
|
7
8
|
module Ask
|
|
8
9
|
module Rails
|
|
@@ -64,7 +65,7 @@ module Ask
|
|
|
64
65
|
env_mode = configuration.effective_mode
|
|
65
66
|
return {} unless env_mode
|
|
66
67
|
|
|
67
|
-
perms = Ask::Agent::
|
|
68
|
+
perms = Ask::Agent::Policies::Permissions.new(mode: env_mode)
|
|
68
69
|
{ before_tool: [perms.method(:before_tool_call)] }
|
|
69
70
|
rescue ArgumentError => e
|
|
70
71
|
warn "[ask-rails-harness] Invalid environment mode: #{e.message}"
|
|
@@ -173,6 +174,8 @@ require_relative "harness/tools/read_model"
|
|
|
173
174
|
require_relative "harness/tools/read_log"
|
|
174
175
|
require_relative "harness/tools/schema_graph"
|
|
175
176
|
require_relative "harness/tools/route_inspector"
|
|
177
|
+
require_relative "harness/tools/run_tests"
|
|
178
|
+
require_relative "harness/minitest_json_reporter"
|
|
176
179
|
|
|
177
180
|
# Railtie is loaded only when Rails is fully available
|
|
178
181
|
if defined?(::Rails::Railtie)
|
|
@@ -185,5 +188,5 @@ Ask::Rails::Harness::CORE_RAILS_TOOLS = [
|
|
|
185
188
|
Ask::Rails::Harness::Tools::SearchCodebase, Ask::Rails::Harness::Tools::ReadRoutes,
|
|
186
189
|
Ask::Rails::Harness::Tools::QueryDatabase, Ask::Rails::Harness::Tools::ReadModel,
|
|
187
190
|
Ask::Rails::Harness::Tools::ReadLog, Ask::Rails::Harness::Tools::SchemaGraph,
|
|
188
|
-
Ask::Rails::Harness::Tools::RouteInspector
|
|
191
|
+
Ask::Rails::Harness::Tools::RouteInspector, Ask::Rails::Harness::Tools::RunTests
|
|
189
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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -57,14 +57,14 @@ dependencies:
|
|
|
57
57
|
requirements:
|
|
58
58
|
- - ">="
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version:
|
|
60
|
+
version: 0.28.0
|
|
61
61
|
type: :runtime
|
|
62
62
|
prerelease: false
|
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
64
|
requirements:
|
|
65
65
|
- - ">="
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
|
-
version:
|
|
67
|
+
version: 0.28.0
|
|
68
68
|
- !ruby/object:Gem::Dependency
|
|
69
69
|
name: ask-auth
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -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
|