// Replays every scenario in test/scenarios with the TypeScript engine and // compares the result with what the filius-ipad Swift engine produced for the // same file (.swift.json, written by tools/swift-oracle/run.sh). // Every trace event must match: identity, time, node, headers, detail. import { readdirSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { NetworkRuntimeEngine, type DhcpClientStatus, type IPv4DeliveryResult, type IcmpObservation, type TraceEvent, } from '../src/index.js'; import { snapshotOf, type Scenario } from './scenario.js'; const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios'); const upper = (id: string) => id.toUpperCase(); function dhcpStatusJson(status: DhcpClientStatus) { return { state: status.state, errors: status.errorCount, server: status.selectedServerIPAddress ?? null, offered: status.offeredIPAddress ?? null, succeeded: status.succeeded, }; } function traceJson(event: TraceEvent): Record { return { id: event.id, time: event.timeMilliseconds, node: upper(event.nodeId), direction: event.direction, layer: event.layer, operation: event.operation, before: event.beforeHeaders.map((field) => [field.name, field.value]), after: event.afterHeaders.map((field) => [field.name, field.value]), ...(event.frameIdentity === undefined ? {} : { frame: event.frameIdentity }), ...(event.packetIdentity === undefined ? {} : { packet: event.packetIdentity }), ...(event.interfaceId === undefined ? {} : { interface: upper(event.interfaceId) }), ...(event.detail === undefined ? {} : { detail: event.detail }), }; } function deliveryJson(result: IPv4DeliveryResult): Record { switch (result.type) { case 'delivered': return { type: 'delivered', packet: result.packetIdentity, node: upper(result.nodeId) }; case 'icmpError': return { type: 'icmpError', packet: result.packetIdentity, node: upper(result.nodeId), kind: result.kind }; case 'dropped': return { type: 'dropped', packet: result.packetIdentity, ...(result.nodeId === undefined ? {} : { node: upper(result.nodeId) }), }; } } function observationJson(item: IcmpObservation): Record { return { kind: item.message.kind, identifier: item.message.identifier, sequence: item.message.sequenceNumber, sender: item.packet.senderIPAddress, }; } function run(scenario: Scenario): Record { const engine = new NetworkRuntimeEngine(scenario.seed); if (scenario.speedPercent !== undefined) engine.setSimulationSpeed(scenario.speedPercent); engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 }); const results: unknown[] = []; for (const step of scenario.steps) { switch (step.op) { case 'ping': results.push( deliveryJson( engine.sendIcmpEcho(step.from!, step.target!, { timeToLive: step.ttl ?? 64, identifier: step.identifier ?? 1, sequenceNumber: step.sequence ?? 1, }), ), ); break; case 'traceroute': results.push( engine .traceIcmpEcho(step.from!, step.target!, { maximumTimeToLive: step.maxTtl ?? 64, identifier: step.identifier ?? 1, }) .map(observationJson), ); break; case 'resolveMac': results.push(engine.resolveMacAddress(step.node!, step.target!) ?? null); break; case 'advance': engine.handle({ type: 'advance', toMilliseconds: step.to! }); results.push(engine.state.currentTimeMilliseconds); break; case 'packetLoss': engine.setGlobalPacketLossEnabled(step.enabled!); results.push(engine.state.globalPacketLossEnabled); break; case 'sat': results.push( engine.switchSatEntries(step.node!).map((entry) => ({ mac: entry.macAddress, port: entry.portLabel, updated: entry.updatedAtMilliseconds, })), ); break; case 'speed': engine.setSimulationSpeed(step.percent!); results.push(engine.simulationSpeed.percent); break; case 'bindUdp': results.push( engine.bindUdpSocket(step.node!, { ...(step.port === undefined ? {} : { localPort: step.port }), ...(step.localIp === undefined ? {} : { localIPAddress: step.localIp }), ...(step.remoteIp === undefined ? {} : { remoteIPAddress: step.remoteIp }), ...(step.remotePort === undefined ? {} : { remotePort: step.remotePort }), }) ?? null, ); break; case 'sendUdp': { const result = engine.sendUdp(step.socket!, new TextEncoder().encode(step.payload ?? ''), { ...(step.target === undefined ? {} : { ipAddress: step.target }), ...(step.port === undefined ? {} : { port: step.port }), }); results.push(result === undefined ? null : deliveryJson(result)); break; } case 'receiveUdp': { const item = engine.receiveUdp(step.socket!, step.timeout ?? 0); results.push( item === undefined ? null : { sender: item.senderIPAddress, receiver: item.receiverIPAddress, sourcePort: item.datagram.sourcePort, destinationPort: item.datagram.destinationPort, payload: new TextDecoder().decode(item.datagram.payload), at: item.receivedAtMilliseconds, }, ); break; } case 'openTcpServer': results.push(engine.openTcpServerSocket(step.node!, step.port!, step.localIp) ?? null); break; case 'openTcpClient': results.push( engine.openTcpClientSocket(step.node!, step.target!, step.port!, { ...(step.localIp === undefined ? {} : { localIPAddress: step.localIp }), ...(step.localPort === undefined ? {} : { localPort: step.localPort }), }) ?? null, ); break; case 'connectTcp': results.push(engine.connectTcpWithResult(step.socket!)); break; case 'sendTcp': results.push(engine.sendTcp(step.socket!, new TextEncoder().encode((step.payload ?? '').repeat(step.repeatCount ?? 1)))); break; case 'receiveTcp': { const message = engine.receiveTcp(step.socket!); results.push(message === undefined ? null : new TextDecoder().decode(message)); break; } case 'acceptedTcp': results.push(engine.state.tcpAcceptedSocketIdsByListenerId.get(step.socket!) ?? []); break; case 'peerTcp': results.push(engine.peerTcpSocketId(step.socket!) ?? null); break; case 'cleanTcp': results.push(engine.cleanClosedTcpSocket(step.socket!)); break; case 'runDhcp': { const status = engine.runDhcpClient(step.node!); results.push(status === undefined ? null : dhcpStatusJson(status)); break; } case 'closeSocket': engine.closeSocket(step.socket!); results.push(!engine.state.socketsById.has(step.socket!)); break; default: throw new Error(`unknown step ${step.op}`); } } const state = engine.state; const mapOf = (map: Map, convert: (value: V) => unknown) => Object.fromEntries([...map].map(([key, value]) => [upper(key), convert(value)])); return { results, traces: state.packetTraces.map(traceJson), time: state.currentTimeMilliseconds, pendingEvents: state.pendingEvents.length, arp: mapOf(state.arpCachesByNodeId, (cache) => Object.fromEntries([...cache].map(([ip, entry]) => [ip, entry.macAddress])), ), switchTables: mapOf(state.switchForwardingTablesByNodeId, (table) => Object.fromEntries([...table].map(([mac, port]) => [mac, upper(port)])), ), icmpObservations: mapOf(state.icmpObservationsByNodeId, (items) => items.map(observationJson)), devices: Object.fromEntries( [...state.topologySnapshot.deviceConfigurations].map(([node, device]) => [ upper(node), [device.ipAddress, device.subnetMask, device.defaultGateway, device.dnsServer], ]), ), dhcp: { clients: mapOf(state.dhcpClientStatusesByNodeId, dhcpStatusJson), leases: [...state.dhcpLeasesByIPAddress] .sort(([lhs], [rhs]) => (lhs < rhs ? -1 : 1)) .map(([key, lease]) => [key, lease.clientMacAddress, lease.ipAddress, lease.expiresAtMilliseconds ?? null]), offers: [...state.dhcpOffersByIPAddress] .sort(([lhs], [rhs]) => (lhs < rhs ? -1 : 1)) .map(([key, offer]) => [key, offer.clientMacAddress, offer.expiresAtMilliseconds]), blacklist: mapOf(state.dhcpBlacklistByServerNodeId, (entries) => [...entries].sort(([lhs], [rhs]) => (lhs < rhs ? -1 : 1)).map(([address, expiry]) => [address, expiry]), ), servers: mapOf(state.dhcpServerSocketIdsByNodeId, upper), }, sockets: [...state.socketsById.values()] .sort((lhs, rhs) => (upper(lhs.id) < upper(rhs.id) ? -1 : 1)) .map((socket) => ({ id: upper(socket.id), node: upper(socket.nodeId), protocol: socket.protocol, localIp: socket.localIPAddress ?? null, localPort: socket.localPort, remoteIp: socket.remoteIPAddress ?? null, remotePort: socket.remotePort ?? null, queued: state.udpReceiveQueuesBySocketId.get(socket.id)?.length ?? 0, tcpState: socket.tcpState ?? null, parent: socket.parentListenerSocketId === undefined ? null : upper(socket.parentListenerSocketId), session: ((session) => session === undefined ? null : { attempts: session.sendAttempts, next: session.nextSendSequenceNumber, remote: session.remoteSequenceNumber, pending: session.lastSentSegment !== undefined, assembly: session.receiveAssembly.length, messages: session.receivedMessages.length, })(state.tcpSessionsById.get(socket.id)), })), firewallDecisions: state.firewallDecisions.map((decision) => ({ packet: decision.packetIdentity, node: upper(decision.nodeId), accepted: decision.accepted, ...(decision.ruleIndex === undefined ? {} : { rule: decision.ruleIndex }), })), }; } const scenarioNames = readdirSync(scenarioDirectory) .filter((name) => name.endsWith('.scenario.json')) .map((name) => name.replace('.scenario.json', '')) .sort(); /** * Where the Swift engine is wrong, per scenario: its result corrected. None * at the moment. (Until F2.3b: dhcp-lan, the status a directly run DHCP * client reported — Swift wrote back a stale copy after a nested run.) */ const SWIFT_ERRORS: Record) => Record> = {}; /** * Scenarios where the engine follows Java instead of Swift; the Java * comparison checks them (dhcpJava.test.ts, measured with Java FILIUS). Here * they must differ from Swift, or the departure is not real. */ const JAVA_INSTEAD_OF_SWIFT: Record = { 'dhcp-lan': 'DHCP client: one message per attempt, the ARP check waits a round trip, which follows the speed', 'dhcp-two-servers': 'DHCP client: the second OFFER is read and dropped, no REQUEST twice (Swift: answered with NAK)', 'dhcp-conflict': 'DHCP client: the ARP check finds the address taken and declines (Swift: it does not wait, two cards share the address)', 'dhcp-no-server': 'DHCP client: a link-local address after five timeouts (Swift: the old address after ten)', }; describe('same result as the Swift engine', () => { it('corrects Swift only where it errs', () => { for (const [name, correct] of Object.entries(SWIFT_ERRORS)) { const expected = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.swift.json`), 'utf8')) as Record; expect(JSON.stringify(correct(expected)), name).not.toBe(JSON.stringify(expected)); } }); it('has scenarios with Swift results', () => { expect(scenarioNames.length).toBeGreaterThan(0); }); for (const [name, reason] of Object.entries(JAVA_INSTEAD_OF_SWIFT)) { it(`${name} follows Java instead: ${reason}`, () => { const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario; const swift = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.swift.json`), 'utf8')) as Record; expect(run(scenario)).not.toEqual(swift); }); } for (const name of scenarioNames.filter((each) => !(each in JAVA_INSTEAD_OF_SWIFT))) { it(name, () => { const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario; const swift = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.swift.json`), 'utf8')) as Record; const expected = SWIFT_ERRORS[name]?.(swift) ?? swift; const actual = run(scenario); // Traces first and one by one: the first difference is the one that matters. const expectedTraces = expected.traces as unknown[]; const actualTraces = actual.traces as unknown[]; for (let index = 0; index < Math.max(expectedTraces.length, actualTraces.length); index += 1) { expect(actualTraces[index], `trace event #${index + 1}`).toEqual(expectedTraces[index]); } expect(actual).toEqual(expected); }); } });