prompt_warden 0.1.0 → 0.1.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/.gitignore +3 -0
- data/CHANGELOG.md +33 -2
- data/Gemfile +4 -4
- data/Gemfile.lock +21 -1
- data/README.md +217 -19
- data/Rakefile +19 -2
- data/bin/console +3 -3
- data/bin/pw_tail +8 -0
- data/examples/policy.yml +22 -0
- data/lib/prompt_warden/adapter.rb +59 -0
- data/lib/prompt_warden/buffer.rb +60 -0
- data/lib/prompt_warden/cli.rb +199 -0
- data/lib/prompt_warden/configuration.rb +39 -0
- data/lib/prompt_warden/cost_calculator.rb +105 -0
- data/lib/prompt_warden/event.rb +18 -0
- data/lib/prompt_warden/instrumentation/anthropic.rb +85 -0
- data/lib/prompt_warden/instrumentation/langchain.rb +76 -0
- data/lib/prompt_warden/instrumentation/openai.rb +79 -0
- data/lib/prompt_warden/policy.rb +73 -0
- data/lib/prompt_warden/railtie.rb +15 -0
- data/lib/prompt_warden/uploader.rb +93 -0
- data/lib/prompt_warden/version.rb +1 -1
- data/lib/prompt_warden.rb +32 -3
- data/prompt_warden.gemspec +33 -25
- data/spec/adapter_auto_detect_spec.rb +65 -0
- data/spec/anthropic_adapter_spec.rb +137 -0
- data/spec/buffer_spec.rb +44 -0
- data/spec/cli_spec.rb +255 -0
- data/spec/configuration_spec.rb +30 -0
- data/spec/cost_calculator_spec.rb +216 -0
- data/spec/event_spec.rb +30 -0
- data/spec/langchain_adapter_spec.rb +139 -0
- data/spec/openai_adapter_spec.rb +153 -0
- data/spec/policy_spec.rb +170 -0
- data/spec/prompt_warden_spec.rb +2 -2
- data/spec/spec_helper.rb +7 -8
- data/spec/uploader_spec.rb +79 -0
- metadata +98 -15
@@ -0,0 +1,93 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'faraday'
|
4
|
+
require 'faraday/retry'
|
5
|
+
require 'singleton'
|
6
|
+
require 'tmpdir'
|
7
|
+
require 'fileutils'
|
8
|
+
|
9
|
+
module PromptWarden
|
10
|
+
class Uploader
|
11
|
+
include Singleton
|
12
|
+
|
13
|
+
def initialize
|
14
|
+
cfg = PromptWarden.configuration
|
15
|
+
@token = cfg.project_token
|
16
|
+
@logger = cfg.logger
|
17
|
+
api_url = cfg.respond_to?(:api_url) ? cfg.api_url : 'https://httpbin.org/post'
|
18
|
+
|
19
|
+
@client = Faraday.new(url: api_url) do |f|
|
20
|
+
f.request :retry,
|
21
|
+
max: cfg.max_retries,
|
22
|
+
interval: 0.05,
|
23
|
+
backoff_factor: 2,
|
24
|
+
methods: %i[get post],
|
25
|
+
retry_statuses: [500, 502, 503]
|
26
|
+
f.adapter :net_http
|
27
|
+
end
|
28
|
+
|
29
|
+
@queue = Queue.new
|
30
|
+
start_worker
|
31
|
+
end
|
32
|
+
|
33
|
+
# Enqueue compressed batch for async upload
|
34
|
+
def enqueue(payload)
|
35
|
+
@queue << payload
|
36
|
+
end
|
37
|
+
|
38
|
+
# Retry all failed uploads from disk
|
39
|
+
def self.retry_failed!
|
40
|
+
files = Dir.glob(File.join(Dir.tmpdir, 'pw_failed_*.json.gz'))
|
41
|
+
logger = PromptWarden.configuration.logger
|
42
|
+
|
43
|
+
if files.empty?
|
44
|
+
logger.info "No failed upload files found to retry"
|
45
|
+
return
|
46
|
+
end
|
47
|
+
|
48
|
+
logger.info "Found #{files.size} failed upload file(s) to retry"
|
49
|
+
|
50
|
+
files.each do |file|
|
51
|
+
begin
|
52
|
+
instance.enqueue(File.binread(file))
|
53
|
+
File.delete(file)
|
54
|
+
logger.info "Successfully retried and deleted: #{File.basename(file)}"
|
55
|
+
rescue => e
|
56
|
+
# If upload fails again, leave the file for next retry
|
57
|
+
logger.error "Retry failed for #{File.basename(file)}: #{e.class}: #{e.message}"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
def start_worker
|
65
|
+
Thread.new do
|
66
|
+
loop do
|
67
|
+
payload = @queue.pop
|
68
|
+
begin
|
69
|
+
resp = @client.post do |req|
|
70
|
+
req.headers['Authorization'] = "Bearer #{@token}"
|
71
|
+
req.headers['Content-Encoding'] = 'gzip'
|
72
|
+
req.body = payload
|
73
|
+
end
|
74
|
+
@logger.info "PromptWarden upload: #{resp.status}"
|
75
|
+
rescue StandardError => e
|
76
|
+
@logger.error "PromptWarden upload failed: #{e.message}"
|
77
|
+
# Disk fallback: write failed batch to tmpdir
|
78
|
+
begin
|
79
|
+
dir = Dir.tmpdir
|
80
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
81
|
+
path = File.join(dir, 'pw_failed_') + Time.now.utc.strftime('%Y%m%d%H%M%S%L') + '.json.gz'
|
82
|
+
File.binwrite(path, payload)
|
83
|
+
@logger.info "PromptWarden wrote failed batch to #{path}"
|
84
|
+
rescue StandardError => file_err
|
85
|
+
@logger.error "PromptWarden failed to write fallback file: #{file_err.class}: #{file_err.message}"
|
86
|
+
end
|
87
|
+
# TODO: persist to disk for retry on process restart
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end.tap { |t| t.name = 'PromptWarden::UploaderWorker' }
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
data/lib/prompt_warden.rb
CHANGED
@@ -1,8 +1,37 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative
|
3
|
+
require_relative 'prompt_warden/version'
|
4
|
+
require_relative 'prompt_warden/configuration'
|
5
|
+
require_relative 'prompt_warden/railtie' if defined?(Rails)
|
6
|
+
require_relative 'prompt_warden/event'
|
7
|
+
require_relative 'prompt_warden/buffer'
|
8
|
+
require_relative 'prompt_warden/uploader'
|
9
|
+
require_relative 'prompt_warden/policy'
|
10
|
+
require_relative 'prompt_warden/adapter'
|
11
|
+
require_relative 'prompt_warden/cli'
|
12
|
+
require_relative 'prompt_warden/cost_calculator'
|
4
13
|
|
5
14
|
module PromptWarden
|
6
|
-
class
|
7
|
-
|
15
|
+
class << self
|
16
|
+
def configuration = (@configuration ||= Configuration.new)
|
17
|
+
def configure = yield(configuration).tap { configuration.validate! }
|
18
|
+
def reset! = (@configuration = nil)
|
19
|
+
|
20
|
+
def record(event_attrs)
|
21
|
+
buffer.push(Event.new(**event_attrs))
|
22
|
+
end
|
23
|
+
|
24
|
+
def calculate_cost(prompt:, model:, response_tokens: nil)
|
25
|
+
CostCalculator.calculate_cost(prompt: prompt, model: model, response_tokens: response_tokens)
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def buffer
|
31
|
+
@buffer ||= Buffer.new
|
32
|
+
end
|
33
|
+
end
|
8
34
|
end
|
35
|
+
|
36
|
+
# Auto‑load adapters after core is ready
|
37
|
+
PromptWarden::Adapter.auto_load_all!
|
data/prompt_warden.gemspec
CHANGED
@@ -1,42 +1,50 @@
|
|
1
|
-
#
|
2
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'lib/prompt_warden/version'
|
3
4
|
|
4
5
|
Gem::Specification.new do |spec|
|
5
6
|
# ---- Core identity --------------------------------------------------
|
6
|
-
spec.name =
|
7
|
-
spec.version = PromptWarden::VERSION
|
8
|
-
spec.authors = [
|
9
|
-
spec.email = [
|
10
|
-
spec.summary =
|
7
|
+
spec.name = 'prompt_warden'
|
8
|
+
spec.version = PromptWarden::VERSION # keep in one place
|
9
|
+
spec.authors = ['Tyler Hammett']
|
10
|
+
spec.email = ['hello@promptwarden.io']
|
11
|
+
spec.summary = 'Record, audit, and guard AI prompt usage with automatic SDK instrumentation.'
|
11
12
|
spec.description = <<~DESC
|
12
|
-
PromptWarden
|
13
|
-
|
13
|
+
PromptWarden provides automatic instrumentation for OpenAI, Anthropic, and Langchain SDKs.
|
14
|
+
Features include policy enforcement, cost calculation, real-time monitoring, and alert recording.
|
15
|
+
Includes CLI tool for live event streaming and comprehensive test coverage.
|
14
16
|
DESC
|
15
|
-
spec.license =
|
16
|
-
spec.homepage =
|
17
|
+
spec.license = 'MIT'
|
18
|
+
spec.homepage = 'https://promptwarden.io'
|
17
19
|
|
18
20
|
# ---- Compatibility ---------------------------------------------------
|
19
|
-
spec.required_ruby_version =
|
21
|
+
spec.required_ruby_version = '>= 3.1'
|
20
22
|
|
21
23
|
# ---- Files & executables --------------------------------------------
|
22
|
-
spec.files
|
23
|
-
|
24
|
-
|
25
|
-
spec.bindir =
|
26
|
-
spec.executables = []
|
27
|
-
spec.require_paths = [
|
24
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
25
|
+
`git ls-files -z`.split("\x0")
|
26
|
+
end
|
27
|
+
spec.bindir = 'bin'
|
28
|
+
spec.executables = ['pw_tail']
|
29
|
+
spec.require_paths = ['lib']
|
28
30
|
|
29
31
|
# ---- Runtime dependencies -------------------------------------------
|
30
32
|
# HTTP client for uploads
|
31
|
-
spec.add_runtime_dependency
|
33
|
+
spec.add_runtime_dependency 'faraday', '~> 2.9'
|
34
|
+
spec.add_runtime_dependency 'faraday-retry', '~> 2.2'
|
35
|
+
|
36
|
+
# YAML for policy configuration
|
37
|
+
spec.add_runtime_dependency 'yaml'
|
32
38
|
|
33
39
|
# ActiveSupport for Notifications
|
34
|
-
spec.add_runtime_dependency
|
40
|
+
spec.add_runtime_dependency 'activesupport', '>= 6.1'
|
35
41
|
|
36
42
|
# ---- Development / test dependencies -------------------------------
|
37
|
-
spec.add_development_dependency
|
38
|
-
spec.add_development_dependency
|
39
|
-
spec.add_development_dependency
|
40
|
-
spec.add_development_dependency
|
41
|
-
spec.add_development_dependency
|
43
|
+
spec.add_development_dependency 'bundler', '>= 2.3.0'
|
44
|
+
spec.add_development_dependency 'rake', '~> 13.2'
|
45
|
+
spec.add_development_dependency 'rspec', '~> 3.12'
|
46
|
+
spec.add_development_dependency 'rubocop', '~> 1.60'
|
47
|
+
spec.add_development_dependency 'rubocop-rspec', '~> 2.26'
|
48
|
+
spec.add_development_dependency 'timecop', '~> 0.9'
|
49
|
+
spec.add_development_dependency 'webmock', '~> 3.20'
|
42
50
|
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# --- Helper to stub an SDK constant --------------------------
|
4
|
+
module OpenAI
|
5
|
+
class Client
|
6
|
+
def initialize(api_key: nil, **opts)
|
7
|
+
@api_key = api_key
|
8
|
+
end
|
9
|
+
|
10
|
+
def chat(*); { "choices" => [{ "message" => { "content" => "hi" } }] }; end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def stub_openai_constant
|
15
|
+
# No-op: the constant is now always defined
|
16
|
+
PromptWarden::Adapter.auto_load_all!
|
17
|
+
if defined?(PromptWarden::Instrumentation::OpenAI)
|
18
|
+
OpenAI::Client.prepend(PromptWarden::Instrumentation::OpenAI)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
RSpec.describe 'PromptWarden adapter auto‑detection' do
|
23
|
+
# ------------------------------------------------------------------
|
24
|
+
it 'loads adapter when gem is in Gem.loaded_specs' do
|
25
|
+
Gem.loaded_specs['openai'] = Gem::Specification.new('openai') # simulate Gemfile
|
26
|
+
|
27
|
+
require 'prompt_warden' # auto‑loader runs
|
28
|
+
stub_openai_constant # constant after load
|
29
|
+
|
30
|
+
PromptWarden.configure { |c| c.project_token = 'tok' }
|
31
|
+
expect(OpenAI::Client.ancestors)
|
32
|
+
.to include(PromptWarden::Instrumentation::OpenAI)
|
33
|
+
ensure
|
34
|
+
Gem.loaded_specs.delete('openai')
|
35
|
+
end
|
36
|
+
|
37
|
+
# ------------------------------------------------------------------
|
38
|
+
it 'loads adapter when constant exists (no gem spec)' do
|
39
|
+
stub_openai_constant # constant first
|
40
|
+
require 'prompt_warden' # auto‑loader sees it
|
41
|
+
|
42
|
+
PromptWarden.configure { |c| c.project_token = 'tok' }
|
43
|
+
client = OpenAI::Client.new
|
44
|
+
expect(client).to respond_to(:chat) # wrapper prepended
|
45
|
+
end
|
46
|
+
|
47
|
+
# ------------------------------------------------------------------
|
48
|
+
it 'executes user‑registered adapter via register_adapter' do
|
49
|
+
require 'prompt_warden'
|
50
|
+
called = false
|
51
|
+
|
52
|
+
PromptWarden.configure do |c|
|
53
|
+
c.project_token = 'tok'
|
54
|
+
c.register_adapter(:fake_sdk) { called = true }
|
55
|
+
end
|
56
|
+
|
57
|
+
# Fake gem appears later in runtime
|
58
|
+
Gem.loaded_specs['fake_sdk'] = Gem::Specification.new('fake_sdk')
|
59
|
+
PromptWarden.configuration.run_pending_adapters
|
60
|
+
|
61
|
+
expect(called).to be true
|
62
|
+
ensure
|
63
|
+
Gem.loaded_specs.delete('fake_sdk')
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,137 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../lib/prompt_warden/instrumentation/anthropic'
|
4
|
+
|
5
|
+
RSpec.describe 'PromptWarden Anthropic adapter' do
|
6
|
+
before do
|
7
|
+
require 'prompt_warden'
|
8
|
+
PromptWarden.configure { |c| c.project_token = 'tok' }
|
9
|
+
allow(PromptWarden).to receive(:record)
|
10
|
+
allow(PromptWarden::Policy.instance).to receive(:check_alerts).and_return([])
|
11
|
+
allow(PromptWarden).to receive(:calculate_cost).and_return(0.005)
|
12
|
+
end
|
13
|
+
|
14
|
+
it 'records prompt usage via instrumentation' do
|
15
|
+
client = Object.new
|
16
|
+
client.extend(PromptWarden::Instrumentation::Anthropic)
|
17
|
+
# Call the instrumentation method directly
|
18
|
+
client.send(:_pw_wrap, :complete, {
|
19
|
+
prompt: 'hi',
|
20
|
+
model: 'claude-3-sonnet-20240229',
|
21
|
+
max_tokens: 1000
|
22
|
+
}) do
|
23
|
+
{ 'completion' => 'anthropic reply' }
|
24
|
+
end
|
25
|
+
expect(PromptWarden).to have_received(:record).with(hash_including(
|
26
|
+
prompt: 'hi',
|
27
|
+
model: 'claude-3-sonnet-20240229',
|
28
|
+
cost_usd: 0.005,
|
29
|
+
status: 'ok',
|
30
|
+
alerts: []
|
31
|
+
))
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'includes alerts when policy alerts are detected' do
|
35
|
+
alerts = [{ type: 'regex', rule: '/confidential/i', level: 'warn' }]
|
36
|
+
allow(PromptWarden::Policy.instance).to receive(:check_alerts).and_return(alerts)
|
37
|
+
client = Object.new
|
38
|
+
client.extend(PromptWarden::Instrumentation::Anthropic)
|
39
|
+
# Call the instrumentation method directly
|
40
|
+
client.send(:_pw_wrap, :complete, {
|
41
|
+
prompt: 'This is confidential information',
|
42
|
+
model: 'claude-3-sonnet-20240229'
|
43
|
+
}) do
|
44
|
+
{ 'completion' => 'anthropic reply' }
|
45
|
+
end
|
46
|
+
expect(PromptWarden).to have_received(:record).with(hash_including(
|
47
|
+
prompt: 'This is confidential information',
|
48
|
+
model: 'claude-3-sonnet-20240229',
|
49
|
+
status: 'ok',
|
50
|
+
alerts: alerts
|
51
|
+
))
|
52
|
+
end
|
53
|
+
|
54
|
+
it 'records failed status on error with alerts' do
|
55
|
+
alerts = [{ type: 'regex', rule: '/ETA/i', level: 'warn' }]
|
56
|
+
allow(PromptWarden::Policy.instance).to receive(:check_alerts).and_return(alerts)
|
57
|
+
client = Object.new
|
58
|
+
client.extend(PromptWarden::Instrumentation::Anthropic)
|
59
|
+
expect {
|
60
|
+
client.send(:_pw_wrap, :complete, {
|
61
|
+
prompt: 'What is the ETA?',
|
62
|
+
model: 'claude-3-sonnet-20240229'
|
63
|
+
}) do
|
64
|
+
raise 'test error'
|
65
|
+
end
|
66
|
+
}.to raise_error('test error')
|
67
|
+
expect(PromptWarden).to have_received(:record).with(hash_including(
|
68
|
+
prompt: 'What is the ETA?',
|
69
|
+
model: 'claude-3-sonnet-20240229',
|
70
|
+
status: 'failed',
|
71
|
+
alerts: alerts
|
72
|
+
))
|
73
|
+
end
|
74
|
+
|
75
|
+
it 'handles messages format for Anthropic API' do
|
76
|
+
client = Object.new
|
77
|
+
client.extend(PromptWarden::Instrumentation::Anthropic)
|
78
|
+
|
79
|
+
client.send(:_pw_wrap, :messages, {
|
80
|
+
messages: [
|
81
|
+
{ role: 'user', content: 'Hello' },
|
82
|
+
{ role: 'assistant', content: 'Hi there' },
|
83
|
+
{ role: 'user', content: 'How are you?' }
|
84
|
+
],
|
85
|
+
model: 'claude-3-sonnet-20240229'
|
86
|
+
}) do
|
87
|
+
{
|
88
|
+
'content' => [{ 'type' => 'text', 'text' => 'anthropic reply' }],
|
89
|
+
'usage' => { 'output_tokens' => 30 }
|
90
|
+
}
|
91
|
+
end
|
92
|
+
|
93
|
+
expect(PromptWarden).to have_received(:record).with(hash_including(
|
94
|
+
prompt: "Hello\nHi there\nHow are you?",
|
95
|
+
model: 'claude-3-sonnet-20240229',
|
96
|
+
status: 'ok',
|
97
|
+
alerts: []
|
98
|
+
))
|
99
|
+
end
|
100
|
+
|
101
|
+
it 'uses enhanced cost calculation for estimates and actual costs' do
|
102
|
+
client = Object.new
|
103
|
+
client.extend(PromptWarden::Instrumentation::Anthropic)
|
104
|
+
|
105
|
+
# Mock cost calculation calls
|
106
|
+
allow(PromptWarden).to receive(:calculate_cost).with(
|
107
|
+
prompt: 'hi',
|
108
|
+
model: 'claude-3-sonnet-20240229'
|
109
|
+
).and_return(0.003) # Estimate
|
110
|
+
|
111
|
+
allow(PromptWarden).to receive(:calculate_cost).with(
|
112
|
+
prompt: 'hi',
|
113
|
+
model: 'claude-3-sonnet-20240229',
|
114
|
+
response_tokens: 30
|
115
|
+
).and_return(0.005) # Actual cost
|
116
|
+
|
117
|
+
client.send(:_pw_wrap, :complete, {
|
118
|
+
prompt: 'hi',
|
119
|
+
model: 'claude-3-sonnet-20240229'
|
120
|
+
}) do
|
121
|
+
{
|
122
|
+
'completion' => 'anthropic reply',
|
123
|
+
'usage' => { 'output_tokens' => 30 }
|
124
|
+
}
|
125
|
+
end
|
126
|
+
|
127
|
+
expect(PromptWarden).to have_received(:calculate_cost).with(
|
128
|
+
prompt: 'hi',
|
129
|
+
model: 'claude-3-sonnet-20240229'
|
130
|
+
)
|
131
|
+
expect(PromptWarden).to have_received(:calculate_cost).with(
|
132
|
+
prompt: 'hi',
|
133
|
+
model: 'claude-3-sonnet-20240229',
|
134
|
+
response_tokens: 30
|
135
|
+
)
|
136
|
+
end
|
137
|
+
end
|
data/spec/buffer_spec.rb
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'webmock/rspec'
|
4
|
+
require 'timecop'
|
5
|
+
|
6
|
+
RSpec.describe PromptWarden::Buffer do
|
7
|
+
let(:config) do
|
8
|
+
PromptWarden::Configuration.new.tap do |c|
|
9
|
+
c.project_token = 'test'
|
10
|
+
c.flush_interval = 10 # long, we’ll call flush! manually
|
11
|
+
c.batch_bytes = 1_000
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
before { PromptWarden.reset! } # ensure fresh singleton
|
16
|
+
|
17
|
+
# spec/buffer_spec.rb fragment
|
18
|
+
it 'enqueues events and flushes when batch_bytes exceeded' do
|
19
|
+
uploader = instance_double(PromptWarden::Uploader, enqueue: true)
|
20
|
+
allow(PromptWarden::Uploader).to receive(:instance).and_return(uploader)
|
21
|
+
|
22
|
+
config.batch_bytes = 1_000 # raise threshold
|
23
|
+
buffer = described_class.new(config)
|
24
|
+
|
25
|
+
3.times { buffer.push(PromptWarden::Event.new(prompt: 'hi').to_h) }
|
26
|
+
|
27
|
+
buffer.flush! # ensure final flush for deterministic spec
|
28
|
+
|
29
|
+
expect(uploader).to have_received(:enqueue).once
|
30
|
+
end
|
31
|
+
|
32
|
+
it 'timer flushes automatically after interval' do
|
33
|
+
uploader = instance_double(PromptWarden::Uploader, enqueue: true)
|
34
|
+
allow(PromptWarden::Uploader).to receive(:instance).and_return(uploader)
|
35
|
+
|
36
|
+
# use very small interval so we can wait for real time
|
37
|
+
config.flush_interval = 0.05
|
38
|
+
buffer = described_class.new(config)
|
39
|
+
buffer.push(PromptWarden::Event.new(prompt: 'auto').to_h)
|
40
|
+
|
41
|
+
sleep 0.1 # two ticks of interval
|
42
|
+
expect(uploader).to have_received(:enqueue).at_least(:once)
|
43
|
+
end
|
44
|
+
end
|