crawlscope 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +38 -0
  4. data/lib/crawlscope/cli.rb +20 -1
  5. data/lib/crawlscope/configuration.rb +10 -1
  6. data/lib/crawlscope/context.rb +1 -1
  7. data/lib/crawlscope/crawl.rb +74 -14
  8. data/lib/crawlscope/crawler.rb +3 -17
  9. data/lib/crawlscope/document_text.rb +7 -2
  10. data/lib/crawlscope/fetch_executor/async.rb +32 -0
  11. data/lib/crawlscope/fetch_executor/threaded.rb +32 -0
  12. data/lib/crawlscope/fetch_executor.rb +43 -0
  13. data/lib/crawlscope/http.rb +7 -1
  14. data/lib/crawlscope/rake_tasks.rb +27 -12
  15. data/lib/crawlscope/reporter.rb +131 -7
  16. data/lib/crawlscope/result.rb +1 -1
  17. data/lib/crawlscope/rules/content_quality.rb +1 -1
  18. data/lib/crawlscope/rules/indexability.rb +155 -20
  19. data/lib/crawlscope/rules/links.rb +379 -12
  20. data/lib/crawlscope/rules/metadata.rb +61 -6
  21. data/lib/crawlscope/rules/structured_data.rb +31 -0
  22. data/lib/crawlscope/rules/uniqueness.rb +45 -4
  23. data/lib/crawlscope/sitemap.rb +39 -12
  24. data/lib/crawlscope/version.rb +1 -1
  25. data/lib/tasks/crawlscope_tasks.rake +24 -24
  26. data/test/crawlscope/cli_test.rb +29 -2
  27. data/test/crawlscope/configuration_test.rb +21 -0
  28. data/test/crawlscope/content_quality_rule_test.rb +18 -0
  29. data/test/crawlscope/crawl_test.rb +167 -3
  30. data/test/crawlscope/crawler_test.rb +61 -0
  31. data/test/crawlscope/fetch_executor_test.rb +44 -0
  32. data/test/crawlscope/indexability_rule_test.rb +33 -0
  33. data/test/crawlscope/links_rule_test.rb +249 -3
  34. data/test/crawlscope/metadata_rule_test.rb +36 -0
  35. data/test/crawlscope/rake_tasks_test.rb +70 -0
  36. data/test/crawlscope/reporter_test.rb +136 -7
  37. data/test/crawlscope/result_test.rb +35 -0
  38. data/test/crawlscope/sitemap_test.rb +76 -0
  39. data/test/crawlscope/structured_data_rule_test.rb +56 -0
  40. data/test/crawlscope/uniqueness_rule_test.rb +17 -2
  41. data/test/performance/async_fetch_benchmark.rb +127 -0
  42. data/test/performance/fetch_executor_matrix.rb +162 -0
  43. data/test/performance/sitemap_expansion_benchmark.rb +121 -0
  44. metadata +39 -2
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class CrawlscopeFetchExecutorTest < Minitest::Test
6
+ class RecordingExecutor
7
+ attr_reader :items
8
+
9
+ def call(items)
10
+ @items = items
11
+ items.map { |item| yield(item) }
12
+ end
13
+ end
14
+
15
+ def test_map_preserves_input_order
16
+ results = Crawlscope::FetchExecutor.map(name: :threaded, concurrency: 2, items: [3, 1, 2]) do |item|
17
+ item * 10
18
+ end
19
+
20
+ assert_equal [30, 10, 20], results
21
+ end
22
+
23
+ def test_map_uses_sequential_fallback_for_single_item
24
+ executor = RecordingExecutor.new
25
+
26
+ results = Crawlscope::FetchExecutor.map(name: executor, concurrency: 4, items: ["one"]) do |item|
27
+ item.upcase
28
+ end
29
+
30
+ assert_equal ["ONE"], results
31
+ assert_nil executor.items
32
+ end
33
+
34
+ def test_map_uses_injected_executor_for_parallel_work
35
+ executor = RecordingExecutor.new
36
+
37
+ results = Crawlscope::FetchExecutor.map(name: executor, concurrency: 4, items: %w[a b]) do |item|
38
+ item.upcase
39
+ end
40
+
41
+ assert_equal %w[A B], results
42
+ assert_equal %w[a b], executor.items
43
+ end
44
+ end
@@ -20,6 +20,39 @@ class CrawlscopeIndexabilityRuleTest < Minitest::Test
20
20
  assert_equal :noindex_meta, issue.code
