perchfall 0.3.2 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b71c5cd92df55f4c28471df0920f19be7c4b81586d52c76e0704a2d1a605265a
4
- data.tar.gz: 2dbe1f2b439949f7d7ed2cff08a2a51979e8c02cf1cd7c74c701f3de3a428e01
3
+ metadata.gz: 7d608f361607d90f51b79aee9aab5c4585dfbc3c5f5a7556c60eef9154588664
4
+ data.tar.gz: 17a9717ef25c6c5e81a38fa8a255730013320d38aa5636f15b60d4ed15252297
5
5
  SHA512:
6
- metadata.gz: 2372a6936e06871d890e272e7c722b5dc59e4a2c2ac86e08e882479f3537f85ef3a66bf68b0a913a24029c300740183126437dda332934b975f9bc84fc64146e
7
- data.tar.gz: fd02d5e014638c12695932361995443ad1f1c6a66aa104c0db360b931e55291cfca1a176aa2353a16267e73c3e651325a94c5ce4bb1f334b3ee4cdadb6ebc6ff
6
+ metadata.gz: febd8246e1b5560784ab0530a50191d274697d863e50b73409e18acadd762fff8669a67b8b35894bf3f31ec386509c6f61e63b9ce1c9b74fda59fe3f0cfdb19b
7
+ data.tar.gz: cc4a837fe3f82be4686854348d556e60b362d7336c0f5f2277d8e3c4edad42f994234484c64b588b12d7ff6da90ce48448200edd73b33865d35a7f3a07ab35b9
data/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-03-27
11
+
12
+ ### Added
13
+
14
+ - `capture_resources: false` option on `Perchfall.run` / `Client#run` — when `true`, collects metadata for every resource loaded during the page run and stores large ones on the report
15
+ - `report.resources` — array of `Resource` objects (url, http_method, status, content_type, transfer_size, resource_type) whose transfer size met or exceeded the configured threshold, plus any resource whose size could not be determined (absent `content-length`)
16
+ - `large_resource_threshold_bytes:` option (default 200 000 bytes / 200 KB) — controls the minimum transfer size for a resource to appear in `report.resources`; only meaningful when `capture_resources: true`
17
+ - `Perchfall::Resource` value object (`Data.define`) with all resource fields; `transfer_size` is `Integer` or `nil` (nil means unknown, not zero)
18
+
19
+ ### Notes
20
+
21
+ - Resource capture is opt-in and off by default. No overhead is incurred unless `capture_resources: true` is passed.
22
+ - Resources with an unknown transfer size (`nil`) are always included in `report.resources` — they cannot be proven to be below the threshold.
23
+ - `report.ok?` is not affected by `report.resources` — size is a metric, not a pass/fail signal.
24
+
10
25
  ## [0.3.2] - 2026-03-19
11
26
 
12
27
  ### Added
@@ -74,7 +89,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
74
89
  - Full dependency injection throughout — test suite runs in ~0.4 s with no browser, Node, or network required
75
90
  - GitHub Actions CI workflow (unit suite) and manual Playwright smoke check workflow
76
91
 
77
- [Unreleased]: https://github.com/beflagrant/perchfall/compare/v0.3.2...HEAD
92
+ [Unreleased]: https://github.com/beflagrant/perchfall/compare/v0.4.0...HEAD
93
+ [0.4.0]: https://github.com/beflagrant/perchfall/compare/v0.3.2...v0.4.0
78
94
  [0.3.2]: https://github.com/beflagrant/perchfall/compare/v0.3.1...v0.3.2
79
95
  [0.3.1]: https://github.com/beflagrant/perchfall/compare/v0.2.0...v0.3.1
80
96
  [0.2.0]: https://github.com/beflagrant/perchfall/compare/v0.1.0...v0.2.0
@@ -82,29 +82,49 @@ module Perchfall
82
82
  report
83
83
  end
84
84
 
