aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_downloads.py
diff options
context:
space:
mode:
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"