Files
houseplan-card/tests_backend/test_frontend_assets.py
T
Codexandclaude[bot] 78c6020747 fix: lazy delivery survives flaky networks and stale caches (#353)
Network failure of the editor runtime is no longer terminal: the loader
re-arms to idle and the next explicit press starts a fresh cycle, while a
fingerprint mismatch on either attempt stays terminal. The toast now says
what actually helps — retry advice for the network, refresh advice for a
foreign build — via one shared lazyLoadFailureMessage helper (new i18n key
editor.retry_advice in en/ru/de).

The field smoke caught a second, deeper bug on the way: Chromium records a
FAILED module in the page module map permanently, so retrying the same URL
(even the cache-busted one) never touched the network again. Every retry
now carries a per-cycle nonce and becomes a genuinely new module request.

A proxy-cached stale entry no longer kills the card silently: the entry
facade is rewritten at build time from a static re-export into a top-level
`try{await import(...)}catch{...}` — importers keep the happy-path
guarantee (await import(entry) still resolves only after
customElements.define), and the catch defines a fallback element with a
localized "reload the page" panel. Content-hashed chunks are served with
`public, max-age=31536000, immutable`, and verifyBundleTree now fails on
orphan chunks that the manifest does not name.

Proofs: loader units for re-arm/terminality/toast wording + an AST check
that both loaders forward the terminality flag; smoke_entry_stale (en/ru)
against a tree without the main chunk; smoke_lazy_editor_chunk extended —
second press after network failure now really opens the editor; pytest for
the immutable header; orphan-tree unit; five new registry mutants.
TESTING.md budget line updated to the #352 ceiling alongside.

Issue: #353
User-Visible: yes
2026-08-28 14:32:08 +00:00

77 lines
3.1 KiB
Python

"""Pure path/manifest guards for public frontend chunks (#337)."""
from __future__ import annotations
import json
import importlib.util
from pathlib import Path
_PATH = Path(__file__).parents[1] / "custom_components" / "houseplan" / "frontend_asset_manifest.py"
_SPEC = importlib.util.spec_from_file_location("hp_frontend_asset_manifest", _PATH)
assert _SPEC and _SPEC.loader
_MODULE = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(_MODULE)
resolve_frontend_asset = _MODULE.resolve_frontend_asset
def _root(tmp_path: Path) -> Path:
root = tmp_path / "frontend"
assets = root / "houseplan-assets"
assets.mkdir(parents=True)
(assets / "editor-abc.js").write_text("export {};", encoding="utf-8")
(assets / "stale.js").write_text("export {};", encoding="utf-8")
(root / "houseplan-assets.json").write_text(json.dumps({
"schema": 1,
"files": [{"path": "houseplan-assets/editor-abc.js"}],
}), encoding="utf-8")
return root
def test_frontend_asset_requires_current_manifest_entry(tmp_path: Path) -> None:
root = _root(tmp_path)
assert resolve_frontend_asset(root, "editor-abc.js") == (
root / "houseplan-assets" / "editor-abc.js"
).resolve()
assert resolve_frontend_asset(root, "stale.js") is None
assert resolve_frontend_asset(root, "missing.js") is None
assert resolve_frontend_asset(root, "houseplan-assets.json") is None
def test_frontend_asset_refuses_traversal_and_malformed_manifest(tmp_path: Path) -> None:
root = _root(tmp_path)
for value in (
"../editor-abc.js", "..\\editor-abc.js", "nested/editor-abc.js",
"..%2feditor-abc.js", "editor-abc.css", "..", "",
):
assert resolve_frontend_asset(root, value) is None
(root / "houseplan-assets.json").write_text("not json", encoding="utf-8")
assert resolve_frontend_asset(root, "editor-abc.js") is None
def test_frontend_asset_refuses_wrong_schema_and_symlink_escape(tmp_path: Path) -> None:
root = _root(tmp_path)
manifest = root / "houseplan-assets.json"
manifest.write_text(json.dumps({
"schema": 2, "files": [{"path": "houseplan-assets/editor-abc.js"}],
}), encoding="utf-8")
assert resolve_frontend_asset(root, "editor-abc.js") is None
outside = tmp_path / "outside.js"
outside.write_text("outside", encoding="utf-8")
link = root / "houseplan-assets" / "linked.js"
try:
link.symlink_to(outside)
except OSError:
return # Windows without Developer Mode; containment is covered elsewhere.
manifest.write_text(json.dumps({
"schema": 1, "files": [{"path": "houseplan-assets/linked.js"}],
}), encoding="utf-8")
assert resolve_frontend_asset(root, "linked.js") is None
def test_hashed_chunks_are_immutably_cacheable() -> None:
"""#353 AC3-в: content-hashed bodies never change under their URL."""
assert _MODULE.ASSET_CACHE_CONTROL == "public, max-age=31536000, immutable"
view_source = (_PATH.parent / "frontend_assets.py").read_text(encoding="utf-8")
assert "ASSET_CACHE_CONTROL" in view_source
assert '"no-cache"' not in view_source