85
+ DEFAULT_RESOURCE_THRESHOLD = Parsers::PlaywrightJsonParser::DEFAULT_LARGE_RESOURCE_THRESHOLD_BYTES
86
+
87
+ RunOptions = Data.define(
88
+ :url, :ignore, :wait_until, :timeout_ms, :scenario_name,
89
+ :timestamp, :cache_profile, :capture_resources, :large_resource_threshold_bytes
90
+ ) do
91
+ def self.from_kwargs(url:, ignore: [], wait_until: 'load', timeout_ms: 30_000,
92
+ scenario_name: nil, timestamp: Time.now.utc,
93
+ cache_profile: :query_bust, capture_resources: false,
94
+ large_resource_threshold_bytes: DEFAULT_RESOURCE_THRESHOLD)
95
+ new(url: url, ignore: ignore, wait_until: wait_until, timeout_ms: timeout_ms,
96
+ scenario_name: scenario_name, timestamp: timestamp, cache_profile: cache_profile,
97
+ capture_resources: capture_resources,
98
+ large_resource_threshold_bytes: large_resource_threshold_bytes)
99
+ end
100
+ end
101
+
85
102
  private
86
103
 
87
- def invoke(url:, ignore: [], wait_until: "load", timeout_ms: 30_000, scenario_name: nil, timestamp: Time.now.utc, cache_profile: :query_bust)
88
- profile = resolve_cache_profile!(cache_profile)
89
- validate_wait_until!(wait_until)
90
- validate_timeout_ms!(timeout_ms)
91
- effective_url = profile[:bust_url] ? append_cache_buster(url) : url
104
+ def invoke(url:, **kwargs)
105
+ opts = RunOptions.from_kwargs(url: url, **kwargs)
106
+ profile = resolve_cache_profile!(opts.cache_profile)
107
+ validate_wait_until!(opts.wait_until)
108
+ validate_timeout_ms!(opts.timeout_ms)
109
+ effective_url = profile[:bust_url] ? append_cache_buster(opts.url) : opts.url
92
110
  @validator.validate!(effective_url)
93
- merged_ignore = Perchfall::DEFAULT_IGNORE_RULES + ignore
94
- invoker_opts = {
95
- url: effective_url,
96
- original_url: url,
97
- ignore: merged_ignore,
98
- wait_until: wait_until,
99
- timeout_ms: timeout_ms,
100
- scenario_name: scenario_name,
101
- timestamp: timestamp,
102
- cache_profile: cache_profile
111
+ @limiter.acquire { @invoker.run(**build_invoker_opts(opts, effective_url, profile)) }
112
+ end
113
+
114
+ def build_invoker_opts(opts, effective_url, profile)
115
+ result = {
116
+ url: effective_url, original_url: opts.url,
117
+ ignore: Perchfall::DEFAULT_IGNORE_RULES + opts.ignore,
118
+ wait_until: opts.wait_until, timeout_ms: opts.timeout_ms,
119
+ scenario_name: opts.scenario_name, timestamp: opts.timestamp,
120
+ cache_profile: opts.cache_profile
103
121
  }
104
- invoker_opts[:extra_headers] = profile[:headers] unless profile[:headers].empty?
105
- @limiter.acquire do
106
- @invoker.run(**invoker_opts)
122
+ result[:extra_headers] = profile[:headers] unless profile[:headers].empty?
123
+ if opts.capture_resources
124
+ result[:capture_resources] = true
125
+ result[:large_resource_threshold_bytes] = opts.large_resource_threshold_bytes
107
126
  end
127
+ result
108
128
  end
109
129
 
110
130
  private
@@ -13,16 +13,18 @@ module Perchfall
13
13
  @filter = filter
14
14
  end
15
15
 
16
- def parse(raw_json, timestamp:, scenario_name: nil, original_url: nil, cache_profile: nil)
16
+ DEFAULT_LARGE_RESOURCE_THRESHOLD_BYTES = 200_000
17
+
18
+ def parse(raw_json, timestamp:, scenario_name: nil, original_url: nil, cache_profile: nil, capture_resources: false, large_resource_threshold_bytes: DEFAULT_LARGE_RESOURCE_THRESHOLD_BYTES)
17
19
  data = JSON.parse(raw_json, symbolize_names: true)
18
- build_report(data, scenario_name: scenario_name, timestamp: timestamp, original_url: original_url, cache_profile: cache_profile)
20
+ build_report(data, scenario_name: scenario_name, timestamp: timestamp, original_url: original_url, cache_profile: cache_profile, capture_resources: capture_resources, large_resource_threshold_bytes: large_resource_threshold_bytes)
19
21
  rescue JSON::ParserError => e
20
22
  raise Errors::ParseError, "Invalid JSON from Playwright script: #{e.message}"
21
23
  end
22
24
 
23
25
  private
24
26
 
25
- def build_report(data, scenario_name:, timestamp:, original_url: nil, cache_profile: nil)
27
+ def build_report(data, scenario_name:, timestamp:, original_url: nil, cache_profile: nil, capture_resources: false, large_resource_threshold_bytes: DEFAULT_LARGE_RESOURCE_THRESHOLD_BYTES)
26
28
  net_filtered = @filter.filter_network(parse_network_errors(data.fetch(:network_errors, [])))
27
29
  console_filtered = @filter.filter_console(parse_console_errors(data.fetch(:console_errors, [])))
28
30
 
@@ -38,7 +40,8 @@ module Perchfall
38
40
  error: data[:error],
39
41
  scenario_name: scenario_name,
40
42
  timestamp: timestamp,
41
- cache_profile: cache_profile
43
+ cache_profile: cache_profile,
44
+ resources: capture_resources ? parse_resources(data.fetch(:resources, []), threshold_bytes: large_resource_threshold_bytes) : []
42
45
  )
