mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-29 15:15:19 -07:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3befa567b1 |
@@ -32,7 +32,6 @@ group :test do
|
||||
end
|
||||
|
||||
group :spec do
|
||||
# Includes the range resource-limit specs from https://github.com/Shopify/liquid-spec/pull/165.
|
||||
gem 'liquid-spec', github: 'Shopify/liquid-spec', ref: '84bf25e0edbca5f7e530574788b0f875ab331f50'
|
||||
gem 'liquid-spec', github: 'Shopify/liquid-spec'
|
||||
gem 'activesupport', require: false
|
||||
end
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# Liquid Change Log
|
||||
|
||||
## 5.14.0
|
||||
|
||||
* Avoid materializing integer ranges in `for` and `tablerow` loops, and account for each visited range item [Ian Ker-Seymer]
|
||||
|
||||
## 5.13.0
|
||||
|
||||
* Add TruffleRuby in CI [Benoit Daloze]
|
||||
|
||||
@@ -149,13 +149,6 @@ template.render!({ 'x' => 1}, { strict_variables: true })
|
||||
#=> Liquid::UndefinedVariable: Liquid error: undefined variable y
|
||||
```
|
||||
|
||||
### Resource limits
|
||||
|
||||
`render_score_limit` and `cumulative_render_score_limit` account for each item visited by
|
||||
integer-range `for` and `tablerow` loops, including loops with empty bodies. This bounds range
|
||||
iteration work when a score limit is configured; `render_length_limit` only bounds generated
|
||||
output and does not by itself limit CPU work for output-free loops.
|
||||
|
||||
### Usage tracking
|
||||
|
||||
To help track usages of a feature or code path in production, we have released opt-in usage tracking. To enable this, we provide an empty `Liquid:: Usage.increment` method which you can customize to your needs. The feature is well suited to https://github.com/Shopify/statsd-instrument. However, the choice of implementation is up to you.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Recording and replaying renders
|
||||
|
||||
`Liquid::TemplateRecorder` captures successful template renders so they can be
|
||||
replayed without the application's file system or Drop implementations.
|
||||
Recording does not wrap or replace assigns, so the recorded render has the same
|
||||
semantics as a normal render.
|
||||
|
||||
```ruby
|
||||
Liquid::TemplateRecorder.record("render.json") do
|
||||
template = Liquid::Template.parse(source)
|
||||
template.render!(assigns)
|
||||
end
|
||||
|
||||
replayer = Liquid::TemplateRecorder.replay_from("render.json", mode: :verify)
|
||||
replayer.render # raises if the output changed
|
||||
```
|
||||
|
||||
A recording contains the root template, every parsed partial, partial contents,
|
||||
plain Hash/Array values resolved by the template, properties actually read from `Liquid::Drop` objects,
|
||||
filter-call diagnostics, engine options, and the rendered output. Drop instance
|
||||
variables are never inspected. An unsupported Ruby object raises
|
||||
`Liquid::TemplateRecorder::SerializationError` rather than silently producing a
|
||||
recording that cannot be replayed.
|
||||
|
||||
## Storage formats
|
||||
|
||||
A `.json` destination is written atomically after the recording block succeeds.
|
||||
It contains a session with every render performed by the block.
|
||||
|
||||
A `.jsonl` destination is append-only. Each successful top-level render is one
|
||||
compact, self-contained JSON line. This is the recommended format for production
|
||||
sampling: a process failure can lose at most the render being written, writers
|
||||
are serialized with `flock`, and a recording can be replayed by index.
|
||||
|
||||
A destination may instead be any writer object responding to `write(record)`. The
|
||||
writer receives one self-contained recording Hash per successful render. Liquid
|
||||
does not own or close injected writers, so applications can publish records to
|
||||
Kafka, object storage, or another transport without coupling that transport to
|
||||
the recorder.
|
||||
Pass `on_error:` to keep serialization or sink failures out of the render path;
|
||||
the callback receives the error and should not raise.
|
||||
|
||||
```ruby
|
||||
Liquid::TemplateRecorder.record(kafka_writer) do
|
||||
template.render!(assigns)
|
||||
end
|
||||
```
|
||||
|
||||
```ruby
|
||||
Liquid::TemplateRecorder.replay_from("renders.jsonl") # last render
|
||||
Liquid::TemplateRecorder.replay_from("renders.jsonl", index: 0) # first render
|
||||
Liquid::TemplateRecorder.records("renders.jsonl") # inspect all
|
||||
```
|
||||
|
||||
Compression is intentionally separate from the schema. In particular, one
|
||||
long-lived compressed stream makes appending, recovery, and selecting a render
|
||||
harder. Compress rotated `.jsonl` files with the storage system of your choice;
|
||||
a future compressed writer can use one independent frame per record without a
|
||||
schema change.
|
||||
|
||||
Recording sessions are thread-local. Nested sessions in the same thread are
|
||||
rejected. Existing application register names and the one-argument
|
||||
`FileSystem#read_template_file` API remain unchanged.
|
||||
|
||||
## Replay modes
|
||||
|
||||
* `:compute` runs filters normally. Pass application filters with
|
||||
`replayer.render(filters: MyFilters)`.
|
||||
* `:strict` returns each exact recorded filter result and rejects a changed
|
||||
filter sequence. This can replay application-specific or nondeterministic
|
||||
filters without loading their implementations.
|
||||
* `:verify` computes normally and raises when the final output differs.
|
||||
+1
-1
@@ -83,7 +83,6 @@ require 'liquid/resource_limits'
|
||||
require 'liquid/expression'
|
||||
require 'liquid/template'
|
||||
require 'liquid/condition'
|
||||
require 'liquid/range_slice'
|
||||
require 'liquid/utils'
|
||||
require 'liquid/tokenizer'
|
||||
require 'liquid/parse_context'
|
||||
@@ -91,3 +90,4 @@ require 'liquid/partial_cache'
|
||||
require 'liquid/usage'
|
||||
require 'liquid/registers'
|
||||
require 'liquid/template_factory'
|
||||
require "liquid/template_recorder"
|
||||
|
||||
@@ -84,10 +84,15 @@ module Liquid
|
||||
|
||||
# @api private
|
||||
def self.render_node(context, output, node)
|
||||
recorder = TemplateRecorder.current
|
||||
tag_call = recorder&.begin_tag_render(node, context)
|
||||
output_start = output.length
|
||||
node.render_to_output_buffer(context, output)
|
||||
rescue => exc
|
||||
blank_tag = !node.instance_of?(Variable) && node.blank?
|
||||
rescue_render_node(context, output, node.line_number, exc, blank_tag)
|
||||
ensure
|
||||
recorder&.finish_tag_render(tag_call, output[output_start..]) if output_start
|
||||
end
|
||||
|
||||
# @api private
|
||||
|
||||
@@ -228,6 +228,8 @@ module Liquid
|
||||
|
||||
liquid_variable.context = self if variable != liquid_variable && liquid_variable.respond_to?(:context=)
|
||||
|
||||
recorder = @registers[TemplateRecorder::REGISTER_KEY] if defined?(TemplateRecorder)
|
||||
recorder&.emit_variable_read(key, liquid_variable)
|
||||
liquid_variable
|
||||
end
|
||||
|
||||
|
||||
+5
-1
@@ -37,11 +37,15 @@ module Liquid
|
||||
|
||||
# called by liquid to invoke a drop
|
||||
def invoke_drop(method_or_key)
|
||||
if self.class.invokable?(method_or_key)
|
||||
result = if self.class.invokable?(method_or_key)
|
||||
send(method_or_key)
|
||||
else
|
||||
liquid_method_missing(method_or_key)
|
||||
end
|
||||
|
||||
recorder = @context&.registers&.[](TemplateRecorder::REGISTER_KEY) if defined?(TemplateRecorder)
|
||||
recorder&.emit_drop_read(self, method_or_key, result)
|
||||
result
|
||||
end
|
||||
|
||||
def key?(_name)
|
||||
|
||||
@@ -10,6 +10,8 @@ module Liquid
|
||||
|
||||
file_system = context.registers[:file_system]
|
||||
source = file_system.read_template_file(template_name)
|
||||
recorder = context.registers[TemplateRecorder::REGISTER_KEY] if defined?(TemplateRecorder)
|
||||
recorder&.emit_file_read(template_name, source)
|
||||
|
||||
parse_context.partial = true
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
class RangeSlice
|
||||
attr_reader :length
|
||||
|
||||
def initialize(range, from, to, resource_limits)
|
||||
range_length = range.end - range.begin
|
||||
range_length += 1 unless range.exclude_end?
|
||||
range_length = 0 if range_length.negative?
|
||||
|
||||
start = [from, 0].max
|
||||
finish = [to || range_length, range_length].min
|
||||
|
||||
@first = range.begin + start
|
||||
@length = [finish - start, 0].max
|
||||
@direction = 1
|
||||
@resource_limits = resource_limits
|
||||
end
|
||||
|
||||
def empty?
|
||||
@length.zero?
|
||||
end
|
||||
|
||||
def each
|
||||
return enum_for(:each) unless block_given?
|
||||
|
||||
value = @first
|
||||
@length.times do
|
||||
@resource_limits.increment_render_score(1)
|
||||
yield value
|
||||
value += @direction
|
||||
end
|
||||
end
|
||||
|
||||
def reverse!
|
||||
unless empty?
|
||||
@first += @direction * (@length - 1)
|
||||
@direction = -@direction
|
||||
end
|
||||
self
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -48,13 +48,17 @@ module Liquid
|
||||
end
|
||||
|
||||
def invoke(method, *args)
|
||||
if self.class.invokable?(method)
|
||||
result = if self.class.invokable?(method)
|
||||
send(method, *args)
|
||||
elsif @context.strict_filters
|
||||
raise Liquid::UndefinedFilter, "undefined filter #{method}"
|
||||
else
|
||||
args.first
|
||||
end
|
||||
|
||||
recorder = @context&.registers&.[](TemplateRecorder::REGISTER_KEY) if defined?(TemplateRecorder)
|
||||
recorder&.emit_filter_call(method, args.first, args.drop(1), result)
|
||||
result
|
||||
rescue ::ArgumentError => e
|
||||
raise Liquid::ArgumentError, e.message, e.backtrace
|
||||
end
|
||||
|
||||
@@ -130,6 +130,7 @@ module Liquid
|
||||
end
|
||||
|
||||
collection = context.evaluate(@collection_name)
|
||||
collection = collection.to_a if collection.is_a?(Range)
|
||||
|
||||
limit_value = context.evaluate(@limit)
|
||||
to = if limit_value.nil?
|
||||
@@ -138,9 +139,7 @@ module Liquid
|
||||
Utils.to_integer(limit_value) + from
|
||||
end
|
||||
|
||||
segment = Utils.slice_collection_for_iteration(
|
||||
collection, from, to, context.resource_limits, use_range_to_a: true
|
||||
)
|
||||
segment = Utils.slice_collection(collection, from, to)
|
||||
segment.reverse! if @reversed
|
||||
|
||||
offsets[@name] = from + segment.length
|
||||
|
||||
@@ -85,13 +85,12 @@ module Liquid
|
||||
from = @attributes.key?('offset') ? to_integer(context.evaluate(@attributes['offset'])) : 0
|
||||
to = @attributes.key?('limit') ? from + to_integer(context.evaluate(@attributes['limit'])) : nil
|
||||
|
||||
collection = Utils.slice_collection_for_iteration(collection, from, to, context.resource_limits, allow_endless: true)
|
||||
collection = Utils.slice_collection(collection, from, to)
|
||||
length = collection.length
|
||||
|
||||
cols = @attributes.key?('cols') ? to_integer(context.evaluate(@attributes['cols'])) : length
|
||||
|
||||
output << "<tr class=\"row1\">\n"
|
||||
context.resource_limits.increment_write_score(output)
|
||||
context.stack do
|
||||
tablerowloop = Liquid::TablerowloopDrop.new(length, cols)
|
||||
context['tablerowloop'] = tablerowloop
|
||||
@@ -102,7 +101,6 @@ module Liquid
|
||||
output << "<td class=\"col#{tablerowloop.col}\">"
|
||||
super
|
||||
output << '</td>'
|
||||
context.resource_limits.increment_write_score(output)
|
||||
|
||||
# Handle any interrupts if they exist.
|
||||
if context.interrupt?
|
||||
@@ -112,7 +110,6 @@ module Liquid
|
||||
|
||||
if tablerowloop.col_last && !tablerowloop.last
|
||||
output << "</tr>\n<tr class=\"row#{tablerowloop.row + 1}\">"
|
||||
context.resource_limits.increment_write_score(output)
|
||||
end
|
||||
|
||||
tablerowloop.send(:increment!)
|
||||
@@ -120,7 +117,6 @@ module Liquid
|
||||
end
|
||||
|
||||
output << "</tr>\n"
|
||||
context.resource_limits.increment_write_score(output)
|
||||
output
|
||||
end
|
||||
|
||||
|
||||
+26
-2
@@ -105,6 +105,7 @@ module Liquid
|
||||
|
||||
tokenizer = parse_context.new_tokenizer(source, start_line_number: @line_numbers && 1)
|
||||
@root = Document.parse(tokenizer, parse_context)
|
||||
@template_recorder_source = source.dup.freeze if defined?(TemplateRecorder) && TemplateRecorder.current
|
||||
self
|
||||
end
|
||||
|
||||
@@ -141,6 +142,8 @@ module Liquid
|
||||
def render(*args)
|
||||
return '' if @root.nil?
|
||||
|
||||
recording_session = TemplateRecorder.current if defined?(TemplateRecorder)
|
||||
recording_assigns = args.first
|
||||
context = case args.first
|
||||
when Liquid::Context
|
||||
c = args.shift
|
||||
@@ -180,6 +183,13 @@ module Liquid
|
||||
context.add_filters(args.pop)
|
||||
end
|
||||
|
||||
recording = recording_session&.begin_render(self, recording_assigns, context)
|
||||
if recording
|
||||
recorder_registers = context.registers.static
|
||||
previous_recorder = recorder_registers[TemplateRecorder::REGISTER_KEY]
|
||||
recorder_registers[TemplateRecorder::REGISTER_KEY] = recording
|
||||
end
|
||||
|
||||
# Retrying a render resets resource usage
|
||||
context.resource_limits.reset
|
||||
|
||||
@@ -192,17 +202,31 @@ module Liquid
|
||||
previous_error_mode = context.registers.static[:template_error_mode]
|
||||
context.registers.static[:template_error_mode] = @error_mode
|
||||
|
||||
rendered_output = nil
|
||||
render_succeeded = false
|
||||
begin
|
||||
# render the nodelist.
|
||||
@root.render_to_output_buffer(context, output || +'')
|
||||
rendered_output = @root.render_to_output_buffer(context, output || +'')
|
||||
render_succeeded = true
|
||||
rendered_output
|
||||
rescue Liquid::MemoryError => e
|
||||
context.handle_error(e)
|
||||
rendered_output = context.handle_error(e)
|
||||
render_succeeded = true
|
||||
rendered_output
|
||||
ensure
|
||||
if previous_error_mode
|
||||
context.registers.static[:template_error_mode] = previous_error_mode
|
||||
else
|
||||
context.registers.static.delete(:template_error_mode)
|
||||
end
|
||||
if recording
|
||||
if previous_recorder
|
||||
recorder_registers[TemplateRecorder::REGISTER_KEY] = previous_recorder
|
||||
else
|
||||
recorder_registers.delete(TemplateRecorder::REGISTER_KEY)
|
||||
end
|
||||
recording_session.finish_render(recording, rendered_output, context, success: render_succeeded)
|
||||
end
|
||||
@errors = context.errors
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'English'
|
||||
require "digest/sha2"
|
||||
require "json"
|
||||
require "securerandom"
|
||||
require "tempfile"
|
||||
require "time"
|
||||
|
||||
module Liquid
|
||||
# Records complete Liquid renders without changing the objects being rendered.
|
||||
# A .json file contains one session; a .jsonl file is an append-only sequence
|
||||
# of independently replayable renders.
|
||||
class TemplateRecorder
|
||||
FORMAT = "liquid-render"
|
||||
SCHEMA_VERSION = 1
|
||||
REGISTER_KEY = :__liquid_template_recorder
|
||||
REPLAYER_REGISTER_KEY = :__liquid_template_recorder_replayer
|
||||
|
||||
class Error < StandardError; end
|
||||
class ReplayError < Error; end
|
||||
class SerializationError < Error; end
|
||||
|
||||
class << self
|
||||
def record(destination, on_error: nil)
|
||||
raise ArgumentError, "a block is required" unless block_given?
|
||||
|
||||
previous_session = current
|
||||
raise Error, "nested recording sessions are not supported" if previous_session
|
||||
|
||||
session = Session.new(destination, on_error: on_error)
|
||||
Thread.current[thread_key] = session
|
||||
yield
|
||||
ensure
|
||||
if session
|
||||
Thread.current[thread_key] = previous_session
|
||||
session.close if $ERROR_INFO.nil?
|
||||
end
|
||||
end
|
||||
|
||||
def current
|
||||
Thread.current[thread_key]
|
||||
end
|
||||
|
||||
def replay_from(path, mode: :compute, index: -1)
|
||||
records = Store.read(path)
|
||||
raise ReplayError, "recording contains no renders" if records.empty?
|
||||
|
||||
record = records.fetch(index)
|
||||
Replayer.new(record, mode: mode)
|
||||
rescue IndexError
|
||||
raise ReplayError, "render index #{index} does not exist"
|
||||
end
|
||||
|
||||
def records(path)
|
||||
Store.read(path)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def thread_key
|
||||
:__liquid_template_recorder_session
|
||||
end
|
||||
end
|
||||
|
||||
class Session
|
||||
def initialize(destination, on_error:)
|
||||
@path = destination.to_s if destination.is_a?(String) || destination.respond_to?(:to_path)
|
||||
@writer = destination unless @path
|
||||
@on_error = on_error
|
||||
@records = []
|
||||
@active = nil
|
||||
@pending_files = {}
|
||||
end
|
||||
|
||||
def begin_render(template, _assigns, context)
|
||||
if @active
|
||||
@active.add_template(template)
|
||||
@active.nesting += 1
|
||||
return @active
|
||||
end
|
||||
|
||||
@active = Render.new(template, context)
|
||||
@pending_files.each { |path, source| @active.emit_file_read(path, source) }
|
||||
@pending_files.clear
|
||||
@active
|
||||
end
|
||||
|
||||
def emit_file_read(path, source)
|
||||
if @active
|
||||
@active.emit_file_read(path, source)
|
||||
else
|
||||
@pending_files[path.to_s] = source
|
||||
end
|
||||
end
|
||||
|
||||
def emit_variable_output(output)
|
||||
@active&.emit_variable_output(output)
|
||||
end
|
||||
|
||||
def begin_tag_render(node, context)
|
||||
@active&.begin_tag_render(node, context)
|
||||
end
|
||||
|
||||
def finish_tag_render(call, output)
|
||||
@active&.finish_tag_render(call, output)
|
||||
end
|
||||
|
||||
def finish_render(render, output, context, success:)
|
||||
return unless render.equal?(@active)
|
||||
|
||||
if render.nesting.positive?
|
||||
render.nesting -= 1
|
||||
return
|
||||
end
|
||||
|
||||
if success
|
||||
begin
|
||||
record = render.finish(output, context)
|
||||
if @writer
|
||||
@writer.write(record)
|
||||
elsif Store.jsonl?(@path)
|
||||
Store.append(@path, record)
|
||||
else
|
||||
@records << record
|
||||
end
|
||||
rescue => error
|
||||
handle_error(error)
|
||||
end
|
||||
end
|
||||
@active = nil
|
||||
end
|
||||
|
||||
def close
|
||||
return if @writer || Store.jsonl?(@path)
|
||||
|
||||
Store.write_session(@path, @records)
|
||||
rescue => error
|
||||
handle_error(error)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_error(error)
|
||||
raise error unless @on_error
|
||||
|
||||
@on_error.call(error)
|
||||
end
|
||||
end
|
||||
|
||||
class Render
|
||||
attr_accessor :nesting
|
||||
|
||||
def initialize(template, _context)
|
||||
@nesting = 0
|
||||
@templates = []
|
||||
@files = {}
|
||||
@filter_calls = []
|
||||
@tag_calls = []
|
||||
@tag_render_depth = 0
|
||||
@variables = {}
|
||||
@variable_outputs = []
|
||||
@drop_values = {}
|
||||
@bindings = {}.compare_by_identity
|
||||
@root_template = template
|
||||
add_template(template)
|
||||
end
|
||||
|
||||
def add_template(template)
|
||||
source = template.instance_variable_get(:@template_recorder_source)
|
||||
return unless source
|
||||
|
||||
entrypoint = template.name
|
||||
digest = Digest::SHA256.hexdigest(source)
|
||||
return if @templates.any? { |item| item["sha256"] == digest && item["entrypoint"] == entrypoint }
|
||||
|
||||
@templates << { "source" => source, "entrypoint" => entrypoint, "sha256" => digest }
|
||||
end
|
||||
|
||||
def emit_variable_output(output)
|
||||
return if @tag_render_depth.positive?
|
||||
|
||||
@variable_outputs << output
|
||||
end
|
||||
|
||||
def begin_tag_render(node, context)
|
||||
name = context.environment.tags.key(node.class)
|
||||
return unless name
|
||||
|
||||
@tag_render_depth += 1
|
||||
return :nested if @tag_render_depth > 1
|
||||
|
||||
call = { "name" => name.to_s, "output" => nil }
|
||||
@tag_calls << call
|
||||
call
|
||||
end
|
||||
|
||||
def finish_tag_render(call, output)
|
||||
return unless call
|
||||
|
||||
@tag_render_depth -= 1
|
||||
call["output"] = output unless call == :nested
|
||||
end
|
||||
|
||||
def emit_variable_read(name, value)
|
||||
path = [name.to_s]
|
||||
@variables[name.to_s] = serialize(value, path, bind: true)
|
||||
rescue SerializationError
|
||||
# Unsupported values must not affect the render being observed.
|
||||
end
|
||||
|
||||
def emit_drop_read(drop, key, value)
|
||||
base = @bindings[drop]
|
||||
return unless base
|
||||
|
||||
path = base + [key.to_s]
|
||||
set_path(@drop_values, path, serialize(value, path, bind: true))
|
||||
rescue SerializationError
|
||||
# Unsupported values must not affect the render being observed.
|
||||
end
|
||||
|
||||
def emit_file_read(path, source)
|
||||
@files[path.to_s] = source.to_s
|
||||
end
|
||||
|
||||
def emit_filter_call(name, input, arguments, output)
|
||||
return if @tag_render_depth.positive?
|
||||
|
||||
@filter_calls << {
|
||||
"name" => name.to_s,
|
||||
"input" => serialize(input, ["filters", @filter_calls.length, "input"]),
|
||||
"arguments" => serialize(arguments, ["filters", @filter_calls.length, "arguments"]),
|
||||
"output" => serialize(output, ["filters", @filter_calls.length, "output"]),
|
||||
}
|
||||
rescue SerializationError
|
||||
# Filter diagnostics must never make an otherwise replayable render fail.
|
||||
end
|
||||
|
||||
def finish(output, context)
|
||||
variables = deep_merge(@variables, @drop_values)
|
||||
source = @root_template.instance_variable_get(:@template_recorder_source)
|
||||
raise Error, "the rendered template was parsed outside the recording block" unless source
|
||||
|
||||
{
|
||||
"format" => FORMAT,
|
||||
"schema_version" => SCHEMA_VERSION,
|
||||
"id" => SecureRandom.uuid,
|
||||
"recorded_at" => Time.now.utc.iso8601,
|
||||
"engine" => {
|
||||
"liquid_version" => Liquid::VERSION,
|
||||
"ruby_version" => RUBY_VERSION,
|
||||
"strict_variables" => !!context.strict_variables,
|
||||
"strict_filters" => !!context.strict_filters,
|
||||
},
|
||||
"template" => @templates.first,
|
||||
"templates" => @templates,
|
||||
"assigns" => variables,
|
||||
"variable_outputs" => @variable_outputs,
|
||||
"file_system" => @files,
|
||||
"filter_calls" => @filter_calls,
|
||||
"tag_calls" => @tag_calls,
|
||||
"output" => output.to_s,
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialize(value, path, seen = {}.compare_by_identity, bind: false)
|
||||
case value
|
||||
when nil, true, false, String, Integer, Float
|
||||
value
|
||||
when Symbol
|
||||
value.to_s
|
||||
when Liquid::Drop
|
||||
@bindings[value] ||= path if bind
|
||||
existing = value_at(@drop_values, @bindings[value])
|
||||
existing || {}
|
||||
when Hash
|
||||
raise SerializationError, "circular value at #{format_path(path)}" if seen.key?(value)
|
||||
|
||||
seen[value] = true
|
||||
result = value.each_with_object({}) do |(key, child), hash|
|
||||
string_key = key.to_s
|
||||
hash[string_key] = serialize(child, path + [string_key], seen, bind: bind)
|
||||
end
|
||||
seen.delete(value)
|
||||
result
|
||||
when Array
|
||||
raise SerializationError, "circular value at #{format_path(path)}" if seen.key?(value)
|
||||
|
||||
seen[value] = true
|
||||
result = value.each_with_index.map { |child, index| serialize(child, path + [index], seen, bind: bind) }
|
||||
seen.delete(value)
|
||||
result
|
||||
else
|
||||
raise SerializationError, "cannot record #{value.class} at #{format_path(path)}"
|
||||
end
|
||||
end
|
||||
|
||||
def set_path(root, path, value)
|
||||
return root.replace(value) if path.empty? && value.is_a?(Hash)
|
||||
|
||||
cursor = root
|
||||
path.each_with_index do |segment, index|
|
||||
last = index == path.length - 1
|
||||
if segment.is_a?(Integer)
|
||||
break unless cursor.is_a?(Array)
|
||||
|
||||
end
|
||||
cursor[segment] = last ? value : (cursor[segment] ||= container_for(path[index + 1]))
|
||||
cursor = cursor[segment] unless last
|
||||
end
|
||||
end
|
||||
|
||||
def value_at(root, path)
|
||||
return unless path
|
||||
|
||||
path.reduce(root) { |value, segment| value.respond_to?(:[]) ? value[segment] : nil }
|
||||
end
|
||||
|
||||
def container_for(segment)
|
||||
segment.is_a?(Integer) ? [] : {}
|
||||
end
|
||||
|
||||
def deep_merge(left, right)
|
||||
return right unless left.is_a?(Hash) && right.is_a?(Hash)
|
||||
|
||||
left.merge(right) { |_key, a, b| deep_merge(a, b) }
|
||||
end
|
||||
|
||||
def format_path(path)
|
||||
path.empty? ? "<root>" : path.join(".")
|
||||
end
|
||||
end
|
||||
|
||||
class Store
|
||||
class << self
|
||||
def jsonl?(path)
|
||||
path.end_with?(".jsonl")
|
||||
end
|
||||
|
||||
def append(path, record)
|
||||
line = JSON.generate(record) << "\n"
|
||||
File.open(path, File::WRONLY | File::CREAT | File::APPEND, 0o600) do |file|
|
||||
file.flock(File::LOCK_EX)
|
||||
file.write(line)
|
||||
file.flush
|
||||
end
|
||||
end
|
||||
|
||||
def write_session(path, records)
|
||||
payload = JSON.pretty_generate(
|
||||
"format" => "liquid-recording-session",
|
||||
"schema_version" => SCHEMA_VERSION,
|
||||
"renders" => records,
|
||||
) << "\n"
|
||||
directory = File.dirname(File.expand_path(path))
|
||||
Tempfile.create([".liquid-recording", ".tmp"], directory, mode: File::RDWR, perm: 0o600) do |file|
|
||||
file.write(payload)
|
||||
file.flush
|
||||
file.fsync
|
||||
File.rename(file.path, path)
|
||||
end
|
||||
end
|
||||
|
||||
def read(path)
|
||||
content = File.binread(path)
|
||||
records = if jsonl?(path)
|
||||
read_jsonl(content)
|
||||
else
|
||||
parsed = JSON.parse(content)
|
||||
parsed["renders"] || [parsed]
|
||||
end
|
||||
records.each { |record| validate!(record) }
|
||||
records
|
||||
rescue Errno::ENOENT
|
||||
raise ReplayError, "recording file not found: #{path}"
|
||||
rescue JSON::ParserError => error
|
||||
raise ReplayError, "invalid recording JSON: #{error.message}"
|
||||
end
|
||||
|
||||
def read_jsonl(content)
|
||||
lines = content.lines
|
||||
lines.filter_map.with_index do |line, index|
|
||||
next if line.strip.empty?
|
||||
|
||||
JSON.parse(line)
|
||||
rescue JSON::ParserError
|
||||
last_truncated_line = index == lines.length - 1 && !content.end_with?("\n")
|
||||
raise unless last_truncated_line
|
||||
end
|
||||
end
|
||||
|
||||
def validate!(record)
|
||||
raise ReplayError, "unsupported recording format" unless record["format"] == FORMAT
|
||||
raise ReplayError, "unsupported schema version #{record["schema_version"].inspect}" unless record["schema_version"] == SCHEMA_VERSION
|
||||
|
||||
['template', 'assigns', 'file_system', 'output'].each do |key|
|
||||
raise ReplayError, "recording is missing #{key}" unless record.key?(key)
|
||||
end
|
||||
template = record["template"]
|
||||
expected = Digest::SHA256.hexdigest(template.fetch("source"))
|
||||
raise ReplayError, "template checksum does not match" unless template["sha256"] == expected
|
||||
rescue KeyError, TypeError => error
|
||||
raise ReplayError, "invalid recording schema: #{error.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class MemoryFileSystem
|
||||
def initialize(files)
|
||||
@files = files
|
||||
end
|
||||
|
||||
def read_template_file(path)
|
||||
@files.fetch(path.to_s) { raise FileSystemError, "No such template '#{path}'" }
|
||||
end
|
||||
end
|
||||
|
||||
class Replayer
|
||||
def initialize(record, mode: :compute, environment: nil)
|
||||
@record = record
|
||||
@mode = mode.to_sym
|
||||
@environment = environment
|
||||
unless [:compute, :strict, :verify].include?(@mode)
|
||||
raise ReplayError, "mode must be :compute, :strict, or :verify"
|
||||
end
|
||||
end
|
||||
|
||||
def render(to: nil, filters: nil)
|
||||
@filter_index = 0
|
||||
@tag_index = 0
|
||||
@variable_index = 0
|
||||
parse_options = {}
|
||||
parse_options[:environment] = strict_environment if @mode == :strict
|
||||
template = Liquid::Template.parse(@record.dig("template", "source"), parse_options)
|
||||
registers = { file_system: MemoryFileSystem.new(@record["file_system"]) }
|
||||
registers[REPLAYER_REGISTER_KEY] = self if @mode == :strict && @record.key?("variable_outputs")
|
||||
options = {
|
||||
registers: registers,
|
||||
strict_variables: @record.dig("engine", "strict_variables"),
|
||||
strict_filters: @record.dig("engine", "strict_filters"),
|
||||
}
|
||||
options[:filters] = filters if filters
|
||||
output = template.render!(@record["assigns"], options)
|
||||
if @mode == :strict
|
||||
verify_filter_count! unless @record.key?("variable_outputs")
|
||||
verify_tag_count!
|
||||
verify_variable_count!
|
||||
end
|
||||
if [:strict, :verify].include?(@mode) && output != @record["output"]
|
||||
raise ReplayError, "replayed output does not match the recording"
|
||||
end
|
||||
|
||||
File.binwrite(to, output) if to
|
||||
output
|
||||
end
|
||||
|
||||
def replay_filter(name)
|
||||
call = @record["filter_calls"].fetch(@filter_index) do
|
||||
raise ReplayError, "unexpected filter call #{name}"
|
||||
end
|
||||
if call["name"] != name.to_s
|
||||
raise ReplayError, "expected filter #{call["name"]}, got #{name}"
|
||||
end
|
||||
|
||||
@filter_index += 1
|
||||
JSON.parse(JSON.generate(call["output"]))
|
||||
end
|
||||
|
||||
def replay_variable
|
||||
value = @record.fetch("variable_outputs").fetch(@variable_index) do
|
||||
raise ReplayError, "unexpected variable render"
|
||||
end
|
||||
@variable_index += 1
|
||||
value
|
||||
end
|
||||
|
||||
def replay_tag(name)
|
||||
call = @record.fetch("tag_calls", []).fetch(@tag_index) do
|
||||
raise ReplayError, "unexpected tag call #{name}"
|
||||
end
|
||||
if call["name"] != name.to_s
|
||||
raise ReplayError, "expected tag #{call["name"]}, got #{name}"
|
||||
end
|
||||
|
||||
@tag_index += 1
|
||||
call["output"]
|
||||
end
|
||||
|
||||
def recorded_output
|
||||
@record["output"]
|
||||
end
|
||||
|
||||
def templates
|
||||
@record["templates"]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def strict_environment
|
||||
replayer = self
|
||||
strainer = Class.new(Liquid::StrainerTemplate) do
|
||||
define_method(:invoke) do |name, *_args|
|
||||
replayer.replay_filter(name)
|
||||
end
|
||||
end
|
||||
tags = (@environment || Liquid::Environment.default).tags.dup
|
||||
tags&.each do |name, tag_class|
|
||||
tags[name] = replay_tag_class(tag_class, name)
|
||||
end
|
||||
Liquid::Environment.build(tags: tags) do |environment|
|
||||
environment.strainer_template = strainer
|
||||
end
|
||||
end
|
||||
|
||||
def replay_tag_class(tag_class, name)
|
||||
replayer = self
|
||||
Class.new(tag_class) do
|
||||
define_method(:render_to_output_buffer) do |_context, output|
|
||||
output << replayer.replay_tag(name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def verify_variable_count!
|
||||
return unless @record.key?("variable_outputs")
|
||||
|
||||
expected = @record["variable_outputs"].length
|
||||
return if @variable_index == expected
|
||||
|
||||
raise ReplayError, "expected #{expected} variable renders, got #{@variable_index}"
|
||||
end
|
||||
|
||||
def verify_tag_count!
|
||||
expected = @record.fetch("tag_calls", []).length
|
||||
return if @tag_index == expected
|
||||
|
||||
raise ReplayError, "expected #{expected} tag calls, got #{@tag_index}"
|
||||
end
|
||||
|
||||
def verify_filter_count!
|
||||
expected = @record["filter_calls"].length
|
||||
return if @filter_index == expected
|
||||
|
||||
raise ReplayError, "expected #{expected} filter calls, got #{@filter_index}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -13,26 +13,6 @@ module Liquid
|
||||
end
|
||||
end
|
||||
|
||||
# This is intentionally separate from slice_collection, whose Array-returning
|
||||
# behavior is used outside of the iteration tags.
|
||||
def self.slice_collection_for_iteration(
|
||||
collection, from, to, resource_limits, allow_endless: false, use_range_to_a: false
|
||||
)
|
||||
if integer_range?(collection)
|
||||
RangeSlice.new(collection, from, to, resource_limits)
|
||||
elsif collection.is_a?(Range)
|
||||
if use_range_to_a && range_method_overridden?(collection, :to_a)
|
||||
# For historically honored custom Range#to_a. Charge the resulting
|
||||
# selection before buffering it, just as for a custom #each.
|
||||
slice_collection_for_iteration_using_each(collection.to_a, from, to, resource_limits)
|
||||
else
|
||||
slice_range_using_each(collection, from, to, resource_limits, allow_endless: allow_endless)
|
||||
end
|
||||
else
|
||||
slice_collection(collection, from, to)
|
||||
end
|
||||
end
|
||||
|
||||
def self.slice_collection_using_each(collection, from, to)
|
||||
segments = []
|
||||
index = 0
|
||||
@@ -58,55 +38,6 @@ module Liquid
|
||||
segments
|
||||
end
|
||||
|
||||
# Arithmetic slicing must not bypass a Range subclass's custom #each.
|
||||
def self.integer_range?(collection)
|
||||
collection.instance_of?(Range) && collection.begin.is_a?(Integer) && collection.end.is_a?(Integer)
|
||||
end
|
||||
private_class_method :integer_range?
|
||||
|
||||
# Preserve support for Ruby-supplied string ranges and custom Range#each.
|
||||
# Their selected length cannot be inferred from integer bounds, but the tags
|
||||
# need it before rendering for loop metadata, continuation offsets, and columns.
|
||||
# Buffer the selection so we do not have to replay a potentially custom iterator.
|
||||
def self.slice_range_using_each(collection, from, to, resource_limits, allow_endless:)
|
||||
# TableRow historically accepted an endless subclass when its custom #each
|
||||
# was finite, while For historically raised through Range#to_a.
|
||||
if collection.end.nil? && !(allow_endless && (!to.nil? || range_method_overridden?(collection, :each)))
|
||||
raise RangeError, "cannot convert endless range to an array"
|
||||
end
|
||||
if collection.begin.nil? && !range_method_overridden?(collection, :each)
|
||||
raise TypeError, "can't iterate from NilClass"
|
||||
end
|
||||
|
||||
slice_collection_for_iteration_using_each(collection, from, to, resource_limits)
|
||||
end
|
||||
private_class_method :slice_range_using_each
|
||||
|
||||
# Custom Range#each can make a nominally beginless range finite; standard
|
||||
# beginless ranges were rejected before reaching this budgeted traversal.
|
||||
def self.slice_collection_for_iteration_using_each(collection, from, to, resource_limits)
|
||||
return [] if to && to <= from
|
||||
|
||||
segments = []
|
||||
index = 0
|
||||
collection.each do |item|
|
||||
break if to && to <= index
|
||||
|
||||
# Charge preparation, including skipped offsets, before buffering; checking
|
||||
# only while rendering the buffered values would leave this work unbudgeted.
|
||||
resource_limits.increment_render_score(1)
|
||||
segments << item if from <= index
|
||||
index += 1
|
||||
end
|
||||
segments
|
||||
end
|
||||
private_class_method :slice_collection_for_iteration_using_each
|
||||
|
||||
def self.range_method_overridden?(collection, method_name)
|
||||
collection.method(method_name).owner != Range.instance_method(method_name).owner
|
||||
end
|
||||
private_class_method :range_method_overridden?
|
||||
|
||||
def self.to_integer(num)
|
||||
return num if num.is_a?(Integer)
|
||||
num = num.to_s
|
||||
|
||||
@@ -109,8 +109,14 @@ module Liquid
|
||||
end
|
||||
|
||||
def render_to_output_buffer(context, output)
|
||||
if (replayer = context.registers[TemplateRecorder::REPLAYER_REGISTER_KEY])
|
||||
return output << replayer.replay_variable
|
||||
end
|
||||
|
||||
output_start = output.length
|
||||
obj = render(context)
|
||||
render_obj_to_output(obj, output)
|
||||
TemplateRecorder.current&.emit_variable_output(output[output_start..])
|
||||
output
|
||||
end
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
VERSION = "5.14.0"
|
||||
VERSION = "5.13.0"
|
||||
end
|
||||
|
||||
@@ -265,13 +265,12 @@ class ErrorHandlingTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_bug_compatible_silencing_of_errors_in_blank_nodes
|
||||
with_error_modes(:lax, :strict) do
|
||||
output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}not blank{% assign x = 3 %}{% endif %}{{ x }}").render
|
||||
assert_equal("Liquid error: comparison of Integer with String failed0", output)
|
||||
output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}not blank{% assign x = 3 %}{% endif %}{{ x }}").render
|
||||
assert_equal("Liquid error: comparison of Integer with String failed0", output)
|
||||
|
||||
output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}{% assign x = 3 %}{% endif %}{{ x }}").render
|
||||
assert_equal("0", output)
|
||||
end
|
||||
output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}{% assign x = 3 %}{% endif %}{{ x }}").render
|
||||
expected = ENV["LIQUID_PARSER_MODE"] == "strict2" ? "Liquid error: comparison of Integer with String failed0" : "0"
|
||||
assert_equal(expected, output)
|
||||
end
|
||||
|
||||
def test_syntax_error_is_raised_with_template_name
|
||||
|
||||
@@ -61,15 +61,12 @@ class SecurityTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_does_not_add_drop_methods_to_symbol_table
|
||||
assigns = { 'drop' => Drop.new }
|
||||
method_names = Array.new(3) { |index| "untrusted_drop_method_#{object_id}_#{index}" }
|
||||
|
||||
method_names.each do |method_name|
|
||||
assert_equal("", Template.parse("{{ drop.#{method_name} }}").render!(assigns))
|
||||
assert_no_new_symbols do
|
||||
assigns = { 'drop' => Drop.new }
|
||||
assert_equal("", Template.parse("{{ drop.custom_method_1 }}", assigns).render!)
|
||||
assert_equal("", Template.parse("{{ drop.custom_method_2 }}", assigns).render!)
|
||||
assert_equal("", Template.parse("{{ drop.custom_method_3 }}", assigns).render!)
|
||||
end
|
||||
|
||||
# JITs can intern internal metadata; untrusted Drop method names must not be interned.
|
||||
assert_equal([], Symbol.all_symbols.map(&:to_s) & method_names)
|
||||
end
|
||||
|
||||
def assert_no_new_symbols
|
||||
|
||||
@@ -465,85 +465,4 @@ HERE
|
||||
|
||||
assert(context.registers[:for_stack].empty?)
|
||||
end
|
||||
|
||||
def test_integer_range_is_not_materialized_and_charges_empty_iterations
|
||||
range = bounded_integer_range_with_tripwires
|
||||
template = Template.parse('{% for i in numbers %}{% endfor %}')
|
||||
template.resource_limits.render_score_limit = 3
|
||||
|
||||
assert_raises(Liquid::MemoryError) { template.render!('numbers' => range) }
|
||||
|
||||
assert(template.resource_limits.reached?)
|
||||
assert_equal(4, template.resource_limits.render_score)
|
||||
end
|
||||
|
||||
def test_integer_range_uses_arithmetic_offsets_reversal_and_metadata
|
||||
range = bounded_integer_range_with_tripwires
|
||||
template = Template.parse(
|
||||
'{% for i in numbers reversed offset:997 limit:2 %}{{ forloop.length }}:{{ i }}{% endfor %}',
|
||||
)
|
||||
template.resource_limits.render_score_limit = 10
|
||||
|
||||
assert_equal('2:9992:998', template.render!('numbers' => range))
|
||||
end
|
||||
|
||||
def test_range_scores_cannot_be_bypassed_by_repeated_renders
|
||||
template = Template.parse('{% for i in (1..2) %}{% endfor %}')
|
||||
template.resource_limits.cumulative_render_score_limit = 3
|
||||
assert_equal('', template.render!)
|
||||
assert_raises(Liquid::MemoryError) { template.render! }
|
||||
end
|
||||
|
||||
def test_range_break_charges_only_visited_items_and_preserves_full_metadata
|
||||
range = bounded_integer_range_with_tripwires
|
||||
template = Template.parse(
|
||||
'{% for i in numbers reversed %}{{ forloop.length }}:{{ i }}{% break %}{% endfor %}',
|
||||
)
|
||||
template.resource_limits.render_score_limit = 10
|
||||
|
||||
assert_equal('1000:1000', template.render!('numbers' => range))
|
||||
assert_operator(template.resource_limits.render_score, :<=, 10)
|
||||
end
|
||||
|
||||
def test_range_subclass_uses_its_custom_each
|
||||
range = Class.new(Range) do
|
||||
def each
|
||||
yield 10
|
||||
yield 20
|
||||
end
|
||||
end.new(nil, 3)
|
||||
|
||||
assert_template_result('1020', '{% for i in numbers %}{{ i }}{% endfor %}', { 'numbers' => range })
|
||||
end
|
||||
|
||||
def test_range_subclass_custom_to_a_is_honored_for_finite_and_open_bounds
|
||||
range_class = Class.new(Range) do
|
||||
def to_a
|
||||
[42]
|
||||
end
|
||||
end
|
||||
|
||||
[range_class.new(1, 3), range_class.new(nil, 3), range_class.new(1, nil)].each do |range|
|
||||
assert_template_result('42', '{% for i in numbers %}{{ i }}{% endfor %}', { 'numbers' => range })
|
||||
end
|
||||
end
|
||||
|
||||
def test_endless_range_remains_unsupported_with_a_limit
|
||||
template = Template.parse('{% for i in numbers limit:2 %}{{ i }}{% endfor %}')
|
||||
|
||||
assert_raises(RangeError) { template.render!('numbers' => (1..)) }
|
||||
end
|
||||
|
||||
def test_endless_range_subclass_with_custom_each_remains_unsupported
|
||||
range = Class.new(Range) do
|
||||
def each
|
||||
yield 10
|
||||
yield 20
|
||||
end
|
||||
end.new(1, nil)
|
||||
|
||||
assert_raises(RangeError) do
|
||||
Template.parse('{% for i in numbers %}{{ i }}{% endfor %}').render!('numbers' => range)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -465,94 +465,4 @@ class TableRowTest < Minitest::Test
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_integer_range_is_not_materialized_and_charges_empty_iterations
|
||||
range = bounded_integer_range_with_tripwires
|
||||
template = Template.parse('{% tablerow i in numbers %}{% endtablerow %}')
|
||||
template.resource_limits.render_score_limit = 3
|
||||
|
||||
assert_raises(Liquid::MemoryError) { template.render!('numbers' => range) }
|
||||
|
||||
assert(template.resource_limits.reached?)
|
||||
assert_equal(4, template.resource_limits.render_score)
|
||||
end
|
||||
|
||||
def test_integer_range_uses_arithmetic_offsets_limit_and_full_metadata
|
||||
range = bounded_integer_range_with_tripwires
|
||||
template = Template.parse(
|
||||
'{% tablerow i in numbers offset:997 limit:2 %}{{ tablerowloop.length }}:{{ tablerowloop.index }}:{{ i }}{% endtablerow %}',
|
||||
)
|
||||
|
||||
assert_equal(
|
||||
"<tr class=\"row1\">\n<td class=\"col1\">2:1:998</td><td class=\"col2\">2:2:999</td></tr>\n",
|
||||
template.render!('numbers' => range),
|
||||
)
|
||||
end
|
||||
|
||||
def test_tablerow_checks_generated_output_during_empty_body_iteration
|
||||
template = Template.parse('{% tablerow i in (1..100) %}{% endtablerow %}')
|
||||
template.resource_limits.render_length_limit = 40
|
||||
checked_lengths = []
|
||||
limits = template.resource_limits
|
||||
original_increment_write_score = limits.method(:increment_write_score)
|
||||
limits.define_singleton_method(:increment_write_score) do |output|
|
||||
checked_lengths << output.bytesize
|
||||
original_increment_write_score.call(output)
|
||||
end
|
||||
|
||||
assert_equal('Liquid error: Memory limits exceeded', template.render)
|
||||
assert(template.resource_limits.reached?)
|
||||
assert_operator(checked_lengths.last, :<, 1000)
|
||||
end
|
||||
|
||||
def test_tablerow_range_scores_persist_across_renders
|
||||
template = Template.parse('{% tablerow i in (1..2) %}{% endtablerow %}')
|
||||
template.resource_limits.cumulative_render_score_limit = 3
|
||||
template.render!
|
||||
assert_raises(Liquid::MemoryError) { template.render! }
|
||||
end
|
||||
|
||||
def test_range_subclass_uses_its_custom_each_with_beginless_bounds
|
||||
range = Class.new(Range) do
|
||||
def each
|
||||
yield 10
|
||||
yield 20
|
||||
end
|
||||
end.new(nil, 3)
|
||||
|
||||
assert_template_result(
|
||||
"<tr class=\"row1\">\n<td class=\"col1\">10</td><td class=\"col2\">20</td></tr>\n",
|
||||
'{% tablerow i in numbers %}{{ i }}{% endtablerow %}',
|
||||
{ 'numbers' => range },
|
||||
)
|
||||
end
|
||||
|
||||
def test_endless_range_subclass_uses_its_custom_each_without_a_limit
|
||||
range = Class.new(Range) do
|
||||
def each
|
||||
yield 10
|
||||
yield 20
|
||||
end
|
||||
end.new(1, nil)
|
||||
|
||||
assert_template_result(
|
||||
"<tr class=\"row1\">\n<td class=\"col1\">10</td><td class=\"col2\">20</td></tr>\n",
|
||||
'{% tablerow i in numbers %}{{ i }}{% endtablerow %}',
|
||||
{ 'numbers' => range },
|
||||
)
|
||||
end
|
||||
|
||||
def test_range_subclass_custom_to_a_is_not_used
|
||||
range = Class.new(Range) do
|
||||
def to_a
|
||||
[42]
|
||||
end
|
||||
end.new(1, 3)
|
||||
|
||||
assert_template_result(
|
||||
"<tr class=\"row1\">\n<td class=\"col1\">1</td><td class=\"col2\">2</td><td class=\"col3\">3</td></tr>\n",
|
||||
'{% tablerow i in numbers %}{{ i }}{% endtablerow %}',
|
||||
{ 'numbers' => range },
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../test_helper"
|
||||
require "tmpdir"
|
||||
|
||||
class TemplateRecorderTest < Minitest::Test
|
||||
class ProductDrop < Liquid::Drop
|
||||
def initialize(title, secret)
|
||||
super()
|
||||
@title = title
|
||||
@secret = secret
|
||||
end
|
||||
|
||||
attr_reader :title
|
||||
|
||||
def details
|
||||
DetailsDrop.new
|
||||
end
|
||||
end
|
||||
|
||||
class DetailsDrop < Liquid::Drop
|
||||
def count
|
||||
3
|
||||
end
|
||||
end
|
||||
|
||||
class LegacyFileSystem
|
||||
attr_reader :reads
|
||||
|
||||
def initialize
|
||||
@reads = []
|
||||
end
|
||||
|
||||
def read_template_file(name)
|
||||
@reads << name
|
||||
"partial={{ product.title }}"
|
||||
end
|
||||
end
|
||||
|
||||
class UnsupportedLiquidValue
|
||||
def to_liquid
|
||||
self
|
||||
end
|
||||
|
||||
def to_s
|
||||
"unsupported"
|
||||
end
|
||||
end
|
||||
|
||||
class WrapperTag < Liquid::Block
|
||||
end
|
||||
|
||||
class MarkerTag < Liquid::Tag
|
||||
def render(_context)
|
||||
"custom"
|
||||
end
|
||||
end
|
||||
|
||||
class CollectingWriter
|
||||
attr_reader :records
|
||||
|
||||
def initialize
|
||||
@records = []
|
||||
end
|
||||
|
||||
def write(record)
|
||||
@records << record
|
||||
end
|
||||
end
|
||||
|
||||
def setup
|
||||
@directory = Dir.mktmpdir
|
||||
end
|
||||
|
||||
def teardown
|
||||
FileUtils.remove_entry(@directory)
|
||||
end
|
||||
|
||||
def path(name = "recording.json")
|
||||
File.join(@directory, name)
|
||||
end
|
||||
|
||||
def test_records_and_verifies_a_render_without_changing_drop_behavior
|
||||
product = ProductDrop.new("Computed", "must not be recorded")
|
||||
template_source = "{{ product.title }} ({{ product.details.count }})"
|
||||
|
||||
output = Liquid::TemplateRecorder.record(path) do
|
||||
Liquid::Template.parse(template_source).render!("product" => product)
|
||||
end
|
||||
|
||||
assert_equal("Computed (3)", output)
|
||||
record = Liquid::TemplateRecorder.records(path).first
|
||||
assert_equal({ "title" => "Computed", "details" => { "count" => 3 } }, record.dig("assigns", "product"))
|
||||
refute_includes(File.read(path), "must not be recorded")
|
||||
assert_equal(output, Liquid::TemplateRecorder.replay_from(path, mode: :verify).render)
|
||||
end
|
||||
|
||||
def test_preserves_the_legacy_one_argument_file_system_contract
|
||||
file_system = LegacyFileSystem.new
|
||||
environment = Liquid::Environment.build { |env| env.file_system = file_system }
|
||||
|
||||
output = Liquid::TemplateRecorder.record(path) do
|
||||
Liquid::Template.parse("before {% include 'card' %}", environment: environment)
|
||||
.render!("product" => ProductDrop.new("Hat", "secret"))
|
||||
end
|
||||
|
||||
assert_equal("before partial=Hat", output)
|
||||
assert_equal(["card"], file_system.reads)
|
||||
record = Liquid::TemplateRecorder.records(path).first
|
||||
assert_equal({ "card" => "partial={{ product.title }}" }, record["file_system"])
|
||||
assert_equal(2, record["templates"].length)
|
||||
assert_equal(output, Liquid::TemplateRecorder.replay_from(path, mode: :verify).render)
|
||||
end
|
||||
|
||||
def test_jsonl_appends_one_self_contained_record_per_render
|
||||
recording = path("renders.jsonl")
|
||||
|
||||
2.times do |sequence|
|
||||
Liquid::TemplateRecorder.record(recording) do
|
||||
Liquid::Template.parse("value={{ value }}").render!("value" => sequence)
|
||||
end
|
||||
end
|
||||
|
||||
assert_equal(2, File.readlines(recording).length)
|
||||
assert_equal(["value=0", "value=1"], Liquid::TemplateRecorder.records(recording).map { |item| item["output"] })
|
||||
assert_equal("value=0", Liquid::TemplateRecorder.replay_from(recording, index: 0).render)
|
||||
assert_equal("value=1", Liquid::TemplateRecorder.replay_from(recording).render)
|
||||
end
|
||||
|
||||
def test_json_session_supports_multiple_renders
|
||||
Liquid::TemplateRecorder.record(path) do
|
||||
Liquid::Template.parse("one={{ value }}").render!("value" => 1)
|
||||
Liquid::Template.new.parse("two={{ value }}").render!("value" => 2)
|
||||
end
|
||||
|
||||
records = Liquid::TemplateRecorder.records(path)
|
||||
assert_equal(["one=1", "two=2"], records.map { |item| item["output"] })
|
||||
assert_equal("two=2", Liquid::TemplateRecorder.replay_from(path).render)
|
||||
end
|
||||
|
||||
def test_failed_recording_does_not_delete_an_existing_json_file
|
||||
File.write(path, "existing")
|
||||
|
||||
assert_raises(RuntimeError) do
|
||||
Liquid::TemplateRecorder.record(path) { raise "boom" }
|
||||
end
|
||||
|
||||
assert_equal("existing", File.read(path))
|
||||
end
|
||||
|
||||
def test_render_failure_is_not_written_to_jsonl
|
||||
recording = path("renders.jsonl")
|
||||
|
||||
assert_raises(Liquid::UndefinedVariable) do
|
||||
Liquid::TemplateRecorder.record(recording) do
|
||||
Liquid::Template.parse("{{ missing }}").render!(nil, strict_variables: true)
|
||||
end
|
||||
end
|
||||
|
||||
refute_path_exists(recording)
|
||||
end
|
||||
|
||||
def test_tampered_template_is_rejected
|
||||
Liquid::TemplateRecorder.record(path) { Liquid::Template.parse("safe").render! }
|
||||
session = JSON.parse(File.read(path))
|
||||
session["renders"][0]["template"]["source"] = "changed"
|
||||
File.write(path, JSON.generate(session))
|
||||
|
||||
error = assert_raises(Liquid::TemplateRecorder::ReplayError) do
|
||||
Liquid::TemplateRecorder.replay_from(path)
|
||||
end
|
||||
assert_match(/checksum/, error.message)
|
||||
end
|
||||
|
||||
def test_recordings_are_thread_local
|
||||
paths = [path("a.json"), path("b.json")]
|
||||
ready = Queue.new
|
||||
release = Queue.new
|
||||
threads = Array.new(2) do |index|
|
||||
Thread.new do
|
||||
Liquid::TemplateRecorder.record(paths[index]) do
|
||||
ready << true
|
||||
release.pop
|
||||
Liquid::Template.parse("thread={{ value }}").render!("value" => index)
|
||||
end
|
||||
end
|
||||
end
|
||||
2.times { ready.pop }
|
||||
2.times { release << true }
|
||||
threads.each(&:join)
|
||||
|
||||
assert_equal("thread=0", Liquid::TemplateRecorder.records(paths[0]).first["output"])
|
||||
assert_equal("thread=1", Liquid::TemplateRecorder.records(paths[1]).first["output"])
|
||||
end
|
||||
|
||||
def test_nested_sessions_fail_without_corrupting_outer_session
|
||||
error = nil
|
||||
Liquid::TemplateRecorder.record(path) do
|
||||
error = assert_raises(Liquid::TemplateRecorder::Error) do
|
||||
Liquid::TemplateRecorder.record(path("inner.json")) { flunk }
|
||||
end
|
||||
Liquid::Template.parse("outer").render!
|
||||
end
|
||||
|
||||
assert_match(/nested/, error.message)
|
||||
assert_equal("outer", Liquid::TemplateRecorder.replay_from(path).render)
|
||||
end
|
||||
|
||||
def test_supported_render_argument_forms_keep_working
|
||||
filter = Module.new do
|
||||
def decorate(input)
|
||||
"[#{input}]"
|
||||
end
|
||||
end
|
||||
template = nil
|
||||
context = Liquid::Context.new([{ "value" => "context" }])
|
||||
|
||||
Liquid::TemplateRecorder.record(path) do
|
||||
template = Liquid::Template.parse("{{ value | decorate }}")
|
||||
assert_equal("[hash]", template.render({ "value" => "hash" }, filter))
|
||||
context.add_filters(filter)
|
||||
assert_equal("[context]", template.render(context))
|
||||
end
|
||||
|
||||
assert_equal(2, Liquid::TemplateRecorder.records(path).length)
|
||||
end
|
||||
|
||||
def test_strict_replay_uses_exact_recorded_filter_outputs
|
||||
filter = Module.new do
|
||||
def external_lookup(_input)
|
||||
"x" * 150
|
||||
end
|
||||
end
|
||||
|
||||
output = Liquid::TemplateRecorder.record(path) do
|
||||
Liquid::Template.parse("{{ key | external_lookup }}").render!({ "key" => "a" }, filter)
|
||||
end
|
||||
|
||||
assert_equal("x" * 150, output)
|
||||
assert_equal(output, Liquid::TemplateRecorder.replay_from(path, mode: :strict).render)
|
||||
assert_equal("a", Liquid::TemplateRecorder.replay_from(path, mode: :compute).render)
|
||||
end
|
||||
|
||||
def test_jsonl_reader_ignores_only_a_truncated_final_record
|
||||
recording = path("renders.jsonl")
|
||||
Liquid::TemplateRecorder.record(recording) { Liquid::Template.parse("complete").render! }
|
||||
File.open(recording, "ab") { |file| file.write('{"format":') }
|
||||
|
||||
assert_equal(["complete"], Liquid::TemplateRecorder.records(recording).map { |item| item["output"] })
|
||||
end
|
||||
|
||||
def test_accepts_a_pluggable_writer
|
||||
writer = CollectingWriter.new
|
||||
|
||||
output = Liquid::TemplateRecorder.record(writer) do
|
||||
Liquid::Template.parse("Hello {{ name }}").render!("name" => "Shopify")
|
||||
end
|
||||
|
||||
assert_equal("Hello Shopify", output)
|
||||
assert_equal(["Hello Shopify"], writer.records.map { |record| record["output"] })
|
||||
end
|
||||
|
||||
def test_records_only_variables_resolved_by_the_template
|
||||
unused = Object.new
|
||||
assigns = { "visible" => "yes", "unused" => unused }
|
||||
|
||||
Liquid::TemplateRecorder.record(path) do
|
||||
Liquid::Template.parse("{{ visible }}").render!(assigns)
|
||||
end
|
||||
|
||||
assert_equal({ "visible" => "yes" }, Liquid::TemplateRecorder.records(path).first["assigns"])
|
||||
end
|
||||
|
||||
def test_recording_scope_is_fiber_local
|
||||
writer = CollectingWriter.new
|
||||
ordinary_output = nil
|
||||
|
||||
Liquid::TemplateRecorder.record(writer) do
|
||||
Fiber.new do
|
||||
ordinary_output = Liquid::Template.parse("ordinary").render!
|
||||
end.resume
|
||||
Liquid::Template.parse("recorded").render!
|
||||
end
|
||||
|
||||
assert_equal("ordinary", ordinary_output)
|
||||
assert_equal(["recorded"], writer.records.map { |record| record["output"] })
|
||||
end
|
||||
|
||||
def test_on_error_keeps_recording_failures_out_of_the_render_path
|
||||
writer = Object.new
|
||||
writer.define_singleton_method(:write) { |_record| raise "sink unavailable" }
|
||||
errors = []
|
||||
|
||||
output = Liquid::TemplateRecorder.record(writer, on_error: errors.method(:<<)) do
|
||||
Liquid::Template.parse("still rendered").render!
|
||||
end
|
||||
|
||||
assert_equal("still rendered", output)
|
||||
assert_equal(["sink unavailable"], errors.map(&:message))
|
||||
end
|
||||
|
||||
def test_unsupported_accessed_values_do_not_affect_the_render
|
||||
value = UnsupportedLiquidValue.new
|
||||
writer = CollectingWriter.new
|
||||
|
||||
output = Liquid::TemplateRecorder.record(writer) do
|
||||
Liquid::Template.parse("{{ value }}").render!("value" => value)
|
||||
end
|
||||
|
||||
assert_equal(value.to_s, output)
|
||||
assert_equal({}, writer.records.first["assigns"])
|
||||
end
|
||||
|
||||
def test_strict_replay_accepts_the_application_environment
|
||||
environment = Liquid::Environment.build(
|
||||
tags: Liquid::Environment.default.tags.merge("marker" => MarkerTag),
|
||||
)
|
||||
writer = CollectingWriter.new
|
||||
Liquid::TemplateRecorder.record(writer) do
|
||||
Liquid::Template.parse("{% marker %}", environment: environment).render!
|
||||
end
|
||||
|
||||
replay = Liquid::TemplateRecorder::Replayer.new(
|
||||
writer.records.first,
|
||||
mode: :strict,
|
||||
environment: environment,
|
||||
)
|
||||
|
||||
assert_equal("custom", replay.render)
|
||||
end
|
||||
|
||||
def test_captures_file_reads_that_happen_before_the_template_starts_rendering
|
||||
writer = CollectingWriter.new
|
||||
Liquid::TemplateRecorder.record(writer) do
|
||||
Liquid::TemplateRecorder.current.emit_file_read("card", "Card")
|
||||
file_system = LegacyFileSystem.new
|
||||
Liquid::Template.parse("{% render 'card' %}").render!({}, registers: { file_system: file_system })
|
||||
end
|
||||
|
||||
replay = Liquid::TemplateRecorder::Replayer.new(writer.records.first, mode: :strict)
|
||||
|
||||
assert_equal("partial=", replay.render)
|
||||
end
|
||||
|
||||
def test_strict_replay_skips_nested_custom_tag_calls
|
||||
environment = Liquid::Environment.build(
|
||||
tags: Liquid::Environment.default.tags.merge("wrapper" => WrapperTag, "marker" => MarkerTag),
|
||||
)
|
||||
writer = CollectingWriter.new
|
||||
Liquid::TemplateRecorder.record(writer) do
|
||||
Liquid::Template.parse("{% wrapper %}{% marker %}{% endwrapper %}", environment: environment).render!
|
||||
end
|
||||
|
||||
record = writer.records.first
|
||||
replay = Liquid::TemplateRecorder::Replayer.new(record, mode: :strict, environment: environment)
|
||||
|
||||
assert_equal(["wrapper"], record["tag_calls"].map { |call| call["name"] })
|
||||
assert_equal("custom", replay.render)
|
||||
end
|
||||
end
|
||||
@@ -132,7 +132,7 @@ class TemplateTest < Minitest::Test
|
||||
assert_equal("Liquid error: Memory limits exceeded", t.render)
|
||||
assert(t.resource_limits.reached?)
|
||||
|
||||
t.resource_limits.render_score_limit = 201
|
||||
t.resource_limits.render_score_limit = 200
|
||||
assert_equal(" foo " * 100, t.render!)
|
||||
refute_nil(t.resource_limits.render_score)
|
||||
end
|
||||
|
||||
@@ -32,15 +32,6 @@ module Minitest
|
||||
module Assertions
|
||||
include Liquid
|
||||
|
||||
# Exact Range fixture for fast-path tests; singleton tripwires must remain
|
||||
# untouched because the arithmetic path does not materialize or traverse it.
|
||||
def bounded_integer_range_with_tripwires
|
||||
range = (1..1000).dup
|
||||
range.define_singleton_method(:to_a) { raise 'range was materialized' }
|
||||
range.define_singleton_method(:each) { raise 'range was traversed' }
|
||||
range
|
||||
end
|
||||
|
||||
def assert_template_result(
|
||||
expected, template, assigns = {},
|
||||
message: nil, partials: nil, error_mode: Liquid::Environment.default.error_mode, render_errors: false,
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class RangeSliceUnitTest < Minitest::Test
|
||||
def test_selects_and_reverses_an_integer_range_without_enumerating_it
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
slice = Liquid::RangeSlice.new(bounded_integer_range_with_tripwires, 997, 999, limits)
|
||||
|
||||
assert_equal(2, slice.length)
|
||||
refute(slice.empty?)
|
||||
slice.reverse!
|
||||
assert_equal([999, 998], slice.each.to_a)
|
||||
assert_equal(2, limits.render_score)
|
||||
end
|
||||
|
||||
def test_charges_only_values_yielded_before_a_break
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
slice = Liquid::RangeSlice.new(bounded_integer_range_with_tripwires, 0, nil, limits)
|
||||
|
||||
slice.each { break }
|
||||
|
||||
assert_equal(1, limits.render_score)
|
||||
end
|
||||
|
||||
def test_non_integer_ranges_are_sliced_without_to_a_and_charge_visited_values
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
range = Class.new(Range) do
|
||||
def to_a
|
||||
raise 'range was materialized'
|
||||
end
|
||||
end.new('a', 'c')
|
||||
|
||||
assert_equal(['b', 'c'], Liquid::Utils.slice_collection_for_iteration(range, 1, nil, limits))
|
||||
assert_equal(3, limits.render_score)
|
||||
|
||||
limited = Liquid::ResourceLimits.new(render_score_limit: 2)
|
||||
assert_raises(Liquid::MemoryError) do
|
||||
Liquid::Utils.slice_collection_for_iteration('a'..'z', 10, 11, limited)
|
||||
end
|
||||
end
|
||||
|
||||
def test_non_integer_range_empty_windows_do_not_visit_a_sentinel_value
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
|
||||
assert_equal([], Liquid::Utils.slice_collection_for_iteration('a'..'z', 2, 2, limits))
|
||||
assert_equal(0, limits.render_score)
|
||||
assert_equal(['a', 'b'], Liquid::Utils.slice_collection_for_iteration('a'..'z', 0, 2, limits))
|
||||
assert_equal(2, limits.render_score)
|
||||
end
|
||||
|
||||
def test_standard_beginless_range_raises_for_empty_windows
|
||||
[[0, 0], [1, 0]].each do |from, to|
|
||||
assert_raises(TypeError) do
|
||||
Liquid::Utils.slice_collection_for_iteration(Range.new(nil, 3), from, to, Liquid::ResourceLimits.new({}))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def test_custom_range_to_a_is_sliced_with_a_budget
|
||||
range = Class.new(Range) do
|
||||
def to_a
|
||||
[1, 2, 3]
|
||||
end
|
||||
end.new(nil, 3)
|
||||
limits = Liquid::ResourceLimits.new(render_score_limit: 2)
|
||||
|
||||
assert_raises(Liquid::MemoryError) do
|
||||
Liquid::Utils.slice_collection_for_iteration(range, 1, 3, limits, use_range_to_a: true)
|
||||
end
|
||||
assert_equal(3, limits.render_score)
|
||||
end
|
||||
|
||||
def test_preserves_slice_bounds_for_negative_offsets_and_limits
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
|
||||
assert_equal([1, 2], Liquid::RangeSlice.new(1..5, -2, 2, limits).each.to_a)
|
||||
empty = Liquid::RangeSlice.new(1..5, 2, 1, limits)
|
||||
assert(empty.empty?)
|
||||
assert_equal([], empty.each.to_a)
|
||||
assert_equal(2, limits.render_score) # the empty window performs no work
|
||||
assert(Liquid::RangeSlice.new(5..1, 0, nil, limits).empty?)
|
||||
assert_equal([1, 2, 3, 4], Liquid::RangeSlice.new(1...5, 0, nil, limits).each.to_a)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user