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>
This commit is contained in:
2026-10-01 08:53:37 +00:00
co-authored by Claude Opus 5.5
parent 65ccd9ddd7
commit 088b62469f
24 changed files with 796 additions and 344 deletions
+60
View File
@@ -21,6 +21,7 @@
// time (250 ms) may not. A cable carries one frame after the other in both
// (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 { snapshotOf, type Scenario } from './scenario.js';
@@ -154,3 +155,62 @@ export function javaFrames(framesByNode: Record<string, JavaFrame[]>): SeenFrame
export function gaps(frames: readonly SeenFrame[]) {
return frames.map((frame, index) => ({ text: frame.text, gap: index === 0 ? 0 : frame.time - frames[index - 1]!.time }));
}
/**
* Each card's packet capture against Java's (the Lauscher's list of the
* card, as the oracle wrote it: under the node's id for its first card,
* `id#index` for the others): the same frames in the same order, each at
* about the same time (both counted from the run's first frame, or from
* `starts`). `text`
* puts placeholders in a frame's text (ports, IDs a test cannot predict),
* `normalized` does so for a card's whole list; with `until` (Java's time),
* only the frames before it; with `from`, only those from it.
*/
export function expectSameCaptures(
engine: NetworkRuntimeEngine,
scenario: Scenario,
java: Record<string, JavaFrame[]>,
options: {
text?: (text: string) => string;
normalized?: (frames: SeenFrame[]) => SeenFrame[];
from?: number | undefined;
until?: number | undefined;
tolerance?: number;
starts?: { java: number; engine: number };
} = {},
): void {
const text = options.text ?? ((each: string) => each);
const engineByCard = new Map<string, SeenFrame[]>();
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 javaByCard = new Map(Object.entries(java).map(([card, frames]) => [card, frames.map((frame) => ({ time: frame.time, text: text(javaText(frame)) }))]));
expect([...engineByCard.keys()].sort()).toEqual([...javaByCard.keys()].sort());
const first = (byCard: Map<string, SeenFrame[]>) => Math.min(...[...byCard.values()].flat().map((frame) => frame.time));
const engineStart = options.starts?.engine ?? first(engineByCard);
const javaStart = options.starts?.java ?? first(javaByCard);
const from = (options.from ?? Number.NEGATIVE_INFINITY) - javaStart;
const until = (options.until ?? Number.POSITIVE_INFINITY) - javaStart;
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 }));
for (const [card, javaList] of javaByCard) {
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) {
expect(actual[index]?.text, `${card} frame #${index + 1}`).toBe(expected[index]?.text);
expect(Math.abs(actual[index]!.time - expected[index]!.time), `${card} time of frame #${index + 1}`).toBeLessThanOrEqual(
options.tolerance ?? GAP_TOLERANCE_MILLISECONDS,
);
}
}
}
/** A client's free port as "client": Java draws it at random, the engine from its seed. */
export function withClientPortText(text: string): string {
return text.replace(/:(\d{5})\b/g, (whole, port: string) => (Number(port) >= 49_152 ? ':client' : whole));
}