aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/test_memory_ceiling.py209
-rw-r--r--packages/meshbay-hub/tests/test_zip_size_limit.py59
2 files changed, 262 insertions, 6 deletions
diff --git a/packages/meshbay-hub/tests/test_memory_ceiling.py b/packages/meshbay-hub/tests/test_memory_ceiling.py
new file mode 100644
index 0000000..9966e48
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_memory_ceiling.py
@@ -0,0 +1,209 @@
+"""
+No download above the ceiling is ever collected in the page.
+
+`pipelinedDownload` with no `writable` allocates `new Array(totalChunks)` and
+keeps every decrypted chunk, so whatever `_openDownloadTarget` returns `null`
+for is a file held whole in RAM. That floor had no upper bound: the
+`!window.showSaveFilePicker` branch returned `null` at any size, so on a browser
+without the File System Access API a 20 GB film went to memory whenever the
+service-worker path did not answer — which happens for ordinary reasons. The
+symptom was the tab dying, with nothing in the source to lead back here.
+
+The real `_openDownloadTarget` is lifted out of `file-utils.js` **as text** and
+executed against stubbed browsers, on the rule this repo already follows for the
+video player: model the environment, never the code under test. A test that
+transcribed the decision tree would agree with a broken version of it by
+construction.
+
+`test_no_unguarded_memory_floor` is the one that outlives today's branches: it
+reads the function and fails if a `return null` appears in it that does not go
+through the guard — which is what a fourth fallback added in a hurry would look
+like.
+"""
+
+import json
+import re
+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")
+
+CEILING = 100 * 1024 * 1024
+GB = 1024 * 1024 * 1024
+
+
+def _lift(name, source):
+ """The text of one top-level declaration, from its opening line to the
+ column-0 brace that closes it. Nothing is re-typed into this test."""
+ start = source.index(name)
+ end = source.index("\n}\n", start) + len("\n}\n")
+ return source[start:end]
+
+
+@pytest.fixture(scope="module")
+def target_fn():
+ """The ceiling, its error and the real function — read, never re-typed."""
+ src = FILE_UTILS.read_text()
+ ceiling = re.search(r"^const MEMORY_CEILING = .*?;$", src, re.M)
+ assert ceiling, "MEMORY_CEILING is gone from file-utils.js"
+ # The test's own CEILING constant must agree with the source's, or every
+ # boundary case below is asserting against a number nothing uses.
+ assert str(CEILING) in ceiling.group(0).replace(" ", "") or \
+ eval(ceiling.group(0).split("=")[1].strip(" ;")) == CEILING
+ return "\n".join([
+ ceiling.group(0),
+ _lift("class TooLargeForMemoryError", src),
+ _lift("async function _openDownloadTarget", src),
+ ])
+
+
+def _run(target_fn, tmp_path, *, size, native=False, granted=False,
+ streamed=False, picker=False, mode="auto"):
+ """Drive the real function against one browser shape."""
+ script = tmp_path / "case.mjs"
+ script.write_text(f"""
+// Stubs for everything the lifted function reaches. `formatSize` and `t` only
+// build the message; the assertions are about which branch was taken.
+const formatSize = (n) => `${{n}} B`;
+const t = (key, vars) => key + ' ' + JSON.stringify(vars);
+const platform = {{
+ capabilities: {{ nativeSave: {json.dumps(native)} }},
+ nativeSave: async () => ({{ name: 'n', writable: {{}} }}),
+ bridgeMessage: (e) => String(e),
+}};
+const downloads = {{
+ BLOB_LIMIT: 512 * 1024 * 1024,
+ // Called by the refusal to name why the streamed path declined -- absent
+ // from this stub, the error constructor threw TypeError and the test saw the
+ // wrong failure entirely.
+ lastStreamFailure: () => 'stubbed: no streamed target in this harness',
+ getMode: () => {json.dumps(mode)},
+ openTarget: async () => ({json.dumps(granted)} ? {{ name: 'g', writable: {{}} }} : null),
+ openStreamedDownload: async () =>
+ ({json.dumps(streamed)} ? {{ name: 's', writable: {{}} }} : null),
+}};
+globalThis.window = {{}};
+if ({json.dumps(picker)}) {{
+ window.showSaveFilePicker = async () => ({{
+ name: 'p', createWritable: async () => ({{}}),
+ }});
+}}
+
+{target_fn}
+
+let outcome;
+try {{
+ const r = await _openDownloadTarget('film.mkv', {size});
+ outcome = r === null ? {{ kind: 'memory' }}
+ : r === false ? {{ kind: 'cancelled' }}
+ : {{ kind: 'stream', name: r.name }};
+}} catch (err) {{
+ outcome = {{ kind: 'refused', name: err.name, message: err.message }};
+}}
+console.log(JSON.stringify(outcome));
+""")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+# ── The hole this was written for ───────────────────────────────────────────
+
+def test_a_film_is_refused_rather_than_collected_in_memory(target_fn, tmp_path):
+ """Firefox/Safari shape: no picker, no granted folder, the worker did not
+ answer. This returned null — 20 GB into a tab."""
+ out = _run(target_fn, tmp_path, size=20 * GB)
+ assert out["kind"] == "refused", out
+ assert out["name"] == "TooLargeForMemoryError"
+
+
+def test_the_refusal_says_how_big_and_what_the_limit_is(target_fn, tmp_path):
+ out = _run(target_fn, tmp_path, size=20 * GB)
+ assert "download.too_large_for_memory" in out["message"]
+ assert str(20 * GB) in out["message"]
+ assert str(CEILING) in out["message"]
+
+
+def test_the_same_browser_in_ask_mode_is_refused_too(target_fn, tmp_path):
+ """'ask' skips the service-worker block entirely, so it reached the
+ unguarded branch without even trying to stream."""
+ out = _run(target_fn, tmp_path, size=20 * GB, mode="ask")
+ assert out["kind"] == "refused", out
+
+
+# ── What must keep working ──────────────────────────────────────────────────
+
+def test_something_small_still_uses_the_memory_floor(target_fn, tmp_path):
+ out = _run(target_fn, tmp_path, size=4 * 1024 * 1024)
+ assert out["kind"] == "memory", out
+
+
+def test_the_boundary_is_the_ceiling_itself(target_fn, tmp_path):
+ assert _run(target_fn, tmp_path, size=CEILING)["kind"] == "memory"
+ assert _run(target_fn, tmp_path, size=CEILING + 1)["kind"] == "refused"
+
+
+def test_a_granted_folder_streams_whatever_the_size(target_fn, tmp_path):
+ out = _run(target_fn, tmp_path, size=20 * GB, granted=True)
+ assert out == {"kind": "stream", "name": "g"}
+
+
+def test_the_service_worker_streams_whatever_the_size(target_fn, tmp_path):
+ out = _run(target_fn, tmp_path, size=20 * GB, streamed=True)
+ assert out == {"kind": "stream", "name": "s"}
+
+
+def test_the_desktop_app_streams_whatever_the_size(target_fn, tmp_path):
+ out = _run(target_fn, tmp_path, size=20 * GB, native=True)
+ assert out == {"kind": "stream", "name": "n"}
+
+
+def test_a_browser_with_a_picker_is_offered_one_instead_of_being_refused(
+ target_fn, tmp_path):
+ """Chrome/Edge: the file is large, nothing streamed yet, but Save As does.
+ A refusal here would be this fix breaking a path that was never broken."""
+ out = _run(target_fn, tmp_path, size=20 * GB, picker=True)
+ assert out == {"kind": "stream", "name": "p"}
+
+
+# ── The one that outlives today's branches ──────────────────────────────────
+
+def test_no_unguarded_memory_floor(target_fn):
+ """Every `return null` in the function goes through the guard.
+
+ A fourth fallback appended to the chain — which is exactly how the third one
+ got here — is caught by this even though no case above covers it.
+ """
+ body = target_fn[target_fn.index("async function _openDownloadTarget"):]
+ lines = body.splitlines()
+ # The guard's own `return null` is the one legitimate instance, so cut its
+ # definition out before looking. Comments go too — the branch that used to
+ # be the bug is now described in one, and a test that reads prose is the
+ # mistake already recorded in CLAUDE.md for the packaged systemd unit.
+ start = next(n for n, l in enumerate(lines) if "const _memoryFloor" in l)
+ end = next(n for n in range(start, len(lines)) if lines[n].strip() == "};")
+ rest = lines[:start] + lines[end + 1:]
+ code = [re.sub(r"//.*$", "", l) for l in rest]
+ bare = [l.strip() for l in code if re.search(r"\breturn null\b", l)]
+ assert bare == [], (
+ "an unguarded in-memory fallback was added to _openDownloadTarget; "
+ "return _memoryFloor() instead: " + "; ".join(bare))
+
+
+def test_the_guard_is_what_the_preview_uses_too(target_fn):
+ """`FilePreview` decrypts a whole entry with no writable at all, so it needs
+ the same ceiling — and must import it rather than keep a second number."""
+ files_app = (STATIC / "files-app.js").read_text()
+ assert "MEMORY_CEILING" in files_app
+ assert re.search(r"entry\.size\s*>\s*MEMORY_CEILING", files_app), (
+ "the preview modal must refuse an oversized entry before fetching it")
+ assert not re.search(r"100\s*\*\s*1024\s*\*\s*1024", files_app), (
+ "the ceiling is defined once, in file-utils.js")
diff --git a/packages/meshbay-hub/tests/test_zip_size_limit.py b/packages/meshbay-hub/tests/test_zip_size_limit.py
index 203c10c..44ad59e 100644
--- a/packages/meshbay-hub/tests/test_zip_size_limit.py
+++ b/packages/meshbay-hub/tests/test_zip_size_limit.py
@@ -6,11 +6,16 @@ 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*
+Three 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
+written is worse than no dialog at all. 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.
+of allowance and nobody would ever notice. And that the two limits in play do
+not contradict each other: ZIP_MAX_BYTES (512 MB) bounds the archive, while
+MEMORY_CEILING (100 MB, test_memory_ceiling.py) bounds what may be built in the
+page — so a 400 MB zip is allowed when there is somewhere to stream it and
+refused when the only route left is memory. The `confirm()` that offers the
+build-in-memory path therefore only ever appears below the ceiling.
"""
import json
@@ -30,7 +35,7 @@ pytestmark = pytest.mark.skipif(
MIB = 1024 * 1024
-def _run(total_bytes, tmp_path):
+def _run(total_bytes, tmp_path, picker=False):
"""
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
@@ -44,6 +49,7 @@ def _run(total_bytes, tmp_path):
(tmp_path / "package.json").write_text('{"type":"module"}')
script = tmp_path / "case.mjs"
+ picker_js = "true" if picker else "false"
script.write_text(f"""
const store = new Map();
globalThis.localStorage = {{
@@ -60,6 +66,17 @@ const out = {{ errors: [], started: 0, asked: 0 }};
// 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; }};
+// With `picker`, the browser can stream to a file the person chooses, which is
+// the only legal route for an archive over MEMORY_CEILING. Never exercised —
+// the stubbed `transfers.start` below does not run the job — it just has to be
+// a target rather than null.
+if ({picker_js}) {{
+ window.showSaveFilePicker = async () => ({{
+ name: 'album.zip',
+ createWritable: async () => ({{ write: async () => {{}}, close: async () => {{}},
+ abort: async () => {{}} }}),
+ }});
+}}
const M = await import('{(sandbox / "file-utils.js").as_posix()}');
@@ -101,7 +118,37 @@ def test_an_oversized_folder_is_refused_before_anything_opens(tmp_path):
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)
+ """The bound is inclusive: `> ZIP_MAX_BYTES`, not `>=`.
+
+ Given somewhere to stream to, because 512 MB is five times MEMORY_CEILING
+ and building it in the page is no longer a route this code will take. That
+ is what the next test is about; this one is still only about the off-by-one.
+ """
+ result = _run(512 * MIB, tmp_path, picker=True)
assert result["errors"] == []
assert result["started"] == 1
+ assert result["asked"] == 0, "nothing is built in memory when it can stream"
+
+
+def test_a_zip_over_the_memory_ceiling_is_refused_when_nothing_streams(tmp_path):
+ """
+ Between the two limits — larger than the page may hold, smaller than the
+ archive limit — and no way to stream it. Before the ceiling existed this
+ asked "build it in memory?" and, on yes, held 400 MB in the tab.
+
+ The refusal names the memory ceiling, not the zip limit: quoting 512 MB at
+ someone whose folder is under 512 MB would be a message about the wrong
+ rule.
+ """
+ result = _run(400 * MIB, tmp_path)
+ assert result["started"] == 0
+ assert result["asked"] == 0, (
+ "the person must not be offered a build-in-memory path above the ceiling")
+ assert result["errors"] and "group.zip_too_large" not in result["errors"][0]
+
+
+def test_a_small_folder_may_still_be_built_in_memory(tmp_path):
+ """The floor is intact below the ceiling — that is what it is for."""
+ result = _run(4 * MIB, tmp_path)
+ assert result["errors"] == []
+ assert result["asked"] == 1 and result["started"] == 1