From e30d5be2a62f4951bbfdcf5d1048e8e3d2457178 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Sat, 11 Jul 2026 06:15:35 -0400 Subject: [PATCH 1/3] Add browser pool core data source Expose exact browser pool lookup by ID or name with predictable project scoping. Keep the first slice limited to canonical identity, name, and durable size so runtime state cannot enter Terraform. --- docs/data-sources/browser_pool.md | 26 ++ .../datasources/browserpool/datasource.go | 216 ++++++++++++++ .../browserpool/datasource_test.go | 276 ++++++++++++++++++ internal/provider/provider.go | 2 + internal/provider/provider_test.go | 6 +- 5 files changed, 523 insertions(+), 3 deletions(-) create mode 100644 docs/data-sources/browser_pool.md create mode 100644 internal/datasources/browserpool/datasource.go create mode 100644 internal/datasources/browserpool/datasource_test.go diff --git a/docs/data-sources/browser_pool.md b/docs/data-sources/browser_pool.md new file mode 100644 index 0000000..9b92a3c --- /dev/null +++ b/docs/data-sources/browser_pool.md @@ -0,0 +1,26 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "kernel_browser_pool Data Source - Kernel" +subcategory: "" +description: |- + Lookup durable Kernel browser pool configuration. +--- + +# kernel_browser_pool (Data Source) + +Lookup durable Kernel browser pool configuration. + + + + +## Schema + +### Optional + +- `id` (String) Browser pool ID. +- `name` (String) Browser pool name for exact lookup. +- `project_id` (String) Project to look the browser pool up in. Defaults to the provider `project_id`; when neither is set, the API key's project binding determines the project. + +### Read-Only + +- `size` (Number) Number of browsers maintained in the pool. diff --git a/internal/datasources/browserpool/datasource.go b/internal/datasources/browserpool/datasource.go new file mode 100644 index 0000000..fa044d0 --- /dev/null +++ b/internal/datasources/browserpool/datasource.go @@ -0,0 +1,216 @@ +package browserpool + +import ( + "context" + "encoding/json" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + dschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/datasources" + "github.com/kernel/terraform-provider-kernel/internal/projectscope" +) + +var ( + _ datasource.DataSource = (*browserPoolDataSource)(nil) + _ datasource.DataSourceWithConfigure = (*browserPoolDataSource)(nil) +) + +type browserPoolClient interface { + DefaultProjectID() string + GetBrowserPool(context.Context, string, string) (*kernel.BrowserPool, error) +} + +type browserPoolDataSource struct { + client browserPoolClient +} + +type browserPoolModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + ProjectID types.String `tfsdk:"project_id"` + Size types.Int64 `tfsdk:"size"` +} + +func NewDataSource() datasource.DataSource { + return &browserPoolDataSource{} +} + +func newDataSourceWithClient(client browserPoolClient) *browserPoolDataSource { + return &browserPoolDataSource{client: client} +} + +func (d *browserPoolDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_browser_pool" +} + +func (d *browserPoolDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = dschema.Schema{ + MarkdownDescription: "Lookup durable Kernel browser pool configuration.", + Attributes: map[string]dschema.Attribute{ + "id": dschema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Browser pool ID.", + }, + "name": dschema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Browser pool name for exact lookup.", + }, + "project_id": dschema.StringAttribute{ + Optional: true, + MarkdownDescription: "Project to look the browser pool up in. Defaults to the provider `project_id`; when neither is set, the API key's project binding determines the project.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "size": dschema.Int64Attribute{ + Computed: true, + MarkdownDescription: "Number of browsers maintained in the pool.", + }, + }, + } +} + +func (d *browserPoolDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(browserPoolClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Kernel Client Type", + "Expected provider data to implement the browser pool data source durable client contract.", + ) + return + } + d.client = client +} + +func (d *browserPoolDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config browserPoolModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + state, diags := d.read(ctx, config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (d *browserPoolDataSource) read(ctx context.Context, config browserPoolModel) (browserPoolModel, diag.Diagnostics) { + var diags diag.Diagnostics + if d.client == nil { + diags.AddError("Missing Kernel Client", "The browser pool data source was not configured with a Kernel client.") + return browserPoolModel{}, diags + } + + selector, selectorDiags := datasources.ResolveIDNameSelector("Browser Pool", "kernel_browser_pool", config.ID, config.Name) + diags.Append(selectorDiags...) + if diags.HasError() { + return browserPoolModel{}, diags + } + + projectID := projectscope.ResolveDataSource(&diags, config.ProjectID, d.client.DefaultProjectID()) + if diags.HasError() { + return browserPoolModel{}, diags + } + + var idOrName string + switch { + case selector.HasID: + idOrName = config.ID.ValueString() + case selector.HasName: + idOrName = config.Name.ValueString() + default: + diags.AddError("Missing Browser Pool Selector", "Configure id or name for kernel_browser_pool.") + return browserPoolModel{}, diags + } + + pool, err := d.client.GetBrowserPool(ctx, projectID, idOrName) + if err != nil { + projectscope.AddError(&diags, "Read Kernel Browser Pool", projectID, err) + return browserPoolModel{}, diags + } + if pool == nil { + diags.AddError("Read Kernel Browser Pool", "Kernel returned an empty browser pool response.") + return browserPoolModel{}, diags + } + + state, flattenDiags := flattenBrowserPool(*pool) + diags.Append(flattenDiags...) + if diags.HasError() { + return browserPoolModel{}, diags + } + if selector.HasID && state.ID.ValueString() != config.ID.ValueString() { + diags.AddError( + "Browser Pool ID Mismatch", + "Kernel returned browser pool "+strconv.Quote(state.ID.ValueString())+" for id selector "+strconv.Quote(config.ID.ValueString())+".", + ) + return browserPoolModel{}, diags + } + if selector.HasName && (state.Name.IsNull() || state.Name.ValueString() != config.Name.ValueString()) { + diags.AddError( + "Browser Pool Name Mismatch", + "Kernel returned a browser pool whose name does not match exact selector "+strconv.Quote(config.Name.ValueString())+".", + ) + return browserPoolModel{}, diags + } + + state.ProjectID = config.ProjectID + return state, diags +} + +func flattenBrowserPool(pool kernel.BrowserPool) (browserPoolModel, diag.Diagnostics) { + var diags diag.Diagnostics + if !datasources.ValidResponseString(pool.JSON.ID.Raw(), pool.JSON.ID.Valid(), pool.ID) { + datasources.AddInvalidResponseField(&diags, "Browser Pool", "id") + } + if !validResponseInt64(pool.BrowserPoolConfig.JSON.Size.Raw(), pool.BrowserPoolConfig.JSON.Size.Valid(), pool.BrowserPoolConfig.Size) || pool.BrowserPoolConfig.Size < 1 { + datasources.AddInvalidResponseField(&diags, "Browser Pool", "browser_pool_config.size") + } + + name := types.StringNull() + switch { + case datasources.FieldPresent(pool.JSON.Name.Raw()): + if !datasources.ValidResponseString(pool.JSON.Name.Raw(), pool.JSON.Name.Valid(), pool.Name) { + datasources.AddInvalidResponseField(&diags, "Browser Pool", "name") + } else { + name = types.StringValue(pool.Name) + } + case datasources.FieldPresent(pool.BrowserPoolConfig.JSON.Name.Raw()): + if !datasources.ValidResponseString(pool.BrowserPoolConfig.JSON.Name.Raw(), pool.BrowserPoolConfig.JSON.Name.Valid(), pool.BrowserPoolConfig.Name) { + datasources.AddInvalidResponseField(&diags, "Browser Pool", "browser_pool_config.name") + } else { + name = types.StringValue(pool.BrowserPoolConfig.Name) + } + } + if diags.HasError() { + return browserPoolModel{}, diags + } + + return browserPoolModel{ + ID: types.StringValue(pool.ID), + Name: name, + Size: types.Int64Value(pool.BrowserPoolConfig.Size), + }, diags +} + +func validResponseInt64(raw string, valid bool, value int64) bool { + if !datasources.FieldPresent(raw) || !valid { + return false + } + var decoded int64 + return json.Unmarshal([]byte(raw), &decoded) == nil && decoded == value +} diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go new file mode 100644 index 0000000..9bde833 --- /dev/null +++ b/internal/datasources/browserpool/datasource_test.go @@ -0,0 +1,276 @@ +package browserpool + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/terraform-provider-kernel/internal/kernelclient" +) + +var _ browserPoolClient = kernelclient.Clients{} + +type fakeBrowserPoolClient struct { + defaultProjectID string + get func(context.Context, string, string) (*kernel.BrowserPool, error) +} + +func (f fakeBrowserPoolClient) DefaultProjectID() string { + return f.defaultProjectID +} + +func (f fakeBrowserPoolClient) GetBrowserPool(ctx context.Context, projectID, idOrName string) (*kernel.BrowserPool, error) { + if f.get == nil { + return nil, errors.New("unexpected GetBrowserPool call") + } + return f.get(ctx, projectID, idOrName) +} + +func TestDataSourceMetadataSchemaAndConfigure(t *testing.T) { + t.Parallel() + + ds := NewDataSource() + var metadata datasource.MetadataResponse + ds.Metadata(context.Background(), datasource.MetadataRequest{ProviderTypeName: "kernel"}, &metadata) + if metadata.TypeName != "kernel_browser_pool" { + t.Fatalf("type name = %q, want kernel_browser_pool", metadata.TypeName) + } + + var schema datasource.SchemaResponse + ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema) + for _, name := range []string{"id", "name", "project_id", "size"} { + if _, ok := schema.Schema.Attributes[name]; !ok { + t.Fatalf("schema missing %s", name) + } + } + for _, runtimeField := range []string{"acquired_count", "available_count", "standby", "sessions"} { + if _, ok := schema.Schema.Attributes[runtimeField]; ok { + t.Fatalf("schema includes runtime field %s", runtimeField) + } + } + + configured := &browserPoolDataSource{} + var configure datasource.ConfigureResponse + configured.Configure(context.Background(), datasource.ConfigureRequest{ProviderData: kernelclient.Clients{}}, &configure) + if configure.Diagnostics.HasError() || configured.client == nil { + t.Fatalf("configure diagnostics/client = %v/%v", configure.Diagnostics, configured.client) + } + + var invalid datasource.ConfigureResponse + configured.Configure(context.Background(), datasource.ConfigureRequest{ProviderData: "not a client"}, &invalid) + if len(invalid.Diagnostics) != 1 || invalid.Diagnostics[0].Summary() != "Unexpected Kernel Client Type" { + t.Fatalf("invalid configure diagnostics = %v", invalid.Diagnostics) + } +} + +func TestReadBrowserPoolByIDOrName(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + config browserPoolModel + defaultProjectID string + wantProjectID string + wantSelector string + }{ + "id": { + config: browserPoolModel{ID: types.StringValue("pool-1"), ProjectID: types.StringValue("project-explicit")}, + defaultProjectID: "project-default", + wantProjectID: "project-explicit", + wantSelector: "pool-1", + }, + "name uses provider default": { + config: browserPoolModel{Name: types.StringValue("Pool"), ProjectID: types.StringNull()}, + defaultProjectID: "project-default", + wantProjectID: "project-default", + wantSelector: "Pool", + }, + "unscoped": { + config: browserPoolModel{ID: types.StringValue("pool-1"), ProjectID: types.StringNull()}, + wantSelector: "pool-1", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + var gotProjectID, gotSelector string + ds := newDataSourceWithClient(fakeBrowserPoolClient{ + defaultProjectID: test.defaultProjectID, + get: func(ctx context.Context, projectID, idOrName string) (*kernel.BrowserPool, error) { + gotProjectID, gotSelector = projectID, idOrName + return browserPoolForTest("pool-1", "Pool", 2), nil + }, + }) + + state, diags := ds.read(context.Background(), test.config) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if gotSelector != test.wantSelector { + t.Fatalf("selector = %q, want %q", gotSelector, test.wantSelector) + } + if gotProjectID != test.wantProjectID { + t.Fatalf("project = %q, want %q", gotProjectID, test.wantProjectID) + } + if state.ID.ValueString() != "pool-1" || state.Name.ValueString() != "Pool" || state.Size.ValueInt64() != 2 { + t.Fatalf("state = %#v", state) + } + if !state.ProjectID.Equal(test.config.ProjectID) { + t.Fatalf("state project_id = %v, want %v", state.ProjectID, test.config.ProjectID) + } + }) + } +} + +func TestReadSetsTerraformState(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeBrowserPoolClient{ + get: func(context.Context, string, string) (*kernel.BrowserPool, error) { + return browserPoolForTest("pool-1", "Pool", 2), nil + }, + }) + var schema datasource.SchemaResponse + ds.Schema(context.Background(), datasource.SchemaRequest{}, &schema) + req := datasource.ReadRequest{Config: tfsdk.Config{ + Schema: schema.Schema, + Raw: browserPoolConfigValue( + tftypes.NewValue(tftypes.String, nil), + tftypes.NewValue(tftypes.String, "Pool"), + tftypes.NewValue(tftypes.String, nil), + ), + }} + resp := datasource.ReadResponse{State: tfsdk.State{Schema: schema.Schema}} + + ds.Read(context.Background(), req, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + + var state browserPoolModel + resp.Diagnostics.Append(resp.State.Get(context.Background(), &state)...) + if resp.Diagnostics.HasError() { + t.Fatalf("read state: %v", resp.Diagnostics) + } + if state.ID.ValueString() != "pool-1" || state.Name.ValueString() != "Pool" || state.Size.ValueInt64() != 2 { + t.Fatalf("state = %#v", state) + } +} + +func TestReadBrowserPoolAllowsUnnamedPoolByID(t *testing.T) { + t.Parallel() + + ds := newDataSourceWithClient(fakeBrowserPoolClient{ + get: func(context.Context, string, string) (*kernel.BrowserPool, error) { + return browserPoolForTest("pool-1", "", 1), nil + }, + }) + state, diags := ds.read(context.Background(), browserPoolModel{ID: types.StringValue("pool-1")}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if !state.Name.IsNull() { + t.Fatalf("name = %v, want null", state.Name) + } +} + +func TestReadBrowserPoolRejectsInvalidInputsAndResponses(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + client browserPoolClient + config browserPoolModel + }{ + "missing client": {config: browserPoolModel{ID: types.StringValue("pool-1")}}, + "missing selector": {client: fakeBrowserPoolClient{}}, + "conflicting selectors": { + client: fakeBrowserPoolClient{}, + config: browserPoolModel{ID: types.StringValue("pool-1"), Name: types.StringValue("Pool")}, + }, + "unknown project": { + client: fakeBrowserPoolClient{}, + config: browserPoolModel{ID: types.StringValue("pool-1"), ProjectID: types.StringUnknown()}, + }, + "API error": { + client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) { + return nil, errors.New("connection reset") + }}, + config: browserPoolModel{ID: types.StringValue("pool-1")}, + }, + "empty response": { + client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) { return nil, nil }}, + config: browserPoolModel{ID: types.StringValue("pool-1")}, + }, + "invalid response": { + client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) { + return browserPoolFromJSON(`{"id":"pool-1","browser_pool_config":{"size":"2"}}`), nil + }}, + config: browserPoolModel{ID: types.StringValue("pool-1")}, + }, + "ID mismatch": { + client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) { + return browserPoolForTest("pool-other", "Pool", 1), nil + }}, + config: browserPoolModel{ID: types.StringValue("pool-1")}, + }, + "name mismatch": { + client: fakeBrowserPoolClient{get: func(context.Context, string, string) (*kernel.BrowserPool, error) { + return browserPoolForTest("pool-1", "Other", 1), nil + }}, + config: browserPoolModel{Name: types.StringValue("Pool")}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + ds := newDataSourceWithClient(test.client) + _, diags := ds.read(context.Background(), test.config) + if !diags.HasError() { + t.Fatal("expected diagnostics") + } + }) + } +} + +func browserPoolForTest(id, name string, size int64) *kernel.BrowserPool { + nameJSON := "null" + configName := "" + if name != "" { + nameJSON = strconv.Quote(name) + configName = `,"name":` + strconv.Quote(name) + } + return browserPoolFromJSON(`{"id":` + strconv.Quote(id) + `,"name":` + nameJSON + `,"browser_pool_config":{"size":` + strconv.FormatInt(size, 10) + configName + `}}`) +} + +func browserPoolFromJSON(body string) *kernel.BrowserPool { + var pool kernel.BrowserPool + if err := json.Unmarshal([]byte(body), &pool); err != nil { + panic(err) + } + return &pool +} + +func browserPoolConfigValue(id, name, projectID tftypes.Value) tftypes.Value { + return tftypes.NewValue( + tftypes.Object{AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "name": tftypes.String, + "project_id": tftypes.String, + "size": tftypes.Number, + }}, + map[string]tftypes.Value{ + "id": id, + "name": name, + "project_id": projectID, + "size": tftypes.NewValue(tftypes.Number, nil), + }, + ) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 5e3a9ee..2e647a3 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" + browserpooldatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/browserpool" "github.com/kernel/terraform-provider-kernel/internal/datasources/extension" "github.com/kernel/terraform-provider-kernel/internal/datasources/profile" projectdatasource "github.com/kernel/terraform-provider-kernel/internal/datasources/project" @@ -88,6 +89,7 @@ func (p *kernelProvider) Resources(ctx context.Context) []func() resource.Resour func (p *kernelProvider) DataSources(ctx context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ + browserpooldatasource.NewDataSource, projectdatasource.NewDataSource, profile.NewDataSource, proxy.NewDataSource, diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index a3fdd0d..f1905f7 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -62,8 +62,8 @@ func TestProviderRegistersDataSources(t *testing.T) { p := provider.New("test")() dataSources := p.DataSources(context.Background()) - if len(dataSources) != 4 { - t.Fatalf("DataSources length = %d, want 4", len(dataSources)) + if len(dataSources) != 5 { + t.Fatalf("DataSources length = %d, want 5", len(dataSources)) } got := make(map[string]bool, len(dataSources)) @@ -77,7 +77,7 @@ func TestProviderRegistersDataSources(t *testing.T) { got[resp.TypeName] = true } - for _, want := range []string{"kernel_project", "kernel_profile", "kernel_proxy", "kernel_extension"} { + for _, want := range []string{"kernel_browser_pool", "kernel_project", "kernel_profile", "kernel_proxy", "kernel_extension"} { if !got[want] { t.Fatalf("missing data source %s; got %v", want, got) } From 01562e482f571b16f8731466d1ba605da423ab34 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:52:02 -0400 Subject: [PATCH 2/3] test browser pool data source schema mutations Lock selector, project scope, and computed attribute semantics after targeted mutation testing exposed missing schema assertions. --- .../browserpool/datasource_test.go | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go index 9bde833..f190292 100644 --- a/internal/datasources/browserpool/datasource_test.go +++ b/internal/datasources/browserpool/datasource_test.go @@ -8,6 +8,9 @@ import ( "testing" "github.com/hashicorp/terraform-plugin-framework/datasource" + dschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -70,6 +73,49 @@ func TestDataSourceMetadataSchemaAndConfigure(t *testing.T) { } } +func TestDataSourceSchemaSemantics(t *testing.T) { + t.Parallel() + + ds := NewDataSource() + var resp datasource.SchemaResponse + ds.Schema(context.Background(), datasource.SchemaRequest{}, &resp) + + id, ok := resp.Schema.Attributes["id"].(dschema.StringAttribute) + if !ok || !id.Optional || !id.Computed || id.Required { + t.Fatalf("id must be an optional, computed string: %#v", resp.Schema.Attributes["id"]) + } + name, ok := resp.Schema.Attributes["name"].(dschema.StringAttribute) + if !ok || !name.Optional || !name.Computed || name.Required { + t.Fatalf("name must be an optional, computed string: %#v", resp.Schema.Attributes["name"]) + } + projectID, ok := resp.Schema.Attributes["project_id"].(dschema.StringAttribute) + if !ok || !projectID.Optional || projectID.Computed || projectID.Required { + t.Fatalf("project_id must be an optional string: %#v", resp.Schema.Attributes["project_id"]) + } + size, ok := resp.Schema.Attributes["size"].(dschema.Int64Attribute) + if !ok || !size.Computed || size.Optional || size.Required { + t.Fatalf("size must be a computed integer: %#v", resp.Schema.Attributes["size"]) + } + + if !validateProjectID(projectID.Validators, "").HasError() { + t.Fatal("project_id accepted an empty string") + } + if diags := validateProjectID(projectID.Validators, "project-1"); diags.HasError() { + t.Fatalf("project_id rejected a non-empty string: %v", diags) + } +} + +func validateProjectID(validators []validator.String, value string) diag.Diagnostics { + var diags diag.Diagnostics + for _, candidate := range validators { + req := validator.StringRequest{ConfigValue: types.StringValue(value)} + var resp validator.StringResponse + candidate.ValidateString(context.Background(), req, &resp) + diags.Append(resp.Diagnostics...) + } + return diags +} + func TestReadBrowserPoolByIDOrName(t *testing.T) { t.Parallel() From 1dbab8a93aa3cd8d798d53b52c4f06457afd7822 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:07:04 -0400 Subject: [PATCH 3/3] test reusable data source schema modes Keep schema mutation assertions concise so later browser-pool data source slices can verify their computed-only fields without duplicating type-specific checks. --- .../browserpool/datasource_test.go | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/internal/datasources/browserpool/datasource_test.go b/internal/datasources/browserpool/datasource_test.go index f190292..d276787 100644 --- a/internal/datasources/browserpool/datasource_test.go +++ b/internal/datasources/browserpool/datasource_test.go @@ -80,23 +80,12 @@ func TestDataSourceSchemaSemantics(t *testing.T) { var resp datasource.SchemaResponse ds.Schema(context.Background(), datasource.SchemaRequest{}, &resp) - id, ok := resp.Schema.Attributes["id"].(dschema.StringAttribute) - if !ok || !id.Optional || !id.Computed || id.Required { - t.Fatalf("id must be an optional, computed string: %#v", resp.Schema.Attributes["id"]) - } - name, ok := resp.Schema.Attributes["name"].(dschema.StringAttribute) - if !ok || !name.Optional || !name.Computed || name.Required { - t.Fatalf("name must be an optional, computed string: %#v", resp.Schema.Attributes["name"]) - } - projectID, ok := resp.Schema.Attributes["project_id"].(dschema.StringAttribute) - if !ok || !projectID.Optional || projectID.Computed || projectID.Required { - t.Fatalf("project_id must be an optional string: %#v", resp.Schema.Attributes["project_id"]) - } - size, ok := resp.Schema.Attributes["size"].(dschema.Int64Attribute) - if !ok || !size.Computed || size.Optional || size.Required { - t.Fatalf("size must be a computed integer: %#v", resp.Schema.Attributes["size"]) - } + assertAttributeMode(t, resp.Schema, "id", true, true) + assertAttributeMode(t, resp.Schema, "name", true, true) + assertAttributeMode(t, resp.Schema, "project_id", true, false) + assertAttributeMode(t, resp.Schema, "size", false, true) + projectID := resp.Schema.Attributes["project_id"].(dschema.StringAttribute) if !validateProjectID(projectID.Validators, "").HasError() { t.Fatal("project_id accepted an empty string") } @@ -105,6 +94,17 @@ 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] + if !ok { + t.Fatalf("schema missing %s", name) + } + if attribute.IsOptional() != optional || attribute.IsComputed() != computed || attribute.IsRequired() { + t.Fatalf("%s has unexpected schema mode: %#v", name, attribute) + } +} + func validateProjectID(validators []validator.String, value string) diag.Diagnostics { var diags diag.Diagnostics for _, candidate := range validators {