diff --git a/app/src/services/transport/TransportManager.race.test.ts b/app/src/services/transport/TransportManager.race.test.ts new file mode 100644 index 000000000..1c8eb1fe7 --- /dev/null +++ b/app/src/services/transport/TransportManager.race.test.ts @@ -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 => Promise.resolve(false), + tunnelHealthy: (): Promise => Promise.resolve(false), + lanClose: vi.fn((): Promise => Promise.resolve()), + tunnelClose: vi.fn((): Promise => 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 { + 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(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); + }); +}); diff --git a/plan.md b/plan.md index d2b89f5f1..0dcc2aad6 100644 --- a/plan.md +++ b/plan.md @@ -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 E2E change). -**Phase 3 — P1 backlog (2–4 weeks, interleave with feature work)** -- Approval×turn integration, TransportManager race, socket backoff, hostile webhook payloads, - path_scope invariant, web3 tool wiring, threadSlice/accountsSlice, journey spec, Playwright - approval mirror. +**Phase 3 — P1 backlog** — ⏳ partial (PR: `test/phase3-p1-gaps`). Verification-first pass: like §4 +P0, most P1 "untested" claims were **inaccurate against current `main`**. Verified state: +- [x] **`TransportManager.raceLanAndTunnel`** — GENUINE GAP (the sibling test only covered profile + *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)** - proptest + cargo-fuzz targets; scoped cargo-mutants; jest-axe lane; migration fixture;