Files
mdg-filius/packages/core/test/terminalJava.test.ts
T
julian.kiedaischandClaude Opus 5.5 49b8fe715e test: F3 acceptance — the classroom classics on FILIUS' examples against Java
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>
2026-10-03 08:01:32 +00:00

592 lines
31 KiB
TypeScript

// The command line's network commands as Java FILIUS runs them.
// tools/java-oracle's TerminalRun typed lines into FILIUS' command line on
// the hosts of terminal-net at set times and wrote every frame each card
// saw, Java's tables of them, and everything each command line told its
// window (terminal-java-<story>.java.json). The engine types the same lines
// at the same times (apps/terminal.ts): each card's frames in the same
// order, with the same content and rows, at about the same time; the same
// texts and busy marks, at about the same time.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { NetworkRuntimeEngine, withSnapshot } from '../src/index.js';
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, watchedEngine, withClientPortText, type JavaFrame, type JavaTables, type SeenFrame } from './javaRun.js';
import { POLL_MILLISECONDS } from './programLines.js';
import { snapshotOf, type Scenario } from './scenario.js';
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
const CLIENT = '00000000-0000-0000-0000-000000000401';
const DNS = '00000000-0000-0000-0000-000000000402';
const WEB = '00000000-0000-0000-0000-000000000403';
const ECHO = '00000000-0000-0000-0000-000000000404';
const FAR = '00000000-0000-0000-0000-000000000406';
const HOSTS = [CLIENT, DNS, WEB, ECHO, FAR];
type Told = ['text' | 'busy', string];
interface JavaRun extends JavaTables {
frames: Record<string, JavaFrame[]>;
acts: [number, string, string][];
/** time, host, kind (call, text or busy), text */
events: [number, string, string, string][];
}
const STORIES = [
'pingLan',
'pingRouted',
'pingNobody',
'pingUnreachable',
'pingBroadcast',
'pingSelf',
'pingErrors',
'interrupt',
'dns',
'dnsTimeout',
'arp',
'netstat',
'traceLan',
'traceRouted',
'traceUnreachable',
'traceNoRoute',
'traceNobody',
'traceSelf',
'traceErrors',
'traceInterrupt',
'tcpdump',
] as const;
type Lines = Record<string, [number, Told][]>;
function javaLines(java: JavaRun): Lines {
const lines: Lines = Object.fromEntries(HOSTS.map((host) => [host, []]));
for (const [time, host, kind, text] of java.events) {
if (kind === 'call') continue;
lines[host]!.push([time, [kind as Told[0], text]]);
}
return lines;
}
function engineLines(engine: NetworkRuntimeEngine): Lines {
return Object.fromEntries(
HOSTS.map((host) => [
host,
engine.terminal(host)!.log.all.map((line): [number, Told] => [line.timeMilliseconds, line.kind === 'text' ? ['text', line.text] : ['busy', String(line.busy)]]),
]),
);
}
/**
* A text with placeholders: a client's port; a ping's time (compared on its
* own); the rows of an ARP or netstat table sorted (Java lists them in a
* HashMap's order, the engine in the order learned, or made).
*/
function lineText(text: string): string {
const table = /^(\| [^\n]*\|\n-+\n)((?:\|[^\n]*\n)*)(-+\n)?$/.exec(text);
// Sorted within each protocol of netstat's, the protocols in the order they come (TCP, then UDP, in Java too).
const rows = table?.[2]!.split('\n').filter(Boolean) ?? [];
const protocols = [...new Set(rows.map((row) => /^\| (TCP|UDP) /.exec(row)?.[1] ?? ''))];
const protocol = (row: string) => protocols.indexOf(/^\| (TCP|UDP) /.exec(row)?.[1] ?? '');
rows.sort((lhs, rhs) => protocol(lhs) - protocol(rhs) || (lhs < rhs ? -1 : lhs > rhs ? 1 : 0));
const sorted = table === null ? text : `${table[1]}${rows.join('\n')}\n${table[3] ?? ''}`;
// tcpdump: a frame's time of day, a ping's identifier, a DNS query's ID.
return withClientPortText(sorted)
.replace(/time=\d+ms/, 'time=…ms')
.replace(/^\n {2}\d{2}:\d{2}:\d{2}\.\d{3} /, '\n <time> ')
.replace(/Identifier: \d+/, 'Identifier: x')
.replace(/ID=\d+/, 'ID=x');
}
const pingTime = (text: string) => Number(/time=(\d+)ms/.exec(text)?.[1] ?? 0);
/** The identifier a ping draws at random (Java: new Random()). */
const cellText = (text: string) => text.replace(/Identifier: \d+/, 'Identifier: x').replace(/identifier=\d+/, 'identifier=x');
/** Java's run with what each line told replaced where Java errs. */
function withTold(java: JavaRun, change: (host: string, text: string, time: number) => string | undefined): JavaRun {
return {
...java,
events: java.events.flatMap(([time, host, kind, text]): [number, string, string, string][] => {
if (kind !== 'text') return [[time, host, kind, text]];
const changed = change(host, text, time);
return changed === undefined ? [] : [[time, host, kind, changed]];
}),
};
}
/** The answers to an echo from `from` to the client, in Java's frames and rows, as from `to`. */
function withEchoReplySource(java: JavaRun, from: string, to: string, after: number): JavaRun {
const frames = Object.fromEntries(
Object.entries(java.frames).map(([card, list]) => [
card,
list.map((frame) => (frame.icmp === 0 && frame.sourceIp === from && frame.time >= after ? { ...frame, sourceIp: to } : frame)),
]),
);
const replies = (card: string) =>
new Set(java.frames[card]!.flatMap((frame, index) => (frame.icmp === 0 && frame.sourceIp === from && frame.time >= after ? [String(index + 1)] : [])));
const rows = (tables: Record<string, (string | null)[][]> | undefined) =>
tables === undefined
? undefined
: Object.fromEntries(
Object.entries(tables).map(([card, list]) => [card, list.map((row) => (replies(card).has(row[0]!) && row[1] === from ? [row[0]!, to, ...row.slice(2)] : row))]),
);
return { ...java, frames, rows: rows(java.rows), layeredRows: rows(java.layeredRows) };
}
/** Java's tables with each cell changed. */
function withCells(java: JavaRun, change: (cell: string) => string): JavaRun {
const tables = (list: Record<string, (string | null)[][]> | undefined) =>
list === undefined
? undefined
: Object.fromEntries(Object.entries(list).map(([card, rows]) => [card, rows.map((row) => row.map((cell) => (cell === null ? null : change(cell))))]));
return { ...java, rows: tables(java.rows), layeredRows: tables(java.layeredRows) };
}
/** traceroute: the summary told before the end mark (Java tells it after, where its window shows no more text). */
function withSummaryBeforeEnd(java: JavaRun): JavaRun {
const events = [...java.events];
for (let index = 0; index + 1 < events.length; index += 1) {
const [time, host, kind, text] = events[index]!;
const next = events.findIndex((each, at) => at > index && each[1] === host);
if (kind !== 'busy' || text !== 'false' || next === -1 || events[next]![2] !== 'text' || !events[next]![3].startsWith('\n\n')) continue;
const summary = events[next]!;
events.splice(next, 1);
events.splice(index, 0, [time, host, 'text', summary[3]]);
index += 1;
}
return { ...java, events };
}
const USAGE_PING = 'ping - test connection to other computer\nUsage: ping <remote host> \n <remote host> the remote address as IP address or host name\n';
/**
* Where Java errs: its run corrected, as Java means it.
* - Java's ping names its first parameter where it means the host (-b in the
* statistics of ping -b), and takes any first word for -b (ping -x pings).
* - An echo's answer comes from the address asked (RFC 792: the addresses
* reversed): Java answers from 127.0.0.1 when the host's own address is
* asked, from the near card when a router's far address is.
* - netstat on a UDP server lists its listening socket only: Java also lists
* a socket for each address and port it ever answered (never closed).
* - A router's network unreachable carries the echo as it would have
* forwarded it, with its sender (Java's is half made: sender null).
* - traceroute tells its summary before the end mark (withSummaryBeforeEnd);
* the host unreachable a host gives itself comes from its address (Java:
* 127.0.0.1, as its answer to itself).
*/
const JAVA_BUGS: Partial<Record<(typeof STORIES)[number], (java: JavaRun) => JavaRun>> = {
pingBroadcast: (java) =>
withTold(java, (host, text, time) => {
const target = host === CLIENT && time >= 7_000 ? '255.255.255.255' : '192.168.0.255';
return text.replace('--- -b ', `--- ${target} `).replace('From -b (', `From ${target} (`);
}),
pingErrors: (java) => {
const typed = java.acts.find(([, , line]) => line.startsWith('ping -x'))![0];
const corrected = withTold(java, (_, text, time) => (time < typed ? text : undefined));
return {
...corrected,
frames: Object.fromEntries(Object.entries(java.frames).map(([card, list]) => [card, list.filter((frame) => frame.time < typed)])),
events: [...corrected.events.filter(([time, , kind]) => time < typed || kind !== 'busy'), [typed, CLIENT, 'text', USAGE_PING]],
};
},
pingSelf: (java) => withTold(java, (_, text, time) => (time < 6_000 ? text.replace('From 127.0.0.1:', 'From 192.168.0.20:') : text)),
pingRouted: (java) => {
const typed = java.acts.find(([, , line]) => line === 'ping 10.0.0.1')![0];
return withEchoReplySource(
withTold(java, (_, text, time) => (time >= typed ? text.replace('From 192.168.0.1:', 'From 10.0.0.1:') : text)),
'192.168.0.1',
'10.0.0.1',
typed,
);
},
pingUnreachable: (java) => withCells(java, (cell) => cell.replace('sender=null;', 'sender=192.168.0.20;')),
traceLan: withSummaryBeforeEnd,
traceRouted: (java) => {
const typed = java.acts.find(([, , line]) => line === 'traceroute 10.0.0.1')![0];
return withEchoReplySource(
withSummaryBeforeEnd(withTold(java, (_, text, time) => (time >= typed && text === '192.168.0.1' ? '10.0.0.1' : text))),
'192.168.0.1',
'10.0.0.1',
typed,
);
},
traceUnreachable: withSummaryBeforeEnd,
traceNoRoute: (java) => withSummaryBeforeEnd(withCells(java, (cell) => cell.replace('sender=null;', 'sender=192.168.0.20;'))),
traceNobody: (java) => withSummaryBeforeEnd(withTold(java, (_, text) => text.replace('127.0.0.1', '192.168.0.20'))),
traceSelf: (java) => withSummaryBeforeEnd(withTold(java, (_, text, time) => (time < 1_500 ? text.replace('127.0.0.1', '192.168.0.20') : text))),
traceInterrupt: withSummaryBeforeEnd,
netstat: (java) => withTold(java, (host, text) => (host === DNS ? text.replace(/\| UDP {5}\| {4}192\.168\.0\.10:53 {4}\|[^\n]*CONNECTED +\|\n/g, '') : text)),
};
/**
* Where the frames are compared only up to a time (Java's): pingErrors, the
* ping Java sends for ping -x; traceUnreachable, from the sixth probe on:
* Java's router logs it 2.5 s late. Its ICMP thread waits for ARP inside
* the lock of its buffer (ProtokollThread.run), the card's thread blocks on
* handing it the next frame, and the frames behind are read, and logged,
* only when ARP gives up; the engine logs a frame when it comes. (The texts
* are the same either way.)
*/
/** traceroute's sixth probe: its numbers count on from 42 * 23. */
const TRACEROUTE_SIXTH_PROBE = 42 * 23 + 6;
const UNTIL: Partial<Record<(typeof STORIES)[number], (java: JavaRun) => number>> = {
pingErrors: (java) => java.acts.find(([, , line]) => line.startsWith('ping -x'))![0],
traceUnreachable: (java) => java.frames[CLIENT]!.find((frame) => frame.icmp === 8 && frame.sequence === TRACEROUTE_SIXTH_PROBE)!.time + 50,
};
/** The hosts that answer a broadcast echo at once: which answer comes first is a race of Java's threads. */
const BROADCAST_ANSWER = /From 192\.168\.0\.(1|10|30|31): icmp_seq/;
/**
* pingBroadcast: the hosts answer each broadcast echo at once, and the order
* of their ARP requests and answers on the cables is a race of Java's
* threads. Its frames are compared second by second (each ping's), as a
* set; Java's tables, which go frame by frame, are left out.
*/
const RACING = new Set(['pingBroadcast']);
const bySecond = (frames: SeenFrame[]): SeenFrame[] =>
frames
.map((frame) => ({ time: Math.floor((frame.time - 900) / 1_000) * 1_000 + 900, text: frame.text }))
.sort((lhs, rhs) => lhs.time - rhs.time || lhs.text.localeCompare(rhs.text));
describe('the command line on the network as Java FILIUS runs it', () => {
it('corrects Java only where it errs, and cuts only after the story began', () => {
for (const [name, correct] of Object.entries(JAVA_BUGS)) {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `terminal-java-${name}.java.json`), 'utf8')) as JavaRun;
expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java));
expect(UNTIL[name as (typeof STORIES)[number]]?.(java) ?? Number.POSITIVE_INFINITY, name).toBeGreaterThan(1_000);
}
});
// ICMPThread.waitForIcmpResponse misses its wake-up when the answer is there before it waits (a race of Java's
// threads: an echo to the host itself, the host unreachable a host gives itself), and waits a whole round trip.
// The engine answers at once; a recording with a missed wake-up is made again (tools/java-oracle/run.sh).
it("was recorded without a missed wake-up of Java's", () => {
const told = (name: string) =>
(JSON.parse(readFileSync(join(scenarioDirectory, `terminal-java-${name}.java.json`), 'utf8')) as JavaRun).events.filter(([, , kind]) => kind === 'text');
for (const [, , , text] of told('pingSelf')) expect(pingTime(text), `pingSelf: ${JSON.stringify(text)}`).toBeLessThan(1_000);
const timeouts = told('pingNobody').filter(([, , , text]) => text.includes('Timeout!'));
for (const [index, [time, , , text]] of timeouts.slice(1).entries()) {
if (text.includes('icmp_seq=1 ')) continue;
expect(time - timeouts[index]![0], 'pingNobody: two ARP round trips per echo').toBeLessThan(6_000);
}
});
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'terminal-net.scenario.json'), 'utf8')) as Scenario;
for (const name of STORIES) {
it(name, () => {
const measured = JSON.parse(readFileSync(join(scenarioDirectory, `terminal-java-${name}.java.json`), 'utf8')) as JavaRun;
const java = JAVA_BUGS[name]?.(measured) ?? measured;
const snapshot = withSnapshot(snapshotOf(scenario), {
language: 'de',
terminalNodeIds: new Set(HOSTS),
...(name === 'netstat' || name === 'tcpdump'
? {
echoServersByNodeId: new Map([
[ECHO, { port: 55555, isActive: true }],
[DNS, { port: 55555, isActive: true }],
]),
simpleClientsByNodeId: new Map([[CLIENT, { targetAddress: '', targetPort: 55555 }]]),
}
: {}),
});
const { engine } = watchedEngine(scenario, snapshot);
const acts: [number, () => void][] = java.acts.map(([time, host, line]) => [
time,
() => {
if (line === '^C') engine.terminal(host)!.interrupt();
else if (line === 'disconnect') engine.simpleClient(CLIENT)!.disconnect();
else if (line === 'connect') engine.simpleClient(CLIENT)!.connect('192.168.0.31', 55555);
else engine.terminal(host)!.enter(line);
},
]);
if (name === 'netstat') acts.unshift([500, () => engine.simpleClient(CLIENT)!.connect('192.168.0.31', 55555)]);
for (const [time, act] of acts) {
engine.handle({ type: 'advance', toMilliseconds: time });
act();
}
engine.handle({ type: 'advance', toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 5_000 });
const racing = RACING.has(name);
expectSameCaptures(engine, scenario, java.frames, {
text: (text) => withClientPortText(text).replace(/ID=\d+/g, 'ID=x'),
...(racing ? { normalized: bySecond, starts: { java: 0, engine: 0 } } : { tables: java }),
cell: cellText,
until: UNTIL[name]?.(measured),
});
if (racing) {
// ICMP.sendBroadcast: with TTL 1, to every card.
const echoes = engine.capture.frames(scenario.nodes.find((node) => node.id === CLIENT)!.ports[0]!.id).flatMap(({ frame }) => {
const packet = frame.payload.kind === 'ipv4' ? frame.payload.packet : undefined;
return packet?.payload.kind === 'icmp' && packet.payload.message.kind === 'echoRequest' ? [[frame.destinationMacAddress, packet.timeToLive]] : [];
});
expect(echoes).toEqual(Array.from({ length: 8 }, () => ['FF:FF:FF:FF:FF:FF', 1]));
}
const expected = javaLines(java);
const actual = engineLines(engine);
const told = ([kind, text]: Told): Told => [kind, lineText(name === 'pingBroadcast' ? text.replace(BROADCAST_ANSWER, 'From <broadcast>: icmp_seq') : text)];
for (const host of HOSTS) {
expect(
actual[host]!.map(([, line]) => told(line)),
`${host}'s lines`,
).toEqual(expected[host]!.map(([, line]) => told(line)));
for (const [index, [javaTime, line]] of expected[host]!.entries()) {
const [engineTime, engineLine] = actual[host]![index]!;
const late = javaTime - engineTime;
expect(Math.abs(late), `${host}'s line ${JSON.stringify(line[1].slice(0, 60))}: Java ${late} ms later`).toBeLessThanOrEqual(
// tcpdump: Java looks for new frames every 100 ms.
GAP_TOLERANCE_MILLISECONDS + (name === 'tcpdump' ? POLL_MILLISECONDS : 0),
);
expect(Math.abs(pingTime(line[1]) - pingTime(engineLine[1])), `${host}'s ping time in ${JSON.stringify(line[1])}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
}
}
});
}
});
describe('the command line on the network beyond the stories', () => {
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'terminal-net.scenario.json'), 'utf8')) as Scenario;
const started = () => watchedEngine(scenario, withSnapshot(snapshotOf(scenario), { language: 'de', terminalNodeIds: new Set(HOSTS) })).engine;
const texts = (engine: NetworkRuntimeEngine) => engine.terminal(CLIENT)!.log.all.flatMap((line) => (line.kind === 'text' ? [line.text] : []));
const echoIdentifiers = (engine: NetworkRuntimeEngine) =>
engine.capture.frames(scenario.nodes.find((node) => node.id === CLIENT)!.ports[0]!.id).flatMap(({ frame }) => {
const packet = frame.payload.kind === 'ipv4' ? frame.payload.packet : undefined;
return packet?.payload.kind === 'icmp' && packet.payload.message.kind === 'echoRequest' ? [packet.payload.message.identifier] : [];
});
it('sends no echo for a ping whose Ctrl+C came before it began', () => {
const engine = started();
engine.terminal(CLIENT)!.enter('host www.filius.de');
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
engine.terminal(CLIENT)!.interrupt();
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
expect(texts(engine).slice(-2)).toEqual([
'PING 192.168.0.31 (192.168.0.31)',
'\n--- 192.168.0.31 Paketstatistik ---\n0 Paket(e) gesendet, 0 Paket(e) empfangen, 0% Paketverlust\n',
]);
expect(echoIdentifiers(engine)).toEqual([]);
});
it("counts no answer to another ping's echo, and waits out its second", () => {
const engine = started();
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
// An echo reply with the first echo's number, but another identifier (a multiple of 23 is drawn), first in the queue.
const reply = { kind: 'echoReply', identifier: 1, sequenceNumber: 1, data: new Uint8Array() } as const;
const packet = {
identity: 0,
senderIPAddress: '192.168.0.31',
receiverIPAddress: '192.168.0.20',
timeToLive: 64,
protocol: 'icmp',
payload: { kind: 'icmp', message: reply },
} as const;
const queue = engine.state.icmpObservationsByNodeId;
queue.set(CLIENT, [...(queue.get(CLIENT) ?? []), { nodeId: CLIENT, packet, message: reply }]);
engine.handle({ type: 'advance', toMilliseconds: 6_000 });
const lines = engine.terminal(CLIENT)!.log.all.flatMap((line) => (line.kind === 'text' ? [[line.timeMilliseconds, line.text] as const] : []));
expect(lines.map(([, text]) => text.replace(/time=\d+ms/, 'time=…ms'))).toEqual([
'PING 192.168.0.31 (192.168.0.31)',
'\nFrom 192.168.0.31: icmp_seq=2 ttl=64 time=…ms',
'\nFrom 192.168.0.31: icmp_seq=3 ttl=64 time=…ms',
'\nFrom 192.168.0.31: icmp_seq=4 ttl=64 time=…ms',
'\n--- 192.168.0.31 Paketstatistik ---\n4 Paket(e) gesendet, 3 Paket(e) empfangen, 25% Paketverlust\n',
]);
expect(lines[1]![0]).toBeGreaterThanOrEqual(1_000);
});
it('goes on with the next identifier, from one drawn at random', () => {
const engine = started();
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
engine.terminal(CLIENT)!.enter('ping 192.168.0.30');
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
const identifiers = echoIdentifiers(engine);
expect(identifiers).toHaveLength(8);
expect(identifiers[0]! % 23).toBe(0);
expect(identifiers[0]!).toBeLessThanOrEqual(42 * 23);
expect(identifiers).toEqual([...Array(4).fill(identifiers[0]), ...Array(4).fill(identifiers[0]! + 1)]);
});
/** An ICMP message put first in the client's queue, as if it had come (an answer to another echo). */
const queueEchoReply = (engine: NetworkRuntimeEngine, sequenceNumber: number) => {
const reply = { kind: 'echoReply', identifier: 1, sequenceNumber, data: new Uint8Array() } as const;
const packet = {
identity: 0,
senderIPAddress: '192.168.0.31',
receiverIPAddress: '192.168.0.20',
timeToLive: 64,
protocol: 'icmp',
payload: { kind: 'icmp', message: reply },
} as const;
const queue = engine.state.icmpObservationsByNodeId;
queue.set(CLIENT, [...(queue.get(CLIENT) ?? []), { nodeId: CLIENT, packet, message: reply }]);
};
it('takes an echo reply with another number for a timeout, and sends the next echo at once', () => {
const engine = started();
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
queueEchoReply(engine, 9);
engine.handle({ type: 'advance', toMilliseconds: 6_000 });
const lines = engine.terminal(CLIENT)!.log.all.flatMap((line) => (line.kind === 'text' ? [[line.timeMilliseconds, line.text] as const] : []));
expect(lines[1]![1]).toBe('\nFrom 192.168.0.31 (192.168.0.31): icmp_seq=1 -- Timeout!');
// The next echo goes at once, and the late answer to the first comes first: a timeout too.
expect(lines[2]![1]).toBe('\nFrom 192.168.0.31 (192.168.0.31): icmp_seq=2 -- Timeout!');
expect(lines[2]![0] - lines[1]![0]).toBeLessThan(500);
});
it('takes the first ICMP message that came, not the last', () => {
const engine = started();
engine.terminal(CLIENT)!.enter('ping 192.168.0.99');
// First the answer to another echo, then (when ARP gives up) the host unreachable the client gives itself.
queueEchoReply(engine, 1);
engine.handle({ type: 'advance', toMilliseconds: 30_000 });
expect(texts(engine).slice(1)).toEqual([
'\nFrom 192.168.0.99 (192.168.0.99): icmp_seq=2 -- Timeout!',
'\nFrom 192.168.0.99 (192.168.0.99): icmp_seq=3 -- Timeout!',
'\nFrom 192.168.0.99 (192.168.0.99): icmp_seq=4 -- Timeout!',
'\n--- 192.168.0.99 Paketstatistik ---\n4 Paket(e) gesendet, 0 Paket(e) empfangen, 100% Paketverlust\n',
]);
});
it('rounds the loss (Math.round)', () => {
const engine = started();
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
queueEchoReply(engine, 1);
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
queueEchoReply(engine, 2);
engine.handle({ type: 'advance', toMilliseconds: 2_500 });
engine.terminal(CLIENT)!.interrupt();
engine.handle({ type: 'advance', toMilliseconds: 6_000 });
expect(texts(engine).at(-1)).toBe('\n--- 192.168.0.31 Paketstatistik ---\n3 Paket(e) gesendet, 1 Paket(e) empfangen, 67% Paketverlust\n');
});
it("refuses the broadcast of the route's network, whatever its mask", () => {
const wide = { ...scenario, devices: { ...scenario.devices, [CLIENT]: { ...scenario.devices![CLIENT]!, mask: '255.255.0.0' } } } as Scenario;
const engine = watchedEngine(wide, withSnapshot(snapshotOf(wide), { language: 'de', terminalNodeIds: new Set([CLIENT]) })).engine;
engine.terminal(CLIENT)!.enter('ping 192.168.255.255');
expect(texts(engine)).toEqual(['Ping zu Broadcast-Adressen wird nicht unterstützt.']);
});
it('draws the first identifier at random', () => {
const drawn = new Set(Array.from({ length: 10 }, (_, seed) => new NetworkRuntimeEngine(seed).drawIcmpIdentifier()));
expect(drawn.size).toBeGreaterThan(1);
});
it('routes by the first of rows with the same mask (determineRouteFromStaticRoutingTable)', () => {
const snapshot = withSnapshot(snapshotOf(scenario), {
manualRoutesByNodeId: new Map([[CLIENT, [{ destinationNetwork: '192.168.0.0', subnetMask: '255.255.255.0', gateway: '192.168.0.99', interfaceIPAddress: '192.168.0.20' }]]]),
});
const engine = watchedEngine(scenario, snapshot).engine;
const rows = engine.forwardingTable(CLIENT).filter((row) => row.destinationNetwork === '192.168.0.0' && row.subnetMask === '255.255.255.0');
expect(rows).toHaveLength(2);
expect(engine.routeRow(CLIENT, '192.168.0.31')).toEqual(rows[0]);
});
/**
* 19 routers in a row, the host behind the last: 20 hops. Java says the
* host could not be reached (it asks ttl >= 20 after the loop, also when
* the 20th hop answered); measured only up to two hops (traceRouted).
*/
it('says a host was reached at the 20th hop', () => {
const id = (number: number) => `00000000-0000-0000-0000-${String(number).padStart(12, '0')}`;
const ROUTERS = 19;
const client = id(1);
const host = id(2);
const router = (index: number) => id(100 + index);
const port = (node: number, side: number) => id(1000 + node * 2 + side);
const nodes: Scenario['nodes'] = [
{ id: client, kind: 'pc', ports: [{ id: port(1, 0), label: 'eth0' }] },
{ id: host, kind: 'pc', ports: [{ id: port(2, 0), label: 'eth0' }] },
...Array.from({ length: ROUTERS }, (_, index) => ({
id: router(index + 1),
kind: 'router' as const,
ports: [
{ id: port(100 + index + 1, 0), label: 'eth0' },
{ id: port(100 + index + 1, 1), label: 'eth1' },
],
})),
];
const ends = [port(1, 0), ...Array.from({ length: ROUTERS }, (_, index) => [port(101 + index, 0), port(101 + index, 1)]).flat(), port(2, 0)];
const owner = (portId: string) => nodes.find((node) => node.ports.some((each) => each.id === portId))!.id;
const links: Scenario['links'] = Array.from({ length: ROUTERS + 1 }, (_, index) => ({
id: id(5000 + index),
sourceNode: owner(ends[2 * index]!),
sourcePort: ends[2 * index]!,
targetNode: owner(ends[2 * index + 1]!),
targetPort: ends[2 * index + 1]!,
}));
// Network k is 10.k.0.0/24; router i has 10.i.0.1 towards the client and 10.(i+1).0.2 towards the host.
const mask = '255.255.255.0';
const chain: Scenario = {
seed: 7,
nodes,
links,
devices: {
[client]: { ip: '10.1.0.10', mask, gateway: '10.1.0.1' },
[host]: { ip: `10.${ROUTERS + 1}.0.10`, mask, gateway: `10.${ROUTERS + 1}.0.2` },
...Object.fromEntries(Array.from({ length: ROUTERS }, (_, index) => [router(index + 1), { ip: `10.${index + 1}.0.1`, mask }])),
},
interfaces: Array.from({ length: ROUTERS }, (_, index) => [
{ node: router(index + 1), port: port(101 + index, 0), ip: `10.${index + 1}.0.1`, mask },
{ node: router(index + 1), port: port(101 + index, 1), ip: `10.${index + 2}.0.2`, mask },
]).flat(),
routes: Object.fromEntries(
Array.from({ length: ROUTERS }, (_, index) => {
const number = index + 1;
const back = number === 1 ? [] : [{ destination: '10.1.0.0', mask, gateway: `10.${number}.0.2`, interface: `10.${number}.0.1` }];
const on = number === ROUTERS ? [] : [{ destination: `10.${ROUTERS + 1}.0.0`, mask, gateway: `10.${number + 1}.0.1`, interface: `10.${number + 1}.0.2` }];
return [router(number), [...back, ...on]];
}),
),
steps: [],
};
const engine = watchedEngine(chain, withSnapshot(snapshotOf(chain), { language: 'de', terminalNodeIds: new Set([client]) })).engine;
engine.terminal(client)!.enter(`traceroute 10.${ROUTERS + 1}.0.10`);
engine.handle({ type: 'advance', toMilliseconds: 120_000 });
const told = engine.terminal(client)!.log.all.flatMap((line) => (line.kind === 'text' ? [line.text] : []));
expect(told.slice(-3)).toEqual([' 20 ', '10.20.0.10', '\n\n10.20.0.10 wurde nach 20 Sprüngen (hops) erreicht.']);
});
it("takes no echo reply of another identifier for traceroute's probe", () => {
const engine = started();
engine.terminal(CLIENT)!.enter('traceroute 192.168.0.31');
queueEchoReply(engine, 42 * 23 + 1);
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
// The first probe gets the other reply: a star (and then the late answer to it comes first for the next).
expect(texts(engine).slice(1, 3)).toEqual([' 1 ', '* ']);
});
it('dumps only the frames from its start, each with its time of day', () => {
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'terminal-net.scenario.json'), 'utf8')) as Scenario;
// The first frame comes about 110 ms after 3 s: at a time of day with two digits of milliseconds (Java pads to three).
const base = new Date(2026, 9, 2, 10, 37, 6, 900).getTime();
const engine: NetworkRuntimeEngine = new NetworkRuntimeEngine(scenario.seed, undefined, { wallClock: () => base + engine.state.currentTimeMilliseconds });
engine.handle({
type: 'start',
snapshot: withSnapshot(snapshotOf(scenario), { language: 'de', terminalNodeIds: new Set(HOSTS) }),
seed: scenario.seed,
initialTimeMilliseconds: 0,
});
engine.terminal(CLIENT)!.enter('host www.filius.de');
engine.handle({ type: 'advance', toMilliseconds: 3_000 });
engine.terminal(ECHO)!.enter('tcpdump');
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
engine.handle({ type: 'advance', toMilliseconds: 3_900 });
const card = scenario.nodes.find((node) => node.id === ECHO)!.ports[0]!.id;
const first = engine.capture.frames(card).find((frame) => frame.timeMilliseconds >= 3_000)!;
const time = new Date(base + first.timeMilliseconds);
const two = (value: number) => String(value).padStart(2, '0');
expect(time.getMilliseconds()).toBeLessThan(100);
const expected = `${two(time.getHours())}:${two(time.getMinutes())}:${two(time.getSeconds())}.${String(time.getMilliseconds()).padStart(3, '0')}`;
const dumped = engine.terminal(ECHO)!.log.all.flatMap((line) => (line.kind === 'text' ? [line.text] : []));
// The ARP request of the lookup before is not dumped: the ping's ARP request first.
expect(dumped[1]).toBe(
`\n ${expected} ARP 192.168.0.20 > 192.168.0.31 Suche nach MAC f\u00fcr 192.168.0.31 [op=REQUEST, sender=02:00:00:00:04:11|192.168.0.20, target=FF:FF:FF:FF:FF:FF|192.168.0.31]`,
);
});
});