feat(core): event queue, Ethernet, switch, ARP, IPv4, ICMP, routing and firewall

First slice of the TypeScript port of the filius-ipad runtime engine,
with 29 tests ported from the Swift suite. Parts not ported yet throw
NotPortedError instead of guessing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 19:22:57 +00:00
co-authored by Claude Opus 5.5
parent 7caee33b3d
commit 696334aa5a
29 changed files with 6199 additions and 11 deletions
+631
View File
@@ -0,0 +1,631 @@
// Ported from filius-ipad ios/FiliusPadTests/TopologyNetworkRuntimeEngineTests.swift:
// the event queue, the virtual clock, Java-compatible randomness, restart,
// Ethernet/switch/ARP/IPv4/ICMP, routing errors, the firewall, packet loss
// and trace retention. The remaining Swift tests (UDP, TCP, DHCP, DNS, RIP,
// NAT, remote link, packet viewer) follow with their protocol slices.
import { describe, expect, it } from 'vitest';
import {
EMPTY_TOPOLOGY_SNAPSHOT,
ETHERNET_BROADCAST_MAC_ADDRESS,
firewallConfiguration,
firewallRule,
DIRECTLY_CONNECTED_SOURCE_MARKER,
forwardingClone,
icmpMessage,
interfaceKey,
JavaCompatibleRandom,
NetworkRuntimeEngine,
portSnapshot,
stableMacAddress,
switchConfiguration,
TCP_FLAG,
topologySnapshot,
traceRetentionPolicy,
UNSPECIFIED_MAC_ADDRESS,
type EthernetFrame,
type EthernetPayload,
type FirewallConfiguration,
type IPv4Packet,
type NodeSnapshot,
type RuntimeOutput,
type ScheduledEventKind,
} from '../src/index.js';
import { device, link, pc, startedEngine, uuid } from './fixtures.js';
const marker = (label: string): ScheduledEventKind => ({ kind: 'parityMarker', label });
function firedKinds(outputs: RuntimeOutput[]): ScheduledEventKind[] {
return outputs.flatMap((output) => (output.kind.type === 'fired' ? [output.kind.event.kind] : []));
}
function routedTopology() {
const sourceNodeId = uuid(0x1451);
const sourcePortId = uuid(0x1461);
const routerNodeId = uuid(0x1452);
const routerPortA = uuid(0x1462);
const routerPortB = uuid(0x1463);
const targetNodeId = uuid(0x1453);
const targetPortId = uuid(0x1464);
const router: NodeSnapshot = {
id: routerNodeId,
kind: 'router',
ports: [portSnapshot(routerPortA, 'rt1'), portSnapshot(routerPortB, 'rt2')],
};
const snapshot = topologySnapshot({
nodes: [pc(sourceNodeId, sourcePortId), router, pc(targetNodeId, targetPortId)],
links: [
link(sourceNodeId, sourcePortId, routerNodeId, routerPortA),
link(routerNodeId, routerPortB, targetNodeId, targetPortId),
],
deviceConfigurations: new Map([
[sourceNodeId, device('10.0.0.2', { gateway: '10.0.0.1' })],
[routerNodeId, device('10.0.0.1')],
[targetNodeId, device('20.0.0.2', { gateway: '20.0.0.1' })],
]),
interfaceConfigurations: new Map([
[interfaceKey(routerNodeId, routerPortA), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
[interfaceKey(routerNodeId, routerPortB), { ipAddress: '20.0.0.1', subnetMask: '255.255.255.0' }],
]),
});
const engine = new NetworkRuntimeEngine(14);
engine.handle({ type: 'start', snapshot, seed: 14, initialTimeMilliseconds: 0 });
return { engine, sourceNodeId, routerNodeId };
}
function firewallEngine(configuration: FirewallConfiguration) {
const routerId = uuid(0x1802);
const portId = uuid(0x1812);
const engine = startedEngine(
{
nodes: [{ id: routerId, kind: 'router', ports: [portSnapshot(portId, 'rt1')] }],
links: [],
interfaceConfigurations: new Map([
[interfaceKey(routerId, portId), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
]),
firewallConfigurationsByNodeId: new Map([[routerId, configuration]]),
},
18,
);
return { engine, routerId };
}
function tcpPacket(identity: number, destinationPort: number, flags: number): IPv4Packet {
return {
identity,
senderIPAddress: '203.0.113.10',
receiverIPAddress: '10.0.0.1',
timeToLive: 64,
protocol: 'tcp',
payload: {
kind: 'tcp',
segment: {
sourcePort: 50_000,
destinationPort,
sequenceNumber: identity,
acknowledgementNumber: 0,
flags,
payload: new Uint8Array(),
},
},
};
}
describe('event queue and virtual clock', () => {
it('orders events by deadline, then by insertion sequence', () => {
const engine = new NetworkRuntimeEngine(42);
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 42, initialTimeMilliseconds: 0 });
engine.handle({ type: 'schedule', deadlineMilliseconds: 100, kind: marker('first-at-100') });
engine.handle({ type: 'schedule', deadlineMilliseconds: 50, kind: marker('at-50') });
engine.handle({ type: 'schedule', deadlineMilliseconds: 100, kind: marker('second-at-100') });
const outputs = engine.handle({ type: 'advance', toMilliseconds: 100 });
expect(firedKinds(outputs)).toEqual([marker('at-50'), marker('first-at-100'), marker('second-at-100')]);
expect(engine.state.currentTimeMilliseconds).toBe(100);
expect(engine.state.pendingEvents).toEqual([]);
});
it('rejects a past time without changing the clock', () => {
const engine = new NetworkRuntimeEngine(7);
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 7, initialTimeMilliseconds: 10 });
engine.handle({ type: 'advance', toMilliseconds: 25 });
const outputs = engine.handle({ type: 'advance', toMilliseconds: 24 });
expect(outputs.map((output) => output.kind)).toEqual([{ type: 'advanceRejectedPastTime' }]);
expect(engine.state.currentTimeMilliseconds).toBe(25);
});
it('rejects a past deadline without using up a sequence number', () => {
const engine = new NetworkRuntimeEngine(8);
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 8, initialTimeMilliseconds: 25 });
const outputs = engine.handle({ type: 'schedule', deadlineMilliseconds: 24, kind: marker('past') });
expect(outputs.map((output) => output.kind)).toEqual([
{ type: 'scheduleRejectedPastTime', deadlineMilliseconds: 24 },
]);
expect(engine.state.pendingEvents).toEqual([]);
expect(engine.state.nextEventSequenceNumber).toBe(0);
expect(engine.state.currentTimeMilliseconds).toBe(25);
});
it('replays a named scenario straight to a stable state', () => {
const finalState = NetworkRuntimeEngine.run({
name: 'equal-deadline-order',
seed: 1,
advanceToMilliseconds: 1_000,
scheduledEvents: [
{ deadlineMilliseconds: 500, kind: marker('a') },
{ deadlineMilliseconds: 500, kind: marker('b') },
],
});
expect(finalState.currentTimeMilliseconds).toBe(1_000);
expect(finalState.pendingEvents).toEqual([]);
expect(finalState.seed).toBe(1n);
});
it('clears transient state on restart and numbers packets from one again', () => {
const nodeId = uuid(0x12);
const engine = new NetworkRuntimeEngine(12);
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 12, initialTimeMilliseconds: 0 });
engine.handle({ type: 'schedule', deadlineMilliseconds: 40, kind: marker('stale') });
expect(engine.allocateFrameIdentity()).toBe(1);
expect(engine.allocatePacketIdentity()).toBe(1);
engine.recordTrace({ packetIdentity: 1, nodeId, direction: 'local', layer: 'application', operation: 'created' });
engine.handle({ type: 'stop' });
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 12, initialTimeMilliseconds: 0 });
const state = engine.state;
expect(state.pendingEvents).toEqual([]);
expect(state.packetTraces).toEqual([]);
expect(state.arpCachesByNodeId.size).toBe(0);
expect(state.switchForwardingTablesByNodeId.size).toBe(0);
expect(state.switchSeenFrameIdentitiesByNodeId.size).toBe(0);
expect(state.deliveredIPv4PacketsByNodeId.size).toBe(0);
expect(state.icmpObservationsByNodeId.size).toBe(0);
expect(state.firewallDecisions).toEqual([]);
expect(engine.allocateFrameIdentity()).toBe(1);
expect(engine.allocatePacketIdentity()).toBe(1);
});
});
describe('Java-compatible randomness', () => {
it('draws the same RIP jitter as java.util.Random', () => {
const random = new JavaCompatibleRandom(42);
expect(Array.from({ length: 5 }, () => random.nextRipJitterMilliseconds())).toEqual([
32_475, 25_746, 32_032, 25_679, 28_287,
]);
});
it('matches java.util.Random.nextInt for bounded and power-of-two ranges', () => {
// Measured on OpenJDK 21 (29.09.2026): new java.util.Random(42),
// nextInt(100) three times, then nextInt(16) three times.
const random = new JavaCompatibleRandom(42);
expect([random.nextInt(100), random.nextInt(100), random.nextInt(100)]).toEqual([30, 63, 48]);
expect([random.nextInt(16), random.nextInt(16), random.nextInt(16)]).toEqual([0, 4, 15]);
});
});
describe('IPv4 packets', () => {
it('keeps the identity and lowers the TTL when forwarding', () => {
const packet: IPv4Packet = {
identity: 99,
senderIPAddress: '192.168.0.10',
receiverIPAddress: '192.168.1.20',
timeToLive: 2,
protocol: 'icmp',
payload: { kind: 'icmp', message: icmpMessage('echoRequest', { identifier: 3, sequenceNumber: 4 }) },
};
const forwarded = forwardingClone(packet);
const expired = forwardingClone(forwarded);
expect(forwarded).toMatchObject({ identity: 99, timeToLive: 1 });
expect(expired).toMatchObject({ identity: 99, timeToLive: 0 });
});
});
describe('Ethernet, ARP and ICMP', () => {
it('fills both ARP caches with one request and reply over a direct cable', () => {
const sourceNodeId = uuid(0x1401);
const sourcePortId = uuid(0x1411);
const targetNodeId = uuid(0x1402);
const targetPortId = uuid(0x1412);
const engine = startedEngine({
nodes: [pc(sourceNodeId, sourcePortId), pc(targetNodeId, targetPortId)],
links: [link(sourceNodeId, sourcePortId, targetNodeId, targetPortId)],
deviceConfigurations: new Map([
[sourceNodeId, device('192.168.0.10')],
[targetNodeId, device('192.168.0.20')],
]),
});
const resolved = engine.resolveMacAddress(sourceNodeId, '192.168.0.20');
expect(resolved).toBe(stableMacAddress(targetPortId));
expect(engine.state.arpCachesByNodeId.get(sourceNodeId)?.get('192.168.0.20')?.macAddress).toBe(resolved);
expect(engine.state.arpCachesByNodeId.get(targetNodeId)?.get('192.168.0.10')?.macAddress).toBe(
stableMacAddress(sourcePortId),
);
});
it('pings across a switch with Ethernet, ARP and ICMP packets', () => {
const sourceNodeId = uuid(0x1421);
const sourcePortId = uuid(0x1431);
const switchNodeId = uuid(0x1422);
const switchPortA = uuid(0x1432);
const switchPortB = uuid(0x1433);
const targetNodeId = uuid(0x1423);
const targetPortId = uuid(0x1434);
const switchNode: NodeSnapshot = {
id: switchNodeId,
kind: 'networkSwitch',
ports: [portSnapshot(switchPortA, 'sw1'), portSnapshot(switchPortB, 'sw2')],
};
const engine = startedEngine({
nodes: [pc(sourceNodeId, sourcePortId), switchNode, pc(targetNodeId, targetPortId)],
links: [
link(sourceNodeId, sourcePortId, switchNodeId, switchPortA),
link(switchNodeId, switchPortB, targetNodeId, targetPortId),
],
deviceConfigurations: new Map([
[sourceNodeId, device('192.168.0.10')],
[targetNodeId, device('192.168.0.20')],
]),
});
const result = engine.sendIcmpEcho(sourceNodeId, '192.168.0.20', { identifier: 14, sequenceNumber: 1 });
expect(result.type).toBe('delivered');
expect(engine.state.icmpObservationsByNodeId.get(sourceNodeId)?.at(-1)?.message.kind).toBe('echoReply');
expect(engine.state.switchForwardingTablesByNodeId.get(switchNodeId)?.size).toBe(2);
expect(engine.state.packetTraces.some((trace) => trace.detail === 'ARP request for 192.168.0.20')).toBe(true);
expect(
engine.state.packetTraces.some((trace) => trace.packetIdentity !== undefined && trace.layer === 'network'),
).toBe(true);
});
it('answers an expired TTL at the router with time exceeded and the original packet', () => {
const topology = routedTopology();
const result = topology.engine.sendIcmpEcho(topology.sourceNodeId, '20.0.0.2', {
timeToLive: 1,
identifier: 140,
sequenceNumber: 1,
});
expect(result).toMatchObject({ type: 'icmpError', kind: 'timeExceeded' });
const observation = topology.engine.state.icmpObservationsByNodeId.get(topology.sourceNodeId)?.at(-1);
expect(observation?.message.kind).toBe('timeExceeded');
expect(observation?.message.embeddedOriginalPacket?.timeToLive).toBe(1);
expect(observation?.message.embeddedOriginalPacket?.receiverIPAddress).toBe('20.0.0.2');
});
it('keeps the packet identity and lowers the TTL when a router forwards', () => {
const topology = routedTopology();
const result = topology.engine.sendIcmpEcho(topology.sourceNodeId, '20.0.0.2', {
timeToLive: 8,
identifier: 141,
sequenceNumber: 1,
});
expect(result.type).toBe('delivered');
const forwardingTrace = topology.engine.state.packetTraces.find(
(trace) =>
trace.nodeId === topology.routerNodeId && trace.operation === 'forwarded' && trace.detail === 'TTL decremented',
);
expect(forwardingTrace?.packetIdentity).toBeDefined();
expect(forwardingTrace?.beforeHeaders.find((field) => field.name === 'ttl')?.value).toBe('8');
expect(forwardingTrace?.afterHeaders.find((field) => field.name === 'ttl')?.value).toBe('7');
});
it('reports network unreachable when the router has no route', () => {
const topology = routedTopology();
const snapshot = topology.engine.state.topologySnapshot;
const sourceToRouter = snapshot.links.find(
(candidate) => candidate.sourceNodeId === topology.sourceNodeId || candidate.targetNodeId === topology.sourceNodeId,
);
const engine = new NetworkRuntimeEngine(14);
engine.handle({
type: 'start',
snapshot: topologySnapshot({
nodes: snapshot.nodes,
links: sourceToRouter === undefined ? [] : [sourceToRouter],
deviceConfigurations: snapshot.deviceConfigurations,
interfaceConfigurations: snapshot.interfaceConfigurations,
manualRoutesByNodeId: snapshot.manualRoutesByNodeId,
}),
seed: 14,
initialTimeMilliseconds: 0,
});
const result = engine.sendIcmpEcho(topology.sourceNodeId, '30.0.0.2', { identifier: 142, sequenceNumber: 1 });
expect(result).toMatchObject({ type: 'icmpError', kind: 'destinationNetworkUnreachable' });
});
it('reports host unreachable when a connected target never answers ARP', () => {
const sourceNodeId = uuid(0x1481);
const sourcePortId = uuid(0x1491);
const engine = startedEngine({
nodes: [pc(sourceNodeId, sourcePortId)],
links: [],
deviceConfigurations: new Map([[sourceNodeId, device('192.168.50.10')]]),
});
const result = engine.sendIcmpEcho(sourceNodeId, '192.168.50.99', { identifier: 143, sequenceNumber: 1 });
expect(result).toMatchObject({ type: 'icmpError', kind: 'destinationHostUnreachable' });
});
});
describe('switch', () => {
it('learns, floods, forwards, filters and ages its address table', () => {
const hostAId = uuid(0x1a01);
const hostAPortId = uuid(0x1a11);
const hostBId = uuid(0x1a02);
const hostBPortId = uuid(0x1a12);
const hostCId = uuid(0x1a03);
const hostCPortId = uuid(0x1a13);
const switchId = uuid(0x1a04);
const switchPorts = [1, 2, 3].map((index) => portSnapshot(uuid(0x1a20 + index), `sw${index}`));
const engine = startedEngine(
{
nodes: [
pc(hostAId, hostAPortId),
pc(hostBId, hostBPortId),
pc(hostCId, hostCPortId),
{ id: switchId, kind: 'networkSwitch', ports: switchPorts },
],
links: [
link(hostAId, hostAPortId, switchId, switchPorts[0]!.id),
link(hostBId, hostBPortId, switchId, switchPorts[1]!.id),
link(hostCId, hostCPortId, switchId, switchPorts[2]!.id),
],
switchConfigurationsByNodeId: new Map([[switchId, switchConfiguration('Classroom', 1_000)]]),
},
20,
);
const macA = stableMacAddress(hostAPortId);
const macB = stableMacAddress(hostBPortId);
const payload: EthernetPayload = {
kind: 'arp',
packet: {
operation: 'request',
senderMacAddress: macA,
senderIPAddress: '192.0.2.1',
targetMacAddress: UNSPECIFIED_MAC_ADDRESS,
targetIPAddress: '192.0.2.200',
},
};
const frame = (sourceMacAddress: string, destinationMacAddress: string): EthernetFrame => ({
identity: engine.allocateFrameIdentity(),
sourceMacAddress,
destinationMacAddress,
payload,
});
const switchTraces = (detail: string) =>
engine.state.packetTraces.filter((trace) => trace.nodeId === switchId && trace.detail === detail);
engine.sendEthernetFrame(hostAId, hostAPortId, frame(macA, '02:00:00:00:00:FE'));
expect(switchTraces('switch unknown unicast flooding')).toHaveLength(2);
engine.sendEthernetFrame(hostBId, hostBPortId, frame(macB, macA));
expect(switchTraces('switch learned unicast forwarding')).toHaveLength(1);
engine.sendEthernetFrame(hostAId, hostAPortId, frame(macA, macA));
expect(switchTraces('switch filtered destination on incoming port').length).toBeGreaterThan(0);
expect(engine.switchSatEntries(switchId)).toHaveLength(2);
const retention = 1_000;
const updateTimes = [...engine.state.switchForwardingUpdatedAtMillisecondsByNodeId.get(switchId)!.values()];
const expirationDeadlines = updateTimes.map((time) => time + retention);
const firstExpiration = Math.min(...expirationDeadlines);
const lastExpiration = Math.max(...expirationDeadlines);
engine.handle({ type: 'advance', toMilliseconds: firstExpiration - 1 });
expect(engine.switchSatEntries(switchId)).toHaveLength(2);
engine.handle({ type: 'advance', toMilliseconds: lastExpiration });
expect(engine.switchSatEntries(switchId)).toEqual([]);
});
});
describe('firewall', () => {
it('accepts when inactive, and an active ICMP drop bypasses the ordered rules', () => {
const routerId = uuid(0x1801);
const portId = uuid(0x1811);
const router: NodeSnapshot = { id: routerId, kind: 'router', ports: [portSnapshot(portId, 'rt1')] };
const packet: IPv4Packet = {
identity: 18,
senderIPAddress: '203.0.113.10',
receiverIPAddress: '10.0.0.1',
timeToLive: 64,
protocol: 'icmp',
payload: { kind: 'icmp', message: icmpMessage('echoRequest') },
};
const interfaces = new Map([
[interfaceKey(routerId, portId), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
]);
const inactive = startedEngine(
{
nodes: [router],
links: [],
interfaceConfigurations: interfaces,
firewallConfigurationsByNodeId: new Map([[routerId, firewallConfiguration()]]),
},
18,
);
expect(inactive.evaluateFirewall(packet, routerId).accepted).toBe(true);
const active = startedEngine(
{
nodes: [router],
links: [],
interfaceConfigurations: interfaces,
firewallConfigurationsByNodeId: new Map([
[
routerId,
firewallConfiguration({
isActive: true,
defaultPolicy: 'accept',
dropIcmp: true,
rules: [firewallRule({ protocol: 'icmp', action: 'accept' })],
}),
],
]),
},
18,
);
expect(active.evaluateFirewall(packet, routerId).accepted).toBe(false);
expect(active.state.firewallDecisions.at(-1)?.ruleIndex).toBeUndefined();
expect(active.state.packetTraces.at(-1)?.operation).toBe('dropped');
});
it('lets the first matching TCP rule win, and established traffic bypasses SYN-only rules', () => {
const { engine, routerId } = firewallEngine(
firewallConfiguration({
isActive: true,
defaultPolicy: 'drop',
filterSynSegmentsOnly: true,
rules: [
firewallRule({ port: 443, protocol: 'tcp', action: 'accept' }),
firewallRule({ port: 443, protocol: 'tcp', action: 'drop' }),
],
}),
);
const synDecision = engine.evaluateFirewall(tcpPacket(181, 443, TCP_FLAG.synchronize), routerId);
expect(synDecision.accepted).toBe(true);
expect(synDecision.ruleIndex).toBe(0);
expect(engine.evaluateFirewall(tcpPacket(182, 22, TCP_FLAG.acknowledgement), routerId).accepted).toBe(true);
});
it('matches a UDP response by its explicit source port and the direct-network marker', () => {
const { engine, routerId } = firewallEngine(
firewallConfiguration({
isActive: true,
defaultPolicy: 'drop',
rules: [
firewallRule({
sourceIPAddress: DIRECTLY_CONNECTED_SOURCE_MARKER,
destinationIPAddress: '8.8.8.8',
destinationSubnetMask: '255.255.255.255',
port: 53,
protocol: 'udp',
action: 'accept',
}),
],
}),
);
const udp = (identity: number, sender: string, receiver: string, sourcePort: number, destinationPort: number): IPv4Packet => ({
identity,
senderIPAddress: sender,
receiverIPAddress: receiver,
timeToLive: 64,
protocol: 'udp',
payload: { kind: 'udp', datagram: { sourcePort, destinationPort, payload: new Uint8Array() } },
});
expect(engine.evaluateFirewall(udp(183, '10.0.0.20', '8.8.8.8', 50_001, 53), routerId).accepted).toBe(true);
expect(engine.evaluateFirewall(udp(184, '8.8.8.8', '10.0.0.20', 53, 50_001), routerId).accepted).toBe(true);
});
});
describe('packet loss and trace retention', () => {
it('drops every physical delivery while global packet loss is on', () => {
const firstNodeId = uuid(0x101);
const firstPortId = uuid(0x102);
const secondNodeId = uuid(0x103);
const secondPortId = uuid(0x104);
const engine = startedEngine({
nodes: [pc(firstNodeId, firstPortId), pc(secondNodeId, secondPortId)],
links: [link(firstNodeId, firstPortId, secondNodeId, secondPortId)],
deviceConfigurations: new Map([
[firstNodeId, device('10.0.0.1')],
[secondNodeId, device('10.0.0.2')],
]),
});
const frame: EthernetFrame = {
identity: engine.allocateFrameIdentity(),
sourceMacAddress: stableMacAddress(firstPortId),
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
payload: {
kind: 'arp',
packet: {
operation: 'request',
senderMacAddress: stableMacAddress(firstPortId),
senderIPAddress: '10.0.0.1',
targetMacAddress: UNSPECIFIED_MAC_ADDRESS,
targetIPAddress: '10.0.0.2',
},
},
};
engine.setGlobalPacketLossEnabled(true);
engine.sendEthernetFrame(firstNodeId, firstPortId, frame);
expect(engine.state.globalPacketLossEnabled).toBe(true);
expect(engine.state.arpCachesByNodeId.get(secondNodeId)?.size ?? 0).toBe(0);
expect(
engine.state.packetTraces.some(
(trace) =>
trace.frameIdentity === frame.identity &&
trace.nodeId === secondNodeId &&
trace.operation === 'dropped' &&
trace.detail === 'global packet-loss simulation',
),
).toBe(true);
engine.setGlobalPacketLossEnabled(false);
engine.sendEthernetFrame(firstNodeId, firstPortId, frame);
expect(engine.state.globalPacketLossEnabled).toBe(false);
expect(engine.state.arpCachesByNodeId.get(secondNodeId)?.get('10.0.0.1')?.macAddress).toBe(
stableMacAddress(firstPortId),
);
});
it('resets packet loss across stop and restart', () => {
const engine = new NetworkRuntimeEngine(42);
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 42, initialTimeMilliseconds: 0 });
engine.setGlobalPacketLossEnabled(true);
expect(engine.state.globalPacketLossEnabled).toBe(true);
engine.handle({ type: 'stop' });
expect(engine.state.globalPacketLossEnabled).toBe(false);
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 42, initialTimeMilliseconds: 0 });
expect(engine.state.globalPacketLossEnabled).toBe(false);
});
it('evicts the oldest trace events in configured batches', () => {
const nodeId = uuid(0x2001);
const interfaceId = uuid(0x2002);
const engine = new NetworkRuntimeEngine(20, traceRetentionPolicy(3, 2));
for (let identity = 1; identity <= 6; identity += 1) {
engine.recordTrace({
frameIdentity: identity,
nodeId,
interfaceId,
direction: 'outbound',
layer: 'dataLink',
operation: 'sent',
detail: `trace-${identity}`,
});
}
expect(engine.state.packetTraces.map((trace) => trace.id)).toEqual([5, 6]);
expect(engine.state.packetTraces.map((trace) => trace.detail)).toEqual(['trace-5', 'trace-6']);
expect(engine.state.discardedPacketTraceCount).toBe(4);
expect(engine.state.nextTraceIdentity).toBe(7);
});
});
+50
View File
@@ -0,0 +1,50 @@
// Test fixtures, ported from the private helpers of
// filius-ipad ios/FiliusPadTests/TopologyNetworkRuntimeEngineTests.swift.
import {
deviceConfiguration,
NetworkRuntimeEngine,
portSnapshot,
topologySnapshot,
type DeviceConfiguration,
type Id,
type LinkSnapshot,
type NodeSnapshot,
type TopologySnapshotInput,
} from '../src/index.js';
let linkCounter = 0;
/** Swift uses a fresh `UUID()` per link; a counter keeps runs reproducible. */
export function link(sourceNodeId: Id, sourcePortId: Id, targetNodeId: Id, targetPortId: Id): LinkSnapshot {
linkCounter += 1;
return {
id: `ffffffff-0000-0000-0000-${linkCounter.toString(16).padStart(12, '0')}`,
sourceNodeId,
sourcePortId,
targetNodeId,
targetPortId,
};
}
export function pc(nodeId: Id, portId: Id): NodeSnapshot {
return { id: nodeId, kind: 'pc', ports: [portSnapshot(portId, 'eth0')] };
}
export function device(ipAddress: string, options: { mask?: string; gateway?: string } = {}): DeviceConfiguration {
return deviceConfiguration(ipAddress, options.mask ?? '255.255.255.0', options.gateway ?? '');
}
export function startedEngine(
input: TopologySnapshotInput,
seed: number = 14,
): NetworkRuntimeEngine {
const engine = new NetworkRuntimeEngine(seed);
engine.handle({ type: 'start', snapshot: topologySnapshot(input), seed, initialTimeMilliseconds: 0 });
return engine;
}
/** `00000000-0000-0000-0000-` followed by twelve hex digits. */
export function uuid(suffix: number): Id {
return `00000000-0000-0000-0000-${suffix.toString(16).toUpperCase().padStart(12, '0')}`;
}
+121
View File
@@ -0,0 +1,121 @@
// Ported from filius-ipad ios/FiliusPadTests/TopologyJavaRouteTableTests.swift.
// Left out: column titles and geometry (editor), and the reducer tests
// (they move with the document layer, packages/doc).
import { describe, expect, it } from 'vitest';
import {
appendingBlankDraftIfNeeded,
bestMatchingManualRoute,
blankRouteDraft,
isValidJavaIPAddress,
isValidJavaSubnetMask,
manualRouteFromDraft,
routeTableRows,
visibleRouteRows,
type ManualRoute,
} from '../src/index.js';
function route(destinationNetwork: string, subnetMask: string, gateway: string, interfaceIPAddress: string): ManualRoute {
return { destinationNetwork, subnetMask, gateway, interfaceIPAddress };
}
describe('Java route table', () => {
it('projects multiple interfaces in Java order with Java values', () => {
const rows = routeTableRows(
[
{ ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' },
{ ipAddress: '192.168.0.1', subnetMask: '255.255.255.0' },
],
[
route('172.16.0.0', '255.255.0.0', '10.0.0.2', '10.0.0.1'),
route('203.0.113.0', '255.255.255.0', '192.168.0.2', '192.168.0.1'),
],
'10.0.0.254',
);
expect(rows.map((row) => row.destinationNetwork)).toEqual([
'192.168.0.1', '10.0.0.1',
'192.168.0.0', '10.0.0.0',
'127.0.0.0',
'172.16.0.0', '203.0.113.0',
'0.0.0.0',
]);
expect(rows.map((row) => row.origin)).toEqual([
'localHost', 'localHost', 'connected', 'connected', 'localhost', 'manual', 'manual', 'defaultGateway',
]);
expect(rows.map((row) => row.isEditable)).toEqual([false, false, false, false, false, true, true, false]);
expect(rows[0]).toMatchObject({ subnetMask: '255.255.255.255', nextHop: '127.0.0.1', interfaceIPAddress: '127.0.0.1' });
expect(rows[2]?.nextHop).toBe('192.168.0.1');
expect(rows[4]?.nextHop).toBe('127.0.0.1');
expect(rows.at(-1)).toMatchObject({ nextHop: '10.0.0.254', interfaceIPAddress: '10.0.0.1' });
});
it('puts the default route on the last matching interface and falls back to the primary one', () => {
const interfaces = [
{ ipAddress: '10.0.0.1', subnetMask: '255.0.0.0' },
{ ipAddress: '10.1.0.1', subnetMask: '255.0.0.0' },
];
const matching = routeTableRows(interfaces, [], '10.2.0.1');
expect(matching.at(-1)).toMatchObject({ origin: 'defaultGateway', interfaceIPAddress: '10.1.0.1' });
const fallback = routeTableRows(interfaces, [], '192.168.0.1');
expect(fallback.at(-1)?.interfaceIPAddress).toBe('10.0.0.1');
});
it('shows only manual rows when generated rows are hidden', () => {
const rows = routeTableRows(
[{ ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
[route('172.16.0.0', '255.255.0.0', '10.0.0.2', '10.0.0.1')],
'10.0.0.254',
);
expect(visibleRouteRows(rows, false).map((row) => row.origin)).toEqual(['manual']);
expect(visibleRouteRows(rows, true)).toEqual(rows);
});
it('accepts leading zeros and non-contiguous masks like Java', () => {
expect(isValidJavaIPAddress('001.002.003.004')).toBe(true);
expect(isValidJavaSubnetMask('255.0.255.0')).toBe(true);
expect(isValidJavaIPAddress('256.0.0.1')).toBe(false);
expect(isValidJavaSubnetMask('255.255.255')).toBe(false);
expect(isValidJavaIPAddress(' 10.0.0.1')).toBe(false);
});
it('commits a draft only when all four cells are valid', () => {
const draft = blankRouteDraft('00000000-0000-0000-0000-000000000013');
draft.destinationNetwork = '10.0.0.0';
draft.subnetMask = '255.0.255.0';
draft.nextGateway = '10.0.0.2';
expect(manualRouteFromDraft(draft)).toBeUndefined();
draft.interfaceIPAddress = '10.0.0.1';
expect(manualRouteFromDraft(draft)).toEqual(route('10.0.0.0', '255.0.255.0', '10.0.0.2', '10.0.0.1'));
});
it('keeps at most one pending blank draft', () => {
let counter = 0;
const newId = () => `draft-${(counter += 1)}`;
const first = appendingBlankDraftIfNeeded([], newId);
const second = appendingBlankDraftIfNeeded(first, newId);
expect(first).toHaveLength(1);
expect(second).toEqual(first);
expect(second[0]?.isPendingInsertion).toBe(true);
});
it('picks the greatest numeric mask and keeps the first row on a tie', () => {
const broad = route('10.0.0.0', '255.0.0.0', '1.1.1.1', '10.0.0.1');
const firstSpecific = route('10.1.0.0', '255.255.0.0', '2.2.2.2', '10.0.0.1');
const tiedSpecific = route('10.1.0.0', '255.255.0.0', '3.3.3.3', '10.0.0.1');
expect(bestMatchingManualRoute('10.1.2.3', [broad, firstSpecific, tiedSpecific])).toBe(firstSpecific);
});
it('never includes learned RIP rows in the static projection', () => {
const rows = routeTableRows([{ ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }], [], undefined);
expect(rows.some((row) => row.origin === 'rip')).toBe(false);
});
});