Files
odysseus/tests/test_frontend_build.py
T

171 lines
8.1 KiB
Python

"""Exercise publication failure modes without changing the real static tree."""
import json
import subprocess
import sys
from pathlib import Path
import pytest
from tests.helpers.cli_loader import load_script
ROOT = Path(__file__).resolve().parents[1]
build = load_script("build_frontend.py")
def project(tmp_path, entries=None):
source = tmp_path / "frontend/src/shared/helper.js"
source.parent.mkdir(parents=True)
source.write_bytes(b"export const value = 1;\n")
manifest = {"version": 1, "modules": entries if entries is not None else [
{"source": "shared/helper.js", "deployment": "js/helper.js"}
]}
(tmp_path / "frontend/modules.json").write_text(json.dumps(manifest), encoding="utf-8")
(tmp_path / ".gitattributes").write_text("* text=auto\nfrontend/src/** text eol=lf\nstatic/js/helper.js text eol=lf\n", encoding="utf-8")
return source, tmp_path / "static/js/helper.js"
def test_check_detects_missing_and_stale_output_without_writing(tmp_path):
source, output = project(tmp_path)
assert build.synchronize(tmp_path, check=True) == ["static/js/helper.js"]
assert not output.exists()
assert build.synchronize(tmp_path) == ["static/js/helper.js"]
assert output.read_bytes() == source.read_bytes()
source.write_bytes(b"export const value = 2;\n")
assert build.synchronize(tmp_path, check=True) == ["static/js/helper.js"]
assert output.read_bytes() == b"export const value = 1;\n"
def test_publication_is_idempotent_and_preserves_unowned_assets(tmp_path):
source, output = project(tmp_path)
unowned = tmp_path / "static/js/notes.js"
unowned.parent.mkdir(parents=True)
unowned.write_bytes(b"legacy state owner\n")
build.synchronize(tmp_path)
timestamp = output.stat().st_mtime_ns
assert build.synchronize(tmp_path) == []
assert output.stat().st_mtime_ns == timestamp
assert unowned.read_bytes() == b"legacy state owner\n"
assert output.read_bytes() == source.read_bytes()
@pytest.mark.parametrize("bad", ["../outside.js", "/absolute.js", "shared/../helper.js", "shared\\helper.js", "shared/helper.js?v=1", "shared/helper.js#fragment", "shared/helper.ts"])
def test_invalid_plan_does_not_partially_publish(tmp_path, bad):
project(tmp_path, [
{"source": "shared/helper.js", "deployment": "js/helper.js"},
{"source": bad, "deployment": "js/other.js"},
])
with pytest.raises(ValueError, match="invalid module path"):
build.synchronize(tmp_path)
assert not (tmp_path / "static").exists()
def test_duplicate_outputs_and_unmapped_sources_are_rejected(tmp_path):
source, output = project(tmp_path)
extra = source.with_name("extra.js")
extra.write_bytes(b"export const value = 2;\n")
with pytest.raises(ValueError, match="missing from manifest"):
build.synchronize(tmp_path)
manifest_path = tmp_path / "frontend/modules.json"
manifest = json.loads(manifest_path.read_text())
manifest["modules"].append({"source": "shared/extra.js", "deployment": "js/helper.js"})
manifest_path.write_text(json.dumps(manifest))
with pytest.raises(ValueError, match="must be unique"):
build.synchronize(tmp_path)
assert not output.exists()
def test_symlink_output_cannot_overwrite_another_file(tmp_path):
_, output = project(tmp_path)
outside = tmp_path / "outside.js"
outside.write_bytes(b"untouched\n")
output.parent.mkdir(parents=True)
output.symlink_to(outside)
with pytest.raises(ValueError, match="escapes|symlink"):
build.synchronize(tmp_path)
assert outside.read_bytes() == b"untouched\n"
def test_real_checkout_check_works_from_another_directory(tmp_path):
result = subprocess.run([sys.executable, str(ROOT / "scripts/build_frontend.py"), "--check"], cwd=tmp_path, capture_output=True, text=True, timeout=20)
assert result.returncode == 0, result.stdout + result.stderr
assert (ROOT / "frontend/src/shared/actionMenuOrder.js").read_bytes() == (ROOT / "static/js/actionMenuOrder.js").read_bytes()
def test_isolated_python_distribution_runs_without_source_or_node():
result = subprocess.run([sys.executable, str(ROOT / "scripts/verify_frontend_runtime.py")], cwd=ROOT, capture_output=True, text=True, timeout=90)
assert result.returncode == 0, result.stdout + result.stderr
assert "PASS: isolated Python lifespan" in result.stdout
@pytest.mark.parametrize("check", [False, True])
@pytest.mark.parametrize("path,rule", [
("frontend/src/shared/helper.js", "text"),
("static/js/helper.js", "text"),
("frontend/src/shared/helper.js", "text eol=crlf"),
("static/js/helper.js", "text=auto eol=lf"),
("static/js/helper.js", "-text eol=lf"),
])
def test_each_manifest_path_requires_lf_even_when_bytes_already_match(tmp_path, check, path, rule):
source, output = project(tmp_path)
output.parent.mkdir(parents=True)
output.write_bytes(source.read_bytes())
other = "static/js/helper.js" if path.startswith("frontend/") else "frontend/src/shared/helper.js"
(tmp_path / ".gitattributes").write_text(f"* text=auto\n{other} text eol=lf\n{path} {rule}\n", encoding="utf-8")
with pytest.raises(ValueError, match="require repository-owned.*" + path):
build.synchronize(tmp_path, check=check)
assert output.read_bytes() == source.read_bytes()
def test_future_manifest_pair_cannot_publish_without_output_attributes(tmp_path):
source, output = project(tmp_path)
extra = source.with_name("future.js")
extra.write_bytes(b"export const future = true;\n")
manifest_file = tmp_path / "frontend/modules.json"
manifest = json.loads(manifest_file.read_text())
manifest["modules"].append({"source": "shared/future.js", "deployment": "js/future.js"})
manifest_file.write_text(json.dumps(manifest))
with pytest.raises(ValueError, match="static/js/future.js"):
build.synchronize(tmp_path)
assert not output.exists()
assert not (tmp_path / "static/js/future.js").exists()
with (tmp_path / ".gitattributes").open("a", encoding="utf-8") as attributes:
attributes.write("static/js/future.js text eol=lf\n")
assert build.synchronize(tmp_path) == ["static/js/helper.js", "static/js/future.js"]
assert build.synchronize(tmp_path, check=True) == []
def test_nested_git_attribute_rules_and_paths_with_spaces_are_supported(tmp_path):
source, _ = project(tmp_path, [{"source": "shared/helper.js", "deployment": "js/feature space/helper.js"}])
attributes = tmp_path / "static/js/feature space/.gitattributes"
attributes.parent.mkdir(parents=True)
attributes.write_text("*.js text eol=lf\n", encoding="utf-8")
assert build.synchronize(tmp_path) == ["static/js/feature space/helper.js"]
assert (attributes.parent / "helper.js").read_bytes() == source.read_bytes()
attributes.write_text("*.js text eol=crlf\n", encoding="utf-8")
with pytest.raises(ValueError, match="eol=crlf"):
build.synchronize(tmp_path, check=True)
def test_global_and_local_git_settings_cannot_mask_missing_repository_rule(tmp_path, monkeypatch):
project(tmp_path)
(tmp_path / ".gitattributes").write_text("frontend/src/** text eol=lf\n", encoding="utf-8")
global_attributes = tmp_path / "global-attributes"
global_attributes.write_text("* text eol=lf\n", encoding="utf-8")
config = tmp_path / "global-config"
config.write_text(f'[core]\n attributesFile = "{global_attributes.as_posix()}"\n autocrlf = true\n', encoding="utf-8")
subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True, capture_output=True)
(tmp_path / ".git/info/attributes").write_text("* text eol=lf\n", encoding="utf-8")
monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config))
monkeypatch.setenv("GIT_DIR", str(tmp_path / ".git"))
monkeypatch.setenv("GIT_WORK_TREE", str(tmp_path))
with pytest.raises(ValueError, match="static/js/helper.js"):
build.synchronize(tmp_path, check=True)
def test_attribute_enforcement_fails_clearly_when_development_git_is_missing(tmp_path, monkeypatch):
_, output = project(tmp_path)
monkeypatch.setattr(build.shutil, "which", lambda name: None)
with pytest.raises(ValueError, match="Git is required for frontend publication"):
build.synchronize(tmp_path)
assert not output.exists()