Files
Piik/tests/nat-prediction.test.ts
T
AAA 3583e92938 feat(nat): make NAT prediction deployment-configurable (#372)
* Make NAT prediction deployment-configurable

* Ignore optional predicted candidate rejection
2026-09-02 01:37:04 +08:00

285 lines
8.1 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
addRemoteIceCandidate,
iceServersWithNatPrediction,
MAX_NAT_PREDICTION_CANDIDATES,
NatPredictionCandidateBatch,
NatPredictionCandidateEmitter,
natPredictionSurveyUrls,
predictSrflxCandidates,
type SignalCandidate,
} from "../src/client/webrtc/nat-prediction.ts";
function candidate(port: number, address = "203.0.113.7"): SignalCandidate {
return {
candidate:
`candidate:base 1 udp 2122260223 ${address} ${port} ` +
"typ srflx raddr 192.0.2.7 rport 50000 generation 0 ufrag test",
sdpMid: "0",
sdpMLineIndex: 0,
usernameFragment: "test",
};
}
function hostCandidate(): SignalCandidate {
return {
candidate:
"candidate:host 1 udp 2122260223 192.0.2.7 50000 typ host generation 0 ufrag test",
sdpMid: "0",
sdpMLineIndex: 0,
usernameFragment: "test",
};
}
function portOf(value: SignalCandidate): number {
return Number(value?.candidate.trim().split(/\s+/)[5]);
}
function rtcCandidate(port: number): RTCIceCandidate {
return {
...candidate(port),
} as unknown as RTCIceCandidate;
}
describe("NAT prediction ICE adapter", () => {
it("ignores only rejected predicted candidates", async () => {
const error = new Error("candidate rejected");
const connection = {
addIceCandidate: vi.fn(async () => {
throw error;
}),
} as unknown as RTCPeerConnection;
const predicted = candidate(40_000);
if (predicted) {
predicted.candidate = predicted.candidate.replace(
"candidate:base",
"candidate:sp1",
);
}
await expect(
addRemoteIceCandidate(connection, predicted),
).resolves.toBeUndefined();
await expect(
addRemoteIceCandidate(connection, candidate(40_000)),
).rejects.toBe(error);
await expect(addRemoteIceCandidate(connection, null)).rejects.toBe(error);
});
it("leaves the configured servers unchanged when disabled", () => {
const servers = [
{ urls: ["stun:share.example.test:3478"] },
{ urls: "stun:other.example.test:5349" },
];
expect(
iceServersWithNatPrediction(
servers,
false,
[
"stun:share.example.test:3479",
"stun:share.example.test:3480",
],
),
).toEqual(servers);
});
it("adds only configured self-hosted auxiliary listeners", () => {
const servers = [{ urls: "stun:share.example.test:3478" }];
const auxiliary = [
"stun:share.example.test:3479",
"stun:share.example.test:3480",
];
expect(
iceServersWithNatPrediction(servers, true, auxiliary),
).toEqual([
...servers,
{ urls: "stun:share.example.test:3479" },
{ urls: "stun:share.example.test:3480" },
]);
expect(
natPredictionSurveyUrls(servers, auxiliary),
).toEqual(
new Set([
"stun:share.example.test:3478",
"stun:share.example.test:3479",
"stun:share.example.test:3480",
]),
);
expect(
natPredictionSurveyUrls([
{ urls: "stun:share.example.test:5349" },
], auxiliary),
).toEqual(new Set());
});
it("does not invent auxiliary listeners that the server did not enable", () => {
const servers = [
{ urls: ["stun:share.example.test:3478", "stun:share.example.test:3478"] },
{ urls: "stun:other.example.test:3478" },
];
expect(iceServersWithNatPrediction(servers, true)).toEqual(servers);
expect(natPredictionSurveyUrls(servers)).toEqual(new Set());
});
it("does not duplicate already configured auxiliary listeners", () => {
const servers = [
{ urls: "stun:share.example.test:3478" },
{ urls: "stun:share.example.test:3479" },
];
expect(iceServersWithNatPrediction(servers, true, [
"stun:share.example.test:3479",
"stun:share.example.test:3480",
])).toEqual([
...servers,
{ urls: "stun:share.example.test:3480" },
]);
});
it("predicts a bounded two-sided window for a stable arithmetic shape", () => {
const predictions = predictSrflxCandidates([
candidate(40_000),
candidate(40_003),
candidate(40_006),
]);
expect(predictions).toHaveLength(MAX_NAT_PREDICTION_CANDIDATES);
expect(predictions.map(portOf)).toEqual([
40_009,
40_012,
40_015,
40_018,
39_997,
39_994,
39_991,
39_988,
]);
expect(predictions.every((value) => value?.sdpMid === "0")).toBe(true);
});
it("anchors on the port sequence extreme when responses arrive reversed", () => {
const predictions = predictSrflxCandidates([
candidate(40_006),
candidate(40_003),
candidate(40_000),
]);
expect(predictions.map(portOf)).toEqual([
40_009,
40_012,
40_015,
40_018,
39_997,
39_994,
39_991,
39_988,
]);
});
it("does not predict an unstable or incomplete mapping shape", () => {
expect(predictSrflxCandidates([candidate(40_000), candidate(40_001)])).toEqual(
[],
);
expect(
predictSrflxCandidates([
candidate(40_000),
candidate(40_003),
candidate(40_011),
]),
).toEqual([]);
});
it("appends predictions without delaying or replacing ordinary candidates", () => {
const sent: Array<SignalCandidate | null> = [];
const batch = new NatPredictionCandidateBatch((value) => sent.push(value));
const first = candidate(40_000);
const second = candidate(40_003);
const third = candidate(40_006);
batch.add(hostCandidate());
batch.add(first);
batch.add(second);
expect(sent).toEqual([hostCandidate(), first, second]);
batch.add(third);
batch.complete();
batch.complete();
expect(sent[0]).toEqual(hostCandidate());
const firstPrediction = sent.findIndex(
(value) => value?.candidate.includes("candidate:s") ?? false,
);
expect(firstPrediction).toBeGreaterThan(sent.indexOf(second));
expect(firstPrediction).toBeLessThan(sent.indexOf(third));
expect(sent.filter((value) => value === first)).toHaveLength(1);
expect(sent.filter((value) => value === second)).toHaveLength(1);
expect(sent.filter((value) => value === third)).toHaveLength(1);
expect(sent.at(-1)).toBeNull();
});
it("flushes ordinary candidates without inventing a prediction", () => {
const sent: Array<SignalCandidate | null> = [];
const batch = new NatPredictionCandidateBatch((value) => sent.push(value));
const first = candidate(40_000);
const second = candidate(40_007);
batch.add(first);
batch.add(second);
batch.complete();
expect(sent).toEqual([first, second, null]);
});
it("does not wait for auxiliary gathering before sending stock srflx", () => {
const sent: Array<SignalCandidate | null> = [];
const batch = new NatPredictionCandidateBatch((value) => sent.push(value));
const first = candidate(40_000);
batch.add(first);
expect(sent).toEqual([first]);
});
it("forwards an ineligible srflx candidate without using it as sequence evidence", () => {
const sent: Array<SignalCandidate | null> = [];
const batch = new NatPredictionCandidateBatch((value) => sent.push(value));
const external = candidate(50_000);
batch.add(external, false);
batch.add(candidate(40_000));
batch.add(candidate(40_003));
batch.add(candidate(40_006));
expect(sent[0]).toEqual(external);
expect(
sent.some((value) => value?.candidate.startsWith("candidate:sp")),
).toBe(true);
});
it("does not predict when the Browser omits candidate source URLs", () => {
const sent: Array<SignalCandidate | null> = [];
const emitter = new NatPredictionCandidateEmitter(
true,
(value) => sent.push(value),
new Set([
"stun:share.example.test:3478",
"stun:share.example.test:3479",
"stun:share.example.test:3480",
]),
);
emitter.add(rtcCandidate(40_000));
emitter.add(rtcCandidate(40_003));
emitter.add(rtcCandidate(40_006));
emitter.gatheringComplete();
expect(
sent.some((value) => value?.candidate.startsWith("candidate:sp")),
).toBe(false);
expect(sent.filter((value) => value !== null)).toHaveLength(3);
});
});