aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_downloads.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-09 01:54:10 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-09 01:54:10 +0200
commitd6c4808d9a3ef6bc740cda7892508f15ee9ea030 (patch)
treeb9420bd6cc86ac0a72aef09d5ca0ee7e25a3533f /packages/meshbay-hub/tests/test_downloads.py
parenta0b070e5fd382bb4a4262637836cb8d4b268fbf7 (diff)
downloadmeshbay-d6c4808d9a3ef6bc740cda7892508f15ee9ea030.tar.gz
fix(spa): one save dialog per batch, not one per file
Selecting four files on Chrome produced a Save As dialog for the first, then one for the second only after that file had finished, while the last two timed out; on a later attempt the three remaining transfers appeared frozen. Two things were going on. Opening the target inside `prepare` had removed the accidental serialisation that `for (…) await downloadFile(e)` used to provide, so `_openTargetInTurn` now queues the openings — but a queue whose head is an unanswered dialog is a head-of-line block, which is what the "freeze" was. The code already recovered from a picker with no gesture behind it by streaming instead, on the `SecurityError` Chrome throws. That branch was never reached: Chrome does not throw, it shows the dialog anyway and waits for a human. So anything that has to wait its turn is now marked `batched`, and a batched opening prefers the streamed path whatever the download mode says. The first file of a batch — the one actually holding the gesture — still gets its dialog, so the preference is honoured where it can be. For the rest there is no gesture left to spend and nothing is lost by streaming: the file still lands on disk, in the browser's own download folder. Only the choice of folder goes, and it was not on offer. If the worker does not answer, a batched download falls back to the dialog rather than failing. Also logs which path led to a dialog. A dialog is the one outcome nobody can diagnose after the fact — it looks the same whether it was asked for or fallen back to — and the report this fixes needed three test cycles to narrow. The two harnesses that lift `_openDownloadTarget` as text now route console.info to stderr, since they parse stdout as JSON. Measured against the deployed hub in Chrome 152: the streamed path serves the hidden iframe in 2-3 ms on a normal load, after a hard reload (via the `mbdl-claim` recovery already in `_claimController`), and twice in the same document. Hub suite 824 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
Diffstat (limited to 'packages/meshbay-hub/tests/test_downloads.py')
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py66
1 files changed, 65 insertions, 1 deletions
diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py
index e68396f..81ab9d3 100644
--- a/packages/meshbay-hub/tests/test_downloads.py
+++ b/packages/meshbay-hub/tests/test_downloads.py
@@ -154,7 +154,10 @@ def test_a_length_is_only_promised_when_it_is_known(tmp_path):
# the ability to refuse an oversized download (test_memory_ceiling.py).
# What this test is about -- the `0` -- did not move.
app = (STATIC / "file-utils.js").read_text()
- zip_call = app[app.index("_openDownloadTarget(suggested"):]
+ # Anchored on the argument list, not on the function name: the call became
+ # `_openTargetInTurn(suggested, …)` when target openings were serialised.
+ # The `0` this test is about did not move.
+ zip_call = app[app.index("(suggested, totalBytes"):]
zip_call = zip_call[:zip_call.index(");") + 2]
assert zip_call.rstrip().endswith(", 0);"), (
"the zip download announces a Content-Length it will not match")
@@ -363,3 +366,64 @@ def test_the_worker_is_kept_alive_while_it_streams():
# event, but the reply is what tells the page it is talking to the worker
# that holds its stream.
assert "mbdl-ping" in sw and "mbdl-pong" in sw
+
+
+def test_targets_are_opened_one_at_a_time(tmp_path):
+ """
+ A browser shows one file picker at a time and grants one per user gesture,
+ so four downloads asking at once get one dialog and three failures.
+
+ That used to be prevented by accident: `downloadEntry` awaited the target
+ inline and files-app.js's `for (…) await downloadFile(e)` serialised them.
+ Opening the target inside `prepare` — so the row appears at the click rather
+ than tens of seconds later — removed the accident, and four pickers raced.
+ Reported from Chrome: one file downloaded, a prompt for the second, the
+ other two timed out. Firefox and Electron never noticed, because neither
+ opens a picker at all, which is why this reached one browser only.
+
+ The queue is on the *targets*, never on the rows: every download still
+ appears the moment it is asked for.
+
+ Queueing alone was not enough: a second dialog with no gesture behind it
+ still waits for a human, and the two behind it wait for the dialog. So
+ everything that has to wait its turn is also marked `batched`, which the
+ opener reads as "do not ask" — see the streamed-path branch below.
+ """
+ src = (STATIC / "file-utils.js").read_text()
+ fn = src[src.index("function _openTargetInTurn"):]
+ fn = fn[:fn.index("\n}\n") + 2]
+
+ script = tmp_path / "case.mjs"
+ script.write_text("""
+const out = [];
+let live = 0, peak = 0;
+// Stands in for _openDownloadTarget: records how many are open at once.
+const asked = [];
+const _openDownloadTarget = async (name, size, opts, swSize, flags) => {
+ live += 1; peak = Math.max(peak, live);
+ asked.push(flags && flags.batched);
+ await new Promise(r => setTimeout(r, 20));
+ live -= 1;
+ if (name === 'boom') throw new Error('refused');
+ return { name };
+};
+let _targetQueue = Promise.resolve();
+let _targetsInFlight = 0;
+""" + fn + """
+const results = await Promise.allSettled(
+ ['a', 'boom', 'c', 'd'].map(n => _openTargetInTurn(n)));
+out.push(peak);
+out.push(results.map(r => r.status).join(','));
+out.push(asked);
+console.log(JSON.stringify(out));
+""")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ peak, statuses, batched = json.loads(proc.stdout)
+ assert peak == 1, f"{peak} targets were being opened at once"
+ assert batched == [False, True, True, True], (
+ "only the first of a batch holds the user's gesture; the rest must be "
+ "opened without asking")
+ # And one refusal must not stop the rest: a chain that breaks on a rejection
+ # leaves every later download unable to open anything at all.
+ assert statuses == "fulfilled,rejected,fulfilled,fulfilled"