diff --git a/README.md b/README.md index f62285b9..a6d63363 100644 --- a/README.md +++ b/README.md @@ -325,8 +325,8 @@ out of regular runs. | Suite | Tests | Description | |-------|-------|-------------| -| **basics** | 945 | Essential Liquid features - start here! Ordered by complexity with implementation hints | -| **liquid_ruby** | 2,097 | Core Liquid specs from [Shopify/liquid](https://github.com/Shopify/liquid) integration tests | +| **basics** | 955 | Essential Liquid features - start here! Ordered by complexity with implementation hints | +| **liquid_ruby** | 2,106 | Core Liquid specs from [Shopify/liquid](https://github.com/Shopify/liquid) integration tests | | **liquid_ruby_lax** | 121 | Lax-mode reference behavior | | **parser_errors** | 1,905 | Strict parser error compatibility and mutation matrices | | **partials** | 12 | Include/render focused compatibility specs and timings | diff --git a/examples/json_rpc_ruby_liquid.rb b/examples/json_rpc_ruby_liquid.rb index a1693860..930632e4 100644 --- a/examples/json_rpc_ruby_liquid.rb +++ b/examples/json_rpc_ruby_liquid.rb @@ -47,7 +47,7 @@ config.render_error_modes = [:raise, :inline] # This JSON-RPC adapter supports core behavior including drops. # because they implement bidirectional callbacks - config.missing_features = [:ruby_types, :ruby_drops, :binary_data, :template_factory, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :strict2_blank_body_errors] + config.missing_features = [:ruby_types, :ruby_drops, :binary_data, :template_factory, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :range_resource_limits, :strict2_blank_body_errors] end LiquidSpec.compile do |ctx, source, parse_options| diff --git a/examples/liquid_c.rb b/examples/liquid_c.rb index 724ba8a6..c14867b7 100644 --- a/examples/liquid_c.rb +++ b/examples/liquid_c.rb @@ -38,7 +38,7 @@ def parse_expression(markup, safe: false) LiquidSpec.configure do |config| config.error_modes = [:lax] config.render_error_modes = [:raise, :inline] - config.missing_features = [:drops, :ruby_types, :ruby_drops, :binary_data, :template_factory, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access] + config.missing_features = [:drops, :ruby_types, :ruby_drops, :binary_data, :template_factory, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :range_resource_limits] end LiquidSpec.compile do |ctx, source, parse_options| diff --git a/examples/liquid_c_strict.rb b/examples/liquid_c_strict.rb index 45440872..ebf0e097 100644 --- a/examples/liquid_c_strict.rb +++ b/examples/liquid_c_strict.rb @@ -26,7 +26,7 @@ LiquidSpec.configure do |config| config.error_modes = [:strict] config.render_error_modes = [:raise, :inline] - config.missing_features = [:drops, :ruby_types, :ruby_drops, :binary_data, :template_factory, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access] + config.missing_features = [:drops, :ruby_types, :ruby_drops, :binary_data, :template_factory, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :range_resource_limits] end LiquidSpec.compile do |ctx, source, parse_options| diff --git a/examples/liquid_ruby.rb b/examples/liquid_ruby.rb index 5fd2210f..4d3021d8 100644 --- a/examples/liquid_ruby.rb +++ b/examples/liquid_ruby.rb @@ -22,7 +22,7 @@ LiquidSpec.configure do |config| config.error_modes = [:strict2, :strict] config.render_error_modes = [:raise, :inline] - config.missing_features = [:self_environment_shadowing, :drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :strict2_blank_body_errors] + config.missing_features = [:self_environment_shadowing, :drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :range_resource_limits, :strict2_blank_body_errors] end LiquidSpec.compile do |ctx, source, parse_options| diff --git a/examples/liquid_ruby_lax.rb b/examples/liquid_ruby_lax.rb index 5cc54806..28ee15b3 100644 --- a/examples/liquid_ruby_lax.rb +++ b/examples/liquid_ruby_lax.rb @@ -22,7 +22,7 @@ LiquidSpec.configure do |config| config.error_modes = [:lax] config.render_error_modes = [:raise, :inline] - config.missing_features = [:drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access] + config.missing_features = [:drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :range_resource_limits] end LiquidSpec.compile do |ctx, source, parse_options| diff --git a/examples/liquid_ruby_shopify.rb b/examples/liquid_ruby_shopify.rb index 89bd9608..92ef0919 100644 --- a/examples/liquid_ruby_shopify.rb +++ b/examples/liquid_ruby_shopify.rb @@ -188,7 +188,7 @@ def render_to_output_buffer(context, output) config.render_error_modes = [:raise, :inline] # This adapter is a benchmark fixture with a few local theme filters, not a # full Shopify runtime. Keep production/theme suites out of its matrix row. - config.missing_features = [:drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :shopify_resource_limits] + config.missing_features = [:drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :shopify_resource_limits, :range_resource_limits] config.suites = [:benchmarks] config.filter = "shopify_" end diff --git a/examples/liquid_ruby_yjit.rb b/examples/liquid_ruby_yjit.rb index 27bae86f..723b0812 100644 --- a/examples/liquid_ruby_yjit.rb +++ b/examples/liquid_ruby_yjit.rb @@ -24,7 +24,7 @@ LiquidSpec.configure do |config| config.error_modes = [:strict2, :strict] config.render_error_modes = [:raise, :inline] - config.missing_features = [:drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :shopify_resource_limits, :strict2_blank_body_errors] + config.missing_features = [:drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :shopify_resource_limits, :range_resource_limits, :strict2_blank_body_errors] end LiquidSpec.compile do |ctx, source, parse_options| diff --git a/examples/liquid_ruby_zjit.rb b/examples/liquid_ruby_zjit.rb index bd5452b1..d28d9052 100644 --- a/examples/liquid_ruby_zjit.rb +++ b/examples/liquid_ruby_zjit.rb @@ -24,7 +24,7 @@ LiquidSpec.configure do |config| config.error_modes = [:strict2, :strict] config.render_error_modes = [:raise, :inline] - config.missing_features = [:drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :shopify_resource_limits, :strict2_blank_body_errors] + config.missing_features = [:drop_class_output, :shopify_tags, :shopify_objects, :shopify_filters, :shopify_includes, :shopify_blank, :shopify_error_handling, :shopify_error_format, :shopify_string_access, :shopify_resource_limits, :range_resource_limits, :strict2_blank_body_errors] end LiquidSpec.compile do |ctx, source, parse_options| diff --git a/lib/liquid/spec/cli/adapter_dsl.rb b/lib/liquid/spec/cli/adapter_dsl.rb index fd9540a2..7cdc7020 100644 --- a/lib/liquid/spec/cli/adapter_dsl.rb +++ b/lib/liquid/spec/cli/adapter_dsl.rb @@ -80,6 +80,7 @@ def format_timeout(value) strict2_blank_body_errors: "strict2 surfaces inline errors even when the block body is blank", drops: "Standard test drop library (BooleanDrop, MethodDrop, SequenceDrop, etc.)", shopify_resource_limits: "Shopify resource limit enforcement (render score tracking, cumulative limits across partials)", + range_resource_limits: "Post-5.13 range iteration resource-limit accounting", randomness: "Specs using generated random values for increased coverage", }.freeze class Configuration diff --git a/lib/liquid/spec/cli/features.rb b/lib/liquid/spec/cli/features.rb index 711451c8..78fc7480 100644 --- a/lib/liquid/spec/cli/features.rb +++ b/lib/liquid/spec/cli/features.rb @@ -129,6 +129,11 @@ module Features recommendation: :optional, note: "Score bookkeeping and limit enforcement is implementation plumbing, not Liquid semantics. Recursion depth / stack overflow specs are core; score-counting specs are opt-out.", }, + range_resource_limits: { + description: "Post-5.13 range iteration resource-limit accounting", + recommendation: :optional, + note: "Requires the range iteration accounting introduced after Liquid 5.13. Keep it disabled until that behavior is implemented.", + }, }.freeze RECOMMENDATION_LABELS = { diff --git a/lib/liquid/spec/cli/init.rb b/lib/liquid/spec/cli/init.rb index 950c07c8..73f61dc2 100644 --- a/lib/liquid/spec/cli/init.rb +++ b/lib/liquid/spec/cli/init.rb @@ -361,6 +361,7 @@ module Init :shopify_error_handling, :shopify_error_format, :shopify_string_access, + :range_resource_limits, :strict2_blank_body_errors, ] end diff --git a/lib/liquid/spec/deps/liquid_ruby.rb b/lib/liquid/spec/deps/liquid_ruby.rb index 79c49691..d0f73a4e 100644 --- a/lib/liquid/spec/deps/liquid_ruby.rb +++ b/lib/liquid/spec/deps/liquid_ruby.rb @@ -269,9 +269,9 @@ def to_liquid class RangeWrapper def self.new(params) if params.is_a?(Array) - Range.new(params[0], params[1]) + Range.new(params[0], params[1], params[2] || false) elsif params.is_a?(Hash) - Range.new(params["begin"] || params[:begin], params["end"] || params[:end]) + Range.new(params["begin"] || params[:begin], params["end"] || params[:end], params["exclude_end"] || params[:exclude_end] || false) else params end @@ -551,8 +551,9 @@ def secret; "FAKE_SECRET"; end Liquid::Spec::ClassRegistry.register("FakeDropObject") { |p| FakeDropObject.new } -# Range - special handling for array format [start, end] -Liquid::Spec::ClassRegistry.register("Range") { |p| Range.new(p[0], p[1]) } +# Range - array format [start, end] retains the historic inclusive encoding; +# a third truthy item preserves an exclusive endpoint. +Liquid::Spec::ClassRegistry.register("Range") { |p| Range.new(p[0], p[1], p[2] || false) } # Returns the Liquid::Drop class itself (for edge case tests) Liquid::Spec::ClassRegistry.register("LiquidDropClass") { |_p| Liquid::Drop } diff --git a/lib/liquid/spec/deps/yaml_coders.rb b/lib/liquid/spec/deps/yaml_coders.rb index cc37633b..c14a09d1 100644 --- a/lib/liquid/spec/deps/yaml_coders.rb +++ b/lib/liquid/spec/deps/yaml_coders.rb @@ -13,10 +13,14 @@ # --- Core type coders --- -# Range serializes as instantiate:Range: [begin, end] +# Inclusive ranges retain the historical [begin, end] representation. Exclusive +# ranges append true so their endpoint semantics survive capture; nil end supports +# endless ranges without a special representation. class Range def encode_with(coder) - coder.represent_map(nil, { "instantiate:Range:" => [self.begin, self.end] }) + params = [self.begin, self.end] + params << true if exclude_end? + coder.represent_map(nil, { "instantiate:Range:" => params }) end end diff --git a/scripts/verifiers/spec_schema.rb b/scripts/verifiers/spec_schema.rb index 6985b118..7a66f5fa 100755 --- a/scripts/verifiers/spec_schema.rb +++ b/scripts/verifiers/spec_schema.rb @@ -51,6 +51,7 @@ drops randomness shopify_resource_limits + range_resource_limits activesupport ].freeze diff --git a/specs/basics/range-resource-limits.yml b/specs/basics/range-resource-limits.yml new file mode 100644 index 00000000..3e5ef954 --- /dev/null +++ b/specs/basics/range-resource-limits.yml @@ -0,0 +1,146 @@ +_metadata: + hint: | + Regression coverage for bounded range iteration's resource-limit accounting. + These cases require the post-5.13 range resource-limit implementation in + addition to Shopify resource-limit support. + complexity: 500 + features: + - shopify_resource_limits + - range_resource_limits + +specs: +- name: for_dynamic_range_empty_body_charges_each_item + complexity: 500 + template: "{% for i in (first..last) %}{% endfor %}" + environment: + first: 1 + last: 3 + error_mode: [strict2, strict] + render_errors: false + resource_limits: + render_score_limit: 3 + errors: + render_error: + - Memory limits exceeded + hint: | + The root loop tag costs one render-score unit and its three yielded values + cost three more, even when the body is empty. Dynamic bounds must use the + same accounting as a literal range, so the four-unit loop exceeds 3. + +- name: tablerow_literal_range_empty_body_charges_each_item + complexity: 501 + template: "{% tablerow i in (1..3) %}{% endtablerow %}" + error_mode: [strict2, strict] + render_errors: false + resource_limits: + render_score_limit: 3 + errors: + render_error: + - Memory limits exceeded + hint: | + tablerow also charges its tag plus every range item before rendering the + body. Empty cells still consume work, so the fourth unit exceeds this + render-score limit. + +- name: for_literal_range_reversed_offset_limit_keeps_metadata + complexity: 502 + template: "{% for i in (1..10) reversed offset:7 limit:2 %}{{ forloop.length }}:{{ forloop.index }}:{{ i }}{% endfor %}" + expected: "2:1:92:2:8" + hint: | + Apply offset and limit to the range segment without materializing it, then + reverse that segment. forloop.length and forloop.index describe the two + selected values (9 and 8), not the original ten-item range. + +- name: tablerow_literal_range_offset_limit_keeps_metadata + complexity: 503 + template: "{% tablerow i in (1..10) offset:7 limit:2 %}{{ tablerowloop.length }}:{{ tablerowloop.index }}:{{ i }}{% endtablerow %}" + expected: | + + 2:1:82:2:9 + hint: | + tablerow selects a bounded range segment before generating markup. Its + loop metadata reports the two selected values (8 and 9), including their + one-based indexes, rather than the full range length. + +- name: range_score_is_cumulative_across_isolated_render_partials + complexity: 504 + template: "{% render 'range_loop' %}{% render 'range_loop' %}" + error_mode: [strict2, strict] + render_errors: false + filesystem: + range_loop.liquid: "{% for i in (1..2) %}{% endfor %}" + resource_limits: + cumulative_render_score_limit: 5 + errors: + render_error: + - Memory limits exceeded + hint: | + render creates an isolated Liquid context, but its work contributes to the + caller's cumulative render score. Two empty two-item range loops cost more + than 5 together even though neither partial has its own per-render limit. + +- name: tablerow_capture_output_respects_assign_score_limit + complexity: 505 + template: "{% capture table %}{% tablerow i in (1..2) %}{{ i }}{% endtablerow %}{% endcapture %}" + error_mode: [strict2, strict] + render_errors: false + resource_limits: + assign_score_limit: 69 + errors: + render_error: + - Memory limits exceeded + hint: | + Capturing tablerow's generated HTML adds all 70 bytes to assign score even + though capture emits no final output. The limit is checked against the + complete generated table, not just the tag body. + +- name: tablerow_break_final_markup_respects_render_length_limit + complexity: 506 + template: "{% tablerow i in (1..3) %}{{ i }}{% break %}{% endtablerow %}" + error_mode: [strict2, strict] + render_errors: false + resource_limits: + render_length_limit: 46 + errors: + render_error: + - Memory limits exceeded + hint: | + break stops further cells but tablerow must still close the current cell + and row. The resulting 47-byte final markup is subject to the render + length limit, so a limit of 46 raises a memory error. + +- name: for_nonempty_range_at_render_score_boundary + complexity: 507 + template: "{% for i in (1..3) %}{{ i }}{% endfor %}" + resource_limits: + render_score_limit: 7 + expected: "123" + hint: | + A three-item range loop with one rendered node per item costs seven render + score units: one loop tag, three yielded values, and three body renders. + The exact boundary succeeds and produces all three values. + +- name: for_nonempty_range_over_render_score_boundary + complexity: 508 + template: "{% for i in (1..3) %}{{ i }}{% endfor %}" + error_mode: [strict2, strict] + render_errors: false + resource_limits: + render_score_limit: 6 + errors: + render_error: + - Memory limits exceeded + hint: | + The same seven-unit nonempty range loop must raise when the render-score + limit is six. Check every yielded value and body render against the limit. + +- name: for_offset_range_does_not_charge_skipped_items + complexity: 509 + template: "{% for i in (1..10) offset:7 limit:2 %}{{ i }}{% endfor %}" + resource_limits: + render_score_limit: 5 + expected: "89" + hint: | + Offset and limit select only 8 and 9 from this bounded native integer + range. Their five score units are the loop tag, two selected values, and + two body renders; the seven skipped values must not be charged. diff --git a/specs/basics/resource-limits.yml b/specs/basics/resource-limits.yml index ce85347d..56ad04ed 100644 --- a/specs/basics/resource-limits.yml +++ b/specs/basics/resource-limits.yml @@ -23,8 +23,10 @@ specs: render_error: - Liquid error hint: | - Each partial renders a for-loop scoring ~10 points. Per-template limit (100) - is fine, but cumulative limit (25) is exceeded after the second partial. + Each nonempty ten-item partial loop costs 21 render-score units: one loop + tag, ten yielded values, and ten body renders. The caller precharges its + three render nodes, so the first partial reaches 24 and the second exceeds + the cumulative limit of 25 while it is rendering. - name: cumulative_limits_not_triggered_when_unset complexity: 1000 diff --git a/specs/liquid_ruby/range_resource_limits.yml b/specs/liquid_ruby/range_resource_limits.yml new file mode 100644 index 00000000..7a74b81b --- /dev/null +++ b/specs/liquid_ruby/range_resource_limits.yml @@ -0,0 +1,106 @@ +--- +- name: ForTagTest#test_assigned_dynamic_inclusive_and_exclusive_ranges_preserve_slice_semantics_16239422 + template: "{% assign first = 1 %}{% assign last = 5 %}{% for i in (first..last) + offset:-2 limit:4 %}{{ i }}{% endfor %}" + render_errors: false + expected: '12' + url: https://github.com/Shopify/liquid/blob/cdba4e9137d50e3a4a8f714d383e6c704a809bd1/test/integration/tags/for_tag_test.rb#L539 + complexity: 1000 +- name: ForTagTest#test_assigned_dynamic_inclusive_and_exclusive_ranges_preserve_slice_semantics_b1b92eca + template: "{% for i in numbers %}{{ i }}{% endfor %}" + environment: + numbers: + 'instantiate:Range:': + - 1 + - 5 + - true + render_errors: false + expected: '1234' + url: https://github.com/Shopify/liquid/blob/cdba4e9137d50e3a4a8f714d383e6c704a809bd1/test/integration/tags/for_tag_test.rb#L540 + features: + - ruby_drops + complexity: 1000 +- name: ForTagTest#test_assigned_dynamic_inclusive_and_exclusive_ranges_preserve_slice_semantics_c8c4a23c + template: "{% for i in (1..5) offset:2 limit:-1 %}{{ i }}{% else %}empty{% endfor + %}" + render_errors: false + expected: empty + url: https://github.com/Shopify/liquid/blob/cdba4e9137d50e3a4a8f714d383e6c704a809bd1/test/integration/tags/for_tag_test.rb#L541 + complexity: 1000 +- name: ForTagTest#test_range_break_does_not_charge_unvisited_items_or_shorten_continuation_541d6967 + template: "{% for i in (1..10) limit:5 %}{% break %}{% endfor %}{% for i in (1..10) + offset:continue limit:2 %}{{ i }}{% endfor %}" + render_errors: false + expected: '67' + url: https://github.com/Shopify/liquid/blob/cdba4e9137d50e3a4a8f714d383e6c704a809bd1/test/integration/tags/for_tag_test.rb#L532 + complexity: 1000 +- name: TableRowTest#test_assigned_endless_range_with_a_limit_4423c4c9 + template: "{% tablerow i in numbers limit:2 %}{{ i }}{% endtablerow %}" + environment: + numbers: + 'instantiate:Range:': + - 1 + - + render_errors: false + expected: | + + 12 + url: https://github.com/Shopify/liquid/blob/cdba4e9137d50e3a4a8f714d383e6c704a809bd1/test/integration/tags/table_row_test.rb#L584 + features: + - ruby_drops + complexity: 1000 +- name: TableRowTest#test_assigned_endless_range_with_a_limit_9b47203f + template: "{% tablerow i in numbers limit:-1 %}{{ i }}{% endtablerow %}" + environment: + numbers: + 'instantiate:Range:': + - 1 + - + render_errors: false + expected: | + + + url: https://github.com/Shopify/liquid/blob/cdba4e9137d50e3a4a8f714d383e6c704a809bd1/test/integration/tags/table_row_test.rb#L594 + features: + - ruby_drops + complexity: 1000 +- name: TableRowTest#test_assigned_endless_range_with_a_limit_cc799bee + template: "{% tablerow i in numbers limit:0 %}{{ i }}{% endtablerow %}" + environment: + numbers: + 'instantiate:Range:': + - 1 + - + render_errors: false + expected: | + + + url: https://github.com/Shopify/liquid/blob/cdba4e9137d50e3a4a8f714d383e6c704a809bd1/test/integration/tags/table_row_test.rb#L589 + features: + - ruby_drops + complexity: 1000 +- name: TableRowTest#test_tablerow_assigned_dynamic_and_exclusive_ranges_3afac3d3 + template: "{% tablerow i in numbers offset:1 %}{{ i }}{% endtablerow %}" + environment: + numbers: + 'instantiate:Range:': + - 1 + - 4 + - true + render_errors: false + expected: | + + 23 + url: https://github.com/Shopify/liquid/blob/cdba4e9137d50e3a4a8f714d383e6c704a809bd1/test/integration/tags/table_row_test.rb#L561 + features: + - ruby_drops + complexity: 1000 +- name: TableRowTest#test_tablerow_assigned_dynamic_and_exclusive_ranges_7ce37019 + template: "{% assign first = 1 %}{% assign last = 4 %}{% tablerow i in (first..last) + offset:1 %}{{ i }}{% endtablerow %}" + render_errors: false + expected: | + + 234 + url: https://github.com/Shopify/liquid/blob/cdba4e9137d50e3a4a8f714d383e6c704a809bd1/test/integration/tags/table_row_test.rb#L557 + complexity: 1000 diff --git a/tasks/helpers.rb b/tasks/helpers.rb index ac3774c1..b032f616 100644 --- a/tasks/helpers.rb +++ b/tasks/helpers.rb @@ -61,11 +61,16 @@ def reset_captures(path) File.write(path, "---\n", mode: "a+") end - def format_and_write_specs(capture_path, outfile, metadata: nil) + def format_and_write_specs(capture_path, outfile, metadata: nil, default_complexity: nil) yaml = File.read(capture_path, encoding: Encoding::UTF_8) data = YAML.safe_load(yaml, permitted_classes: [Symbol, Date, Time], aliases: true) - data.each { |spec| annotate_required_features!(spec) } + metadata = metadata&.transform_keys(&:to_s) + metadata_has_complexity = metadata && (metadata.key?("minimum_complexity") || metadata.key?("complexity")) + data.each do |spec| + annotate_features!(spec) + spec["complexity"] ||= default_complexity if default_complexity && !metadata_has_complexity + end data.sort_by! { |h| h["name"] } data.uniq! @@ -77,10 +82,10 @@ def format_and_write_specs(capture_path, outfile, metadata: nil) private - # Auto-detect required_features based on environment content. + # Auto-detect v2 feature tags based on environment content. # - ruby_drops: environment uses instantiate: (Drop objects) # - ruby_types: environment has integer keys or symbol keys - def annotate_required_features!(spec) + def annotate_features!(spec) features = [] env = spec["environment"] @@ -89,7 +94,9 @@ def annotate_required_features!(spec) features << "ruby_types" if has_ruby_type_usage?(env) end - spec["required_features"] = features unless features.empty? + existing_features = Array(spec["features"]) + merged_features = (existing_features + features).uniq + spec["features"] = merged_features unless merged_features.empty? end def has_ruby_type_usage?(value, depth: 0) diff --git a/test/feature_coverage_baseline.txt b/test/feature_coverage_baseline.txt index 55fc92a6..9c552481 100644 --- a/test/feature_coverage_baseline.txt +++ b/test/feature_coverage_baseline.txt @@ -1,3 +1,4 @@ +range_resource_limits shopify_blank shopify_error_format shopify_error_handling diff --git a/test/init_test.rb b/test/init_test.rb index 682d05cd..70dd9c0d 100644 --- a/test/init_test.rb +++ b/test/init_test.rb @@ -26,6 +26,7 @@ def test_generated_json_rpc_adapter_uses_context_to_pass_template_id assert_includes source, "ctx[:adapter].render(ctx[:template_id], assigns, options)" assert_includes source, "config.error_modes = [:strict2]" assert_includes source, "config.render_error_modes = [:raise]" + assert_includes source, ":range_resource_limits," refute_includes source, "LiquidSpec.render do |ctx, template_id, assigns, options|" assert_valid_ruby(source) end diff --git a/test/instantiation_test.rb b/test/instantiation_test.rb index d4eab025..93650fbb 100644 --- a/test/instantiation_test.rb +++ b/test/instantiation_test.rb @@ -16,8 +16,8 @@ def setup end Liquid::Spec::ClassRegistry.register("Range") do |params| - if params.is_a?(Array) && params.size == 2 - Range.new(params[0], params[1]) + if params.is_a?(Array) && (2..3).cover?(params.size) + Range.new(params[0], params[1], params[2] || false) else params end diff --git a/test/runs_test.rb b/test/runs_test.rb index c79eee71..d56a1619 100644 --- a/test/runs_test.rb +++ b/test/runs_test.rb @@ -20,6 +20,19 @@ def test_default_builtin_adapters_exclude_legacy_liquid_c assert_equal names.sort, runs.adapter_names.sort end + def test_default_builtin_adapters_skip_staged_range_resource_limit_specs + Liquid::Spec::CLI::Runs.default_builtin_adapter_paths.each do |path| + assert_includes File.read(path), ":range_resource_limits", "#{File.basename(path)} must skip staged range cases" + end + end + + def test_explicit_liquid_c_adapters_skip_staged_range_resource_limit_specs + %w[liquid_c.rb liquid_c_strict.rb].each do |name| + path = File.expand_path("../examples/#{name}", __dir__) + assert_includes File.read(path), ":range_resource_limits", "#{name} must skip staged range cases" + end + end + def test_legacy_liquid_c_adapter_remains_explicitly_addressable runs = Liquid::Spec::CLI::Runs.new runs.add_adapter("liquid_c") diff --git a/test/tasks_helpers_test.rb b/test/tasks_helpers_test.rb new file mode 100644 index 00000000..3e6a7292 --- /dev/null +++ b/test/tasks_helpers_test.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require_relative "test_helper" +require "date" +require "tmpdir" +require "yaml" +require_relative "../tasks/helpers" + +class TasksHelpersTest < Minitest::Test + def with_formatted_specs(capture, metadata: nil, default_complexity: nil) + Dir.mktmpdir("liquid-spec-helper-test") do |dir| + capture_path = File.join(dir, "capture.yml") + output_path = File.join(dir, "specs.yml") + File.write(capture_path, YAML.dump(capture)) + + Helpers.format_and_write_specs( + capture_path, + output_path, + metadata: metadata, + default_complexity: default_complexity, + ) + yield YAML.safe_load_file(output_path), Liquid::Spec::SpecLoader.load_file(output_path) + end + end + + def test_format_preserves_explicit_features_when_adding_inferred_ruby_features + capture = [{ + "name" => "preserves_features", + "template" => "{{ range }}", + "expected" => "1..5", + "features" => ["core"], + "environment" => { "range" => { "instantiate:Range:" => [1, 5] } }, + }] + + with_formatted_specs(capture) do |document, _specs| + assert_equal ["core", "ruby_drops"], document.first.fetch("features") + end + end + + def test_format_preserves_explicit_and_file_metadata_complexity + capture = [ + { "name" => "explicit_complexity", "template" => "a", "expected" => "a", "complexity" => 321 }, + { "name" => "metadata_complexity", "template" => "b", "expected" => "b" }, + ] + + with_formatted_specs(capture, metadata: { "complexity" => 777 }, default_complexity: 1000) do |document, specs| + assert_equal 777, document.fetch("_metadata").fetch("complexity") + assert_equal 321, document.fetch("specs").first.fetch("complexity") + refute document.fetch("specs").last.key?("complexity") + assert_equal [321, 777], specs.map(&:complexity) + end + end + + def test_format_normalizes_symbol_complexity_metadata + capture = [ + { "name" => "explicit_complexity", "template" => "a", "expected" => "a", "complexity" => 321 }, + { "name" => "metadata_complexity", "template" => "b", "expected" => "b" }, + ] + + with_formatted_specs(capture, metadata: { complexity: 777 }, default_complexity: 1000) do |document, specs| + assert_equal 777, document.fetch("_metadata").fetch("complexity") + refute document.fetch("_metadata").key?(:complexity) + refute document.fetch("specs").last.key?("complexity") + assert_equal [321, 777], specs.map(&:complexity) + end + end + + def test_format_string_minimum_complexity_metadata_blocks_per_spec_default + assert_metadata_complexity("minimum_complexity" => 777) + end + + def test_format_symbol_minimum_complexity_metadata_blocks_per_spec_default + assert_metadata_complexity(minimum_complexity: 777) + end + + def test_format_applies_an_opt_in_default_only_to_missing_complexity + capture = [ + { "name" => "explicit_complexity", "template" => "a", "expected" => "a", "complexity" => 321 }, + { "name" => "default_complexity", "template" => "b", "expected" => "b" }, + ] + + with_formatted_specs(capture, default_complexity: 1000) do |document, _specs| + complexities = document.to_h { |spec| [spec.fetch("name"), spec.fetch("complexity")] } + assert_equal 321, complexities.fetch("explicit_complexity") + assert_equal 1000, complexities.fetch("default_complexity") + end + end + + private + + def assert_metadata_complexity(metadata) + capture = [ + { "name" => "explicit_complexity", "template" => "a", "expected" => "a", "complexity" => 321 }, + { "name" => "metadata_complexity", "template" => "b", "expected" => "b" }, + ] + + with_formatted_specs(capture, metadata: metadata, default_complexity: 1000) do |document, specs| + assert_equal 777, document.fetch("_metadata").fetch("minimum_complexity") + refute document.fetch("_metadata").key?(:minimum_complexity) + refute document.fetch("specs").last.key?("complexity") + assert_equal [321, 777], specs.map(&:complexity) + end + end +end diff --git a/test/yaml_coders_test.rb b/test/yaml_coders_test.rb new file mode 100644 index 00000000..9f40959b --- /dev/null +++ b/test/yaml_coders_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require_relative "test_helper" +require "liquid" +require "liquid/spec/deps/liquid_ruby" + +class YamlCodersTest < Minitest::Test + include Liquid::Spec::TestHelpers + + def test_range_capture_round_trips_inclusive_exclusive_and_endless_ranges + { + "inclusive" => (1..5), + "exclusive" => (1...5), + "endless" => (1..), + }.each do |name, range| + captured = YAML.safe_load(YAML.dump({ "range" => range })) + instantiated = create_spec(raw_environment: captured).instantiate_environment.fetch("range") + + assert_equal range, instantiated + assert_equal range.exclude_end?, instantiated.exclude_end? + assert_equal range.begin, instantiated.begin + range.end.nil? ? assert_nil(instantiated.end) : assert_equal(range.end, instantiated.end) + end + end + + def test_inclusive_range_capture_keeps_the_historical_two_item_encoding + captured = YAML.safe_load(YAML.dump({ "range" => (1..5) })) + + assert_equal [1, 5], captured.fetch("range").fetch("instantiate:Range:") + end + + def test_exclusive_range_capture_appends_the_exclusive_flag + captured = YAML.safe_load(YAML.dump({ "range" => (1...5) })) + + assert_equal [1, 5, true], captured.fetch("range").fetch("instantiate:Range:") + end +end