""" 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"] # ── What the widget shows without being opened ────────────────────────────── APP = STATIC / "app.js" def _widget() -> str: source = APP.read_text(encoding="utf-8") start = source.index("function TransferWidget(") return source[start:source.index("\nfunction ", start + 1)] def test_the_widget_marks_itself_while_transfers_run(): """ The badge counts them, but a count has to be read. Colour is what says "something is moving" from across the room, which is the point of a widget that lives in the nav bar rather than on the page. """ widget = _widget() assert "running.length ? 'active' : ''" in widget, ( "the button no longer marks itself while transfers are running") def test_the_mark_comes_off_when_the_last_one_finishes(): """`running` is derived from the live list on every render, not stored — so there is no state to forget to clear.""" widget = _widget() assert "const running = items.filter(i => i.status === 'running');" in widget def test_the_colour_is_defined_for_that_mark(): """The class is set in one file and coloured in another; either alone does nothing, and neither fails loudly.""" css = (STATIC / "style.css").read_text(encoding="utf-8") assert ".transfer-btn.active" in css # ── The file list ─────────────────────────────────────────────────────────── def test_a_folder_name_carries_no_trailing_slash(): """The folder icon in the cell beside it already says what it is.""" source = STATIC.joinpath("files-app.js").read_text(encoding="utf-8") row = source[source.index('class="file-row dir-row"'):] row = row[:row.index("")] assert "${d}/" not in row, "the folder name is rendered with a trailing slash" assert "${d}" in row