43
46
  rescue KeyError => e
44
47
  raise Errors::ParseError, "Playwright JSON missing required field: #{e.message}"
@@ -67,6 +70,21 @@ module Perchfall
67
70
  rescue KeyError => e
68
71
  raise Errors::ParseError, "Malformed console_error entry: #{e.message}"
69
72
  end
73
+
74
+ def parse_resources(raw, threshold_bytes:)
75
+ raw.filter_map do |item|
76
+ size = item[:transfer_size]
77
+ next if size && size < threshold_bytes
78
+ Resource.new(
79
+ url: item[:url],
80
+ http_method: item[:method],
81
+ status: item[:status],
82
+ content_type: item[:content_type],
83
+ transfer_size: size,
84
+ resource_type: item[:resource_type]
85
+ )
86
+ end
87
+ end
70
88
  end
71
89
  end
72
90
  end
@@ -24,10 +24,10 @@ module Perchfall
24
24
  @script_path = script_path
25
25
  end
26
26
 
27
- def run(url:, timestamp:, timeout_ms: 30_000, wait_until: "load", scenario_name: nil, ignore: [], original_url: nil, extra_headers: {}, cache_profile: nil)
27
+ def run(url:, timestamp:, timeout_ms: 30_000, wait_until: "load", scenario_name: nil, ignore: [], original_url: nil, extra_headers: {}, cache_profile: nil, capture_resources: false, large_resource_threshold_bytes: Parsers::PlaywrightJsonParser::DEFAULT_LARGE_RESOURCE_THRESHOLD_BYTES)
28
28
  parser = build_parser(ignore)
29
- result = execute(build_command(url: url, timeout_ms: timeout_ms, wait_until: wait_until, extra_headers: extra_headers))
30
- parse(result, parser: parser, scenario_name: scenario_name, timestamp: timestamp, original_url: original_url || url, cache_profile: cache_profile)
29
+ result = execute(build_command(url: url, timeout_ms: timeout_ms, wait_until: wait_until, extra_headers: extra_headers, capture_resources: capture_resources))
30
+ parse(result, parser: parser, scenario_name: scenario_name, timestamp: timestamp, original_url: original_url || url, cache_profile: cache_profile, capture_resources: capture_resources, large_resource_threshold_bytes: large_resource_threshold_bytes)
31
31
  end
32
32
 
33
33
  private
@@ -36,9 +36,10 @@ module Perchfall
36
36
  Parsers::PlaywrightJsonParser.new(filter: ErrorFilter.new(rules: ignore_rules))
37
37
  end
38
38
 
39
- def build_command(url:, timeout_ms:, wait_until:, extra_headers: {})
39
+ def build_command(url:, timeout_ms:, wait_until:, extra_headers: {}, capture_resources: false)
40
40
  cmd = ["node", @script_path, "--url", url, "--timeout", timeout_ms.to_s, "--wait-until", wait_until]
41
41
  cmd += ["--headers", extra_headers.to_json] unless extra_headers.empty?
42
+ cmd += ["--capture-resources"] if capture_resources
42
43
  cmd
43
44
  end
44
45
 
@@ -57,7 +58,7 @@ module Perchfall
57
58
  )
58
59
  end
59
60
 
60
- parser.parse(result.stdout, **opts)
61
+ parser.parse(result.stdout, timestamp: opts[:timestamp], scenario_name: opts[:scenario_name], original_url: opts[:original_url], cache_profile: opts[:cache_profile], capture_resources: opts[:capture_resources] || false, large_resource_threshold_bytes: opts[:large_resource_threshold_bytes] || Parsers::PlaywrightJsonParser::DEFAULT_LARGE_RESOURCE_THRESHOLD_BYTES)
61
62
  end