21
21
  assert_equal :error, issue.severity
22
22
  assert_equal "noindex, follow", issue.details[:content]
23
+
24
+ codes = issues.to_a.map(&:code)
25
+ assert_includes codes, :noindex_follow_meta
26
+ assert_includes codes, :sitemap_noindex_url
27
+ end
28
+
29
+ def test_reports_meta_nofollow
30
+ issues = Crawlscope::IssueCollection.new
31
+ page = page_with(
32
+ body: <<~HTML
33
+ <html>
34
+ <head><meta name="robots" content="nofollow"></head>
35
+ <body><main>Visible content</main></body>
36
+ </html>
37
+ HTML
38
+ )
39
+
40
+ Crawlscope::Rules::Indexability.new.call(urls: [page.url], pages: [page], issues: issues)
41
+
42
+ assert_equal [:nofollow_meta], issues.to_a.map(&:code)
43
+ end
44
+
45
+ def test_reports_noindex_nofollow_header
46
+ issues = Crawlscope::IssueCollection.new
47
+ page = page_with(headers: {"X-Robots-Tag" => "googlebot: noindex, nofollow"})
48
+
49
+ Crawlscope::Rules::Indexability.new.call(urls: [page.url], pages: [page], issues: issues)
50
+
51
+ codes = issues.to_a.map(&:code)
52
+ assert_includes codes, :noindex_header
53
+ assert_includes codes, :nofollow_header
54
+ assert_includes codes, :noindex_nofollow_header
55
+ assert_includes codes, :sitemap_noindex_url
23
56
  end
24
57
 
25
58
  def test_reports_x_robots_tag_noindex
@@ -41,7 +41,7 @@ class CrawlscopeLinksRuleTest < Minitest::Test
41
41
  context: context
42
42
  )
43
43
 
44
- assert_equal [:broken_internal_link], issues.to_a.map(&:code)
44
+ assert_includes issues.to_a.map(&:code), :broken_internal_link
45
45
  assert_includes issues.to_a.first.message, "HTTP 404"
46
46
  end
47
47
 
@@ -114,8 +114,226 @@ class CrawlscopeLinksRuleTest < Minitest::Test
114
114
  context: context
115
115
  )
116
116
 
