mutineer-mongoid 2.0.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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +1194 -0
  3. data/LICENSE +21 -0
  4. data/README.md +659 -0
  5. data/bin/mutineer +6 -0
  6. data/lib/mutineer/baseline.rb +185 -0
  7. data/lib/mutineer/changed_lines.rb +136 -0
  8. data/lib/mutineer/child_stdout.rb +35 -0
  9. data/lib/mutineer/cli.rb +681 -0
  10. data/lib/mutineer/config.rb +372 -0
  11. data/lib/mutineer/coverage_map.rb +1366 -0
  12. data/lib/mutineer/daemon_backend.rb +351 -0
  13. data/lib/mutineer/daemon_client.rb +407 -0
  14. data/lib/mutineer/daemon_server.rb +351 -0
  15. data/lib/mutineer/external_backend.rb +288 -0
  16. data/lib/mutineer/file_swap.rb +194 -0
  17. data/lib/mutineer/isolation.rb +321 -0
  18. data/lib/mutineer/job_plan.rb +352 -0
  19. data/lib/mutineer/kill_channel.rb +284 -0
  20. data/lib/mutineer/kill_matrix.rb +88 -0
  21. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  22. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  23. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +145 -0
  24. data/lib/mutineer/minitest_integration.rb +137 -0
  25. data/lib/mutineer/mongoid_failures.rb +82 -0
  26. data/lib/mutineer/mongoid_worker_db.rb +97 -0
  27. data/lib/mutineer/mutant_id.rb +126 -0
  28. data/lib/mutineer/mutation.rb +27 -0
  29. data/lib/mutineer/mutator_registry.rb +109 -0
  30. data/lib/mutineer/mutators/arithmetic.rb +35 -0
  31. data/lib/mutineer/mutators/array_literal.rb +44 -0
  32. data/lib/mutineer/mutators/base.rb +105 -0
  33. data/lib/mutineer/mutators/boolean_connector.rb +52 -0
  34. data/lib/mutineer/mutators/boolean_literal.rb +56 -0
  35. data/lib/mutineer/mutators/chain_link.rb +132 -0
  36. data/lib/mutineer/mutators/collection_method.rb +44 -0
  37. data/lib/mutineer/mutators/comparison.rb +35 -0
  38. data/lib/mutineer/mutators/condition_forcing.rb +134 -0
  39. data/lib/mutineer/mutators/condition_negation.rb +49 -0
  40. data/lib/mutineer/mutators/literal_mutation.rb +51 -0
  41. data/lib/mutineer/mutators/negation_removal.rb +44 -0
  42. data/lib/mutineer/mutators/operand_removal.rb +58 -0
  43. data/lib/mutineer/mutators/operator_assignment.rb +113 -0
  44. data/lib/mutineer/mutators/range_literal.rb +54 -0
  45. data/lib/mutineer/mutators/regex_literal.rb +72 -0
  46. data/lib/mutineer/mutators/return_nil.rb +72 -0
  47. data/lib/mutineer/mutators/safe_navigation.rb +76 -0
  48. data/lib/mutineer/mutators/statement_removal.rb +33 -0
  49. data/lib/mutineer/mutators/string_literal.rb +34 -0
  50. data/lib/mutineer/orphan_guard.rb +28 -0
  51. data/lib/mutineer/pairing.rb +298 -0
  52. data/lib/mutineer/parser.rb +45 -0
  53. data/lib/mutineer/progress.rb +46 -0
  54. data/lib/mutineer/project.rb +506 -0
  55. data/lib/mutineer/project_path.rb +57 -0
  56. data/lib/mutineer/rails_worker_db.rb +212 -0
  57. data/lib/mutineer/reporter.rb +926 -0
  58. data/lib/mutineer/result.rb +240 -0
  59. data/lib/mutineer/runner.rb +418 -0
  60. data/lib/mutineer/statement_lines.rb +183 -0
  61. data/lib/mutineer/subject.rb +38 -0
  62. data/lib/mutineer/test_runners/minitest.rb +22 -0
  63. data/lib/mutineer/test_runners/rspec.rb +330 -0
  64. data/lib/mutineer/test_runners.rb +30 -0
  65. data/lib/mutineer/version.rb +6 -0
  66. data/lib/mutineer/worker_pool.rb +147 -0
  67. data/lib/mutineer.rb +48 -0
  68. metadata +143 -0
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Mutineer
6
+ # Per-worker database isolation for the daemon path.
7
+ #
8
+ # Loaded APP-SIDE by {DaemonServer} (a sibling gem file, pulled in by absolute
9
+ # path so it bypasses the app bundle, the same trick {DaemonClient} uses to run
10
+ # `daemon_server.rb` under a bundle that has no mutineer). It uses the app's
11
+ # OWN already-booted ActiveRecord and NEVER `require "active_record"`: every
12
+ # method that touches AR first confirms {available?}, so the daemon core stays
13
+ # framework-agnostic and the gem keeps its zero-runtime-dependency promise.
14
+ #
15
+ # Isolation model: each parallel worker gets its OWN database so concurrent
16
+ # forks cannot clobber each other's transactional fixtures. {after_fork} runs
17
+ # inside a freshly-forked child and points that child's connection at the
18
+ # worker's database BEFORE any test loads; transactional fixtures then
19
+ # repopulate that isolated database per test. On a slot's first use the worker
20
+ # database starts as a copy of the base test database, so it holds what the
21
+ # booted parent wrote, as the in-process backend sees it.
22
+ #
23
+ # Scope: SQLite adapter only (per-worker file, hermetic). Postgres per-worker
24
+ # DBs (`CREATE DATABASE <db>-<worker>`) are not implemented yet; a non-SQLite
25
+ # config raises a clear NotImplementedError rather than silently mis-routing.
26
+ #
27
+ # Routing failures surface as `error` via {verify_connection!}. Tagging an
28
+ # in-test DB failure as `error` (not `killed`) is only observable under
29
+ # concurrent load and is not yet implemented.
30
+ module RailsWorkerDb
31
+ # True when the app has ActiveRecord loaded. The only condition under which
32
+ # any other method here may touch AR. Never triggers an autoload/require of
33
+ # AR itself.
34
+ #
35
+ # @return [Boolean]
36
+ def self.available?
37
+ defined?(ActiveRecord::Base) ? true : false
38
+ end
39
+
40
+ # Derive a per-worker database path from a base path by inserting `-<worker>`
41
+ # before the extension. Pure string transform (no AR) so it is unit-testable
42
+ # in the zero-dep suite. `storage/test.sqlite3`, worker 1 ->
43
+ # `storage/test-1.sqlite3`.
44
+ #
45
+ # @param database [String] the base database path.
46
+ # @param worker [Integer] the worker slot index (0..N-1).
47
+ # @return [String] the per-worker database path.
48
+ def self.worker_database_path(database, worker)
49
+ ext = File.extname(database)
50
+ "#{database.delete_suffix(ext)}-#{worker}#{ext}"
51
+ end
52
+
53
+ # Build the AR connection config for one worker by copying the app's current
54
+ # (default test) config and swapping in the per-worker database path. SQLite
55
+ # only this pass: a non-SQLite adapter raises so the SQLite-first scope fails
56
+ # loud instead of mis-routing.
57
+ #
58
+ # @param worker [Integer] the worker slot index.
59
+ # @return [Hash] a symbol-keyed AR configuration hash for the worker database.
60
+ # @raise [NotImplementedError] when the app's database is non-SQLite or in-memory.
61
+ def self.worker_db_config(worker)
62
+ hash = ActiveRecord::Base.connection_db_config.configuration_hash
63
+ adapter = hash[:adapter].to_s
64
+ # Config shaping (per_worker_config) is already adapter-general: it derives
65
+ # correct SQLite and Postgres worker-DB names. What is gated is runtime
66
+ # provisioning: SQLite files are created on connect, but Postgres needs an
67
+ # explicit `CREATE DATABASE` per worker. Until that lands, refuse non-SQLite
68
+ # loudly rather than route to a database that does not exist.
69
+ unless adapter.start_with?("sqlite")
70
+ raise NotImplementedError,
71
+ "worker-DB isolation currently provisions SQLite only (got adapter #{adapter.inspect}); " \
72
+ "Postgres per-worker provisioning is not yet supported. Use a SQLite test DB with --daemon, or drop --daemon to run serially."
73
+ end
74
+
75
+ per_worker_config(hash, worker)
76
+ end
77
+
78
+ # Pure config-shaping (no AR): given a connection config hash, return the
79
+ # per-worker variant with its database swapped to the worker's own name.
80
+ # Adapter-general: SQLite (`storage/test.sqlite3` -> `storage/test-<w>.sqlite3`)
81
+ # and Postgres (`myapp_test` -> `myapp_test-<w>`, Rails `parallelize` naming)
82
+ # both fall out of {worker_database_path}. Extracted and unit-tested so the
83
+ # Postgres shape is proven ready without a live database.
84
+ #
85
+ # @param config_hash [Hash] a connection config hash (symbol or string keys).
86
+ # @param worker [Integer] the worker slot index.
87
+ # @return [Hash] the per-worker config (symbol keys), database swapped.
88
+ # @raise [NotImplementedError] for an in-memory or empty database (no per-worker split).
89
+ def self.per_worker_config(config_hash, worker)
90
+ hash = config_hash.transform_keys(&:to_sym)
91
+ database = hash[:database].to_s
92
+ if database.empty? || database == ":memory:"
93
+ raise NotImplementedError,
94
+ "worker-DB isolation needs a file/name-backed database (got #{database.inspect})."
95
+ end
96
+
97
+ hash.merge(database: worker_database_path(database, worker))
98
+ end
99
+
100
+ # Child-side (after fork): route this process's ActiveRecord at the worker's
101
+ # own database and confirm it is reachable, so a routing failure reads as
102
+ # `error` (via the daemon's child rescue) rather than a false verdict.
103
+ #
104
+ # With `seed: true` (the slot's first use) the worker database first becomes
105
+ # a copy of the base test database, schema and rows, so rows the daemon
106
+ # parent wrote while it booted (initializers, `--require` files) are there,
107
+ # as they are for the in-process backend (#222). The schema is then loaded
108
+ # only when the copy differs from `schema.rb` ({schema_current?}: schema
109
+ # version or stored `schema_sha1`, as in a stale or empty base database):
110
+ # `schema.rb` runs with `force: true`, which drops the copied rows of the
111
+ # tables it defines.
112
+ #
113
+ # @param worker [Integer] the worker slot index.
114
+ # @param schema_path [String, nil] absolute path to `db/schema.rb`, or nil to skip.
115
+ # @param seed [Boolean] copy the base database into the worker database first.
116
+ # @return [void]
117
+ def self.after_fork(worker, schema_path = nil, seed: false)
118
+ return unless available?
119
+
120
+ config = worker_db_config(worker)
121
+ base = ActiveRecord::Base.connection.select_value("SELECT file FROM pragma_database_list WHERE name = 'main'") if seed
122
+ ActiveRecord::Base.establish_connection(config)
123
+ seed_from(base) if seed
124
+ load_schema(schema_path) if schema_path && !schema_current?(schema_path)
125
+ verify_connection!
126
+ end
127
+
128
+ # Copy the base test database file into the worker database this fork is
129
+ # now connected to, with the SQLite online backup API: one consistent
130
+ # snapshot of the committed data (WAL included) that replaces whatever the
131
+ # worker file held (an earlier run, or a copy a timeout interrupted). Both
132
+ # ends are files SQLite itself opened, so no path rules are re-derived here.
133
+ #
134
+ # @param base_file [String] the base database file, from `pragma_database_list`.
135
+ # @return [void]
136
+ def self.seed_from(base_file)
137
+ codes = SQLite3::Constants::ErrorCode
138
+ source = SQLite3::Database.new(base_file, readonly: true)
139
+ source.busy_timeout = 5000
140
+ backup = SQLite3::Backup.new(ActiveRecord::Base.connection.raw_connection, "main", source, "main")
141
+ # Another worker's process can hold a lock on the base file for a moment;
142
+ # BUSY/LOCKED steps are retried for up to 5 seconds, then the copy fails
143
+ # with a message that names the database (scored `error`).
144
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
145
+ until (status = backup.step(-1)) == codes::DONE
146
+ retry_ok = [codes::BUSY, codes::LOCKED].include?(status) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
147
+ raise "copying #{base_file} into the worker database failed (SQLite code #{status})" unless retry_ok
148
+
149
+ sleep 0.01
150
+ end
151
+ ensure
152
+ backup&.finish
153
+ source&.close
154
+ end
155
+
156
+ # True when the current database already holds the schema that `schema.rb`
157
+ # declares: its newest `schema_migrations` row matches the declared version,
158
+ # and the `schema_sha1` Rails stores in `ar_internal_metadata` matches the
159
+ # file, which catches an edited schema with the same version. No stored
160
+ # checksum counts as out of date, as in Rails' own `schema_up_to_date?`.
161
+ #
162
+ # @param schema_path [String] absolute path to `db/schema.rb`.
163
+ # @return [Boolean]
164
+ def self.schema_current?(schema_path)
165
+ text = File.read(schema_path)
166
+ version = schema_file_version(text)
167
+ conn = ActiveRecord::Base.connection
168
+ base = ActiveRecord::Base
169
+ migrations = "#{base.table_name_prefix}#{base.schema_migrations_table_name}#{base.table_name_suffix}"
170
+ metadata = "#{base.table_name_prefix}#{base.internal_metadata_table_name}#{base.table_name_suffix}"
171
+ return false unless version && conn.table_exists?(migrations)
172
+ return false unless conn.select_values("SELECT version FROM #{conn.quote_table_name(migrations)}").map(&:to_i).max == version
173
+ return false unless conn.table_exists?(metadata)
174
+
175
+ conn.select_value("SELECT value FROM #{conn.quote_table_name(metadata)} WHERE key = 'schema_sha1'") ==
176
+ Digest::SHA1.hexdigest(text)
177
+ end
178
+
179
+ # The version a `schema.rb` declares in `define(version: ...)`, or nil. Pure
180
+ # string parse (no AR) so it is unit-testable in the zero-dep suite.
181
+ #
182
+ # @param text [String] the `schema.rb` source.
183
+ # @return [Integer, nil]
184
+ def self.schema_file_version(text)
185
+ text[/define\(version:\s*([\d_]+)/, 1]&.delete("_")&.to_i
186
+ end
187
+
188
+ # Load a Rails `schema.rb` into the current connection with output silenced
189
+ # (fork child stdout is already File::NULL; this is belt-and-braces).
190
+ #
191
+ # @param schema_path [String] absolute path to `db/schema.rb`.
192
+ # @return [void]
193
+ def self.load_schema(schema_path)
194
+ ActiveRecord::Migration.verbose = false if defined?(ActiveRecord::Migration)
195
+ original = $stdout
196
+ $stdout = File.open(File::NULL, "w")
197
+ load schema_path
198
+ ensure
199
+ $stdout.close unless $stdout.equal?(original)
200
+ $stdout = original
201
+ end
202
+
203
+ # Force a round-trip to the freshly-routed connection so a broken route fails
204
+ # HERE (→ `error`) instead of later masquerading as a test failure
205
+ # (→ false `killed`).
206
+ #
207
+ # @return [void]
208
+ def self.verify_connection!
209
+ ActiveRecord::Base.connection.execute("SELECT 1")
210
+ end
211
+ end
212
+ end