62
63
 
63
64
  end
@@ -18,10 +18,13 @@ module Perchfall
18
18
  # ignored_console_errors - Array<ConsoleError>: errors suppressed by ignore rules
19
19
  # error - String or nil: set only when status == "error"
20
20
  # cache_profile - Symbol or nil: the cache profile used for this run
21
+ # resources - Array<Resource>: resources that exceeded the configured size
22
+ # threshold; only populated when capture_resources: true was passed
21
23
  class Report
22
24
  attr_reader :status, :url, :scenario_name, :timestamp, :duration_ms,
23
25
  :http_status, :network_errors, :ignored_network_errors,
24
- :console_errors, :ignored_console_errors, :error, :cache_profile
26
+ :console_errors, :ignored_console_errors, :error, :cache_profile,
27
+ :resources
25
28
 
26
29
  def initialize(
27
30
  status:,
@@ -35,7 +38,8 @@ module Perchfall
35
38
  ignored_console_errors: [],
36
39
  scenario_name: nil,
37
40
  timestamp: Time.now.utc,
38
- cache_profile: nil
41
+ cache_profile: nil,
42
+ resources: []
39
43
  )
40
44
  @status = status.freeze
41
45
  @url = url.freeze
@@ -49,6 +53,7 @@ module Perchfall
49
53
  @ignored_console_errors = ignored_console_errors.freeze
50
54
  @error = error&.freeze
51
55
  @cache_profile = cache_profile
56
+ @resources = resources.freeze
52
57
  freeze
53
58
  end
54
59
 
@@ -70,7 +75,8 @@ module Perchfall
70
75
  console_errors: console_errors.map(&:to_h),
71
76
  ignored_console_errors: ignored_console_errors.map(&:to_h),
72
77
  error: error,
73
- cache_profile: cache_profile
78
+ cache_profile: cache_profile,
79
+ resources: resources.map(&:to_h)
74
80
  }
75
81
  end
76
82
 
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perchfall
4
+ # Represents a single resource loaded during a page run.
5
+ #
6
+ # Attributes:
7
+ # url - String: the resource URL
8
+ # http_method - String: HTTP method (e.g. "GET")
9
+ # status - Integer: HTTP response status code
10
+ # content_type - String or nil: Content-Type header value
11
+ # transfer_size - Integer or nil: wire bytes from Content-Length header;
12
+ # nil means the header was absent (chunked/inline) — unknown, not zero
13
+ # resource_type - String: Playwright resource type (e.g. "image", "script", "stylesheet")
14
+ Resource = Data.define(:url, :http_method, :status, :content_type, :transfer_size, :resource_type)
15
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Perchfall
4
- VERSION = "0.3.2"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/perchfall.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "perchfall/error_filter"
10
10
  require_relative "perchfall/command_runner"
11
11
  require_relative "perchfall/concurrency_limiter"
12
12
  require_relative "perchfall/url_validator"
13
+ require_relative "perchfall/resource"
13
14
  require_relative "perchfall/parsers/playwright_json_parser"
14
15
  require_relative "perchfall/playwright_invoker"
15
16
  require_relative "perchfall/client"
data/playwright/check.js CHANGED
@@ -20,10 +20,11 @@ const { parseArgs } = require("node:util");
20
20
 
21
21
  const { values: args } = parseArgs({
22
22
  options: {
23
- url: { type: "string" },
24
- timeout: { type: "string", default: "30000" },
25
- "wait-until": { type: "string", default: "load" },
26
- headers: { type: "string", default: "{}" },
23
+ url: { type: "string" },
24
+ timeout: { type: "string", default: "30000" },
25
+ "wait-until": { type: "string", default: "load" },
26
+ headers: { type: "string", default: "{}" },
27
+ "capture-resources": { type: "boolean", default: false },
27
28
  },
28
29
  strict: true,
29
30
  });
@@ -33,9 +34,10 @@ if (!args.url) {
33
34
  process.exit(1);
34
35
  }
35
36
 
36
- const TARGET_URL = args.url;
37
- const TIMEOUT_MS = parseInt(args.timeout, 10);
38
- const WAIT_UNTIL = args["wait-until"];
37
+ const TARGET_URL = args.url;
38
+ const TIMEOUT_MS = parseInt(args.timeout, 10);
39
+ const WAIT_UNTIL = args["wait-until"];
40
+ const CAPTURE_RESOURCES = args["capture-resources"];
39
41
 
