diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/test_connect_never_hangs.py | 91 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_resume_position.py | 158 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_spa_ordering.py | 59 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_table_rows_measured.py | 116 |
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}") |