A web page by its DNS name (internetwork_komplett), an e-mail between two networks through NAT and MX relay (Internet_Komplett_..._DE), a file found and fetched with Gnutella across a router (zwei_rechnernetze_komplett): ExampleRun records frames, tables, program lines and end states; the core plays the same calls and matches card by card. Equal RIP ties take Java's choice, RIP beacons are left out, the search's two connections compared flow by flow. Also: a core test that an equal RIP route from another router neither replaces nor refreshes the route, and nine stale type errors in core tests fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
263 lines
14 KiB
TypeScript
263 lines
14 KiB
TypeScript
// FILIUS' own examples run as Java FILIUS runs them (the acceptance of F2).
|
|
// tools/java-oracle's ExampleRun opened each file in Java FILIUS as FILIUS
|
|
// opens it, started it as the action mode does, played a story and wrote
|
|
// every frame each card saw, the capture's tables, the story's calls and the
|
|
// hosts' addresses (examples/<story>.java.json). Here the file is read with
|
|
// this package's reader and run in the core (projectSnapshot); each card's
|
|
// capture must hold the same frames at about the same times with the same
|
|
// table rows (javaRun.ts, cards by their MAC address from the file), the
|
|
// calls must return the same, the hosts must end with the same addresses.
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import { interfaceKey, NetworkRuntimeEngine, stableMacAddress, type DnsAddressResolution, type Id, type IPv4DeliveryResult } from '@mdg-filius/core';
|
|
import { projectSnapshot } from '../src/index.js';
|
|
import { exampleCards, exampleRecording, namesInJavaOrder, openExample } from './exampleRun.js';
|
|
import {
|
|
engineText,
|
|
expectSameCaptures,
|
|
GAP_TOLERANCE_MILLISECONDS,
|
|
gaps,
|
|
javaFrames,
|
|
withClientPortText,
|
|
type JavaFrame,
|
|
type JavaTables,
|
|
type SeenFrame,
|
|
} from '../../core/test/javaRun.js';
|
|
|
|
const SEED = 14;
|
|
|
|
interface JavaRun extends JavaTables {
|
|
frames: Record<string, JavaFrame[]>;
|
|
events: [number, string][];
|
|
devices: Record<string, [string, string, string, string]>;
|
|
/** Each card's MAC address in Java and its node's name with the card's index, `name/index`. */
|
|
cards: Record<string, string>;
|
|
}
|
|
|
|
type Call = (label: string, run: () => string) => void;
|
|
|
|
interface Story {
|
|
readonly file: string;
|
|
/**
|
|
* Hosts that start their DHCP clients together: which sends first is up to
|
|
* Java's threads, and its log loses some of their frames (dhcpJava.test.ts).
|
|
* Compared per client instead of per card: its own frames and the
|
|
* server's answers to it, the long waits at about the same distances. Which address a
|
|
* client gets follows the order the server hears them in: an address of
|
|
* the server's range (`dhcpRange`) is a placeholder, and the hosts must
|
|
* end with the same addresses, in any order.
|
|
*/
|
|
readonly dhcpClients?: readonly string[];
|
|
readonly dhcpRange?: RegExp;
|
|
/**
|
|
* When the story's calls begin (the engine's time), after the DHCP clients
|
|
* are done: the clients are compared before it, each card from it on, both
|
|
* counted from the first call.
|
|
*/
|
|
readonly storyStart?: number;
|
|
readonly play: (engine: NetworkRuntimeEngine, node: (name: string) => Id, call: Call) => void;
|
|
}
|
|
|
|
const ICMP_CODES: Record<string, string> = { timeExceeded: '11/0', destinationNetworkUnreachable: '3/0', destinationHostUnreachable: '3/1' };
|
|
|
|
/** A ping in Java's words (ICMP.sendProbe): the answer's type, code and sender, or null. */
|
|
function ping(engine: NetworkRuntimeEngine, from: Id, target: string, sequenceNumber: number): string {
|
|
const result: IPv4DeliveryResult = engine.sendIcmpEcho(from, target, { sequenceNumber, identifier: 1 });
|
|
const observations = engine.state.icmpObservationsByNodeId.get(from) ?? [];
|
|
if (result.type === 'icmpError') {
|
|
const error = observations.findLast((observation) => observation.message.kind === result.kind);
|
|
return `ICMP ${ICMP_CODES[result.kind]} from ${error?.packet.senderIPAddress ?? '?'}`;
|
|
}
|
|
const reply = observations.find((observation) => observation.message.kind === 'echoReply' && observation.message.sequenceNumber === sequenceNumber);
|
|
return reply === undefined ? 'null' : `ICMP 0/0 from ${reply.packet.senderIPAddress}`;
|
|
}
|
|
|
|
/** A lookup in Java's words (Resolver): the address, null, or the exception. */
|
|
function lookup(resolution: DnsAddressResolution): string {
|
|
return resolution.type === 'timedOut' ? 'failed TimeOutException' : `returned ${resolution.address ?? 'null'}`;
|
|
}
|
|
|
|
const STORIES: Record<string, Story> = {
|
|
routing: {
|
|
file: 'internetwork_komplett.fls',
|
|
play: (engine, node, call) => {
|
|
call('sendProbe 141.99.5.10', () => ping(engine, node('Notebook 1.10'), '141.99.5.10', 1));
|
|
call('sendProbe 141.99.5.10', () => ping(engine, node('Notebook 1.11'), '141.99.5.10', 2));
|
|
},
|
|
},
|
|
dhcp: {
|
|
file: 'einfaches_rechnernetz_komplett.fls',
|
|
dhcpClients: ['Notebook 1', 'Notebook 2', 'Notebook 3', 'Notebook 4'],
|
|
dhcpRange: /192\.168\.0\.2\d/g,
|
|
play: () => {},
|
|
},
|
|
dns: {
|
|
file: 'dns_server.fls',
|
|
play: (engine, node, call) => {
|
|
call('holeIPAdresse www.filius.de', () => lookup(engine.resolveDnsAddress(node('Notebook'), 'www.filius.de')));
|
|
call('holeIPAdresseMailServer filius.de', () => lookup(engine.resolveDnsMailServer(node('Notebook'), 'filius.de')));
|
|
},
|
|
},
|
|
firewall: {
|
|
file: 'Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls',
|
|
dhcpClients: ['Notebook', 'Notebook#2', 'Heimrouter', 'Notebook#3', 'iPad 1', 'iPad 2', 'iPad 3', 'iPad 4'],
|
|
dhcpRange: /10\.50\.50\.\d+/g,
|
|
storyStart: 8_000,
|
|
play: (engine, node, call) => {
|
|
engine.handle({ type: 'advance', toMilliseconds: 8_000 });
|
|
call('sendProbe 130.100.200.25', () => ping(engine, node('iPad 1'), '130.100.200.25', 1));
|
|
call('sendProbe 130.100.200.25', () => ping(engine, node('Notebook'), '130.100.200.25', 2));
|
|
call('sendProbe 42.0.0.10', () => ping(engine, node('mail.gmx.de (130.100.200.25)'), '42.0.0.10', 3));
|
|
},
|
|
},
|
|
nat: {
|
|
file: 'nat_with_mapped_port.fls',
|
|
play: (engine, node, call) => {
|
|
call('sendProbe 141.99.4.10', () => ping(engine, node('Notebook 1.10'), '141.99.4.10', 1));
|
|
call('sendProbe 141.99.5.10', () => ping(engine, node('Notebook 1.10'), '141.99.5.10', 2));
|
|
},
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Where Java errs, per story: its run corrected, as Java means it.
|
|
*
|
|
* routing: Notebook 1.11 has no gateway; Java's sendProbe fails with a
|
|
* NullPointerException (no route), the engine answers network unreachable
|
|
* from the notebook itself, as a router does.
|
|
*
|
|
* nat: both pings leave with identifier 1. Java's Heimrouter Paul keeps one
|
|
* entry per LAN endpoint, for its first remote end (natJava.test.ts,
|
|
* udpTwoRemotes): the echo reply of the second, Heimrouter Vincent, finds no
|
|
* entry and stays at Paul's WAN card. The engine adds an entry for the second
|
|
* remote end: the reply goes on through Paul's LAN card to the notebook, a
|
|
* cable later.
|
|
*/
|
|
const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
|
|
routing: (java) => ({
|
|
...java,
|
|
events: java.events.map(([time, event]) => [time, event === 'sendProbe 141.99.5.10 failed NullPointerException' ? 'sendProbe 141.99.5.10 returned ICMP 3/0 from 141.99.1.11' : event]),
|
|
}),
|
|
nat: (java) => {
|
|
const card = (name: string) => Object.entries(java.cards).find(([, each]) => each === name)![0];
|
|
const [paulWan, paulLan, notebook] = [card('Heimrouter Paul/0'), card('Heimrouter Paul/1'), card('Notebook 1.10/0')];
|
|
const reply = java.frames[paulWan]!.find((frame) => frame.icmp === 0 && frame.sourceIp === '141.99.5.10')!;
|
|
const request = java.frames[paulLan]!.find((frame) => frame.icmp === 8 && frame.destinationIp === '141.99.5.10')!;
|
|
const onLan: JavaFrame = { ...reply, time: reply.time + 1, source: request.destination, destination: request.source, destinationIp: request.sourceIp! };
|
|
const called = java.events.findIndex(([, event]) => event === 'sendProbe 141.99.5.10 called');
|
|
return {
|
|
...java,
|
|
frames: { ...java.frames, [paulLan]: [...java.frames[paulLan]!, onLan], [notebook]: [...java.frames[notebook]!, { ...onLan, time: onLan.time + 55 }] },
|
|
events: java.events.map(([time, event], index) =>
|
|
index === called + 1 ? [onLan.time + 55, 'sendProbe 141.99.5.10 returned ICMP 0/0 from 141.99.5.10'] : [time, event],
|
|
),
|
|
};
|
|
},
|
|
};
|
|
|
|
describe("FILIUS' examples as Java FILIUS runs them", () => {
|
|
it('corrects Java only where it errs', () => {
|
|
for (const [name, correct] of Object.entries(JAVA_BUGS)) {
|
|
const java = exampleRecording<JavaRun>(name);
|
|
expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java));
|
|
}
|
|
});
|
|
|
|
for (const [name, story] of Object.entries(STORIES)) {
|
|
it(`${name}: ${story.file}`, () => {
|
|
const measured = exampleRecording<JavaRun>(name);
|
|
const java = JAVA_BUGS[name]?.(measured) ?? measured;
|
|
const project = openExample(story.file);
|
|
const names = namesInJavaOrder(project);
|
|
const engine = new NetworkRuntimeEngine(SEED);
|
|
engine.handle({ type: 'start', snapshot: projectSnapshot(project), seed: SEED, initialTimeMilliseconds: 0 });
|
|
engine.handle({ type: 'advance', toMilliseconds: 500 });
|
|
|
|
const events: [number, string][] = [];
|
|
story.play(engine, (nodeName) => names.get(nodeName)!, (label, run) => {
|
|
events.push([engine.state.currentTimeMilliseconds, `${label} called`]);
|
|
events.push([engine.state.currentTimeMilliseconds, `${label} ${run().replace(/^(?!returned |failed )/, 'returned ')}`]);
|
|
});
|
|
const last = Math.max(...Object.values(java.frames).flat().map((frame) => frame.time), ...java.events.map(([time]) => time));
|
|
engine.handle({ type: 'advance', toMilliseconds: Math.max(engine.state.currentTimeMilliseconds, last) + 1_000 });
|
|
|
|
expect(events.map(([, event]) => event)).toEqual(java.events.map(([, event]) => event));
|
|
const { cards, withEngineMacs } = exampleCards(project, names, java.cards);
|
|
const javaStoryStart = java.events[0]?.[0];
|
|
if (story.storyStart !== undefined) {
|
|
expectSameCaptures(engine, cards, java.frames, {
|
|
text: (text) => withClientPortText(withEngineMacs(text)).replace(/ID=\d+/g, 'ID=x'),
|
|
cell: withEngineMacs,
|
|
tables: measured,
|
|
from: javaStoryStart!,
|
|
starts: { java: javaStoryStart!, engine: story.storyStart },
|
|
});
|
|
}
|
|
if (story.dhcpClients === undefined) {
|
|
expectSameCaptures(engine, cards, java.frames, {
|
|
text: (text) => withClientPortText(withEngineMacs(text)).replace(/ID=\d+/g, 'ID=x'),
|
|
cell: withEngineMacs,
|
|
tables: measured,
|
|
});
|
|
} else {
|
|
// Each frame where it first shows on any card. Java's log loses some of a client's own frames at
|
|
// its card, and four clients' broadcasts queue on the cables: a frame reaches the cards up to some
|
|
// 450 ms apart, so the same text within a second is the same frame (a client repeats a message
|
|
// only after a round trip, 2750 ms).
|
|
const engineFrames = new Map<number, SeenFrame>();
|
|
for (const { portId } of cards) {
|
|
for (const each of engine.capture.frames(portId)) {
|
|
if (!engineFrames.has(each.frame.identity)) engineFrames.set(each.frame.identity, { time: each.timeMilliseconds, text: engineText(each.frame) });
|
|
}
|
|
}
|
|
const allEngine = [...engineFrames.values()].filter((frame) => frame.time < (story.storyStart ?? Infinity)).sort((lhs, rhs) => lhs.time - rhs.time);
|
|
const allJava = javaFrames(java.frames, 1_000)
|
|
.filter((frame) => story.storyStart === undefined || frame.time < javaStoryStart!).map((frame) => ({ ...frame, text: withEngineMacs(frame.text) }));
|
|
for (const client of story.dhcpClients) {
|
|
const port = project.graph.nodes.find((node) => node.id === names.get(client))!.ports[0]!;
|
|
const mac = (port.importedMacAddress ?? stableMacAddress(port.id)).toUpperCase();
|
|
const own = (frames: readonly SeenFrame[]) =>
|
|
gaps(
|
|
frames
|
|
.filter((frame) => frame.text.startsWith(`${mac} >`) || frame.text.includes(`chaddr=${mac}`))
|
|
.map((frame) => ({ ...frame, text: frame.text.replace(story.dhcpRange!, 'leased') })),
|
|
);
|
|
const [expected, actual] = [own(allJava), own(allEngine)];
|
|
for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
|
|
expect(actual[index]?.text, `${client} frame #${index + 1}`).toBe(expected[index]?.text);
|
|
// The server serves clients that come together one after the other, in the order Java's
|
|
// threads happen to send, and their broadcasts queue on the cables: a short step depends on
|
|
// how many frames are ahead (measured: up to some 550 ms with eight clients). Only the long
|
|
// waits (a round trip) are compared, as tightly as elsewhere.
|
|
if (expected[index]!.gap < 1_000) continue;
|
|
expect(Math.abs(actual[index]!.gap - expected[index]!.gap), `${client} gap before frame #${index + 1}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Java's addresses of a host's card (ScenarioNetwork.writeDevices: the first card of every node with
|
|
// an IP stack); a router without a gateway has only its cards' settings in the model.
|
|
const snapshot = engine.state.topologySnapshot;
|
|
const devices = Object.fromEntries(
|
|
[...names].flatMap(([nodeName, id]) => {
|
|
const device = snapshot.deviceConfigurations.get(id);
|
|
if (device !== undefined) return [[nodeName, [device.ipAddress, device.subnetMask, device.defaultGateway, device.dnsServer]]];
|
|
const node = project.graph.nodes.find((each) => each.id === id)!;
|
|
const card = node.ports[0] === undefined ? undefined : snapshot.interfaceConfigurations.get(interfaceKey(id, node.ports[0].id));
|
|
return card === undefined || node.kind === 'networkSwitch' ? [] : [[nodeName, [card.ipAddress, card.subnetMask, '', '']]];
|
|
}),
|
|
);
|
|
if (story.dhcpRange === undefined) {
|
|
expect(devices).toEqual(java.devices);
|
|
} else {
|
|
const leased = (byName: Record<string, string[]>) =>
|
|
Object.values(byName)
|
|
.map((device) => device.join(' '))
|
|
.sort();
|
|
expect(leased(devices)).toEqual(leased(java.devices));
|
|
expect(Object.keys(devices).sort()).toEqual(Object.keys(java.devices).sort());
|
|
}
|
|
});
|
|
}
|
|
});
|