crawlscope 0.7.2 → 0.8.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +95 -18
  4. data/lib/crawlscope/browser.rb +18 -1
  5. data/lib/crawlscope/configuration.rb +6 -1
  6. data/lib/crawlscope/crawl.rb +5 -3
  7. data/lib/crawlscope/http.rb +13 -2
  8. data/lib/crawlscope/page.rb +8 -0
  9. data/lib/crawlscope/reporter.rb +144 -61
  10. data/lib/crawlscope/request_headers.rb +34 -0
  11. data/lib/crawlscope/result.rb +4 -0
  12. data/lib/crawlscope/rules/content_quality.rb +5 -5
  13. data/lib/crawlscope/rules/indexability.rb +1 -1
  14. data/lib/crawlscope/rules/links.rb +28 -34
  15. data/lib/crawlscope/rules/metadata.rb +25 -25
  16. data/lib/crawlscope/rules/structured_data.rb +13 -4
  17. data/lib/crawlscope/rules/uniqueness.rb +9 -7
  18. data/lib/crawlscope/run.rb +0 -10
  19. data/lib/crawlscope/schema_registry.rb +17 -1
  20. data/lib/crawlscope/server_timing/reporter.rb +114 -0
  21. data/lib/crawlscope/server_timing/summary.rb +107 -0
  22. data/lib/crawlscope/server_timing.rb +138 -0
  23. data/lib/crawlscope/sitemap.rb +17 -5
  24. data/lib/crawlscope/structured_data/error_formatter.rb +42 -0
  25. data/lib/crawlscope/structured_data/reporter.rb +10 -10
  26. data/lib/crawlscope/version.rb +1 -1
  27. data/lib/generators/crawlscope/templates/initializer.rb.tt +1 -1
  28. data/test/crawlscope/browser_test.rb +56 -3
  29. data/test/crawlscope/configuration_test.rb +50 -10
  30. data/test/crawlscope/crawl_test.rb +65 -0
  31. data/test/crawlscope/http_test.rb +60 -1
  32. data/test/crawlscope/install_generator_test.rb +9 -11
  33. data/test/crawlscope/links_rule_test.rb +40 -4
  34. data/test/crawlscope/metadata_rule_test.rb +37 -4
  35. data/test/crawlscope/reporter_test.rb +249 -20
  36. data/test/crawlscope/run_test.rb +4 -12
  37. data/test/crawlscope/schema_registry_test.rb +33 -0
  38. data/test/crawlscope/server_timing_summary_test.rb +87 -0
  39. data/test/crawlscope/server_timing_test.rb +101 -0
  40. data/test/crawlscope/sitemap_test.rb +21 -0
  41. data/test/crawlscope/structured_data_error_formatter_test.rb +46 -0
  42. data/test/crawlscope/structured_data_reporter_test.rb +26 -0
  43. data/test/crawlscope/structured_data_rule_test.rb +2 -0
  44. data/test/crawlscope/uniqueness_rule_test.rb +46 -3
  45. metadata +10 -2
@@ -10,7 +10,7 @@ class CrawlscopeReporterTest < Minitest::Test
10
10
  base_url: "https://example.com",
11
11
  sitemap_path: "/tmp/sitemap.xml",
12
12
  urls: ["https://example.com"],
13
- pages: [Object.new],
13
+ pages: [page],
14
14
  issues: Crawlscope::IssueCollection.new
15
15
  )
16
16
 
@@ -21,6 +21,7 @@ class CrawlscopeReporterTest < Minitest::Test
21
21
  assert_includes output, "Crawlscope validation"
22
22
  assert_includes output, "Status: OK"
23
23
  refute_includes output, "Status: FAILED"
24
+ refute_includes output, "Server Timing:"
24
25
  end
25
26
 
26
27
  def test_reports_warning_result_with_grouped_one_line_issues
@@ -46,7 +47,7 @@ class CrawlscopeReporterTest < Minitest::Test
46
47
  base_url: "https://example.com",
47
48
  sitemap_path: "/tmp/sitemap.xml",
48
49
  urls: ["https://example.com/a", "https://example.com/b"],
49
- pages: [Object.new, Object.new],
50
+ pages: [page("/one"), page("/two")],
50
51
  issues: issues
51
52
  )
52
53
 