117
- assert_equal [:low_inbound_anchor_links], issues.to_a.map(&:code)
118
- assert_equal "https://example.com/guide", issues.to_a.first.url
117
+ orphan_issue = issues.to_a.find { |item| item.code == :orphan_page }
118
+ assert orphan_issue
119
+ assert_includes issues.to_a.map(&:code), :low_dofollow_inlinks
120
+ assert_equal "https://example.com/guide", orphan_issue.url
121
+ end
122
+
123
+ def test_reports_pages_with_no_outgoing_internal_links
124
+ issues = Crawlscope::IssueCollection.new
125
+
126
+ Crawlscope::Rules::Links.new.call(
127
+ urls: ["https://example.com/guide", "https://example.com/pricing"],
128
+ pages: [
129
+ page(url: "https://example.com/guide", body: "<main><a href=\"/pricing\">Pricing</a></main>"),
130
+ page(url: "https://example.com/pricing", body: "<main><p>Pricing</p></main>")
131
+ ],
132
+ issues: issues,
133
+ context: context
134
+ )
135
+
136
+ issue = issues.to_a.find { |item| item.code == :page_has_no_outgoing_links }
137
+ assert issue
138
+ assert_equal "https://example.com/pricing", issue.url
139
+ end
140
+
141
+ def test_reports_nofollow_outlinks_and_inlink_follow_mix
142
+ issues = Crawlscope::IssueCollection.new
143
+
144
+ Crawlscope::Rules::Links.new.call(
145
+ urls: ["https://example.com/guide", "https://example.com/pricing", "https://example.com/about"],
146
+ pages: [
147
+ page(url: "https://example.com/guide", body: "<main><a href=\"/pricing\" rel=\"nofollow\">Pricing</a><a href=\"/about\">About</a></main>"),
148
+ page(url: "https://example.com/about", body: "<main><a href=\"/pricing\">Pricing</a></main>"),
149
+ page(url: "https://example.com/pricing", body: "<main><p>Pricing</p></main>")
150
+ ],
151
+ issues: issues,
152
+ context: context(resolver: ->(target_url) { {crawled: true, error: nil, final_url: target_url, status: 200} })
153
+ )
154
+
155
+ codes = issues.to_a.map(&:code)
156
+ assert_includes codes, :nofollow_internal_outlinks
157
+ assert_includes codes, :mixed_follow_internal_inlinks
158
+ end
159
+
160
+ def test_reports_only_nofollow_internal_inlinks
161
+ issues = Crawlscope::IssueCollection.new
162
+
163
+ Crawlscope::Rules::Links.new.call(
164
+ urls: ["https://example.com/guide", "https://example.com/pricing"],
165
+ pages: [
166
+ page(url: "https://example.com/guide", body: "<main><a href=\"/pricing\" rel=\"nofollow\">Pricing</a></main>"),
167
+ page(url: "https://example.com/pricing", body: "<main><p>Pricing</p></main>")
168
+ ],
169
+ issues: issues,
170
+ context: context(resolver: ->(target_url) { {crawled: true, error: nil, final_url: target_url, status: 200} })
171
+ )
172
+
173
+ assert_includes issues.to_a.map(&:code), :only_nofollow_internal_inlinks
174
+ end
175
+
176
+ def test_reports_https_pages_linking_to_internal_http_urls
177
+ issues = Crawlscope::IssueCollection.new
178
+
179
+ Crawlscope::Rules::Links.new.call(
180
+ urls: ["https://example.com/guide"],
181
+ pages: [page(url: "https://example.com/guide", body: "<main><a href=\"http://example.com/pricing\">Pricing</a></main>")],
182
+ issues: issues,
183
+ context: context(resolver: ->(target_url) { {crawled: true, error: nil, final_url: target_url, status: 200} })
184
+ )
185
+
186
+ assert_includes issues.to_a.map(&:code), :http_internal_link
187
+ end
188
+
189
+ def test_reports_canonical_target_link_issues
190
+ issues = Crawlscope::IssueCollection.new
191
+ resolver = lambda do |target_url|
192
+ redirects = target_url == "https://example.com/canonical-about"
193
+ status = redirects ? 301 : 200
194
+ final_url = redirects ? "https://example.com/about" : target_url
195
+ {crawled: false, error: nil, final_url: final_url, status: status}
196
+ end
197
+
198
+ Crawlscope::Rules::Links.new.call(
199
+ urls: ["https://example.com/guide", "https://example.com/about"],
200
+ pages: [
201
+ page(url: "https://example.com/guide", body: "<main><a href=\"/about\">About</a></main>"),
202
+ page(
203
+ url: "https://example.com/about",
204
+ body: <<~HTML
205
+ <html>
206
+ <head><link rel="canonical" href="https://example.com/canonical-about"></head>
207
+ <body><main><p>About</p></main></body>
208
+ </html>
209
+ HTML
210
+ )
211
+ ],
212
+ issues: issues,
213
+ context: context(resolver: resolver)
214
+ )
215
+
216
+ codes = issues.to_a.map(&:code)
217
+ assert_includes codes, :canonical_no_internal_inlinks
218
+ assert_includes codes, :canonical_points_to_redirect
219
+ end
220
+
221
+ def test_does_not_report_missing_inlinks_for_root_canonical
222
+ issues = Crawlscope::IssueCollection.new
223
+ resolver = lambda do |target_url|
224
+ {crawled: true, error: nil, final_url: target_url, html: true, status: 200}
225
+ end
226
+
227
+ Crawlscope::Rules::Links.new.call(
228
+ urls: ["https://example.com/", "https://example.com/about"],
229
+ pages: [
230
+ page(
231
+ url: "https://example.com/",
232
+ body: <<~HTML
233
+ <html>
234
+ <head><link rel="canonical" href="https://example.com/"></head>
235
+ <body><main><a href="/about">About</a></main></body>
236
+ </html>
237
+ HTML
238
+ ),
239
+ page(url: "https://example.com/about", body: "<main><a href=\"/\">Home</a></main>")
240
+ ],
241
+ issues: issues,
242
+ context: context(resolver: resolver)
243
+ )
244
+
245
+ refute_includes issues.to_a.map(&:code), :canonical_no_internal_inlinks
246
+ end
247
+
248
+ def test_reports_indexable_internal_pages_missing_from_sitemap
249
+ issues = Crawlscope::IssueCollection.new
250
+ resolver = lambda do |target_url|
251
+ {
252
+ crawled: false,
253
+ error: nil,
254
+ final_url: target_url,
255
+ html: true,
256
+ status: 200
257
+ }
258
+ end
259
+
260
+ Crawlscope::Rules::Links.new.call(
261
+ urls: ["https://example.com/guide"],
262
+ pages: [page(url: "https://example.com/guide", body: "<main><a href=\"/hidden\">Hidden</a></main>")],
263
+ issues: issues,
264
+ context: context(resolver: resolver)
265
+ )
266
+
267
+ issue = issues.to_a.find { |item| item.code == :indexable_page_missing_from_sitemap }
268
+ assert issue
269
+ assert_equal "https://example.com/hidden", issue.url
270
+ end
271
+
272
+ def test_does_not_report_noindex_internal_pages_missing_from_sitemap
273
+ issues = Crawlscope::IssueCollection.new
274
+ resolver = lambda do |target_url|
275
+ {
276
+ crawled: false,
277
+ doc: Nokogiri::HTML("<head><meta name=\"robots\" content=\"noindex, follow\"></head>"),
278
+ error: nil,
279
+ final_url: target_url,
280
+ headers: {},
281
+ html: true,
282
+ status: 200
283
+ }
284
+ end
285
+
286
+ Crawlscope::Rules::Links.new.call(
287
+ urls: ["https://example.com/guide"],
288
+ pages: [page(url: "https://example.com/guide", body: "<main><a href=\"/hidden\">Hidden</a></main>")],
289
+ issues: issues,
290
+ context: context(resolver: resolver)
291
+ )
292
+
293
+ refute_includes issues.to_a.map(&:code), :indexable_page_missing_from_sitemap
294
+ end
295
+
296
+ def test_does_not_report_x_robots_noindex_internal_pages_missing_from_sitemap
297
+ issues = Crawlscope::IssueCollection.new
298
+ resolver = lambda do |target_url|
299
+ {
300
+ crawled: false,
301
+ doc: Nokogiri::HTML("<main>Hidden</main>"),
302
+ error: nil,
303
+ final_url: target_url,
304
+ headers: {"X-Robots-Tag" => "noindex"},
305
+ html: true,
306
+ status: 200
307
+ }
308
+ end
309
+
310
+ Crawlscope::Rules::Links.new.call(
311
+ urls: ["https://example.com/guide"],
312
+ pages: [page(url: "https://example.com/guide", body: "<main><a href=\"/hidden\">Hidden</a></main>")],
313
+ issues: issues,
314
+ context: context(resolver: resolver)
315
+ )
316
+
317
+ refute_includes issues.to_a.map(&:code), :indexable_page_missing_from_sitemap
318
+ end
319
+
320
+ def test_reports_url_hygiene_issues
321
+ issues = Crawlscope::IssueCollection.new
322
+ long_path = "a" * 2_050
323
+
324
+ Crawlscope::Rules::Links.new.call(
325
+ urls: ["https://example.com//bad", "https://example.com/#{long_path}"],
326
+ pages: [
327
+ page(url: "https://example.com//bad", body: "<main><a href=\"/ok\">OK</a></main>"),
328
+ page(url: "https://example.com/#{long_path}", body: "<main><a href=\"/ok\">OK</a></main>")
329
+ ],
330
+ issues: issues,
331
+ context: context(resolver: ->(target_url) { {crawled: false, error: nil, final_url: target_url, html: true, status: 200} })
332
+ )
333
+
334
+ codes = issues.to_a.map(&:code)
335
+ assert_includes codes, :url_double_slash
336
+ assert_includes codes, :url_too_long
119
337
  end
