← back to Cli Printing Press
fix(cli): drive browser-sniff http_transport from HAR HTTP-version distribution (#1540)
833213ff69318e47017d87d31030d4e0a08b6e12 · 2026-05-16 16:25:04 -0700 · Trevin Chow
* fix(cli): drive browser-sniff http_transport from HAR HTTP-version distribution
Pre-fix the browser-sniff parser wrote spec.http_transport = browser-chrome-h3
whenever reachability.mode was browser_clearance_http and the bare
browser-chrome enum whenever mode was browser_http, regardless of the HTTP
version the HAR actually recorded. Origins that serve H/2 but not H/3 (most
Cloudflare and Next.js + Vercel SaaS today) shipped CLIs that failed their
first live call with CRYPTO_ERROR 0x128 (remote): tls: handshake failure.
Adds a new browser-chrome-h2 enum + UsesBrowserHTTP2Transport predicate.
Captures HAR httpVersion on every entry, computes a per-target distribution
in TrafficAnalysisSummary, and maps the majority version to the matching
transport: H/3 -> browser-chrome-h3, H/2 -> browser-chrome-h2, H/1.1 ->
browser-chrome (no version force). Empty distribution falls back to bare
browser-chrome so the runtime negotiates rather than guesses.
The bare browser-chrome enum now means "no version force"; the implicit
HTTP/2 force from the pre-fix template requires opt-in via browser-chrome-h2.
EffectiveHTTPTransport's default-sniffed and browser-auth-for-HTML branches
return browser-chrome-h2 instead of bare browser-chrome so the implicit H/2
force is preserved for shipped CLIs that did not go through browser-sniff.
ApplyReachabilityDefaults now runs before applyHTTPTransportDefault so the
HAR-driven mapping wins for browser_http / browser_clearance_http modes.
browser_required intentionally leaves HTTPTransport empty since the operator
must drive a real browser.
Closes #1387
* fix(cli): allow --transport browser-chrome-h2 via the CLI flag
normalizeHTTPTransport's CLI-flag allow-list was the one place that did
not learn about the new browser-chrome-h2 enum. Operators passing
--transport browser-chrome-h2 hit the validator's reject path even
though the value is accepted by spec.Validate, catalog.Validate, and the
generator's enum predicates. Add it to the allow-list and extend the
error message and the existing test to cover the new value.
Refs #1387
* fix(cli): preserve implicit H/2 force on protection-driven transport path
applyHTTPTransportDefault writes spec.HTTPTransportBrowserChrome (bare,
no force after the template change) whenever trafficAnalysisRecommendsBrowserTransport
fires for non-reachability-mode signals: Cloudflare/DataDome/Akamai/PerimeterX
protections, html_scrape protocol, generic browser/scrape hints. Pre-PR the
template's else branch unconditionally emitted ForceHTTP2() for bare
browser-chrome; post-PR it emits neither, so specs flagged via those
heuristics lost their implicit H/2 force.
Switch this fallback to browser-chrome-h2 so the generated client still
forces HTTP/2 in the absence of HAR HTTP-version data. The reachability-
driven paths (browser_http / browser_clearance_http) keep using the HAR
distribution via ApplyReachabilityDefaults; explicit H/3 hints still win
over the protection default.
Refs #1387
---------
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Files touched
M internal/browsersniff/analysis.goM internal/browsersniff/analysis_test.goA internal/browsersniff/http_version.goA internal/browsersniff/http_version_test.goM internal/browsersniff/parser.goM internal/browsersniff/reachability.goM internal/browsersniff/types.goM internal/catalog/catalog.goM internal/cli/generate_test.goM internal/cli/root.goM internal/generator/generator_test.goM internal/generator/templates/client.go.tmplM internal/spec/spec.goM internal/spec/spec_test.goM testdata/golden/expected/browser-sniff-sample/traffic-analysis.json
Diff
commit 833213ff69318e47017d87d31030d4e0a08b6e12
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sat May 16 16:25:04 2026 -0700
fix(cli): drive browser-sniff http_transport from HAR HTTP-version distribution (#1540)
* fix(cli): drive browser-sniff http_transport from HAR HTTP-version distribution
Pre-fix the browser-sniff parser wrote spec.http_transport = browser-chrome-h3
whenever reachability.mode was browser_clearance_http and the bare
browser-chrome enum whenever mode was browser_http, regardless of the HTTP
version the HAR actually recorded. Origins that serve H/2 but not H/3 (most
Cloudflare and Next.js + Vercel SaaS today) shipped CLIs that failed their
first live call with CRYPTO_ERROR 0x128 (remote): tls: handshake failure.
Adds a new browser-chrome-h2 enum + UsesBrowserHTTP2Transport predicate.
Captures HAR httpVersion on every entry, computes a per-target distribution
in TrafficAnalysisSummary, and maps the majority version to the matching
transport: H/3 -> browser-chrome-h3, H/2 -> browser-chrome-h2, H/1.1 ->
browser-chrome (no version force). Empty distribution falls back to bare
browser-chrome so the runtime negotiates rather than guesses.
The bare browser-chrome enum now means "no version force"; the implicit
HTTP/2 force from the pre-fix template requires opt-in via browser-chrome-h2.
EffectiveHTTPTransport's default-sniffed and browser-auth-for-HTML branches
return browser-chrome-h2 instead of bare browser-chrome so the implicit H/2
force is preserved for shipped CLIs that did not go through browser-sniff.
ApplyReachabilityDefaults now runs before applyHTTPTransportDefault so the
HAR-driven mapping wins for browser_http / browser_clearance_http modes.
browser_required intentionally leaves HTTPTransport empty since the operator
must drive a real browser.
Closes #1387
* fix(cli): allow --transport browser-chrome-h2 via the CLI flag
normalizeHTTPTransport's CLI-flag allow-list was the one place that did
not learn about the new browser-chrome-h2 enum. Operators passing
--transport browser-chrome-h2 hit the validator's reject path even
though the value is accepted by spec.Validate, catalog.Validate, and the
generator's enum predicates. Add it to the allow-list and extend the
error message and the existing test to cover the new value.
Refs #1387
* fix(cli): preserve implicit H/2 force on protection-driven transport path
applyHTTPTransportDefault writes spec.HTTPTransportBrowserChrome (bare,
no force after the template change) whenever trafficAnalysisRecommendsBrowserTransport
fires for non-reachability-mode signals: Cloudflare/DataDome/Akamai/PerimeterX
protections, html_scrape protocol, generic browser/scrape hints. Pre-PR the
template's else branch unconditionally emitted ForceHTTP2() for bare
browser-chrome; post-PR it emits neither, so specs flagged via those
heuristics lost their implicit H/2 force.
Switch this fallback to browser-chrome-h2 so the generated client still
forces HTTP/2 in the absence of HAR HTTP-version data. The reachability-
driven paths (browser_http / browser_clearance_http) keep using the HAR
distribution via ApplyReachabilityDefaults; explicit H/3 hints still win
over the protection default.
Refs #1387
---------
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
---
internal/browsersniff/analysis.go | 36 +++--
internal/browsersniff/analysis_test.go | 10 +-
internal/browsersniff/http_version.go | 66 ++++++++
internal/browsersniff/http_version_test.go | 179 +++++++++++++++++++++
internal/browsersniff/parser.go | 14 ++
internal/browsersniff/reachability.go | 15 +-
internal/browsersniff/types.go | 17 +-
internal/catalog/catalog.go | 5 +-
internal/cli/generate_test.go | 59 ++++++-
internal/cli/root.go | 24 ++-
internal/generator/generator_test.go | 75 +++++++++
internal/generator/templates/client.go.tmpl | 2 +-
internal/spec/spec.go | 30 ++--
internal/spec/spec_test.go | 26 ++-
.../browser-sniff-sample/traffic-analysis.json | 4 +
15 files changed, 510 insertions(+), 52 deletions(-)
diff --git a/internal/browsersniff/analysis.go b/internal/browsersniff/analysis.go
index 2fb14d87..f914266c 100644
--- a/internal/browsersniff/analysis.go
+++ b/internal/browsersniff/analysis.go
@@ -134,14 +134,15 @@ func unmarshalGenerationHints(data []byte) ([]string, error) {
}
type TrafficAnalysisSummary struct {
- TargetURL string `json:"target_url,omitempty"`
- CapturedAt string `json:"captured_at,omitempty"`
- EntryCount int `json:"entry_count"`
- APIEntryCount int `json:"api_entry_count"`
- NoiseEntryCount int `json:"noise_entry_count"`
- HostDistribution map[string]int `json:"host_distribution,omitempty"`
- TimeStart string `json:"time_start,omitempty"`
- TimeEnd string `json:"time_end,omitempty"`
+ TargetURL string `json:"target_url,omitempty"`
+ CapturedAt string `json:"captured_at,omitempty"`
+ EntryCount int `json:"entry_count"`
+ APIEntryCount int `json:"api_entry_count"`
+ NoiseEntryCount int `json:"noise_entry_count"`
+ HostDistribution map[string]int `json:"host_distribution,omitempty"`
+ HTTPVersionDistribution map[string]int `json:"http_version_distribution,omitempty"`
+ TimeStart string `json:"time_start,omitempty"`
+ TimeEnd string `json:"time_end,omitempty"`
}
// EvidenceRef cites a piece of evidence for an observation. Two flavors:
@@ -599,12 +600,13 @@ func entryClassificationKey(entry EnrichedEntry) string {
func buildTrafficSummary(capture *EnrichedCapture, apiEntries []EnrichedEntry, noiseEntries []EnrichedEntry) TrafficAnalysisSummary {
summary := TrafficAnalysisSummary{
- TargetURL: capture.TargetURL,
- CapturedAt: capture.CapturedAt,
- EntryCount: len(capture.Entries),
- APIEntryCount: len(apiEntries),
- NoiseEntryCount: len(noiseEntries),
- HostDistribution: map[string]int{},
+ TargetURL: capture.TargetURL,
+ CapturedAt: capture.CapturedAt,
+ EntryCount: len(capture.Entries),
+ APIEntryCount: len(apiEntries),
+ NoiseEntryCount: len(noiseEntries),
+ HostDistribution: map[string]int{},
+ HTTPVersionDistribution: map[string]int{},
}
var start *time.Time
var end *time.Time
@@ -613,6 +615,9 @@ func buildTrafficSummary(capture *EnrichedCapture, apiEntries []EnrichedEntry, n
if host != "" {
summary.HostDistribution[host]++
}
+ if v := NormalizeHTTPVersion(entry.HTTPVersion); v != "" {
+ summary.HTTPVersionDistribution[v]++
+ }
parsed, ok := parseEntryTime(entry.StartedDateTime)
if !ok {
continue
@@ -629,6 +634,9 @@ func buildTrafficSummary(capture *EnrichedCapture, apiEntries []EnrichedEntry, n
if len(summary.HostDistribution) == 0 {
summary.HostDistribution = nil
}
+ if len(summary.HTTPVersionDistribution) == 0 {
+ summary.HTTPVersionDistribution = nil
+ }
if start != nil {
summary.TimeStart = start.Format(time.RFC3339Nano)
}
diff --git a/internal/browsersniff/analysis_test.go b/internal/browsersniff/analysis_test.go
index e55feb0b..1988fe0e 100644
--- a/internal/browsersniff/analysis_test.go
+++ b/internal/browsersniff/analysis_test.go
@@ -518,7 +518,10 @@ func TestApplyReachabilityDefaultsAddsBrowserClearanceCookieAuth(t *testing.T) {
Resources: map[string]spec.Resource{"posts": {Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/posts"}}}},
}
analysis := &TrafficAnalysis{
- Summary: TrafficAnalysisSummary{TargetURL: "https://www.producthunt.com"},
+ Summary: TrafficAnalysisSummary{
+ TargetURL: "https://www.producthunt.com",
+ HTTPVersionDistribution: map[string]int{"h3": 12},
+ },
Reachability: &ReachabilityAnalysis{
Mode: "browser_clearance_http",
Confidence: 0.9,
@@ -551,7 +554,10 @@ func TestApplyReachabilityDefaultsDoesNotRequireProofWithoutValidationPath(t *te
}}}},
}
analysis := &TrafficAnalysis{
- Summary: TrafficAnalysisSummary{TargetURL: "https://www.producthunt.com"},
+ Summary: TrafficAnalysisSummary{
+ TargetURL: "https://www.producthunt.com",
+ HTTPVersionDistribution: map[string]int{"h3": 12},
+ },
Reachability: &ReachabilityAnalysis{
Mode: "browser_clearance_http",
Confidence: 0.9,
diff --git a/internal/browsersniff/http_version.go b/internal/browsersniff/http_version.go
new file mode 100644
index 00000000..60b93dd3
--- /dev/null
+++ b/internal/browsersniff/http_version.go
@@ -0,0 +1,66 @@
+package browsersniff
+
+import (
+ "strings"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+)
+
+// NormalizeHTTPVersion canonicalizes HAR-recorded httpVersion strings to
+// a fixed set of labels: "h3", "h2", "http/1.1", or "" when the input
+// is empty or unrecognized. HAR exporters spell HTTP/2 as "h2",
+// "HTTP/2", "h2c", or even "HTTP/2.0"; HTTP/3 appears as "h3" or
+// "HTTP/3". Normalizing here keeps the distribution map's keys stable
+// regardless of which browser captured the trace.
+func NormalizeHTTPVersion(raw string) string {
+ v := strings.ToLower(strings.TrimSpace(raw))
+ if v == "" {
+ return ""
+ }
+ switch {
+ case v == "h3", strings.HasPrefix(v, "http/3"):
+ return "h3"
+ case v == "h2", v == "h2c", strings.HasPrefix(v, "http/2"):
+ return "h2"
+ case strings.HasPrefix(v, "http/1.1"):
+ return "http/1.1"
+ }
+ return ""
+}
+
+// browserTransportFromHARVersion picks the HTTPTransport enum value
+// from the HAR's HTTP-version distribution. The most-frequent recognized
+// version wins; ties go to the higher-version (H/3 > H/2 > H/1.1) so a
+// 50/50 H/2-vs-H/3 origin biases toward the stricter wire protocol the
+// origin already proved it can serve. An empty distribution returns
+// HTTPTransportBrowserChrome (no version force) so callers fall back to
+// Chrome's own version negotiation rather than guessing.
+func browserTransportFromHARVersion(dist map[string]int) string {
+ if len(dist) == 0 {
+ return spec.HTTPTransportBrowserChrome
+ }
+ // Iterate in descending priority order; the first version with a
+ // strict majority wins. When two versions tie for the lead, the
+ // higher version wins by virtue of being checked first.
+ type bucket struct {
+ key string
+ transport string
+ }
+ priority := []bucket{
+ {"h3", spec.HTTPTransportBrowserChromeH3},
+ {"h2", spec.HTTPTransportBrowserChromeH2},
+ {"http/1.1", spec.HTTPTransportBrowserChrome},
+ }
+ best := bucket{transport: spec.HTTPTransportBrowserChrome}
+ bestCount := 0
+ for _, b := range priority {
+ if c := dist[b.key]; c > bestCount {
+ best = b
+ bestCount = c
+ }
+ }
+ if bestCount == 0 {
+ return spec.HTTPTransportBrowserChrome
+ }
+ return best.transport
+}
diff --git a/internal/browsersniff/http_version_test.go b/internal/browsersniff/http_version_test.go
new file mode 100644
index 00000000..7d34efaa
--- /dev/null
+++ b/internal/browsersniff/http_version_test.go
@@ -0,0 +1,179 @@
+package browsersniff
+
+import (
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+)
+
+func TestNormalizeHTTPVersion(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ in string
+ want string
+ }{
+ {"HTTP/3", "h3"},
+ {"http/3.0", "h3"},
+ {"h3", "h3"},
+ {"HTTP/2", "h2"},
+ {"http/2.0", "h2"},
+ {"h2", "h2"},
+ {"h2c", "h2"},
+ {"HTTP/1.1", "http/1.1"},
+ {"http/1.1", "http/1.1"},
+ {"", ""},
+ {"unknown", ""},
+ {"HTTP/0.9", ""},
+ {" HTTP/2 ", "h2"},
+ }
+ for _, c := range cases {
+ got := NormalizeHTTPVersion(c.in)
+ if got != c.want {
+ t.Errorf("NormalizeHTTPVersion(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestBrowserTransportFromHARVersion_PicksMajority(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ dist map[string]int
+ want string
+ }{
+ {
+ name: "h3 majority",
+ dist: map[string]int{"h3": 20, "h2": 3},
+ want: spec.HTTPTransportBrowserChromeH3,
+ },
+ {
+ name: "h2 majority",
+ dist: map[string]int{"h2": 18, "h3": 2, "http/1.1": 1},
+ want: spec.HTTPTransportBrowserChromeH2,
+ },
+ {
+ name: "h1 only",
+ dist: map[string]int{"http/1.1": 5},
+ want: spec.HTTPTransportBrowserChrome,
+ },
+ {
+ name: "empty falls back to no-force browser-chrome",
+ dist: map[string]int{},
+ want: spec.HTTPTransportBrowserChrome,
+ },
+ {
+ name: "nil distribution falls back to no-force browser-chrome",
+ dist: nil,
+ want: spec.HTTPTransportBrowserChrome,
+ },
+ {
+ name: "tie between h3 and h2 prefers h3",
+ dist: map[string]int{"h3": 5, "h2": 5},
+ want: spec.HTTPTransportBrowserChromeH3,
+ },
+ {
+ name: "unrecognized keys ignored",
+ dist: map[string]int{"spdy": 99},
+ want: spec.HTTPTransportBrowserChrome,
+ },
+ }
+ for _, c := range cases {
+ got := browserTransportFromHARVersion(c.dist)
+ if got != c.want {
+ t.Errorf("%s: browserTransportFromHARVersion(%v) = %q, want %q", c.name, c.dist, got, c.want)
+ }
+ }
+}
+
+func TestApplyReachabilityDefaults_HARVersionDrivesTransport(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ mode string
+ dist map[string]int
+ want string
+ }{
+ {"clearance + h3", "browser_clearance_http", map[string]int{"h3": 9}, spec.HTTPTransportBrowserChromeH3},
+ {"clearance + h2", "browser_clearance_http", map[string]int{"h2": 9}, spec.HTTPTransportBrowserChromeH2},
+ {"clearance + h1", "browser_clearance_http", map[string]int{"http/1.1": 5}, spec.HTTPTransportBrowserChrome},
+ {"browser_http + h2", "browser_http", map[string]int{"h2": 4}, spec.HTTPTransportBrowserChromeH2},
+ {"empty distribution + clearance", "browser_clearance_http", nil, spec.HTTPTransportBrowserChrome},
+ }
+ for _, c := range cases {
+ apiSpec := &spec.APISpec{
+ Name: c.name,
+ BaseURL: "https://www.example.com",
+ Auth: spec.AuthConfig{Type: "none"},
+ Resources: map[string]spec.Resource{
+ "posts": {Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/posts"}}},
+ },
+ }
+ analysis := &TrafficAnalysis{
+ Summary: TrafficAnalysisSummary{
+ TargetURL: "https://www.example.com",
+ HTTPVersionDistribution: c.dist,
+ },
+ Reachability: &ReachabilityAnalysis{Mode: c.mode, Confidence: 0.9},
+ }
+ ApplyReachabilityDefaults(apiSpec, analysis)
+ if apiSpec.HTTPTransport != c.want {
+ t.Errorf("%s: HTTPTransport = %q, want %q", c.name, apiSpec.HTTPTransport, c.want)
+ }
+ }
+}
+
+// TestApplyReachabilityDefaults_BrowserRequiredLeavesEmpty pins the
+// invariant that browser_required mode does not set HTTPTransport even
+// when a HAR distribution is present. browser_required means the
+// operator must drive a real browser; the surf client is not used and
+// emitting a transport would mislead downstream code.
+func TestApplyReachabilityDefaults_BrowserRequiredLeavesEmpty(t *testing.T) {
+ t.Parallel()
+ apiSpec := &spec.APISpec{
+ Name: "browserrequired",
+ BaseURL: "https://www.example.com",
+ Auth: spec.AuthConfig{Type: "none"},
+ Resources: map[string]spec.Resource{
+ "posts": {Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/posts"}}},
+ },
+ }
+ analysis := &TrafficAnalysis{
+ Summary: TrafficAnalysisSummary{
+ TargetURL: "https://www.example.com",
+ HTTPVersionDistribution: map[string]int{"h3": 10},
+ },
+ Reachability: &ReachabilityAnalysis{Mode: "browser_required", Confidence: 0.9},
+ }
+ ApplyReachabilityDefaults(apiSpec, analysis)
+ if apiSpec.HTTPTransport != "" {
+ t.Errorf("browser_required must leave HTTPTransport empty regardless of HAR; got %q", apiSpec.HTTPTransport)
+ }
+}
+
+// TestApplyReachabilityDefaults_PreservesExplicitTransport keeps the
+// caller's explicit choice when HTTPTransport is already set, so an
+// operator who hand-tunes the spec is not overridden by the HAR
+// majority. Matches the old switch semantics.
+func TestApplyReachabilityDefaults_PreservesExplicitTransport(t *testing.T) {
+ t.Parallel()
+ apiSpec := &spec.APISpec{
+ Name: "preserve",
+ BaseURL: "https://www.example.com",
+ Auth: spec.AuthConfig{Type: "none"},
+ HTTPTransport: spec.HTTPTransportBrowserChromeH3,
+ Resources: map[string]spec.Resource{
+ "posts": {Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/posts"}}},
+ },
+ }
+ analysis := &TrafficAnalysis{
+ Summary: TrafficAnalysisSummary{
+ TargetURL: "https://www.example.com",
+ HTTPVersionDistribution: map[string]int{"h2": 99},
+ },
+ Reachability: &ReachabilityAnalysis{Mode: "browser_clearance_http", Confidence: 0.9},
+ }
+ ApplyReachabilityDefaults(apiSpec, analysis)
+ if apiSpec.HTTPTransport != spec.HTTPTransportBrowserChromeH3 {
+ t.Errorf("explicit HTTPTransport must be preserved; got %q", apiSpec.HTTPTransport)
+ }
+}
diff --git a/internal/browsersniff/parser.go b/internal/browsersniff/parser.go
index fccf509e..f6f82899 100644
--- a/internal/browsersniff/parser.go
+++ b/internal/browsersniff/parser.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
+ "strings"
)
func ParseHAR(path string) (*HAR, error) {
@@ -56,6 +57,7 @@ func convertHAREntry(entry HAREntry) EnrichedEntry {
return EnrichedEntry{
Method: entry.Request.Method,
URL: entry.Request.URL,
+ HTTPVersion: pickHARHTTPVersion(entry),
StartedDateTime: entry.StartedDateTime,
DurationMS: entry.Time,
RequestBody: requestBody,
@@ -66,3 +68,15 @@ func convertHAREntry(entry HAREntry) EnrichedEntry {
ResponseHeaders: responseHeaders,
}
}
+
+// pickHARHTTPVersion returns the response httpVersion when present,
+// falling back to the request httpVersion. HAR exporters vary: Chrome
+// fills both, some intermediaries fill only one. The response wire
+// version is the more accurate signal for "what the origin spoke" so
+// it wins when both are set.
+func pickHARHTTPVersion(entry HAREntry) string {
+ if v := strings.TrimSpace(entry.Response.HTTPVersion); v != "" {
+ return v
+ }
+ return strings.TrimSpace(entry.Request.HTTPVersion)
+}
diff --git a/internal/browsersniff/reachability.go b/internal/browsersniff/reachability.go
index 9f3bc7e9..9a69b9a6 100644
--- a/internal/browsersniff/reachability.go
+++ b/internal/browsersniff/reachability.go
@@ -17,14 +17,15 @@ func ApplyReachabilityDefaults(apiSpec *spec.APISpec, analysis *TrafficAnalysis)
applyHTMLScrapeExtractionDefaults(apiSpec, analysis.Reachability.HTMLExtractSignature)
}
- if analysis.Reachability.Mode == "browser_http" || analysis.Reachability.Mode == "browser_clearance_http" || analysis.Reachability.Mode == "browser_required" {
+ // browser_required is the "no Go-client replay possible" mode -- the
+ // operator must drive a real browser. It intentionally leaves
+ // HTTPTransport empty so downstream code does not try to wire a surf
+ // client. The HAR-driven transport mapping applies only to modes
+ // where the captured traffic is replayable.
+ switch analysis.Reachability.Mode {
+ case "browser_http", "browser_clearance_http":
if apiSpec.HTTPTransport == "" {
- switch analysis.Reachability.Mode {
- case "browser_clearance_http":
- apiSpec.HTTPTransport = spec.HTTPTransportBrowserChromeH3
- case "browser_http":
- apiSpec.HTTPTransport = spec.HTTPTransportBrowserChrome
- }
+ apiSpec.HTTPTransport = browserTransportFromHARVersion(analysis.Summary.HTTPVersionDistribution)
}
}
diff --git a/internal/browsersniff/types.go b/internal/browsersniff/types.go
index edee1caf..0ecc0fd5 100644
--- a/internal/browsersniff/types.go
+++ b/internal/browsersniff/types.go
@@ -16,10 +16,11 @@ type HAREntry struct {
}
type HARRequest struct {
- Method string `json:"method"`
- URL string `json:"url"`
- Headers []HARHeader `json:"headers"`
- PostData *HARPostData `json:"postData,omitempty"`
+ Method string `json:"method"`
+ URL string `json:"url"`
+ HTTPVersion string `json:"httpVersion,omitempty"`
+ Headers []HARHeader `json:"headers"`
+ PostData *HARPostData `json:"postData,omitempty"`
}
type HARPostData struct {
@@ -28,9 +29,10 @@ type HARPostData struct {
}
type HARResponse struct {
- Status int `json:"status"`
- Headers []HARHeader `json:"headers,omitempty"`
- Content HARResponseContent `json:"content"`
+ Status int `json:"status"`
+ HTTPVersion string `json:"httpVersion,omitempty"`
+ Headers []HARHeader `json:"headers,omitempty"`
+ Content HARResponseContent `json:"content"`
}
type HARResponseContent struct {
@@ -64,6 +66,7 @@ type AuthCapture struct {
type EnrichedEntry struct {
Method string `json:"method"`
URL string `json:"url"`
+ HTTPVersion string `json:"http_version,omitempty"`
StartedDateTime string `json:"started_date_time,omitempty"`
DurationMS float64 `json:"duration_ms,omitempty"`
RequestBody string `json:"request_body"`
diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go
index f2e4d82d..6f3be7ff 100644
--- a/internal/catalog/catalog.go
+++ b/internal/catalog/catalog.go
@@ -72,7 +72,8 @@ var validClientPatterns = map[string]struct{}{
var validHTTPTransports = map[string]struct{}{
"standard": {}, // Plain net/http, default for official APIs
"browser-http": {}, // Plain net/http with HTTP/2 disabled for browser-facing websites
- "browser-chrome": {}, // Browser-compatible transport for web-discovered/non-official APIs
+ "browser-chrome": {}, // Browser-compatible transport for web-discovered/non-official APIs (no version force; Chrome negotiates)
+ "browser-chrome-h2": {}, // Chrome-compatible HTTP transport forced through HTTP/2
"browser-chrome-h3": {}, // Chrome-compatible HTTP transport forced through HTTP/3
}
@@ -333,7 +334,7 @@ func (e *Entry) Validate() error {
}
if e.HTTPTransport != "" {
if _, ok := validHTTPTransports[e.HTTPTransport]; !ok {
- return fmt.Errorf("http_transport must be one of: standard, browser-http, browser-chrome, browser-chrome-h3")
+ return fmt.Errorf("http_transport must be one of: standard, browser-http, browser-chrome, browser-chrome-h2, browser-chrome-h3")
}
}
if e.BaseURL != "" && !strings.HasPrefix(e.BaseURL, "https://") {
diff --git a/internal/cli/generate_test.go b/internal/cli/generate_test.go
index 680e9bea..17f6453c 100644
--- a/internal/cli/generate_test.go
+++ b/internal/cli/generate_test.go
@@ -11,6 +11,7 @@ import (
"testing"
catalogfs "github.com/mvanhorn/cli-printing-press/v4/catalog"
+ "github.com/mvanhorn/cli-printing-press/v4/internal/browsersniff"
"github.com/mvanhorn/cli-printing-press/v4/internal/catalog"
"github.com/mvanhorn/cli-printing-press/v4/internal/catalogmeta"
"github.com/mvanhorn/cli-printing-press/v4/internal/pipeline"
@@ -714,7 +715,8 @@ resources:
"summary": {
"target_url": "https://www.producthunt.com",
"entry_count": 1,
- "api_entry_count": 1
+ "api_entry_count": 1,
+ "http_version_distribution": {"h2": 9}
},
"reachability": {
"mode": "browser_clearance_http",
@@ -753,6 +755,9 @@ resources:
require.NoError(t, err)
assert.NotContains(t, string(clientGo), `req.Header.Set("User-Agent"`)
assert.Contains(t, string(clientGo), `"github.com/enetx/surf"`)
+ // HAR distribution declares H/2 majority -> ForceHTTP2 is emitted and
+ // ForceHTTP3 is not. With an empty distribution the bare browser-chrome
+ // enum would emit neither; the fixture above pins the HAR-driven path.
assert.Contains(t, string(clientGo), "ForceHTTP2()")
assert.NotContains(t, string(clientGo), "ForceHTTP3()")
assert.NotContains(t, string(clientGo), "runBrowserUseFetch")
@@ -807,7 +812,8 @@ resources:
"summary": {
"target_url": "https://www.producthunt.com",
"entry_count": 1,
- "api_entry_count": 1
+ "api_entry_count": 1,
+ "http_version_distribution": {"h2": 9}
},
"reachability": {
"mode": "browser_clearance_http",
@@ -831,6 +837,7 @@ resources:
clientGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
require.NoError(t, err)
assert.Contains(t, string(clientGo), `"github.com/enetx/surf"`)
+ // HAR distribution declares H/2 majority -> ForceHTTP2 emits.
assert.Contains(t, string(clientGo), "ForceHTTP2()")
assert.NotContains(t, string(clientGo), "ForceHTTP3()")
assert.NotContains(t, string(clientGo), "runBrowserUseFetch")
@@ -1525,6 +1532,10 @@ func TestNormalizeHTTPTransportAllowsBrowserChromeH3(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, spec.HTTPTransportBrowserHTTP, got)
+ got, err = normalizeHTTPTransport(spec.HTTPTransportBrowserChromeH2)
+ require.NoError(t, err)
+ assert.Equal(t, spec.HTTPTransportBrowserChromeH2, got)
+
got, err = normalizeHTTPTransport(spec.HTTPTransportBrowserChromeH3)
require.NoError(t, err)
assert.Equal(t, spec.HTTPTransportBrowserChromeH3, got)
@@ -1532,10 +1543,54 @@ func TestNormalizeHTTPTransportAllowsBrowserChromeH3(t *testing.T) {
_, err = normalizeHTTPTransport("browser-chrome-http3")
require.ErrorContains(t, err, "browser-chrome-h3")
+ _, err = normalizeHTTPTransport("browser-chrome-http2")
+ require.ErrorContains(t, err, "browser-chrome-h2")
+
_, err = normalizeHTTPTransport("browser-runtime")
require.ErrorContains(t, err, "--transport must be one of")
}
+// TestApplyHTTPTransportDefaultPreservesH2ForProtectionPath pins the
+// protection-driven branch (Cloudflare/DataDome/html_scrape/browser
+// hints without a reachability mode). Pre-fix the template's else
+// branch unconditionally emitted ForceHTTP2 for bare browser-chrome;
+// post-fix the bare enum means "no force" so this path must opt into
+// the -h2 variant explicitly to keep shipped CLIs on these origins
+// behaving identically.
+func TestApplyHTTPTransportDefaultPreservesH2ForProtectionPath(t *testing.T) {
+ t.Parallel()
+ apiSpec := &spec.APISpec{
+ Name: "cloudflareapp",
+ BaseURL: "https://www.example.com",
+ Auth: spec.AuthConfig{Type: "none"},
+ }
+ analysis := &browsersniff.TrafficAnalysis{
+ Protections: []browsersniff.ProtectionObservation{{Label: "cloudflare"}},
+ }
+ applyHTTPTransportDefault(apiSpec, analysis)
+ assert.Equal(t, spec.HTTPTransportBrowserChromeH2, apiSpec.HTTPTransport,
+ "protection-driven path must write browser-chrome-h2 so the generated client still forces HTTP/2")
+}
+
+// TestApplyHTTPTransportDefaultExplicitH3WinsOverProtection pins that
+// an explicit H/3 hint still wins over the protection heuristic when
+// both fire; the H/3 force preempts the H/2 fallback.
+func TestApplyHTTPTransportDefaultExplicitH3WinsOverProtection(t *testing.T) {
+ t.Parallel()
+ apiSpec := &spec.APISpec{
+ Name: "cloudflareh3app",
+ BaseURL: "https://www.example.com",
+ Auth: spec.AuthConfig{Type: "none"},
+ }
+ analysis := &browsersniff.TrafficAnalysis{
+ Protections: []browsersniff.ProtectionObservation{{Label: "cloudflare"}},
+ GenerationHints: []string{"prefer_http3"},
+ }
+ applyHTTPTransportDefault(apiSpec, analysis)
+ assert.Equal(t, spec.HTTPTransportBrowserChromeH3, apiSpec.HTTPTransport,
+ "explicit H/3 hint must beat the protection-driven H/2 default")
+}
+
func TestGenerateCmdInfersTrafficAnalysisForSniffedSpec(t *testing.T) {
t.Parallel()
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 3afcff02..00962bcf 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -495,8 +495,17 @@ func runGenerateProject(apiSpec *spec.APISpec, absOut string, opts generateProje
if opts.rejectUnshippablePageContextTraffic && trafficAnalysisRequiresUnshippablePageContext(trafficAnalysis) {
return nil, false, &ExitError{Code: ExitInputError, Err: fmt.Errorf("traffic analysis says this target requires live browser page-context execution; persistent browser transport is not a shippable printed CLI runtime. Re-run discovery for a Surf/direct/browser-clearance replayable surface instead")}
}
- applyHTTPTransportDefault(apiSpec, trafficAnalysis)
+ // ApplyReachabilityDefaults runs first so its HAR-driven HTTP-version
+ // mapping wins for browser_http / browser_clearance_http modes.
+ // applyHTTPTransportDefault then fills the cases reachability does
+ // not cover (no reachability section, hint-only signals, browser_required)
+ // because its own no-op-when-set guard short-circuits in the populated
+ // case. The two functions cover disjoint reachability modes, so the
+ // short-circuit is the only thing keeping a write-write conflict
+ // impossible today; preserve that invariant if either function's
+ // mode coverage widens.
browsersniff.ApplyReachabilityDefaults(apiSpec, trafficAnalysis)
+ applyHTTPTransportDefault(apiSpec, trafficAnalysis)
gen.TrafficAnalysis = trafficAnalysis
if err := gen.Generate(); err != nil {
return nil, false, &ExitError{Code: ExitGenerationError, Err: fmt.Errorf("generating project: %w", err)}
@@ -561,10 +570,10 @@ func normalizeClientPattern(value string) (string, error) {
func normalizeHTTPTransport(value string) (string, error) {
switch value {
- case "", spec.HTTPTransportStandard, spec.HTTPTransportBrowserHTTP, spec.HTTPTransportBrowserChrome, spec.HTTPTransportBrowserChromeH3:
+ case "", spec.HTTPTransportStandard, spec.HTTPTransportBrowserHTTP, spec.HTTPTransportBrowserChrome, spec.HTTPTransportBrowserChromeH2, spec.HTTPTransportBrowserChromeH3:
return value, nil
default:
- return "", fmt.Errorf("--transport must be one of: standard, browser-http, browser-chrome, browser-chrome-h3 (got %q)", value)
+ return "", fmt.Errorf("--transport must be one of: standard, browser-http, browser-chrome, browser-chrome-h2, browser-chrome-h3 (got %q)", value)
}
}
@@ -596,7 +605,14 @@ func applyHTTPTransportDefault(apiSpec *spec.APISpec, analysis *browsersniff.Tra
return
}
if trafficAnalysisRecommendsBrowserTransport(analysis) {
- apiSpec.HTTPTransport = spec.HTTPTransportBrowserChrome
+ // Surface the implicit H/2 force the pre-template-change else-branch
+ // provided. ApplyReachabilityDefaults handles the browser_http /
+ // browser_clearance_http modes with HAR-driven precision; everything
+ // this branch covers (Cloudflare/DataDome/Akamai protections, html_scrape
+ // protocol, generic browser/scrape hints) lacks HAR HTTP-version data,
+ // so default to -h2 instead of bare browser-chrome (no force) to keep
+ // shipped CLIs on origins these heuristics flag behaving identically.
+ apiSpec.HTTPTransport = spec.HTTPTransportBrowserChromeH2
}
}
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index b469ffa2..e669ef5b 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -1701,6 +1701,81 @@ func TestGenerateBrowserChromeH3Transport(t *testing.T) {
runGoCommand(t, outputDir, "test", "./internal/client")
}
+// TestGenerateBrowserChromeH2Transport pins the explicit
+// browser-chrome-h2 enum: the client emits ForceHTTP2() and no
+// ForceHTTP3(). Separate from the bare browser-chrome case (no version
+// force) so a future refactor cannot collapse the two without a failing
+// test.
+func TestGenerateBrowserChromeH2Transport(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := &spec.APISpec{
+ Name: "websurfaceh2",
+ Version: "0.1.0",
+ BaseURL: "https://www.example.com",
+ HTTPTransport: spec.HTTPTransportBrowserChromeH2,
+ Auth: spec.AuthConfig{Type: "none"},
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: "~/.config/websurfaceh2-pp-cli/config.toml",
+ },
+ Resources: map[string]spec.Resource{
+ "posts": {
+ Description: "Browse posts",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/", Description: "List posts"},
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "websurfaceh2-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ clientGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+ require.NoError(t, err)
+ assert.Contains(t, string(clientGo), `"github.com/enetx/surf"`)
+ assert.Contains(t, string(clientGo), "ForceHTTP2()")
+ assert.NotContains(t, string(clientGo), "ForceHTTP3()")
+}
+
+// TestGenerateBrowserChromeNoVersionForce pins the bare browser-chrome
+// enum (no -h2 / -h3 suffix): the surf client is used but no
+// ForceHTTPN() call is emitted, so Chrome's negotiated version wins.
+// Operators who want an explicit H/2 force must set browser-chrome-h2.
+func TestGenerateBrowserChromeNoVersionForce(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := &spec.APISpec{
+ Name: "websurfacenoforce",
+ Version: "0.1.0",
+ BaseURL: "https://www.example.com",
+ HTTPTransport: spec.HTTPTransportBrowserChrome,
+ Auth: spec.AuthConfig{Type: "none"},
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: "~/.config/websurfacenoforce-pp-cli/config.toml",
+ },
+ Resources: map[string]spec.Resource{
+ "posts": {
+ Description: "Browse posts",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/", Description: "List posts"},
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "websurfacenoforce-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ clientGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client.go"))
+ require.NoError(t, err)
+ assert.Contains(t, string(clientGo), `"github.com/enetx/surf"`)
+ assert.NotContains(t, string(clientGo), "ForceHTTP2()")
+ assert.NotContains(t, string(clientGo), "ForceHTTP3()")
+}
+
func TestGenerateBrowserHTTPTransportDisablesHTTP2(t *testing.T) {
t.Parallel()
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 77b75d82..497bb71d 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -284,7 +284,7 @@ func newHTTPClient(timeout time.Duration, jar http.CookieJar) *http.Client {
Timeout(timeout)
{{- if .UsesBrowserHTTP3Transport}}
builder = builder.ForceHTTP3()
-{{- else}}
+{{- else if .UsesBrowserHTTP2Transport}}
builder = builder.ForceHTTP2()
{{- end}}
if jar == nil {
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 360536e1..7a66a156 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -27,7 +27,8 @@ const (
const (
HTTPTransportStandard = "standard" // default for official API clients
HTTPTransportBrowserHTTP = "browser-http" // stdlib transport with HTTP/2 disabled for browser-facing web surfaces
- HTTPTransportBrowserChrome = "browser-chrome" // Chrome-impersonated transport for browser-facing web surfaces
+ HTTPTransportBrowserChrome = "browser-chrome" // Chrome-impersonated transport for browser-facing web surfaces (no version force; Chrome negotiates)
+ HTTPTransportBrowserChromeH2 = "browser-chrome-h2" // Chrome-impersonated transport forced through HTTP/2 for origins that serve H/2 but not H/3
HTTPTransportBrowserChromeH3 = "browser-chrome-h3" // Chrome-impersonated transport forced through HTTP/3 for stricter bot screens
)
@@ -144,7 +145,7 @@ type APISpec struct {
Kind string `yaml:"kind,omitempty" json:"kind,omitempty"` // "rest" (default) or "synthetic" — synthetic CLIs aggregate multiple sources beyond the spec; dogfood's path-validity check is relaxed accordingly
SpecSource string `yaml:"spec_source,omitempty" json:"spec_source,omitempty"` // official, community, sniffed, docs — affects generated client defaults
ClientPattern string `yaml:"client_pattern,omitempty" json:"client_pattern,omitempty"` // rest (default), proxy-envelope — affects generated HTTP client
- HTTPTransport string `yaml:"http_transport,omitempty" json:"http_transport,omitempty"` // standard (default for official APIs), browser-http, browser-chrome, or browser-chrome-h3
+ HTTPTransport string `yaml:"http_transport,omitempty" json:"http_transport,omitempty"` // standard (default for official APIs), browser-http, browser-chrome, browser-chrome-h2, or browser-chrome-h3
HealthCheckPath string `yaml:"health_check_path,omitempty" json:"health_check_path,omitempty"`
ProxyRoutes map[string]string `yaml:"proxy_routes,omitempty" json:"proxy_routes,omitempty"` // path prefix → service name for proxy-envelope routing
BearerRefresh BearerRefreshConfig `yaml:"bearer_refresh,omitempty" json:"bearer_refresh,omitzero"` // live-source metadata for rotating public client bearer tokens
@@ -372,15 +373,22 @@ func (s *APISpec) EffectiveHTTPTransport() string {
return HTTPTransportStandard
}
switch s.HTTPTransport {
- case HTTPTransportStandard, HTTPTransportBrowserHTTP, HTTPTransportBrowserChrome, HTTPTransportBrowserChromeH3:
+ case HTTPTransportStandard, HTTPTransportBrowserHTTP, HTTPTransportBrowserChrome, HTTPTransportBrowserChromeH2, HTTPTransportBrowserChromeH3:
return s.HTTPTransport
}
+ // Defaults map to the explicit -h2 variant. The bare "browser-chrome"
+ // enum means "no version force"; default-sniffed and
+ // browser-auth-for-HTML specs surface their H/2 force through the
+ // explicit "-h2" enum so the spec field always names the wire
+ // protocol the runtime will pick. browser-sniff overrides this with
+ // HAR-driven mapping in ApplyReachabilityDefaults before
+ // EffectiveHTTPTransport runs.
if s.usesBrowserAuthForHTML() {
- return HTTPTransportBrowserChrome
+ return HTTPTransportBrowserChromeH2
}
switch s.SpecSource {
case "community", "sniffed":
- return HTTPTransportBrowserChrome
+ return HTTPTransportBrowserChromeH2
default:
return HTTPTransportStandard
}
@@ -397,7 +405,7 @@ func (s *APISpec) usesBrowserAuthForHTML() bool {
func (s *APISpec) UsesBrowserHTTPTransport() bool {
switch s.EffectiveHTTPTransport() {
- case HTTPTransportBrowserChrome, HTTPTransportBrowserChromeH3:
+ case HTTPTransportBrowserChrome, HTTPTransportBrowserChromeH2, HTTPTransportBrowserChromeH3:
return true
default:
return false
@@ -408,13 +416,17 @@ func (s *APISpec) UsesBrowserHTTP3Transport() bool {
return s.EffectiveHTTPTransport() == HTTPTransportBrowserChromeH3
}
+func (s *APISpec) UsesBrowserHTTP2Transport() bool {
+ return s.EffectiveHTTPTransport() == HTTPTransportBrowserChromeH2
+}
+
func (s *APISpec) UsesHTTP2DisabledTransport() bool {
return s.EffectiveHTTPTransport() == HTTPTransportBrowserHTTP
}
func (s *APISpec) UsesBrowserManagedUserAgent() bool {
switch s.EffectiveHTTPTransport() {
- case HTTPTransportBrowserChrome, HTTPTransportBrowserChromeH3:
+ case HTTPTransportBrowserChrome, HTTPTransportBrowserChromeH2, HTTPTransportBrowserChromeH3:
return true
default:
return false
@@ -1986,9 +1998,9 @@ func (s *APISpec) Validate() error {
return fmt.Errorf("at least one resource is required")
}
switch s.HTTPTransport {
- case "", HTTPTransportStandard, HTTPTransportBrowserHTTP, HTTPTransportBrowserChrome, HTTPTransportBrowserChromeH3:
+ case "", HTTPTransportStandard, HTTPTransportBrowserHTTP, HTTPTransportBrowserChrome, HTTPTransportBrowserChromeH2, HTTPTransportBrowserChromeH3:
default:
- return fmt.Errorf("http_transport must be one of: standard, browser-http, browser-chrome, browser-chrome-h3")
+ return fmt.Errorf("http_transport must be one of: standard, browser-http, browser-chrome, browser-chrome-h2, browser-chrome-h3")
}
if err := validateExtraCommands(s.ExtraCommands); err != nil {
return err
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index f59984be..ff3ca40d 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -2681,7 +2681,7 @@ func TestHTTPTransportValidationAndDefaults(t *testing.T) {
sniffed := base
sniffed.SpecSource = "sniffed"
- assert.Equal(t, HTTPTransportBrowserChrome, sniffed.EffectiveHTTPTransport())
+ assert.Equal(t, HTTPTransportBrowserChromeH2, sniffed.EffectiveHTTPTransport())
require.NoError(t, sniffed.Validate())
browserHTTP := base
@@ -2694,13 +2694,31 @@ func TestHTTPTransportValidationAndDefaults(t *testing.T) {
community := base
community.SpecSource = "community"
- assert.Equal(t, HTTPTransportBrowserChrome, community.EffectiveHTTPTransport())
+ assert.Equal(t, HTTPTransportBrowserChromeH2, community.EffectiveHTTPTransport())
+
+ browserChrome := base
+ browserChrome.HTTPTransport = HTTPTransportBrowserChrome
+ assert.Equal(t, HTTPTransportBrowserChrome, browserChrome.EffectiveHTTPTransport())
+ assert.True(t, browserChrome.UsesBrowserHTTPTransport())
+ assert.False(t, browserChrome.UsesBrowserHTTP2Transport())
+ assert.False(t, browserChrome.UsesBrowserHTTP3Transport())
+ require.NoError(t, browserChrome.Validate())
+
+ h2 := base
+ h2.HTTPTransport = HTTPTransportBrowserChromeH2
+ assert.Equal(t, HTTPTransportBrowserChromeH2, h2.EffectiveHTTPTransport())
+ assert.True(t, h2.UsesBrowserHTTPTransport())
+ assert.True(t, h2.UsesBrowserHTTP2Transport())
+ assert.False(t, h2.UsesBrowserHTTP3Transport())
+ assert.True(t, h2.UsesBrowserManagedUserAgent())
+ require.NoError(t, h2.Validate())
h3 := base
h3.HTTPTransport = HTTPTransportBrowserChromeH3
assert.Equal(t, HTTPTransportBrowserChromeH3, h3.EffectiveHTTPTransport())
assert.True(t, h3.UsesBrowserHTTPTransport())
assert.True(t, h3.UsesBrowserHTTP3Transport())
+ assert.False(t, h3.UsesBrowserHTTP2Transport())
assert.True(t, h3.UsesBrowserManagedUserAgent())
require.NoError(t, h3.Validate())
@@ -2718,11 +2736,11 @@ func TestHTTPTransportValidationAndDefaults(t *testing.T) {
},
},
}
- assert.Equal(t, HTTPTransportBrowserChrome, cookieHTML.EffectiveHTTPTransport())
+ assert.Equal(t, HTTPTransportBrowserChromeH2, cookieHTML.EffectiveHTTPTransport())
composedHTML := cookieHTML
composedHTML.Auth.Type = "composed"
- assert.Equal(t, HTTPTransportBrowserChrome, composedHTML.EffectiveHTTPTransport())
+ assert.Equal(t, HTTPTransportBrowserChromeH2, composedHTML.EffectiveHTTPTransport())
jsonCookie := cookieHTML
jsonCookie.Resources = map[string]Resource{
diff --git a/testdata/golden/expected/browser-sniff-sample/traffic-analysis.json b/testdata/golden/expected/browser-sniff-sample/traffic-analysis.json
index 0a2baff6..4f397bb9 100644
--- a/testdata/golden/expected/browser-sniff-sample/traffic-analysis.json
+++ b/testdata/golden/expected/browser-sniff-sample/traffic-analysis.json
@@ -235,6 +235,10 @@
"data": 1,
"httpbin.org": 4
},
+ "http_version_distribution": {
+ "h2": 4,
+ "http/1.1": 1
+ },
"noise_entry_count": 2,
"target_url": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwcHgiICBoZWlnaHQ9IjIwMHB4IiAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB2aWV3Qm94PSIwIDAgMTAwIDEwMCIgcHJlc2VydmVBc3BlY3RSYXRpbz0ieE1pZFlNaWQiIGNsYXNzPSJsZHMtcm9sbGluZyIgc3R5bGU9ImJhY2tncm91bmQtaW1hZ2U6IG5vbmU7IGJhY2tncm91bmQtcG9zaXRpb246IGluaXRpYWwgaW5pdGlhbDsgYmFja2dyb3VuZC1yZXBlYXQ6IGluaXRpYWwgaW5pdGlhbDsiPjxjaXJjbGUgY3g9IjUwIiBjeT0iNTAiIGZpbGw9Im5vbmUiIG5nLWF0dHItc3Ryb2tlPSJ7e2NvbmZpZy5jb2xvcn19IiBuZy1hdHRyLXN0cm9rZS13aWR0aD0ie3tjb25maWcud2lkdGh9fSIgbmctYXR0ci1yPSJ7e2NvbmZpZy5yYWRpdXN9fSIgbmctYXR0ci1zdHJva2UtZGFzaGFycmF5PSJ7e2NvbmZpZy5kYXNoYXJyYXl9fSIgc3Ryb2tlPSIjNTU1NTU1IiBzdHJva2Utd2lkdGg9IjEwIiByPSIzNSIgc3Ryb2tlLWRhc2hhcnJheT0iMTY0LjkzMzYxNDMxMzQ2NDE1IDU2Ljk3Nzg3MTQzNzgyMTM4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIHR5cGU9InJvdGF0ZSIgY2FsY01vZGU9ImxpbmVhciIgdmFsdWVzPSIwIDUwIDUwOzM2MCA1MCA1MCIga2V5VGltZXM9IjA7MSIgZHVyPSIxcyIgYmVnaW49IjBzIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSI+PC9hbmltYXRlVHJhbnNmb3JtPjwvY2lyY2xlPjwvc3ZnPgo=",
"time_end": "2026-03-28T22:47:12.3217152Z",
← 63f745b8 fix(skills): split SQL example out of go-fenced block in NUL
·
back to Cli Printing Press
·
fix(ci): cancel-in-progress=false so cancelled check_runs do 80ba507e →