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
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Crawlscope
6
+ class ServerTiming
7
+ class Reporter
8
+ SLOW_PAGE_THRESHOLD_MS = 50
9
+
10
+ def initialize(io:)
11
+ @io = io
12
+ end
13
+
14
+ def report(summary, base_url:)
15
+ return unless summary.present?
16
+
17
+ @io.puts("")
18
+ @io.puts("Server Timing:")
19
+ @io.puts(
20
+ " Coverage: #{summary.coverage}/#{summary.pages.size} pages " \
21
+ "(#{percentage(summary.coverage, summary.pages.size)}); " \
22
+ "durations on #{summary.duration_pages} #{pluralize(:page, summary.duration_pages)}"
23
+ )
24
+
25
+ report_metrics(summary.metrics)
26
+ report_signals(summary.signals)
27
+ report_worst_pages(summary.worst_pages(limit: summary.pages.size), base_url: base_url)
28
+ @io.puts(" Ignored malformed entries: #{summary.invalid_count}") if summary.invalid_count.positive?
29
+ end
30
+
31
+ private
32
+
33
+ def report_metrics(metrics)
34
+ return if metrics.empty?
35
+
36
+ @io.puts(" Response times (ms):")
37
+ metrics.each do |metric|
38
+ label = metric[:name]
39
+ label += " #{description(metric[:description])}" if metric[:description]
40
+
41
+ @io.puts(
42
+ " #{label}: " \
43
+ "#{metric[:samples]} #{pluralize(:sample, metric[:samples])} / " \
44
+ "#{metric[:pages]} #{pluralize(:page, metric[:pages])}; " \
45
+ "avg #{duration(metric[:average])}; " \
46
+ "p50 #{duration(metric[:p50])}; " \
47
+ "p95 #{duration(metric[:p95])}; " \
48
+ "max #{duration(metric[:maximum])}"
49
+ )
50
+ end
51
+ end
52
+
53
+ def report_signals(signals)
54
+ return if signals.empty?
55
+
56
+ @io.puts(" Signals:")
57
+ signals.each do |signal|
58
+ label = signal[:name]
59
+ label += " #{description(signal[:description])}" if signal[:description]
60
+
61
+ @io.puts(
62
+ " #{label}: " \
63
+ "#{signal[:samples]} #{pluralize(:sample, signal[:samples])} / " \
64
+ "#{signal[:pages]} #{pluralize(:page, signal[:pages])}"
65
+ )
66
+ end
67
+ end
68
+
69
+ def report_worst_pages(samples, base_url:)
70
+ samples = samples.select { |sample| sample[:metric].duration > SLOW_PAGE_THRESHOLD_MS }
71
+ return if samples.empty?
72
+
73
+ @io.puts(" Pages above #{SLOW_PAGE_THRESHOLD_MS} ms:")
74
+ samples.each do |sample|
75
+ metric = sample[:metric]
76
+ detail = metric.description ? " #{description(metric.description)}" : ""
77
+ @io.puts(
78
+ " #{relative_url(sample[:page].url, base_url: base_url)}: " \
79
+ "#{duration(metric.duration)} (#{metric.name}#{detail})"
80
+ )
81
+ end
82
+ end
83
+
84
+ def duration(value)
85
+ "#{format("%.3f", value).sub(/\.?0+\z/, "")}ms"
86
+ end
87
+
88
+ def description(value)
89
+ value = value.to_s
90
+ value.inspect
91
+ end
92
+
93
+ def percentage(numerator, denominator)
94
+ return "0.0%" if denominator.zero?
95
+
96
+ format("%.1f%%", numerator.fdiv(denominator) * 100)
97
+ end
98
+
99
+ def relative_url(url, base_url:)
100
+ uri = URI.parse(url)
101
+ base_uri = URI.parse(base_url)
102
+ return url unless uri.host == base_uri.host && uri.scheme == base_uri.scheme && uri.port == base_uri.port
103
+
104
+ uri.request_uri
105
+ rescue URI::InvalidURIError
106
+ url
107
+ end
108
+
109
+ def pluralize(word, count)
110
+ (count == 1) ? word.to_s : "#{word}s"
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crawlscope
4
+ class ServerTiming
5
+ class Summary
6
+ WORST_PAGE_LIMIT = 10
7
+
8
+ attr_reader :pages
9
+
10
+ def initialize(pages)
11
+ @pages = pages
12
+ @timings = pages.filter_map do |page|
13
+ timing = page.server_timing
14
+ [page, timing] if timing.present?
15
+ end
16
+ end
17
+
18
+ def present?
19
+ @timings.any?
20
+ end
21
+
22
+ def duration_pages
23
+ @timings.count { |_page, timing| timing.any?(&:timed?) }
24
+ end
25
+
26
+ def invalid_count
27
+ @timings.sum { |_page, timing| timing.invalid_count }
28
+ end
29
+
30
+ def metrics
31
+ timed_metrics
32
+ .group_by { |_page, metric| [metric.name, metric.description] }
33
+ .map { |(name, description), samples| metric_summary(name, description, samples) }
34
+ .sort_by { |metric| [-metric[:p95], -metric[:maximum], metric[:name]] }
35
+ end
36
+
37
+ def signals
38
+ all_metrics
39
+ .reject { |_page, metric| metric.timed? }
40
+ .group_by { |_page, metric| [metric.name, metric.description] }
41
+ .map { |(name, description), samples| signal_summary(name, description, samples) }
42
+ .sort_by { |signal| [-signal[:samples], signal[:name], signal[:description].to_s] }
43
+ end
44
+
45
+ def worst_pages(limit: WORST_PAGE_LIMIT)
46
+ @timings
47
+ .filter_map { |page, timing| worst_metric(page, timing) }
48
+ .min_by(limit) { |sample| [-sample[:metric].duration, sample[:page].url] }
49
+ .sort_by { |sample| [-sample[:metric].duration, sample[:page].url] }
50
+ end
51
+
52
+ def coverage
53
+ @timings.size
54
+ end
55
+
56
+ private
57
+
58
+ def timed_metrics
59
+ all_metrics.select { |_page, metric| metric.timed? }
60
+ end
61
+
62
+ def all_metrics
63
+ @all_metrics ||= @timings.flat_map do |page, timing|
64
+ timing.map { |metric| [page, metric] }
65
+ end
66
+ end
67
+
68
+ def metric_summary(name, description, samples)
69
+ durations = samples.map { |_page, metric| metric.duration }.sort
70
+
71
+ {
72
+ name: name,
73
+ description: description,
74
+ samples: samples.size,
75
+ pages: samples.map(&:first).uniq.size,
76
+ average: durations.sum.fdiv(durations.size),
77
+ p50: percentile(durations, 0.50),
78
+ p95: percentile(durations, 0.95),
79
+ maximum: durations.last
80
+ }
81
+ end
82
+
83
+ def signal_summary(name, description, samples)
84
+ {
85
+ name: name,
86
+ description: description,
87
+ samples: samples.size,
88
+ pages: samples.map(&:first).uniq.size
89
+ }
90
+ end
91
+
92
+ def percentile(values, percentile)
93
+ position = (values.size - 1) * percentile
94
+ lower = values[position.floor]
95
+ upper = values[position.ceil]
96
+
97
+ lower + (upper - lower) * (position - position.floor)
98
+ end
99
+
100
+ def worst_metric(page, timing)
101
+ if (metric = timing.select(&:timed?).max_by(&:duration))
102
+ {page: page, metric: metric}
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crawlscope
4
+ class ServerTiming
5
+ include Enumerable
6
+
7
+ TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
8
+ NUMBER = /\A-?(?:(?:\d+(?:\.\d*)?)|(?:\.\d+))(?:[eE][+-]?\d+)?\z/
9
+ QUOTED_STRING = /\A"(?:[^"\\\x00-\x1F]|\\[\t\x20-\x7E])*"\z/
10
+ EMPTY_LIST_MEMBER = /\A[ \t]*\z/
11
+
12
+ Metric = Data.define(:name, :duration, :description) do
13
+ def timed?
14
+ !duration.nil?
15
+ end
16
+ end
17
+
18
+ attr_reader :invalid_count
19
+
20
+ def initialize(header)
21
+ @header = header
22
+ @invalid_count = 0
23
+ @metrics = parse.freeze
24
+ end
25
+
26
+ def each(&block)
27
+ @metrics.each(&block)
28
+ end
29
+
30
+ def empty?
31
+ @metrics.empty?
32
+ end
33
+
34
+ def present?
35
+ !@header.nil?
36
+ end
37
+
38
+ def size
39
+ @metrics.size
40
+ end
41
+
42
+ private
43
+
44
+ def parse
45
+ Array(@header).flatten.compact.flat_map do |header|
46
+ metrics_from(header.to_s)
47
+ end
48
+ end
49
+
50
+ def metrics_from(header)
51
+ fields, complete = split(header, ",")
52
+
53
+ unless complete
54
+ @invalid_count += 1
55
+ fields.pop
56
+ end
57
+
58
+ fields.filter_map do |field|
59
+ metric_from(field) unless EMPTY_LIST_MEMBER.match?(field)
60
+ end
61
+ end
62
+
63
+ def metric_from(field)
64
+ parts, complete = split(field, ";")
65
+ name = parts.shift.to_s.strip
66
+
67
+ if complete && TOKEN.match?(name)
68
+ metric(name, parameters_from(parts))
69
+ else
70
+ @invalid_count += 1
71
+ nil
72
+ end
73
+ end
74
+
75
+ def parameters_from(parts)
76
+ parts.each_with_object({}) do |part, parameters|
77
+ name, value = part.strip.split("=", 2)
78
+ next unless name
79
+
80
+ name = name.downcase
81
+ next if parameters.key?(name)
82
+
83
+ parameters[name] = parameter_value(value.to_s.strip)
84
+ end
85
+ end
86
+
87
+ def parameter_value(value)
88
+ if TOKEN.match?(value)
89
+ value
90
+ elsif QUOTED_STRING.match?(value)
91
+ value[1...-1].gsub(/\\(.)/m, '\1')
92
+ end
93
+ end
94
+
95
+ def metric(name, parameters)
96
+ duration = duration_from(parameters["dur"])
97
+
98
+ if (parameters.key?("dur") && duration.nil?) ||
99
+ (parameters.key?("desc") && parameters["desc"].nil?)
100
+ @invalid_count += 1
101
+ nil
102
+ else
103
+ Metric.new(
104
+ name: name,
105
+ duration: duration,
106
+ description: parameters["desc"]
107
+ )
108
+ end
109
+ end
110
+
111
+ def duration_from(value)
112
+ if value && NUMBER.match?(value)
113
+ duration = Float(value)
114
+ duration if duration.finite?
115
+ end
116
+ end
117
+
118
+ def split(value, delimiter)
119
+ fields, quoted, escaped = [+""], false, false
120
+
121
+ value.each_char do |character|
122
+ if escaped
123
+ escaped = false
124
+ elsif quoted && character == "\\"
125
+ escaped = true
126
+ elsif character == "\""
127
+ quoted = !quoted
128
+ elsif character == delimiter && !quoted
129
+ fields << +""
130
+ end
131
+
132
+ fields.last << character unless character == delimiter && !quoted
133
+ end
134
+
135
+ [fields, !quoted && !escaped]
136
+ end
137
+ end
138
+ end
@@ -9,11 +9,12 @@ module Crawlscope
9
9
  class Sitemap
