squishling 0.4.0 → 0.5.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 +4 -4
- data/CHANGELOG.md +149 -0
- data/README.md +26 -10
- data/docs/adapters.md +257 -0
- data/docs/configuration.md +54 -19
- data/docs/failures.md +170 -22
- data/docs/harnesses.md +39 -15
- data/docs/measuring-tokens.md +12 -6
- data/docs/observers.md +3 -3
- data/docs/routing.md +14 -3
- data/docs/schemas.md +24 -2
- data/docs/testing.md +187 -0
- data/docs/verifying.md +86 -0
- data/lib/squishling/adapter/error_body.rb +54 -0
- data/lib/squishling/adapter.rb +328 -0
- data/lib/squishling/adapters/openai_compatible.rb +311 -0
- data/lib/squishling/adapters/openai_options.rb +114 -0
- data/lib/squishling/adapters/openai_transport.rb +123 -0
- data/lib/squishling/adapters/ruby_llm.rb +229 -0
- data/lib/squishling/budget.rb +71 -0
- data/lib/squishling/class_methods.rb +43 -7
- data/lib/squishling/class_registry.rb +63 -0
- data/lib/squishling/configuration.rb +58 -6
- data/lib/squishling/definition.rb +90 -16
- data/lib/squishling/errors.rb +94 -7
- data/lib/squishling/harness/judged_squishsum.rb +2 -2
- data/lib/squishling/harness/option_types.rb +2 -1
- data/lib/squishling/harness/registry.rb +8 -36
- data/lib/squishling/invoker.rb +44 -14
- data/lib/squishling/judge.rb +16 -3
- data/lib/squishling/llm_client.rb +66 -132
- data/lib/squishling/model_path.rb +60 -13
- data/lib/squishling/named_registry.rb +64 -0
- data/lib/squishling/observer.rb +13 -4
- data/lib/squishling/output_check.rb +13 -9
- data/lib/squishling/params.rb +7 -24
- data/lib/squishling/railtie.rb +10 -0
- data/lib/squishling/result.rb +50 -16
- data/lib/squishling/rotation.rb +5 -0
- data/lib/squishling/router.rb +3 -2
- data/lib/squishling/rspec.rb +18 -0
- data/lib/squishling/run/messages.rb +40 -3
- data/lib/squishling/run/rejections.rb +43 -0
- data/lib/squishling/run/reports.rb +63 -0
- data/lib/squishling/run.rb +60 -53
- data/lib/squishling/schema.rb +50 -9
- data/lib/squishling/secret.rb +45 -0
- data/lib/squishling/squawk.rb +37 -4
- data/lib/squishling/tasks/squishling.rake +18 -0
- data/lib/squishling/testing/adapter.rb +194 -0
- data/lib/squishling/testing/checks.rb +39 -0
- data/lib/squishling/testing/direct.rb +41 -0
- data/lib/squishling/testing/errors.rb +18 -0
- data/lib/squishling/testing/fixtures.rb +206 -0
- data/lib/squishling/testing/helpers.rb +71 -0
- data/lib/squishling/testing/minitest.rb +32 -0
- data/lib/squishling/testing/observer.rb +31 -0
- data/lib/squishling/testing/reply.rb +60 -0
- data/lib/squishling/testing/request.rb +75 -0
- data/lib/squishling/testing/rspec.rb +30 -0
- data/lib/squishling/testing/script.rb +189 -0
- data/lib/squishling/testing/state.rb +74 -0
- data/lib/squishling/testing/stubs.rb +112 -0
- data/lib/squishling/testing.rb +126 -0
- data/lib/squishling/verification.rb +39 -0
- data/lib/squishling/verifier.rb +179 -0
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling/wrapper.rb +19 -1
- data/lib/squishling.rb +60 -4
- metadata +43 -21
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# One scripted line: the requests it answers and what they get, in order. The last answer repeats unless the
|
|
6
|
+
# rule is #once. Build them through Script (the block Testing.install yields).
|
|
7
|
+
class Rule
|
|
8
|
+
# What a rule answers. nil means any. kind is :chat (Adapter#complete) or :judgment (Adapter#judge); role is
|
|
9
|
+
# nil (an escalation attempt, or any role when the rule names none), :sample, or :judge.
|
|
10
|
+
Selector = Data.define(:kind, :role, :sample, :model, :provider, :attempt)
|
|
11
|
+
|
|
12
|
+
# Rule#take when nothing is left to answer with.
|
|
13
|
+
EXHAUSTED = Object.new.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :selector
|
|
16
|
+
|
|
17
|
+
def initialize(selector)
|
|
18
|
+
@selector = selector
|
|
19
|
+
@replies = []
|
|
20
|
+
@taken = 0
|
|
21
|
+
@once = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Answers, in order: a Hash (the model's answer), a String (raw text, malformed or fenced JSON included), nil
|
|
25
|
+
# (an empty response), a Reply (with a model id and token usage), or an Exception (raised as is).
|
|
26
|
+
def reply(*replies)
|
|
27
|
+
@replies.concat(replies)
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A provider failure named by intent (see Failures::INTENTS).
|
|
32
|
+
def fail(intent)
|
|
33
|
+
@replies << Failure.new(Failures.check!(intent))
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Running out of answers is an error instead of repeating the last one.
|
|
38
|
+
def once
|
|
39
|
+
@once = true
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def matches?(kind, call)
|
|
44
|
+
selector.kind == kind && fits?(selector.role, call[:role]) && fits?(selector.sample, call[:sample]) &&
|
|
45
|
+
fits?(selector.model, call[:model]) && fits?(selector.provider, call[:provider]) &&
|
|
46
|
+
fits?(selector.attempt, call[:attempt])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# How narrowly the rule aims, lowest first: rules for a sample or the judge, then those naming the most of a
|
|
50
|
+
# model, a provider, and an attempt, then the rest.
|
|
51
|
+
def specificity
|
|
52
|
+
return 0 if selector.sample || selector.role
|
|
53
|
+
|
|
54
|
+
4 - [selector.model, selector.provider, selector.attempt].count { |named| !named.nil? }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The next answer, or EXHAUSTED. Called with the script's lock held.
|
|
58
|
+
def take
|
|
59
|
+
return EXHAUSTED if @replies.empty? || (@once && @taken >= @replies.size)
|
|
60
|
+
|
|
61
|
+
@replies[[@taken, @replies.size - 1].min].tap { @taken += 1 }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def empty?
|
|
65
|
+
@replies.empty?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def describe
|
|
69
|
+
aimed = selector.to_h.compact.except(:kind).map { |key, value| "#{key}: #{value.inspect}" }
|
|
70
|
+
"#{selector.kind} #{aimed.empty? ? 'any' : aimed.join(', ')}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def fits?(wanted, actual)
|
|
76
|
+
wanted.nil? || wanted.to_s == actual.to_s
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The judge's rules: a chat judge answers a verdict, a System One judge a judgment with a confidence.
|
|
81
|
+
class JudgeRules
|
|
82
|
+
CHOICES = %i[a b neither].freeze
|
|
83
|
+
|
|
84
|
+
def initialize(chat, judgment)
|
|
85
|
+
@chat = chat
|
|
86
|
+
@judgment = judgment
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A chat judge's strict verdict (:a, :b, or :neither) and its reason.
|
|
90
|
+
def verdict(choice, reason: "scripted verdict")
|
|
91
|
+
@chat.reply({ "verdict" => check(choice).to_s, "reason" => reason })
|
|
92
|
+
self
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A System One judgment (type: :judgment) choosing :a, :b, or :neither with that probability; the rest of the
|
|
96
|
+
# probability is split between the other two choices. Pass an Exception to raise it instead.
|
|
97
|
+
def judgment(choice, confidence: 0.95)
|
|
98
|
+
unless choice.is_a?(Exception) || confidence.between?(0, 1)
|
|
99
|
+
raise ArgumentError, "confidence is a probability from 0 to 1, got #{confidence.inspect}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@judgment.reply(choice.is_a?(Exception) ? choice : answer(check(choice), confidence))
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Anything a chat judge's request can answer with (see Rule#reply): off-schema verdicts, raw text, ...
|
|
107
|
+
def reply(*replies)
|
|
108
|
+
@chat.reply(*replies)
|
|
109
|
+
self
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def fail(intent)
|
|
113
|
+
@chat.fail(intent)
|
|
114
|
+
self
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def fail_judgment(intent)
|
|
118
|
+
@judgment.fail(intent)
|
|
119
|
+
self
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def once
|
|
123
|
+
@chat.once
|
|
124
|
+
@judgment.once
|
|
125
|
+
self
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def check(choice)
|
|
131
|
+
return choice.to_sym if CHOICES.include?(choice.to_s.to_sym)
|
|
132
|
+
|
|
133
|
+
raise ArgumentError, "a judge choice is one of #{CHOICES.map(&:inspect).join(', ')}, got #{choice.inspect}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def answer(choice, confidence)
|
|
137
|
+
others = (1 - confidence) / 2.0
|
|
138
|
+
probabilities = CHOICES.to_h { |key| [key, key == choice ? confidence.to_f : others] }
|
|
139
|
+
Judgment.new({ winner: Answer.new(choice, probabilities) })
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# What a test says the model will do. Rules are matched most specific first (a sample or the judge, then a
|
|
144
|
+
# named model, then step(:any)), and by declaration order within that.
|
|
145
|
+
#
|
|
146
|
+
# llm.step("flash").reply({ score: 99 }, { score: 5 }) # garbage, then an answer
|
|
147
|
+
# llm.step("pro").fail(:rate_limit)
|
|
148
|
+
# llm.sample("a").reply(valid_a)
|
|
149
|
+
# llm.judge.verdict(:a)
|
|
150
|
+
class Script
|
|
151
|
+
def initialize
|
|
152
|
+
@rules = []
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Escalation attempts (and any other chat request no sample or judge rule claims) on a model, a provider, or
|
|
156
|
+
# :any model; attempt: narrows it to one attempt of the escalation (counting from 1).
|
|
157
|
+
def step(model = :any, provider: nil, attempt: nil)
|
|
158
|
+
add(:chat, model: model == :any ? nil : model, provider:, attempt:)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# A harness sample's requests ("a", "b", ...). Each sample has its own queue, so the order the sample threads
|
|
162
|
+
# happen to run in can't change what a sample gets.
|
|
163
|
+
def sample(name)
|
|
164
|
+
add(:chat, role: :sample, sample: name.to_s)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The judge of :judged_squishsum, chat or System One.
|
|
168
|
+
def judge
|
|
169
|
+
@judge ||= JudgeRules.new(add(:chat, role: :judge), add(:judgment, role: :judge))
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# The rule that answers a request of this kind, or nil. Called with the lock held (see Adapter).
|
|
173
|
+
def rule_for(kind, call)
|
|
174
|
+
candidates = @rules.each_with_index.select { |rule, _| rule.matches?(kind, call) }
|
|
175
|
+
candidates.min_by { |rule, index| [rule.specificity, index] }&.first
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def describe
|
|
179
|
+
@rules.empty? ? "nothing" : @rules.map(&:describe).join("; ")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
def add(kind, role: nil, sample: nil, model: nil, provider: nil, attempt: nil)
|
|
185
|
+
Rule.new(Rule::Selector.new(kind:, role:, sample:, model:, provider:, attempt:)).tap { |rule| @rules << rule }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# What the helpers changed in Squishling.config (the adapter and observer), remembered so Testing.reset! puts
|
|
6
|
+
# back exactly that: not an app's other settings (which Squishling.reset_config! would also clear), and not a
|
|
7
|
+
# value a test assigned itself after the helpers did.
|
|
8
|
+
module State
|
|
9
|
+
LOCK = Mutex.new
|
|
10
|
+
private_constant :LOCK
|
|
11
|
+
|
|
12
|
+
@entries = {} # setting => [what it was before the helpers, what the helpers set it to]
|
|
13
|
+
@config = nil
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# Sets a Squishling.config setting, remembering what it was before the first change.
|
|
17
|
+
def set(setting, value)
|
|
18
|
+
LOCK.synchronize do
|
|
19
|
+
config = Squishling.config
|
|
20
|
+
@entries = {} unless config.equal?(@config) # a replaced config is already pristine
|
|
21
|
+
@config = config
|
|
22
|
+
original = original_of(config, setting)
|
|
23
|
+
config.public_send(:"#{setting}=", value)
|
|
24
|
+
@entries[setting] = [original, value]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Puts back the settings still holding what the helpers set; leaves a config that has been replaced alone.
|
|
29
|
+
def restore!
|
|
30
|
+
config, entries = LOCK.synchronize do
|
|
31
|
+
[@config, @entries].tap do
|
|
32
|
+
@entries = {}
|
|
33
|
+
@config = nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
return unless config.equal?(Squishling.config)
|
|
37
|
+
|
|
38
|
+
entries.each do |setting, (original, installed)|
|
|
39
|
+
config.public_send(:"#{setting}=", original) if config.public_send(setting).equal?(installed)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# What to put back: the value before the helpers' first change, unless something else was assigned since.
|
|
46
|
+
def original_of(config, setting)
|
|
47
|
+
current = config.public_send(setting)
|
|
48
|
+
entry = @entries[setting]
|
|
49
|
+
entry && current.equal?(entry.last) ? entry.first : current
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# What a test asked squawk to record: every attempt's raw output, error, and metadata (role, sample, attempt,
|
|
55
|
+
# final, usage, ...), and the report of a call stopped by a token budget or a disagreement (stopped).
|
|
56
|
+
# Use Testing.with_squawk, which puts the previous hook back.
|
|
57
|
+
class SquawkCapture
|
|
58
|
+
Call = Data.define(:output, :error, :metadata)
|
|
59
|
+
|
|
60
|
+
def initialize
|
|
61
|
+
@lock = Mutex.new
|
|
62
|
+
@calls = []
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def call(output: nil, error: nil, metadata: nil)
|
|
66
|
+
@lock.synchronize { @calls << Call.new(output:, error:, metadata:) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def calls
|
|
70
|
+
@lock.synchronize { @calls.dup }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# Replaces a squished method for the tests of its callers: the method returns (or raises) what the test says and
|
|
6
|
+
# never reaches Squishling, but what it returns is a real typed result, validated against the method's output
|
|
7
|
+
# schema, so the stand-in can't drift from the real result type. Cleared by Testing.reset!.
|
|
8
|
+
module Stubs
|
|
9
|
+
LOCK = Mutex.new
|
|
10
|
+
private_constant :LOCK
|
|
11
|
+
|
|
12
|
+
# [class, method name] => callable producing the result, or raising the error
|
|
13
|
+
@active = {}
|
|
14
|
+
# [class, method name] => the module prepended to the class. A prepended module can't be removed, so it stays
|
|
15
|
+
# (inert once its stub is cleared) and keeps its class alive: stub the classes of a suite, not one per test.
|
|
16
|
+
@hooks = {}
|
|
17
|
+
|
|
18
|
+
ERRORS = {
|
|
19
|
+
invalid_output: :invalid_output_error, llm_error: :llm_error, configuration: :configuration_error,
|
|
20
|
+
disagreement: :disagreement_error
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# A result of the method's schema, with these attributes (squished: false for one the Ruby path returned).
|
|
25
|
+
def result(klass, attrs, method: :call, squished: true, &)
|
|
26
|
+
definition = klass.squishling_definition(method)
|
|
27
|
+
built = typed(definition, attrs, squished:)
|
|
28
|
+
activate(klass, method, -> { built }, &)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The method raises this kind of error: :invalid_output (errors:, raw:, attempts:, models:), :llm_error
|
|
32
|
+
# (message:), :configuration (message:), or :disagreement (candidates:, verdict:, reason:, models:).
|
|
33
|
+
def error(klass, kind, method: :call, **options, &)
|
|
34
|
+
builder = ERRORS.fetch(kind) do
|
|
35
|
+
raise ArgumentError, "unknown error #{kind.inspect} (one of #{ERRORS.keys.map(&:inspect).join(', ')})"
|
|
36
|
+
end
|
|
37
|
+
definition = klass.squishling_definition(method)
|
|
38
|
+
# Built once now, so a mistyped option fails here and not in the code under test.
|
|
39
|
+
send(builder, definition, **options)
|
|
40
|
+
activate(klass, method, -> { raise send(builder, definition, **options) }, &)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def clear!
|
|
44
|
+
LOCK.synchronize { @active.clear }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The stub for a method, called by the module prepended to its class (nil when there is none).
|
|
48
|
+
def stub_for(klass, method)
|
|
49
|
+
LOCK.synchronize { @active[[klass, method]] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def activate(klass, method, callable)
|
|
55
|
+
previous = LOCK.synchronize { @active.delete([klass, method]).tap { @active[[klass, method]] = callable } }
|
|
56
|
+
hook(klass, method)
|
|
57
|
+
return klass unless block_given?
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
yield
|
|
61
|
+
ensure
|
|
62
|
+
LOCK.synchronize { previous ? @active[[klass, method]] = previous : @active.delete([klass, method]) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Prepended once per method: calls the stub while there is one, and the real method otherwise.
|
|
67
|
+
def hook(klass, method)
|
|
68
|
+
LOCK.synchronize { @hooks[[klass, method]] ||= build_hook(klass, method) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A Wrapper, so Wrapper.implementation looks past it to the real method (its parameter names name the
|
|
72
|
+
# arguments the model is sent) once the stub is gone.
|
|
73
|
+
def build_hook(klass, method)
|
|
74
|
+
hook = Wrapper.new
|
|
75
|
+
hook.define_method(method) do |*args, **kwargs, &block|
|
|
76
|
+
stub = Stubs.stub_for(klass, method) if instance_of?(klass) # a subclass keeps its own behavior
|
|
77
|
+
stub ? stub.call : super(*args, **kwargs, &block)
|
|
78
|
+
end
|
|
79
|
+
klass.prepend(hook)
|
|
80
|
+
hook
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The attributes as the method's own typed result, validated by the same path as a Ruby-path return.
|
|
84
|
+
def typed(definition, attrs, squished:)
|
|
85
|
+
definition.build_result(attrs, squished:)
|
|
86
|
+
rescue InvalidOutputError => e
|
|
87
|
+
raise SetupError, "#{definition.label}: the stubbed result doesn't match its output_schema: " \
|
|
88
|
+
"#{e.errors.join('; ')}"
|
|
89
|
+
rescue ConfigurationError
|
|
90
|
+
raise SetupError, "#{definition.label} has no output_schema to build a result from"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def invalid_output_error(_definition, errors: ["stubbed invalid output"], raw: nil, attempts: 1, models: nil)
|
|
94
|
+
InvalidOutputError.new(errors: Array(errors), raw:, attempts:, models: models || ([nil] * attempts))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def llm_error(definition, message: "stubbed LLM failure")
|
|
98
|
+
LLMError.new("#{definition.label}: #{message}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def configuration_error(definition, message: "stubbed configuration error")
|
|
102
|
+
ConfigurationError.new("#{definition.label}: #{message}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def disagreement_error(definition, candidates:, verdict: nil, reason: nil, models: nil)
|
|
106
|
+
typed = candidates.map { |candidate| typed(definition, candidate, squished: true) }
|
|
107
|
+
DisagreementError.new(candidates: typed, verdict:, reason:, models:)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "squishling"
|
|
4
|
+
|
|
5
|
+
require_relative "testing/errors"
|
|
6
|
+
require_relative "testing/reply"
|
|
7
|
+
require_relative "testing/script"
|
|
8
|
+
require_relative "testing/request"
|
|
9
|
+
require_relative "testing/observer"
|
|
10
|
+
require_relative "testing/adapter"
|
|
11
|
+
require_relative "testing/state"
|
|
12
|
+
require_relative "testing/checks"
|
|
13
|
+
require_relative "testing/stubs"
|
|
14
|
+
require_relative "testing/direct"
|
|
15
|
+
require_relative "testing/fixtures"
|
|
16
|
+
require_relative "testing/helpers"
|
|
17
|
+
require_relative "testing/minitest"
|
|
18
|
+
require_relative "testing/rspec"
|
|
19
|
+
|
|
20
|
+
module Squishling
|
|
21
|
+
# Test support, opt-in: `require "squishling/testing"` (it is not loaded with `require "squishling"`, and needs no
|
|
22
|
+
# test framework). A scripted adapter answers in place of a provider and records what was sent to it, plus stubs
|
|
23
|
+
# for the callers of squished methods, schema-driven fixtures, and assertions. See docs/testing.md and the
|
|
24
|
+
# Squishling::Testing::Minitest and ::RSpec mixins.
|
|
25
|
+
module Testing
|
|
26
|
+
class << self
|
|
27
|
+
# Makes the scripted Adapter the one every squished call goes through, and returns it. The block scripts it
|
|
28
|
+
# (see Script); #script adds to it later.
|
|
29
|
+
def install(adapter = Adapter.new)
|
|
30
|
+
yield adapter.script if block_given?
|
|
31
|
+
State.set(:observer, Observer.new(chained_observer(Squishling.config.observer)))
|
|
32
|
+
State.set(:adapter, adapter)
|
|
33
|
+
adapter
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Any request that reaches the provider seam raises ProviderCallError, naming the squished method. Call it in a
|
|
37
|
+
# suite-wide before hook: a test that forgot to script its calls then fails at the call, not far from it.
|
|
38
|
+
def forbid_provider_calls!
|
|
39
|
+
State.set(:adapter, ForbiddenAdapter.new)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Runs the block with provider calls forbidden and returns the calls (the squished methods' labels) that tried
|
|
43
|
+
# to reach the provider, even if the block rescued the error. The adapter is put back afterwards.
|
|
44
|
+
def no_llm_call
|
|
45
|
+
config = Squishling.config
|
|
46
|
+
previous = config.adapter
|
|
47
|
+
guard = ForbiddenAdapter.new
|
|
48
|
+
config.adapter = guard
|
|
49
|
+
begin
|
|
50
|
+
yield
|
|
51
|
+
rescue ProviderCallError
|
|
52
|
+
nil
|
|
53
|
+
ensure
|
|
54
|
+
config.adapter = previous
|
|
55
|
+
end
|
|
56
|
+
guard.attempts
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# See Stubs.result: Klass.call returns a real, schema-validated result of the method's type.
|
|
60
|
+
def stub_result(klass, attrs = nil, method: :call, squished: true, **more, &)
|
|
61
|
+
Stubs.result(klass, Schema.jsonify((attrs || {}).merge(more)), method:, squished:, &)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# See Stubs.error: Klass.call raises the real error class with realistic attributes.
|
|
65
|
+
def stub_error(klass, kind, **, &)
|
|
66
|
+
Stubs.error(klass, kind, **, &)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The messages squish_validate and the schema reject this reply with. See Direct.
|
|
70
|
+
def validate(klass, reply, **inputs)
|
|
71
|
+
Direct.validate(klass, reply, **inputs)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def fallback_for(klass, error, **inputs)
|
|
75
|
+
Direct.fallback_for(klass, error, **inputs)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def preview(klass, **inputs)
|
|
79
|
+
Direct.preview(klass, **inputs)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def valid_reply_for(source, **overrides)
|
|
83
|
+
Fixtures.valid_reply_for(source, **overrides)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def invalid_reply_for(source, **)
|
|
87
|
+
Fixtures.invalid_reply_for(source, **)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A recorder to assign as config.squawk (or give to with_squawk): every attempt's output, error and metadata.
|
|
91
|
+
def capture_squawk
|
|
92
|
+
SquawkCapture.new
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Runs the block with the capture as config.squawk, and puts the previous hook back. A squawk set on a class or
|
|
96
|
+
# a method still takes precedence over the configured one.
|
|
97
|
+
def with_squawk(capture = capture_squawk)
|
|
98
|
+
config = Squishling.config
|
|
99
|
+
previous = config.squawk
|
|
100
|
+
config.squawk = capture
|
|
101
|
+
yield capture
|
|
102
|
+
capture
|
|
103
|
+
ensure
|
|
104
|
+
config.squawk = previous
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Puts back what the helpers changed (the adapter and observer) and clears the stubs. The mixins do this
|
|
108
|
+
# after every test; call it yourself with a custom setup.
|
|
109
|
+
def reset!
|
|
110
|
+
Stubs.clear!
|
|
111
|
+
State.restore!
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
# An observer the app configured itself (an object, or :coolhand) is kept in the chain; the default Coolhand
|
|
117
|
+
# auto-detection, and our own observer from an earlier install, are not.
|
|
118
|
+
def chained_observer(observer)
|
|
119
|
+
return observer.previous if observer.is_a?(Observer)
|
|
120
|
+
return Squishling::Observer.resolve(observer) if observer.is_a?(Symbol)
|
|
121
|
+
|
|
122
|
+
observer if observer.respond_to?(:around)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# What Squishling.verify found: the problems in the squished classes' setup, none when it is sound.
|
|
5
|
+
module Verification
|
|
6
|
+
# One thing wrong. label names where ("Triage#call", or "adapter" for a setup that isn't one class's).
|
|
7
|
+
Problem = Data.define(:label, :message) do
|
|
8
|
+
# The existing messages already start with the label; say it once.
|
|
9
|
+
def to_s
|
|
10
|
+
message.start_with?(label) ? message : "#{label}: #{message}"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class Report
|
|
15
|
+
attr_reader :problems
|
|
16
|
+
|
|
17
|
+
def initialize(problems)
|
|
18
|
+
@problems = problems.dup.freeze
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def ok?
|
|
23
|
+
problems.empty?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def size
|
|
27
|
+
problems.size
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A lint-style listing, one problem per line.
|
|
31
|
+
def to_s
|
|
32
|
+
return "Squishling: no problems found" if ok?
|
|
33
|
+
|
|
34
|
+
lines = problems.map { |problem| " #{problem}" }
|
|
35
|
+
"Squishling found #{size} #{size == 1 ? 'problem' : 'problems'}:\n#{lines.join("\n")}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|