120
338
 
121
339
  def test_counts_root_page_links_as_inbound_links
@@ -157,6 +375,32 @@ class CrawlscopeLinksRuleTest < Minitest::Test
157
375
  assert_includes redirect_issue.message, "https://example.com/pricing"
158
376
  end
159
377
 
378
+ def test_reuses_link_target_resolution_for_later_link_checks
379
+ issues = Crawlscope::IssueCollection.new
380
+ resolution_counts = Hash.new(0)
381
+ resolver = lambda do |target_url|
382
+ resolution_counts[target_url] += 1
383
+ {
384
+ crawled: false,
385
+ doc: Nokogiri::HTML("<main>Hidden</main>"),
386
+ error: nil,
387
+ final_url: target_url,
388
+ headers: {},
389
+ html: true,
390
+ status: 200
391
+ }
392
+ end
393
+
394
+ Crawlscope::Rules::Links.new.call(
395
+ urls: ["https://example.com/guide"],
396
+ pages: [page(url: "https://example.com/guide", body: "<main><a href=\"/hidden\">Hidden</a></main>")],
397
+ issues: issues,
398
+ context: context(resolver: resolver)
399
+ )
400
+
401
+ assert_equal 1, resolution_counts.fetch("https://example.com/hidden")
402
+ end
403
+
160
404
  def test_ignores_links_that_should_not_be_crawled
