Python · 10819 bytes Raw Blame History
1 """Unit tests for ``sway pack`` / ``sway unpack`` (Sprint 26 / X3).
2
3 Round-trips a synthetic spec through pack → unpack → re-load and
4 verifies every artifact comes back byte-identical (modulo tarball
5 metadata). Also pins the structural error paths: missing dlm_source,
6 size cap, refusing to overwrite, malformed manifest, version
7 mismatch, path-traversal rejection.
8
9 End-to-end "sway run on the unpacked spec matches the pre-pack
10 verdict" lives in ``tests/integration/test_pack_run_roundtrip.py``.
11 """
12
13 from __future__ import annotations
14
15 import json
16 import tarfile
17 from pathlib import Path
18
19 import pytest
20
21 from dlm_sway.cli._pack import (
22 DEFAULT_MAX_PACK_SIZE_BYTES,
23 SWAYPACK_VERSION,
24 PackError,
25 pack_spec,
26 )
27 from dlm_sway.cli._unpack import UnpackError, unpack_swaypack
28
29
30 def _write_minimal_spec(spec_path: Path, *, dlm_source: str | None = None) -> None:
31 """Write a tiny syntactically-valid sway.yaml. The dlm_source
32 field is optional — set it to point at a sibling ``.dlm`` to
33 test source-bundling."""
34 body = """\
35 version: 1
36 models:
37 base: {kind: dummy, base: dummy-base}
38 ft: {kind: dummy, base: dummy-base}
39 defaults:
40 seed: 0
41 suite:
42 - {name: dk, kind: delta_kl, prompts: [hello]}
43 """
44 if dlm_source:
45 body += f"dlm_source: {dlm_source}\n"
46 spec_path.write_text(body, encoding="utf-8")
47
48
49 class TestPackBasics:
50 def test_round_trip_minimal_spec(self, tmp_path: Path) -> None:
51 spec_path = tmp_path / "sway.yaml"
52 _write_minimal_spec(spec_path)
53 out = tmp_path / "test.swaypack.tar.gz"
54
55 report = pack_spec(spec_path, out_path=out, include_null_cache=False)
56
57 assert out.exists()
58 assert report.size_bytes > 0
59 assert report.spec_path == spec_path.resolve()
60 assert report.section_bytes == 0
61 assert report.null_stats_count == 0
62 assert report.golden_included is False
63
64 def test_unpack_round_trip(self, tmp_path: Path) -> None:
65 spec_path = tmp_path / "sway.yaml"
66 _write_minimal_spec(spec_path)
67 out = tmp_path / "test.swaypack.tar.gz"
68 pack_spec(spec_path, out_path=out, include_null_cache=False)
69
70 target = tmp_path / "unpacked"
71 report = unpack_swaypack(out, target_dir=target)
72
73 assert report.out_dir == (target / "swaypack").resolve()
74 assert report.spec_path.exists()
75 # Spec round-trips byte-identical.
76 assert report.spec_path.read_bytes() == spec_path.read_bytes()
77 assert report.null_stats_dir is None # No cache packed.
78
79 def test_manifest_records_pack_metadata(self, tmp_path: Path) -> None:
80 """Manifest must include swaypack_version + sway_version
81 + packed_at + counts so an unpacker can reason about the pack."""
82 spec_path = tmp_path / "sway.yaml"
83 _write_minimal_spec(spec_path)
84 out = tmp_path / "test.swaypack.tar.gz"
85 pack_spec(spec_path, out_path=out, include_null_cache=False)
86
87 with tarfile.open(str(out), mode="r:gz") as tar:
88 manifest_member = tar.getmember("swaypack/manifest.json")
89 f = tar.extractfile(manifest_member)
90 assert f is not None
91 manifest = json.loads(f.read().decode("utf-8"))
92
93 assert manifest["swaypack_version"] == SWAYPACK_VERSION
94 assert "sway_version" in manifest
95 assert "packed_at" in manifest
96 assert manifest["section_bytes"] == 0
97 assert manifest["null_stats_count"] == 0
98 assert manifest["golden_included"] is False
99 assert manifest["dlm_source_packed"] is False
100
101 def test_dlm_source_bundled_when_present(self, tmp_path: Path) -> None:
102 """A spec with ``dlm_source`` next to it bundles source.dlm."""
103 dlm_path = tmp_path / "doc.dlm"
104 dlm_path.write_text("---\ndlm_id: 01TEST\n---\nbody\n", encoding="utf-8")
105
106 spec_path = tmp_path / "sway.yaml"
107 _write_minimal_spec(spec_path, dlm_source="doc.dlm")
108 out = tmp_path / "test.swaypack.tar.gz"
109
110 report = pack_spec(spec_path, out_path=out, include_null_cache=False)
111 assert report.section_bytes > 0
112
113 target = tmp_path / "unpacked"
114 unpack_report = unpack_swaypack(out, target_dir=target)
115 bundled = unpack_report.out_dir / "source.dlm"
116 assert bundled.exists()
117 assert bundled.read_bytes() == dlm_path.read_bytes()
118
119 def test_dlm_source_missing_logs_warning_but_succeeds(
120 self, tmp_path: Path, caplog: pytest.LogCaptureFixture
121 ) -> None:
122 """Spec with dlm_source pointing at a nonexistent file: pack
123 succeeds with section_bytes=0 + a warning."""
124 spec_path = tmp_path / "sway.yaml"
125 _write_minimal_spec(spec_path, dlm_source="missing.dlm")
126 out = tmp_path / "test.swaypack.tar.gz"
127
128 with caplog.at_level("WARNING"):
129 report = pack_spec(spec_path, out_path=out, include_null_cache=False)
130 assert report.section_bytes == 0
131 assert any("missing.dlm" in rec.message for rec in caplog.records)
132
133
134 class TestPackErrors:
135 def test_refuses_to_overwrite_existing_pack(self, tmp_path: Path) -> None:
136 spec_path = tmp_path / "sway.yaml"
137 _write_minimal_spec(spec_path)
138 out = tmp_path / "test.swaypack.tar.gz"
139 out.write_bytes(b"placeholder")
140 with pytest.raises(PackError, match="refusing to overwrite"):
141 pack_spec(spec_path, out_path=out, include_null_cache=False)
142
143 def test_size_cap_rejects_oversize_pack(self, tmp_path: Path) -> None:
144 """A 0-byte cap should reject any non-empty pack."""
145 spec_path = tmp_path / "sway.yaml"
146 _write_minimal_spec(spec_path)
147 out = tmp_path / "test.swaypack.tar.gz"
148 with pytest.raises(PackError, match="exceeds the cap"):
149 pack_spec(
150 spec_path,
151 out_path=out,
152 include_null_cache=False,
153 max_size_bytes=10,
154 )
155 # File must NOT have been written when the cap was tripped.
156 assert not out.exists()
157
158 def test_default_max_size_is_50mb(self) -> None:
159 """The default cap is the published 50 MB number — a regression
160 guard so a stray edit doesn't quietly raise the bar."""
161 assert DEFAULT_MAX_PACK_SIZE_BYTES == 50 * 1024 * 1024
162
163 def test_include_golden_missing_raises(self, tmp_path: Path) -> None:
164 spec_path = tmp_path / "sway.yaml"
165 _write_minimal_spec(spec_path)
166 out = tmp_path / "test.swaypack.tar.gz"
167 with pytest.raises(PackError, match="--include-golden"):
168 pack_spec(
169 spec_path,
170 out_path=out,
171 include_golden=tmp_path / "nope.json",
172 include_null_cache=False,
173 )
174
175 def test_include_golden_bundles_file(self, tmp_path: Path) -> None:
176 spec_path = tmp_path / "sway.yaml"
177 _write_minimal_spec(spec_path)
178 golden = tmp_path / "report.json"
179 golden.write_text(json.dumps({"verdict": "pass"}), encoding="utf-8")
180 out = tmp_path / "test.swaypack.tar.gz"
181
182 report = pack_spec(
183 spec_path,
184 out_path=out,
185 include_golden=golden,
186 include_null_cache=False,
187 )
188 assert report.golden_included is True
189
190 target = tmp_path / "u"
191 unpack_swaypack(out, target_dir=target)
192 bundled = target / "swaypack" / "golden.json"
193 assert bundled.exists()
194 assert json.loads(bundled.read_text()) == {"verdict": "pass"}
195
196
197 class TestUnpackErrors:
198 def test_missing_pack_file_raises(self, tmp_path: Path) -> None:
199 with pytest.raises(UnpackError, match="not found"):
200 unpack_swaypack(tmp_path / "no.tar.gz", target_dir=tmp_path)
201
202 def test_pack_path_is_dir_raises(self, tmp_path: Path) -> None:
203 d = tmp_path / "adir"
204 d.mkdir()
205 with pytest.raises(UnpackError, match="must be a file"):
206 unpack_swaypack(d, target_dir=tmp_path)
207
208 def test_refuses_to_overwrite_existing_swaypack_dir(self, tmp_path: Path) -> None:
209 spec_path = tmp_path / "sway.yaml"
210 _write_minimal_spec(spec_path)
211 out = tmp_path / "test.swaypack.tar.gz"
212 pack_spec(spec_path, out_path=out, include_null_cache=False)
213
214 target = tmp_path / "u"
215 unpack_swaypack(out, target_dir=target) # First unpack succeeds.
216 with pytest.raises(UnpackError, match="refusing to overwrite"):
217 unpack_swaypack(out, target_dir=target) # Second refuses.
218
219 def test_corrupt_pack_raises(self, tmp_path: Path) -> None:
220 bad = tmp_path / "bad.tar.gz"
221 bad.write_bytes(b"not a tarball")
222 with pytest.raises(UnpackError, match="failed to extract"):
223 unpack_swaypack(bad, target_dir=tmp_path)
224
225 def test_pack_without_swaypack_root_raises(self, tmp_path: Path) -> None:
226 """A tar.gz that doesn't follow the ``swaypack/`` layout is rejected."""
227 bad = tmp_path / "wrong.tar.gz"
228 with tarfile.open(str(bad), mode="w:gz") as tar:
229 info = tarfile.TarInfo(name="other/sway.yaml")
230 payload = b"version: 1\n"
231 info.size = len(payload)
232 import io as _io
233
234 tar.addfile(info, _io.BytesIO(payload))
235 with pytest.raises(UnpackError, match="missing the expected 'swaypack/'"):
236 unpack_swaypack(bad, target_dir=tmp_path)
237
238 def test_missing_manifest_raises(self, tmp_path: Path) -> None:
239 """A pack with the right top-level dir but no manifest fails."""
240 bad = tmp_path / "no-manifest.tar.gz"
241 with tarfile.open(str(bad), mode="w:gz") as tar:
242 info = tarfile.TarInfo(name="swaypack/sway.yaml")
243 payload = b"version: 1\n"
244 info.size = len(payload)
245 import io as _io
246
247 tar.addfile(info, _io.BytesIO(payload))
248 with pytest.raises(UnpackError, match="missing manifest.json"):
249 unpack_swaypack(bad, target_dir=tmp_path)
250
251 def test_version_mismatch_raises(self, tmp_path: Path) -> None:
252 """A future swaypack_version is rejected with a clear message."""
253 bad = tmp_path / "future.tar.gz"
254 manifest = json.dumps({"swaypack_version": 99, "spec_filename": "sway.yaml"})
255 with tarfile.open(str(bad), mode="w:gz") as tar:
256 for name, payload in (
257 ("swaypack/sway.yaml", b"version: 1\n"),
258 ("swaypack/manifest.json", manifest.encode("utf-8")),
259 ):
260 info = tarfile.TarInfo(name=name)
261 info.size = len(payload)
262 import io as _io
263
264 tar.addfile(info, _io.BytesIO(payload))
265 with pytest.raises(UnpackError, match="unsupported swaypack_version=99"):
266 unpack_swaypack(bad, target_dir=tmp_path)