From 8528bce49bb637f4e9fb653fff148b7ea45cfdac Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 15 Aug 2026 14:34:08 +0200 Subject: feat(settings): choose between Save As and saving into a folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/meshbay-hub/tests/test_downloads.py | 97 ++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 packages/meshbay-hub/tests/test_downloads.py (limited to 'packages/meshbay-hub/tests/test_downloads.py') 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] -- cgit v1.2.3