161
405
  issues = Crawlscope::IssueCollection.new
162
406
 
@@ -217,6 +461,7 @@ class CrawlscopeLinksRuleTest < Minitest::Test
217
461
  crawled: true,
218
462
  error: nil,
219
463
  final_url: target_url,
464
+ html: true,
220
465
  status: 200
221
466
  }
222
467
  when "https://example.com/missing"
@@ -224,6 +469,7 @@ class CrawlscopeLinksRuleTest < Minitest::Test
224
469
  crawled: false,
225
470
  error: nil,
226
471
  final_url: target_url,
472
+ html: false,
227
473
  status: 404
228
474
  }
229
475
  end
@@ -48,6 +48,42 @@ class CrawlscopeMetadataRuleTest < Minitest::Test
48
48
  refute_includes issues.to_a.map(&:code), :canonical_mismatch
49
49
  end
50
50
 
51
+ def test_reports_multiple_title_multiple_descriptions_empty_h1_and_sitemap_canonical_mismatch
52
+ issues = Crawlscope::IssueCollection.new
53
+ invalid_page = page(
54
+ body: <<~HTML
55
+ <html>
56
+ <head>
57
+ <title>About</title>
58
+ <title>Duplicate About</title>
59
+ <meta name="description" content="A clear description that is long enough for search snippets, local validation checks, and realistic production metadata audits.">
60
+ <meta name="description" content="Duplicate description">
61
+ <link rel="canonical" href="https://example.com/canonical-about">
62
+ <meta property="og:title" content="About">
63
+ <meta property="og:description" content="About page">
64
+ <meta property="og:url" content="https://example.com/about">
65
+ <meta property="og:type" content="website">
66
+ <meta property="og:image" content="https://example.com/icon.png">
67
+ </head>
68
+ <body><main><h1> </h1></main></body>
69
+ </html>
70
+ HTML
71
+ )
72
+
73
+ Crawlscope::Rules::Metadata.new.call(
74
+ urls: [invalid_page.url],
75
+ pages: [invalid_page],
76
+ issues: issues
77
+ )
78
+
79
+ codes = issues.to_a.map(&:code)
80
+ assert_includes codes, :multiple_title_tags
81
+ assert_includes codes, :multiple_meta_descriptions
82
+ assert_includes codes, :empty_h1
83
+ assert_includes codes, :canonical_mismatch
84
+ assert_includes codes, :non_canonical_page_in_sitemap
85
+ end
86
+
51
87
  private
