feat(core): the pages of routers and home routers after Java, measured against Java

Routers and home routers start a web server on port 80 with Java's index,
log and routes pages in the project's language (routerPages.ts); the
firewall keeps a log per node that outlives a new start; RIP routes carry
the telling router's public address. TCP counts sequence numbers and
segments in Java's characters, not bytes. RouterWebRun records six stories,
with firewalls now built by ScenarioNetwork. Not taken from Java: its router
server sometimes never listening (a race in setAktiv), and </titel>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 17:41:52 +00:00
co-authored by Claude Opus 5.5
parent 4cd5749350
commit 7dac57cbbe
29 changed files with 20398 additions and 39 deletions
+3 -3
View File
@@ -2100,8 +2100,8 @@ describe('RIP', () => {
it('starts with a route of hop count 0 to each own network that never expires, the first beacon after a second', () => {
const engine = ripTopology();
expect(engine.state.ripTablesByNodeId.get(routerAId)).toEqual([
{ destinationNetwork: '10.0.0.0', subnetMask: '255.255.255.0', nextHop: '10.0.0.1', interfaceIPAddress: '10.0.0.1', origin: 'connected', isEditable: false, metric: 0 },
{ destinationNetwork: '192.168.0.0', subnetMask: '255.255.255.0', nextHop: '192.168.0.1', interfaceIPAddress: '192.168.0.1', origin: 'connected', isEditable: false, metric: 0 },
{ destinationNetwork: '10.0.0.0', subnetMask: '255.255.255.0', nextHop: '10.0.0.1', interfaceIPAddress: '10.0.0.1', origin: 'connected', isEditable: false, metric: 0, hopPublicIPAddress: '10.0.0.1' },
{ destinationNetwork: '192.168.0.0', subnetMask: '255.255.255.0', nextHop: '192.168.0.1', interfaceIPAddress: '192.168.0.1', origin: 'connected', isEditable: false, metric: 0, hopPublicIPAddress: '10.0.0.1' },
]);
expect(beacons(engine, routerAId)).toEqual([1_000]);
// The server's socket on 520, the beacon's on 521.
@@ -2113,7 +2113,7 @@ describe('RIP', () => {
const engine = ripTopology();
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
expect(route(engine, routerAId, '172.16.0.0')).toMatchObject({ nextHop: '10.0.0.2', interfaceIPAddress: '10.0.0.1', metric: 1, origin: 'rip', expiresAtMilliseconds: 1_110 + 75_000 });
expect(route(engine, routerAId, '172.16.0.0')).toMatchObject({ nextHop: '10.0.0.2', interfaceIPAddress: '10.0.0.1', metric: 1, origin: 'rip', expiresAtMilliseconds: 1_110 + 75_000, hopPublicIPAddress: '10.0.0.2' });
expect(route(engine, routerBId, '192.168.0.0')).toMatchObject({ nextHop: '10.0.0.1', metric: 1 });
const payloads = (engine.state.deliveredIPv4PacketsByNodeId.get(routerBId) ?? [])
.filter((delivered) => delivered.packet.senderIPAddress === '10.0.0.1')
+2 -1
View File
@@ -25,6 +25,7 @@ import { expect } from 'vitest';
import {
captureCommentText,
captureRows,
javaTextLength,
NetworkRuntimeEngine,
TCP_FLAG,
type CapturedFrame,
@@ -112,7 +113,7 @@ export function engineText(frame: EthernetFrame): string {
if (packet.payload.kind === 'tcp') {
const segment = packet.payload.segment;
const flags = FLAG_LETTERS.filter(([bit]) => (segment.flags & bit) !== 0).map(([, letter]) => letter).join('');
return `${macs} TCP ${packet.senderIPAddress}:${segment.sourcePort} > ${packet.receiverIPAddress}:${segment.destinationPort} ${flags || '-'} seq=${segment.sequenceNumber} ack=${segment.acknowledgementNumber} length=${segment.payload.length}`;
return `${macs} TCP ${packet.senderIPAddress}:${segment.sourcePort} > ${packet.receiverIPAddress}:${segment.destinationPort} ${flags || '-'} seq=${segment.sequenceNumber} ack=${segment.acknowledgementNumber} length=${javaTextLength(segment.payload)}`;
}
const datagram = packet.payload.datagram;
return `${macs} UDP ${packet.senderIPAddress}:${datagram.sourcePort} > ${packet.receiverIPAddress}:${datagram.destinationPort} ${new TextDecoder().decode(datagram.payload)}`;
+6 -3
View File
@@ -12,9 +12,11 @@
// follows those threads, so it is not compared. Free ports — a client's, and the WAN port
// the router draws for an entry — are Java's random and the engine's seed:
// both are compared as "port". Java numbers the TCP sockets' initial
// sequence numbers over all sockets, and its home router's web server (not
// in the engine yet) takes the first: each is compared by the order it first
// shows in. Frames that show within TIE_MILLISECONDS of each other Java's
// sequence numbers over all sockets; its home router's web server takes the
// first when it listens — in these runs it did not (setAktiv wakes its thread
// only when it already waits), in the engine it always does: each initial
// number is compared by the order it first shows in, and in the tables only
// below the millions. Frames that show within TIE_MILLISECONDS of each other Java's
// packet log writes in any order; they are compared sorted by their text.
import { readFileSync } from 'node:fs';
@@ -265,6 +267,7 @@ describe('NAT as Java FILIUS runs it', () => {
expectSameCaptures(engine, scenario, java.frames, {
normalized: withPlaceholders,
tables: measured,
cell: (text) => text.replace(/\b\d+\.(\d{3}\.\d{3})\b/g, 'n.$1'),
tolerance: TCP_STORIES.has(name) ? TCP_TOLERANCE_MILLISECONDS : GAP_TOLERANCE_MILLISECONDS,
});
+1 -1
View File
@@ -73,7 +73,7 @@ function observationJson(item: IcmpObservation): Record<string, unknown> {
function run(scenario: Scenario): Record<string, unknown> {
// Swift's cables carry all frames at once; everything else is compared as it is.
const engine = new NetworkRuntimeEngine(scenario.seed, undefined, { swiftCables: true, swiftArp: true, swiftSends: true });
const engine = new NetworkRuntimeEngine(scenario.seed, undefined, { swiftCables: true, swiftArp: true, swiftSends: true, swiftRouters: true });
if (scenario.speedPercent !== undefined) engine.setSimulationSpeed(scenario.speedPercent);
engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 });
+18 -1
View File
@@ -2,7 +2,7 @@
// message bundle says them: for comparing the core's structured lines with
// what the Java oracle recorded (echoJava.test.ts, webJava.test.ts).
import type { EchoServerLogEntry, SendFailure, SimpleClientLogEntry } from '../src/index.js';
import type { EchoServerLogEntry, SendFailure, SimpleClientLogEntry, WebBrowserLogEntry } from '../src/index.js';
/** Java's client and server look every 100 ms whether they are connected, or switched off; the core at once. */
export const POLL_MILLISECONDS = 100;
@@ -71,3 +71,20 @@ export function clientText(entry: SimpleClientLogEntry): string {
return 'Keine Verbindung hergestellt!';
}
}
const IMAGE_TYPES = new Set(['png', 'gif', 'jpg', 'bmp']);
/** A browser's line as its window took it: a page (with its text only for 200), or "update" for an image. */
export function browserText(entry: WebBrowserLogEntry): string | undefined {
switch (entry.kind) {
case 'page':
return `page ${entry.statusCode} ${entry.contentType ?? 'null'}${entry.statusCode === 200 ? `\n${entry.body ?? 'null'}` : ''}`;
case 'unreachable':
return 'page 0 null';
case 'image': {
// WebBrowser.retrieveImages tells the window of an image that came, or of a status other than 200.
const isImage = entry.contentType !== undefined && IMAGE_TYPES.has(entry.contentType.toLowerCase().replace('image/', ''));
return entry.statusCode !== 200 || isImage ? 'update' : undefined;
}
}
}
+184
View File
@@ -0,0 +1,184 @@
// The pages of routers and home routers (apps/routerPages.ts), the firewall
// log behind them, and TCP counting Java's characters, where
// routerWebJava.test.ts's stories do not go.
import { describe, expect, it } from 'vitest';
import {
deviceConfiguration,
firewallConfiguration,
firewallLogPage,
firewallRule,
firewallRuleText,
forwardingTablePage,
interfaceKey,
javaTextLength,
NetworkRuntimeEngine,
topologySnapshot,
portSnapshot,
ripTablePage,
routerIndexPage,
type RouteRow,
type TopologySnapshotInput,
} from '../src/index.js';
import { link, pc, startedEngine, uuid } from './fixtures.js';
const row = (fields: Partial<RouteRow>): RouteRow => ({
destinationNetwork: '10.0.0.0',
subnetMask: '255.255.255.0',
nextHop: '10.0.0.1',
interfaceIPAddress: '10.0.0.1',
origin: 'connected',
isEditable: false,
...fields,
});
describe('the pages of a router', () => {
it('colours the forwarding table: own networks and localhost green with 0 hops, the default gateway blue, others with "?"', () => {
const page = forwardingTablePage(
[
row({}),
row({ destinationNetwork: '127.0.0.0', subnetMask: '255.0.0.0', nextHop: '127.0.0.1', interfaceIPAddress: '127.0.0.1' }),
row({ destinationNetwork: '0.0.0.0', subnetMask: '0.0.0.0', nextHop: '10.0.0.254' }),
row({ destinationNetwork: '30.0.0.0', nextHop: '10.0.0.9' }),
],
'de',
);
expect(page).toContain(
"<tr style='background-color:#aaffaa'><td>10.0.0.0</td><td>255.255.255.0</td><td> 0 </td><td>unbegrenzt</td><td><a href=\"http://10.0.0.1/routes\">10.0.0.1</a></td><td> - </td></tr>" +
"<tr style='background-color:#aaffaa'><td>127.0.0.0</td>",
);
expect(page).toContain("<tr style='background-color:#aaaaff'><td>0.0.0.0</td><td>0.0.0.0</td><td> ? </td>");
expect(page).toContain('<tr><td>30.0.0.0</td><td>255.255.255.0</td><td> ? </td>');
// A route over the loopback interface counts as local, whatever its next hop.
expect(forwardingTablePage([row({ destinationNetwork: '50.0.0.0', nextHop: '10.0.0.7', interfaceIPAddress: '127.0.0.1' })], 'de')).toContain(
"<tr style='background-color:#aaffaa'><td>50.0.0.0</td><td>255.255.255.0</td><td> 0 </td>",
);
expect(page).toContain('Routing-/Weiterleitungs-Tabelle');
});
it('shows a RIP route in red at 16 hops, its time left in whole seconds', () => {
const page = ripTablePage(
[row({ metric: 0, hopPublicIPAddress: '10.0.0.1' }), row({ destinationNetwork: '40.0.0.0', nextHop: '10.0.0.2', metric: 16, expiresAtMilliseconds: 10_999, hopPublicIPAddress: '10.0.0.2' })],
1_000,
'en',
);
expect(page).toContain("<tr style='background-color:#aaffaa'><td>10.0.0.0</td><td>255.255.255.0</td><td>0</td><td>unlimited</td>");
expect(page).toContain("<tr style='background-color:#ffaaaa'><td>40.0.0.0</td><td>255.255.255.0</td><td>16</td><td>9</td><td>10.0.0.2</td><td><a href=\"http://10.0.0.2/routes\">10.0.0.2</a></td></tr>");
expect(page).toContain('Routing Table');
expect(page).toContain('This table is generated and updated by the Routing Information Protocol (RIP).');
});
it('writes the index pages in the project language', () => {
expect(routerIndexPage('router', 'en')).toContain('<title>Router Administration</title>');
expect(routerIndexPage('gateway', 'de')).toBe(
'<html>\n <head>\n <title>Administration des Gateways</title>\n </head>\n <body>\n <h1>Firewall-Administration</h1>\n' +
' <p> <a href="konfig">Konfiguration</a> </p>\n <p> <a href="log">Log-Eintr&auml;ge anzeigen</a> </p>\n </body>\n</html>\n',
);
});
it('names a rule as Java does: empty fields as *, "directly connected" by the first network', () => {
const first = { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' };
expect(firewallRuleText(firewallRule({ protocol: 'all', action: 'accept' }), first)).toBe('*/* -> */*; *:* => ACCEPT');
expect(firewallRuleText(firewallRule({ sourceIPAddress: '999.999.999.999', destinationIPAddress: '20.0.0.0', destinationSubnetMask: '255.0.0.0', protocol: 'udp', port: 53 }), first)).toBe(
'10.0.0.0/255.255.255.0 -> 20.0.0.0/255.0.0.0; UDP:53 => DROP',
);
});
});
const ROUTER = uuid(0x4001);
const LEFT = uuid(0x4002);
const RIGHT = uuid(0x4003);
/** 10.0.0.2 — router 10.0.0.1/20.0.0.1 with a firewall — 20.0.0.2. */
function network(changes: Partial<TopologySnapshotInput> = {}): TopologySnapshotInput {
return {
nodes: [{ id: ROUTER, kind: 'router', ports: [portSnapshot(uuid(0x4011), 'eth0'), portSnapshot(uuid(0x4012), 'eth1')] }, pc(LEFT, uuid(0x4021)), pc(RIGHT, uuid(0x4031))],
links: [link(ROUTER, uuid(0x4011), LEFT, uuid(0x4021)), link(ROUTER, uuid(0x4012), RIGHT, uuid(0x4031))],
deviceConfigurations: new Map([
[ROUTER, deviceConfiguration('10.0.0.1', '255.255.255.0', '', '')],
[LEFT, deviceConfiguration('10.0.0.2', '255.255.255.0', '10.0.0.1', '')],
[RIGHT, deviceConfiguration('20.0.0.2', '255.255.255.0', '20.0.0.1', '')],
]),
interfaceConfigurations: new Map([
[interfaceKey(ROUTER, uuid(0x4011)), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
[interfaceKey(ROUTER, uuid(0x4012)), { ipAddress: '20.0.0.1', subnetMask: '255.255.255.0' }],
]),
firewallConfigurationsByNodeId: new Map([
[ROUTER, firewallConfiguration({ isActive: true, defaultPolicy: 'accept', rules: [firewallRule({ protocol: 'udp', port: 7, action: 'drop' })] })],
]),
...changes,
};
}
describe("a router's firewall log", () => {
it('logs a UDP rule that matched, and keeps the log over a new start, as Java does', () => {
const engine = startedEngine(network());
const send = () => engine.sendUdp(engine.bindUdpSocket(LEFT, { remoteIPAddress: '20.0.0.2', remotePort: 7 })!, new TextEncoder().encode('x'));
send();
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
engine.handle({ type: 'stop' });
engine.handle({ type: 'start', snapshot: engine.state.topologySnapshot, seed: 14, initialTimeMilliseconds: 0 });
send();
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
expect(engine.firewallLog(ROUTER)).toHaveLength(2);
expect(firewallLogPage(engine.firewallLog(ROUTER), 'en')).toBe(
'<html>\n\t<head>\n\t\t<title>Log-data set entry of the firewall</title>\n\t</head>\n\t<body>\n\t\t<h2>Log-data set entry of the firewall</h2>\n\n' +
'<br /> \nFirewall rule matched: #1 (*/* -> */*; UDP:7 => DROP) -> drop<br /> \nFirewall rule matched: #1 (*/* -> */*; UDP:7 => DROP) -> drop' +
'\n\n\t</body>\n</html>',
);
});
it('has the router switch on its web server at the start; with swiftRouters none, as in Swift', () => {
const engine = startedEngine(network());
expect(engine.webServer(ROUTER)?.isActive).toBe(true);
expect(engine.webServer(ROUTER)?.log.all.map((line) => line.kind)).toEqual(['acceptingStarted']);
expect(engine.webServer(LEFT)).toBeUndefined();
const swift = new NetworkRuntimeEngine(14, undefined, { swiftRouters: true });
swift.handle({ type: 'start', snapshot: topologySnapshot(network()), seed: 14, initialTimeMilliseconds: 0 });
expect(swift.webServer(ROUTER)).toBeUndefined();
expect(swift.state.socketsById.size).toBe(0);
});
});
describe("a RIP route's public address (hopPublicIp)", () => {
it('is the telling router\'s, also when a shorter way replaces the route', () => {
const engine = startedEngine(network({ ripEnabledByNodeId: new Map([[ROUTER, true]]), firewallConfigurationsByNodeId: new Map() }));
type Learn = (nodeId: string, message: unknown) => void;
const learn = (engine as unknown as { learnRipRoutes: Learn }).learnRipRoutes.bind(engine);
const message = (ipAddress: string, publicIPAddress: string, hops: number) => ({
ipAddress,
publicIPAddress,
infinity: 16,
timeoutMilliseconds: 75_000,
routes: [{ network: '60.0.0.0', mask: '255.0.0.0', hops }],
});
const route = () => engine.ripTable(ROUTER)!.find((each) => each.destinationNetwork === '60.0.0.0');
learn(ROUTER, message('10.0.0.5', '10.0.0.5', 2));
expect(route()).toMatchObject({ metric: 3, nextHop: '10.0.0.5', hopPublicIPAddress: '10.0.0.5' });
learn(ROUTER, message('20.0.0.6', '30.0.0.6', 0));
expect(route()).toMatchObject({ metric: 1, nextHop: '20.0.0.6', hopPublicIPAddress: '30.0.0.6' });
learn(ROUTER, message('10.0.0.5', '10.0.0.5', 0));
expect(route()).toMatchObject({ metric: 1, nextHop: '20.0.0.6', hopPublicIPAddress: '30.0.0.6' });
});
});
describe('TCP as Java counts it', () => {
it('counts characters, not bytes, and never cuts a character of two units in halves', () => {
expect(javaTextLength(new TextEncoder().encode('Grüße'))).toBe(5);
expect(javaTextLength(new Uint8Array([0xff, 0x00]))).toBe(2);
const engine = startedEngine(network({ firewallConfigurationsByNodeId: new Map() }));
const listener = engine.openTcpServerSocket(RIGHT, 80)!;
const client = engine.openTcpClientSocket(LEFT, '20.0.0.2', 80)!;
expect(engine.connectTcpWithResult(client)).toBe('connected');
const accepted = engine.state.tcpAcceptedSocketIdsByListenerId.get(listener)![0]!;
const text = `${'ä'.repeat(1_459)}😀${'b'.repeat(10)}`;
expect(engine.sendTcp(client, new TextEncoder().encode(text))).toBe(true);
expect(new TextDecoder().decode(engine.receiveTcp(accepted))).toBe(text);
const lengths = engine.state.packetTraces
.filter((trace) => trace.nodeId === LEFT && trace.detail?.startsWith('TCP data') === true)
.map((trace) => Number(trace.afterHeaders.find((header) => header.name === 'payloadLength')?.value));
// 1459 two-byte characters (the emoji does not fit whole), then the emoji and the rest.
expect(lengths).toEqual([2 * 1_459, 4 + 10]);
});
});
+185
View File
@@ -0,0 +1,185 @@
// The pages of routers and home routers as Java FILIUS serves them.
// tools/java-oracle's RouterWebRun let a host's browser ask a router's or
// home router's web server for its index page, its routing table and its
// firewall log (routerweb-java-<story>.java.json). The engine plays the same
// stories: each card's frames must come in the same order, with the same
// content and rows, at about the same time; the server's lines and the
// browser's pages likewise. The pages are Java's German ones.
//
// The router's server switches itself on when the simulation starts (Java
// writes so into its window): lines from before the story's first call are
// not compared. Java's router server sometimes never listens (setAktiv wakes
// its thread only when it already waits), and takes no initial sequence
// number then; the engine's always listens: sequence numbers are compared
// below the millions. The settings page (konfig) comes with F3.2c.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import type { LogLine, NetworkRuntimeEngine } from '../src/index.js';
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, watchedEngine, withClientPortText, type JavaFrame, type JavaTables } from './javaRun.js';
import { browserText, isPolled, POLL_MILLISECONDS, serverText } from './programLines.js';
import { snapshotOf, type Scenario } from './scenario.js';
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
const id = (suffix: number) => `00000000-0000-0000-0000-${String(suffix).padStart(12, '0')}`;
type Program = 'server' | 'browser';
interface JavaRun extends JavaTables {
frames: Record<string, JavaFrame[]>;
events: [number, string, string][];
}
interface Story {
readonly scenario: string;
readonly browser: string;
readonly router: string;
readonly acts: readonly [number, (engine: NetworkRuntimeEngine, browser: string) => void][];
}
const open =
(typed: string) =>
(engine: NetworkRuntimeEngine, browser: string): void => {
engine.webBrowser(browser)!.open(typed);
};
const STORIES: Record<string, Story> = {
routerIndex: { scenario: 'router-web', browser: id(301), router: id(302), acts: [[500, open('10.0.0.1')]] },
routerRoutes: { scenario: 'router-web', browser: id(301), router: id(302), acts: [[500, open('10.0.0.1/routes')]] },
routerLog: {
scenario: 'router-web',
browser: id(301),
router: id(302),
acts: [
[500, (engine, browser) => engine.sendIcmpEcho(browser, '20.0.0.2', { sequenceNumber: 1 })],
[3500, open('10.0.0.1')],
[7000, open('10.0.0.1/log')],
],
},
gatewayIndex: {
scenario: 'nat-home',
browser: id(501),
router: id(502),
acts: [
[500, open('192.168.0.1')],
[4000, open('192.168.0.1/routes')],
],
},
gatewayFromWan: { scenario: 'nat-home', browser: id(504), router: id(502), acts: [[500, open('42.0.0.10')]] },
ripRoutes: { scenario: 'rip-chain', browser: id(401), router: id(402), acts: [[40_000, open('10.0.1.1/routes')]] },
};
/** Sequence numbers below the millions: which socket drew which initial number differs (see above). */
const withoutInitialNumbers = (text: string) =>
withClientPortText(text)
.replace(/(seq|ack)=(\d+)/g, (_, name: string, value: string) => `${name}=${Number(value) % 1_000_000}`)
.replace(/(SEQ|ACK): \d+\.(\d{3}\.\d{3})/g, '$1: n.$2');
/**
* Where Java errs: its run corrected, as Java means it. routerLog: the
* German log page closes its title with </titel> (sw_firewallweblog_msg1);
* the engine writes </title>.
*/
const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
routerLog: (java) => {
const fixed = (text: string | null) => text?.replace('</titel>', '</title>') ?? null;
const rows = (byCard: Record<string, (string | null)[][]> | undefined) =>
Object.fromEntries(Object.entries(byCard ?? {}).map(([card, cardRows]) => [card, cardRows.map((row) => row.map(fixed))]));
return { ...java, events: java.events.map(([time, who, text]) => [time, who, fixed(text)!]), rows: rows(java.rows), layeredRows: rows(java.layeredRows) };
},
};
/**
* ripRoutes: how long a learned route has left. Java's routers start one
* after the other and draw their beacons' pauses in another order (rip-chain
* in ripJava.test.ts: R2 sends three times at the start, not twice); a
* neighbour's beacon refreshes the route some seconds apart. Compared as a
* placeholder; the page's other cells exactly.
*/
const PAGE_PLACEHOLDERS: Record<string, (text: string) => string> = {
ripRoutes: (text) => text.replace(/<td>-?\d+<\/td>(<td>[\d.]+<\/td><td><a )/g, '<td>validity</td>$1'),
};
type Lines = Record<Program, [number, string][]>;
function javaLines(java: JavaRun): Lines {
const lines: Lines = { server: [], browser: [] };
const firstCall = Math.min(...java.events.filter(([, , text]) => text.startsWith('call ')).map(([time]) => time));
for (const [time, program, text] of java.events) {
if (program !== 'server' && program !== 'browser') continue;
if (time < firstCall || text === 'null' || text.startsWith('call ')) continue;
const page = /^page (\d+) (\S+)\n/.exec(text);
lines[program].push([time, withClientPortText(page !== null && page[1] !== '200' ? `page ${page[1]} ${page[2]}` : text)]);
}
return lines;
}
function engineLines(engine: NetworkRuntimeEngine, story: Story, firstCall: number): Lines {
const lines = <E>(log: readonly LogLine<E>[], text: (entry: E) => string | undefined): [number, string][] =>
log.flatMap((line) => {
const each = line.timeMilliseconds < firstCall ? undefined : text(line);
return each === undefined ? [] : [[line.timeMilliseconds, withClientPortText(each)] as [number, string]];
});
return {
server: lines(engine.webServer(story.router)!.log.all, serverText),
browser: lines(engine.webBrowser(story.browser)!.log.all, browserText),
};
}
describe('the pages of routers and home routers as Java FILIUS serves them', () => {
it('corrects Java only where it errs, and puts in placeholders only where they change something', () => {
for (const [name, correct] of Object.entries(JAVA_BUGS)) {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `routerweb-java-${name}.java.json`), 'utf8')) as JavaRun;
expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java));
}
for (const [name, placeholders] of Object.entries(PAGE_PLACEHOLDERS)) {
const text = readFileSync(join(scenarioDirectory, `routerweb-java-${name}.java.json`), 'utf8');
expect(placeholders(text), name).not.toBe(text);
}
});
for (const [name, story] of Object.entries(STORIES)) {
it(name, () => {
const measured = JSON.parse(readFileSync(join(scenarioDirectory, `routerweb-java-${name}.java.json`), 'utf8')) as JavaRun;
const java = JAVA_BUGS[name]?.(measured) ?? measured;
const placeholders = PAGE_PLACEHOLDERS[name] ?? ((text: string) => text);
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${story.scenario}.scenario.json`), 'utf8')) as Scenario;
const snapshot = { ...snapshotOf(scenario), webBrowserNodeIds: new Set([story.browser]) };
const { engine } = watchedEngine(scenario, snapshot);
for (const [time, act] of story.acts) {
engine.handle({ type: 'advance', toMilliseconds: time });
act(engine, story.browser);
}
engine.handle({ type: 'advance', toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 5_000 });
const firstCall = story.acts[0]![0];
// Before the story, only what the network does by itself (RIP); compared from the first call.
expectSameCaptures(engine, scenario, java.frames, {
text: withoutInitialNumbers,
cell: (text) => placeholders(withoutInitialNumbers(text)),
tables: java,
from: firstCall,
starts: { java: 0, engine: 0 },
});
const withPlaceholders = (lines: Lines): Lines => ({
server: lines.server.map(([time, text]) => [time, placeholders(text)]),
browser: lines.browser.map(([time, text]) => [time, placeholders(text)]),
});
const expected = withPlaceholders(javaLines(java));
const actual = withPlaceholders(engineLines(engine, story, firstCall));
for (const program of ['server', 'browser'] as const) {
expect(actual[program].map(([, text]) => text), `${program}'s lines`).toEqual(expected[program].map(([, text]) => text));
for (const [index, [javaTime, text]] of expected[program].entries()) {
const late = javaTime - actual[program][index]![0];
const most = (isPolled(text) ? POLL_MILLISECONDS : 0) + GAP_TOLERANCE_MILLISECONDS;
expect(late, `${program}'s line ${JSON.stringify(text.slice(0, 60))}`).toBeGreaterThanOrEqual(-GAP_TOLERANCE_MILLISECONDS);
expect(late, `${program}'s line ${JSON.stringify(text.slice(0, 60))}`).toBeLessThanOrEqual(most);
}
}
});
}
});
@@ -0,0 +1,126 @@
{
"seed": 71,
"nodes": [
{
"id": "00000000-0000-0000-0000-000000000301",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000311",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000302",
"kind": "router",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000321",
"label": "eth0"
},
{
"id": "00000000-0000-0000-0000-000000000322",
"label": "eth1"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000303",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000313",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000304",
"kind": "networkSwitch",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000341",
"label": "sw1"
},
{
"id": "00000000-0000-0000-0000-000000000342",
"label": "sw2"
},
{
"id": "00000000-0000-0000-0000-000000000343",
"label": "sw3"
}
]
}
],
"links": [
{
"id": "00000000-0000-0000-0000-00000000F301",
"sourceNode": "00000000-0000-0000-0000-000000000301",
"sourcePort": "00000000-0000-0000-0000-000000000311",
"targetNode": "00000000-0000-0000-0000-000000000304",
"targetPort": "00000000-0000-0000-0000-000000000341"
},
{
"id": "00000000-0000-0000-0000-00000000F302",
"sourceNode": "00000000-0000-0000-0000-000000000302",
"sourcePort": "00000000-0000-0000-0000-000000000321",
"targetNode": "00000000-0000-0000-0000-000000000304",
"targetPort": "00000000-0000-0000-0000-000000000342"
},
{
"id": "00000000-0000-0000-0000-00000000F303",
"sourceNode": "00000000-0000-0000-0000-000000000302",
"sourcePort": "00000000-0000-0000-0000-000000000322",
"targetNode": "00000000-0000-0000-0000-000000000303",
"targetPort": "00000000-0000-0000-0000-000000000313"
}
],
"devices": {
"00000000-0000-0000-0000-000000000301": {
"ip": "10.0.0.2",
"mask": "255.255.255.0",
"gateway": "10.0.0.1"
},
"00000000-0000-0000-0000-000000000302": {
"ip": "10.0.0.1",
"mask": "255.255.255.0"
},
"00000000-0000-0000-0000-000000000303": {
"ip": "20.0.0.2",
"mask": "255.255.255.0",
"gateway": "20.0.0.1"
}
},
"interfaces": [
{
"node": "00000000-0000-0000-0000-000000000302",
"port": "00000000-0000-0000-0000-000000000321",
"ip": "10.0.0.1",
"mask": "255.255.255.0"
},
{
"node": "00000000-0000-0000-0000-000000000302",
"port": "00000000-0000-0000-0000-000000000322",
"ip": "20.0.0.1",
"mask": "255.255.255.0"
}
],
"firewalls": {
"00000000-0000-0000-0000-000000000302": {
"active": true,
"defaultPolicy": "accept",
"dropIcmp": true,
"rules": [
{
"source": "10.0.0.0",
"sourceMask": "255.255.255.0",
"protocol": "tcp",
"port": 80,
"action": "accept"
}
]
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -17
View File
@@ -24,10 +24,9 @@ import {
withSnapshot,
type LogLine,
type NetworkRuntimeEngine,
type WebBrowserLogEntry,
} from '../src/index.js';
import { expectSameCaptures, watchedEngine, withClientPortText, type JavaFrame, type JavaTables, type SeenFrame } from './javaRun.js';
import { clientText, isPolled, POLL_MILLISECONDS, serverText } from './programLines.js';
import { browserText, clientText, isPolled, POLL_MILLISECONDS, serverText } from './programLines.js';
import { snapshotOf, type Scenario } from './scenario.js';
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
@@ -107,21 +106,6 @@ function fileSystemOf(files: [string, string, string][]): VirtualFileSystem {
return fileSystem;
}
/** A browser's line as its window took it: a page (with its text only for 200), or "update" for an image. */
function browserText(entry: WebBrowserLogEntry): string | undefined {
switch (entry.kind) {
case 'page':
return `page ${entry.statusCode} ${entry.contentType ?? 'null'}${entry.statusCode === 200 ? `\n${entry.body ?? 'null'}` : ''}`;
case 'unreachable':
return 'page 0 null';
case 'image': {
// WebBrowser.retrieveImages tells the window of an image that came, or of a status other than 200.
const isImage = entry.contentType !== undefined && IMAGE_TYPES.has(entry.contentType.toLowerCase().replace('image/', ''));
return entry.statusCode !== 200 || isImage ? 'update' : undefined;
}
}
}
type Lines = Record<Program, [number, string][]>;
function javaLines(java: JavaRun): Lines {