summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-15 13:13:08 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-15 13:13:08 +0200
commit41e2b79cb1bc9d188853aeff5a55cd2237268587 (patch)
treebe19e833e633d23cd42068ce40b8ab9baabed532 /packages/meshbay-hub/tests
parent8cd7e467ebec987f66c4fe93a8d87dfbc57304d2 (diff)
downloadmeshbay-41e2b79cb1bc9d188853aeff5a55cd2237268587.tar.gz
feat(files): transfers that outlive the page, and selection instead of per-row menus
Downloads and uploads were state inside GroupPage. Leaving a group unmounted the component, its cleanup closed the DataChannel, and a half-written file was all you had — which is also why only one thing could be in flight at a time. They live in a module-level store now. A group page hands its transport over on the way out rather than closing it, and the last transfer using it closes it; signing out is the one thing that cancels everything, because those transfers are moving data on a token about to stop being ours. The store is plain JavaScript with no browser globals, so test_transfers.py runs it under Node and pins the parts that are timing and lifetime rather than markup: that a cancel stops the work instead of greying out a row, that a stalled transfer reads as stalled rather than reporting its own historical average, and that a released transport is closed by the last transfer and not before. The widget by the bell shows each transfer with its rate and a cancel button, so the Files panel no longer carries progress bars — you can watch a 40 GB archive from the chat, or from another group. Selection replaces the per-row menu: a Select toggle puts checkboxes on files and folders, and ⋮ Actions acts on what is ticked. Ticks survive walking into another folder, so a selection can span directories. Downloads start together and run together. Videos offer Play only — View did the same thing, which is the sort of duplication that makes people wonder what the difference is. Uploads had to become parallel-safe for any of this to mean anything: their acks were matched by arrival order, so two at once credited each other's progress. The node names the file in every ack, so they are keyed by name now — with the same file twice refused, since the node keys its own upload state that way too. Two mistakes worth recording. The selection column went into the body rows and not the header, because that edit matched nothing and I had not made it assert; the columns were misaligned until a screenshot showed it. And the Actions menu opened leftwards from a button at the right edge of the toolbar, half of it off-screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/test_spa_ordering.py32
-rw-r--r--packages/meshbay-hub/tests/test_transfers.py169
2 files changed, 201 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py
index 1329b74..04c5159 100644
--- a/packages/meshbay-hub/tests/test_spa_ordering.py
+++ b/packages/meshbay-hub/tests/test_spa_ordering.py
@@ -167,3 +167,35 @@ def test_no_caller_waits_for_one_chunk_at_a_time():
app = APP.read_text()
assert "uploadChunk(" not in app, (
"a per-chunk await is back in the SPA; use transport.uploadFile()")
+
+
+# ── Transfers outlive the page ──────────────────────────────────────────────
+#
+# Downloads and uploads used to be state inside GroupPage, so leaving a group
+# unmounted the component, its cleanup closed the DataChannel, and a half-written
+# file was all you had. The store in transfers.js owns them now; these check the
+# two ends of that, since neither shows up in any Python test.
+
+def test_leaving_a_group_hands_the_transport_over_rather_than_closing_it():
+ app = APP.read_text()
+ cleanup = app[app.index(" return () => {\n cancelled = true;"):]
+ cleanup = cleanup[:cleanup.index("\n }, [groupId")]
+ assert "releaseWhenIdle" in cleanup, (
+ "the group page closes its transport directly again — a running "
+ "download would die with the page")
+ assert ".close()" not in cleanup
+
+
+def test_signing_out_stops_them():
+ app = APP.read_text()
+ logout = app[app.index(" logout: () => {"):]
+ logout = logout[:logout.index("navigate('/login')")]
+ assert "transfers.reset()" in logout, (
+ "logout must cancel transfers: they run on tokens that stop being ours")
+
+
+def test_the_files_panel_no_longer_carries_its_own_progress_bars():
+ """They moved next to the bell, where they stay visible across the app."""
+ app = APP.read_text()
+ for gone in ("setUlState", "setDlState", "dl-bar"):
+ assert gone not in app, f"{gone} survived the move to the transfer widget"
diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py
new file mode 100644
index 0000000..08743f6
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_transfers.py
@@ -0,0 +1,169 @@
+"""
+The transfer store, which is what keeps a download alive after you leave a group.
+
+Run under Node, because the behaviour worth pinning is timing and lifetime:
+that a cancel actually stops the work rather than only greying out a row, that a
+stalled transfer reads as slow instead of reporting its historical average, and
+that a transport handed over by a departing page is closed by the last transfer
+using it — not before, and not never.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+TRANSFERS = STATIC / "transfers.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not TRANSFERS.exists(),
+ reason="node or the SPA sources are not available")
+
+
+def _run(body, tmp_path):
+ module = tmp_path / "transfers.mjs"
+ module.write_text(TRANSFERS.read_text())
+ script = tmp_path / "case.mjs"
+ script.write_text(
+ f"import {{ TransferStore, formatSpeed }} from '{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_a_cancel_actually_stops_the_work(tmp_path):
+ """
+ The flag has to be read by the thing doing the work. A store that only
+ marks a row cancelled gives you a button that lies.
+ """
+ result = _run("""
+const store = new TransferStore();
+let chunksWritten = 0;
+const id = store.start({
+ kind: 'download', name: 'big.mp4', total: 100,
+ run: async ({ signal, onProgress }) => {
+ for (let i = 0; i < 100; i++) {
+ if (signal.aborted) throw Object.assign(new Error('stop'), { name: 'AbortError' });
+ chunksWritten++;
+ onProgress(i + 1, 100);
+ await new Promise(r => setTimeout(r, 1));
+ }
+ },
+});
+await new Promise(r => setTimeout(r, 20));
+store.cancel(id);
+await new Promise(r => setTimeout(r, 40));
+say(chunksWritten < 100, store.list()[0].status, store.active);
+""", tmp_path)
+ assert result[0] is True, "the work ran to completion despite being cancelled"
+ assert result[1] == "cancelled"
+ assert result[2] == 0
+
+
+def test_speed_is_measured_over_a_window_not_since_the_start(tmp_path):
+ """A transfer that stalls should read as slow now, not as its own average."""
+ result = _run("""
+let clock = 0;
+const store = new TransferStore(() => clock);
+let report;
+store.start({
+ kind: 'download', name: 'x', total: 100_000_000,
+ run: async ({ onProgress }) => { report = onProgress; await new Promise(() => {}); },
+});
+await new Promise(r => setTimeout(r, 1)); // start() defers run() by a tick
+// A fast megabyte a second, for six seconds.
+for (let i = 1; i <= 6; i++) { clock = i * 1000; report(i * 1_000_000, 100_000_000); }
+say(Math.round(store.list()[0].speed));
+// Then it stalls: the clock moves, the bytes do not.
+for (let i = 7; i <= 12; i++) { clock = i * 1000; report(6_000_000, 100_000_000); }
+say(Math.round(store.list()[0].speed));
+""", tmp_path)
+ assert 900_000 <= result[0] <= 1_100_000, f"expected ~1 MB/s, got {result[0]}"
+ assert result[1] == 0, f"a stalled transfer still reports {result[1]} B/s"
+
+
+def test_a_transport_is_closed_by_the_last_transfer_that_needed_it(tmp_path):
+ """
+ Leaving a group page must not kill a running download, and must not leak
+ the connection either.
+ """
+ result = _run("""
+const store = new TransferStore();
+let closed = 0;
+const transport = { close: () => { closed++; }, onIndexSync: () => {} };
+let done1, done2;
+store.start({ kind: 'download', name: 'a', transport,
+ run: () => new Promise(r => { done1 = r; }) });
+store.start({ kind: 'download', name: 'b', transport,
+ run: () => new Promise(r => { done2 = r; }) });
+
+store.releaseWhenIdle(transport); // the page goes away
+await new Promise(r => setTimeout(r, 5));
+say(closed); // still working: must stay open
+
+done1(); await new Promise(r => setTimeout(r, 5));
+say(closed); // one left: still open
+
+done2(); await new Promise(r => setTimeout(r, 5));
+say(closed); // now it can go
+""", tmp_path)
+ assert result == [0, 0, 1], f"close() calls after each step: {result}"
+
+
+def test_an_idle_transport_is_closed_straight_away(tmp_path):
+ result = _run("""
+const store = new TransferStore();
+let closed = 0;
+store.releaseWhenIdle({ close: () => { closed++; } });
+say(closed);
+""", tmp_path)
+ assert result == [1], "leaving a group with nothing running should close it"
+
+
+def test_signing_out_cancels_everything_and_lets_go(tmp_path):
+ """
+ Navigating is not signing out. The second one has to stop transfers running
+ on tokens that are about to stop being ours.
+ """
+ result = _run("""
+const store = new TransferStore();
+let closed = 0;
+const transport = { close: () => { closed++; } };
+store.start({ kind: 'upload', name: 'a', transport,
+ run: () => new Promise(() => {}) });
+store.reset();
+await new Promise(r => setTimeout(r, 5));
+say(store.list().length, closed, store.active);
+""", tmp_path)
+ assert result == [0, 1, 0]
+
+
+def test_a_failure_is_kept_and_named(tmp_path):
+ """A transfer that dies silently is one the user retries at random."""
+ result = _run("""
+const store = new TransferStore();
+store.start({ kind: 'download', name: 'gone.mp4',
+ run: async () => { throw new Error('File not found'); } });
+await new Promise(r => setTimeout(r, 5));
+const row = store.list()[0];
+say(row.status, row.error);
+store.clearFinished();
+say(store.list().length);
+""", tmp_path)
+ assert result[0] == "failed"
+ assert result[1] == "File not found"
+ assert result[2] == 0
+
+
+def test_the_rate_is_readable(tmp_path):
+ result = _run("""
+say(formatSpeed(0), formatSpeed(2048), formatSpeed(5 * 1024 * 1024));
+""", tmp_path)
+ assert result == ["", "2 KB/s", "5.0 MB/s"]