40
42
  let EXTRA_HEADERS;
41
43
  try {
@@ -66,8 +68,8 @@ try {
66
68
  // Helpers
67
69
  // ---------------------------------------------------------------------------
68
70
 
69
- function buildResult({ status, durationMs, httpStatus, networkErrors, consoleErrors, error }) {
70
- return JSON.stringify({
71
+ function buildResult({ status, durationMs, httpStatus, networkErrors, consoleErrors, resources, error }) {
72
+ const result = {
71
73
  status,
72
74
  url: TARGET_URL,
73
75
  duration_ms: durationMs,
@@ -75,7 +77,25 @@ function buildResult({ status, durationMs, httpStatus, networkErrors, consoleErr
75
77
  network_errors: networkErrors,
76
78
  console_errors: consoleErrors,
77
79
  error: error ?? null,
78
- });
80
+ };
81
+ if (resources !== undefined) result.resources = resources;
82
+ return JSON.stringify(result);
83
+ }
84
+
85
+ async function resolveResources(pending) {
86
+ if (pending === null) return undefined;
87
+ return Promise.all(pending.map(async (res) => {
88
+ const headers = await res.allHeaders();
89
+ const rawLength = headers["content-length"];
90
+ return {
91
+ url: res.url(),
92
+ method: res.request().method(),
93
+ status: res.status(),
94
+ content_type: headers["content-type"] ?? null,
95
+ transfer_size: rawLength != null ? parseInt(rawLength, 10) : null,
96
+ resource_type: res.request().resourceType(),
97
+ };
98
+ }));
79
99
  }
80
100
 
81
101
  // ---------------------------------------------------------------------------
@@ -86,6 +106,7 @@ async function run() {
86
106
  const startedAt = Date.now();
87
107
  const networkErrors = [];
88
108
  const consoleErrors = [];
109
+ const pendingResources = CAPTURE_RESOURCES ? [] : null;
89
110
  let browser;
90
111
 
91
112
  try {
@@ -106,6 +127,7 @@ async function run() {
106
127
  });
107
128
 
108
129
  // Collect non-2xx/3xx responses as network errors too.
130
+ // When resource capture is enabled, stash every response for later processing.
109
131
  page.on("response", (response) => {
110
132
  const status = response.status();
111
133
  if (status >= 400) {
@@ -115,6 +137,9 @@ async function run() {
115
137
  failure: `HTTP ${status}`,
116
138
  });
117
139
  }
140
+ if (pendingResources !== null) {
141
+ pendingResources.push(response);
142
+ }
118
143
  });
119
144
 
120
145
  // Collect browser console errors.
@@ -135,6 +160,7 @@ async function run() {
135
160
  });
136
161
 
137
162
  const durationMs = Date.now() - startedAt;
163
+ const resources = await resolveResources(pendingResources);
138
164
 
139
165
  process.stdout.write(buildResult({
140
166
  status: "ok",
@@ -142,6 +168,7 @@ async function run() {
142
168
  httpStatus: response ? response.status() : null,
143
169
  networkErrors,
144
170
  consoleErrors,
171
+ resources,
145
172
  error: null,
146
173
  }));
147
174
 
@@ -150,6 +177,7 @@ async function run() {
150
177
  } catch (err) {
151
178
  // Page-level failure (timeout, DNS, etc.) — exit 0 so Ruby reads the JSON.
152
179
  const durationMs = Date.now() - startedAt;
180
+ const resources = await resolveResources(pendingResources);
153
181
 
154
182
  process.stdout.write(buildResult({
155
183
  status: "error",
@@ -157,6 +185,7 @@ async function run() {
157
185
  httpStatus: null,
158
186
  networkErrors,
159
187
  consoleErrors,
188
+ resources,
160
189
  error: err.message,
161
190
  }));
162
191
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: perchfall
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Remsik
@@ -89,6 +89,7 @@ files:
89
89
  - lib/perchfall/parsers/playwright_json_parser.rb
90
90
  - lib/perchfall/playwright_invoker.rb
91
91
  - lib/perchfall/report.rb
92
+ - lib/perchfall/resource.rb
92
93
  - lib/perchfall/url_validator.rb
93
94
  - lib/perchfall/version.rb
94
95
  - perchfall.gemspec