mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Finding 1 (relayReconnectController.ts): subscribeToConnectionState can invoke its listener synchronously with the current state before returning. When that sync emission signals connected, onConnected() → finish() → cancelTimers() runs while unsubscribeConnectionState is still null — then the caller assigns all three handles after finish, leaking them for the app lifetime. Fix: subscribe first, then check resolved immediately after the return value is assigned. If resolved, unsubscribe the handle (which cancelTimers couldn't reach because the assignment hadn't happened yet) and return before installing the poll interval and backstop timer. Finding 2 (ProfileStep.tsx): reconnect() always returns false in phase 3, so runConnectivityAction never calls markSuccess(), and the onboarding component had no connection-state subscription. The error card stayed showing failure even after the relay healed. Fix: observe the shared relayConnectivitySuccess store (already used by the sidebar) via useSyncExternalStore + useEffect in OnboardingRelayConnectionErrorCard. Export subscribeRelayConnectivitySuccess and getRelayConnectivitySuccessSnapshot from useSidebarRelayConnectionCard.ts so both surfaces share exactly one success-signalling mechanism. Test: add a sync-emission unit test with a subscribeToConnectionState fake that calls the listener before returning its cleanup handle — asserts onSuccess fires once, no interval/backstop installed, cleanup called. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
517 lines
16 KiB
JavaScript
517 lines
16 KiB
JavaScript
/**
|
|
* Unit tests for RelayReconnectController.
|
|
*
|
|
* Tests the production controller class with injected dependencies — no
|
|
* React, no Tauri, no DOM required. Covers the three-phase strategy,
|
|
* cancellation token correctness, single-flight guard, and state transitions.
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import test, { mock } from "node:test";
|
|
|
|
import { RelayReconnectController } from "./relayReconnectController.ts";
|
|
|
|
// ── Dep builder helpers ───────────────────────────────────────────────────────
|
|
|
|
/** Build a minimal deps object with controllable preconnect behaviour. */
|
|
function makeDeps({
|
|
preconnectResult = async () => {},
|
|
hookConfiguredResult = async () => false,
|
|
runHookResult = async () => {},
|
|
connectionStateListener = null,
|
|
} = {}) {
|
|
const timers = [];
|
|
const intervals = [];
|
|
let timerSeq = 0;
|
|
let intervalSeq = 0;
|
|
|
|
const deps = {
|
|
preconnect: mock.fn(preconnectResult),
|
|
hookConfigured: mock.fn(hookConfiguredResult),
|
|
runHook: mock.fn(runHookResult),
|
|
subscribeToConnectionState: mock.fn((listener) => {
|
|
if (connectionStateListener) connectionStateListener.set(listener);
|
|
return () => {};
|
|
}),
|
|
onSuccess: mock.fn(),
|
|
onBackstop: mock.fn(),
|
|
setTimeout: mock.fn((fn, ms) => {
|
|
const id = ++timerSeq;
|
|
timers.push({ id, fn, ms });
|
|
return id;
|
|
}),
|
|
clearTimeout: mock.fn((id) => {
|
|
const idx = timers.findIndex((t) => t.id === id);
|
|
if (idx !== -1) timers.splice(idx, 1);
|
|
}),
|
|
setInterval: mock.fn((fn, ms) => {
|
|
const id = ++intervalSeq;
|
|
intervals.push({ id, fn, ms });
|
|
return id;
|
|
}),
|
|
clearInterval: mock.fn((id) => {
|
|
const idx = intervals.findIndex((t) => t.id === id);
|
|
if (idx !== -1) intervals.splice(idx, 1);
|
|
}),
|
|
// Test helpers to fire timers/intervals.
|
|
_fireTimer: (id) => timers.find((t) => t.id === id)?.fn(),
|
|
_fireInterval: (id) => intervals.find((t) => t.id === id)?.fn(),
|
|
_timers: timers,
|
|
_intervals: intervals,
|
|
};
|
|
return deps;
|
|
}
|
|
|
|
// ── Phase 1: fast path ────────────────────────────────────────────────────────
|
|
|
|
test("fast-path success — hook never invoked, onSuccess fires, state resets", async () => {
|
|
const ctrl = new RelayReconnectController();
|
|
const deps = makeDeps({ preconnectResult: async () => {} });
|
|
|
|
const result = await ctrl.start(deps);
|
|
|
|
assert.equal(result, true, "start() returns true on fast-path success");
|
|
assert.equal(deps.hookConfigured.mock.calls.length, 0, "hook not queried");
|
|
assert.equal(deps.runHook.mock.calls.length, 0, "hook not run");
|
|
assert.equal(deps.onSuccess.mock.calls.length, 1, "onSuccess fired");
|
|
assert.deepEqual(ctrl.getState(), {
|
|
isPending: false,
|
|
isWaitingOnReconnectHook: false,
|
|
});
|
|
});
|
|
|
|
test("fast-path success — no poll interval or backstop scheduled", async () => {
|
|
const ctrl = new RelayReconnectController();
|
|
const deps = makeDeps({ preconnectResult: async () => {} });
|
|
|
|
await ctrl.start(deps);
|
|
|
|
assert.equal(deps.setInterval.mock.calls.length, 0, "no poll interval");
|
|
// The fast-path withDeadline schedules one timeout for the deadline, but
|
|
// that is cleared by finally(). After success no backstop should remain.
|
|
assert.equal(
|
|
deps._timers.length,
|
|
0,
|
|
"no outstanding timers after fast-path win",
|
|
);
|
|
});
|
|
|
|
// ── Phase 2: escalation ───────────────────────────────────────────────────────
|
|
|
|
test("escalation fires only when fast path fails and hook is configured", async () => {
|
|
const ctrl = new RelayReconnectController();
|
|
let callIdx = 0;
|
|
const deps = makeDeps({
|
|
preconnectResult: async () => {
|
|
// First call (fast path) fails; subsequent calls (poll) succeed.
|
|
if (++callIdx === 1) throw new Error("relay unreachable");
|
|
},
|
|
hookConfiguredResult: async () => true,
|
|
});
|
|
|
|
// Don't await — it enters phase 3 and returns false.
|
|
const promise = ctrl.start(deps);
|
|
|
|
// Let the async phases run.
|
|
await promise;
|
|
|
|
assert.equal(
|
|
deps.hookConfigured.mock.calls.length,
|
|
1,
|
|
"hook-configured checked once",
|
|
);
|
|
assert.equal(deps.runHook.mock.calls.length, 1, "hook run once");
|
|
});
|
|
|
|
test("escalation skipped when hook not configured", async () => {
|
|
const ctrl = new RelayReconnectController();
|
|
const deps = makeDeps({
|
|
preconnectResult: async () => {
|
|
throw new Error("fail");
|
|
},
|
|
hookConfiguredResult: async () => false,
|
|
});
|
|
|
|
await ctrl.start(deps);
|
|
|
|
assert.equal(
|
|
deps.runHook.mock.calls.length,
|
|
0,
|
|
"hook not run when not configured",
|
|
);
|
|
});
|
|
|
|
test("hook failure is non-fatal — polling still starts", async () => {
|
|
const ctrl = new RelayReconnectController();
|
|
const deps = makeDeps({
|
|
preconnectResult: async () => {
|
|
throw new Error("fail");
|
|
},
|
|
hookConfiguredResult: async () => true,
|
|
runHookResult: async () => {
|
|
throw new Error("hook blew up");
|
|
},
|
|
});
|
|
|
|
// Should not throw.
|
|
let threw = false;
|
|
try {
|
|
await ctrl.start(deps);
|
|
} catch {
|
|
threw = true;
|
|
}
|
|
|
|
assert.equal(threw, false, "hook failure does not propagate");
|
|
assert.ok(
|
|
deps._intervals.length > 0 || deps._timers.length > 0,
|
|
"phase 3 timers scheduled",
|
|
);
|
|
});
|
|
|
|
// ── Phase 3: poll-until-connected ─────────────────────────────────────────────
|
|
|
|
test("poll preempts backstop — onSuccess fires when poll wins", async () => {
|
|
const ctrl = new RelayReconnectController();
|
|
let callIdx = 0;
|
|
const deps = makeDeps({
|
|
preconnectResult: async () => {
|
|
if (++callIdx <= 2) throw new Error("not yet");
|
|
// Third call succeeds — simulates poll winning.
|
|
},
|
|
hookConfiguredResult: async () => false,
|
|
});
|
|
|
|
// Phase 1 fails, enters phase 3. start() returns false.
|
|
const promise = ctrl.start(deps);
|
|
await promise;
|
|
|
|
// Fire the poll interval twice to reach the succeeding call.
|
|
const intervalId = deps._intervals[0]?.id;
|
|
assert.ok(intervalId !== undefined, "poll interval is active");
|
|
|
|
await deps._fireInterval(intervalId); // call 2 — fails
|
|
await deps._fireInterval(intervalId); // call 3 — succeeds
|
|
|
|
assert.equal(
|
|
deps.onSuccess.mock.calls.length,
|
|
1,
|
|
"onSuccess fired after poll win",
|
|
);
|
|
assert.deepEqual(ctrl.getState(), {
|
|
isPending: false,
|
|
isWaitingOnReconnectHook: false,
|
|
});
|
|
});
|
|
|
|
test("connection-state emitter fires onSuccess and cancels poll/backstop", async () => {
|
|
let capturedListener = null;
|
|
const ctrl = new RelayReconnectController();
|
|
const deps = makeDeps({
|
|
preconnectResult: async () => {
|
|
throw new Error("fail");
|
|
},
|
|
hookConfiguredResult: async () => false,
|
|
connectionStateListener: {
|
|
set: (l) => {
|
|
capturedListener = l;
|
|
},
|
|
},
|
|
});
|
|
|
|
await ctrl.start(deps);
|
|
|
|
// Emitter fires "connected" — should resolve without waiting for poll.
|
|
assert.ok(capturedListener !== null, "connection-state listener registered");
|
|
capturedListener("connected");
|
|
|
|
assert.equal(
|
|
deps.onSuccess.mock.calls.length,
|
|
1,
|
|
"onSuccess fired via emitter",
|
|
);
|
|
assert.deepEqual(ctrl.getState(), {
|
|
isPending: false,
|
|
isWaitingOnReconnectHook: false,
|
|
});
|
|
assert.equal(deps._intervals.length, 0, "poll interval cancelled");
|
|
assert.equal(deps._timers.length, 0, "backstop cancelled");
|
|
});
|
|
|
|
test("backstop fires onBackstop, not onSuccess, and resets state", async () => {
|
|
const ctrl = new RelayReconnectController();
|
|
const deps = makeDeps({
|
|
preconnectResult: async () => {
|
|
throw new Error("always fails");
|
|
},
|
|
hookConfiguredResult: async () => false,
|
|
});
|
|
|
|
await ctrl.start(deps);
|
|
|
|
// Locate and fire the backstop timer (largest ms).
|
|
const backstopTimer = deps._timers.reduce(
|
|
(max, t) => (t.ms >= max.ms ? t : max),
|
|
{ ms: 0, id: -1, fn: null },
|
|
);
|
|
assert.ok(backstopTimer.id !== -1, "backstop timer exists");
|
|
backstopTimer.fn();
|
|
|
|
assert.equal(deps.onBackstop.mock.calls.length, 1, "onBackstop called");
|
|
assert.equal(deps.onSuccess.mock.calls.length, 0, "onSuccess NOT called");
|
|
assert.deepEqual(ctrl.getState(), {
|
|
isPending: false,
|
|
isWaitingOnReconnectHook: false,
|
|
});
|
|
});
|
|
|
|
// ── Cancellation token ────────────────────────────────────────────────────────
|
|
|
|
test("cancellation token — superseded poll callback does not call onSuccess", async () => {
|
|
const ctrl = new RelayReconnectController();
|
|
let preconnectCallCount = 0;
|
|
let resolveFirstPreconnect;
|
|
const deps = makeDeps({
|
|
preconnectResult: async () => {
|
|
preconnectCallCount++;
|
|
if (preconnectCallCount === 1) {
|
|
// First fast-path call: stall so we can cancel mid-flight.
|
|
await new Promise((resolve) => {
|
|
resolveFirstPreconnect = resolve;
|
|
});
|
|
throw new Error("cancelled");
|
|
}
|
|
},
|
|
hookConfiguredResult: async () => false,
|
|
});
|
|
|
|
// Start attempt #1 — stalls in fast path.
|
|
const attempt1 = ctrl.start(deps);
|
|
|
|
// Cancel the attempt before the stalled preconnect resolves.
|
|
ctrl.cancel();
|
|
// Now resolve the stalled promise — it should be ignored.
|
|
resolveFirstPreconnect?.();
|
|
await attempt1;
|
|
|
|
// Start attempt #2 — clean state.
|
|
const deps2 = makeDeps({ preconnectResult: async () => {} });
|
|
const result2 = await ctrl.start(deps2);
|
|
|
|
assert.equal(result2, true, "second attempt succeeds on fast path");
|
|
assert.equal(
|
|
deps2.onSuccess.mock.calls.length,
|
|
1,
|
|
"only second attempt's onSuccess fires",
|
|
);
|
|
assert.equal(
|
|
deps.onSuccess.mock.calls.length,
|
|
0,
|
|
"first attempt's onSuccess never fires",
|
|
);
|
|
});
|
|
|
|
test("cancel mid fast-path — preconnect resolves SUCCESSFULLY after cancel but onSuccess never fires", async () => {
|
|
const ctrl = new RelayReconnectController();
|
|
let resolvePreconnect;
|
|
const deps = makeDeps({
|
|
// preconnect stalls then SUCCEEDS — worst case: cancel races a fast-path win.
|
|
preconnectResult: () =>
|
|
new Promise((resolve) => {
|
|
resolvePreconnect = resolve;
|
|
}),
|
|
hookConfiguredResult: async () => false,
|
|
});
|
|
|
|
const attempt = ctrl.start(deps);
|
|
|
|
// Cancel while fast-path is in flight.
|
|
ctrl.cancel();
|
|
|
|
// Let preconnect resolve successfully after cancel.
|
|
resolvePreconnect?.();
|
|
await attempt;
|
|
|
|
assert.equal(
|
|
deps.onSuccess.mock.calls.length,
|
|
0,
|
|
"onSuccess never fires after cancel",
|
|
);
|
|
assert.deepEqual(
|
|
ctrl.getState(),
|
|
{ isPending: false, isWaitingOnReconnectHook: false },
|
|
"state is idle",
|
|
);
|
|
assert.equal(deps._timers.length, 0, "no timers outstanding after cancel");
|
|
assert.equal(
|
|
deps._intervals.length,
|
|
0,
|
|
"no intervals outstanding after cancel",
|
|
);
|
|
});
|
|
|
|
test("last subscriber unsubscribe cancels in-flight attempt", async () => {
|
|
const ctrl = new RelayReconnectController();
|
|
let resolvePreconnect;
|
|
const deps = makeDeps({
|
|
preconnectResult: () =>
|
|
new Promise((resolve) => {
|
|
resolvePreconnect = resolve;
|
|
}),
|
|
hookConfiguredResult: async () => false,
|
|
});
|
|
|
|
// Two subscribers.
|
|
const unsub1 = ctrl.subscribe(() => {});
|
|
const unsub2 = ctrl.subscribe(() => {});
|
|
|
|
const attempt = ctrl.start(deps);
|
|
|
|
// First unsub — still one subscriber, should NOT cancel.
|
|
unsub1();
|
|
assert.equal(
|
|
ctrl.getState().isPending,
|
|
true,
|
|
"still pending after first unsub",
|
|
);
|
|
|
|
// Last unsub — should cancel.
|
|
unsub2();
|
|
assert.equal(ctrl.getState().isPending, false, "cancelled after last unsub");
|
|
|
|
// Let preconnect resolve successfully — should be ignored.
|
|
resolvePreconnect?.();
|
|
await attempt;
|
|
|
|
assert.equal(
|
|
deps.onSuccess.mock.calls.length,
|
|
0,
|
|
"onSuccess never fires after last-subscriber cancel",
|
|
);
|
|
});
|
|
|
|
// ── Single-flight guard ───────────────────────────────────────────────────────
|
|
|
|
test("second start() while first is pending returns false immediately", async () => {
|
|
const ctrl = new RelayReconnectController();
|
|
let resolvePreconnect;
|
|
const deps = makeDeps({
|
|
preconnectResult: () =>
|
|
new Promise((resolve) => {
|
|
resolvePreconnect = resolve;
|
|
}),
|
|
});
|
|
const deps2 = makeDeps({ preconnectResult: async () => {} });
|
|
|
|
// Start attempt #1 — stalls in fast path.
|
|
const attempt1 = ctrl.start(deps);
|
|
|
|
// Second start should be rejected immediately.
|
|
const result2 = await ctrl.start(deps2);
|
|
assert.equal(
|
|
result2,
|
|
false,
|
|
"second start returns false while first pending",
|
|
);
|
|
assert.equal(
|
|
deps2.preconnect.mock.calls.length,
|
|
0,
|
|
"second attempt never calls preconnect",
|
|
);
|
|
|
|
// Finish the first attempt.
|
|
resolvePreconnect?.();
|
|
await attempt1;
|
|
});
|
|
|
|
// ── State listener ────────────────────────────────────────────────────────────
|
|
|
|
test("subscribers receive state transitions and can unsubscribe", async () => {
|
|
const ctrl = new RelayReconnectController();
|
|
const states = [];
|
|
const unsub = ctrl.subscribe((s) => states.push({ ...s }));
|
|
|
|
const deps = makeDeps({ preconnectResult: async () => {} });
|
|
await ctrl.start(deps);
|
|
|
|
unsub();
|
|
|
|
// Should have seen: initial idle, pending=true, pending=false (success).
|
|
assert.ok(states.length >= 2, "at least 2 state transitions observed");
|
|
assert.equal(
|
|
states[states.length - 1].isPending,
|
|
false,
|
|
"last state is not pending",
|
|
);
|
|
|
|
// After unsubscribe, no further updates.
|
|
const countBefore = states.length;
|
|
const deps2 = makeDeps({ preconnectResult: async () => {} });
|
|
await ctrl.start(deps2);
|
|
assert.equal(states.length, countBefore, "no updates after unsubscribe");
|
|
});
|
|
|
|
// ── OSS build boundary ────────────────────────────────────────────────────────
|
|
|
|
test("OSS build (hookConfigured returns false) — runHook never called", async () => {
|
|
const ctrl = new RelayReconnectController();
|
|
const deps = makeDeps({
|
|
preconnectResult: async () => {
|
|
throw new Error("relay unreachable");
|
|
},
|
|
hookConfiguredResult: async () => false,
|
|
});
|
|
|
|
await ctrl.start(deps);
|
|
|
|
assert.equal(
|
|
deps.runHook.mock.calls.length,
|
|
0,
|
|
"runHook not called in OSS build",
|
|
);
|
|
});
|
|
|
|
// ── Synchronous connected emission ───────────────────────────────────────────
|
|
|
|
test("sync connected emission — onSuccess fires once, no interval/backstop installed, subscription cleaned up", async () => {
|
|
const ctrl = new RelayReconnectController();
|
|
|
|
// subscribeToConnectionState fake that invokes the listener synchronously
|
|
// with "connected" BEFORE returning the cleanup handle. This models the
|
|
// production subscribeToConnectionState documented behaviour: it fires the
|
|
// listener with the current state before returning.
|
|
let cleanupCalled = false;
|
|
const deps = makeDeps({
|
|
preconnectResult: async () => {
|
|
throw new Error("relay unreachable");
|
|
},
|
|
hookConfiguredResult: async () => false,
|
|
});
|
|
|
|
// Override subscribeToConnectionState with the synchronous-emission fake.
|
|
deps.subscribeToConnectionState = mock.fn((listener) => {
|
|
// Invoke immediately — simulates "already connected" at subscribe time.
|
|
listener("connected");
|
|
// Return cleanup handle (production unsubscribe fn).
|
|
const cleanup = () => {
|
|
cleanupCalled = true;
|
|
};
|
|
return cleanup;
|
|
});
|
|
|
|
await ctrl.start(deps);
|
|
|
|
assert.equal(
|
|
deps.onSuccess.mock.calls.length,
|
|
1,
|
|
"onSuccess fires exactly once",
|
|
);
|
|
assert.equal(deps._intervals.length, 0, "no poll interval installed");
|
|
assert.equal(deps._timers.length, 0, "no backstop timer installed");
|
|
assert.equal(cleanupCalled, true, "subscription cleanup handle was called");
|
|
assert.deepEqual(
|
|
ctrl.getState(),
|
|
{ isPending: false, isWaitingOnReconnectHook: false },
|
|
"state is idle after sync success",
|
|
);
|
|
});
|