10
10
  SITEMAP_NAMESPACE = {"xmlns" => "http://www.sitemaps.org/schemas/sitemap/0.9"}.freeze
11
11
 
12
- def initialize(path:, adapter: nil, concurrency: Configuration::DEFAULT_CONCURRENCY, fetch_executor: Configuration::DEFAULT_FETCH_EXECUTOR, timeout_seconds: Configuration::DEFAULT_TIMEOUT_SECONDS)
12
+ def initialize(path:, adapter: nil, concurrency: Configuration::DEFAULT_CONCURRENCY, fetch_executor: Configuration::DEFAULT_FETCH_EXECUTOR, profile_token: nil, timeout_seconds: Configuration::DEFAULT_TIMEOUT_SECONDS)
13
13
  @path = path
14
14
  @adapter = adapter
15
15
  @concurrency = concurrency
16
16
  @fetch_executor = fetch_executor
17
+ @profile_token = profile_token
17
18
  @timeout_seconds = timeout_seconds
18
19
  end
19
20
 
@@ -34,7 +35,7 @@ module Crawlscope
34
35
  end
35
36
  return [] if already_visited
36
37
 
37
- document = Nokogiri::XML(read(source))
38
+ document = Nokogiri::XML(read(source, base_url: base_url))
38
39
  root_name = document.root&.name
