From 6bab8cea32729432c6025842c29b272f932c7fdb Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 07:27:36 -0400 Subject: [PATCH 1/2] Expose browser pool viewport Read viewport configuration into a typed Terraform object. Reject malformed dimensions and preserve omitted viewport and refresh-rate values without partial state. --- docs/data-sources/browser_pool.md | 10 +++ .../datasources/browserpool/datasource.go | 55 +++++++++++++++ .../browserpool/datasource_test.go | 68 ++++++++++++++++++- 3 files changed, 131 insertions(+), 2 deletions(-) diff --git a/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md index 5f05ade..7c6dccd 100644 --- a/docs/data-sources/browser_pool.md +++ b/docs/data-sources/browser_pool.md @@ -33,3 +33,13 @@ Lookup durable Kernel browser pool configuration. - `start_url` (String) URL opened when a browser is warmed into the pool, if configured. - `stealth` (Boolean) Whether browsers launch in stealth mode. - `timeout_seconds` (Number) Default idle timeout in seconds for acquired browsers. +- `viewport` (Attributes) Browser viewport configured for the pool, if any. (see [below for nested schema](#nestedatt--viewport)) + + +### Nested Schema for `viewport` + +Read-Only: + +- `height` (Number) Browser window height in pixels. +- `refresh_rate` (Number) Display refresh rate in Hz, if configured. +- `width` (Number) Browser window width in pixels. diff --git a/internal/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go index 9a9ed8d..07fd0db 100644 --- a/internal/datasources/browserpool/datasource.go +++ b/internal/datasources/browserpool/datasource.go @@ -28,6 +28,7 @@ const ( minBrowserPoolTimeoutSeconds = 10 maxBrowserPoolTimeoutSeconds = 259200 minBrowserPoolFillRate = 0 + minBrowserPoolViewportValue = 1 ) type browserPoolClient interface { @@ -53,6 +54,7 @@ type browserPoolModel struct { StartURL types.String `tfsdk:"start_url"` TimeoutSeconds types.Int64 `tfsdk:"timeout_seconds"` FillRatePerMinute types.Int64 `tfsdk:"fill_rate_per_minute"` + Viewport types.Object `tfsdk:"viewport"` } func NewDataSource() datasource.DataSource { @@ -129,6 +131,15 @@ func (d *browserPoolDataSource) Schema(_ context.Context, _ datasource.SchemaReq Computed: true, MarkdownDescription: "Percentage of the pool filled per minute.", }, + "viewport": dschema.SingleNestedAttribute{ + Computed: true, + MarkdownDescription: "Browser viewport configured for the pool, if any.", + Attributes: map[string]dschema.Attribute{ + "width": dschema.Int64Attribute{Computed: true, MarkdownDescription: "Browser window width in pixels."}, + "height": dschema.Int64Attribute{Computed: true, MarkdownDescription: "Browser window height in pixels."}, + "refresh_rate": dschema.Int64Attribute{Computed: true, MarkdownDescription: "Display refresh rate in Hz, if configured."}, + }, + }, }, } } @@ -269,6 +280,7 @@ func flattenBrowserPool(pool kernel.BrowserPool) (browserPoolModel, diag.Diagnos StartURL: flattenOptionalString("browser_pool_config.start_url", config.JSON.StartURL.Raw(), config.JSON.StartURL.Valid(), config.StartURL, &diags), TimeoutSeconds: flattenTimeoutSeconds(config.JSON.TimeoutSeconds.Raw(), config.JSON.TimeoutSeconds.Valid(), config.TimeoutSeconds, &diags), FillRatePerMinute: flattenFillRatePerMinute(config.JSON.FillRatePerMinute.Raw(), config.JSON.FillRatePerMinute.Valid(), config.FillRatePerMinute, &diags), + Viewport: flattenViewport(config.JSON.Viewport.Raw(), config.JSON.Viewport.Valid(), config.Viewport, &diags), }, diags } @@ -329,6 +341,49 @@ func flattenOptionalInt64(field, raw string, valid bool, value int64, diags *dia return types.Int64Value(value) } +func flattenViewport(raw string, valid bool, viewport shared.BrowserViewport, diags *diag.Diagnostics) types.Object { + if raw == "" { + return types.ObjectNull(viewportAttributeTypes()) + } + if !datasources.FieldPresent(raw) || !valid { + datasources.AddInvalidResponseField(diags, "Browser Pool", "browser_pool_config.viewport") + return types.ObjectNull(viewportAttributeTypes()) + } + + diagnosticsBefore := len(*diags) + width := flattenRequiredPositiveInt64("browser_pool_config.viewport.width", viewport.JSON.Width.Raw(), viewport.JSON.Width.Valid(), viewport.Width, diags) + height := flattenRequiredPositiveInt64("browser_pool_config.viewport.height", viewport.JSON.Height.Raw(), viewport.JSON.Height.Valid(), viewport.Height, diags) + refreshRate := types.Int64Null() + if viewport.JSON.RefreshRate.Raw() != "" { + refreshRate = flattenRequiredPositiveInt64("browser_pool_config.viewport.refresh_rate", viewport.JSON.RefreshRate.Raw(), viewport.JSON.RefreshRate.Valid(), viewport.RefreshRate, diags) + } + if len(*diags) > diagnosticsBefore { + return types.ObjectNull(viewportAttributeTypes()) + } + + return types.ObjectValueMust(viewportAttributeTypes(), map[string]attr.Value{ + "width": width, + "height": height, + "refresh_rate": refreshRate, + }) +} + +func flattenRequiredPositiveInt64(field, raw string, valid bool, value int64, diags *diag.Diagnostics) types.Int64 { + if !validResponseInt64(raw, valid, value) || value < minBrowserPoolViewportValue { + datasources.AddInvalidResponseField(diags, "Browser Pool", field) + return types.Int64Null() + } + return types.Int64Value(value) +} + +func viewportAttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "width": types.Int64Type, + "height": types.Int64Type, + "refresh_rate": types.Int64Type, + } +} + func flattenResolvedProfileID(pool kernel.BrowserPool, diags *diag.Diagnostics) types.String { raw := pool.JSON.ProfileID.Raw() if raw != "" { diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go index 6cd7157..4d8c3e8 100644 --- a/internal/datasources/browserpool/datasource_test.go +++ b/internal/datasources/browserpool/datasource_test.go @@ -48,7 +48,7 @@ func TestDataSourceMetadataSchemaAndConfigure(t *testing.T) { var schema datasource.SchemaResponse ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema) - for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids", "proxy_id", "headless", "kiosk_mode", "stealth", "start_url", "timeout_seconds", "fill_rate_per_minute"} { + for _, name := range []string{"id", "name", "project_id", "size", "profile_id", "extension_ids", "proxy_id", "headless", "kiosk_mode", "stealth", "start_url", "timeout_seconds", "fill_rate_per_minute", "viewport"} { if _, ok := schema.Schema.Attributes[name]; !ok { t.Fatalf("schema missing %s", name) } @@ -201,7 +201,8 @@ func TestReadSetsTerraformState(t *testing.T) { "stealth":true, "start_url":"chrome://newtab", "timeout_seconds":10, - "fill_rate_per_minute":0 + "fill_rate_per_minute":0, + "viewport":{"width":1280,"height":800,"refresh_rate":60} } }`), nil }, @@ -241,6 +242,51 @@ func TestReadSetsTerraformState(t *testing.T) { if state.StartURL.ValueString() != "chrome://newtab" || state.TimeoutSeconds.ValueInt64() != 10 || state.FillRatePerMinute.IsNull() || state.FillRatePerMinute.IsUnknown() || state.FillRatePerMinute.ValueInt64() != 0 { t.Fatalf("warmup state = %#v", state) } + assertBrowserPoolViewport(t, state.Viewport, 1280, 800, types.Int64Value(60)) +} + +func TestFlattenBrowserPoolViewportOptionalFields(t *testing.T) { + t.Parallel() + + omitted, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1}}`)) + if diags.HasError() { + t.Fatalf("unexpected omitted viewport diagnostics: %v", diags) + } + if !omitted.Viewport.IsNull() || len(omitted.Viewport.AttributeTypes(t.Context())) != 3 { + t.Fatalf("omitted viewport = %#v, want typed null", omitted.Viewport) + } + + withoutRefreshRate, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":1280,"height":800}}}`)) + if diags.HasError() { + t.Fatalf("unexpected viewport diagnostics: %v", diags) + } + assertBrowserPoolViewport(t, withoutRefreshRate.Viewport, 1280, 800, types.Int64Null()) +} + +func TestFlattenBrowserPoolRejectsInvalidViewport(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "null viewport": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":null}}`, + "non-object viewport": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":[]}}`, + "missing width": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"height":800}}}`, + "missing height": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":1280}}}`, + "zero width": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":0,"height":800}}}`, + "negative height": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":1280,"height":-1}}}`, + "null refresh rate": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":1280,"height":800,"refresh_rate":null}}}`, + "zero refresh rate": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":1280,"height":800,"refresh_rate":0}}}`, + "non-number dimension": `{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":"1280","height":800}}}`, + } + + for name, body := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, diags := flattenBrowserPool(*browserPoolFromJSON(body)) + if !diags.HasError() { + t.Fatal("expected diagnostics") + } + }) + } } func TestFlattenBrowserPoolWarmupConfigurationBoundaries(t *testing.T) { @@ -528,6 +574,11 @@ func browserPoolFromJSON(body string) *kernel.BrowserPool { } func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value { + viewportType := tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "width": tftypes.Number, + "height": tftypes.Number, + "refresh_rate": tftypes.Number, + }} return tftypes.NewValue( tftypes.Object{AttributeTypes: map[string]tftypes.Type{ "id": tftypes.String, @@ -543,6 +594,7 @@ func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value { "start_url": tftypes.String, "timeout_seconds": tftypes.Number, "fill_rate_per_minute": tftypes.Number, + "viewport": viewportType, }}, map[string]tftypes.Value{ "id": id, @@ -558,10 +610,22 @@ func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value { "start_url": tftypes.NewValue(tftypes.String, nil), "timeout_seconds": tftypes.NewValue(tftypes.Number, nil), "fill_rate_per_minute": tftypes.NewValue(tftypes.Number, nil), + "viewport": tftypes.NewValue(viewportType, nil), }, ) } +func assertBrowserPoolViewport(t *testing.T, viewport types.Object, width, height int64, refreshRate types.Int64) { + t.Helper() + if viewport.IsNull() || viewport.IsUnknown() { + t.Fatalf("viewport = %#v, want known object", viewport) + } + attributes := viewport.Attributes() + if !attributes["width"].(types.Int64).Equal(types.Int64Value(width)) || !attributes["height"].(types.Int64).Equal(types.Int64Value(height)) || !attributes["refresh_rate"].(types.Int64).Equal(refreshRate) { + t.Fatalf("viewport = %#v, want %dx%d refresh %v", viewport, width, height, refreshRate) + } +} + func assertBrowserPoolStringList(t *testing.T, got types.List, want []string) { t.Helper() if got.IsNull() || got.IsUnknown() { From c851e417b7b6ce09b419f34b8f7aad2ee1af3759 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:21:50 -0400 Subject: [PATCH 2/2] test viewport schema and minimum boundary --- .../browserpool/datasource_test.go | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go index 4d8c3e8..bdd714f 100644 --- a/internal/datasources/browserpool/datasource_test.go +++ b/internal/datasources/browserpool/datasource_test.go @@ -93,6 +93,12 @@ func TestDataSourceSchemaSemantics(t *testing.T) { assertAttributeMode(t, resp.Schema, "start_url", false, true) assertAttributeMode(t, resp.Schema, "timeout_seconds", false, true) assertAttributeMode(t, resp.Schema, "fill_rate_per_minute", false, true) + assertAttributeMode(t, resp.Schema, "viewport", false, true) + + viewport := resp.Schema.Attributes["viewport"].(dschema.SingleNestedAttribute) + for _, name := range []string{"width", "height", "refresh_rate"} { + assertAttributeMapMode(t, viewport.Attributes, name, false, true) + } projectID := resp.Schema.Attributes["project_id"].(dschema.StringAttribute) if !validateProjectID(projectID.Validators, "").HasError() { @@ -105,7 +111,12 @@ func TestDataSourceSchemaSemantics(t *testing.T) { func assertAttributeMode(t *testing.T, schema dschema.Schema, name string, optional, computed bool) { t.Helper() - attribute, ok := schema.Attributes[name] + assertAttributeMapMode(t, schema.Attributes, name, optional, computed) +} + +func assertAttributeMapMode(t *testing.T, attributes map[string]dschema.Attribute, name string, optional, computed bool) { + t.Helper() + attribute, ok := attributes[name] if !ok { t.Fatalf("schema missing %s", name) } @@ -263,6 +274,16 @@ func TestFlattenBrowserPoolViewportOptionalFields(t *testing.T) { assertBrowserPoolViewport(t, withoutRefreshRate.Viewport, 1280, 800, types.Int64Null()) } +func TestFlattenBrowserPoolAcceptsMinimumViewport(t *testing.T) { + t.Parallel() + + state, diags := flattenBrowserPool(*browserPoolFromJSON(`{"id":"pool-1","extension_ids":[],"browser_pool_config":{"size":1,"viewport":{"width":1,"height":1,"refresh_rate":1}}}`)) + if diags.HasError() { + t.Fatalf("unexpected minimum viewport diagnostics: %v", diags) + } + assertBrowserPoolViewport(t, state.Viewport, 1, 1, types.Int64Value(1)) +} + func TestFlattenBrowserPoolRejectsInvalidViewport(t *testing.T) { t.Parallel()