@@ -57,11 +58,11 @@ class CrawlscopeReporterTest < Minitest::Test
57
58
  assert_includes output, "Status: WARNINGS"
58
59
  refute_includes output, "Status: FAILED"
59
60
  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"
61
+ refute_includes output, "Summary:"
62
+ assert_includes output, "Links: Too few incoming links without nofollow (4 warnings)"
63
+ assert_includes output, " - /page-1 incoming links without nofollow: 1; minimum 2\n linked from: /source-1"
64
+ assert_includes output, " - /page-4 incoming links without nofollow: 1; minimum 2\n linked from: /source-4"
65
+ assert_includes output, "Metadata: Missing title (1 warning)"
65
66
  refute_includes output, "Severity:"
66
67
  refute_includes output, "Category:"
67
68
  refute_includes output, "... 1 more"
@@ -77,7 +78,7 @@ class CrawlscopeReporterTest < Minitest::Test
77
78
  base_url: "https://example.com",
78
79
  sitemap_path: "/tmp/sitemap.xml",
79
80
  urls: ["https://example.com/a"],
80
- pages: [Object.new],
81
+ pages: [page],
81
82
  issues: issues
82
83
  )
83
84
 
@@ -89,7 +90,7 @@ class CrawlscopeReporterTest < Minitest::Test
89
90
  assert_includes output, "Issues: 2 total (1 error, 1 warning)"
90
91
  end
91
92
 
92
- def test_limits_large_issue_groups
93
+ def test_reports_every_issue_in_large_groups
93
94
  io = StringIO.new
94
95
  issues = Crawlscope::IssueCollection.new
95
96
  21.times do |index|
@@ -107,7 +108,7 @@ class CrawlscopeReporterTest < Minitest::Test
107
108
  base_url: "https://example.com",
108
109
  sitemap_path: "/tmp/sitemap.xml",
109
110
  urls: ["https://example.com"],
110
- pages: [Object.new],
111
+ pages: [page],
111
112
  issues: issues
112
113
  )
113
114
 
@@ -115,10 +116,10 @@ class CrawlscopeReporterTest < Minitest::Test
115
116
 
116
117
  output = io.string
117
118
 
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"
119
+ assert_includes output, "Links: Too few incoming links without nofollow (21 warnings)"
120
+ assert_includes output, " - /page-20 incoming links without nofollow: 1; minimum 2"
121
+ assert_includes output, " - /page-21"
122
+ refute_includes output, " ... 1 more"
122
123
  end
123
124
 
124
125
  def test_reports_ratio_with_enough_precision_to_show_threshold_difference
@@ -137,13 +138,13 @@ class CrawlscopeReporterTest < Minitest::Test
137
138
  base_url: "https://example.com",
138
139
  sitemap_path: "/tmp/sitemap.xml",
139
140
  urls: ["https://example.com/a"],
140
- pages: [Object.new],
141
+ pages: [page],
141
142
  issues: issues
142
143
  )
143
144
 
144
145
  Crawlscope::Reporter.new(io: io).report(result)
145
146
 
146
- assert_includes io.string, "ratio 0.249/0.250"
147
+ assert_includes io.string, "unique words: 24.9%; minimum 25%"
147
148
  end
148
149
 
149
150
  def test_reports_source_details_on_one_line
@@ -164,7 +165,7 @@ class CrawlscopeReporterTest < Minitest::Test
164
165
  base_url: "https://example.com",
165
166
  sitemap_path: "/tmp/sitemap.xml",
166
167
  urls: ["https://example.com"],
167
- pages: [Object.new],
168
+ pages: [page],
168
169
  issues: issues
169
170
  )
170
171
 
@@ -172,8 +173,236 @@ class CrawlscopeReporterTest < Minitest::Test
172
173
 
173
174
  output = io.string