39
40
  unless %w[sitemapindex urlset].include?(root_name)
40
41
  raise ValidationError, "Sitemap #{source} has unexpected root #{root_name.inspect}"
@@ -55,9 +56,16 @@ module Crawlscope
55
56
  end
56
57
  end
57
58
 
58
- def read(source)
59
+ def read(source, base_url:)
59
60
  if Url.remote?(source)
60
- response = connection.get(source)
61
+ response = connection.get(source) do |request|
62
+ RequestHeaders.add_profile_token(
63
+ request.headers,
64
+ url: source,
65
+ base_url: base_url,
66
+ profile_token: @profile_token
67
+ )
68
+ end
61
69
  unless response.status.to_i.between?(200, 299)
62
70
  raise ValidationError, "Sitemap #{source} returned HTTP #{response.status}"
63
71
  end
@@ -92,7 +100,11 @@ module Crawlscope
92
100
 
93
101
  def connection
94
102
  Faraday.new do |faraday|
95
- faraday.response :follow_redirects, limit: Http::MAX_REDIRECTS
103
+ faraday.response(
104
+ :follow_redirects,
105
+ limit: Http::MAX_REDIRECTS,
106
+ callback: RequestHeaders.method(:strip_profile_token_on_cross_origin_redirect)
107
+ )
96
108
  faraday.options.timeout = @timeout_seconds
