summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_downloads.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-15 14:34:08 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-15 14:34:08 +0200
commit8528bce49bb637f4e9fb653fff148b7ea45cfdac (patch)
treece34c75cb71fcd2a6526bf72474ca42442f7825d /packages/meshbay-hub/tests/test_downloads.py
parent41e2b79cb1bc9d188853aeff5a55cd2237268587 (diff)
downloadmeshbay-8528bce49bb637f4e9fb653fff148b7ea45cfdac.tar.gz
feat(settings): choose between Save As and saving into a folder
Downloading a selection of twenty files meant twenty Save As dialogs, which is the wrong answer for the feature that had just been built. Settings → Downloads now offers saving automatically, and that is the default; asking every time stays available for people who want it. The correction worth recording: a web page cannot be given a filesystem path and cannot read one either. There is no ~/Downloads to configure and nothing to type, on any operating system — which is also why none of this will need changing on Windows. What a browser grants is a handle to a folder the user picked in a dialog, so that is what the setting keeps: picked once, stored in IndexedDB, re-confirmed once a session because the grant comes back as a claim rather than a permission. Where no folder has been granted, and in Firefox and Safari where none can be, files go to the browser's own download folder — which on most machines is the folder that was meant all along. Automatic saving has one risk a dialog does not: it can silently replace a file. It does not — a taken name gets a suffix before the extension, `clip (2).mp4`, so a download folder does not fill up with files the system no longer recognises. That, and the default, are what test_downloads.py pins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_downloads.py')
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py97
1 files changed, 97 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py
new file mode 100644
index 0000000..cc4fdd1
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_downloads.py
@@ -0,0 +1,97 @@
+"""
+Where downloads are written.
+
+The module is mostly browser plumbing — a directory handle from a picker, kept
+in IndexedDB — but two pieces decide behaviour and can be checked here: that
+automatic is the default, and that writing into the same folder repeatedly does
+not quietly replace what is already there. The second one is the whole risk of
+the automatic mode: a Save As dialog warns you about a collision, and a folder
+you never look at does not.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+DOWNLOADS = STATIC / "downloads.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not DOWNLOADS.exists(),
+ reason="node or the SPA sources are not available")
+
+
+def _run(body, tmp_path):
+ module = tmp_path / "downloads.mjs"
+ module.write_text(DOWNLOADS.read_text())
+ script = tmp_path / "case.mjs"
+ script.write_text(
+ # A localStorage good enough for a preference, so the module can be
+ # imported outside a browser at all.
+ "const store = new Map();\n"
+ "globalThis.localStorage = {\n"
+ " getItem: k => (store.has(k) ? store.get(k) : null),\n"
+ " setItem: (k, v) => store.set(k, String(v)),\n"
+ "};\n"
+ f"const M = await import('{module.as_posix()}');\n"
+ "const out = [];\n"
+ "const say = (...a) => out.push(...a);\n"
+ f"{body}\n"
+ "console.log(JSON.stringify(out));\n")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+def test_saving_automatically_is_the_default(tmp_path):
+ """
+ Grouped downloads are the reason this setting exists: twelve files must not
+ mean twelve dialogs unless someone asked for that.
+ """
+ assert _run("say(M.getMode());", tmp_path) == ["auto"]
+
+
+def test_the_choice_is_remembered_and_nothing_else_is_accepted(tmp_path):
+ result = _run("""
+M.setMode('ask'); say(M.getMode());
+M.setMode('auto'); say(M.getMode());
+M.setMode('nonsense'); say(M.getMode());
+""", tmp_path)
+ assert result == ["ask", "auto", "auto"]
+
+
+def test_a_second_copy_does_not_replace_the_first(tmp_path):
+ result = _run("""
+const taken = new Set(['clip.mp4', 'clip (2).mp4', 'notes']);
+const exists = async n => taken.has(n);
+say(await M.freeName('clip.mp4', exists));
+say(await M.freeName('other.mp4', exists));
+say(await M.freeName('notes', exists));
+say(await M.freeName('archive.tar.gz', exists));
+""", tmp_path)
+ assert result == [
+ "clip (3).mp4", # (2) was taken as well
+ "other.mp4", # free: left alone
+ "notes (2)", # no extension to keep
+ "archive.tar.gz", # free, and the double extension is not mangled
+ ]
+
+
+def test_the_suffix_goes_before_the_extension(tmp_path):
+ """
+ "clip.mp4 (2)" would stop being a video as far as the operating system is
+ concerned, which is how a download folder ends up full of files nothing opens.
+ """
+ result = _run("""
+say(await M.freeName('clip.mp4', async () => false));
+say(await M.freeName('clip.mp4', async n => n === 'clip.mp4'));
+""", tmp_path)
+ assert result == ["clip.mp4", "clip (2).mp4"]
+
+
+def test_a_browser_without_the_api_reports_it(tmp_path):
+ """`SUPPORTED` decides whether Settings offers a choice or an explanation."""
+ assert _run("say(M.SUPPORTED);", tmp_path) == [False]