174
175
 
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"
176
+ assert_includes output, "Sitemaps: Linked pages missing from sitemap (4 warnings)"
177
+ assert_includes output, " - /overview-1 linked page is missing from sitemap\n linked from: /source-1"
178
+ assert_includes output, " - /overview-4 linked page is missing from sitemap\n linked from: /source-4"
179
+ end
180
+
181
+ def test_reports_server_timing_coverage_percentiles_signals_and_worst_pages
182
+ io = StringIO.new
183
+ result = Crawlscope::Result.new(
184
+ base_url: "https://example.com",
185
+ sitemap_path: "/tmp/sitemap.xml",
186
+ urls: [
187
+ "https://example.com/fast",
188
+ "https://example.com/slow",
189
+ "https://example.com/malformed"
190
+ ],
191
+ pages: [
192
+ page(
193
+ "/fast",
194
+ server_timing: 'total;dur=100, db;dur=20;desc="Primary", cache;desc="HIT"'
195
+ ),
196
+ page(
197
+ "/slow",
198
+ server_timing: 'total;dur=300, db;dur=80, cache;desc="MISS"'
199
+ ),
200
+ page("/malformed", server_timing: "bad metric;dur=10")
201
+ ],
202
+ issues: Crawlscope::IssueCollection.new
203
+ )
204
+
205
+ Crawlscope::Reporter.new(io: io).report(result)
206
+
207
+ output = io.string
208
+ assert_includes output, "Server Timing:"
209
+ assert_includes output, "Coverage: 3/3 pages (100.0%); durations on 2 pages"
210
+ assert_includes output, "Response times (ms):"
211
+ assert_includes output, "total: 2 samples / 2 pages; avg 200ms; p50 200ms; p95 290ms; max 300ms"
212
+ assert_includes output, 'db "Primary": 1 sample / 1 page'
213
+ assert_includes output, 'cache "HIT": 1 sample / 1 page'
214
+ assert_includes output, "Pages above 50 ms:"
215
+ assert_includes output, "/slow: 300ms (total)"
216
+ assert_includes output, "Ignored malformed entries: 1"
217
+ end
218
+
219
+ def test_reports_redirect_destination_once_and_keeps_external_origins
220
+ [:sitemap_redirect_url, :redirected_page, :internal_link_redirects].each do |code|
221
+ output = report_issues({code: code, details: {final_url: "https://example.com/new?lang=en#top", status: 200}})
222
+
223
+ assert_includes output, "redirects to /new?lang=en#top"
224
+ assert_equal 1, output.scan("/new?lang=en#top").size
225
+ refute_includes output, "status 200"
226
+ refute_includes output, "final:"
227
+ end
228
+
229
+ ["https://other.example/new", "http://example.com/new", "https://example.com:8443/new"].each do |destination|
230
+ output = report_issues({code: :sitemap_redirect_url, details: {final_url: destination, status: 200}})
231
+ assert_includes output, "redirects to #{destination}"
232
+ end
233
+ end
234
+
235
+ def test_reports_canonical_target_and_explains_sitemap_mismatch
236
+ ["https://example.com/preferred?lang=en", "/preferred?lang=en"].each do |canonical|
237
+ output = report_issues({code: :non_canonical_page_in_sitemap, category: :sitemaps, details: {canonical: canonical}})
238
+ assert_includes output, "/page sitemap lists this URL, but its canonical points to /preferred?lang=en"
239
+ end
240
+
241
+ output = report_issues({code: :canonical_mismatch, details: {canonical: "https://other.example/page"}})
242
+ assert_includes output, "canonical points to a different URL: https://other.example/page"
243
+ end
244
+
245
+ def test_reports_real_schema_errors_on_separate_lines_without_raw_json
246
+ errors = Crawlscope::SchemaRegistry.default.validate({"@type" => "Article", "headline" => "x" * 111, "author" => "Name"})
247
+ output = report_issues({code: :structured_data_schema_error, category: :structured_data, details: {source: "json-ld", errors: errors}})
248
+
249
+ assert_includes output, "Structured data: Schema errors (1 warning)"
250
+ assert_includes output, " - /page JSON-LD\n Article.headline: 111 characters; maximum 110\n Article.author: must be object (got string)"
251
+ refute_includes output, "in schema"
252
+ refute_includes output, '"field":'
253
+ assert_includes errors.first[:issue], "in schema"
254
+ end
255
+
256
+ def test_reports_link_failures_without_repeating_status_or_sources
257
+ output = report_issues(
258
+ {code: :broken_internal_link, message: "broken internal link (HTTP 404, sources: https://example.com/source)", details: {status: 404, source_urls: ["https://example.com/source"]}},
259
+ {code: :unresolved_internal_link, details: {error: "timeout", source_urls: ["https://example.com/source"]}},
260
+ {code: :internal_link_redirects, details: {final_url: "https://example.com/new", source_urls: ["https://example.com/source"], status: 200}}
261
+ )
262
+
263
+ assert_includes output, "linked URL returns HTTP 404\n linked from: /source"
264
+ assert_equal 1, output.scan("404").size
265
+ assert_includes output, "cannot check linked URL: timeout\n linked from: /source"
266
+ assert_includes output, "redirects to /new (final HTTP 200)\n linked from: /source"
267
+ refute_includes output, "https://example.com/source"
268
+ end
269
+
270
+ def test_reports_canonical_status_with_both_relevant_urls
271
+ output = report_issues(
272
+ {code: :canonical_points_to_redirect, details: {canonical: "https://example.com/old", final_url: "https://example.com/new", status: 200}},
273
+ {code: :canonical_points_to_error, details: {canonical: "https://example.com/missing", status: 404}},
274
+ {code: :canonical_no_internal_inlinks, message: "canonical URL has no incoming internal links without nofollow", details: {source_url: "https://example.com/article"}}
275
+ )
276
+
277
+ assert_includes output, "canonical /old redirects to /new"
278
+ assert_includes output, "canonical /missing returns HTTP 404"
279
+ assert_includes output, " canonical on: /article"
280
+ refute_includes output, "linked from: /article"
281
+ end
282
+
283
+ def test_reports_errors_before_larger_warning_groups
284
+ warnings = 3.times.map { |index| {code: :missing_title, category: :metadata, url: "https://example.com/#{index}"} }
285
+ output = report_issues(*warnings, {code: :fetch_failed, category: :crawl, severity: :error, message: "timeout"})
286
+
287
+ assert_operator output.index("Crawl: Fetch failed"), :<, output.index("Metadata: Missing title")
288
+ refute_includes output, "metadata / missing_title"
289
+ end
290
+
291
+ def test_reports_full_duplicate_values_and_every_url
292
+ urls = 5.times.map { |index| "https://example.com/page-#{index}" }
293
+ value = "A long description " * 20
294
+ details = {value: value, urls: urls}
295
+ output = report_issues({code: :duplicate_meta_description, category: :uniqueness, details: details})
296
+
297
+ assert_includes output, "same meta description:"
298
+ assert_includes output, value
299
+ urls.each { |url| assert_includes output, " page: #{URI(url).path}" }
300
+ refute_includes output, "more)"
301
+ assert_equal value, details[:value]
302
+ assert_equal 5, details[:urls].size
303
+ end
304
+
305
+ def test_reports_measurements_with_units_and_limits
306
+ output = report_issues(
307
+ {code: :low_visible_text_ratio, details: {ratio: 0.079, threshold: 0.08}},
308
+ {code: :thin_visible_text, details: {word_count: 40, minimum: 250}},
309
+ {code: :near_duplicate_content, details: {similarity: 0.925, threshold: 0.9, urls: ["https://example.com/a", "https://example.com/b"]}},
310
+ {code: :near_duplicate_scan_skipped, details: {page_count: 300, max_pages: 250}}
311
+ )
312
+
313
+ assert_includes output, "visible text / HTML bytes: 7.9%; minimum 8%"
314
+ assert_includes output, "visible words: 40; minimum 250"
315
+ assert_includes output, "text similarity: 92.5%; threshold 90%\n page: /a\n page: /b"
316
+ assert_includes output, "similar-page check skipped: 300 pages; limit 250"
317
+ end
318
+
319
+ def test_reports_all_source_and_target_urls_on_labeled_lines
320
+ sources = 6.times.map { |index| "https://example.com/source-#{index}" }
321
+ targets = 6.times.map { |index| "https://example.com/target-#{index}" }
322
+ output = report_issues({code: :nofollow_internal_outlinks, details: {source_url: sources.first, source_urls: sources, target_urls: targets}})
323
+
324
+ sources.each { |url| assert_includes output, " linked from: #{URI(url).path}\n" }
325
+ targets.each { |url| assert_includes output, " links to: #{URI(url).path}\n" }
326
+ assert_equal 1, output.scan("linked from: /source-0").size
327
+ refute_includes output, "more"
328
+ end
329
+
330
+ def test_reports_all_timed_pages_and_full_descriptions
331
+ description = "A detailed metric description " * 5
332
+ pages = 12.times.map { |index| page("/timed-#{index}", server_timing: %(total;dur=#{index + 60};desc="#{description}")) }
333
+ result = Crawlscope::Result.new(base_url: "https://example.com", sitemap_path: "/sitemap.xml", urls: pages.map(&:url), pages: pages, issues: Crawlscope::IssueCollection.new)
334
+ io = StringIO.new
335
+ Crawlscope::Reporter.new(io: io).report(result)
336
+
337
+ pages.each { |item| assert_includes io.string, "#{URI(item.url).path}:" }
338
+ assert_includes io.string, description
339
+ end
340
+
341
+ def test_length_rows_do_not_repeat_the_field_or_limit_from_the_heading
342
+ output = report_issues(
343
+ {code: :meta_description_too_long, category: :metadata, message: "meta description: 184 characters; maximum 160", details: {length: 184}},
344
+ {code: :meta_description_too_short, category: :metadata, details: {length: 40, minimum: 110}},
345
+ {code: :title_too_long, category: :metadata, details: {length: 80}}
346
+ )
347
+
348
+ assert_includes output, "Metadata: Meta description exceeds 160 characters (1 warning)\n - /page 184 characters"
349
+ assert_includes output, "Metadata: Meta description below 110 characters (1 warning)\n - /page 40 characters"
350
+ assert_includes output, "Metadata: Title exceeds 72 characters (1 warning)\n - /page 80 characters"
351
+ refute_includes output, "meta description:"
352
+ refute_includes output, "maximum 160"
353
+ end
354
+
355
+ def test_lists_only_pages_strictly_above_50_milliseconds
356
+ pages = [page("/fast", server_timing: "total;dur=49"), page("/boundary", server_timing: "total;dur=50"), page("/slow", server_timing: "total;dur=50.001")]
357
+ result = Crawlscope::Result.new(base_url: "https://example.com", sitemap_path: "/sitemap.xml", urls: pages.map(&:url), pages: pages, issues: Crawlscope::IssueCollection.new)
358
+ io = StringIO.new
359
+ Crawlscope::Reporter.new(io: io).report(result)
360
+
361
+ assert_includes io.string, "Pages above 50 ms:\n /slow: 50.001ms (total)"
362
+ refute_includes io.string, "/fast:"
363
+ refute_includes io.string, "/boundary:"
364
+ assert_includes io.string, "Coverage: 3/3 pages"
365
+ assert_includes io.string, "total: 3 samples / 3 pages"
366
+ end
367
+
368
+ def test_omits_slow_page_section_when_all_pages_are_at_or_below_50_milliseconds
369
+ pages = [page("/boundary", server_timing: "total;dur=50")]
370
+ result = Crawlscope::Result.new(base_url: "https://example.com", sitemap_path: "/sitemap.xml", urls: pages.map(&:url), pages: pages, issues: Crawlscope::IssueCollection.new)
371
+ io = StringIO.new
372
+ Crawlscope::Reporter.new(io: io).report(result)
373
+
374
+ refute_includes io.string, "Pages above"
375
+ assert_includes io.string, "Server Timing:"
376
+ end
377
+
378
+ private
379
+
380
+ def report_issues(*attributes, base_url: "https://example.com")
381
+ issues = Crawlscope::IssueCollection.new
382
+ attributes.each do |values|
383
+ defaults = {severity: :warning, category: :links, url: "#{base_url}/page", message: "original message", details: {}}
384
+ issues.add(**defaults.merge(values))
385
+ end
386
+ result = Crawlscope::Result.new(base_url: base_url, sitemap_path: "/sitemap.xml", urls: [], pages: [], issues: issues)
387
+ io = StringIO.new
388
+ Crawlscope::Reporter.new(io: io).report(result)
389
+ io.string
390
+ end
391
+
392
+ def page(path = "/", server_timing: nil)
393
+ url = "https://example.com#{path}"
394
+ headers = {}
395
+ headers["Server-Timing"] = server_timing unless server_timing.nil?
396
+
397
+ Crawlscope::Page.new(
398
+ url: url,
399
+ normalized_url: url,
400
+ final_url: url,
401
+ normalized_final_url: url,
402
+ status: 200,
403
+ headers: headers,
404
+ body: "",
405
+ doc: nil
406
+ )
178
407
  end
179
408
  end
@@ -130,29 +130,21 @@ class CrawlscopeRunTest < Minitest::Test
130
130
  )
131
131
  end
132
132
 
133
- def test_validate_prefers_local_sitemap_for_localhost
133
+ def test_validate_uses_http_sitemap_for_localhost
134
134
  result = FakeResult.new(reported: true)
135
135
  configuration = FakeConfiguration.new(result: result, base_url: "http://localhost:3000", sitemap_path: nil)
136
136
  reporter = FakeReporter.new
137
- tmp_dir = Dir.mktmpdir
138
- sitemap_path = File.join(tmp_dir, "public", "sitemap.xml")
139
- FileUtils.mkdir_p(File.dirname(sitemap_path))
140
- File.write(sitemap_path, "<urlset></urlset>")
141
137
 
142
- Dir.chdir(tmp_dir) do
143
- Crawlscope::Run.new(configuration: configuration, reporter: reporter).validate
144
- end
138
+ Crawlscope::Run.new(configuration: configuration, reporter: reporter).validate
145
139
 
146
140
  assert_equal(
147
141
  {
148
142
  base_url: "http://localhost:3000",
149
- sitemap_path: sitemap_path,
143
+ sitemap_path: "http://localhost:3000/sitemap.xml",
150
144
  rule_names: nil
151
145
  },
152
146
  configuration.received_arguments
153
147
  )
154
- ensure
155
- FileUtils.rm_rf(tmp_dir) if tmp_dir
156
148
  end
157
149
 
158
150
  def test_validate_json_ld_reports_valid_structured_data
@@ -193,7 +185,7 @@ class CrawlscopeRunTest < Minitest::Test
193
185
  assert_equal ["https://example.com"], JSON.parse(File.read(report_path)).fetch("results").keys
194
186
  assert_includes output.string, "JavaScript mode enabled (Ferrum)"
195
187
  assert_includes output.string, "Validating JSON-LD on 1 URL(s)"
196
- assert_includes output.string, "All valid!"
188
+ assert_includes output.string, "Validation: OK"
197
189
  assert_includes output.string, "All 1 URLs passed validation."
198
190
  ensure
199
191
  FileUtils.rm_rf(report_dir) if report_dir
@@ -46,6 +46,39 @@ class CrawlscopeSchemaRegistryTest < Minitest::Test
46
46
  assert registry.registered?("VideoObject")
47
47
  end
48
48
 
49
+ def test_length_errors_include_character_count_without_copying_the_value
50
+ error = Crawlscope::SchemaRegistry.default.validate({"@type" => "Article", "headline" => "é" * 111}).first
51
+
52
+ assert_equal 111, error[:length]
53
+ assert_equal "headline", error[:field]
54
+ assert_includes error[:issue], "maximum string length of 110"
55
+ assert_equal [:field, :issue, :type, :length], error.keys
56
+ end
57
+
58
+ def test_measures_string_fields_inside_nested_arrays
59
+ schema = {
60
+ type: "object",
61
+ properties: {
62
+ entries: {type: "array", items: {type: "object", properties: {name: {type: "string", maxLength: 3}}}}
63
+ }
64
+ }
65
+ registry = Crawlscope::SchemaRegistry.new(schemas: {"Thing" => schema})
66
+ errors = registry.validate({"@type" => "Thing", "entries" => [{"name" => "ok"}, {"name" => "éééé"}]})
67
+
68
+ assert_equal 1, errors.size
69
+ assert_equal "entries/1/name", errors.first[:field]
70
+ assert_equal 4, errors.first[:length]
71
+ end
72
+
73
+ def test_measures_each_graph_items_headline_independently
74
+ errors = Crawlscope::SchemaRegistry.default.validate({"@graph" => [
75
+ {"@type" => "Article", "headline" => "a" * 111},
76
+ {"@type" => "Article", "headline" => "b" * 125}
77
+ ]})
78
+
79
+ assert_equal [111, 125], errors.map { |error| error[:length] }
80
+ end
81
+
49
82
  def test_web_application_review_requires_review_rating
50
83
  errors = Crawlscope::SchemaRegistry.default.validate(
51
84
  {
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class CrawlscopeServerTimingSummaryTest < Minitest::Test
6
+ def test_aggregates_coverage_percentiles_signals_and_worst_pages
7
+ pages = [
8
+ page("/fast", 'total;dur=100, db;dur=20, cache;desc="HIT"'),
9
+ page("/typical", 'total;dur=200, db;dur=50, cache;desc="MISS"'),
10
+ page("/slow", 'total;dur=300, db;dur=80, cache;desc="HIT", miss'),
11
+ page("/no-header"),
12
+ page("/malformed", "bad metric;dur=10")
13
+ ]
14
+
15
+ summary = Crawlscope::ServerTiming::Summary.new(pages)
16
+
17
+ assert summary.present?
18
+ assert_equal 5, summary.pages.size
19
+ assert_equal 4, summary.coverage
20
+ assert_equal 3, summary.duration_pages
21
+ assert_equal 1, summary.invalid_count
22
+
23
+ total = summary.metrics.find { |metric| metric[:name] == "total" }
24
+ assert_equal 3, total[:samples]
25
+ assert_equal 3, total[:pages]
26
+ assert_in_delta 200.0, total[:average]
27
+ assert_in_delta 200.0, total[:p50]
28
+ assert_in_delta 290.0, total[:p95]
29
+ assert_in_delta 300.0, total[:maximum]
30
+
31
+ hit = summary.signals.find do |signal|
32
+ signal[:name] == "cache" && signal[:description] == "HIT"
33
+ end
34
+ assert_equal 2, hit[:samples]
35
+ assert_equal 2, hit[:pages]
36
+
37
+ assert_equal(
38
+ [
39
+ ["https://example.com/slow", "total", 300.0],
40
+ ["https://example.com/typical", "total", 200.0]
41
+ ],
42
+ summary.worst_pages(limit: 2).map do |sample|
43
+ [sample[:page].url, sample[:metric].name, sample[:metric].duration]
44
+ end
45
+ )
46
+ end
47
+
48
+ def test_is_absent_when_no_pages_publish_the_header
49
+ summary = Crawlscope::ServerTiming::Summary.new([page("/one"), page("/two")])
50
+
51
+ refute summary.present?
52
+ assert_empty summary.metrics
53
+ assert_empty summary.signals
54
+ assert_empty summary.worst_pages
55
+ end
56
+
57
+ def test_returns_ten_worst_pages_by_default
58
+ pages = 12.times.map do |index|
59
+ page("/page-#{index + 1}", "total;dur=#{index + 1}")
60
+ end
61
+
62
+ summary = Crawlscope::ServerTiming::Summary.new(pages)
63
+
64
+ assert_equal 10, summary.worst_pages.size
65
+ assert_equal "https://example.com/page-12", summary.worst_pages.first[:page].url
66
+ assert_equal "https://example.com/page-3", summary.worst_pages.last[:page].url
67
+ end
68
+
69
+ private
70
+
71
+ def page(path, server_timing = nil)
72
+ url = "https://example.com#{path}"
73
+ headers = {}
74
+ headers["Server-Timing"] = server_timing unless server_timing.nil?
75
+
76
+ Crawlscope::Page.new(
77
+ url: url,
78
+ normalized_url: url,
79
+ final_url: url,
80
+ normalized_final_url: url,
81
+ status: 200,
82
+ headers: headers,
83
+ body: "",
84
+ doc: nil
85
+ )
86
+ end
87
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class CrawlscopeServerTimingTest < Minitest::Test
6
+ def test_parses_metrics_durations_descriptions_and_unknown_parameters
7
+ timing = Crawlscope::ServerTiming.new(
8
+ 'miss, db;dur=53, cache;desc="Cache Read";dur=23.2;region=atl'
9
+ )
10
+
11
+ assert timing.present?
12
+ assert_equal 0, timing.invalid_count
13
+ assert_equal %w[miss db cache], timing.map(&:name)
14
+
15
+ cache = timing.to_a.last
16
+ assert_equal 23.2, cache.duration
17
+ assert_equal "Cache Read", cache.description
18
+ end
19
+
20
+ def test_preserves_duplicate_metrics_and_uses_the_first_duplicate_parameter
21
+ timing = Crawlscope::ServerTiming.new(
22
+ ["db;DUR=10;dur=20", "db;dur=30"]
23
+ )
24
+
25
+ assert_equal [10.0, 30.0], timing.map(&:duration)
26
+ assert_equal 0, timing.invalid_count
27
+ end
28
+
29
+ def test_parses_rails_notification_names
30
+ timing = Crawlscope::ServerTiming.new(
31
+ "sql.active_record;dur=82.4, " \
32
+ "render_template.action_view;dur=10.2, " \
33
+ "process_action.action_controller;dur=101.5"
34
+ )
35
+
36
+ assert_equal(
37
+ %w[
38
+ sql.active_record
39
+ render_template.action_view
40
+ process_action.action_controller
41
+ ],
42
+ timing.map(&:name)
43
+ )
44
+ assert_equal 0, timing.invalid_count
45
+ end
46
+
47
+ def test_handles_delimiters_and_escapes_inside_quoted_descriptions
48
+ timing = Crawlscope::ServerTiming.new(
49
+ 'cache;desc="Cache, read; \\"fast\\"";dur=23.2'
50
+ )
51
+
52
+ assert_equal 1, timing.size
53
+ assert_equal 'Cache, read; "fast"', timing.first.description
54
+ assert_equal 0, timing.invalid_count
55
+ end
56
+
57
+ def test_reports_invalid_entries_without_discarding_valid_metrics
58
+ timing = Crawlscope::ServerTiming.new(
59
+ "bad metric;dur=2, db;dur=nope, app;dur=3, cache;broken"
60
+ )
61
+
62
+ assert_equal %w[app cache], timing.map(&:name)
63
+ assert_equal 3.0, timing.first.duration
64
+ assert_equal 2, timing.invalid_count
65
+ end
66
+
67
+ def test_rejects_malformed_standard_parameters
68
+ timing = Crawlscope::ServerTiming.new(
69
+ "db;dur, cache;desc=not quoted, app;dur=3"
70
+ )
71
+
72
+ assert_equal ["app"], timing.map(&:name)
73
+ assert_equal 2, timing.invalid_count
74
+ end
75
+
76
+ def test_keeps_complete_metrics_before_an_unterminated_description
77
+ timing = Crawlscope::ServerTiming.new(
78
+ 'db;dur=5, cache;desc="unterminated'
79
+ )
80
+
81
+ assert_equal ["db"], timing.map(&:name)
82
+ assert_equal 1, timing.invalid_count
83
+ end
84
+
85
+ def test_ignores_empty_list_members
86
+ timing = Crawlscope::ServerTiming.new(", db;dur=5, , app;dur=3,")
87
+
88
+ assert_equal %w[db app], timing.map(&:name)
89
+ assert_equal 0, timing.invalid_count
90
+ end
91
+
92
+ def test_distinguishes_an_absent_header_from_an_empty_header
93
+ refute Crawlscope::ServerTiming.new(nil).present?
94
+
95
+ timing = Crawlscope::ServerTiming.new("")
96
+
97
+ assert timing.present?
98
+ assert_empty timing
99
+ assert_equal 0, timing.invalid_count
100
+ end
101
+ end
@@ -34,6 +34,27 @@ class CrawlscopeSitemapTest < Minitest::Test
34
34
  assert_equal ["https://www.example.com/", "https://www.example.com/pricing"], parser.urls(base_url: "https://www.example.com")
35
35
  end
36
36
 
37
+ def test_remote_sitemap_sends_profile_token
38
+ request = stub_request(:get, "https://www.example.com/sitemap.xml")
39
+ .with(headers: {"X-Profile-Token" => "profile-token"})
40
+ .to_return(
41
+ status: 200,
42
+ body: <<~XML
43
+ <?xml version="1.0" encoding="UTF-8"?>
44
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
45
+ <url><loc>https://www.example.com/</loc></url>
46
+ </urlset>
47
+ XML
48
+ )
49
+
50
+ Crawlscope::Sitemap.new(
51
+ path: "https://www.example.com/sitemap.xml",
52
+ profile_token: "profile-token"
53
+ ).urls(base_url: "https://www.example.com")
54
+
55
+ assert_requested request
56
+ end
57
+
37
58
  def test_parses_remote_sitemap_index_with_child_sitemap
38
59
  stub_request(:get, "https://www.example.com/sitemap.xml")
39
60
  .to_return(