""" The arbitrary ceiling on a directory zip. `downloadDirectory` is the one implementation behind every "download this folder as a zip" button — Files' single folder, Files' multi-folder selection, and the Photos album button (docs/photos.md §3) — so the limit is checked once, there, and holds for all of them. Two things are worth pinning. That an oversized folder is refused *before* `_openDownloadTarget`, because a save dialog for an archive that will never be written is worse than no dialog at all. And that a folder at exactly the limit still goes through, since an off-by-one here silently costs a whole megabyte of allowance and nobody would ever notice. """ import json import shutil import subprocess from pathlib import Path import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" FILE_UTILS = STATIC / "file-utils.js" pytestmark = pytest.mark.skipif( shutil.which("node") is None or not FILE_UTILS.exists(), reason="node or the SPA sources are not available") MIB = 1024 * 1024 def _run(total_bytes, tmp_path): """ Call downloadDirectory over one folder holding `total_bytes`, and report what it did: the errors it set, how many times it put a question to the person, and how many transfers it started. """ sandbox = tmp_path / "static" sandbox.mkdir() for src in STATIC.glob("*.js"): (sandbox / src.name).write_text(src.read_text(encoding="utf-8"), encoding="utf-8") (tmp_path / "package.json").write_text('{"type":"module"}') script = tmp_path / "case.mjs" script.write_text(f""" const store = new Map(); globalThis.localStorage = {{ getItem: k => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: k => store.delete(k), }}; // Node 22 defines `navigator` itself, so it is left alone; `window` is what // platform.js reaches for to decide it is not running in the desktop app. globalThis.window = globalThis; const out = {{ errors: [], started: 0, asked: 0 }}; // Reached only once the size check has passed: with no File System Access API // under Node, downloadDirectory falls through to its build-in-memory path and // asks first. Answering yes is what lets the at-the-limit case get as far as // starting a transfer, and `asked` is how the refusal proves it never did. globalThis.confirm = () => {{ out.asked += 1; return true; }}; const M = await import('{(sandbox / "file-utils.js").as_posix()}'); const transfers = {{ start: () => {{ out.started += 1; }} }}; const transport = {{ connected: true }}; // One file, in the folder itself — entriesUnder keys on `path`. const entries = [{{ id: 'f1', name: 'big.bin', path: 'album', size: {total_bytes}, added_at: 0 }}]; await M.downloadDirectory(transfers, transport, null, entries, 'album', {{ setError: (m) => out.errors.push(m), }}); out.limit = M.ZIP_MAX_BYTES; console.log(JSON.stringify(out)); """, encoding="utf-8") proc = subprocess.run(["node", str(script)], capture_output=True, text=True) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) def test_the_limit_is_512_mib(tmp_path): """The number the refusal quotes is the number the module enforces.""" assert _run(0, tmp_path)["limit"] == 512 * MIB def test_an_oversized_folder_is_refused_before_anything_opens(tmp_path): """ One byte over. Nothing is started, and the person is told why — a silent return would read as a dead button. """ result = _run(512 * MIB + 1, tmp_path) assert result["started"] == 0, "no transfer may begin" assert result["asked"] == 0, "nor may a save dialog have been put up first" assert result["errors"] == ["group.zip_too_large"], ( "the refusal must name its own key; t() falls back to the key with no " "catalogue loaded, and test_locales.py holds the ten translations of it") def test_a_folder_exactly_at_the_limit_still_downloads(tmp_path): """The bound is inclusive: `> ZIP_MAX_BYTES`, not `>=`.""" result = _run(512 * MIB, tmp_path) assert result["errors"] == [] assert result["started"] == 1