From ae0b1154223d641c29eaf47962e016f62fdbf9a9 Mon Sep 17 00:00:00 2001 From: Rubionic Date: Mon, 29 Dec 2025 12:00:10 +0000 Subject: [PATCH 1/3] fix: remove ostruct dependency for Ruby 3.5+ compatibility Replace OpenStruct with custom PropertyStruct implementation to ensure compatibility with Ruby 3.5+ where ostruct is being removed from the standard library. The PropertyStruct class provides the same dynamic attribute access functionality as OpenStruct but without requiring the external gem. Fixes rkoster/rubionic-workspace#229 Related to cloudfoundry/bosh-cli#708 --- templatescompiler/erbrenderer/erb_renderer.rb | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/templatescompiler/erbrenderer/erb_renderer.rb b/templatescompiler/erbrenderer/erb_renderer.rb index b22a30647..d0a506325 100644 --- a/templatescompiler/erbrenderer/erb_renderer.rb +++ b/templatescompiler/erbrenderer/erb_renderer.rb @@ -1,10 +1,32 @@ # Based on common/properties/template_evaluation_context.rb require "rubygems" -require "ostruct" require "json" require "erb" require "yaml" +# Simple struct-like class to replace OpenStruct dependency +# OpenStruct is being removed from Ruby standard library in Ruby 3.5+ +class PropertyStruct + def initialize(hash = {}) + @table = {} + hash.each do |key, value| + @table[key.to_sym] = value + end + end + + def method_missing(method_name, *args) + if method_name.to_s.end_with?("=") + @table[method_name.to_s.chomp("=").to_sym] = args.first + else + @table[method_name.to_sym] + end + end + + def respond_to_missing?(method_name, include_private = false) + true + end +end + class Hash def recursive_merge!(other) self.merge!(other) do |_, old_value, new_value| @@ -99,7 +121,7 @@ def openstruct(object) case object when Hash mapped = object.inject({}) { |h, (k,v)| h[k] = openstruct(v); h } - OpenStruct.new(mapped) + PropertyStruct.new(mapped) when Array object.map { |item| openstruct(item) } else From d09f9b782489733ab2812c380bc577b3c568e977 Mon Sep 17 00:00:00 2001 From: Rubionic Date: Thu, 8 Jan 2026 08:18:09 +0000 Subject: [PATCH 2/3] test: add comprehensive unit tests for PropertyStruct Analyzed ERB templates from 11+ Cloud Foundry repositories to identify all real-world PropertyStruct usage patterns: **Repositories Analyzed:** - cloudfoundry/bosh (director, nats, postgres, health monitor, blobstore) - cloudfoundry/routing-release (gorouter, route registrar, routing API, tcp router) - cloudfoundry/uaa-release (OAuth, SAML, database configuration) - pivotal/credhub-release (encryption providers, HSM integration) - cloudfoundry/bosh-aws-cpi-release - cloudfoundry/bosh-google-cpi-release (certificate handling) - cloudfoundry/bosh-openstack-cpi-release - cloudfoundry/bosh-vsphere-cpi-release - cloudfoundry/bosh-warden-cpi-release - cloudfoundry/bosh-docker-cpi-release - cloudfoundry/bosh-virtualbox-cpi-release **Comprehensive Test Coverage:** Array Operations: - .map(&:symbol), .map { block } - Transformations - .select, .compact - Filtering nils/empty values - .find - Finding elements by condition - .flatten - Nested array flattening - .any? - Predicate checking - .include? - Membership testing - .reject - Filtering with negation - .uniq - Removing duplicates - .first, .last - Array accessors - .join - Array joining Method Chaining: - .to_yaml.gsub - Config generation with string processing - .lines.map - Multiline text indentation - .split - URL/string parsing - .sort_by(&:to_s) - Mixed type sorting Iteration Patterns: - .each_with_index - Indexed iteration Hash Operations: - .keys.sort - Deterministic ordering - .key? - Membership testing - .values - Value extraction - .merge - Combining hashes String Conditionals: - .start_with? - Prefix checking - .empty?, .nil? - Empty/nil validation - .gsub - Pattern replacement - .index - Substring position Type Conversions: - .to_i, .to_s - Type conversions These tests ensure PropertyStruct maintains 100% compatibility with OpenStruct for all usage patterns found in production Cloud Foundry deployments. Related to rkoster/rubionic-workspace#229 --- .../erbrenderer/erb_renderer_test.go | 1211 +++++++++++++++++ 1 file changed, 1211 insertions(+) diff --git a/templatescompiler/erbrenderer/erb_renderer_test.go b/templatescompiler/erbrenderer/erb_renderer_test.go index b52a37ed4..2398d1175 100644 --- a/templatescompiler/erbrenderer/erb_renderer_test.go +++ b/templatescompiler/erbrenderer/erb_renderer_test.go @@ -19,6 +19,7 @@ import ( type testTemplateEvaluationStruct struct { Index int `json:"index"` ID string `json:"id"` + IP string `json:"ip,omitempty"` GlobalProperties map[string]interface{} `json:"global_properties"` ClusterProperties map[string]interface{} `json:"cluster_properties"` DefaultProperties map[string]interface{} `json:"default_properties"` @@ -117,6 +118,1216 @@ property3: default_value3 }) }) + Context("with nested property access patterns", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "director": map[string]interface{}{ + "db": map[string]interface{}{ + "user": "admin", + "password": "secret", + "host": "localhost", + "port": 5432, + }, + "name": "test-director", + }, + "nats": map[string]interface{}{ + "address": "10.0.0.1", + "port": 4222, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "director.db.user": "default_user", + "director.db.password": "default_pass", + "director.db.host": "default_host", + "director.db.port": "default_port", + "director.name": "default_name", + "nats.address": "default_nats", + "nats.port": "default_port", + }, + }, + } + }) + + It("accesses deeply nested properties with dot notation", func() { + erbTemplateContent = `db_user: <%= p('director.db.user') %> +db_pass: <%= p('director.db.password') %> +db_host: <%= p('director.db.host') %> +nats_addr: <%= p('nats.address') %>` + expectedTemplateContents = `db_user: admin +db_pass: secret +db_host: localhost +nats_addr: 10.0.0.1` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with property defaults", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{}, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "existing_prop": "default_value", + }, + }, + } + }) + + It("uses default values for missing properties", func() { + erbTemplateContent = `has_default: <%= p('missing_prop', 'fallback_value') %> +no_default: <%= p('existing_prop') %>` + expectedTemplateContents = `has_default: fallback_value +no_default: default_value` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with if_p conditional property helper", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "enabled_feature": true, + "feature_config": map[string]interface{}{ + "host": "example.com", + "port": 8080, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "enabled_feature": "default", + "feature_config.host": "default", + "feature_config.port": "default", + "missing_feature.host": "default", + }, + }, + } + }) + + It("executes block when property exists", func() { + erbTemplateContent = `<% if_p('feature_config.host') do |host| %> +host_configured: <%= host %> +<% end -%>` + expectedTemplateContents = ` +host_configured: example.com +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("supports multiple properties in if_p", func() { + erbTemplateContent = `<% if_p('feature_config.host', 'feature_config.port') do |host, port| %> +config: <%= host %>:<%= port %> +<% end -%>` + expectedTemplateContents = ` +config: example.com:8080 +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("skips block when property is missing", func() { + erbTemplateContent = `before +<% if_p('completely_missing_prop') do |host| %> +should_not_appear: <%= host %> +<% end -%> +after` + expectedTemplateContents = `before +after` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with array of hashes property access", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "users": []interface{}{ + map[string]interface{}{ + "name": "alice", + "password": "secret1", + }, + map[string]interface{}{ + "name": "bob", + "password": "secret2", + }, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "users": "default", + }, + }, + } + }) + + It("iterates over array and accesses hash elements", func() { + erbTemplateContent = `<% p('users').each do |user| %> +user: <%= user['name'] %> pass: <%= user['password'] %> +<% end -%>` + expectedTemplateContents = ` +user: alice pass: secret1 + +user: bob pass: secret2 +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with spec object access", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 42, + ID: "uuid-123-456", + IP: "192.168.1.100", + GlobalProperties: map[string]interface{}{}, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{}, + }, + } + }) + + It("accesses spec properties via struct notation", func() { + erbTemplateContent = `index: <%= spec.index %> +id: <%= spec.id %> +ip: <%= spec.ip %>` + expectedTemplateContents = `index: 42 +id: uuid-123-456 +ip: 192.168.1.100` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with complex nested object creation", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "blobstore": map[string]interface{}{ + "provider": "s3", + "s3": map[string]interface{}{ + "bucket": "my-bucket", + "access_key": "AKIAIOSFODNN7EXAMPLE", + }, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "blobstore.provider": "default", + "blobstore.s3.bucket": "default", + "blobstore.s3.access_key": "default", + }, + }, + } + }) + + It("builds nested hash structures from properties", func() { + erbTemplateContent = `<%= +config = { + 'provider' => p('blobstore.provider'), + 'options' => { + 'bucket' => p('blobstore.s3.bucket'), + 'access_key' => p('blobstore.s3.access_key') + } +} +require 'json' +JSON.dump(config) +%>` + expectedTemplateContents = `{"provider":"s3","options":{"bucket":"my-bucket","access_key":"AKIAIOSFODNN7EXAMPLE"}}` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with boolean and numeric property types", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "features": map[string]interface{}{ + "enabled": true, + "max_count": 100, + "timeout": 30.5, + "debug_mode": false, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "features.enabled": "default", + "features.max_count": "default", + "features.timeout": "default", + "features.debug_mode": "default", + }, + }, + } + }) + + It("handles boolean and numeric property values", func() { + erbTemplateContent = `enabled: <%= p('features.enabled') %> +max_count: <%= p('features.max_count') %> +timeout: <%= p('features.timeout') %> +debug: <%= p('features.debug_mode') %>` + expectedTemplateContents = `enabled: true +max_count: 100 +timeout: 30.5 +debug: false` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("uses booleans in conditionals", func() { + erbTemplateContent = `<% if p('features.enabled') -%> +feature is enabled +<% end -%> +<% if !p('features.debug_mode') -%> +debug is disabled +<% end -%>` + expectedTemplateContents = `feature is enabled +debug is disabled +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with array map operations", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "ports": []interface{}{"8080", "8443", "9000"}, + "servers": []interface{}{ + map[string]interface{}{"host": "10.0.0.1", "port": 8080}, + map[string]interface{}{"host": "10.0.0.2", "port": 8081}, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "ports": "default", + "servers": "default", + }, + }, + } + }) + + It("converts array elements using map with symbol", func() { + erbTemplateContent = `<%= p('ports').map(&:to_i).inspect %>` + expectedTemplateContents = `[8080, 8443, 9000]` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("transforms array elements using map with block", func() { + erbTemplateContent = `<% p('servers').map { |s| s['host'] }.each do |host| %> +host: <%= host %> +<% end -%>` + expectedTemplateContents = ` +host: 10.0.0.1 + +host: 10.0.0.2 +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with array filtering operations", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "ca_certs": []interface{}{ + "", + " ", + nil, + "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", + "short", + "-----BEGIN CERTIFICATE-----\nMIID...\n-----END CERTIFICATE-----", + }, + "ports": []interface{}{8080, nil, 8443, nil, 9000}, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "ca_certs": "default", + "ports": "default", + }, + }, + } + }) + + It("filters array elements using select", func() { + erbTemplateContent = `<%= p('ca_certs').select{ |v| !v.nil? && !v.strip.empty? && v.length > 50 }.length %>` + expectedTemplateContents = `2` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("removes nil values using compact", func() { + erbTemplateContent = `<%= p('ports').compact.inspect %>` + expectedTemplateContents = `[8080, 8443, 9000]` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with method chaining on property values", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "multiline_cert": "-----BEGIN CERTIFICATE-----\nline1\nline2\n-----END CERTIFICATE-----\n", + "url": "https://example.com:8443/path", + "yaml_data": map[string]interface{}{ + "key": "value", + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "multiline_cert": "default", + "url": "default", + "yaml_data": "default", + }, + }, + } + }) + + It("chains methods on string properties", func() { + erbTemplateContent = `<%= p('url').split(':')[0] %>` + expectedTemplateContents = `https` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("processes multiline strings with lines and map", func() { + erbTemplateContent = `<%= p('multiline_cert').lines.map { |line| " #{line.rstrip}" }.join("\n") %>` + expectedTemplateContents = ` -----BEGIN CERTIFICATE----- + line1 + line2 + -----END CERTIFICATE-----` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("chains to_yaml and gsub on hash properties", func() { + erbTemplateContent = `<%= p('yaml_data').to_yaml.gsub("---","").strip %>` + expectedTemplateContents = `key: value` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with each_with_index iteration", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "routes": []interface{}{ + map[string]interface{}{"uri": "api.example.com"}, + map[string]interface{}{"uri": "www.example.com"}, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "routes": "default", + }, + }, + } + }) + + It("iterates with index access", func() { + erbTemplateContent = `<% p('routes').each_with_index do |route, index| %> +route_<%= index %>: <%= route['uri'] %> +<% end -%>` + expectedTemplateContents = ` +route_0: api.example.com + +route_1: www.example.com +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with hash key access and membership testing", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "clients": map[string]interface{}{ + "client_a": map[string]interface{}{"secret": "secret_a"}, + "client_b": map[string]interface{}{"secret": "secret_b"}, + }, + "config": map[string]interface{}{ + "optional_key": "value", + "required_key": "required", + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "clients": "default", + "config": "default", + }, + }, + } + }) + + It("accesses hash keys and sorts them", func() { + erbTemplateContent = `<%= p('clients').keys.sort.first %>` + expectedTemplateContents = `client_a` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("checks for hash key membership", func() { + erbTemplateContent = `<% if p('config').key?('optional_key') %> +has_key: true +<% end -%>` + expectedTemplateContents = ` +has_key: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with conditional string operations", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "api_url": "https://api.example.com", + "cert": "", + "endpoint": "routing-api.service.cf.internal", + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "api_url": "default", + "cert": "default", + "endpoint": "default", + }, + }, + } + }) + + It("checks string prefix with start_with?", func() { + erbTemplateContent = `<% if p('api_url').start_with?('https') %> +secure: true +<% end -%>` + expectedTemplateContents = ` +secure: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("checks for empty strings", func() { + erbTemplateContent = `<% if p('cert') == "" %> +no_cert: true +<% end -%>` + expectedTemplateContents = ` +no_cert: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("performs string replacement with gsub", func() { + erbTemplateContent = `<%= p('endpoint').gsub('.internal', '.external') %>` + expectedTemplateContents = `routing-api.service.cf.external` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with array find operation", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "databases": []interface{}{ + map[string]interface{}{"tag": "uaa", "name": "uaadb"}, + map[string]interface{}{"tag": "admin", "name": "postgres"}, + }, + "providers": []interface{}{ + map[string]interface{}{"type": "internal", "name": "default"}, + map[string]interface{}{"type": "hsm", "name": "thales"}, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "databases": "default", + "providers": "default", + }, + }, + } + }) + + It("finds elements in array by condition", func() { + erbTemplateContent = `<% db = p('databases').find { |d| d['tag'] == 'uaa' } %> +db_name: <%= db['name'] %> +<% provider = p('providers').find { |p| p['type'] == 'hsm' } %> +provider_name: <%= provider['name'] %>` + expectedTemplateContents = ` +db_name: uaadb + +provider_name: thales` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with array flatten operation", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "nested_providers": []interface{}{ + []interface{}{ + map[string]interface{}{"type": "internal"}, + map[string]interface{}{"type": "hsm"}, + }, + []interface{}{ + map[string]interface{}{"type": "kms-plugin"}, + }, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "nested_providers": "default", + }, + }, + } + }) + + It("flattens nested arrays", func() { + erbTemplateContent = `<%= p('nested_providers').flatten.length %>` + expectedTemplateContents = `3` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with any? predicate", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "providers": []interface{}{ + map[string]interface{}{"type": "internal"}, + map[string]interface{}{"type": "hsm"}, + }, + "empty_list": []interface{}{}, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "providers": "default", + "empty_list": "default", + }, + }, + } + }) + + It("checks if any element matches condition", func() { + erbTemplateContent = `<% if p('providers').any? { |p| p['type'] == 'hsm' } -%> +using_hsm: true +<% end -%> +<% if !p('empty_list').any? -%> +list_empty: true +<% end -%>` + expectedTemplateContents = `using_hsm: true +list_empty: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with nil? and empty? checks", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "optional_cert": "", + "required_key": "actual_value", + "empty_array": []interface{}{}, + "filled_array": []interface{}{"item"}, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "optional_cert": "default", + "required_key": "default", + "empty_array": "default", + "filled_array": "default", + }, + }, + } + }) + + It("checks for empty strings and arrays", func() { + erbTemplateContent = `<% if p('optional_cert').empty? -%> +no_cert: true +<% end -%> +<% if !p('required_key').empty? -%> +has_key: true +<% end -%> +<% if p('empty_array').empty? -%> +array_empty: true +<% end -%> +<% if !p('filled_array').empty? -%> +array_filled: true +<% end -%>` + expectedTemplateContents = `no_cert: true +has_key: true +array_empty: true +array_filled: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with include? membership testing", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "valid_modes": []interface{}{"legacy", "exact"}, + "selected_mode": "exact", + "tls_modes": []interface{}{"enabled", "disabled"}, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "valid_modes": "default", + "selected_mode": "default", + "tls_modes": "default", + }, + }, + } + }) + + It("checks array membership", func() { + erbTemplateContent = `<% if p('valid_modes').include?(p('selected_mode')) -%> +valid_selection: true +<% end -%> +<% if p('tls_modes').include?('enabled') -%> +supports_tls: true +<% end -%>` + expectedTemplateContents = `valid_selection: true +supports_tls: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with reject and uniq operations", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "providers": []interface{}{ + map[string]interface{}{"name": "p1", "enabled": true}, + map[string]interface{}{"name": "p2", "enabled": false}, + map[string]interface{}{"name": "p3", "enabled": true}, + }, + "types": []interface{}{"internal", "hsm", "internal", "kms-plugin"}, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "providers": "default", + "types": "default", + }, + }, + } + }) + + It("rejects unwanted elements", func() { + erbTemplateContent = `<%= p('providers').reject { |p| !p['enabled'] }.length %>` + expectedTemplateContents = `2` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("removes duplicate values", func() { + erbTemplateContent = `<%= p('types').uniq.inspect %>` + expectedTemplateContents = `["internal", "hsm", "kms-plugin"]` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with hash values and merge operations", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "config": map[string]interface{}{ + "host": "localhost", + "port": 5432, + "timeout": 30, + }, + "defaults": map[string]interface{}{ + "timeout": 60, + "retries": 3, + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "config": "default", + "defaults": "default", + }, + }, + } + }) + + It("extracts hash values", func() { + erbTemplateContent = `<%= p('config').values.sort_by(&:to_s).inspect %>` + expectedTemplateContents = `[30, 5432, "localhost"]` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("merges hashes", func() { + erbTemplateContent = `<% merged = p('defaults').merge(p('config')) %> +timeout: <%= merged['timeout'] %> +retries: <%= merged['retries'] %>` + expectedTemplateContents = ` +timeout: 30 +retries: 3` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with string index and type conversion operations", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "cert_with_newlines": "-----BEGIN CERTIFICATE-----\nMIIC...", + "cert_without_newlines": "-----BEGIN CERTIFICATE-----MIIC...", + "port_string": "8443", + "timeout_number": 30, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "cert_with_newlines": "default", + "cert_without_newlines": "default", + "port_string": "default", + "timeout_number": "default", + }, + }, + } + }) + + It("finds substring positions with index", func() { + erbTemplateContent = `<% if p('cert_with_newlines').index("\n").nil? %> +no_real_newline: true +<% else %> +has_real_newline: true +<% end -%> +<% if p('cert_without_newlines').index("\n").nil? %> +no_escaped_newline: true +<% end -%>` + expectedTemplateContents = ` +has_real_newline: true + +no_escaped_newline: true +` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + + It("converts types with to_i and to_s", func() { + erbTemplateContent = `port_number: <%= p('port_string').to_i %> +timeout_string: <%= p('timeout_number').to_s %>` + expectedTemplateContents = `port_number: 8443 +timeout_string: 30` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with first and last array accessors", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "servers": []interface{}{ + "server1.example.com", + "server2.example.com", + "server3.example.com", + }, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "servers": "default", + }, + }, + } + }) + + It("accesses first and last array elements", func() { + erbTemplateContent = `primary: <%= p('servers').first %> +backup: <%= p('servers').last %>` + expectedTemplateContents = `primary: server1.example.com +backup: server3.example.com` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + + Context("with join operation on arrays", func() { + BeforeEach(func() { + context = &testTemplateEvaluationContext{ + testTemplateEvaluationStruct{ + Index: 0, + GlobalProperties: map[string]interface{}{ + "ciphers": []interface{}{ + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + }, + "scopes": []interface{}{"openid", "profile", "email"}, + }, + ClusterProperties: map[string]interface{}{}, + DefaultProperties: map[string]interface{}{ + "ciphers": "default", + "scopes": "default", + }, + }, + } + }) + + It("joins array elements with delimiter", func() { + erbTemplateContent = `ciphers: <%= p('ciphers').join(',') %> +scopes: <%= p('scopes').join(' ') %>` + expectedTemplateContents = `ciphers: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 +scopes: openid profile email` + + err := os.WriteFile(erbTemplateFilepath, []byte(erbTemplateContent), 0666) + Expect(err).ToNot(HaveOccurred()) + + err = erbRenderer.Render(erbTemplateFilepath, renderedTemplatePath, context) + Expect(err).ToNot(HaveOccurred()) + + templateBytes, err := fs.ReadFile(renderedTemplatePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(templateBytes)).To(Equal(expectedTemplateContents)) + }) + }) + Describe("error handling within Ruby", func() { var ( // see erb_renderer.rb From 46c53772f358df6d9b0f6542d27ba627ee164024 Mon Sep 17 00:00:00 2001 From: Rubionic Date: Thu, 15 Jan 2026 14:33:16 +0000 Subject: [PATCH 3/3] test: add Ruby version matrix testing for PropertyStruct Add comprehensive Ruby testing infrastructure to verify PropertyStruct compatibility across Ruby versions 2.6 through head. Following the pattern from PR #707, this adds: - GitHub Actions workflow testing across Ruby 2.6-3.4 and head - RSpec test suite for PropertyStruct - Tests for dynamic attribute access, nested structures, and arrays - Tests for Ruby standard library method pass-through - Tests for OpenStruct API compatibility The Ruby matrix ensures PropertyStruct works correctly across all supported Ruby versions, particularly validating Ruby 3.5+ compatibility where ostruct is being removed. Related to cloudfoundry/bosh-cli#708 --- .github/workflows/ruby.yml | 21 +++++ templatescompiler/erbrenderer/.gitignore | 3 + templatescompiler/erbrenderer/Gemfile | 6 ++ templatescompiler/erbrenderer/Rakefile | 5 ++ .../erbrenderer/spec/property_struct_spec.rb | 86 +++++++++++++++++++ .../erbrenderer/spec/spec_helper.rb | 8 ++ 6 files changed, 129 insertions(+) create mode 100644 .github/workflows/ruby.yml create mode 100644 templatescompiler/erbrenderer/.gitignore create mode 100644 templatescompiler/erbrenderer/Gemfile create mode 100644 templatescompiler/erbrenderer/Rakefile create mode 100644 templatescompiler/erbrenderer/spec/property_struct_spec.rb create mode 100644 templatescompiler/erbrenderer/spec/spec_helper.rb diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml new file mode 100644 index 000000000..710f72f3d --- /dev/null +++ b/.github/workflows/ruby.yml @@ -0,0 +1,21 @@ +name: ERB Renderer Ruby Tests + +on: [push, pull_request] + +jobs: + test_property_struct: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + ruby: ['2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4', head] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + - run: bundle install + working-directory: templatescompiler/erbrenderer/ + - run: bundle exec rake + working-directory: templatescompiler/erbrenderer/ + continue-on-error: ${{ matrix.ruby == 'head' }} diff --git a/templatescompiler/erbrenderer/.gitignore b/templatescompiler/erbrenderer/.gitignore new file mode 100644 index 000000000..72bcafc04 --- /dev/null +++ b/templatescompiler/erbrenderer/.gitignore @@ -0,0 +1,3 @@ +.bundle/ +vendor/bundle/ +Gemfile.lock diff --git a/templatescompiler/erbrenderer/Gemfile b/templatescompiler/erbrenderer/Gemfile new file mode 100644 index 000000000..d9e069199 --- /dev/null +++ b/templatescompiler/erbrenderer/Gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +group :test do + gem "rake" + gem "rspec" +end diff --git a/templatescompiler/erbrenderer/Rakefile b/templatescompiler/erbrenderer/Rakefile new file mode 100644 index 000000000..571441d1a --- /dev/null +++ b/templatescompiler/erbrenderer/Rakefile @@ -0,0 +1,5 @@ +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +task default: [:spec] diff --git a/templatescompiler/erbrenderer/spec/property_struct_spec.rb b/templatescompiler/erbrenderer/spec/property_struct_spec.rb new file mode 100644 index 000000000..3fcd5ff64 --- /dev/null +++ b/templatescompiler/erbrenderer/spec/property_struct_spec.rb @@ -0,0 +1,86 @@ +require "spec_helper" +require "erb_renderer" + +RSpec.describe "PropertyStruct" do + describe "initialization and attribute access" do + it "provides dynamic attribute access for hash keys" do + ps = PropertyStruct.new(name: "test", value: 42) + expect(ps.name).to eq("test") + expect(ps.value).to eq(42) + end + + it "converts string keys to symbols" do + ps = PropertyStruct.new("name" => "test", "value" => 42) + expect(ps.name).to eq("test") + expect(ps.value).to eq(42) + end + + it "supports nested attribute access" do + ps = PropertyStruct.new(config: {database: {host: "localhost", port: 5432}}) + nested = ps.config + expect(nested).to be_a(PropertyStruct) + expect(nested.database).to be_a(PropertyStruct) + expect(nested.database.host).to eq("localhost") + expect(nested.database.port).to eq(5432) + end + + it "handles arrays of hashes" do + ps = PropertyStruct.new(servers: [{name: "web1", ip: "10.0.0.1"}, {name: "web2", ip: "10.0.0.2"}]) + servers = ps.servers + expect(servers).to be_an(Array) + expect(servers.length).to eq(2) + expect(servers.first.name).to eq("web1") + expect(servers.last.ip).to eq("10.0.0.2") + end + + it "responds to method queries correctly" do + ps = PropertyStruct.new(existing_key: "value") + expect(ps.respond_to?(:existing_key)).to be true + expect(ps.respond_to?(:nonexistent_key)).to be false + end + end + + describe "Ruby standard library method pass-through" do + it "supports array operations like map" do + ps = PropertyStruct.new(ports: [8080, 8081, 8082]) + expect(ps.ports.map(&:to_s)).to eq(["8080", "8081", "8082"]) + end + + it "supports string operations" do + ps = PropertyStruct.new(url: "https://example.com") + expect(ps.url.start_with?("https")).to be true + expect(ps.url.split("://")).to eq(["https", "example.com"]) + end + + it "supports hash operations" do + ps = PropertyStruct.new(config: {a: 1, b: 2, c: 3}) + expect(ps.config.keys.sort).to eq([:a, :b, :c]) + expect(ps.config.values.sum).to eq(6) + end + + it "supports nil and empty checks" do + ps = PropertyStruct.new(empty_string: "", nil_value: nil, filled: "data") + expect(ps.empty_string.empty?).to be true + expect(ps.nil_value.nil?).to be true + expect(ps.filled.nil?).to be false + end + end + + describe "compatibility across Ruby versions" do + it "works with ERB rendering" do + template = ERB.new("<%= obj.name.upcase %>: <%= obj.ports.join(',') %>") + ps = PropertyStruct.new(name: "service", ports: [80, 443, 8080]) + result = template.result(binding) + expect(result).to eq("SERVICE: 80,443,8080") + end + + it "maintains OpenStruct API compatibility" do + # Test that PropertyStruct can be used as a drop-in replacement for OpenStruct + ps = PropertyStruct.new(field1: "value1", field2: "value2") + expect(ps).to respond_to(:field1) + expect(ps).to respond_to(:field2) + expect(ps.field1).to eq("value1") + expect(ps.field2).to eq("value2") + end + end +end diff --git a/templatescompiler/erbrenderer/spec/spec_helper.rb b/templatescompiler/erbrenderer/spec/spec_helper.rb new file mode 100644 index 000000000..3158aa6c7 --- /dev/null +++ b/templatescompiler/erbrenderer/spec/spec_helper.rb @@ -0,0 +1,8 @@ +require "rspec" +require "json" +require "tmpdir" +require "erb" + +ERB_RENDERER_ROOT = File.expand_path("..", File.dirname(__FILE__)) + +$LOAD_PATH.unshift(ERB_RENDERER_ROOT)