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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +95 -18
- data/lib/crawlscope/browser.rb +18 -1
- data/lib/crawlscope/configuration.rb +6 -1
- data/lib/crawlscope/crawl.rb +5 -3
- data/lib/crawlscope/http.rb +13 -2
- data/lib/crawlscope/page.rb +8 -0
- data/lib/crawlscope/reporter.rb +144 -61
- data/lib/crawlscope/request_headers.rb +34 -0
- data/lib/crawlscope/result.rb +4 -0
- data/lib/crawlscope/rules/content_quality.rb +5 -5
- data/lib/crawlscope/rules/indexability.rb +1 -1
- data/lib/crawlscope/rules/links.rb +28 -34
- data/lib/crawlscope/rules/metadata.rb +25 -25
- data/lib/crawlscope/rules/structured_data.rb +13 -4
- data/lib/crawlscope/rules/uniqueness.rb +9 -7
- data/lib/crawlscope/run.rb +0 -10
- data/lib/crawlscope/schema_registry.rb +17 -1
- data/lib/crawlscope/server_timing/reporter.rb +114 -0
- data/lib/crawlscope/server_timing/summary.rb +107 -0
- data/lib/crawlscope/server_timing.rb +138 -0
- data/lib/crawlscope/sitemap.rb +17 -5
- data/lib/crawlscope/structured_data/error_formatter.rb +42 -0
- data/lib/crawlscope/structured_data/reporter.rb +10 -10
- data/lib/crawlscope/version.rb +1 -1
- data/lib/generators/crawlscope/templates/initializer.rb.tt +1 -1
- data/test/crawlscope/browser_test.rb +56 -3
- data/test/crawlscope/configuration_test.rb +50 -10
- data/test/crawlscope/crawl_test.rb +65 -0
- data/test/crawlscope/http_test.rb +60 -1
- data/test/crawlscope/install_generator_test.rb +9 -11
- data/test/crawlscope/links_rule_test.rb +40 -4
- data/test/crawlscope/metadata_rule_test.rb +37 -4
- data/test/crawlscope/reporter_test.rb +249 -20
- data/test/crawlscope/run_test.rb +4 -12
- data/test/crawlscope/schema_registry_test.rb +33 -0
- data/test/crawlscope/server_timing_summary_test.rb +87 -0
- data/test/crawlscope/server_timing_test.rb +101 -0
- data/test/crawlscope/sitemap_test.rb +21 -0
- data/test/crawlscope/structured_data_error_formatter_test.rb +46 -0
- data/test/crawlscope/structured_data_reporter_test.rb +26 -0
- data/test/crawlscope/structured_data_rule_test.rb +2 -0
- data/test/crawlscope/uniqueness_rule_test.rb +46 -3
- 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: [
|
|
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: [
|
|
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
|
-
|
|
61
|
-
assert_includes output, "links
|
|
62
|
-
assert_includes output, " - /page-1
|
|
63
|
-
assert_includes output, " - /page-4
|
|
64
|
-
assert_includes output, "
|
|
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: [
|
|
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
|
|
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: [
|
|
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
|
|
119
|
-
assert_includes output, " - /page-20
|
|
120
|
-
|
|
121
|
-
|
|
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: [
|
|
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, "
|
|
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: [
|
|
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, "
|
|
176
|
-
assert_includes output, " - /overview-1
|
|
177
|
-
assert_includes output, " - /overview-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
|
data/test/crawlscope/run_test.rb
CHANGED
|
@@ -130,29 +130,21 @@ class CrawlscopeRunTest < Minitest::Test
|
|
|
130
130
|
)
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
-
def
|
|
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
|
-
|
|
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:
|
|
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, "
|
|
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(
|