test: Phase 3 P1 — TransportManager LAN/Tunnel race (verified backlog, gap filled) (#4500)

This commit is contained in:
Steven Enamakel
2026-07-04 04:58:16 -07:00
committed by GitHub
parent 9882e721d9
commit 1f950aa977
2 changed files with 163 additions and 4 deletions
@@ -0,0 +1,144 @@
/**
* LAN-vs-Tunnel race semantics for TransportManager.raceLanAndTunnel (plan.md
* §4 P1). The sibling TransportManager.test.ts covers profile *selection* but
* never exercises the race path, despite the docstring describing it. LAN and
* Tunnel transports are mocked here so each scenario controls who is healthy
* (and when), and asserts the winner + that the loser is closed.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ConnectionProfile } from './profileStore';
import { TransportManager } from './TransportManager';
// Mutable health/close hooks the mocked transports read at call time. Declared
// via vi.hoisted so the vi.mock factories (hoisted above imports) can see them.
const h = vi.hoisted(() => ({
lanHealthy: (): Promise<boolean> => Promise.resolve(false),
tunnelHealthy: (): Promise<boolean> => Promise.resolve(false),
lanClose: vi.fn((): Promise<void> => Promise.resolve()),
tunnelClose: vi.fn((): Promise<void> => Promise.resolve()),
}));
vi.mock('./LanHttpTransport', () => ({
LanHttpTransport: class {
readonly kind = 'lan-http';
isHealthy() {
return h.lanHealthy();
}
close() {
return h.lanClose();
}
},
}));
vi.mock('./TunnelTransport', () => ({
TunnelTransport: class {
readonly kind = 'tunnel';
isHealthy() {
return h.tunnelHealthy();
}
close() {
return h.tunnelClose();
}
},
}));
function tunnelProfile(overrides: Partial<ConnectionProfile> = {}): ConnectionProfile {
return {
id: 'race-profile',
label: 'Race',
kind: 'tunnel',
// rpcUrl present → raceLanAndTunnel actually races (not tunnel-only).
rpcUrl: 'http://192.168.1.5:7788/rpc',
channelId: 'CHANNEL001',
corePubkey: 'dGVzdHB1YmtleXRlc3RwdWJrZXl0ZXN0cHVia2V5',
sessionToken: 'tok123',
...overrides,
};
}
function manager(profile: ConnectionProfile): TransportManager {
return new TransportManager(
profile,
() => Promise.resolve(''),
() => Promise.resolve(null),
'http://backend:3000'
);
}
const immediately = (v: boolean) => () => Promise.resolve(v);
const after = (ms: number, v: boolean) => () =>
new Promise<boolean>(resolve => setTimeout(() => resolve(v), ms));
describe('TransportManager.raceLanAndTunnel', () => {
beforeEach(() => {
h.lanHealthy = immediately(false);
h.tunnelHealthy = immediately(false);
h.lanClose.mockClear();
h.tunnelClose.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('LAN wins the race and the tunnel is closed', async () => {
h.lanHealthy = immediately(true);
h.tunnelHealthy = after(50, true);
const t = await manager(tunnelProfile()).getTransport();
expect(t.kind).toBe('lan-http');
expect(h.tunnelClose).toHaveBeenCalledTimes(1);
expect(h.lanClose).not.toHaveBeenCalled();
});
it('tunnel wins the race and the LAN transport is closed', async () => {
h.tunnelHealthy = immediately(true);
h.lanHealthy = after(50, true);
const t = await manager(tunnelProfile()).getTransport();
expect(t.kind).toBe('tunnel');
expect(h.lanClose).toHaveBeenCalledTimes(1);
expect(h.tunnelClose).not.toHaveBeenCalled();
});
it('throws when neither transport becomes healthy', async () => {
h.lanHealthy = immediately(false);
h.tunnelHealthy = immediately(false);
await expect(manager(tunnelProfile()).getTransport()).rejects.toThrow(/all transports failed/);
});
it('reset() re-races: LAN wins first, then after reset the tunnel wins', async () => {
const mgr = manager(tunnelProfile());
// First race: LAN wins.
h.lanHealthy = immediately(true);
h.tunnelHealthy = after(50, false);
const first = await mgr.getTransport();
expect(first.kind).toBe('lan-http');
// LAN later fails → caller resets, and the next race must pick the tunnel
// (tunnel healthy immediately; LAN's slow-unhealthy result loses the race).
await mgr.reset();
h.lanHealthy = after(50, false);
h.tunnelHealthy = immediately(true);
const second = await mgr.getTransport();
expect(second.kind).toBe('tunnel');
});
it('caches the winner: a second getTransport does not re-race', async () => {
h.lanHealthy = immediately(true);
h.tunnelHealthy = immediately(false);
const mgr = manager(tunnelProfile());
const a = await mgr.getTransport();
const b = await mgr.getTransport();
expect(a).toBe(b);
// Tunnel closed exactly once (from the single race), not twice.
expect(h.tunnelClose).toHaveBeenCalledTimes(1);
});
});
+19 -4
View File
@@ -414,10 +414,25 @@ of the five "ZERO tests" claims turned out **already covered**, so only the genu
tests as load-bearing boot guards, so they were left intact rather than risk an unvalidated tests as load-bearing boot guards, so they were left intact rather than risk an unvalidated
E2E change). E2E change).
**Phase 3 — P1 backlog (24 weeks, interleave with feature work)** **Phase 3 — P1 backlog** — ⏳ partial (PR: `test/phase3-p1-gaps`). Verification-first pass: like §4
- Approval×turn integration, TransportManager race, socket backoff, hostile webhook payloads, P0, most P1 "untested" claims were **inaccurate against current `main`**. Verified state:
path_scope invariant, web3 tool wiring, threadSlice/accountsSlice, journey spec, Playwright - [x] **`TransportManager.raceLanAndTunnel`** — GENUINE GAP (the sibling test only covered profile
approval mirror. *selection*, never the race). Added a dedicated `TransportManager.race.test.ts`: LAN-wins /
tunnel-wins (loser closed), both-fail throws, `reset()` re-race, and winner-caching. Surfaced a
real timing quirk: when the first `isHealthy()` to settle is `false`, the
`Promise.race``Promise.any` fallback grabs that already-fulfilled `null` and throws instead of
waiting for a later-healthy peer — worth a production follow-up.
- [~] ALREADY COVERED (no gap): `composerInteractionBlocked` (ChatComposer + composerSendDecision
tests), `threadSlice`/`accountsSlice` (multiple `__tests__`), CustomInference/CustomSearch
onboarding pages, AgentAccessPanel (23 tests), and every P2 slice reducer
(socket/channelConnections/ptt/providerSurface) + TeamInvites. web3 tool layer has `web3_tests.rs`.
- [~] NOT A GAP: primary socket-client backoff is delegated to socket.io
(`reconnectionAttempts: Infinity`), not custom logic — nothing bespoke to unit-test.
- [ ] Genuine-but-deferred (need a full Rust build + deep store/parser fixtures, high iteration cost
in the authoring env): memory two-source-one-tree `path_scope` invariant; broad hostile-webhook
payload matrix (wrong types / deep nesting / oversized) beyond the current missing-field tests;
the ~20 controller domains with no `tests/` reference (the Phase 0 inventory allowlist to burn
down). Approval×turn integration, journey spec, and the Playwright approval mirror need WDIO/PW.
**Phase 4 — new dimensions (ongoing)** **Phase 4 — new dimensions (ongoing)**
- proptest + cargo-fuzz targets; scoped cargo-mutants; jest-axe lane; migration fixture; - proptest + cargo-fuzz targets; scoped cargo-mutants; jest-axe lane; migration fixture;