97
109
  faraday.options.open_timeout = @timeout_seconds
98
110
  faraday.adapter @adapter if @adapter
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crawlscope
4
+ module StructuredData
5
+ class ErrorFormatter
6
+ def self.format(error)
7
+ field = (error[:field] || error["field"]).to_s.sub(/\A#\/?/, "")
8
+ type = error[:type] || error["type"]
9
+ issue = error[:issue] || error["issue"] || "Unknown error"
10
+
11
+ if (match = issue.match(/did not contain a required property of '([^']+)'/))
12
+ field = [field, match[1]].reject(&:empty?).join("/")
13
+ message = "is required"
14
+ else
15
+ message = concise_message(issue, error[:length] || error["length"])
16
+ end
17
+
18
+ location = [type, field.empty? ? nil : field].compact.join(".")
19
+ "#{location.empty? ? "$" : location}: #{message}"
20
+ end
21
+
22
+ def self.concise_message(issue, length)
23
+ if length
24
+ unit = (length == 1) ? "character" : "characters"
25
+ measured_length = "#{length} #{unit}"
26
+ end
27
+ case issue
28
+ when /was not of a maximum string length of (\d+)/
29
+ length ? "#{measured_length}; maximum #{Regexp.last_match(1)}" : "exceeds #{Regexp.last_match(1)} characters"
30
+ when /was not of a minimum string length of (\d+)/
31
+ length ? "#{measured_length}; minimum #{Regexp.last_match(1)}" : "must have at least #{Regexp.last_match(1)} characters"
32
+ when /of type (\w+) did not match the following type: (\w+)/
33
+ "must be #{Regexp.last_match(2)} (got #{Regexp.last_match(1)})"
34
+ else
35
+ issue.sub(/\AThe property '[^']*' /, "")
36
+ .sub(/ in schema [\h-]{36}(?:#\S*)?\z/, "")
37
+ end
38
+ end
39
+ private_class_method :concise_message
40
+ end
41
+ end
42
+ end
@@ -67,10 +67,9 @@ module Crawlscope
67
67
  @io.puts("• #{extract_path(entry.url)}")
68
68
 
69
69
  entry.errors.each do |error|
70
+ @io.puts(" #{source_label(error[:source])}:")
70
71
  error[:errors].each do |validation_error|
71
- field = validation_error[:field] || validation_error["field"] || "$"
72
- issue = validation_error[:issue] || validation_error["issue"] || "Unknown error"
73
- @io.puts(" - #{field}: #{issue}")
72
+ @io.puts(" - #{ErrorFormatter.format(validation_error)}")
74
73
  end
75
74
  end
76
75
  end
@@ -83,9 +82,7 @@ module Crawlscope
83
82
  end
84
83
 
85
84
  def report_entry(entry, debug:)
86
- @io.puts("=" * 80)
87
85
  @io.puts("URL: #{entry.url}")
88
- @io.puts("=" * 80)
89
86
 
90
87
  if entry.fetch_error
91
88
  @io.puts("Error: #{entry.fetch_error}")
@@ -124,19 +121,22 @@ module Crawlscope
124
121
 
125
122
  def report_validation(entry)
126
123
  @io.puts("")
127
- @io.puts("Validation results:")
128
-
129
124
  if entry.errors.empty?
130
- @io.puts(" All valid!")
125
+ @io.puts("Validation: OK")
131
126
  else
127
+ @io.puts("Validation: FAILED")
132
128
  entry.errors.each do |error|
133
- @io.puts(" #{error[:type]}: INVALID [#{error[:source]}]")
129
+ @io.puts(" #{source_label(error[:source])}:")
134
130
  error[:errors].each do |validation_error|
135
- @io.puts(" - field: #{validation_error[:field]}, issue: #{validation_error[:issue]}")
131
+ @io.puts(" - #{ErrorFormatter.format(validation_error)}")
136
132
  end
137
133
  end
138
134
  end
139
135
  end
136
+
137
+ def source_label(source)
138
+ (source == "json-ld") ? "JSON-LD" : source
139
+ end
140
140
  end
141
141
  end
142
142
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Crawlscope
4
- VERSION = "0.7.2"
4
+ VERSION = "0.8.1"
5
5
  end
@@ -14,7 +14,7 @@ module CrawlscopeConfiguration
14
14
  Crawlscope.configure do |config|
15
15
  config.base_url = -> { ENV.fetch("CRAWLSCOPE_BASE_URL", "http://localhost:3000") }
16
16
  config.sitemap_path = lambda {
17
- ENV.fetch("SITEMAP", Rails.public_path.join("sitemap.xml").to_s)
17
+ ENV.fetch("SITEMAP", "#{config.base_url.to_s.chomp("/")}/sitemap.xml")
18
18
  }
19
19
  config.site_name = ENV.fetch("CRAWLSCOPE_SITE_NAME", "Application")
20
20
  end
@@ -14,13 +14,14 @@ class CrawlscopeBrowserTest < Minitest::Test
14
14
  end
15
15
 
16
16
  class FakeNetwork
17
- attr_reader :cleared, :idle_waits, :status
17
+ attr_reader :cleared, :idle_waits, :interceptions, :status
18
18
 
19
19
  def initialize(response:, status: 200)
20
20
  @response = response
21
21
  @status = status
22
22
  @cleared = []
23
23
  @idle_waits = []
24
+ @interceptions = []
24
25
  end
25
26
 
26
27
  def clear(scope)
@@ -29,6 +30,10 @@ class CrawlscopeBrowserTest < Minitest::Test
29
30
 
30
31
  attr_reader :response
31
32
 
33
+ def intercept(**options)
34
+ @interceptions << options
35
+ end
36
+
32
37
  def wait_for_idle(duration:, timeout:)
33
38
  @idle_waits << {duration: duration, timeout: timeout}
34
39
  end
@@ -43,6 +48,7 @@ class CrawlscopeBrowserTest < Minitest::Test
43
48
  @current_url = current_url
44
49
  @url = url
45
50
  @evaluations = []
51
+ @callbacks = {}
46
52
  end
47
53
 
48
54
  attr_reader :body
@@ -57,11 +63,40 @@ class CrawlscopeBrowserTest < Minitest::Test
57
63
  @visited_url = url
58
64
  end
59
65
 
66
+ def on(event, &callback)
67
+ @callbacks[event] = callback
68
+ end
69
+
70
+ def publish(event, value)
71
+ @callbacks.fetch(event).call(value)
72
+ end
73
+
60
74
  attr_reader :url
61
75
  end
62
76
 
77
+ class FakeRequest
78
+ attr_reader :continued_headers, :headers, :url
79
+
80
+ def initialize(url:, headers: {})
81
+ @url = url
82
+ @headers = headers
83
+ end
84
+
85
+ def continue(headers:)
86
+ @continued_headers = headers.to_h { |header| [header.fetch(:name), header.fetch(:value)] }
87
+ end
88
+ end
89
+
63
90
  def test_fetch_returns_rendered_page
64
- network = FakeNetwork.new(response: Response.new(url: "https://example.com/final", headers: {"content-type" => "text/html"}))
91
+ network = FakeNetwork.new(
92
+ response: Response.new(
93
+ url: "https://example.com/final",
94
+ headers: {
95
+ "content-type" => "text/html",
96
+ "server-timing" => "app;dur=47.2"
97
+ }
98
+ )
99
+ )
65
100
  page = FakePage.new(network: network, body: "<html><body>Hello</body></html>")
66
101
  browser = browser_with(page: page, scroll_page: false)
67
102
 
@@ -73,6 +108,7 @@ class CrawlscopeBrowserTest < Minitest::Test
73
108
  assert_equal "https://example.com/final", result.normalized_final_url
74
109
  assert_equal 200, result.status
75
110
  assert result.html?
111
+ assert_equal 47.2, result.server_timing.first.duration
76
112
  assert_equal [], page.evaluations
77
113
  end
78
114
 
@@ -88,6 +124,21 @@ class CrawlscopeBrowserTest < Minitest::Test
88
124
  assert_equal 4, network.idle_waits.size
89
125
  end
90
126
 
127
+ def test_profile_token_is_limited_to_same_origin_documents
128
+ network = FakeNetwork.new(response: nil)
129
+ page = FakePage.new(network: network)
130
+ browser_with(page: page, profile_token: "profile-token")
131
+ same_origin_request = FakeRequest.new(url: "https://example.com/page")
132
+ cross_origin_request = FakeRequest.new(url: "https://cdn.example.net/frame")
133
+
134
+ page.publish(:request, same_origin_request)
135
+ page.publish(:request, cross_origin_request)
136
+
137
+ assert_equal [{resource_type: :document}], network.interceptions
138
+ assert_equal "profile-token", same_origin_request.continued_headers.fetch("X-Profile-Token")
139
+ refute_includes cross_origin_request.continued_headers, "X-Profile-Token"
140
+ end
141
+
91
142
  def test_fetch_falls_back_to_page_url_and_original_url
92
143
  page_url_network = FakeNetwork.new(response: nil)
93
144
  page_url = FakePage.new(network: page_url_network, url: "https://example.com/page")
@@ -142,14 +193,16 @@ class CrawlscopeBrowserTest < Minitest::Test
142
193
 
143
194
  private
144
195
 
145
- def browser_with(page: FakePage.new(network: FakeNetwork.new(response: nil)), browser: FakeBrowser.new, scroll_page: false)
196
+ def browser_with(page: FakePage.new(network: FakeNetwork.new(response: nil)), browser: FakeBrowser.new, profile_token: nil, scroll_page: false)
146
197
  Crawlscope::Browser.allocate.tap do |instance|
147
198
  instance.instance_variable_set(:@base_url, "https://example.com")
148
199
  instance.instance_variable_set(:@timeout_seconds, 20)
149
200
  instance.instance_variable_set(:@network_idle_timeout_seconds, 5)
201
+ instance.instance_variable_set(:@profile_token, profile_token)
150
202
  instance.instance_variable_set(:@scroll_page, scroll_page)
151
203
  instance.instance_variable_set(:@browser, browser)
152
204
  instance.instance_variable_set(:@page, page)
205
+ instance.send(:configure_profile_requests)
153
206
  end
154
207
  end
155
208
  end