mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-12 23:40:45 -07:00
Add hermetic template recording and replay
This commit is contained in:
@@ -14,60 +14,60 @@ class BlankBodyErrorHandlingTest < Minitest::Test
|
||||
error = assert_raises(Liquid::ArgumentError) do
|
||||
Liquid::Template.parse(source, line_numbers: true, error_mode: error_mode).render!(assigns)
|
||||
end
|
||||
assert_includes error.message, message if message
|
||||
assert_includes(error.message, message) if message
|
||||
end
|
||||
|
||||
def test_blank_if_body_suppresses_inline_error_text_in_lax_and_strict
|
||||
[:lax, :strict].each do |mode|
|
||||
assert_equal '', render_inline('{% if 5 > "x" %}{% endif %}', error_mode: mode)
|
||||
assert_equal('', render_inline('{% if 5 > "x" %}{% endif %}', error_mode: mode))
|
||||
end
|
||||
end
|
||||
|
||||
def test_blank_unless_body_suppresses_inline_error_text_in_lax_and_strict
|
||||
[:lax, :strict].each do |mode|
|
||||
assert_equal '', render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: mode)
|
||||
assert_equal('', render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: mode))
|
||||
end
|
||||
end
|
||||
|
||||
def test_blank_for_body_suppresses_inline_error_text_in_lax_and_strict
|
||||
[:lax, :strict].each do |mode|
|
||||
assert_equal '', render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: mode, assigns: { 'xs' => 'bad' })
|
||||
assert_equal('', render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: mode, assigns: { 'xs' => 'bad' }))
|
||||
end
|
||||
end
|
||||
|
||||
def test_strict2_blank_if_body_shows_inline_error_text
|
||||
assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% endif %}', error_mode: :strict2)
|
||||
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% endif %}', error_mode: :strict2))
|
||||
end
|
||||
|
||||
def test_strict2_whitespace_if_body_shows_inline_error_text
|
||||
assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %} {% endif %}', error_mode: :strict2)
|
||||
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %} {% endif %}', error_mode: :strict2))
|
||||
end
|
||||
|
||||
def test_strict2_assign_if_body_shows_inline_error_text
|
||||
assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% assign a = 1 %}{% endif %}', error_mode: :strict2)
|
||||
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% assign a = 1 %}{% endif %}', error_mode: :strict2))
|
||||
end
|
||||
|
||||
def test_strict2_comment_if_body_shows_inline_error_text
|
||||
assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% comment %}c{% endcomment %}{% endif %}', error_mode: :strict2)
|
||||
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% comment %}c{% endcomment %}{% endif %}', error_mode: :strict2))
|
||||
end
|
||||
|
||||
def test_strict2_capture_if_body_shows_inline_error_text
|
||||
assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% capture c %}text{% endcapture %}{% endif %}', error_mode: :strict2)
|
||||
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% capture c %}text{% endcapture %}{% endif %}', error_mode: :strict2))
|
||||
end
|
||||
|
||||
def test_strict2_blank_unless_body_shows_inline_error_text
|
||||
assert_equal COMPARISON_ERROR, render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: :strict2)
|
||||
assert_equal(COMPARISON_ERROR, render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: :strict2))
|
||||
end
|
||||
|
||||
def test_strict2_blank_for_body_shows_inline_error_text
|
||||
assert_equal INVALID_INTEGER_ERROR, render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: :strict2, assigns: { 'xs' => 'bad' })
|
||||
assert_equal(INVALID_INTEGER_ERROR, render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: :strict2, assigns: { 'xs' => 'bad' }))
|
||||
end
|
||||
|
||||
def test_nonblank_bodies_show_inline_error_text_in_all_modes
|
||||
[:lax, :strict, :strict2].each do |mode|
|
||||
assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% echo 1 %}{% endif %}', error_mode: mode)
|
||||
assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{{ "" }}{% endif %}', error_mode: mode)
|
||||
assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% else %}E{% endif %}', error_mode: mode)
|
||||
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% echo 1 %}{% endif %}', error_mode: mode))
|
||||
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{{ "" }}{% endif %}', error_mode: mode))
|
||||
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% else %}E{% endif %}', error_mode: mode))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -269,7 +269,8 @@ class ErrorHandlingTest < Minitest::Test
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user