Files
mdg-filius/packages/core/test/arpJava.test.ts
T
julian.kiedaischandClaude Opus 5.5 088b62469f feat(core): packet capture per card after Java's Lauscher, measured card by card
The engine keeps Java's packet capture (capture.ts): per network card
every frame it sent (when it leaves on the cable) or received (also one
for another card), once, with time and number; switches log nothing;
frames the packet loss dropped are kept apart; the capture outlives a
stop and a new start, as in Java, until clearCapture. Per port instead
of per MAC, at most 10,000 frames a card.

expectSameCaptures compares each card's capture with Java's list of that
card in the ARP, WLAN, DNS, TCP, NAT, RIP and single-client DHCP runs.
It found that ScenarioNetwork ignored a switch's learning time (Java ran
300 s, the core the scenarios' 5 s); the oracle sets it now, and the TCP
and DHCP runs are recorded again. A core test found that a new start
numbers frames from 1 again and the capture took them as seen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:53:37 +00:00

124 lines
6.2 KiB
TypeScript

// ARP as Java FILIUS runs it. tools/java-oracle's ArpRun sent to addresses no
// card has on arp-routed's network in Java FILIUS and wrote every frame each
// card saw and when each call started and ended (arp-java-<story>.java.json).
// The engine makes the same calls: the frames must come in the same order,
// with the same content, at about the same distance from each other
// (javaRun.ts); the calls must end at about the same times and return the same.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { icmpMessage, type NetworkRuntimeEngine } from '../src/index.js';
import { expectSameCaptures, withClientPortText, GAP_TOLERANCE_MILLISECONDS, gaps, javaFrames, watchedEngine, type JavaFrame } from './javaRun.js';
import type { Scenario } from './scenario.js';
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
const HOST = '00000000-0000-0000-0000-000000000301';
interface JavaRun {
frames: Record<string, JavaFrame[]>;
events: [number, string][];
}
/** A ping from HOST in Java's words: the ICMP answer's type and code, or null when none came in time. */
function ping(engine: NetworkRuntimeEngine, target: string, sequenceNumber = 1): string {
const result = engine.sendIcmpEcho(HOST, target, { sequenceNumber });
if (result.type === 'icmpError' && result.kind === 'destinationHostUnreachable') return 'ICMP 3/1';
const answered = (engine.state.icmpObservationsByNodeId.get(HOST) ?? []).some(
(observation) => observation.message.kind === 'echoReply' && observation.message.sequenceNumber === sequenceNumber,
);
return answered ? 'ICMP 0/0' : 'null';
}
/** Records a call as ArpRun does: when it started, when it ended and what it returned. */
type Call = (label: string, run: () => string) => void;
function udpSend(engine: NetworkRuntimeEngine, target: string, payload: string): string {
const socket = engine.bindUdpSocket(HOST, { remoteIPAddress: target, remotePort: 9 })!;
engine.sendUdp(socket, new TextEncoder().encode(payload));
return '';
}
const STORIES: Record<string, (engine: NetworkRuntimeEngine, call: Call) => void> = {
pingLan: (engine, call) => call('sendProbe 10.0.0.9', () => ping(engine, '10.0.0.9')),
pingRouted: (engine, call) => call('sendProbe 20.0.0.9', () => ping(engine, '20.0.0.9')),
udpTwice: (engine, call) => {
call('senden eins', () => udpSend(engine, '10.0.0.9', 'eins'));
call('senden zwei', () => udpSend(engine, '10.0.0.9', 'zwei'));
},
udpBehind: (engine, call) => {
call('senden verloren', () => udpSend(engine, '10.0.0.9', 'verloren'));
call('senden dahinter', () => udpSend(engine, '20.0.0.2', 'dahinter'));
},
routedUdpBehind: (engine, call) => {
call('senden verloren', () => udpSend(engine, '20.0.0.9', 'verloren'));
call('senden dahinter', () => udpSend(engine, '20.0.0.2', 'dahinter'));
},
routedPingBesideUdp: (engine, call) => {
call('sendProbe 20.0.0.2', () => ping(engine, '20.0.0.2'));
call('senden verloren', () => udpSend(engine, '20.0.0.9', 'verloren'));
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 100 });
call('sendProbe 20.0.0.2', () => ping(engine, '20.0.0.2', 2));
},
routedPingBehindPing: (engine, call) => {
// Java's sendeICMP sends in the caller's thread, which waits for ARP.
call('sendeICMP 20.0.0.9', () => {
engine.resolveMacAddress(HOST, '10.0.0.1');
engine.sendIPv4Packet(HOST, {
identity: engine.allocatePacketIdentity(),
senderIPAddress: '10.0.0.2',
receiverIPAddress: '20.0.0.9',
timeToLive: 64,
protocol: 'icmp',
payload: { kind: 'icmp', message: icmpMessage('echoRequest', { identifier: 2, sequenceNumber: 2, data: new Uint8Array() }) },
});
return '';
});
call('sendProbe 20.0.0.2', () => ping(engine, '20.0.0.2'));
},
};
/** The client's free port as "client": Java draws it at random, the engine from its seed. */
function withClientPort<T extends { text: string }>(frames: readonly T[]): T[] {
return frames.map((frame) => ({ ...frame, text: frame.text.replace(/:(\d{5})\b/g, (whole, port: string) => (Number(port) >= 49_152 ? ':client' : whole)) }));
}
/** Java's events without its answer's sender ("from …"), which the engine's results do not name. */
function javaEvent(event: string): string {
return event.replace(/ from .*$/, '');
}
describe('ARP as Java FILIUS runs it', () => {
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'arp-routed.scenario.json'), 'utf8')) as Scenario;
for (const [name, story] of Object.entries(STORIES)) {
it(name, () => {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `arp-java-${name}.java.json`), 'utf8')) as JavaRun;
const { engine, frames } = watchedEngine(scenario);
engine.handle({ type: 'advance', toMilliseconds: 500 });
const events: [number, string][] = [];
story(engine, (label, run) => {
events.push([engine.state.currentTimeMilliseconds, `${label} called`]);
const result = run();
events.push([engine.state.currentTimeMilliseconds, `${label} returned ${result}`]);
});
engine.handle({ type: 'advance', toMilliseconds: 16_000 });
const expected = gaps(withClientPort(javaFrames(java.frames)));
const actual = gaps(withClientPort(frames()));
expect(events.map(([, event]) => event)).toEqual(java.events.map(([, event]) => javaEvent(event)));
for (const [index, [time]] of events.entries()) {
expect(Math.abs(time - events[0]![0] - (java.events[index]![0] - java.events[0]![0])), `time of ${events[index]![1]}`).toBeLessThanOrEqual(
GAP_TOLERANCE_MILLISECONDS,
);
}
for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
expect(actual[index]?.text, `frame #${index + 1}`).toBe(expected[index]?.text);
expect(Math.abs(actual[index]!.gap - expected[index]!.gap), `gap before frame #${index + 1}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
}
expectSameCaptures(engine, scenario, java.frames, { text: withClientPortText });
});
}
});