aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/test_layout_measured.py119
-rw-r--r--packages/meshbay-hub/tests/test_memory_ceiling.py36
-rw-r--r--packages/meshbay-hub/tests/test_transfers.py213
-rw-r--r--packages/meshbay-hub/tests/test_zip_size_limit.py12
4 files changed, 375 insertions, 5 deletions
diff --git a/packages/meshbay-hub/tests/test_layout_measured.py b/packages/meshbay-hub/tests/test_layout_measured.py
index 91f1ed0..a71b6b9 100644
--- a/packages/meshbay-hub/tests/test_layout_measured.py
+++ b/packages/meshbay-hub/tests/test_layout_measured.py
@@ -54,7 +54,7 @@ NAV = textwrap.dedent("""
<div class="transfer-item">
<div class="transfer-line">
<span class="transfer-kind">&#8595;</span>
- <span class="transfer-name">S03E01. Salt and Sea, Fire and Blood.mp4</span>
+ <span class="transfer-name">Some Saga S03E01 - A Long Enough Title.mp4</span>
<button class="transfer-cancel">&#10005;</button>
</div>
<div class="dl-progress"><div class="dl-fill" style="width:42%"></div></div>
@@ -144,3 +144,120 @@ def test_the_page_does_not_scroll_sideways(measured, width):
r = measured[str(width)]
assert r["docScrollW"] <= r["viewport"]["w"], (
f"the document scrolls to {r['docScrollW']} px on a {width} px screen")
+
+
+# ── The grouped panel (§8.2) ────────────────────────────────────────────────
+#
+# The panel gained groups, a header summary and a waiting row. Every one of
+# those can push something off a 320 px screen, and none of it can be seen by
+# reading the stylesheet: what decides where the panel lands is the button it
+# hangs from, which is not at the right edge. That is the defect this file was
+# written for, and it comes back with any change to the header's width.
+
+GROUPED = textwrap.dedent("""
+ <nav class="nav">
+ <div class="nav-left">
+ <button class="nav-hamburger">&#9776;</button>
+ <a class="nav-brand" href="#/">MeshBay</a>
+ </div>
+ <div class="nav-right">
+ <div class="transfer-wrap">
+ <button class="nav-notif transfer-btn">&#8595;</button>
+ <div class="transfer-panel">
+ <div class="transfer-head">
+ <span class="transfer-head-title">Transfers</span>
+ <span class="transfer-head-summary">2 running &middot; 3 waiting</span>
+ <button class="btn-secondary">Clear finished</button>
+ </div>
+ <div class="transfer-group">
+ <div class="transfer-group-head">Running</div>
+ <div class="transfer-item transfer-running">
+ <div class="transfer-line">
+ <span class="transfer-kind">&#8595;</span>
+ <span class="transfer-name">Some Saga S03E01 - A Long Enough Title.mp4</span>
+ <button class="transfer-cancel">&#10005;</button>
+ </div>
+ <div class="dl-progress"><div class="dl-fill" style="width:42%"></div></div>
+ <div class="transfer-meta"><span>210 MB / 493 MB</span><span>3.1 MB/s &middot; 4 min left</span></div>
+ </div>
+ </div>
+ <div class="transfer-group">
+ <div class="transfer-group-head">Waiting</div>
+ <div class="transfer-item transfer-queued">
+ <div class="transfer-line">
+ <span class="transfer-kind">&#8595;</span>
+ <span class="transfer-name">Another File With A Long Name.mkv</span>
+ <button class="transfer-cancel">&#10005;</button>
+ </div>
+ <div class="dl-progress dl-waiting"></div>
+ <div class="transfer-meta"><span>Waiting &mdash; your slots are busy</span><span>1.2 GB</span></div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <a class="nav-notif" href="#/">&#128276;</a>
+ <div class="user-menu"><button class="nav-btn">someone</button></div>
+ </div>
+ </nav>
+""")
+
+GROUPED_SELECTORS = [".transfer-panel", ".transfer-head", ".transfer-head-summary",
+ ".transfer-group-head", ".transfer-name",
+ ".transfer-item.transfer-queued .dl-progress"]
+
+
+@pytest.fixture(scope="module")
+def grouped(tmp_path_factory):
+ fragment = tmp_path_factory.mktemp("grouped") / "fragment.html"
+ fragment.write_text(GROUPED)
+ proc = subprocess.run(
+ ["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS),
+ str(fragment), *GROUPED_SELECTORS],
+ capture_output=True, text=True, timeout=180)
+ assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
+ out = json.loads(proc.stdout)
+ assert "error" not in out, f"no measurement: {out}"
+ return out
+
+
+def test_the_grouped_panel_stays_on_a_phone_screen(grouped):
+ for width in WIDTHS:
+ box = grouped[str(width)]["boxes"][".transfer-panel"]
+ assert box["offLeft"] == 0, (
+ f"at {width} px the panel hangs {box['offLeft']} px off the left — "
+ "which is where the file names are")
+ assert box["offRight"] == 0, (
+ f"at {width} px the panel hangs {box['offRight']} px off the right")
+
+
+def test_the_file_name_is_on_screen_in_every_group(grouped):
+ for width in WIDTHS:
+ box = grouped[str(width)]["boxes"][".transfer-name"]
+ assert box["offLeft"] == 0 and box["offRight"] == 0, (
+ f"at {width} px a file name is cut off: {box}")
+ assert box["width"] > 40, "the name column collapsed to nothing"
+
+
+def test_the_header_summary_does_not_push_the_header_taller(grouped):
+ """It is the one part of the header that grows with what is happening. If
+ it wraps, the header changes height as transfers come and go and every row
+ below it moves — on the narrowest screen, repeatedly."""
+ for width in WIDTHS:
+ head = grouped[str(width)]["boxes"][".transfer-head"]
+ summary = grouped[str(width)]["boxes"][".transfer-head-summary"]
+ assert head["height"] <= 48, (
+ f"at {width} px the header is {head['height']} px tall — it wrapped")
+ assert summary["height"] <= 24, (
+ f"at {width} px the summary wrapped to {summary['height']} px")
+
+
+def test_the_waiting_bar_is_as_wide_as_a_progress_bar(grouped):
+ """A waiting row has no inner fill element — the stripes are on the track
+ itself. Getting that wrong renders a zero-width bar, which reads as a
+ transfer stuck at 0% rather than one that has not started."""
+ for width in WIDTHS:
+ bar = grouped[str(width)]["boxes"][
+ ".transfer-item.transfer-queued .dl-progress"]
+ assert bar["width"] > 100, (
+ f"at {width} px the waiting bar is {bar['width']} px wide")
+ assert bar["height"] >= 3, "the waiting bar has no height"
diff --git a/packages/meshbay-hub/tests/test_memory_ceiling.py b/packages/meshbay-hub/tests/test_memory_ceiling.py
index 9966e48..8430627 100644
--- a/packages/meshbay-hub/tests/test_memory_ceiling.py
+++ b/packages/meshbay-hub/tests/test_memory_ceiling.py
@@ -92,9 +92,15 @@ const downloads = {{
}};
globalThis.window = {{}};
if ({json.dumps(picker)}) {{
- window.showSaveFilePicker = async () => ({{
- name: 'p', createWritable: async () => ({{}}),
- }});
+ window.showSaveFilePicker = async () => {{
+ if ({json.dumps(picker)} === 'no-gesture') {{
+ const e = new Error("Failed to execute 'showSaveFilePicker' on 'Window': "
+ + "Must be handling a user gesture to show a file picker.");
+ e.name = 'SecurityError';
+ throw e;
+ }}
+ return {{ name: 'p', createWritable: async () => ({{}}) }};
+ }};
}}
{target_fn}
@@ -207,3 +213,27 @@ def test_the_guard_is_what_the_preview_uses_too(target_fn):
"the preview modal must refuse an oversized entry before fetching it")
assert not re.search(r"100\s*\*\s*1024\s*\*\s*1024", files_app), (
"the ceiling is defined once, in file-utils.js")
+
+
+def test_a_lost_gesture_streams_instead_of_failing(target_fn, tmp_path):
+ """
+ A browser grants one file picker per user gesture, and downloading three
+ files is one gesture — so the second and third throw "Must be handling a
+ user gesture". The person sees a failed transfer, with a message from Chrome
+ about gestures, for having done something entirely reasonable.
+
+ The streamed path needs no gesture, so it is the right answer rather than a
+ consolation: the file lands on disk either way, and the only thing lost is
+ the choice of folder, which there was no picker to make anyway.
+ """
+ out = _run(target_fn, tmp_path, size=20 * GB,
+ picker="no-gesture", streamed=True, mode="ask")
+ assert out == {"kind": "stream", "name": "s"}, out
+
+
+def test_a_lost_gesture_with_nothing_to_stream_to_still_refuses(target_fn, tmp_path):
+ """And the ceiling still holds underneath: no gesture and no stream is not
+ a reason to put twenty gigabytes in the page."""
+ out = _run(target_fn, tmp_path, size=20 * GB,
+ picker="no-gesture", streamed=False, mode="ask")
+ assert out["kind"] == "refused", out
diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py
index 3316615..e805afc 100644
--- a/packages/meshbay-hub/tests/test_transfers.py
+++ b/packages/meshbay-hub/tests/test_transfers.py
@@ -222,3 +222,216 @@ def test_a_folder_name_carries_no_trailing_slash():
assert "dir-row" in row, "the anchor no longer lands on the directory row"
assert "${d}/" not in row, "the folder name is rendered with a trailing slash"
assert "${d}" in row
+
+
+# ── Transfer slots, client side ─────────────────────────────────────────────
+#
+# A queue can lie in two directions, and both are worse than no queue: a
+# transfer that shows "waiting" on a node that already granted it, and a slot
+# the page holds after it has stopped using it. Everything below is one of
+# those two.
+
+def _lease_stub():
+ """A Lease as the store sees it, driveable from the test."""
+ return """
+class L {
+ constructor() {
+ this.state = 'queued'; this.ahead = 2; this.closed = false;
+ this.released = []; this.tr = 'tr1';
+ this._wait = new Promise(r => { this._go = r; });
+ }
+ acquire() { return this._wait; }
+ release(reason) { if (!this.closed) { this.closed = true; this.released.push(reason); } }
+ grant() { this.state = 'granted'; if (this._onState) this._onState(this); this._go(); }
+ push(state, ahead) { this.state = state; this.ahead = ahead; if (this._onState) this._onState(this); }
+}
+"""
+
+
+def test_a_transfer_waiting_for_a_slot_is_queued_not_running(tmp_path):
+ out = _run(_lease_stub() + """
+ const t = new TransferStore();
+ const lease = new L();
+ t.start({ kind: 'download', name: 'f', total: 10, lease,
+ run: async () => { say('ran'); } });
+ say(t.list()[0].status, t.list()[0].ahead);
+ await new Promise(r => setTimeout(r, 0));
+ say('still:' + t.list()[0].status);
+ """, tmp_path)
+ assert out[:2] == ["queued", 2]
+ assert "ran" not in out, "the work started before the slot was granted"
+ assert out[-1] == "still:queued"
+
+
+def test_the_grant_starts_the_work(tmp_path):
+ out = _run(_lease_stub() + """
+ const t = new TransferStore();
+ const lease = new L();
+ t.start({ kind: 'download', name: 'f', total: 10, lease,
+ run: async () => { say('ran:' + t.list()[0].status); } });
+ lease.grant();
+ await new Promise(r => setTimeout(r, 10));
+ say('after:' + t.list()[0].status);
+ """, tmp_path)
+ assert out[0] == "ran:running"
+ assert out[1] == "after:done"
+
+
+def test_the_slot_comes_back_however_the_transfer_ends(tmp_path):
+ """A slot not returned is a member who cannot transfer again until the node
+ times it out — so this must hold for a throw as much as for a success."""
+ out = _run(_lease_stub() + """
+ for (const mode of ['ok', 'throw']) {
+ const t = new TransferStore();
+ const lease = new L();
+ t.start({ kind: 'download', name: 'f', total: 10, lease,
+ run: async () => { if (mode === 'throw') throw new Error('x'); } });
+ lease.grant();
+ await new Promise(r => setTimeout(r, 10));
+ say(mode + ':' + lease.released.join(',') + ':' + t.list()[0].status);
+ }
+ """, tmp_path)
+ assert out == ["ok:done:done", "throw:done:failed"]
+
+
+def test_cancelling_while_queued_gives_the_slot_back(tmp_path):
+ """The transfer somebody is most likely to give up on is the one that has
+ not started. Its queue entry has to go, or the node grants a slot to a
+ transfer that will never use it."""
+ out = _run(_lease_stub() + """
+ const t = new TransferStore();
+ const lease = new L();
+ const id = t.start({ kind: 'download', name: 'f', total: 10, lease,
+ run: async () => { say('ran'); } });
+ t.cancel(id);
+ say(t.list()[0].status, lease.released.join(','));
+ lease.grant();
+ await new Promise(r => setTimeout(r, 10));
+ say('ran?', out.includes('ran'));
+ """, tmp_path)
+ assert out[0] == "cancelled"
+ assert out[1] == "cancelled"
+ assert out[-1] is False, "a cancelled transfer ran anyway once granted"
+
+
+def test_a_queue_position_update_reaches_the_view(tmp_path):
+ out = _run(_lease_stub() + """
+ const t = new TransferStore();
+ const lease = new L();
+ const seen = [];
+ t.subscribe(items => seen.push(items[0].ahead));
+ t.start({ kind: 'download', name: 'f', total: 10, lease, run: async () => {} });
+ lease.push('queued', 1);
+ lease.push('queued', 0);
+ say(seen.join('>'));
+ """, tmp_path)
+ assert out[0].endswith("1>0"), "the widget never learns it is moving up"
+
+
+def test_a_transport_with_a_queued_transfer_is_not_closed(tmp_path):
+ """Closing it would leave the transfer waiting for a grant that can never
+ arrive — waiting for ever, with nothing left to answer."""
+ out = _run(_lease_stub() + """
+ const t = new TransferStore();
+ const lease = new L();
+ let closed = false;
+ const transport = { close() { closed = true; } };
+ t.start({ kind: 'download', name: 'f', total: 10, transport, lease,
+ run: async () => {} });
+ t.releaseWhenIdle(transport);
+ say('closed while queued:', closed);
+ lease.grant();
+ await new Promise(r => setTimeout(r, 10));
+ say('closed after:', closed);
+ """, tmp_path)
+ assert out[1] is False
+ assert out[3] is True
+
+
+def test_clearing_finished_keeps_what_is_waiting(tmp_path):
+ out = _run(_lease_stub() + """
+ const t = new TransferStore();
+ const lease = new L();
+ t.start({ kind: 'download', name: 'waiting', total: 1, lease, run: async () => {} });
+ t.start({ kind: 'download', name: 'done', total: 1, run: async () => {} });
+ await new Promise(r => setTimeout(r, 10));
+ t.clearFinished();
+ say(t.list().map(i => i.name + ':' + i.status).join(','));
+ """, tmp_path)
+ assert out[0] == "waiting:queued"
+
+
+def test_asking_for_a_slot_on_a_dead_channel_does_not_throw(tmp_path):
+ """
+ The transport reconnects on its own and re-asks for every live lease when it
+ does, so a closed channel at the moment a transfer starts is a wait, not a
+ failure. `_fetchChunkResilient` has always treated it that way — and before
+ leases existed a chunk request was the first thing to touch the channel, so
+ a download begun on a briefly dead connection simply retried.
+
+ Asking for a slot first made `_send` the first contact. It threw
+ "DataChannel not open (state: closed)" straight out of `downloadEntry`,
+ where nothing catches it: a download that used to recover became an error
+ with no row in the widget to show it. Found live, by downloading a file
+ just after a connection dropped.
+ """
+ module = tmp_path / "transport_lease.mjs"
+ # The real Lease, lifted out as text — the class is not exported, and a
+ # second copy of it here would agree with whatever it was copied from.
+ src = (STATIC / "transport.js").read_text()
+ # From the constant the class depends on, not from the class: lifting only
+ # the class left LEASE_WATCHDOG_MS undefined, which the class reads the
+ # first time it arms its watchdog.
+ start = src.index("const LEASE_WATCHDOG_MS")
+ end = src.index("\nclass MeshBayTransport")
+ module.write_text(src[start:end] + "\nexport { Lease };\n")
+
+ script = tmp_path / "case.mjs"
+ script.write_text(f"""
+import {{ Lease }} from '{module.as_posix()}';
+const out = [];
+const transport = {{
+ supportsTransferSlots: true,
+ _leases: new Map(),
+ _send() {{ throw new Error('DataChannel not open (state: closed)'); }},
+}};
+let threw = null;
+const lease = new Lease(transport, 'tr1', 'download', 10, 1, null);
+try {{ lease._request(); }} catch (e) {{ threw = e.message; }}
+out.push(threw);
+// And releasing one must be just as safe: a lease not released is a member who
+// cannot start another transfer until the node times it out.
+try {{ lease.release('cancelled'); out.push('release ok'); }}
+catch (e) {{ out.push('release threw: ' + e.message); }}
+clearTimeout(lease._watchdog);
+console.log(JSON.stringify(out));
+""")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ out = json.loads(proc.stdout)
+ assert out[0] is None, f"asking for a slot threw: {out[0]}"
+ assert out[1] == "release ok"
+
+
+def test_the_slot_is_asked_for_after_there_is_somewhere_to_write():
+ """
+ A granted slot has to be taken up within the node's acceptance deadline, so
+ it must not be asked for until the download can actually start.
+
+ Asking first reads better — the widget could draw a row while the target is
+ being chosen — and is wrong: opening a target takes thirty seconds of
+ streamed-download timeouts, or as long as somebody leaves a Save As dialog
+ open. The node revokes the grant, passes it to the next in the queue
+ (`transfer: reclaimed … (not_taken_up)` in its log), and the download then
+ fetches under a `tr` that is no longer granted. Three downloads started, one
+ arrived.
+
+ Source-reading, because the ordering is the whole property and it has no
+ behaviour of its own to drive: what matters is which call comes first.
+ """
+ src = (STATIC / "file-utils.js").read_text()
+ fn = src[src.index("async function downloadEntry"):]
+ fn = fn[:fn.index("\n}\n")]
+ assert fn.index("_openDownloadTarget") < fn.index("openTransfer"), (
+ "downloadEntry asks for a transfer slot before it has anywhere to "
+ "write — the grant expires before the download can use it")
diff --git a/packages/meshbay-hub/tests/test_zip_size_limit.py b/packages/meshbay-hub/tests/test_zip_size_limit.py
index 44ad59e..e970c55 100644
--- a/packages/meshbay-hub/tests/test_zip_size_limit.py
+++ b/packages/meshbay-hub/tests/test_zip_size_limit.py
@@ -81,7 +81,17 @@ if ({picker_js}) {{
const M = await import('{(sandbox / "file-utils.js").as_posix()}');
const transfers = {{ start: () => {{ out.started += 1; }} }};
-const transport = {{ connected: true }};
+// A transport hands out transfer slots now (transfers.py's leases). The stub
+// grants at once, which is what a node with no caps does: what this file is
+// about is the archive limit, not the queue.
+const transport = {{
+ connected: true,
+ openTransfer: () => ({{
+ tr: 'stub', state: 'granted', ahead: 0,
+ acquire: () => Promise.resolve(),
+ release: () => {{}},
+ }}),
+}};
// One file, in the folder itself — entriesUnder keys on `path`.
const entries = [{{ id: 'f1', name: 'big.bin', path: 'album',
size: {total_bytes}, added_at: 0 }}];