52
88
 
53
89
  def page(url: "https://example.com/about", body: nil)
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class CrawlscopeRakeTasksTest < Minitest::Test
6
+ def setup
7
+ @original_start = Crawlscope::Cli.method(:start)
8
+ end
9
+
10
+ def teardown
11
+ singleton_class = class << Crawlscope::Cli; self; end
12
+ original_start = @original_start
13
+ singleton_class.define_method(:start) do |*args, **kwargs|
14
+ original_start.call(*args, **kwargs)
15
+ end
16
+ end
17
+
18
+ def test_validate_passes_rake_arguments_to_cli
19
+ calls = capture_cli_calls
20
+
21
+ Crawlscope::RakeTasks.validate(
22
+ url: "http://localhost:3001",
23
+ sitemap_path: "http://localhost:3001/sitemap.xml",
24
+ rule_names: "metadata,links"
25
+ )
26
+
27
+ assert_equal(
28
+ ["validate", "--url", "http://localhost:3001", "--sitemap", "http://localhost:3001/sitemap.xml", "--rules", "metadata,links"],
29
+ calls.fetch(0).fetch(:argv)
30
+ )
31
+ end
32
+
33
+ def test_validate_rule_passes_rule_and_rake_arguments_to_cli
34
+ calls = capture_cli_calls
35
+
36
+ Crawlscope::RakeTasks.validate_rule(
37
+ "metadata",
38
+ url: "http://localhost:3001",
39
+ sitemap_path: "http://localhost:3001/sitemap.xml"
40
+ )
41
+
42
+ assert_equal(
43
+ ["validate", "--url", "http://localhost:3001", "--sitemap", "http://localhost:3001/sitemap.xml", "--rules", "metadata"],
44
+ calls.fetch(0).fetch(:argv)
45
+ )
46
+ end
47
+
48
+ def test_ldjson_passes_rake_url_argument_to_cli
49
+ calls = capture_cli_calls
50
+
51
+ Crawlscope::RakeTasks.ldjson(urls: "http://localhost:3001/article")
52
+
53
+ assert_equal(
54
+ ["ldjson", "--url", "http://localhost:3001/article"],
55
+ calls.fetch(0).fetch(:argv)
56
+ )
57
+ end
58
+
59
+ private
60
+
61
+ def capture_cli_calls
62
+ calls = []
63
+ singleton_class = class << Crawlscope::Cli; self; end
64
+ singleton_class.define_method(:start) do |argv, **kwargs|
65
+ calls << {argv: argv, kwargs: kwargs}
66
+ 0
67
+ end
68
+ calls
69
+ end
70
+ end
@@ -23,11 +23,25 @@ class CrawlscopeReporterTest < Minitest::Test
23
23
  refute_includes output, "Status: FAILED"
24
24
  end
25
25
 
