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_connect_never_hangs.py91
-rw-r--r--packages/meshbay-hub/tests/test_resume_position.py158
-rw-r--r--packages/meshbay-hub/tests/test_spa_ordering.py59
-rw-r--r--packages/meshbay-hub/tests/test_table_rows_measured.py116
4 files changed, 416 insertions, 8 deletions
diff --git a/packages/meshbay-hub/tests/test_connect_never_hangs.py b/packages/meshbay-hub/tests/test_connect_never_hangs.py
new file mode 100644
index 0000000..d6f1369
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_connect_never_hangs.py
@@ -0,0 +1,91 @@
+"""
+Joining a group must fail, or succeed — never wait forever.
+
+Reported: a first attempt to connect hung with nothing on screen, and the same
+account connected a few minutes later. That is the shape of a network wait with
+no deadline, not of a refusal, and there were two of them.
+
+**ICE gathering.** Signaling here is non-trickle — the offer carries its
+candidates, so it is not sent until gathering says it is done. A STUN server
+that is slow, filtered, or resolved through a DNS that is not answering means
+`icegatheringstatechange` never reaches `complete`, and `connect()` never
+returns. Same shape as the `fullscreen` denial: a promise that never settles
+produces no error to find.
+
+**The hub call in the desktop client.** Node's `fetch` has no default timeout,
+so a host that accepts a connection and then says nothing holds the request for
+as long as the OS allows. `hub:probe` had a deadline; `hub:fetch`, which carries
+signaling, did not.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+TRANSPORT = STATIC / "transport.js"
+MAIN = (Path(__file__).resolve().parents[2] / "meshbay-client" / "src" / "main.js")
+
+pytestmark = pytest.mark.skipif(not TRANSPORT.exists(),
+ reason="SPA sources unavailable")
+
+
+def _gathering_block() -> str:
+ """The wait on ICE gathering, and only it."""
+ source = TRANSPORT.read_text(encoding="utf-8")
+ start = source.index("iceGatheringState")
+ return source[max(0, start - 900):start + 700]
+
+
+def test_ice_gathering_has_a_deadline():
+ block = _gathering_block()
+ assert "setTimeout" in block, (
+ "the wait on ICE gathering can never end, and connect() with it")
+ assert "ICE_GATHER_TIMEOUT_MS" in block
+
+
+def test_the_deadline_is_long_enough_for_a_stun_round_trip():
+ """Cutting gathering off too early drops the reflexive candidate and breaks
+ every connection that is not on the same network."""
+ source = TRANSPORT.read_text(encoding="utf-8")
+ match = re.search(r"const ICE_GATHER_TIMEOUT_MS = (\d+);", source)
+ assert match, "the constant is gone or was renamed"
+ assert 2000 <= int(match.group(1)) <= 10000
+
+
+def test_a_timed_out_gathering_still_sends_the_offer():
+ """Host candidates are already gathered, which is enough on a LAN. Giving
+ up instead would turn a slow STUN server into a refusal to connect."""
+ source = TRANSPORT.read_text(encoding="utf-8")
+ block = _gathering_block()
+ # The deadline resolves the promise; it does not reject it.
+ assert "reject" not in block.split("setTimeout", 1)[1][:300]
+ # And the offer is still posted afterwards.
+ assert "webrtc/offer" in source[source.index("iceGatheringState"):]
+
+
+@pytest.mark.skipif(not MAIN.exists(), reason="the desktop client is not present")
+def test_every_hub_call_from_the_client_has_a_deadline():
+ source = MAIN.read_text(encoding="utf-8")
+ block = source.split("ipcMain.handle('hub:fetch'", 1)[1].split("ipcMain.handle", 1)[0]
+ assert "AbortSignal.timeout" in block, (
+ "a hub that accepts the connection and says nothing holds this for "
+ "as long as the OS allows")
+
+
+@pytest.mark.skipif(not MAIN.exists(), reason="the desktop client is not present")
+def test_the_deadline_outlasts_the_hubs_own_longest_call():
+ """Signaling waits fifteen seconds for a node to answer an offer. A client
+ deadline under that would abort calls that were about to succeed."""
+ source = MAIN.read_text(encoding="utf-8")
+ match = re.search(r"const HUB_FETCH_TIMEOUT_MS = (\d+);", source)
+ assert match, "the constant is gone or was renamed"
+ assert int(match.group(1)) > 15000
+
+
+@pytest.mark.skipif(not MAIN.exists(), reason="the desktop client is not present")
+def test_a_timeout_says_so_rather_than_saying_fetch_failed():
+ source = MAIN.read_text(encoding="utf-8")
+ block = source.split("ipcMain.handle('hub:fetch'", 1)[1].split("ipcMain.handle", 1)[0]
+ assert "TimeoutError" in block and "did not answer" in block
diff --git a/packages/meshbay-hub/tests/test_resume_position.py b/packages/meshbay-hub/tests/test_resume_position.py
new file mode 100644
index 0000000..ef239c5
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_resume_position.py
@@ -0,0 +1,158 @@
+"""
+"Resume where you left off" belongs to an account, not to a machine.
+
+The position was stored as `mb:pos:<file>` in localStorage — per *device*. Sign
+in with a second account on the same computer and the player offered to resume a
+film that account had never opened. Wrong on its own terms, and a small
+disclosure of what the other person watches: the offer only appears for files
+someone has actually been through.
+
+The functions are lifted out of `app.js` and run for real, rather than having
+their source inspected, because the thing worth holding is the behaviour of two
+accounts sharing one storage — which no assertion about the source text says.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+APP = STATIC / "app.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not APP.exists(),
+ reason="node or the SPA sources are not available")
+
+WANTED = ("resumeKey", "readResumePosition", "writeResumePosition",
+ "purgeUnscopedResumePositions")
+
+
+def _extract() -> str:
+ """The real source of the functions under test, and nothing else.
+
+ `app.js` imports preact and cannot be loaded outside a browser, so the
+ declarations are sliced out by brace matching. A rename breaks this loudly,
+ which is the intent — a silently skipped test is worse than a failing one.
+ """
+ source = APP.read_text(encoding="utf-8")
+ out = [
+ f"const RESUME_MIN_S = {_const(source, 'RESUME_MIN_S')};",
+ f"const RESUME_MAX_FRACTION = {_const(source, 'RESUME_MAX_FRACTION')};",
+ ]
+ for name in WANTED:
+ start = source.index(f"function {name}(")
+ depth, i = 0, source.index("{", start)
+ while True:
+ if source[i] == "{":
+ depth += 1
+ elif source[i] == "}":
+ depth -= 1
+ if depth == 0:
+ break
+ i += 1
+ out.append(source[start:i + 1])
+ return "\n".join(out)
+
+
+def _const(source: str, name: str) -> str:
+ match = re.search(rf"^const {name} = ([^;]+);", source, re.M)
+ assert match, f"{name} is gone or was renamed"
+ return match.group(1)
+
+
+def _run(body: str, tmp_path: Path):
+ script = tmp_path / "case.mjs"
+ script.write_text(
+ "const store = new Map();\n"
+ "globalThis.localStorage = {\n"
+ " get length() { return store.size; },\n"
+ " key: i => Array.from(store.keys())[i] ?? null,\n"
+ " getItem: k => (store.has(k) ? store.get(k) : null),\n"
+ " setItem: (k, v) => store.set(k, String(v)),\n"
+ " removeItem: k => store.delete(k),\n"
+ "};\n"
+ # Whoever is signed in, swapped by the cases below.
+ "let AUTH = null;\n"
+ "function loadAuth() { return AUTH; }\n"
+ f"{_extract()}\n"
+ "const out = [];\n"
+ "const say = (...a) => out.push(...a);\n"
+ "const keys = () => Array.from(store.keys()).sort();\n"
+ f"{body}\n"
+ "console.log(JSON.stringify(out));\n",
+ encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+# ── The bug ─────────────────────────────────────────────────────────────────
+
+def test_a_second_account_is_not_offered_the_firsts_position(tmp_path):
+ """The report: a brand-new account was offered a resume point."""
+ assert _run("""
+AUTH = { userId: 'alice' };
+writeResumePosition('film1', 600, 7200);
+say(readResumePosition('film1'));
+
+AUTH = { userId: 'bob' };
+say(readResumePosition('film1'));
+""", tmp_path) == [600, 0]
+
+
+def test_each_account_keeps_its_own_place_in_the_same_film(tmp_path):
+ """Two people watching one film on one machine is the ordinary case, and
+ neither should move the other's bookmark."""
+ assert _run("""
+AUTH = { userId: 'alice' }; writeResumePosition('film1', 600, 7200);
+AUTH = { userId: 'bob' }; writeResumePosition('film1', 1800, 7200);
+AUTH = { userId: 'alice' }; say(readResumePosition('film1'));
+AUTH = { userId: 'bob' }; say(readResumePosition('film1'));
+""", tmp_path) == [600, 1800]
+
+
+def test_nothing_is_written_when_nobody_is_signed_in(tmp_path):
+ assert _run("""
+AUTH = null;
+writeResumePosition('film1', 600, 7200);
+say(keys().length, readResumePosition('film1'));
+""", tmp_path) == [0, 0]
+
+
+def test_positions_written_before_the_fix_are_dropped(tmp_path):
+ """
+ They cannot be re-keyed: there is no record of whose they were, and guessing
+ hands them to whoever signs in next, which is the bug itself.
+ """
+ assert _run("""
+localStorage.setItem('mb:pos:film1', '600'); // the old shape
+localStorage.setItem('mb:pos:alice:film2', '900'); // the new one
+localStorage.setItem('mb_auth', '{}'); // nothing to do with this
+purgeUnscopedResumePositions();
+say(...keys());
+""", tmp_path) == ["mb:pos:alice:film2", "mb_auth"]
+
+
+# ── What must still hold ────────────────────────────────────────────────────
+
+def test_a_glance_at_the_opening_is_not_a_bookmark(tmp_path):
+ assert _run("""
+AUTH = { userId: 'alice' };
+writeResumePosition('film1', 12, 7200);
+say(readResumePosition('film1'));
+""", tmp_path) == [0]
+
+
+def test_a_film_watched_to_the_end_stops_offering_to_resume(tmp_path):
+ """And the earlier bookmark goes with it, rather than sitting there
+ offering the last thirty seconds forever."""
+ assert _run("""
+AUTH = { userId: 'alice' };
+writeResumePosition('film1', 3600, 7200);
+writeResumePosition('film1', 7150, 7200);
+say(readResumePosition('film1'), keys().length);
+""", tmp_path) == [0, 0]
diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py
index 9d02d42..1556cb7 100644
--- a/packages/meshbay-hub/tests/test_spa_ordering.py
+++ b/packages/meshbay-hub/tests/test_spa_ordering.py
@@ -125,24 +125,67 @@ def _component(name: str) -> str:
return source[start:end if end != -1 else len(source)]
-def test_members_panel_renders_what_it_owns():
- panel = _component("MembersPanel")
+def test_the_group_settings_panel_renders_what_it_owns():
+ panel = _component("GroupSettingsPanel")
assert "members.map(" in panel, "the member list is not rendered"
assert "onSubmit=${doInvite}" in panel, "the invite form is not rendered"
assert "onSubmit=${doPair}" in panel, "the pairing form is not rendered"
+ assert "device.mine_title" in panel, "the devices section is not rendered"
-def test_the_invite_form_comes_before_the_list():
- panel = _component("MembersPanel")
- assert panel.index("onSubmit=${doInvite}") < panel.index("members.map("), \
- "the invite form belongs above the member list"
+def test_the_roster_comes_last():
+ """
+ It is the only part of this tab with no upper bound. Two hundred members
+ would put every form and every control below the fold, which is what the
+ order is for — asked for in those terms.
+ """
+ panel = _component("GroupSettingsPanel")
+ listing = panel.index("members.map(")
+ for name, marker in (("the invite form", "onSubmit=${doInvite}"),
+ ("the pairing form", "onSubmit=${doPair}"),
+ ("the devices section", "device.mine_title"),
+ ("leaving and deleting", "members.danger_title")):
+ assert panel.index(marker) < listing, f"{name} belongs above the roster"
+
+
+def test_leaving_a_group_lives_with_the_group_settings():
+ """It used to sit in the page header beside the group's name, which is
+ neither where it belongs nor where anyone looked for it."""
+ panel = _component("GroupSettingsPanel")
+ assert "group.leave_confirm" in panel and "group.delete_group_confirm" in panel
+ page = _component("GroupPage")
+ assert "group.leave_confirm" not in page, "still in the header as well"
+
+
+def test_leaving_does_not_require_the_node_to_be_up():
+ """
+ Moving these into a tab that only rendered on a live connection would have
+ made them unreachable exactly when a node is down — which is when someone
+ most wants to leave. Membership is hub-side; the tab bar does not wait for
+ the node.
+ """
+ page = _component("GroupPage")
+ tabs = page[page.index("group-tabs"):]
+ tabs = tabs[:tabs.index("</div>")]
+ before = page[:page.index("group-tabs")]
+ guard = before[before.rindex("${"):]
+ assert "status === 'connected'" not in guard, (
+ "the tab bar is gated on the connection, so a group on an offline node "
+ "cannot be left")
+
+
+def test_the_node_dependent_sections_say_when_the_node_is_down():
+ """The other half of that: inviting needs the node to wrap the group key,
+ so it must explain itself rather than silently doing nothing."""
+ panel = _component("GroupSettingsPanel")
+ assert "connected &&" in panel and "!connected &&" in panel
-def test_admin_page_does_not_borrow_the_members_panel_state():
+def test_admin_page_does_not_borrow_the_group_settings_state():
admin = _component("AdminPage")
for name in ("doInvite", "adminId", "inviteCode", "setInviteUser"):
assert name not in admin, \
- f"AdminPage references {name}, which only exists in MembersPanel"
+ f"AdminPage references {name}, which only exists in GroupSettingsPanel"
# ── Upload pipelining ───────────────────────────────────────────────────────
diff --git a/packages/meshbay-hub/tests/test_table_rows_measured.py b/packages/meshbay-hub/tests/test_table_rows_measured.py
new file mode 100644
index 0000000..60ec878
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_table_rows_measured.py
@@ -0,0 +1,116 @@
+"""
+Every cell in a row ends where its row ends.
+
+Reported as "un décalage sur les lignes du tableau listant les membres": the
+horizontal rules between rows came out staggered rather than straight.
+
+The cause was `display: flex` on the actions `<td>`. **A flex table cell stops
+being a table cell** — it no longer stretches to the height of its row, so its
+`border-bottom` is drawn wherever its own content happens to end. Measured
+before the fix, in a row whose other cells were `top 76, height 40`, the actions
+cell was `top 77, height 30`: its rule nine pixels above the rest.
+
+Nothing in the stylesheet says this. `min-height: 30px` was already there, added
+for a related symptom, and reads as though it settles the question. Only the
+rectangles show it does not — which is what this file is for.
+"""
+
+import json
+import subprocess
+from pathlib import Path
+
+import shutil
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "layout_probe.py"
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("google-chrome") is None or not (STATIC / "style.css").exists(),
+ reason="Chrome or the SPA stylesheet is not available")
+
+WIDTHS = [420, 900]
+
+# The members table as `MembersTab` renders it: the owner's row carries no
+# button, which is the row that used to break. Two ordinary rows after it, so a
+# rule between two *equal* rows can be told from a rule against the odd one.
+TABLE = """
+<div class="main"><div class="settings-section">
+<table class="admin-table">
+ <thead><tr><th>User</th><th>Role</th><th></th></tr></thead>
+ <tbody>
+ <tr><td>grenet</td>
+ <td><span class="badge">Owner</span></td>
+ <td class="admin-actions"></td></tr>
+ <tr><td>toto</td>
+ <td><span class="badge">Member</span></td>
+ <td class="admin-actions"><button class="admin-btn danger">Remove</button></td></tr>
+ <tr><td>alice</td>
+ <td><span class="badge">Member</span></td>
+ <td class="admin-actions"><button class="admin-btn danger">Remove</button></td></tr>
+ </tbody>
+</table>
+</div></div>
+"""
+
+ROWS = (1, 2, 3)
+SELECTORS = [f"tbody tr:nth-child({r}) td:nth-child({c})"
+ for r in ROWS for c in (1, 2, 3)]
+
+
+@pytest.fixture(scope="module")
+def measured(tmp_path_factory):
+ fragment = tmp_path_factory.mktemp("table") / "fragment.html"
+ fragment.write_text(TABLE)
+ proc = subprocess.run(
+ ["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS),
+ str(fragment), *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 _cells(measured, width: int, row: int) -> list[dict]:
+ boxes = measured[str(width)]["boxes"]
+ return [boxes[f"tbody tr:nth-child({row}) td:nth-child({c})"] for c in (1, 2, 3)]
+
+
+@pytest.mark.parametrize("width", WIDTHS)
+@pytest.mark.parametrize("row", ROWS)
+def test_the_rule_under_a_row_is_one_straight_line(measured, width, row):
+ bottoms = [c["top"] + c["height"] for c in _cells(measured, width, row)]
+ assert max(bottoms) - min(bottoms) <= 1, (
+ f"row {row} at {width}px ends at {bottoms} — the border under the "
+ f"actions cell is drawn {max(bottoms) - min(bottoms)}px off the others")
+
+
+@pytest.mark.parametrize("width", WIDTHS)
+@pytest.mark.parametrize("row", ROWS)
+def test_every_cell_in_a_row_starts_at_the_same_height(measured, width, row):
+ tops = [c["top"] for c in _cells(measured, width, row)]
+ assert max(tops) - min(tops) <= 1, f"row {row} at {width}px starts at {tops}"
+
+
+@pytest.mark.parametrize("width", WIDTHS)
+def test_the_row_without_a_button_is_as_tall_as_the_others(measured, width):
+ """The owner cannot be removed, so that row has an empty actions cell. It
+ still has to be a row, not a thin one that reads as a rendering fault."""
+ heights = [_cells(measured, width, r)[0]["height"] for r in ROWS]
+ assert max(heights) - min(heights) <= 1, (
+ f"row heights at {width}px are {heights}")
+
+
+@pytest.mark.parametrize("width", WIDTHS)
+def test_the_rows_are_stacked_with_no_gap_or_overlap(measured, width):
+ """A cell that does not fill its row leaves the next one starting early or
+ late; consecutive rows meeting exactly is what says the table is intact."""
+ for row in ROWS[:-1]:
+ below = _cells(measured, width, row + 1)[0]["top"]
+ for cell in _cells(measured, width, row):
+ end = cell["top"] + cell["height"]
+ assert abs(below - end) <= 1, (
+ f"at {width}px a cell of row {row} ends at {end} while row "
+ f"{row + 1} starts at {below}")