ruby-link-checker 0.3.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/.github/workflows/coverage.yml +24 -0
- data/.github/workflows/danger-comment.yml +17 -0
- data/.github/workflows/danger.yml +12 -0
- data/.github/workflows/lint.yml +20 -0
- data/.github/workflows/release.yml +14 -0
- data/.github/workflows/test.yml +25 -0
- data/.gitignore +6 -0
- data/.rspec +2 -0
- data/.rubocop.yml +26 -0
- data/.rubocop_todo.yml +332 -0
- data/AGENTS.md +23 -0
- data/CHANGELOG.md +27 -0
- data/CONTRIBUTING.md +4 -0
- data/Dangerfile +2 -0
- data/Gemfile +5 -1
- data/README.md +151 -7
- data/RELEASING.md +17 -40
- data/Rakefile +8 -0
- data/benchmarks/check.rb +27 -20
- data/lib/ruby-link-checker/async/http/checker.rb +118 -0
- data/lib/ruby-link-checker/async/http/config.rb +39 -0
- data/lib/ruby-link-checker/async/http/result.rb +85 -0
- data/lib/ruby-link-checker/async/http.rb +3 -0
- data/lib/ruby-link-checker/cache.rb +101 -0
- data/lib/ruby-link-checker/callbacks.rb +2 -2
- data/lib/ruby-link-checker/checker.rb +76 -4
- data/lib/ruby-link-checker/config.rb +64 -0
- data/lib/ruby-link-checker/errors/connection_error.rb +23 -0
- data/lib/ruby-link-checker/errors/redirect_limit_error.rb +15 -0
- data/lib/ruby-link-checker/errors.rb +2 -0
- data/lib/ruby-link-checker/net/http/checker.rb +4 -1
- data/lib/ruby-link-checker/net/http/result.rb +4 -0
- data/lib/ruby-link-checker/result.rb +25 -0
- data/lib/ruby-link-checker/task.rb +2 -0
- data/lib/ruby-link-checker/tasks.rb +43 -7
- data/lib/ruby-link-checker/typhoeus/hydra/checker.rb +29 -6
- data/lib/ruby-link-checker/typhoeus/hydra/result.rb +4 -0
- data/lib/ruby-link-checker/version.rb +1 -1
- data/lib/ruby-link-checker.rb +2 -0
- data/ruby-link-checker.gemspec +3 -2
- data/spec/ruby-link-checker/async/http/checker_spec.rb +184 -0
- data/spec/ruby-link-checker/callbacks_spec.rb +33 -0
- data/spec/ruby-link-checker/checker_spec.rb +89 -2
- data/spec/ruby-link-checker/config_spec.rb +6 -0
- data/spec/ruby-link-checker/errors/redirect_loop_error_spec.rb +18 -0
- data/spec/ruby-link-checker/net/http/checker_spec.rb +32 -0
- data/spec/ruby-link-checker/result_error_spec.rb +34 -0
- data/spec/ruby-link-checker/result_spec.rb +41 -0
- data/spec/ruby-link-checker/task_spec.rb +20 -0
- data/spec/ruby-link-checker/tasks_spec.rb +91 -0
- data/spec/ruby-link-checker/typhoeus/hydra/checker_spec.rb +116 -3
- data/spec/spec_helper.rb +3 -1
- data/spec/support/link_checker.rb +267 -3
- metadata +42 -35
- data/Gemfile.lock +0 -215
- data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_asc.png +0 -0
- data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_asc_disabled.png +0 -0
- data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_both.png +0 -0
- data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_desc.png +0 -0
- data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_desc_disabled.png +0 -0
- data/coverage/assets/0.13.1/application.css +0 -1
- data/coverage/assets/0.13.1/application.js +0 -7
- data/coverage/assets/0.13.1/colorbox/border.png +0 -0
- data/coverage/assets/0.13.1/colorbox/controls.png +0 -0
- data/coverage/assets/0.13.1/colorbox/loading.gif +0 -0
- data/coverage/assets/0.13.1/colorbox/loading_background.png +0 -0
- data/coverage/assets/0.13.1/favicon_green.png +0 -0
- data/coverage/assets/0.13.1/favicon_red.png +0 -0
- data/coverage/assets/0.13.1/favicon_yellow.png +0 -0
- data/coverage/assets/0.13.1/images/ui-bg_flat_0_aaaaaa_40x100.png +0 -0
- data/coverage/assets/0.13.1/images/ui-bg_flat_75_ffffff_40x100.png +0 -0
- data/coverage/assets/0.13.1/images/ui-bg_glass_55_fbf9ee_1x400.png +0 -0
- data/coverage/assets/0.13.1/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
- data/coverage/assets/0.13.1/images/ui-bg_glass_75_dadada_1x400.png +0 -0
- data/coverage/assets/0.13.1/images/ui-bg_glass_75_e6e6e6_1x400.png +0 -0
- data/coverage/assets/0.13.1/images/ui-bg_glass_95_fef1ec_1x400.png +0 -0
- data/coverage/assets/0.13.1/images/ui-bg_highlight-soft_75_cccccc_1x100.png +0 -0
- data/coverage/assets/0.13.1/images/ui-icons_222222_256x240.png +0 -0
- data/coverage/assets/0.13.1/images/ui-icons_2e83ff_256x240.png +0 -0
- data/coverage/assets/0.13.1/images/ui-icons_454545_256x240.png +0 -0
- data/coverage/assets/0.13.1/images/ui-icons_888888_256x240.png +0 -0
- data/coverage/assets/0.13.1/images/ui-icons_cd0a0a_256x240.png +0 -0
- data/coverage/assets/0.13.1/loading.gif +0 -0
- data/coverage/assets/0.13.1/magnify.png +0 -0
- data/coverage/index.html +0 -15014
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'async'
|
|
5
|
+
require 'async/http'
|
|
6
|
+
|
|
7
|
+
describe LinkChecker::Async::HTTP::Checker do
|
|
8
|
+
module AsyncTestLinkChecker
|
|
9
|
+
class Task < LinkChecker::Async::HTTP::Task; end
|
|
10
|
+
|
|
11
|
+
class LinkChecker < LinkChecker::Async::HTTP::Checker
|
|
12
|
+
def check(url, options = {})
|
|
13
|
+
super
|
|
14
|
+
run
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
before :all do
|
|
20
|
+
VCR.configure do |config|
|
|
21
|
+
config.hook_into :webmock
|
|
22
|
+
# async-http's WebMock adapter replays responses through a real HTTP/1
|
|
23
|
+
# parser, which rejects a Transfer-Encoding header alongside a
|
|
24
|
+
# Content-Length or an empty body. Let the adapter set Content-Length.
|
|
25
|
+
config.before_playback do |interaction|
|
|
26
|
+
interaction.response.headers.delete('Transfer-Encoding')
|
|
27
|
+
interaction.response.headers.delete_if { |key, _value| key.downcase == 'content-length' }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
after do
|
|
33
|
+
LinkChecker::Async::HTTP::Config.reset
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
describe 'concurrency' do
|
|
37
|
+
let(:checker) { described_class.new(max_concurrency: 2) }
|
|
38
|
+
let(:counts) { [] }
|
|
39
|
+
let(:completed) { [] }
|
|
40
|
+
|
|
41
|
+
before do
|
|
42
|
+
active = 0
|
|
43
|
+
5.times do
|
|
44
|
+
checker._queue do
|
|
45
|
+
counts << (active += 1)
|
|
46
|
+
sleep(0.005)
|
|
47
|
+
active -= 1
|
|
48
|
+
completed << active
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'bounds active work and waits for queued tasks' do
|
|
54
|
+
checker.run
|
|
55
|
+
expect(counts.max).to eq(2)
|
|
56
|
+
expect(counts.size).to eq(5)
|
|
57
|
+
expect(completed.size).to eq(5)
|
|
58
|
+
expect(completed.last).to eq(0)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'rejects invalid limits' do
|
|
62
|
+
expect { described_class.new(max_concurrency: 0) }.to raise_error ArgumentError
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'reuses clients for the same endpoint' do
|
|
66
|
+
expect(checker._client_for(URI('https://www.example.org/one'))).to equal(
|
|
67
|
+
checker._client_for(URI('https://www.example.org/two'))
|
|
68
|
+
)
|
|
69
|
+
checker.run
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe AsyncTestLinkChecker::LinkChecker do
|
|
74
|
+
it_behaves_like 'a link checker'
|
|
75
|
+
|
|
76
|
+
describe '.config' do
|
|
77
|
+
it 'returns the Config module' do
|
|
78
|
+
expect(LinkChecker::Async::HTTP.config).to eq LinkChecker::Async::HTTP::Config
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
context 'with timeout options', vcr: { cassette_name: '200' } do
|
|
83
|
+
before do
|
|
84
|
+
LinkChecker::Async::HTTP.configure do |config|
|
|
85
|
+
config.read_timeout = 5
|
|
86
|
+
config.open_timeout = 10
|
|
87
|
+
end
|
|
88
|
+
expect(Async::HTTP::Endpoint).to receive(:parse).with(
|
|
89
|
+
URI(url).to_s,
|
|
90
|
+
hash_including(timeout: 10)
|
|
91
|
+
).and_call_original
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
include_context 'with url'
|
|
95
|
+
|
|
96
|
+
it 'creates requests with a default timeout' do
|
|
97
|
+
expect(result.success?).to be true
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
context 'with a protocol option', vcr: { cassette_name: '200' } do
|
|
102
|
+
before do
|
|
103
|
+
LinkChecker::Async::HTTP.configure do |config|
|
|
104
|
+
config.protocol = protocol
|
|
105
|
+
end
|
|
106
|
+
expect(Async::HTTP::Endpoint).to receive(:parse).with(
|
|
107
|
+
URI(url).to_s,
|
|
108
|
+
hash_including(protocol: expected_protocol)
|
|
109
|
+
).and_call_original
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
include_context 'with url'
|
|
113
|
+
|
|
114
|
+
context 'http1' do
|
|
115
|
+
let(:protocol) { :http1 }
|
|
116
|
+
let(:expected_protocol) { Async::HTTP::Protocol::HTTP1 }
|
|
117
|
+
|
|
118
|
+
it 'creates requests using HTTP/1' do
|
|
119
|
+
expect(result.success?).to be true
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
context 'http2' do
|
|
124
|
+
let(:protocol) { :http2 }
|
|
125
|
+
let(:expected_protocol) { Async::HTTP::Protocol::HTTP2 }
|
|
126
|
+
|
|
127
|
+
it 'creates requests using HTTP/2' do
|
|
128
|
+
expect(result.success?).to be true
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
context 'with an unsupported protocol option' do
|
|
134
|
+
before do
|
|
135
|
+
LinkChecker::Async::HTTP.configure do |config|
|
|
136
|
+
config.protocol = :http3
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
include_context 'with url'
|
|
141
|
+
|
|
142
|
+
it 'raises an ArgumentError' do
|
|
143
|
+
expect(result.error?).to be true
|
|
144
|
+
expect(result.error.message).to eq('Unsupported protocol: :http3, expected :http1 or :http2')
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
context 'timeout' do
|
|
149
|
+
before do
|
|
150
|
+
stub_request(:get, 'https://www.example.org/').to_timeout
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
include_context 'with url'
|
|
154
|
+
|
|
155
|
+
around do |example|
|
|
156
|
+
VCR.turned_off { example.run }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it 'times out' do
|
|
160
|
+
expect(result.success?).to be false
|
|
161
|
+
expect(result.error?).to be true
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
context 'with metadata' do
|
|
165
|
+
let(:options) { { foo: :bar } }
|
|
166
|
+
|
|
167
|
+
it 'times out' do
|
|
168
|
+
expect(result.error?).to be true
|
|
169
|
+
expect(result.options).to eq(foo: :bar)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
describe LinkChecker::Async::HTTP::Result do
|
|
176
|
+
describe '#code' do
|
|
177
|
+
it 'returns -1 when there is no response' do
|
|
178
|
+
request = instance_double(Protocol::HTTP::Request)
|
|
179
|
+
result = described_class.new(URI('https://www.example.org'), 'GET', nil, request, nil, {})
|
|
180
|
+
expect(result.code).to eq(-1)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -6,6 +6,10 @@ describe LinkChecker::Callbacks do
|
|
|
6
6
|
subject do
|
|
7
7
|
Class.new do
|
|
8
8
|
include LinkChecker::Callbacks
|
|
9
|
+
|
|
10
|
+
def logger
|
|
11
|
+
@logger ||= Logger.new(nil)
|
|
12
|
+
end
|
|
9
13
|
end.new
|
|
10
14
|
end
|
|
11
15
|
|
|
@@ -40,4 +44,33 @@ describe LinkChecker::Callbacks do
|
|
|
40
44
|
expect(subject).to have_received(:called!).with(:bar)
|
|
41
45
|
end
|
|
42
46
|
end
|
|
47
|
+
|
|
48
|
+
context 'method_missing' do
|
|
49
|
+
it 'invokes callback for a bang method' do
|
|
50
|
+
allow(subject).to receive(:called!)
|
|
51
|
+
subject.on :foo do |data|
|
|
52
|
+
subject.called! data
|
|
53
|
+
end
|
|
54
|
+
subject.foo! :foo
|
|
55
|
+
expect(subject).to have_received(:called!).with(:foo)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'raises NoMethodError for a non-bang method' do
|
|
59
|
+
expect { subject.foo }.to raise_error NoMethodError
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
context 'when a callback raises an error' do
|
|
64
|
+
before do
|
|
65
|
+
subject.on :foo do
|
|
66
|
+
raise StandardError, 'oops'
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'rescues and logs the error' do
|
|
71
|
+
allow(subject.logger).to receive(:error)
|
|
72
|
+
expect(subject.send(:callback, :foo)).to be false
|
|
73
|
+
expect(subject.logger).to have_received(:error)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
43
76
|
end
|
|
@@ -2,9 +2,96 @@
|
|
|
2
2
|
require 'spec_helper'
|
|
3
3
|
|
|
4
4
|
describe LinkChecker::Checker do
|
|
5
|
-
|
|
5
|
+
describe LinkChecker::Cache do
|
|
6
|
+
let(:directory) { Dir.mktmpdir('link-checker-spec-') }
|
|
7
|
+
let(:file) { File.join(directory, 'cache.json') }
|
|
8
|
+
let(:cache) { described_class.new({ file: file, ttl: 60 }, logger: LinkChecker::Logger.default) }
|
|
9
|
+
let(:result) do
|
|
10
|
+
LinkChecker::Net::HTTP::Result.new(
|
|
11
|
+
URI('https://www.example.org/final'), 'GET', URI('https://www.example.org/'),
|
|
12
|
+
{}, Net::HTTPOK.new('1.1', '200', 'OK'), { location: 'private.html' }
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
after do
|
|
17
|
+
FileUtils.remove_entry(directory)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'expires successful entries using the configured TTL' do
|
|
21
|
+
cache.store('key', result)
|
|
22
|
+
allow(Time).to receive(:now).and_return(Time.now + 61)
|
|
23
|
+
expect(cache.fetch('key', 'https://www.example.org/')).to be_nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'retains the final URL and status without persisting metadata or headers' do
|
|
27
|
+
cache.store('key', result)
|
|
28
|
+
restored = cache.fetch('key', 'https://www.example.org/')
|
|
29
|
+
expect(restored.result_uri.to_s).to eq('https://www.example.org/final')
|
|
30
|
+
expect(restored.code).to eq(200)
|
|
31
|
+
data = JSON.parse(File.read(file)).fetch('entries').fetch('key')
|
|
32
|
+
expect(data.keys).to contain_exactly('checked_at', 'method', 'code', 'result_uri')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'merges writes from separate cache instances' do
|
|
36
|
+
other_cache = described_class.new({ file: file, ttl: 60 }, logger: LinkChecker::Logger.default)
|
|
37
|
+
cache.store('one', result)
|
|
38
|
+
other_cache.store('two', result)
|
|
39
|
+
expect(cache.fetch('one', 'https://www.example.org/')).not_to be_nil
|
|
40
|
+
expect(cache.fetch('two', 'https://www.example.org/')).not_to be_nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'recovers from corrupted cache files' do
|
|
44
|
+
File.write(file, 'invalid JSON')
|
|
45
|
+
expect(cache.fetch('key', 'https://www.example.org/')).to be_nil
|
|
46
|
+
cache.store('key', result)
|
|
47
|
+
expect(cache.fetch('key', 'https://www.example.org/').code).to eq(200)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'ignores malformed cache entries' do
|
|
51
|
+
File.write(file, JSON.generate('version' => 1, 'entries' => { 'key' => true }))
|
|
52
|
+
expect(cache.fetch('key', 'https://www.example.org/')).to be_nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'does not store credentials in redirect URLs' do
|
|
56
|
+
result.result_uri = URI('https://user:password@www.example.org/')
|
|
57
|
+
cache.store('key', result)
|
|
58
|
+
expect(File).not_to exist(file)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'requires an explicit file and TTL' do
|
|
62
|
+
expect { described_class.new({}, logger: LinkChecker::Logger.default) }.to raise_error ArgumentError
|
|
63
|
+
expect { described_class.new({ file: file }, logger: LinkChecker::Logger.default) }.to raise_error ArgumentError
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'does not turn a cache write failure into a failed link' do
|
|
67
|
+
allow(FileUtils).to receive(:mkdir_p).and_raise(Errno::EACCES)
|
|
68
|
+
expect { cache.store('key', result) }.not_to raise_error
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
context 'config' do
|
|
6
73
|
it 'requires at least one method' do
|
|
7
|
-
expect {
|
|
74
|
+
expect { described_class.new(methods: []) }.to raise_error ArgumentError, 'Missing methods.'
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'rejects an invalid redirect limit' do
|
|
78
|
+
expect { described_class.new(max_redirects: -1) }.to raise_error ArgumentError, 'Invalid redirect limit: -1'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'validates retry delays and backoff' do
|
|
82
|
+
expect { described_class.new(retry_after: -1) }.to raise_error ArgumentError
|
|
83
|
+
expect { described_class.new(retry_backoff: 0.5) }.to raise_error ArgumentError
|
|
84
|
+
expect { described_class.new(retry_jitter: 2) }.to raise_error ArgumentError
|
|
85
|
+
expect { described_class.new(max_retry_delay: Float::INFINITY) }.to raise_error ArgumentError
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'rejects invalid retry status codes' do
|
|
89
|
+
expect { described_class.new(retry_status_codes: ['503']) }.to raise_error ArgumentError
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'allows per-instance disabling of global Retry-After handling' do
|
|
93
|
+
LinkChecker.config.respect_retry_after = true
|
|
94
|
+
expect(described_class.new(respect_retry_after: false).respect_retry_after).to be false
|
|
8
95
|
end
|
|
9
96
|
end
|
|
10
97
|
end
|
|
@@ -19,6 +19,12 @@ describe LinkChecker::Config do
|
|
|
19
19
|
it 'requires a positive integer' do
|
|
20
20
|
expect { LinkChecker.config.retries = -1 }.to raise_error ArgumentError, 'Invalid number of retries: -1'
|
|
21
21
|
end
|
|
22
|
+
|
|
23
|
+
context 'max_redirects' do
|
|
24
|
+
it 'rejects negative limits' do
|
|
25
|
+
expect { LinkChecker.config.max_redirects = -1 }.to raise_error ArgumentError, 'Invalid redirect limit: -1'
|
|
26
|
+
end
|
|
27
|
+
end
|
|
22
28
|
end
|
|
23
29
|
end
|
|
24
30
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe LinkChecker::Errors::RedirectLoopError do
|
|
6
|
+
subject(:error) do
|
|
7
|
+
described_class.new(['https://www.example.org', 'https://www.example.org/redirect'])
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it 'returns the last url' do
|
|
11
|
+
expect(error.url).to eq 'https://www.example.org/redirect'
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'formats a message with all urls' do
|
|
15
|
+
expect(error.message)
|
|
16
|
+
.to eq 'Redirect loop: https://www.example.org -> https://www.example.org/redirect.'
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -15,6 +15,27 @@ describe LinkChecker::Net::HTTP::Checker do
|
|
|
15
15
|
|
|
16
16
|
it_behaves_like 'a link checker'
|
|
17
17
|
|
|
18
|
+
context 'with an HTTP URL' do
|
|
19
|
+
let(:checker) { described_class.new(methods: ['GET']) }
|
|
20
|
+
|
|
21
|
+
around do |example|
|
|
22
|
+
VCR.turned_off { example.run }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'does not enable TLS' do
|
|
26
|
+
stub_request(:get, 'http://www.example.org/').to_return(status: 200)
|
|
27
|
+
checker.check('http://www.example.org/')
|
|
28
|
+
expect(checker.results[:success].first.code).to eq(200)
|
|
29
|
+
expect(a_request(:get, 'http://www.example.org/')).to have_been_made.once
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe '.config' do
|
|
34
|
+
it 'returns the Config module' do
|
|
35
|
+
expect(LinkChecker::Net::HTTP.config).to eq LinkChecker::Net::HTTP::Config
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
18
39
|
context 'with timeout options', vcr: { cassette_name: '200' } do
|
|
19
40
|
before do
|
|
20
41
|
LinkChecker::Net::HTTP.configure do |config|
|
|
@@ -58,4 +79,15 @@ describe LinkChecker::Net::HTTP::Checker do
|
|
|
58
79
|
end
|
|
59
80
|
end
|
|
60
81
|
end
|
|
82
|
+
|
|
83
|
+
describe LinkChecker::Net::HTTP::Result do
|
|
84
|
+
describe '#code' do
|
|
85
|
+
it 'returns -1 when the response raises' do
|
|
86
|
+
response = instance_double(Net::HTTPResponse)
|
|
87
|
+
allow(response).to receive(:code).and_raise(StandardError)
|
|
88
|
+
result = described_class.new(URI('https://www.example.org'), 'GET', nil, nil, response, {})
|
|
89
|
+
expect(result.code).to eq(-1)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
61
93
|
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe LinkChecker::ResultError do
|
|
6
|
+
subject(:result) do
|
|
7
|
+
described_class.new('https://www.example.org', 'GET', 'https://www.example.org', StandardError.new('oops'))
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it 'is an error' do
|
|
11
|
+
expect(result.error?).to be true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'returns the error class name as code' do
|
|
15
|
+
expect(result.code).to eq 'StandardError'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'classifies native timeouts without replacing the exception' do
|
|
19
|
+
error = Net::ReadTimeout.new('timeout')
|
|
20
|
+
result.error = error
|
|
21
|
+
expect(result.error_type).to eq(:timeout)
|
|
22
|
+
expect(result.error).to equal(error)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'classifies native connection failures' do
|
|
26
|
+
result.error = Errno::ECONNREFUSED.new
|
|
27
|
+
expect(result.error_type).to eq(:connection)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'does not classify programming errors as transient' do
|
|
31
|
+
result.error = ArgumentError.new
|
|
32
|
+
expect(result.error_type).to eq(:other)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe LinkChecker::Result do
|
|
6
|
+
subject(:result) do
|
|
7
|
+
described_class.new('https://www.example.org', 'GET', 'https://www.example.org')
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it 'defaults to not successful' do
|
|
11
|
+
expect(result.success?).to be false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'defaults to not a failure' do
|
|
15
|
+
expect(result.failure?).to be false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'defaults to not an error' do
|
|
19
|
+
expect(result.error?).to be false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'defaults to not a redirect' do
|
|
23
|
+
expect(result.redirect?).to be false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'defaults redirect_to to nil' do
|
|
27
|
+
expect(result.redirect_to).to be_nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'defaults request_headers to an empty hash' do
|
|
31
|
+
expect(result.request_headers).to eq({})
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'defaults code to nil' do
|
|
35
|
+
expect(result.code).to be_nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'defaults error to nil' do
|
|
39
|
+
expect(result.error).to be_nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe LinkChecker::Task do
|
|
6
|
+
subject(:task) do
|
|
7
|
+
described_class.new(
|
|
8
|
+
LinkChecker::Net::HTTP::Checker.new,
|
|
9
|
+
'https://www.example.org',
|
|
10
|
+
'GET',
|
|
11
|
+
'https://www.example.org'
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
describe '#run!' do
|
|
16
|
+
it 'raises NotImplementedError' do
|
|
17
|
+
expect { task.run! }.to raise_error NotImplementedError
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe LinkChecker::Tasks do
|
|
6
|
+
describe 'retry delays' do
|
|
7
|
+
let(:checker) { LinkChecker::Net::HTTP::Checker.new }
|
|
8
|
+
let(:tasks) { described_class.new(checker, LinkChecker::Net::HTTP::Task, 'https://www.example.org', ['GET']) }
|
|
9
|
+
let(:headers) { { 'Retry-After' => '60' } }
|
|
10
|
+
let(:result) { instance_double(LinkChecker::Net::HTTP::Result, response_headers: headers) }
|
|
11
|
+
|
|
12
|
+
before do
|
|
13
|
+
allow(tasks).to receive(:result).and_return(result)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'parses delta-seconds' do
|
|
17
|
+
expect(tasks.send(:_server_retry_delay)).to eq(60)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
context 'with a past HTTP date' do
|
|
21
|
+
let(:headers) { { 'Retry-After' => (Time.now - 60).httpdate } }
|
|
22
|
+
|
|
23
|
+
it 'does not delay a retry' do
|
|
24
|
+
expect(tasks.send(:_server_retry_delay)).to eq(0)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
context 'with an invalid negative delay' do
|
|
29
|
+
let(:headers) { { 'Retry-After' => '-5' } }
|
|
30
|
+
|
|
31
|
+
it 'does not delay a retry' do
|
|
32
|
+
expect(tasks.send(:_server_retry_delay)).to eq(0)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'caps configured delays' do
|
|
37
|
+
checker.retry_after = 120
|
|
38
|
+
checker.max_retry_delay = 10
|
|
39
|
+
expect(tasks.send(:_retry_delay)).to eq(10)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'adds jitter without reducing the minimum delay' do
|
|
43
|
+
checker.retry_after = 2
|
|
44
|
+
checker.retry_jitter = 0.5
|
|
45
|
+
allow(tasks).to receive(:rand).and_return(0.5)
|
|
46
|
+
expect(tasks.send(:_retry_delay)).to eq(2.5)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
describe '#_handle_result' do
|
|
51
|
+
# Reproduces issue #12: SystemStackError when logging causes repeated errors
|
|
52
|
+
# https://github.com/dblock/ruby-link-checker/issues/12
|
|
53
|
+
context 'when logger.info raises an error repeatedly' do
|
|
54
|
+
let(:checker) { LinkChecker::Net::HTTP::Checker.new(methods: ['GET']) }
|
|
55
|
+
let(:task_klass) { LinkChecker::Net::HTTP::Task }
|
|
56
|
+
let(:url) { 'https://www.example.org' }
|
|
57
|
+
|
|
58
|
+
before do
|
|
59
|
+
stub_request(:get, url).to_return(status: 200, body: '', headers: {})
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'does not cause infinite recursion leading to SystemStackError' do
|
|
63
|
+
tasks = described_class.new(checker, task_klass, url, ['GET'])
|
|
64
|
+
|
|
65
|
+
# Stub logger to always raise an error on info, simulating the scenario
|
|
66
|
+
# from issue #12 where logging causes a stack overflow due to the
|
|
67
|
+
# rescue block in _handle_result recursively calling itself
|
|
68
|
+
allow(tasks).to receive(:logger).and_return(
|
|
69
|
+
instance_double(LinkChecker::Logger).tap do |logger|
|
|
70
|
+
allow(logger).to receive(:info).and_raise(StandardError, 'stack level too deep')
|
|
71
|
+
allow(logger).to receive(:error)
|
|
72
|
+
end
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Should not raise SystemStackError - the error should be handled gracefully
|
|
76
|
+
expect { tasks.execute! }.not_to raise_error
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
describe '#retries' do
|
|
82
|
+
let(:checker) { LinkChecker::Net::HTTP::Checker.new(methods: ['GET'], retries: 2) }
|
|
83
|
+
let(:task_klass) { LinkChecker::Net::HTTP::Task }
|
|
84
|
+
let(:url) { 'https://www.example.org' }
|
|
85
|
+
|
|
86
|
+
it 'delegates to the checker' do
|
|
87
|
+
tasks = described_class.new(checker, task_klass, url, ['GET'])
|
|
88
|
+
expect(tasks.send(:retries)).to eq 2
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|