26
- def test_reports_failed_result_with_severity_counts
26
+ def test_reports_warning_result_with_grouped_one_line_issues
27
27
  io = StringIO.new
28
28
  issues = Crawlscope::IssueCollection.new
29
+ 4.times do |index|
30
+ issues.add(
31
+ code: :low_dofollow_inlinks,
32
+ severity: :warning,
33
+ category: :links,
34
+ url: "https://example.com/page-#{index + 1}",
35
+ message: "dofollow inbound links 1 below 2",
36
+ details: {
37
+ dofollow_inbound_count: 1,
38
+ minimum: 2,
39
+ source_urls: ["https://example.com/source-#{index + 1}"]
40
+ }
41
+ )
42
+ end
29
43
  issues.add(code: :missing_title, severity: :warning, category: :metadata, url: "https://example.com/a", message: "missing <title>", details: {})
30
- issues.add(code: :broken_internal_link, severity: :notice, category: :links, url: "https://example.com/b", message: "broken internal link", details: {})
44
+
31
45
  result = Crawlscope::Result.new(
32
46
  base_url: "https://example.com",
33
47
  sitemap_path: "/tmp/sitemap.xml",
@@ -40,11 +54,126 @@ class CrawlscopeReporterTest < Minitest::Test
40
54
 
41
55
  output = io.string
42
56
 
57
+ assert_includes output, "Status: WARNINGS"
58
+ refute_includes output, "Status: FAILED"
59
+ assert_includes output, "Issues: 5 total (5 warnings)"
60
+ assert_includes output, "Summary:"
61
+ assert_includes output, "links / low_dofollow_inlinks: 4"
62
+ assert_includes output, " - /page-1 inbound 1/2 sources: /source-1"
63
+ assert_includes output, " - /page-4 inbound 1/2 sources: /source-4"
64
+ assert_includes output, "metadata / missing_title: 1"
65
+ refute_includes output, "Severity:"
66
+ refute_includes output, "Category:"
67
+ refute_includes output, "... 1 more"
68
+ end
69
+
70
+ def test_reports_failed_status_when_errors_are_present
71
+ io = StringIO.new
72
+ issues = Crawlscope::IssueCollection.new
73
+ issues.add(code: :fetch_failed, severity: :error, category: :crawl, url: "https://example.com/a", message: "timeout", details: {})
74
+ issues.add(code: :missing_title, severity: :warning, category: :metadata, url: "https://example.com/a", message: "missing <title>", details: {})
75
+
76
+ result = Crawlscope::Result.new(
77
+ base_url: "https://example.com",
78
+ sitemap_path: "/tmp/sitemap.xml",
79
+ urls: ["https://example.com/a"],
80
+ pages: [Object.new],
81
+ issues: issues
82
+ )
83
+
84
+ Crawlscope::Reporter.new(io: io).report(result)
85
+
86
+ output = io.string
87
+
43
88
  assert_includes output, "Status: FAILED"
44
- assert_includes output, "Issues: 2"
45
- assert_includes output, "notice: 1"
46
- assert_includes output, "warning: 1"
47
- assert_includes output, "- [warning] https://example.com/a missing <title>"
48
- assert_includes output, "- [notice] https://example.com/b broken internal link"
89
+ assert_includes output, "Issues: 2 total (1 error, 1 warning)"
90
+ end
91
+
92
+ def test_limits_large_issue_groups
93
+ io = StringIO.new
94
+ issues = Crawlscope::IssueCollection.new
95
+ 21.times do |index|
96
+ issues.add(
97
+ code: :low_dofollow_inlinks,
98
+ severity: :warning,
99
+ category: :links,
100
+ url: "https://example.com/page-#{index + 1}",
101
+ message: "dofollow inbound links 1 below 2",
102
+ details: {dofollow_inbound_count: 1, minimum: 2}
103
+ )
104
+ end
105
+
106
+ result = Crawlscope::Result.new(
107
+ base_url: "https://example.com",
108
+ sitemap_path: "/tmp/sitemap.xml",
109
+ urls: ["https://example.com"],
110
+ pages: [Object.new],
111
+ issues: issues
112
+ )
113
+
114
+ Crawlscope::Reporter.new(io: io).report(result)
115
+
116
+ output = io.string
117
+
118
+ assert_includes output, "links / low_dofollow_inlinks: 21"
119
+ assert_includes output, " - /page-20 inbound 1/2"
120
+ refute_includes output, " - /page-21"
121
+ assert_includes output, " ... 1 more"
122
+ end
123
+
124
+ def test_reports_ratio_with_enough_precision_to_show_threshold_difference
125
+ io = StringIO.new
126
+ issues = Crawlscope::IssueCollection.new
127
+ issues.add(
128
+ code: :low_unique_token_ratio,
129
+ severity: :warning,
130
+ category: :content_quality,
131
+ url: "https://example.com/a",
132
+ message: "visible text has low token variety",
133
+ details: {ratio: 0.249, threshold: 0.25}
134
+ )
135
+
136
+ result = Crawlscope::Result.new(
137
+ base_url: "https://example.com",
138
+ sitemap_path: "/tmp/sitemap.xml",
139
+ urls: ["https://example.com/a"],
140
+ pages: [Object.new],
141
+ issues: issues
142
+ )
143
+
144
+ Crawlscope::Reporter.new(io: io).report(result)
145
+
146
+ assert_includes io.string, "ratio 0.249/0.250"
147
+ end
148
+
149
+ def test_reports_source_details_on_one_line
150
+ io = StringIO.new
151
+ issues = Crawlscope::IssueCollection.new
152
+ 4.times do |index|
153
+ issues.add(
154
+ code: :indexable_page_missing_from_sitemap,
155
+ severity: :warning,
156
+ category: :sitemaps,
157
+ url: "https://example.com/overview-#{index + 1}",
158
+ message: "indexable internal page is missing from sitemap",
159
+ details: {source_url: "https://example.com/source-#{index + 1}"}
160
+ )
161
+ end
162
+
163
+ result = Crawlscope::Result.new(
164
+ base_url: "https://example.com",
165
+ sitemap_path: "/tmp/sitemap.xml",
166
+ urls: ["https://example.com"],
167
+ pages: [Object.new],
168
+ issues: issues
169
+ )
170
+
171
+ Crawlscope::Reporter.new(io: io).report(result)
172
+
173
+ output = io.string
174
+
175
+ assert_includes output, "sitemaps / indexable_page_missing_from_sitemap: 4"
176
+ assert_includes output, " - /overview-1 indexable internal page is missing from sitemap source: /source-1"
177
+ assert_includes output, " - /overview-4 indexable internal page is missing from sitemap source: /source-4"
49
178
  end
50
179
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class CrawlscopeResultTest < Minitest::Test
6
+ def test_ok_when_result_has_warnings_only
7
+ issues = Crawlscope::IssueCollection.new
8
+ issues.add(code: :missing_title, severity: :warning, category: :metadata, url: "https://example.com", message: "missing <title>", details: {})
9
+
10
+ result = result_with(issues)
11
+
12
+ assert result.ok?
13
+ end
14
+
15
+ def test_not_ok_when_result_has_errors
16
+ issues = Crawlscope::IssueCollection.new
17
+ issues.add(code: :fetch_failed, severity: :error, category: :crawl, url: "https://example.com", message: "timeout", details: {})
18
+
19
+ result = result_with(issues)
20
+
21
+ refute result.ok?
22
+ end
23
+
24
+ private
25
+
26
+ def result_with(issues)
27
+ Crawlscope::Result.new(
28
+ base_url: "https://example.com",
29
+ sitemap_path: "/tmp/sitemap.xml",
30
+ urls: ["https://example.com"],
31
+ pages: [Object.new],
32
+ issues: issues
33
+ )
34
+ end
35
+ end