constable-rails 0.1.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/CHANGELOG.md +88 -0
- data/LICENSE.txt +21 -0
- data/README.md +515 -0
- data/exe/constable +7 -0
- data/lib/constable/case.rb +336 -0
- data/lib/constable/cli.rb +475 -0
- data/lib/constable/cold_case/minitest.rb +342 -0
- data/lib/constable/cold_case/rspec.rb +334 -0
- data/lib/constable/cold_case.rb +280 -0
- data/lib/constable/config.rb +125 -0
- data/lib/constable/coverage.rb +951 -0
- data/lib/constable/diff.rb +212 -0
- data/lib/constable/dsl.rb +833 -0
- data/lib/constable/identity.rb +121 -0
- data/lib/constable/importer/modernizer.rb +860 -0
- data/lib/constable/importer/reopener.rb +468 -0
- data/lib/constable/importer.rb +51 -0
- data/lib/constable/investigation.rb +67 -0
- data/lib/constable/isolation.rb +171 -0
- data/lib/constable/jail.rb +399 -0
- data/lib/constable/log_router.rb +197 -0
- data/lib/constable/matchers.rb +834 -0
- data/lib/constable/order_audit.rb +130 -0
- data/lib/constable/rails_support.rb +213 -0
- data/lib/constable/railtie.rb +36 -0
- data/lib/constable/registry.rb +57 -0
- data/lib/constable/reporter.rb +625 -0
- data/lib/constable/result.rb +149 -0
- data/lib/constable/runner.rb +697 -0
- data/lib/constable/selection.rb +205 -0
- data/lib/constable/storage/adapter.rb +91 -0
- data/lib/constable/storage/mysql_adapter.rb +125 -0
- data/lib/constable/storage/postgres_adapter.rb +125 -0
- data/lib/constable/storage/sqlite_adapter.rb +84 -0
- data/lib/constable/storage.rb +847 -0
- data/lib/constable/version.rb +5 -0
- data/lib/constable/warrants.rb +290 -0
- data/lib/constable-rails.rb +16 -0
- data/lib/constable.rb +151 -0
- data/lib/generators/constable/base.rb +99 -0
- data/lib/generators/constable/channel/channel_generator.rb +20 -0
- data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
- data/lib/generators/constable/controller/controller_generator.rb +25 -0
- data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
- data/lib/generators/constable/generator/generator_generator.rb +31 -0
- data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
- data/lib/generators/constable/helper/helper_generator.rb +23 -0
- data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
- data/lib/generators/constable/import_generator.rb +137 -0
- data/lib/generators/constable/install_generator.rb +188 -0
- data/lib/generators/constable/integration/integration_generator.rb +27 -0
- data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
- data/lib/generators/constable/job/job_generator.rb +20 -0
- data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
- data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
- data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
- data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
- data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
- data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
- data/lib/generators/constable/model/model_generator.rb +31 -0
- data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
- data/lib/generators/constable/resource/resource_generator.rb +27 -0
- data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
- data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
- data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
- data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
- data/lib/generators/constable/system/system_generator.rb +20 -0
- data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
- data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
- data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
- data/lib/generators/constable/templates/config.yml.tt +67 -0
- data/lib/generators/constable/templates/example_case.rb.tt +56 -0
- data/lib/generators/constable/templates/matchers.rb.tt +36 -0
- data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
- metadata +209 -0
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "constable/storage/adapter"
|
|
5
|
+
|
|
6
|
+
module Constable
|
|
7
|
+
# The blotter -- everything Constable remembers between runs.
|
|
8
|
+
#
|
|
9
|
+
# This file is the entry point for the storage layer: it pulls in the abstract
|
|
10
|
+
# +Adapter+ interface, declares the three shipped adapters so +Adapter.build+ can
|
|
11
|
+
# resolve them, and defines the SQL implementation all three share.
|
|
12
|
+
#
|
|
13
|
+
# == Why one shared implementation
|
|
14
|
+
#
|
|
15
|
+
# SQLite, Postgres and MySQL differ in about six places -- how you open a connection,
|
|
16
|
+
# how a placeholder is spelled, what an auto-incrementing primary key is called, and
|
|
17
|
+
# how you read back an inserted id. Everything else (the schema, the queries, the jail
|
|
18
|
+
# state machine, the rolling duration average) is identical. +RelationalAdapter+ holds
|
|
19
|
+
# the identical part; each concrete adapter supplies the six differences. There is no
|
|
20
|
+
# third-party SQL abstraction in play here on purpose -- Constable must boot for a
|
|
21
|
+
# :unit-tier run without a Rails app present, so it carries no ActiveRecord dependency.
|
|
22
|
+
#
|
|
23
|
+
# == Conventions every caller can rely on
|
|
24
|
+
#
|
|
25
|
+
# * *Read methods return plain Ruby hashes with SYMBOL keys.* No adapter ever hands back
|
|
26
|
+
# a database-specific row object or a string-keyed hash. Other components (jail,
|
|
27
|
+
# warrants, the reporter, the CLI) consume these directly.
|
|
28
|
+
# * *Enumerable reads are newest-first.* +runs+, +history_for+, +jailed+, +paroled+,
|
|
29
|
+
# +warrants+ and +coverage_trend+ all return most-recent-first. A consumer plotting a
|
|
30
|
+
# trend line reverses; a consumer asking "what happened last?" reads element zero.
|
|
31
|
+
# * *Enum-ish values come back as Symbols* -- +:passed+, +:native+, +:unit+, +:jailed+,
|
|
32
|
+
# +:parole+ -- matching +Constable::Result::STATUSES+ and friends. Free text stays a
|
|
33
|
+
# String, and +nil+ stays +nil+ (never coerced into +:""+).
|
|
34
|
+
# * *Timestamps are ISO-8601 UTC Strings* ("2026-09-06T12:00:00.000Z"), stored in text
|
|
35
|
+
# columns. Portable across all three engines, sortable as text, no timezone surprises,
|
|
36
|
+
# and no driver-specific Time coercion to get wrong.
|
|
37
|
+
# * A read for something that does not exist returns +nil+ (single) or +[]+ (list).
|
|
38
|
+
#
|
|
39
|
+
# == Concurrency
|
|
40
|
+
#
|
|
41
|
+
# *Only the parent process writes to storage.* Workers ship their results back over a
|
|
42
|
+
# pipe as +Result#to_h+ and the parent persists them, so no adapter needs cross-process
|
|
43
|
+
# write locking and no upsert here needs to be atomic against a competing writer --
|
|
44
|
+
# which is why the read-then-insert-or-update helpers below are written as two plain
|
|
45
|
+
# statements rather than as three dialects' worth of ON CONFLICT syntax. SQLite still
|
|
46
|
+
# runs in WAL mode with a busy_timeout anyway: a stray reader (an editor plugin, a
|
|
47
|
+
# second terminal running `constable jail`) costs nothing to tolerate.
|
|
48
|
+
module Storage
|
|
49
|
+
autoload :SqliteAdapter, "constable/storage/sqlite_adapter"
|
|
50
|
+
autoload :PostgresAdapter, "constable/storage/postgres_adapter"
|
|
51
|
+
autoload :MysqlAdapter, "constable/storage/mysql_adapter"
|
|
52
|
+
|
|
53
|
+
# The SQL half of every shipped adapter.
|
|
54
|
+
#
|
|
55
|
+
# Subclasses must implement: +connect!+, +execute_raw+, +query+, +execute+, +insert+,
|
|
56
|
+
# +close+, and may override +types+ and +create_index+ for dialect quirks.
|
|
57
|
+
class RelationalAdapter < Adapter
|
|
58
|
+
# Bumped whenever the schema below changes. Stored in the schema_meta table so a
|
|
59
|
+
# future release can migrate an existing blotter instead of asking for a wipe.
|
|
60
|
+
SCHEMA_VERSION = 1
|
|
61
|
+
|
|
62
|
+
# How many samples the duration average remembers. Past this, each new sample is
|
|
63
|
+
# weighted 1/N against the running mean, so the index tracks a test that got slower
|
|
64
|
+
# (or faster) instead of being anchored by a year of old timings.
|
|
65
|
+
ROLLING_WINDOW = 20
|
|
66
|
+
|
|
67
|
+
TABLES = %w[
|
|
68
|
+
schema_meta runs flake_history jail_docket warrants durations coverage_snapshots
|
|
69
|
+
].freeze
|
|
70
|
+
|
|
71
|
+
# Tables with a generated primary key, so an adapter knows when an insert has an id
|
|
72
|
+
# worth reading back. The rest are keyed by identity, which the caller already has.
|
|
73
|
+
AUTO_ID_TABLES = %w[runs flake_history coverage_snapshots].freeze
|
|
74
|
+
|
|
75
|
+
# Column-name -> Ruby type. Drivers disagree about what they hand back (pg returns
|
|
76
|
+
# every value as a String; sqlite3 and mysql2 return typed values), so every row
|
|
77
|
+
# passes through here and comes out the same shape regardless of engine.
|
|
78
|
+
INTEGER_COLUMNS = %w[
|
|
79
|
+
id run_id line samples times_seen clean_runs failed_runs parole_clean_runs
|
|
80
|
+
parole_violations times_jailed total passed failed jailed skipped errored
|
|
81
|
+
warranted file_count unpatrolled
|
|
82
|
+
].freeze
|
|
83
|
+
|
|
84
|
+
FLOAT_COLUMNS = %w[duration average last_duration max_duration percent].freeze
|
|
85
|
+
BOOLEAN_COLUMNS = %w[full_run].freeze
|
|
86
|
+
SYMBOL_COLUMNS = %w[status kind tier mode state].freeze
|
|
87
|
+
# totals is Constable's own counter hash, so its keys become Symbols. files is keyed
|
|
88
|
+
# by *file path*, which must stay a String.
|
|
89
|
+
SYMBOL_KEYED_JSON_COLUMNS = %w[totals].freeze
|
|
90
|
+
STRING_KEYED_JSON_COLUMNS = %w[files].freeze
|
|
91
|
+
|
|
92
|
+
# Columns whose stored name is not the name callers should see. The run row is
|
|
93
|
+
# opened with `full:`, so it reads back as `:full`; "full" is a reserved word in
|
|
94
|
+
# Postgres, hence the full_run column underneath.
|
|
95
|
+
COLUMN_ALIASES = { "full_run" => :full }.freeze
|
|
96
|
+
|
|
97
|
+
def initialize(config)
|
|
98
|
+
super
|
|
99
|
+
@setup = false
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# --- lifecycle -------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
# Idempotent. Creating the schema is CREATE TABLE IF NOT EXISTS all the way down,
|
|
105
|
+
# so calling this on every boot costs one round trip and never destroys anything.
|
|
106
|
+
def setup!
|
|
107
|
+
connect! unless @connection
|
|
108
|
+
ddl_statements.each { |sql| execute_raw(sql) }
|
|
109
|
+
index_statements.each { |(name, table, columns)| create_index(name, table, columns) }
|
|
110
|
+
stamp_schema_version!
|
|
111
|
+
@setup = true
|
|
112
|
+
self
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def close
|
|
116
|
+
@connection = nil
|
|
117
|
+
@setup = false
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Drops everything and rebuilds. Used by `constable history reset` and by our own
|
|
122
|
+
# suite; never called during a test run.
|
|
123
|
+
def reset!
|
|
124
|
+
connect! unless @connection
|
|
125
|
+
TABLES.reverse_each { |table| execute_raw("DROP TABLE IF EXISTS #{table}") }
|
|
126
|
+
@setup = false
|
|
127
|
+
setup!
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The schema version currently stamped on this blotter, as an Integer.
|
|
131
|
+
def schema_version
|
|
132
|
+
row = query("SELECT value FROM schema_meta WHERE name = ?", ["schema_version"]).first
|
|
133
|
+
row && row["value"].to_i
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# --- runs ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
# Opens a run row and returns its id. Every flake_history and coverage row written
|
|
139
|
+
# afterwards points back at it.
|
|
140
|
+
def start_run(seed:, mode:, full:)
|
|
141
|
+
insert_row("runs", {
|
|
142
|
+
"seed" => seed&.to_s,
|
|
143
|
+
"mode" => (mode || :test).to_s,
|
|
144
|
+
"full_run" => full ? 1 : 0,
|
|
145
|
+
"started_at" => now
|
|
146
|
+
})
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# +totals+ is whatever the reporter counted. Recognised keys land in their own
|
|
150
|
+
# columns so `constable status` can query them; the whole hash is also kept as JSON
|
|
151
|
+
# so a future counter needs no migration to be readable.
|
|
152
|
+
def finish_run(run_id, totals:)
|
|
153
|
+
totals = symbolize(totals || {})
|
|
154
|
+
update_row("runs", { "id" => run_id }, {
|
|
155
|
+
"finished_at" => now,
|
|
156
|
+
"duration" => totals[:duration]&.to_f,
|
|
157
|
+
"total" => totals[:total]&.to_i,
|
|
158
|
+
"passed" => totals[:passed]&.to_i,
|
|
159
|
+
"failed" => totals[:failed]&.to_i,
|
|
160
|
+
"jailed" => totals[:jailed]&.to_i,
|
|
161
|
+
"skipped" => totals[:skipped]&.to_i,
|
|
162
|
+
"warranted" => totals[:warranted]&.to_i,
|
|
163
|
+
"parole_violations" => totals[:parole_violations]&.to_i,
|
|
164
|
+
"totals" => JSON.generate(totals)
|
|
165
|
+
})
|
|
166
|
+
run(run_id)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def run(run_id)
|
|
170
|
+
row(query("SELECT * FROM runs WHERE id = ?", [run_id]).first)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Most recent first.
|
|
174
|
+
def runs(limit: 30)
|
|
175
|
+
rows(query("SELECT * FROM runs ORDER BY id DESC LIMIT ?", [limit.to_i]))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# How much of each run was native and how much was still running as a cold case.
|
|
179
|
+
# Grouped in SQL, folded per run in Ruby -- a LIMIT inside an IN subquery is the one
|
|
180
|
+
# shape MySQL refuses, and this store has to read identically on all three engines.
|
|
181
|
+
def kind_totals(limit: 30)
|
|
182
|
+
grouped = rows(query(<<~SQL, [(limit.to_i * 4) + 8]))
|
|
183
|
+
SELECT run_id, kind, COUNT(*) AS tally
|
|
184
|
+
FROM flake_history
|
|
185
|
+
GROUP BY run_id, kind
|
|
186
|
+
ORDER BY run_id DESC
|
|
187
|
+
LIMIT ?
|
|
188
|
+
SQL
|
|
189
|
+
|
|
190
|
+
grouped.group_by { |row| row[:run_id] }
|
|
191
|
+
.sort_by { |run_id, _| -run_id.to_i }
|
|
192
|
+
.first(limit.to_i)
|
|
193
|
+
.map do |run_id, entries|
|
|
194
|
+
counts = entries.to_h { |e| [e[:kind].to_s, e[:tally].to_i] }
|
|
195
|
+
{
|
|
196
|
+
run_id: run_id,
|
|
197
|
+
native: counts.fetch("native", 0),
|
|
198
|
+
cold: counts.fetch("cold", 0)
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# --- flake history ---------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
# Every result, native and cold alike, one row per test per run. This is the raw
|
|
206
|
+
# material for flake detection (a status flip with no identity change) and for the
|
|
207
|
+
# native-vs-cold trend in `constable status`.
|
|
208
|
+
#
|
|
209
|
+
# Accepts a Constable::Result or the Hash a worker shipped over the pipe.
|
|
210
|
+
def record_result(run_id, result)
|
|
211
|
+
attrs = result_attributes(result)
|
|
212
|
+
insert_row("flake_history", {
|
|
213
|
+
"run_id" => run_id,
|
|
214
|
+
"identity" => attrs[:identity],
|
|
215
|
+
"label" => attrs[:label],
|
|
216
|
+
"case_name" => attrs[:case_name],
|
|
217
|
+
"description" => attrs[:description],
|
|
218
|
+
"file" => attrs[:file],
|
|
219
|
+
"line" => attrs[:line],
|
|
220
|
+
"kind" => attrs[:kind],
|
|
221
|
+
"tier" => attrs[:tier],
|
|
222
|
+
"status" => attrs[:status],
|
|
223
|
+
"duration" => attrs[:duration],
|
|
224
|
+
"failure_message" => attrs[:failure_message],
|
|
225
|
+
"recorded_at" => now
|
|
226
|
+
})
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Most recent first.
|
|
230
|
+
def history_for(identity, limit: 50)
|
|
231
|
+
rows(query(
|
|
232
|
+
"SELECT * FROM flake_history WHERE identity = ? ORDER BY id DESC LIMIT ?",
|
|
233
|
+
[identity.to_s, limit.to_i]
|
|
234
|
+
))
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# The most recently recorded status for a test, as a Symbol, or nil if never seen.
|
|
238
|
+
# The flake detector compares this against the status about to be recorded.
|
|
239
|
+
def last_status(identity)
|
|
240
|
+
row = query(
|
|
241
|
+
"SELECT status FROM flake_history WHERE identity = ? ORDER BY id DESC LIMIT 1",
|
|
242
|
+
[identity.to_s]
|
|
243
|
+
).first
|
|
244
|
+
row && to_symbol(row["status"])
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Every identity the blotter has ever heard of, sorted. Rename detection diffs this
|
|
248
|
+
# against the identities in the current run to spot a test that vanished.
|
|
249
|
+
def known_identities
|
|
250
|
+
query(<<~SQL).map { |r| r["identity"] }.compact.sort
|
|
251
|
+
SELECT identity FROM flake_history
|
|
252
|
+
UNION SELECT identity FROM jail_docket
|
|
253
|
+
UNION SELECT identity FROM warrants
|
|
254
|
+
UNION SELECT identity FROM durations
|
|
255
|
+
SQL
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Moves a test's whole history from one content hash to another -- what
|
|
259
|
+
# `constable history relink OLD NEW` does after a rename that also touched the body.
|
|
260
|
+
#
|
|
261
|
+
# Flake history rows always move. The jail entry, warrant and duration index move
|
|
262
|
+
# too, unless the new identity already has one of its own, in which case the old
|
|
263
|
+
# row is dropped rather than clobbering live state (:merged below).
|
|
264
|
+
#
|
|
265
|
+
# Returns a summary: { moved_results: Integer, jail: Symbol, warrant: Symbol,
|
|
266
|
+
# durations: Symbol } where each Symbol is :moved, :merged or :none.
|
|
267
|
+
def relink(old_identity, new_identity)
|
|
268
|
+
old_identity = old_identity.to_s
|
|
269
|
+
new_identity = new_identity.to_s
|
|
270
|
+
return { moved_results: 0, jail: :none, warrant: :none, durations: :none } if old_identity == new_identity
|
|
271
|
+
|
|
272
|
+
moved = count("SELECT COUNT(*) AS c FROM flake_history WHERE identity = ?", [old_identity])
|
|
273
|
+
execute("UPDATE flake_history SET identity = ? WHERE identity = ?", [new_identity, old_identity])
|
|
274
|
+
|
|
275
|
+
{
|
|
276
|
+
moved_results: moved,
|
|
277
|
+
jail: move_keyed_row("jail_docket", old_identity, new_identity),
|
|
278
|
+
warrant: move_keyed_row("warrants", old_identity, new_identity),
|
|
279
|
+
durations: move_keyed_row("durations", old_identity, new_identity)
|
|
280
|
+
}
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# --- jail docket -----------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
# Jails a test, or re-jails one already on the docket. A repeat offender keeps its
|
|
286
|
+
# history: times_jailed goes up, parole_violations is untouched, and any parole
|
|
287
|
+
# progress is wiped -- coming back to jail means starting the clean-run count over.
|
|
288
|
+
def jail(identity, label:, file:, line:, reason:)
|
|
289
|
+
identity = identity.to_s
|
|
290
|
+
existing = jail_entry(identity)
|
|
291
|
+
if existing
|
|
292
|
+
update_row("jail_docket", { "identity" => identity }, {
|
|
293
|
+
"label" => label, "file" => file, "line" => line, "reason" => reason,
|
|
294
|
+
"jailed_at" => now, "state" => "jailed", "parole_clean_runs" => 0,
|
|
295
|
+
"paroled_at" => nil, "times_jailed" => existing[:times_jailed].to_i + 1,
|
|
296
|
+
"updated_at" => now
|
|
297
|
+
})
|
|
298
|
+
else
|
|
299
|
+
insert_row("jail_docket", {
|
|
300
|
+
"identity" => identity, "label" => label, "file" => file, "line" => line,
|
|
301
|
+
"reason" => reason, "jailed_at" => now, "state" => "jailed",
|
|
302
|
+
"parole_clean_runs" => 0, "parole_violations" => 0, "times_jailed" => 1,
|
|
303
|
+
"updated_at" => now
|
|
304
|
+
})
|
|
305
|
+
end
|
|
306
|
+
jail_entry(identity)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Currently locked up (skipped in normal runs). Most recently jailed first.
|
|
310
|
+
def jailed
|
|
311
|
+
rows(query("SELECT * FROM jail_docket WHERE state = ? ORDER BY jailed_at DESC, identity ASC", ["jailed"]))
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Out on parole -- runs normally, but watched. Most recently paroled first.
|
|
315
|
+
def paroled
|
|
316
|
+
rows(query("SELECT * FROM jail_docket WHERE state = ? ORDER BY paroled_at DESC, identity ASC", ["parole"]))
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def jail_entry(identity)
|
|
320
|
+
row(query("SELECT * FROM jail_docket WHERE identity = ?", [identity.to_s]).first)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Jail -> parole. The clean-run count starts at zero; parole_violations and
|
|
324
|
+
# times_jailed carry over, because parole is supervision, not a fresh start.
|
|
325
|
+
# Returns the updated entry, or nil if the test is not on the docket.
|
|
326
|
+
def parole(identity)
|
|
327
|
+
identity = identity.to_s
|
|
328
|
+
return nil unless jail_entry(identity)
|
|
329
|
+
|
|
330
|
+
update_row("jail_docket", { "identity" => identity }, {
|
|
331
|
+
"state" => "parole", "parole_clean_runs" => 0, "paroled_at" => now, "updated_at" => now
|
|
332
|
+
})
|
|
333
|
+
jail_entry(identity)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Off the docket entirely -- no supervision, no history kept here (flake history is
|
|
337
|
+
# untouched). Returns true if there was something to release.
|
|
338
|
+
def release(identity)
|
|
339
|
+
identity = identity.to_s
|
|
340
|
+
return false unless jail_entry(identity)
|
|
341
|
+
|
|
342
|
+
execute("DELETE FROM jail_docket WHERE identity = ?", [identity])
|
|
343
|
+
true
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# One clean run for a test on parole. On reaching config.parole_period consecutive
|
|
347
|
+
# clean runs the test auto-releases -- no human step, per the spec.
|
|
348
|
+
#
|
|
349
|
+
# Returns the updated entry. An auto-release returns the entry it had at the moment
|
|
350
|
+
# of release with state: :released (the row is gone by then, so this is the only
|
|
351
|
+
# chance the caller gets to report it). Returns nil if the test is not on parole.
|
|
352
|
+
def record_parole_pass(identity)
|
|
353
|
+
identity = identity.to_s
|
|
354
|
+
entry = jail_entry(identity)
|
|
355
|
+
return nil unless entry && entry[:state] == :parole
|
|
356
|
+
|
|
357
|
+
clean = entry[:parole_clean_runs].to_i + 1
|
|
358
|
+
period = parole_period
|
|
359
|
+
|
|
360
|
+
if clean >= period
|
|
361
|
+
execute("DELETE FROM jail_docket WHERE identity = ?", [identity])
|
|
362
|
+
return entry.merge(state: :released, parole_clean_runs: clean, released: true)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
update_row("jail_docket", { "identity" => identity },
|
|
366
|
+
{ "parole_clean_runs" => clean, "updated_at" => now })
|
|
367
|
+
jail_entry(identity).merge(released: false)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# A paroled test failed. No leniency: straight back to jail, both counters up, the
|
|
371
|
+
# clean-run progress discarded. The original jail reason is preserved -- the caller
|
|
372
|
+
# decides whether to overwrite it with a fresh one via #jail.
|
|
373
|
+
# Returns the updated entry, or nil if the test is not on parole.
|
|
374
|
+
def record_parole_violation(identity)
|
|
375
|
+
identity = identity.to_s
|
|
376
|
+
entry = jail_entry(identity)
|
|
377
|
+
return nil unless entry && entry[:state] == :parole
|
|
378
|
+
|
|
379
|
+
update_row("jail_docket", { "identity" => identity }, {
|
|
380
|
+
"state" => "jailed",
|
|
381
|
+
"parole_clean_runs" => 0,
|
|
382
|
+
"parole_violations" => entry[:parole_violations].to_i + 1,
|
|
383
|
+
"times_jailed" => entry[:times_jailed].to_i + 1,
|
|
384
|
+
"jailed_at" => now,
|
|
385
|
+
"paroled_at" => nil,
|
|
386
|
+
"updated_at" => now
|
|
387
|
+
})
|
|
388
|
+
jail_entry(identity)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# --- warrants --------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
# Writes a warrant to the blotter -- never to source. Re-issuing an existing warrant
|
|
394
|
+
# refreshes its label/location and bumps failed_runs rather than resetting issued_at,
|
|
395
|
+
# so "how long has this been flaky" survives.
|
|
396
|
+
def issue_warrant(identity, label:, file:, line:, reason: nil)
|
|
397
|
+
identity = identity.to_s
|
|
398
|
+
existing = warrant_entry(identity)
|
|
399
|
+
if existing
|
|
400
|
+
update_row("warrants", { "identity" => identity }, {
|
|
401
|
+
"label" => label, "file" => file, "line" => line,
|
|
402
|
+
"reason" => reason || existing[:reason],
|
|
403
|
+
"last_seen_at" => now,
|
|
404
|
+
"times_seen" => existing[:times_seen].to_i + 1,
|
|
405
|
+
"failed_runs" => existing[:failed_runs].to_i + 1
|
|
406
|
+
})
|
|
407
|
+
else
|
|
408
|
+
insert_row("warrants", {
|
|
409
|
+
"identity" => identity, "label" => label, "file" => file, "line" => line,
|
|
410
|
+
"reason" => reason, "issued_at" => now, "last_seen_at" => now,
|
|
411
|
+
"times_seen" => 1, "clean_runs" => 0, "failed_runs" => 1
|
|
412
|
+
})
|
|
413
|
+
end
|
|
414
|
+
warrant_entry(identity)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Most recently issued first.
|
|
418
|
+
def warrants
|
|
419
|
+
rows(query("SELECT * FROM warrants ORDER BY issued_at DESC, identity ASC"))
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def warrant_entry(identity)
|
|
423
|
+
row(query("SELECT * FROM warrants WHERE identity = ?", [identity.to_s]).first)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Manual clear -- `constable warrants release PATH:LINE`. Returns true if there was
|
|
427
|
+
# a warrant to clear.
|
|
428
|
+
def clear_warrant(identity)
|
|
429
|
+
identity = identity.to_s
|
|
430
|
+
return false unless warrant_entry(identity)
|
|
431
|
+
|
|
432
|
+
execute("DELETE FROM warrants WHERE identity = ?", [identity])
|
|
433
|
+
true
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Records this run's verdict on a standing warrant.
|
|
437
|
+
#
|
|
438
|
+
# cleared: true -> every retry passed. The warrant is lifted and the row removed.
|
|
439
|
+
# cleared: false -> at least one retry failed. Still under warrant, non-blocking.
|
|
440
|
+
#
|
|
441
|
+
# Returns the entry either way -- the cleared one carries state: :cleared and its
|
|
442
|
+
# final counters, since the row no longer exists for the reporter to look up.
|
|
443
|
+
# Returns nil when there is no warrant on this identity.
|
|
444
|
+
def touch_warrant(identity, cleared:)
|
|
445
|
+
identity = identity.to_s
|
|
446
|
+
entry = warrant_entry(identity)
|
|
447
|
+
return nil unless entry
|
|
448
|
+
|
|
449
|
+
if cleared
|
|
450
|
+
execute("DELETE FROM warrants WHERE identity = ?", [identity])
|
|
451
|
+
return entry.merge(
|
|
452
|
+
state: :cleared, cleared: true, last_seen_at: now,
|
|
453
|
+
times_seen: entry[:times_seen].to_i + 1, clean_runs: entry[:clean_runs].to_i + 1
|
|
454
|
+
)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
update_row("warrants", { "identity" => identity }, {
|
|
458
|
+
"last_seen_at" => now,
|
|
459
|
+
"times_seen" => entry[:times_seen].to_i + 1,
|
|
460
|
+
"failed_runs" => entry[:failed_runs].to_i + 1
|
|
461
|
+
})
|
|
462
|
+
warrant_entry(identity).merge(state: :standing, cleared: false)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# --- durations -------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
# Feeds two things: the parallel workers' load balancer (longest test first) and the
|
|
468
|
+
# SLOWEST section of the summary.
|
|
469
|
+
#
|
|
470
|
+
# The average is rolling, not lifetime: past ROLLING_WINDOW samples each new timing
|
|
471
|
+
# is weighted 1/ROLLING_WINDOW, so a test that got slower shows up as slower within
|
|
472
|
+
# a handful of runs instead of being dragged back by ancient data.
|
|
473
|
+
def record_duration(identity, duration)
|
|
474
|
+
identity = identity.to_s
|
|
475
|
+
duration = duration.to_f
|
|
476
|
+
existing = row(query("SELECT * FROM durations WHERE identity = ?", [identity]).first)
|
|
477
|
+
|
|
478
|
+
if existing
|
|
479
|
+
weight = [existing[:samples].to_i, ROLLING_WINDOW].min
|
|
480
|
+
average = ((existing[:average].to_f * weight) + duration) / (weight + 1)
|
|
481
|
+
update_row("durations", { "identity" => identity }, {
|
|
482
|
+
"average" => average,
|
|
483
|
+
"last_duration" => duration,
|
|
484
|
+
"max_duration" => [existing[:max_duration].to_f, duration].max,
|
|
485
|
+
"samples" => existing[:samples].to_i + 1,
|
|
486
|
+
"updated_at" => now
|
|
487
|
+
})
|
|
488
|
+
else
|
|
489
|
+
insert_row("durations", {
|
|
490
|
+
"identity" => identity, "average" => duration, "last_duration" => duration,
|
|
491
|
+
"max_duration" => duration, "samples" => 1, "updated_at" => now
|
|
492
|
+
})
|
|
493
|
+
end
|
|
494
|
+
row(query("SELECT * FROM durations WHERE identity = ?", [identity]).first)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# { identity(String) => average_seconds(Float) }. The one read method that is not
|
|
498
|
+
# symbol-keyed, because its keys are content hashes rather than field names.
|
|
499
|
+
def duration_index
|
|
500
|
+
query("SELECT identity, average FROM durations").to_h { |r| [r["identity"], r["average"].to_f] }
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Slowest tests by rolling average, worst first. Display fields come from the most
|
|
504
|
+
# recent flake_history row for the identity -- record_duration is given only a
|
|
505
|
+
# timing, so the label lives where the results live.
|
|
506
|
+
def slowest(limit: 10)
|
|
507
|
+
rows(query(<<~SQL, [limit.to_i]))
|
|
508
|
+
SELECT d.identity, d.average, d.last_duration, d.max_duration, d.samples, d.updated_at,
|
|
509
|
+
(SELECT h.label FROM flake_history h WHERE h.identity = d.identity ORDER BY h.id DESC LIMIT 1) AS label,
|
|
510
|
+
(SELECT h.file FROM flake_history h WHERE h.identity = d.identity ORDER BY h.id DESC LIMIT 1) AS file,
|
|
511
|
+
(SELECT h.line FROM flake_history h WHERE h.identity = d.identity ORDER BY h.id DESC LIMIT 1) AS line
|
|
512
|
+
FROM durations d
|
|
513
|
+
ORDER BY d.average DESC, d.identity ASC
|
|
514
|
+
LIMIT ?
|
|
515
|
+
SQL
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# --- coverage --------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
# One snapshot per covered run. +files+ is a { path => percent } Hash (an Array of
|
|
521
|
+
# paths is accepted too, and treated as unpatrolled); it round-trips as JSON so the
|
|
522
|
+
# beat report can rebuild a per-file breakdown from history.
|
|
523
|
+
def record_coverage(run_id, percent:, files:)
|
|
524
|
+
files ||= {}
|
|
525
|
+
insert_row("coverage_snapshots", {
|
|
526
|
+
"run_id" => run_id,
|
|
527
|
+
"percent" => percent.to_f,
|
|
528
|
+
"file_count" => file_count_for(files),
|
|
529
|
+
"unpatrolled" => unpatrolled_for(files),
|
|
530
|
+
"files" => JSON.generate(files),
|
|
531
|
+
"recorded_at" => now
|
|
532
|
+
})
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# Most recent first. Reverse it to plot a line.
|
|
536
|
+
def coverage_trend(limit: 30)
|
|
537
|
+
rows(query("SELECT * FROM coverage_snapshots ORDER BY id DESC LIMIT ?", [limit.to_i]))
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
private
|
|
541
|
+
|
|
542
|
+
# --- schema ----------------------------------------------------------------
|
|
543
|
+
|
|
544
|
+
# Type names per dialect. Values are never interpolated into SQL anywhere in this
|
|
545
|
+
# file -- these are column *types*, fixed strings from the map below.
|
|
546
|
+
def types
|
|
547
|
+
{
|
|
548
|
+
pk: "INTEGER PRIMARY KEY AUTOINCREMENT",
|
|
549
|
+
ident: "VARCHAR(64)",
|
|
550
|
+
text: "TEXT",
|
|
551
|
+
int: "INTEGER",
|
|
552
|
+
float: "REAL",
|
|
553
|
+
time: "VARCHAR(32)"
|
|
554
|
+
}
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def type(name) = types.fetch(name)
|
|
558
|
+
|
|
559
|
+
def ddl_statements
|
|
560
|
+
[
|
|
561
|
+
<<~SQL,
|
|
562
|
+
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
563
|
+
name #{type(:ident)} PRIMARY KEY,
|
|
564
|
+
value #{type(:text)} NOT NULL
|
|
565
|
+
)
|
|
566
|
+
SQL
|
|
567
|
+
<<~SQL,
|
|
568
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
569
|
+
id #{type(:pk)},
|
|
570
|
+
seed #{type(:ident)},
|
|
571
|
+
mode #{type(:ident)},
|
|
572
|
+
full_run #{type(:int)} NOT NULL DEFAULT 0,
|
|
573
|
+
started_at #{type(:time)} NOT NULL,
|
|
574
|
+
finished_at #{type(:time)},
|
|
575
|
+
duration #{type(:float)},
|
|
576
|
+
total #{type(:int)},
|
|
577
|
+
passed #{type(:int)},
|
|
578
|
+
failed #{type(:int)},
|
|
579
|
+
jailed #{type(:int)},
|
|
580
|
+
skipped #{type(:int)},
|
|
581
|
+
warranted #{type(:int)},
|
|
582
|
+
parole_violations #{type(:int)},
|
|
583
|
+
totals #{type(:text)}
|
|
584
|
+
)
|
|
585
|
+
SQL
|
|
586
|
+
<<~SQL,
|
|
587
|
+
CREATE TABLE IF NOT EXISTS flake_history (
|
|
588
|
+
id #{type(:pk)},
|
|
589
|
+
run_id #{type(:int)},
|
|
590
|
+
identity #{type(:ident)} NOT NULL,
|
|
591
|
+
label #{type(:text)},
|
|
592
|
+
case_name #{type(:text)},
|
|
593
|
+
description #{type(:text)},
|
|
594
|
+
file #{type(:text)},
|
|
595
|
+
line #{type(:int)},
|
|
596
|
+
kind #{type(:ident)},
|
|
597
|
+
tier #{type(:ident)},
|
|
598
|
+
status #{type(:ident)} NOT NULL,
|
|
599
|
+
duration #{type(:float)},
|
|
600
|
+
failure_message #{type(:text)},
|
|
601
|
+
recorded_at #{type(:time)} NOT NULL
|
|
602
|
+
)
|
|
603
|
+
SQL
|
|
604
|
+
<<~SQL,
|
|
605
|
+
CREATE TABLE IF NOT EXISTS jail_docket (
|
|
606
|
+
identity #{type(:ident)} PRIMARY KEY,
|
|
607
|
+
label #{type(:text)},
|
|
608
|
+
file #{type(:text)},
|
|
609
|
+
line #{type(:int)},
|
|
610
|
+
reason #{type(:text)},
|
|
611
|
+
jailed_at #{type(:time)} NOT NULL,
|
|
612
|
+
state #{type(:ident)} NOT NULL DEFAULT 'jailed',
|
|
613
|
+
parole_clean_runs #{type(:int)} NOT NULL DEFAULT 0,
|
|
614
|
+
parole_violations #{type(:int)} NOT NULL DEFAULT 0,
|
|
615
|
+
times_jailed #{type(:int)} NOT NULL DEFAULT 1,
|
|
616
|
+
paroled_at #{type(:time)},
|
|
617
|
+
updated_at #{type(:time)} NOT NULL
|
|
618
|
+
)
|
|
619
|
+
SQL
|
|
620
|
+
<<~SQL,
|
|
621
|
+
CREATE TABLE IF NOT EXISTS warrants (
|
|
622
|
+
identity #{type(:ident)} PRIMARY KEY,
|
|
623
|
+
label #{type(:text)},
|
|
624
|
+
file #{type(:text)},
|
|
625
|
+
line #{type(:int)},
|
|
626
|
+
reason #{type(:text)},
|
|
627
|
+
issued_at #{type(:time)} NOT NULL,
|
|
628
|
+
last_seen_at #{type(:time)},
|
|
629
|
+
times_seen #{type(:int)} NOT NULL DEFAULT 0,
|
|
630
|
+
clean_runs #{type(:int)} NOT NULL DEFAULT 0,
|
|
631
|
+
failed_runs #{type(:int)} NOT NULL DEFAULT 0
|
|
632
|
+
)
|
|
633
|
+
SQL
|
|
634
|
+
<<~SQL,
|
|
635
|
+
CREATE TABLE IF NOT EXISTS durations (
|
|
636
|
+
identity #{type(:ident)} PRIMARY KEY,
|
|
637
|
+
average #{type(:float)} NOT NULL DEFAULT 0,
|
|
638
|
+
last_duration #{type(:float)},
|
|
639
|
+
max_duration #{type(:float)},
|
|
640
|
+
samples #{type(:int)} NOT NULL DEFAULT 0,
|
|
641
|
+
updated_at #{type(:time)} NOT NULL
|
|
642
|
+
)
|
|
643
|
+
SQL
|
|
644
|
+
<<~SQL
|
|
645
|
+
CREATE TABLE IF NOT EXISTS coverage_snapshots (
|
|
646
|
+
id #{type(:pk)},
|
|
647
|
+
run_id #{type(:int)},
|
|
648
|
+
percent #{type(:float)},
|
|
649
|
+
file_count #{type(:int)},
|
|
650
|
+
unpatrolled #{type(:int)},
|
|
651
|
+
files #{type(:text)},
|
|
652
|
+
recorded_at #{type(:time)} NOT NULL
|
|
653
|
+
)
|
|
654
|
+
SQL
|
|
655
|
+
]
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# [index name, table, columns]. Only VARCHAR/INTEGER columns are indexed -- MySQL
|
|
659
|
+
# refuses to index a TEXT column without a prefix length.
|
|
660
|
+
def index_statements
|
|
661
|
+
[
|
|
662
|
+
["idx_flake_identity", "flake_history", %w[identity id]],
|
|
663
|
+
["idx_flake_run", "flake_history", %w[run_id]],
|
|
664
|
+
["idx_flake_status", "flake_history", %w[status]],
|
|
665
|
+
["idx_jail_state", "jail_docket", %w[state]],
|
|
666
|
+
["idx_coverage_run", "coverage_snapshots", %w[run_id]]
|
|
667
|
+
]
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
def create_index(name, table, columns)
|
|
671
|
+
execute_raw("CREATE INDEX IF NOT EXISTS #{name} ON #{table} (#{columns.join(", ")})")
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
def stamp_schema_version!
|
|
675
|
+
existing = query("SELECT value FROM schema_meta WHERE name = ?", ["schema_version"]).first
|
|
676
|
+
if existing
|
|
677
|
+
# Nothing to migrate yet -- version 1 is the first schema. A future release adds
|
|
678
|
+
# its migrations here, keyed off existing["value"].to_i.
|
|
679
|
+
nil
|
|
680
|
+
else
|
|
681
|
+
execute("INSERT INTO schema_meta (name, value) VALUES (?, ?)",
|
|
682
|
+
["schema_version", SCHEMA_VERSION.to_s])
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
# --- row plumbing ----------------------------------------------------------
|
|
687
|
+
|
|
688
|
+
def insert_row(table, attrs)
|
|
689
|
+
attrs = attrs.compact
|
|
690
|
+
columns = attrs.keys
|
|
691
|
+
sql = "INSERT INTO #{table} (#{columns.join(", ")}) VALUES (#{placeholders(columns.size)})"
|
|
692
|
+
insert(sql, attrs.values, table)
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def update_row(table, where, attrs)
|
|
696
|
+
return if attrs.empty?
|
|
697
|
+
|
|
698
|
+
assignments = attrs.keys.map { |c| "#{c} = ?" }.join(", ")
|
|
699
|
+
conditions = where.keys.map { |c| "#{c} = ?" }.join(" AND ")
|
|
700
|
+
execute("UPDATE #{table} SET #{assignments} WHERE #{conditions}", attrs.values + where.values)
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
def placeholders(count) = Array.new(count, "?").join(", ")
|
|
704
|
+
|
|
705
|
+
def count(sql, binds = [])
|
|
706
|
+
result = query(sql, binds).first
|
|
707
|
+
return 0 unless result
|
|
708
|
+
|
|
709
|
+
(result["c"] || result.values.first).to_i
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
# Moves a single primary-keyed row from one identity to another. :moved when the row
|
|
713
|
+
# relocated, :merged when the target already had one (the old row is dropped rather
|
|
714
|
+
# than overwriting live state), :none when there was nothing to move.
|
|
715
|
+
def move_keyed_row(table, old_identity, new_identity)
|
|
716
|
+
old_exists = count("SELECT COUNT(*) AS c FROM #{table} WHERE identity = ?", [old_identity]).positive?
|
|
717
|
+
return :none unless old_exists
|
|
718
|
+
|
|
719
|
+
new_exists = count("SELECT COUNT(*) AS c FROM #{table} WHERE identity = ?", [new_identity]).positive?
|
|
720
|
+
if new_exists
|
|
721
|
+
execute("DELETE FROM #{table} WHERE identity = ?", [old_identity])
|
|
722
|
+
:merged
|
|
723
|
+
else
|
|
724
|
+
execute("UPDATE #{table} SET identity = ? WHERE identity = ?", [new_identity, old_identity])
|
|
725
|
+
:moved
|
|
726
|
+
end
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
# --- value coercion --------------------------------------------------------
|
|
730
|
+
|
|
731
|
+
# The single place a driver row becomes a Constable hash: symbol keys, Integers and
|
|
732
|
+
# Floats where the column says so, Symbols for enum-ish columns, parsed JSON for the
|
|
733
|
+
# blob columns, nil left as nil.
|
|
734
|
+
def row(raw)
|
|
735
|
+
return nil if raw.nil?
|
|
736
|
+
|
|
737
|
+
raw.each_with_object({}) do |(column, value), out|
|
|
738
|
+
column = column.to_s
|
|
739
|
+
out[COLUMN_ALIASES.fetch(column, column.to_sym)] = coerce(column, value)
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def rows(raws) = Array(raws).map { |r| row(r) }
|
|
744
|
+
|
|
745
|
+
def coerce(column, value)
|
|
746
|
+
return nil if value.nil?
|
|
747
|
+
|
|
748
|
+
case column
|
|
749
|
+
when *INTEGER_COLUMNS then value.to_i
|
|
750
|
+
when *FLOAT_COLUMNS then value.to_f
|
|
751
|
+
when *BOOLEAN_COLUMNS then truthy_column(value)
|
|
752
|
+
when *SYMBOL_COLUMNS then to_symbol(value)
|
|
753
|
+
when *SYMBOL_KEYED_JSON_COLUMNS then parse_json(value, symbolize: true)
|
|
754
|
+
when *STRING_KEYED_JSON_COLUMNS then parse_json(value, symbolize: false)
|
|
755
|
+
when "seed" then numeric_seed(value)
|
|
756
|
+
else value.is_a?(String) ? value : value.to_s
|
|
757
|
+
end
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
# A seed is an Integer in practice (`--seed 8841`) but is stored as text so an
|
|
761
|
+
# unusual one is never truncated. Hand back the Integer when it round-trips.
|
|
762
|
+
def numeric_seed(value)
|
|
763
|
+
string = value.to_s
|
|
764
|
+
string.match?(/\A-?\d+\z/) ? string.to_i : string
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
def truthy_column(value)
|
|
768
|
+
return value if [true, false].include?(value)
|
|
769
|
+
return false if value.to_s.empty?
|
|
770
|
+
|
|
771
|
+
!%w[0 f false].include?(value.to_s.downcase)
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def to_symbol(value)
|
|
775
|
+
string = value.to_s
|
|
776
|
+
string.empty? ? nil : string.to_sym
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
def parse_json(value, symbolize:)
|
|
780
|
+
return value unless value.is_a?(String)
|
|
781
|
+
|
|
782
|
+
JSON.parse(value, symbolize_names: symbolize)
|
|
783
|
+
rescue JSON::ParserError
|
|
784
|
+
value
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
def symbolize(hash)
|
|
788
|
+
return {} unless hash.respond_to?(:each_pair)
|
|
789
|
+
|
|
790
|
+
hash.each_with_object({}) { |(k, v), out| out[k.to_sym] = v }
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# Accepts a Constable::Result or the Hash a worker shipped over the pipe, and
|
|
794
|
+
# flattens it into the columns flake_history stores.
|
|
795
|
+
def result_attributes(result)
|
|
796
|
+
hash = result.is_a?(Hash) ? symbolize(result) : symbolize(result.to_h)
|
|
797
|
+
failure = hash[:failure]
|
|
798
|
+
failure = symbolize(failure) if failure.is_a?(Hash)
|
|
799
|
+
case_name = hash[:case_name].to_s
|
|
800
|
+
description = hash[:description].to_s
|
|
801
|
+
|
|
802
|
+
{
|
|
803
|
+
identity: hash[:identity].to_s,
|
|
804
|
+
label: hash[:label] || %(#{case_name} "#{description}"),
|
|
805
|
+
case_name: case_name,
|
|
806
|
+
description: description,
|
|
807
|
+
file: hash[:file],
|
|
808
|
+
line: hash[:line],
|
|
809
|
+
kind: (hash[:kind] || :native).to_s,
|
|
810
|
+
tier: hash[:tier]&.to_s,
|
|
811
|
+
status: (hash[:status] || :passed).to_s,
|
|
812
|
+
duration: hash[:duration].to_f,
|
|
813
|
+
failure_message: failure.is_a?(Hash) ? failure[:message] : failure&.message
|
|
814
|
+
}
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
def file_count_for(files)
|
|
818
|
+
files.respond_to?(:size) ? files.size : 0
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
# "Unpatrolled" is a file with zero executed lines -- usually a file the suite never
|
|
822
|
+
# touched at all, which is worth naming separately from a thinly covered one.
|
|
823
|
+
def unpatrolled_for(files)
|
|
824
|
+
case files
|
|
825
|
+
when Hash then files.count { |_, percent| percent.to_f.zero? }
|
|
826
|
+
when Array then files.size
|
|
827
|
+
else 0
|
|
828
|
+
end
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
def parole_period
|
|
832
|
+
period = config.respond_to?(:parole_period) ? config.parole_period.to_i : 0
|
|
833
|
+
period.positive? ? period : 10
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
def now = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
|
|
837
|
+
|
|
838
|
+
# --- driver hooks ----------------------------------------------------------
|
|
839
|
+
|
|
840
|
+
def connect! = raise(NotImplementedError, "#{self.class}#connect!")
|
|
841
|
+
def execute_raw(_sql) = raise(NotImplementedError, "#{self.class}#execute_raw")
|
|
842
|
+
def query(_sql, _binds = []) = raise(NotImplementedError, "#{self.class}#query")
|
|
843
|
+
def execute(_sql, _binds = []) = raise(NotImplementedError, "#{self.class}#execute")
|
|
844
|
+
def insert(_sql, _binds, _table) = raise(NotImplementedError, "#{self.class}#insert")
|
|
845
|
+
end
|
|
846
|
+
end
|
|
847
|
+
end
|