feat(core): the packet capture's table after Java's Lauscher, measured against Java's own tables

captureRows.ts builds Java's rows per frame (Lauscher.datenVorbereiten):
Ethernet; ARP, ICMP or IP; TCP or UDP; the data with Java's protocol
recognition; with addresses and ports or ports only. The comment stays
structured; captureCommentText says it with given words and numbers.

ScenarioNetwork writes Java's finished tables per card (Lauscher.getDaten,
German locale); all runtime runs are recorded again, NatRun has ttlOut.
expectSameCaptures compares both views for every frame. Fixed where the
engine differed: an ARP request's target MAC is the broadcast address,
an ICMP error's identifier and sequence number are 0 (RFC 792), and host
unreachable from a router carries the packet as forwarded. Not taken:
Java's lower-case broadcast address in a reply to 0.0.0.0, and the typo
'Exeeded'. RIP keeps its recordings: Java's staggered router start now
gives a different startup on this machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 15:11:05 +00:00
co-authored by Claude Opus 5.5
parent 0a34f3a4c4
commit 21a404693c
55 changed files with 37423 additions and 1321 deletions
+72 -3
View File
@@ -22,7 +22,15 @@
// (engine.test.ts checks that: one cable's delay is below this tolerance).
import { expect } from 'vitest';
import { NetworkRuntimeEngine, TCP_FLAG, type EthernetFrame } from '../src/index.js';
import {
captureCommentText,
captureRows,
NetworkRuntimeEngine,
TCP_FLAG,
type CapturedFrame,
type CaptureLayer,
type EthernetFrame,
} from '../src/index.js';
import { snapshotOf, type Scenario } from './scenario.js';
export const GAP_TOLERANCE_MILLISECONDS = 120;
@@ -177,15 +185,21 @@ export function expectSameCaptures(
until?: number | undefined;
tolerance?: number;
starts?: { java: number; engine: number };
/** Java's tables of the cards (ScenarioNetwork.writeRows), compared for the frames compared. */
tables?: JavaTables;
/** Placeholders in a table cell, beyond those of tableCell. */
cell?: (text: string) => string;
} = {},
): void {
const text = options.text ?? ((each: string) => each);
const engineByCard = new Map<string, SeenFrame[]>();
const capturedByCard = new Map<string, readonly CapturedFrame[]>();
for (const node of scenario.nodes) {
if (node.kind === 'networkSwitch') continue;
node.ports.forEach((port, index) => {
const frames = engine.capture.frames(port.id).map((each) => ({ time: each.timeMilliseconds, text: text(engineText(each.frame)) }));
engineByCard.set(index === 0 ? node.id : `${node.id}#${index}`, frames);
const card = index === 0 ? node.id : `${node.id}#${index}`;
capturedByCard.set(card, engine.capture.frames(port.id));
engineByCard.set(card, capturedByCard.get(card)!.map((each) => ({ time: each.timeMilliseconds, text: text(engineText(each.frame)) })));
});
}
const javaByCard = new Map(Object.entries(java).map(([card, frames]) => [card, frames.map((frame) => ({ time: frame.time, text: text(javaText(frame)) }))]));
@@ -198,7 +212,29 @@ export function expectSameCaptures(
const normalized = options.normalized ?? ((frames: SeenFrame[]) => frames);
const window = (frames: SeenFrame[], start: number) =>
frames.filter((frame) => frame.time - start >= from && frame.time - start < until).map((frame) => ({ time: frame.time - start, text: frame.text }));
const inWindow = (time: number, start: number) => time - start >= from && time - start < until;
for (const [card, javaList] of javaByCard) {
if (options.tables !== undefined) {
// The rows of the frames in the window, frame by frame.
const javaNumbers = javaList.flatMap((frame, index) => (inWindow(frame.time, javaStart) ? [index + 1] : []));
const engineFrames = capturedByCard.get(card)!.filter((each) => inWindow(each.timeMilliseconds, engineStart));
for (const [view, withAddresses] of [['rows', true], ['layeredRows', false]] as const) {
const javaRows = options.tables[view]?.[card];
if (javaRows === undefined) continue;
const engineRows = captureRows(engineFrames, { withAddresses });
const cell = (value: string | null) => (value === null ? null : (options.cell ?? ((each: string) => each))(tableCell(value)));
for (const [index, number] of javaNumbers.entries()) {
const frame = engineFrames[index];
const expectedRows = javaRows.filter((row) => row[0] === String(number)).map((row) => row.slice(1).map(cell));
// A frame a test added where Java errs (JAVA_BUGS) has no table of Java's.
if (expectedRows.length === 0 && number > Math.max(0, ...javaRows.map((row) => Number(row[0])))) continue;
const actualRows = engineRows
.filter((row) => row.number === frame?.number)
.map((row) => [row.source, row.destination, row.protocol, JAVA_LAYERS[row.layer], captureCommentText(row.comment, JAVA_WORDS, javaNumber)].map(cell));
expect(actualRows, `${card} ${view} of frame #${index + 1}`).toEqual(expectedRows);
}
}
}
const expected = normalized(window(javaList, javaStart));
const actual = normalized(window(engineByCard.get(card)!, engineStart));
for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
@@ -214,3 +250,36 @@ export function expectSameCaptures(
export function withClientPortText(text: string): string {
return text.replace(/:(\d{5})\b/g, (whole, port: string) => (Number(port) >= 49_152 ? ':client' : whole));
}
/** Java's capture tables per card: number, source, destination, protocol, layer, comment (ScenarioNetwork.writeRows). */
export interface JavaTables {
rows?: Record<string, (string | null)[][]>;
layeredRows?: Record<string, (string | null)[][]>;
}
/** Java's layer names and comment words: its German message bundle, the locale it runs in (Locale.GERMANY). */
const JAVA_LAYERS: Record<CaptureLayer, string> = { networkAccess: 'Netzzugang', internet: 'Vermittlung', transport: 'Transport', application: 'Anwendung' };
const JAVA_WORDS = { protocol: 'Protokoll', searchForMac: 'Suche nach MAC f\u00fcr', macIs: 'MAC ist' };
/** NumberFormat for Locale.GERMANY: groups of three digits with dots. */
function javaNumber(value: number): string {
return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, '.');
}
/**
* Placeholders every test needs in a table cell: free ports, DNS message IDs,
* IP packet IDs, link-local addresses. And two departures from Java: its ARP
* reply to 0.0.0.0 writes the broadcast address in lower case
* (ARP.sendArpReply), everything else in upper case — the engine always in
* upper case (better than Java where nothing depends on it); and Java
* misspells "ICMP Time Exeeded" (a typo, not taken).
*/
function tableCell(text: string): string {
return withClientPortText(text)
.replaceAll('ff:ff:ff:ff:ff:ff', 'FF:FF:FF:FF:FF:FF')
.replace('ICMP Time Exeeded (poof)', 'ICMP Time Exceeded (poof)')
.replace(/^(\d{5})$/, (whole, port: string) => (Number(port) >= 49_152 ? 'client' : whole))
.replace(/ID=\d+/g, 'ID=x')
.replace(/\[id=\d+/g, '[id=x')
.replace(/169\.254\.1\.\d+/g, '169.254.1.x');
}