Files
potato-mesh/web/spec/asset_versioning_spec.rb
l5y 8ed15f1142 web: initial-load module-graph waterfall (#832)
* web: initial-load module-graph waterfall

* web: prefetch initial API data on cold load (faster first paint)

Second phase of the initial-load fix (after the module-graph preload in
8915f8c). Even with the graph preloaded, the first /api/* fetch waited for the
~806KB bundle to download, parse, and boot. An early <script type="module"
async> boot module (main/boot-prefetch.js) now fires the first-load (since=0)
requests in parallel with the module graph (at priority:'high') and stashes the
in-flight Response promises on window.__PM_BOOT__; refresh() consumes them on its
first cold refresh via a new responsePromise option on the data-fetchers instead
of issuing its own requests.

Cold loads only: a synchronous localStorage marker (pm:cache-present, maintained
by the cache write-back / clear / disable paths) suppresses the prefetch on warm
revisits, leaving the FC2 seed-then-delta path untouched. Message endpoints are
skipped in private mode (data-pm-chat="false"), mirroring the /api/messages 404
(Invariant II / PS6). Pure pre-warm: an absent or rejected prefetch re-fetches,
so it is never load-bearing (FC7).

Read-side only; no API/DB/ingestor change, no new dependency. Adds ACCEPTANCE
EF-A1/EF-A2/EF-R1; 100% line/branch/func coverage on the new module (lcov);
rspec + npm test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:50:33 +02:00

179 lines
6.9 KiB
Ruby

# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
require "spec_helper"
# Acceptance-aligned rendering suite for asset cache-busting (SPEC AV1-AV5,
# ACCEPTANCE AV-A1..AV-A4). Confirms the dashboard emits version-stamped
# JS/CSS URLs (so a deploy busts the browser cache) while leaving images on
# their existing revalidation.
RSpec.describe "Asset cache-busting" do
let(:app) { Sinatra::Application }
# The live, git-derived cache key; assertions interpolate it so the suite is
# independent of the actual tag/commit the specs run against.
let(:version) { PotatoMesh::Application::APP_VERSION }
describe "template-written asset URLs (AV2)" do
before { get "/" }
it "serves the landing page" do
expect(last_response).to be_ok
end
it "version-stamps the base stylesheet" do
expect(last_response.body).to include("/assets/styles/base.css?v=#{version}")
end
it "version-stamps the classic entry scripts" do
expect(last_response.body).to include("/assets/js/theme.js?v=#{version}")
expect(last_response.body).to include("/assets/js/background.js?v=#{version}")
end
it "version-stamps the module entry point" do
expect(last_response.body).to include("/assets/js/app/index.js?v=#{version}")
end
it "never emits those assets unversioned (anchored to the tag attribute)" do
expect(last_response.body).not_to include('href="/assets/styles/base.css"')
expect(last_response.body).not_to include('src="/assets/js/theme.js"')
expect(last_response.body).not_to include('src="/assets/js/background.js"')
expect(last_response.body).not_to include('src="/assets/js/app/index.js"')
end
end
describe "scope boundary: images are NOT versioned (AV4)" do
before { get "/" }
it "leaves the logo and favicons on existing revalidation" do
expect(last_response.body).to include('src="/potatomesh-logo.svg"')
expect(last_response.body).not_to match(%r{/potatomesh-logo\.svg\?v=})
expect(last_response.body).not_to match(%r{/favicon\.[a-z]+\?v=})
end
end
describe "inline ES-module imports (AV2)" do
it "version-stamps the charts page import specifier" do
get "/charts"
expect(last_response).to be_ok
expect(last_response.body).to include("/assets/js/app/charts-page.js?v=#{version}")
end
end
describe "import map for the deep module graph (AV3)" do
before { get "/" }
it "emits exactly one import map" do
expect(last_response.body.scan('<script type="importmap">').size).to eq(1)
end
it "version-stamps a transitively-imported module (main.js)" do
# main.js is imported only via a relative specifier inside index.js and is
# never written in a template, so its versioned map entry proves the whole
# graph is busted — not just the entry points.
expect(last_response.body).to include(
%("/assets/js/app/main.js":"/assets/js/app/main.js?v=#{version}"),
)
end
it "does not leak test files into the map" do
expect(last_response.body).not_to include("__tests__")
end
it "places the import map before the module entry point" do
body = last_response.body
map_at = body.index('<script type="importmap">')
entry_at = body.index('<script type="module" src="/assets/js/app/index.js')
expect(map_at).to be < entry_at
end
end
# Regression: initial-load module-graph waterfall (slow first data paint).
# Without modulepreload hints the browser discovers the 89-module graph one
# import-tier at a time (≈5 serial round trips) before the app can fire its
# first /api fetch, so data does not paint for 2-3s on a real connection. The
# head must preload the whole ES-module graph so it downloads in parallel.
describe "modulepreload for the deep module graph (initial-load latency)" do
before { get "/" }
it "preloads a transitively-imported module so the graph loads in parallel" do
# main.js is reached only through a relative import inside index.js; a
# modulepreload for it proves the whole graph (not just entry points) is
# fetched up-front instead of tier-by-tier.
expect(last_response.body).to include(
%(<link rel="modulepreload" href="/assets/js/app/main.js?v=#{version}">),
)
end
it "preloads the module entry point itself" do
expect(last_response.body).to include(
%(<link rel="modulepreload" href="/assets/js/app/index.js?v=#{version}">),
)
end
it "does not preload the classic (non-module) scripts" do
expect(last_response.body).not_to include(
'<link rel="modulepreload" href="/assets/js/theme.js',
)
expect(last_response.body).not_to include(
'<link rel="modulepreload" href="/assets/js/background.js',
)
end
it "does not leak test files into the preloads" do
preloads = last_response.body.scan(/<link rel="modulepreload"[^>]*>/).join
expect(preloads).not_to include("__tests__")
end
it "places the preloads after the import map and before the module entry" do
body = last_response.body
map_at = body.index('<script type="importmap">')
preload_at = body.index('<link rel="modulepreload"')
entry_at = body.index('<script type="module" src="/assets/js/app/index.js')
expect(map_at).to be < preload_at
expect(preload_at).to be < entry_at
end
end
# The cold-load boot prefetch: an early async module that fires the first-load
# /api requests in parallel with the module graph (initial-load latency fix).
describe "cold-load boot prefetch tag (initial-load latency)" do
before { get "/" }
it "emits a versioned async module boot tag" do
body = last_response.body
expect(body).to match(%r{<script type="module" async\s+src="/assets/js/app/main/boot-prefetch\.js\?v=#{Regexp.escape(version)}"})
end
it "marks the tag for the prefetch module and enables chat in public mode" do
body = last_response.body
expect(body).to include("data-pm-prefetch")
expect(body).to include('data-pm-chat="true"')
end
it "loads the boot module before the main entry point" do
body = last_response.body
boot_at = body.index("/assets/js/app/main/boot-prefetch.js")
entry_at = body.index('<script type="module" src="/assets/js/app/index.js')
expect(boot_at).to be < entry_at
end
end
end