mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
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
77 lines
3.1 KiB
Python
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
|