diff --git a/config/sus.rb b/config/sus.rb index f517380d..354d59b0 100644 --- a/config/sus.rb +++ b/config/sus.rb @@ -5,3 +5,5 @@ require "covered/sus" include Covered::Sus + +ENV["TRACES_BACKEND"] ||= "traces/backend/test" diff --git a/lib/traces/provider/utopia.rb b/lib/traces/provider/utopia.rb new file mode 100644 index 00000000..ef21f7eb --- /dev/null +++ b/lib/traces/provider/utopia.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "utopia/content/middleware" +require_relative "utopia/static/middleware" diff --git a/lib/traces/provider/utopia/content/middleware.rb b/lib/traces/provider/utopia/content/middleware.rb new file mode 100644 index 00000000..e66b2e6d --- /dev/null +++ b/lib/traces/provider/utopia/content/middleware.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "../../../../utopia/content/middleware" + +require "traces/provider" + +Traces::Provider(Utopia::Content::Middleware) do + def respond(link, request, localization: request.localization) + attributes = { + "link.key" => link.key, + "link.href" => link.href, + "link.locale" => localization&.locale, + } + + Traces.trace("utopia.content.middleware.respond", attributes: attributes){super} + end +end diff --git a/lib/traces/provider/utopia/static/middleware.rb b/lib/traces/provider/utopia/static/middleware.rb new file mode 100644 index 00000000..a0b962cf --- /dev/null +++ b/lib/traces/provider/utopia/static/middleware.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "../../../../utopia/static/middleware" + +require "traces/provider" + +Traces::Provider(Utopia::Static::Middleware) do + def respond(request, path, extension, content_type, localization: request.localization) + attributes = { + path: path, + locale: localization&.locale, + } + + Traces.trace("utopia.static.respond", attributes: attributes){super} + end +end diff --git a/lib/utopia/content/middleware.rb b/lib/utopia/content/middleware.rb index 2fee3fb1..e12fb9dd 100644 --- a/lib/utopia/content/middleware.rb +++ b/lib/utopia/content/middleware.rb @@ -16,7 +16,6 @@ require "xrb/template" require "concurrent/map" -require "traces/provider" module Utopia module Content @@ -226,16 +225,5 @@ def relative_tag(name, node) end end - Traces::Provider(Middleware) do - def respond(link, request, localization: request.localization) - attributes = { - "link.key" => link.key, - "link.href" => link.href, - "link.locale" => localization&.locale, - } - - Traces.trace("utopia.content.middleware.respond", attributes: attributes){super} - end - end end end diff --git a/lib/utopia/static/middleware.rb b/lib/utopia/static/middleware.rb index 8eb0e5e6..e18e5470 100644 --- a/lib/utopia/static/middleware.rb +++ b/lib/utopia/static/middleware.rb @@ -11,8 +11,6 @@ require_relative "mime_types" require_relative "../localization/resolver" -require "traces/provider" - module Utopia module Static DEFAULT_CACHE_CONTROL = "public, max-age=3600".freeze @@ -138,15 +136,5 @@ def call(request) end end - Traces::Provider(Static) do - def respond(request, path, extension, content_type, localization: request.localization) - attributes = { - path: path, - locale: localization&.locale, - } - - Traces.trace("utopia.static.respond", attributes: attributes){super} - end - end end end diff --git a/test/traces/provider/utopia/content/middleware.rb b/test/traces/provider/utopia/content/middleware.rb new file mode 100644 index 00000000..8d52fced --- /dev/null +++ b/test/traces/provider/utopia/content/middleware.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "traces/provider/utopia" + +require "utopia/content/link" +require "utopia/localization/preferences" +require "utopia/request" + +describe Utopia::Content::Middleware do + let(:middleware) do + subject.new( + Protocol::HTTP::Middleware::NotFound, + root: File.expand_path(".", __dir__), + ) + end + + let(:request) {Utopia::Request["GET", "/example"]} + let(:localization) do + Utopia::Localization::Preferences.new( + all_locales: ["en"], + preferred_locales: ["en"], + default_locale: "en", + ) + end + + it "traces content responses" do + traces = [] + + mock(Traces) do |mock| + mock.wrap(:trace) do |original, name, attributes: nil, &block| + traces << [name, attributes] + original.call(name, attributes: attributes, &block) + end + end + + link = Utopia::Content::Link.new(:virtual, "example", "en", "/example", uri: "/target") + response = middleware.respond(link, request, localization: localization) + + expect(response.status).to be == 307 + expect(traces).to be == [[ + "utopia.content.middleware.respond", + { + "link.key" => "example.en", + "link.href" => "/target", + "link.locale" => "en", + }, + ]] + ensure + response&.close + middleware.close + end +end diff --git a/test/traces/provider/utopia/static/middleware.rb b/test/traces/provider/utopia/static/middleware.rb new file mode 100644 index 00000000..1b6d2226 --- /dev/null +++ b/test/traces/provider/utopia/static/middleware.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "traces/provider/utopia/static/middleware" + +require "utopia/request" + +describe Utopia::Static::Middleware do + let(:root) {File.expand_path("../../../../utopia/.static", __dir__)} + let(:middleware) {subject.new(Protocol::HTTP::Middleware::NotFound, root: root)} + let(:request) {Utopia::Request["GET", "/test.txt"]} + + it "traces static responses" do + traces = [] + + mock(Traces) do |mock| + mock.wrap(:trace) do |original, name, attributes: nil, &block| + traces << [name, attributes] + original.call(name, attributes: attributes, &block) + end + end + + path = request.url.path + response = middleware.respond(request, path, ".txt", "text/plain", localization: nil) + + expect(response.status).to be == 200 + expect(traces).to be == [[ + "utopia.static.respond", + { + path: path, + locale: nil, + }, + ]] + ensure + response&.close + middleware.close + end +end diff --git a/test/utopia/static.rb b/test/utopia/static.rb index f81d5719..bd567de2 100755 --- a/test/utopia/static.rb +++ b/test/utopia/static.rb @@ -29,6 +29,23 @@ expect(cache_control).to be(:frozen?) end + it "computes cache control from the served file" do + served_file = nil + application = Utopia::Application.build do + use Utopia::Static, + root: File.expand_path(".static", __dir__), + cache_control: proc{|file| served_file = file; "private, max-age=#{file.bytesize}"} + end + + response = application.call(Protocol::HTTP::Request["GET", "/test.txt"]) + + expect(response.headers["cache-control"]).to be == ["private", "max-age=12"] + expect(served_file).to be_a(Utopia::Static::LocalFile) + ensure + response&.close + application.close + end + let(:middleware) do root = File.expand_path(".static", __dir__) @@ -263,69 +280,4 @@ end end end - - describe Utopia::Static::LocalFile do - it "uses a consistent metadata snapshot" do - Dir.mktmpdir do |directory| - path = File.join(directory, "test.txt") - File.write(path, "Original") - - file = subject.new(path) - mtime_date = file.mtime_date - etag = file.etag - - File.write(path, "Updated content") - - expect(file.bytesize).to be == 8 - expect(file.mtime_date).to be == mtime_date - expect(file.etag).to be == etag - end - end - - it "includes subsecond modification time in entity tags" do - Dir.mktmpdir do |directory| - path = File.join(directory, "test.txt") - File.write(path, "Content") - - seconds = Time.now.to_i - 1 - first_mtime = Time.at(seconds, 100_000_000, :nanosecond) - second_mtime = Time.at(seconds, 200_000_000, :nanosecond) - - File.utime(first_mtime, first_mtime, path) - first = subject.new(path) - - File.utime(second_mtime, second_mtime, path) - second = subject.new(path) - - expect(first.mtime_date).to be == second.mtime_date - expect(first.etag).not.to be == second.etag - end - end - end - - describe Utopia::Static::MIME_TYPES do - let(:extensions) {Utopia::Static::MimeTypeLoader.extensions_for(subject[:default])} - let(:script_extensions) {Utopia::Static::MimeTypeLoader.extensions_for(subject[:scripts])} - - it "groups script extensions" do - expect(script_extensions).to have_keys( - ".js" => be == "text/javascript", - ".mjs" => be == "text/javascript", - ".wasm" => be == "application/wasm", - ) - end - - it "should give the correct mime type" do - expect(extensions).to have_keys( - ".txt" => be == "text/plain", - ".mjs" => be == "text/javascript", - ".wasm" => be == "application/wasm", - ".webm" => be == "video/webm", - ".weba" => be == "audio/webm", - ".ogg" => be == "audio/vorbis", - ".spx" => be == "audio/speex", - ".html" => be == "text/html", - ) - end - end end diff --git a/test/utopia/static/local_file.rb b/test/utopia/static/local_file.rb new file mode 100644 index 00000000..76de1879 --- /dev/null +++ b/test/utopia/static/local_file.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "tmpdir" + +require "utopia/static/local_file" + +describe Utopia::Static::LocalFile do + it "uses a consistent metadata snapshot" do + Dir.mktmpdir do |directory| + path = File.join(directory, "test.txt") + File.write(path, "Original") + + file = subject.new(path) + mtime_date = file.mtime_date + etag = file.etag + + File.write(path, "Updated content") + + expect(file.bytesize).to be == 8 + expect(file.mtime_date).to be == mtime_date + expect(file.etag).to be == etag + end + end + + it "includes subsecond modification time in entity tags" do + Dir.mktmpdir do |directory| + path = File.join(directory, "test.txt") + File.write(path, "Content") + + seconds = Time.now.to_i - 1 + first_mtime = Time.at(seconds, 100_000_000, :nanosecond) + second_mtime = Time.at(seconds, 200_000_000, :nanosecond) + + File.utime(first_mtime, first_mtime, path) + first = subject.new(path) + + File.utime(second_mtime, second_mtime, path) + second = subject.new(path) + + expect(first.mtime_date).to be == second.mtime_date + expect(first.etag).not.to be == second.etag + end + end + + it "matches strong entity tags for range requests" do + Dir.mktmpdir do |directory| + path = File.join(directory, "test.txt") + File.write(path, "Content") + + file = subject.new(path) + file.instance_variable_set(:@etag, '"strong"') + + expect(file.send(:if_range?, '"strong"')).to be == true + expect(file.send(:if_range?, '"different"')).to be == false + end + end +end diff --git a/test/utopia/static/mime_types.rb b/test/utopia/static/mime_types.rb new file mode 100644 index 00000000..db2a3381 --- /dev/null +++ b/test/utopia/static/mime_types.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "utopia/static/mime_types" + +describe "Utopia::Static::MIME_TYPES" do + let(:mime_types) {Utopia::Static::MIME_TYPES} + let(:extensions) {Utopia::Static::MimeTypeLoader.extensions_for(mime_types[:default])} + let(:script_extensions) {Utopia::Static::MimeTypeLoader.extensions_for(mime_types[:scripts])} + + it "groups script extensions" do + expect(script_extensions).to have_keys( + ".js" => be == "text/javascript", + ".mjs" => be == "text/javascript", + ".wasm" => be == "application/wasm", + ) + end + + it "should give the correct mime type" do + expect(extensions).to have_keys( + ".txt" => be == "text/plain", + ".mjs" => be == "text/javascript", + ".wasm" => be == "application/wasm", + ".webm" => be == "video/webm", + ".weba" => be == "audio/webm", + ".ogg" => be == "audio/vorbis", + ".spx" => be == "audio/speex", + ".html" => be == "text/html", + ) + end +end + +describe Utopia::Static::MimeTypeLoader do + it "expands explicit extension mappings" do + extensions = subject.extensions_for([["example", "application/example"]]) + + expect(extensions).to have_keys( + ".example" => be == "application/example", + ) + end + + it "rejects unknown file extensions" do + expect do + subject.extensions_for(["not-a-real-extension"]) + end.to raise_exception(subject::ExpansionError, message: be =~ /Unknown file extension/) + end + + it "rejects unsupported definitions" do + expect do + subject.extensions_for([Object.new]) + end.to raise_exception(subject::ExpansionError, message: be =~ /Unsupported MIME type definition/) + end + + it "wraps errors while expanding named groups" do + expect do + subject.extensions_for([:missing], {}) + end.to raise_exception(subject::ExpansionError, message: be =~ /Error while processing :missing/) + end +end