summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-17 18:51:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-17 18:51:35 +0200
commit7e01b7e8c2ce2ef9fce8b6c053c3b7210b64f9bf (patch)
tree9b5d0aae04e226e1f3c7187a4866ee421544f7a4 /packages/meshbay-hub/tests
parent8125df6d8149368ed8a84c9db6c5e37bfccb5a15 (diff)
downloadmeshbay-7e01b7e8c2ce2ef9fce8b6c053c3b7210b64f9bf.tar.gz
fix(spa): search names the groups refused the local network
Search gives up before ICE does, so the refusal is read from the connection then; the walk moves on to the group's other nodes, and the page lists the refused groups with a link to each. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/harness/local_network_harness.mjs9
-rw-r--r--packages/meshbay-hub/tests/harness/search_connect_harness.mjs15
-rw-r--r--packages/meshbay-hub/tests/harness/search_fanout_harness.mjs17
-rw-r--r--packages/meshbay-hub/tests/test_local_network_refused.py14
-rw-r--r--packages/meshbay-hub/tests/test_search_connect_deadline.py18
-rw-r--r--packages/meshbay-hub/tests/test_search_fanout.py17
-rw-r--r--packages/meshbay-hub/tests/test_search_unreachable_note.py2
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py2
8 files changed, 85 insertions, 9 deletions
diff --git a/packages/meshbay-hub/tests/harness/local_network_harness.mjs b/packages/meshbay-hub/tests/harness/local_network_harness.mjs
index 9afdf59e..3c86cf77 100644
--- a/packages/meshbay-hub/tests/harness/local_network_harness.mjs
+++ b/packages/meshbay-hub/tests/harness/local_network_harness.mjs
@@ -151,6 +151,13 @@ if (cfg.classify) {
undefined, undefined, '', 'pk')
.then(() => { outcome = 'connected'; },
(err) => { outcome = err.message; reason = err.reason || null; main.close(); });
+ // What a caller that stops waiting early reads (connection-pool.js): at
+ // `probeAt` ms, while ICE is still checking, or after it connected.
+ let early = null;
+ if (cfg.probeAt) {
+ await run(cfg.probeAt);
+ early = await main.localNetworkRefused();
+ }
await run(60000);
- process.stdout.write(JSON.stringify({ outcome, reason, statsCalls }));
+ process.stdout.write(JSON.stringify({ outcome, reason, statsCalls, early }));
}
diff --git a/packages/meshbay-hub/tests/harness/search_connect_harness.mjs b/packages/meshbay-hub/tests/harness/search_connect_harness.mjs
index 60cea2ba..71c7e8c4 100644
--- a/packages/meshbay-hub/tests/harness/search_connect_harness.mjs
+++ b/packages/meshbay-hub/tests/harness/search_connect_harness.mjs
@@ -26,6 +26,11 @@ const {
resolveAt = null,
// How many nodes the hub lists for the group.
nodes = 1,
+ // Which of them answer (resolveAt applies to these). null: all.
+ answering = null,
+ // Which of them this device refuses to reach on the local network: what the
+ // transport reads from its connection when search gives up on it.
+ lanRefused = [],
// How far to run the clock before giving up on the harness itself.
runUntil = 300000,
} = cfg;
@@ -65,13 +70,17 @@ async function run(until) {
// ── The environment ──────────────────────────────────────────────────────────
let closed = 0;
+let made = 0;
class FakeTransport {
constructor() {
this.onConnectProgress = null;
this.onNeedToken = null;
+ this.index = made++;
}
+ async localNetworkRefused() { return lanRefused.includes(this.index); }
+
connect() {
const started = now;
for (const at of progressAt) {
@@ -84,7 +93,7 @@ class FakeTransport {
}, at);
}
return new Promise((resolve) => {
- if (resolveAt !== null) {
+ if (resolveAt !== null && (answering === null || answering.includes(this.index))) {
setTimeout(() => resolve({ ok: true, at: now - started }), resolveAt);
}
});
@@ -127,7 +136,9 @@ const connectToGroup = make(hubFetch, ensureFreshToken, globalThis.window);
let outcome = null;
connectToGroup('https://hub.example', 'g1', 'tok', null, 'alice', 'u1')
.then(() => { outcome = { result: 'connected', at: now }; })
- .catch((e) => { outcome = { result: 'failed', at: now, error: String(e.message) }; });
+ .catch((e) => {
+ outcome = { result: 'failed', at: now, error: String(e.message), reason: e.reason || null };
+ });
await run(runUntil);
diff --git a/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs b/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs
index f84d90bc..27ad8757 100644
--- a/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs
+++ b/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs
@@ -23,6 +23,8 @@ const {
// Indexes (0-based, in the hub's listing order) of the groups whose node is
// down. They take `deadMs`; every other group answers in `liveMs`.
dead = [],
+ // Of those, the ones whose node this device refused on the local network.
+ lanRefused = [],
liveMs = 200,
deadMs = 10000,
// Group indexes the browser remembers as silent from a previous sweep.
@@ -87,8 +89,12 @@ const fetchGroupIndex = (_pool, groupId) => new Promise((resolve, reject) => {
const n = Number(String(groupId).split('-')[1]);
const isDead = dead.includes(n);
setTimeout(
- () => (isDead ? reject(new Error('Connection timeout'))
- : resolve({ entries: [{ id: `e${n}` }], roots: {} })),
+ () => {
+ if (!isDead) return resolve({ entries: [{ id: `e${n}` }], roots: {} });
+ const err = new Error('Connection timeout');
+ if (lanRefused.includes(n)) err.reason = 'local_network_blocked';
+ return reject(err);
+ },
isDead ? deadMs : liveMs,
);
});
@@ -125,11 +131,15 @@ const fetchAllIndexes = make(
const list = Array.from({ length: groups }, (_, i) => ({ id: `g-${i}`, name: `G${i}` }));
let finishedAt = null;
+let outcome = null;
fetchAllIndexes(
null, list, 'tok', 'alice', 'u1',
() => {}, // progress counters only
(results) => { shown.push({ at: now, groups: results.size }); },
-).then(() => { finishedAt = now; });
+).then((out) => {
+ finishedAt = now;
+ outcome = { unreachable: out.unreachable, lanBlocked: out.lanBlocked.map((g) => g.name) };
+});
await run(runUntil);
@@ -140,6 +150,7 @@ console.log(JSON.stringify({
// Every render, so a test can see whether live groups waited on dead ones.
renders: shown,
finishedAt,
+ outcome,
inFlight: Number(ceiling[1]),
// What the browser will remember for next time.
remembered: written === null ? null : JSON.parse(written),
diff --git a/packages/meshbay-hub/tests/test_local_network_refused.py b/packages/meshbay-hub/tests/test_local_network_refused.py
index 3d0f3041..4f556474 100644
--- a/packages/meshbay-hub/tests/test_local_network_refused.py
+++ b/packages/meshbay-hub/tests/test_local_network_refused.py
@@ -104,3 +104,17 @@ def test_stats_that_cannot_be_read_leave_the_old_error():
out = _run({"offer": PHONE_WIFI, "answer": NODE, "remote": KEPT_WHEN_REFUSED,
"statsThrow": True})
assert out["reason"] is None and out["outcome"] == "DataChannel closed", out
+
+
+def test_the_refusal_is_read_before_ice_gives_up():
+ """Search stops waiting after ten seconds without progress, and ICE gives up
+ later: the signature is in the pairs as soon as checks begin."""
+ refused = _run({"offer": PHONE_WIFI, "answer": NODE, "remote": KEPT_WHEN_REFUSED,
+ "probeAt": 5000})
+ assert refused["early"] is True, refused
+ paired = _run({"offer": PHONE_WIFI, "answer": NODE, "remote": EVERY_NODE_ADDRESS,
+ "probeAt": 5000})
+ assert paired["early"] is False, paired
+ reached = _run({"offer": PHONE_WIFI, "answer": NODE, "remote": KEPT_WHEN_REFUSED,
+ "iceConnects": True, "probeAt": 5000})
+ assert reached["early"] is False, reached
diff --git a/packages/meshbay-hub/tests/test_search_connect_deadline.py b/packages/meshbay-hub/tests/test_search_connect_deadline.py
index 953f0277..0dfee4e0 100644
--- a/packages/meshbay-hub/tests/test_search_connect_deadline.py
+++ b/packages/meshbay-hub/tests/test_search_connect_deadline.py
@@ -123,3 +123,21 @@ def test_a_fast_connection_is_unaffected():
out = _attempt(progressAt=[], resolveAt=3000)
assert out["result"] == "connected"
assert out["at"] == 3000
+
+
+def test_a_node_refused_the_local_network_is_named_when_search_gives_up():
+ """Android 17 and Chrome 155 refuse the node's address on the phone's Wi-Fi
+ without the permission, and connect() names it only when ICE gives up,
+ long after search has: the refusal is read from the connection then."""
+ out = _attempt(lanRefused=[0])
+ assert out["result"] == "failed" and out["reason"] == "local_network_blocked", out
+ assert out["at"] == STALL_MS, out
+
+
+def test_a_refused_node_does_not_stop_the_walk():
+ """Refused here is one node: another of the group may be elsewhere."""
+ out = _attempt(nodes=2, lanRefused=[0], answering=[1], progressAt=[], resolveAt=500)
+ assert out["result"] == "connected", out
+ # And when no node answers, the refusal is what is said, not a timeout.
+ out = _attempt(nodes=2, lanRefused=[0])
+ assert out["reason"] == "local_network_blocked", out
diff --git a/packages/meshbay-hub/tests/test_search_fanout.py b/packages/meshbay-hub/tests/test_search_fanout.py
index 23a8b67d..73ba4ac0 100644
--- a/packages/meshbay-hub/tests/test_search_fanout.py
+++ b/packages/meshbay-hub/tests/test_search_fanout.py
@@ -50,7 +50,7 @@ import subprocess
from pathlib import Path
import pytest
-from spa_source import search_argv
+from spa_source import search_argv, search_source
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
SEARCH_PAGE = STATIC / "search-page.js"
@@ -174,3 +174,18 @@ def test_no_more_groups_are_dialled_at_once_than_the_ceiling():
out = _sweep(groups=12, dead=list(range(12)))
assert out["inFlight"] == 6
assert out["finishedAt"] == 2 * DEAD_MS
+
+
+def test_a_group_refused_the_local_network_is_named_apart():
+ """Not "unreachable": the page names these groups and says what to change,
+ and they are dialled last next time like any group that did not answer."""
+ out = _sweep(groups=4, dead=[1, 2], lanRefused=[2])
+ assert out["outcome"] == {"unreachable": ["G1"], "lanBlocked": ["G2"]}, out
+ assert sorted(out["remembered"]) == ["g-1", "g-2"], out
+
+
+def test_the_search_page_shows_the_refused_groups_with_a_way_in():
+ source = search_source()
+ start = source.index("lanBlocked.length > 0")
+ hint = source[start:start + 400]
+ assert "t('search.lan_blocked'" in hint and "groupLinkRoute(g)" in hint
diff --git a/packages/meshbay-hub/tests/test_search_unreachable_note.py b/packages/meshbay-hub/tests/test_search_unreachable_note.py
index 41ed25c0..561100f5 100644
--- a/packages/meshbay-hub/tests/test_search_unreachable_note.py
+++ b/packages/meshbay-hub/tests/test_search_unreachable_note.py
@@ -16,7 +16,7 @@ STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
def test_said_once_the_pass_is_over_and_not_rendered():
source = (STATIC / "search-page.js").read_text(encoding="utf-8")
assert "import { say } from './note.js';" in source
- after = source[source.index("const { unreachable } = await fetchAllIndexes("):]
+ after = source[source.index("const { unreachable, lanBlocked: refused } = await fetchAllIndexes("):]
after = after[:after.index("})();")]
assert re.search(r"if \(cancelled\) return;\s*setFetching\(false\);", after)
assert "say(t('search.unreachable', { n: unreachable.length }), UNREACHABLE_NOTE_MS)" in after
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index 0f0edb82..eaa35fbc 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -529,7 +529,7 @@ def test_search_tries_every_node_the_hub_offers():
code = _code_only(search_source())
assert "nodes[0]" not in code, "Search takes the head of the node list again"
walk = code[code.index("for (const n of nodesData.nodes)"):]
- walk = walk[:walk.index("throw (lastErr")]
+ walk = walk[:walk.index("throw (lanErr || lastErr")]
assert "not_hosted" in walk and "throw e" in walk, (
"the walk must stop for a refusal about this browser and move on "
"for one about this node")