feat(core): Gnutella search and download after Java, measured against Java

GnutellaRun adds a line of four peers: a search goes on as a query, each
peer answers with a hit for each matching file and the hits come back the
way the query came; a hit's file is fetched by HTTP on port 6346. The
file type names of the model move from the project export and import into
the core, so that the download uses them too.

Departures from Java: a peer that sent hits keeps its connection, so that
the hits of farther peers come back too (Java closes it and loses them),
and sizes are in characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 14:59:26 +00:00
co-authored by Claude Opus 5.5
parent b4a95e6cac
commit ce5b10c1f3
13 changed files with 39934 additions and 130 deletions
@@ -16,6 +16,7 @@ import {
swiftEqual,
swiftHasPrefix,
swiftLowercased,
swiftPathExtension,
swiftTrim,
} from '../util/swiftString.js';
import { DEFAULT_IMAGE_FILES, LEGACY_DEFAULT_IMAGE_BASE64 } from './defaultImages.js';
@@ -146,6 +147,70 @@ function keyOf(path: string): string {
return path.normalize('NFC');
}
/** The text types Java's importer reads as text, named by a file's extension. */
const TEXT_EXTENSIONS = new Set(['txt', 'html', 'htm', 'css', 'xml', 'csv', 'conf', 'log']);
/** "image/png" → "png", "image/jpeg" → "jpg", as Java names them. */
function javaSubtype(mediaType: string): string {
const name = mediaType.slice(mediaType.indexOf('/') + 1);
return name === 'jpeg' ? 'jpg' : name;
}
/**
* A file's type as Java FILIUS keeps it (Datei.dateiTyp), from what the
* model keeps: a text file its extension when Java reads that as text, else
* "text"; an image or binary file its media type's subtype (none: empty).
* The project export writes it, and a server that names it (Gnutella's
* Content-type) says it.
*/
export function javaFileTypeName(name: string, content: Exclude<VirtualFileContent, { kind: 'directory' }>): string {
if (content.kind === 'text') {
const dot = name.lastIndexOf('.');
const extension = dot < 0 ? '' : name.slice(dot + 1).toLowerCase();
return TEXT_EXTENSIONS.has(extension) ? extension : 'text';
}
if (content.kind === 'image') return javaSubtype(content.mediaType);
return content.mediaType === undefined ? '' : javaSubtype(content.mediaType);
}
const IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'image']);
const TEXT_TYPES = new Set(['', 'text', 'txt', 'text/txt', 'html', 'htm', 'css', 'xml', 'csv', 'conf', 'log']);
/**
* Swift's importedContent: an image by type or extension (Base64), text by
* type, else Base64 as binary data, else text — each fallback with a warning.
*/
/**
* A file's content as the model keeps it, from Java's type and content
* (Datei.dateiTyp, getDateiInhalt): an image or an unknown type decoded
* from Base64, a text type as text; what is no Base64 kept as text or bytes,
* with a warning. The project import reads files so, and a program that
* gets a file from another (Gnutella's download).
*/
export function fileContentOfJavaType(rawType: string, content: string, path: string, warn: (message: string) => void = () => {}): VirtualFileContent {
const type = swiftLowercased(swiftTrim(rawType));
const extension = swiftLowercased(swiftPathExtension(path));
const binary = (data: Uint8Array): VirtualFileContent =>
type === '' ? { kind: 'binary', data } : { kind: 'binary', data, mediaType: `application/${type}` };
if (IMAGE_TYPES.has(type) || IMAGE_TYPES.has(extension)) {
const data = swiftBase64Decode(content);
if (data !== undefined) {
const subtype = type === 'image' || type === '' ? (extension === '' ? 'png' : extension) : type;
return { kind: 'image', data, mediaType: `image/${subtype === 'jpg' ? 'jpeg' : subtype}` };
}
warn(`Java file '${path}' declared image type '${type}' but was not valid Base64; imported as UTF-8 binary data.`);
return binary(encoder.encode(content));
}
if (TEXT_TYPES.has(type)) return { kind: 'text', text: content };
const data = swiftBase64Decode(content);
if (data !== undefined && (data.length > 0 || swiftTrim(content) === '')) {
warn(`Java file '${path}' used unknown type '${type}'; preserved as binary data.`);
return binary(data);
}
warn(`Java file '${path}' used unknown type '${type}'; preserved as UTF-8 text.`);
return { kind: 'text', text: content };
}
export class VirtualFileSystem {
private store: Store;
+237 -20
View File
@@ -2,21 +2,32 @@
// with its PeerToPeerServer on port 6346 and PeerToPeerClient): a host joins
// the network by a ping to one peer, which answers with a pong, takes the
// host as a peer and sends the ping on to its own peers, whose pongs come
// back the same way; each knows at most its number of peers. The messages
// are Java's text packets (PeerToPeerPaket.toString), each on a connection
// of its own that stays open, as Java's do.
// back the same way; each knows at most its number of peers. A search goes
// to the peers as a query, each sends it on and answers with a hit for each
// of its files in /peer2peer whose name holds the words; hits come back the
// way the query came. A hit's file is fetched from its host by HTTP on the
// same port. The messages are Java's text packets (PeerToPeerPaket
// .toString), each on a connection of its own that stays open, as Java's do.
//
// Departures from Java (gnutellaJava.test.ts names each):
// - a ping that comes a second time gets no pong, and its connection stays
// (Java means so, but its worker throws on the missing pong and closes the
// connection).
// connection);
// - a peer that answered a query with hits keeps its connection, so that the
// hits of the peers it sent the query on to come back too (Java closes it
// after its own hits, and theirs are lost);
// - a hit names a text file's size in characters (Java decodes the text as
// Base64: "Hallo Welt" is 6 B).
import type { Id } from '../../ids.js';
import { fileContentOfJavaType, javaFileTypeName, type VirtualFileContent } from '../../model/virtualFileSystem.js';
import type { GnutellaConfiguration } from '../config.js';
import { formatHttpRequest, formatHttpResponse, HTTP_GET, httpPath, parseHttpRequest, parseHttpResponse } from '../http.js';
import type { NetworkRuntimeEngine } from '../engine.js';
import type { Steps } from '../threads.js';
import { ProgramLog } from './program.js';
import { TcpServerProgram } from './server.js';
import { javaBase64 } from '../../util/javaBase64.js';
/** PeerToPeerServer, PeerToPeerClientMitarbeiter: Gnutella's port. */
export const GNUTELLA_PORT = 6346;
@@ -47,9 +58,27 @@ export interface PongPacket extends GnutellaHead {
readonly bytes: number;
}
export type GnutellaPacket = PingPacket | PongPacket;
/** QueryPaket: the least speed asked for (Java's search asks "1") and the words searched for. */
export interface QueryPacket extends GnutellaHead {
readonly kind: 'query';
readonly minimumSpeed: string;
readonly criteria: string;
}
const PAYLOAD_KIND = { ping: '0x00', pong: '0x01' } as const;
/** QueryHitPaket: how many hits its host has, its port and address, its speed, the hit ("name: size B"), its servent id. */
export interface QueryHitPacket extends GnutellaHead {
readonly kind: 'queryHit';
readonly hits: number;
readonly port: number;
readonly ipAddress: string;
readonly speed: string;
readonly result: string;
readonly servent: string;
}
export type GnutellaPacket = PingPacket | PongPacket | QueryPacket | QueryHitPacket;
const PAYLOAD_KIND = { ping: '0x00', pong: '0x01', query: '0x80', queryHit: '0x81' } as const;
/** PeerToPeerPaket.anzahlBenoetigterBits: the least exponent whose power of two is at least the number. */
function bitsFor(value: number): number {
@@ -63,10 +92,35 @@ export function pongPayloadLength(ipAddress: string, port: number, files: number
return ipAddress.length * 8 + bitsFor(port) + bitsFor(files) + bitsFor(bytes);
}
/** PeerToPeerPaket.toString and its subclasses': the fields joined by "//". */
/** QueryPaket.payloadLengthBerechnen. */
export function queryPayloadLength(minimumSpeed: string, criteria: string): number {
return (minimumSpeed.length + criteria.length) * 8;
}
/** QueryHitPaket.payloadLengthBerechnen, as Java's worker leaves it: counted before the hit is put in (an empty one). */
export function queryHitPayloadLength(hits: number, port: number, ipAddress: string, speed: string, servent: string): number {
return bitsFor(hits) + bitsFor(port) + ipAddress.length * 8 + speed.length * 8 + servent.length * 8;
}
/** PeerToPeerPaket.toString and its subclasses': the fields joined by "//"; an empty text written as one blank. */
export function gnutellaText(packet: GnutellaPacket): string {
const head = [packet.guid, PAYLOAD_KIND[packet.kind], packet.hops, packet.ttl, packet.payloadLength];
const body = packet.kind === 'ping' ? [packet.ipAddress] : [packet.ipAddress, packet.port, packet.files, packet.bytes];
const blankForEmpty = (text: string) => (text === '' ? ' ' : text);
let body: (string | number)[];
switch (packet.kind) {
case 'ping':
body = [packet.ipAddress];
break;
case 'pong':
body = [packet.ipAddress, packet.port, packet.files, packet.bytes];
break;
case 'query':
body = [blankForEmpty(packet.minimumSpeed), blankForEmpty(packet.criteria)];
break;
case 'queryHit':
body = [packet.hits, packet.port, packet.ipAddress, packet.speed, blankForEmpty(packet.result), blankForEmpty(packet.servent)];
break;
}
return [...head, ...body].join('//');
}
@@ -93,11 +147,16 @@ export function gnutellaPacket(text: string): GnutellaPacket | undefined {
if (fields[1] === PAYLOAD_KIND.pong && fields[5] !== undefined && port !== undefined && files !== undefined && bytes !== undefined) {
return { kind: 'pong', ...head, ipAddress: fields[5], port, files, bytes };
}
if (fields[1] === PAYLOAD_KIND.query && fields[5] !== undefined && fields[6] !== undefined) return { kind: 'query', ...head, minimumSpeed: fields[5], criteria: fields[6] };
const [hits, hitPort] = [number(5), number(6)];
if (fields[1] === PAYLOAD_KIND.queryHit && hits !== undefined && hitPort !== undefined && fields.length >= 11) {
return { kind: 'queryHit', ...head, hits, port: hitPort, ipAddress: fields[7]!, speed: fields[8]!, result: fields[9]!, servent: fields[10]! };
}
return undefined;
}
/** What the program tells its window: its known peers, whenever they change. */
export type GnutellaLogEntry = { readonly kind: 'peers'; readonly peers: readonly string[] };
/** What the program tells its window: its known peers, or its search results ("address/name: size B"), whenever they change. */
export type GnutellaLogEntry = { readonly kind: 'peers'; readonly peers: readonly string[] } | { readonly kind: 'results'; readonly results: readonly string[] };
/** PeerToPeerServer: answers pings on 6346; a connection's worker remembers the id of its last packet. */
class GnutellaServer extends TcpServerProgram {
@@ -125,8 +184,13 @@ class GnutellaServer extends TcpServerProgram {
return;
}
const text = new TextDecoder().decode(received.payload);
// PeerToPeerServerMitarbeiter.verarbeiteNachricht: a text without a packet's head throws, and the worker closes the
// connection; a packet of another kind is left.
const send = (answer: string) => this.engine.sendTcpSteps(socketId, new TextEncoder().encode(answer));
// PeerToPeerServerMitarbeiter.verarbeiteNachricht: a request for a file; else a text without a packet's head
// throws, and the worker closes the connection; a packet of another kind is left.
if (text.startsWith(HTTP_GET)) {
yield* send(this.peer.fileResponse(text));
continue;
}
if (!hasGnutellaHead(text)) {
this.lastGuidBySocket.delete(socketId);
return;
@@ -134,7 +198,8 @@ class GnutellaServer extends TcpServerProgram {
const packet = gnutellaPacket(text);
if (packet === undefined) continue;
this.lastGuidBySocket.set(socketId, packet.guid);
if (packet.kind === 'ping') yield* this.peer.answerPing(packet, address, (text) => this.engine.sendTcpSteps(socketId, new TextEncoder().encode(text)));
if (packet.kind === 'ping') yield* this.peer.answerPing(packet, address, send);
else if (packet.kind === 'query') yield* this.peer.answerQuery(packet, address, send);
}
}
@@ -155,10 +220,15 @@ class GnutellaServer extends TcpServerProgram {
export class GnutellaPeer {
readonly log: ProgramLog<GnutellaLogEntry>;
private peersValue: string[] = [];
private resultsValue: string[] = [];
private readonly maximumKnownPeers: number;
/** The ids of the program's own pings (eigeneAnfragen), of others' (fremdeAnfragen), and of those it sent on (schonmalVerschicktListe). */
/**
* The ids of the program's own pings and queries (eigeneAnfragen), and of
* those it sent on (schonmalVerschicktListe). Java also keeps others'
* (fremdeAnfragen) and sends back only their hits; a hit goes back only on
* a connection whose last packet had its id, which comes to the same.
*/
private readonly ownRequests = new Set<number>();
private readonly foreignRequests = new Set<number>();
private readonly alreadySent = new Set<number>();
private readonly server: GnutellaServer;
@@ -177,6 +247,11 @@ export class GnutellaPeer {
return this.peersValue;
}
/** The search results (holeErgebnisse): each hit's host address and its hit, "address/name: size B". */
get results(): readonly string[] {
return this.resultsValue;
}
/** The node's first address (Betriebssystem.primaryIPAdresse). */
private get ownAddress(): string {
return this.engine.networkInterfaces(this.nodeId)[0]?.ipAddress ?? '';
@@ -196,6 +271,46 @@ export class GnutellaPeer {
this.log.write({ kind: 'peers', peers: [] });
}
/** sucheDatei: no results, a query for the words to every peer. */
search(criteria: string): void {
this.setResults([]);
const query: QueryPacket = {
kind: 'query',
guid: this.engine.drawGnutellaGuid(),
hops: 0,
ttl: 8,
payloadLength: queryPayloadLength('1', criteria),
minimumSpeed: '1',
criteria,
};
this.ownRequests.add(query.guid);
this.sendQueryOn(query, this.ownAddress);
}
/** loescheSuchergebnisse. */
clearResults(): void {
this.setResults([]);
}
/**
* herunterladenDatei: the result's file asked for by HTTP from its host
* (the result's address, and its name up to the ":"), and kept in
* /peer2peer when the host has it.
*/
download(index: number): void {
const result = this.resultsValue[index];
if (result === undefined) return;
const [owner, rest] = result.split('/').filter((part) => part !== '');
const name = rest?.split(':').find((part) => part !== '');
if (owner === undefined || name === undefined) return;
this.engine.startThread(this.fetchFile(owner, name));
}
private setResults(results: string[]): void {
this.resultsValue = results;
this.log.write({ kind: 'results', results });
}
/** hinzuTeilnehmer: a new peer while there is room. */
private addPeer(address: string): void {
if (this.peersValue.includes(address) || this.peersValue.length >= this.maximumKnownPeers) return;
@@ -213,7 +328,7 @@ export class GnutellaPeer {
// erstellePong: none for a ping sent on before.
if (!this.alreadySent.has(ping.guid)) {
const files = this.sharedFiles();
const bytes = files.reduce((sum, size) => sum + size, 0);
const bytes = files.reduce((sum, file) => sum + file.size, 0);
const address = this.ownAddress;
const pong: PongPacket = {
kind: 'pong',
@@ -235,28 +350,129 @@ export class GnutellaPeer {
private sendPingOn(ping: PingPacket, from: string): void {
if (ping.ttl <= 0 || this.alreadySent.has(ping.guid)) return;
this.alreadySent.add(ping.guid);
this.foreignRequests.add(ping.guid);
for (const peer of this.peersValue) {
if (peer !== from && peer !== ping.ipAddress) this.engine.startThread(this.sendTo(peer, ping));
}
}
/**
* PeerToPeerServerMitarbeiter.verarbeiteQuery, PeerToPeerAnwendung
* .verarbeiteAnfrage: a query not seen before sent on to the other peers
* while it lives, and answered with a hit for each file of /peer2peer
* whose name holds the words (in any case).
*/
*answerQuery(query: QueryPacket, from: string, send: (text: string) => Steps<unknown>): Steps {
if (this.alreadySent.has(query.guid)) return;
this.alreadySent.add(query.guid);
const wanted = query.criteria.toLowerCase();
const hits = this.sharedFiles().filter((file) => file.name.toLowerCase().includes(wanted));
if (query.ttl > 0) this.sendQueryOn({ ...query, ttl: query.ttl - 1, hops: query.hops + 1 }, from);
const address = this.ownAddress;
for (const hit of hits) {
const packet: QueryHitPacket = {
kind: 'queryHit',
guid: query.guid,
hops: 0,
ttl: 8,
payloadLength: queryHitPayloadLength(hits.length, GNUTELLA_PORT, address, '2', ' '),
hits: hits.length,
port: GNUTELLA_PORT,
ipAddress: address,
speed: '2',
result: `${hit.name}: ${hit.size} B`,
servent: ' ',
};
yield* send(gnutellaText(packet));
}
}
/** PeerToPeerClient.sendeAnfrage: to every peer but the sender. */
private sendQueryOn(query: QueryPacket, from: string): void {
for (const peer of this.peersValue) if (peer !== from) this.engine.startThread(this.sendTo(peer, query));
}
/** verarbeiteQueryHit: a hit for the program's own search (a result, once), else sent back the way its query came while it lives. */
private takeQueryHit(hit: QueryHitPacket): void {
if (this.ownRequests.has(hit.guid)) {
const result = `${hit.ipAddress}/${hit.result}`;
if (!this.resultsValue.includes(result)) this.setResults([...this.resultsValue, result]);
} else if (hit.ttl > 0) {
this.server.sendBack({ ...hit, ttl: hit.ttl - 1, hops: hit.hops + 1 });
}
}
/** httpAnfrageVerarbeiten: the asked file of /peer2peer with its type, or 404. */
fileResponse(text: string): string {
const request = parseHttpRequest(text);
const path = request.type === 'request' ? `${GNUTELLA_FOLDER}/${httpPath(request.request.path)}` : undefined;
const file = path === undefined ? undefined : this.sharedFile(path);
if (file === undefined) return formatHttpResponse({ protocolVersion: 'HTTP/1.1', statusCode: 404, contentType: undefined, body: undefined });
const content = file.content;
return formatHttpResponse({
protocolVersion: 'HTTP/1.1',
statusCode: 200,
contentType: javaFileTypeName(file.name, content),
body: content.kind === 'text' ? content.text : javaBase64(content.data),
});
}
/** PeerToPeerClientMitarbeiter.run for HTTP: the request, the answer, the file kept when it came. */
private *fetchFile(owner: string, name: string): Steps {
const engine = this.engine;
const socketId = engine.openTcpClientSocket(this.nodeId, owner, GNUTELLA_PORT);
if (socketId === undefined || (yield* engine.connectTcpSteps(socketId)) !== 'connected') {
if (socketId !== undefined) engine.requestTcpClose(socketId);
return;
}
yield* engine.sendTcpSteps(socketId, new TextEncoder().encode(formatHttpRequest({ method: HTTP_GET, path: name, host: owner })));
const received = yield* engine.receiveTcpSteps(socketId);
const response = received.type === 'message' ? parseHttpResponse(new TextDecoder().decode(received.payload)) : undefined;
if (response?.statusCode === 200) {
const path = `${GNUTELLA_FOLDER}/${name}`;
this.keepFile(path, fileContentOfJavaType(response.contentType ?? '', response.body ?? '', path));
}
engine.requestTcpClose(socketId);
}
/** speicherDatei: over a file of that name in any case, keeping its name (as the file system does). */
private keepFile(path: string, content: VirtualFileContent): void {
const files = this.engine.state.topologySnapshot.fileSystemsByNodeId.get(this.nodeId);
if (files === undefined || content.kind === 'directory') return;
if (content.kind === 'text') files.writeTextFile(path, content.text);
else if (content.kind === 'image') files.writeImageFile(path, content.data, content.mediaType);
else if (content.mediaType === undefined) files.writeBinaryFile(path, content.data);
else files.writeBinaryFile(path, content.data, content.mediaType);
}
/** verarbeitePong: the program's own ping answered (a new peer), else the pong sent back the way its ping came. */
private takePong(pong: PongPacket): void {
if (this.ownRequests.has(pong.guid)) this.addPeer(pong.ipAddress);
else this.server.sendBack({ ...pong, ttl: pong.ttl - 1, hops: pong.hops + 1 });
}
/** The sizes of the files in /peer2peer (Datei.holeGroesse, a text in characters). */
private sharedFiles(): number[] {
/** The files in /peer2peer (Dateisystem.holeDateien), their names and sizes (Datei.holeGroesse, a text in characters). */
private sharedFiles(): { readonly name: string; readonly size: number }[] {
const files = this.engine.state.topologySnapshot.fileSystemsByNodeId.get(this.nodeId);
if (files === undefined || !files.contains(GNUTELLA_FOLDER)) return [];
return files.entriesInCreationOrder(GNUTELLA_FOLDER).flatMap((entry) => {
const content = entry.content;
return content.kind === 'directory' ? [] : [content.kind === 'text' ? content.text.length : content.data.length];
const name = entry.path.slice(entry.path.lastIndexOf('/') + 1);
return content.kind === 'directory' ? [] : [{ name, size: content.kind === 'text' ? content.text.length : content.data.length }];
});
}
/** A file at the path, found in any case (Dateisystem.holeDatei); none for a folder or nothing. */
private sharedFile(path: string): { readonly path: string; readonly name: string; readonly content: Exclude<VirtualFileContent, { kind: 'directory' }> } | undefined {
const files = this.engine.state.topologySnapshot.fileSystemsByNodeId.get(this.nodeId);
try {
const entry = files?.entry(path);
if (entry === undefined || entry.content.kind === 'directory') return undefined;
return { path: entry.path, name: entry.path.slice(entry.path.lastIndexOf('/') + 1), content: entry.content };
} catch {
return undefined;
}
}
/**
* PeerToPeerClientMitarbeiter.run: a connection to the peer's port 6346, the
* packet sent, then each answer taken until the connection ends.
@@ -274,6 +490,7 @@ export class GnutellaPeer {
if (received.type !== 'message') break;
const answer = gnutellaPacket(new TextDecoder().decode(received.payload));
if (answer?.kind === 'pong') this.takePong(answer);
else if (answer?.kind === 'queryHit') this.takeQueryHit(answer);
}
engine.requestTcpClose(socketId);
}
+144 -2
View File
@@ -6,7 +6,17 @@ import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { GNUTELLA_FOLDER, gnutellaPacket, gnutellaText, pongPayloadLength, VirtualFileSystem, withSnapshot, type NetworkRuntimeEngine } from '../src/index.js';
import {
GNUTELLA_FOLDER,
gnutellaPacket,
gnutellaText,
pongPayloadLength,
queryHitPayloadLength,
queryPayloadLength,
VirtualFileSystem,
withSnapshot,
type NetworkRuntimeEngine,
} from '../src/index.js';
import { watchedEngine } from './javaRun.js';
import { snapshotOf, type Scenario } from './scenario.js';
@@ -38,11 +48,27 @@ describe('Gnutella packets as Java writes and reads them', () => {
});
it('reads no packet from a text that is none, or of another kind', () => {
expect(['', 'GET /x HTTP/1.1', '42//0x00//1//7//0', 'x//0x00//1//7//0//a', '42//0x80//1//7//0//1//txt', '42//0x01//0//8//77//10.0.0.2//6346//2'].map(gnutellaPacket)).toEqual(
expect(['', 'GET /x HTTP/1.1', '42//0x00//1//7//0', 'x//0x00//1//7//0//a', '42//0x80//1//7//0//1', '42//0x01//0//8//77//10.0.0.2//6346//2'].map(gnutellaPacket)).toEqual(
Array(6).fill(undefined),
);
});
it('writes and reads a query and a hit, an empty text as one blank', () => {
const query = { kind: 'query', guid: 5, hops: 0, ttl: 8, payloadLength: queryPayloadLength('1', ''), minimumSpeed: '1', criteria: '' } as const;
expect(gnutellaText(query)).toBe('5//0x80//0//8//8//1// ');
expect(gnutellaPacket('5//0x80//1//7//40//1//lied')).toEqual({ kind: 'query', guid: 5, hops: 1, ttl: 7, payloadLength: 40, minimumSpeed: '1', criteria: 'lied' });
const hit = { kind: 'queryHit', guid: 5, hops: 0, ttl: 8, payloadLength: 125, hits: 1, port: 6346, ipAddress: '192.168.0.31', speed: '2', result: '', servent: ' ' } as const;
expect(gnutellaText(hit)).toBe('5//0x81//0//8//125//1//6346//192.168.0.31//2// // ');
expect(gnutellaPacket('5//0x81//1//7//125//1//6346//192.168.0.31//2//lied.txt: 10 B// ')).toEqual({ ...hit, hops: 1, ttl: 7, result: 'lied.txt: 10 B' });
expect(gnutellaPacket('5//0x81//1//7//125//1//6346//192.168.0.31//2//lied.txt: 10 B')).toBeUndefined();
});
it("counts a query's and a hit's payload in Java's bits (a hit's without the hit itself, as Java's worker leaves it)", () => {
expect(queryPayloadLength('1', 'lied')).toBe(40);
expect(queryHitPayloadLength(1, 6346, '192.168.0.31', '2', ' ')).toBe(125);
expect(queryHitPayloadLength(3, 6346, '10.0.0.2', '2', ' ')).toBe(2 + 13 + 64 + 8 + 8);
});
it("counts a pong's payload in Java's bits: the address's characters, then the least power of two for each number", () => {
expect(pongPayloadLength('10.0.0.2', 6346, 0, 0)).toBe(77);
expect(pongPayloadLength('192.168.0.31', 6346, 0, 0)).toBe(109);
@@ -144,4 +170,120 @@ describe('the Gnutella program in the engine', () => {
ping(engine, '12//0x00//0//8//0//192.168.0.20');
expect(forwarded()).toBe(1);
});
it('gives a file of its folder by HTTP with its type, and 404 for one it has not', () => {
const files = new VirtualFileSystem();
files.createDirectory(GNUTELLA_FOLDER);
files.writeTextFile(`${GNUTELLA_FOLDER}/Lied der Woche.txt`, 'La la la');
files.writeImageFile(`${GNUTELLA_FOLDER}/bild.png`, new Uint8Array([1, 2, 3]), 'image/png');
const engine = started(files);
expect(ping(engine, 'GET Lied%20der%20Woche.txt HTTP/1.1\nHost: 192.168.0.31\n').answers).toEqual(['HTTP/1.1 200 OK\nContent-type: txt\n\nLa la la']);
expect(ping(engine, 'GET BILD.PNG HTTP/1.1\nHost: 192.168.0.31\n').answers).toEqual(['HTTP/1.1 200 OK\nContent-type: png\n\nAQID']);
expect(ping(engine, 'GET fehlt.txt HTTP/1.1\nHost: 192.168.0.31\n').answers).toEqual(['HTTP/1.1 404 Not Found\n']);
});
it('answers a query once, and sends it on only while it lives', () => {
const files = new VirtualFileSystem();
files.createDirectory(GNUTELLA_FOLDER);
files.writeTextFile(`${GNUTELLA_FOLDER}/lied.txt`, 'Hallo Welt');
files.writeTextFile(`${GNUTELLA_FOLDER}/Lied2.TXT`, 'Hallo');
const engine = started(files);
const first = ping(engine, '8//0x80//0//8//40//1//LIED');
expect(first.answers.map((answer) => answer.replace(/^\d+\/\/0x81\/\/0\/\/8\/\/\d+\/\//, ''))).toEqual([
'2//6346//192.168.0.31//2//lied.txt: 10 B// ',
'2//6346//192.168.0.31//2//Lied2.TXT: 5 B// ',
]);
expect(ping(engine, '8//0x80//0//8//40//1//LIED').answers).toEqual([]);
expect(ping(engine, '9//0x80//0//8//40//1//xyz').answers).toEqual([]);
});
it('tells its window when it clears its results', () => {
const engine = started();
engine.gnutella(ECHO)!.clearResults();
expect(engine.gnutella(ECHO)!.log.all.map((line) => line.kind)).toEqual(['results']);
});
it('downloads an image as an image, over a file of the name in another case (keeping that name)', () => {
const WEB = '00000000-0000-0000-0000-000000000403';
const echoFiles = new VirtualFileSystem();
echoFiles.createDirectory(GNUTELLA_FOLDER);
echoFiles.writeImageFile(`${GNUTELLA_FOLDER}/bild.png`, new Uint8Array([1, 2, 3]), 'image/png');
const webFiles = new VirtualFileSystem();
webFiles.createDirectory(GNUTELLA_FOLDER);
webFiles.writeTextFile(`${GNUTELLA_FOLDER}/BILD.png`, 'alt');
const engine = watchedEngine(
scenario,
withSnapshot(snapshotOf(scenario), {
gnutellaByNodeId: new Map([
[ECHO, { maximumKnownPeers: 3 }],
[WEB, { maximumKnownPeers: 3 }],
]),
fileSystemsByNodeId: new Map([
[ECHO, echoFiles],
[WEB, webFiles],
]),
}),
).engine;
const web = engine.gnutella(WEB)!;
web.join('192.168.0.31');
engine.handle({ type: 'advance', toMilliseconds: 3_000 });
web.search('bild');
engine.handle({ type: 'advance', toMilliseconds: 6_000 });
expect(web.results).toEqual(['192.168.0.31/bild.png: 3 B']);
web.download(0);
engine.handle({ type: 'advance', toMilliseconds: 9_000 });
expect(webFiles.entriesInCreationOrder(GNUTELLA_FOLDER).map((entry) => [entry.path, entry.content])).toEqual([
[`${GNUTELLA_FOLDER}/BILD.png`, { kind: 'image', data: new Uint8Array([1, 2, 3]), mediaType: 'image/png' }],
]);
});
describe('with a peer that answers as a test wants', () => {
const DNS = '00000000-0000-0000-0000-000000000402';
/** A peer on .10 made of bare sockets: to each connection, the answers given for it, in order. */
const fakePeer = (engine: NetworkRuntimeEngine, answersByConnection: string[][]) => {
const listener = engine.openTcpServerSocket(DNS, 6346)!;
const received: string[] = [];
engine.startThread(
(function* () {
for (const answers of answersByConnection) {
const socketId = (yield* engine.acceptTcpSteps(listener))!;
const message = yield* engine.receiveTcpSteps(socketId);
if (message.type === 'message') received.push(new TextDecoder().decode(message.payload));
for (const answer of answers) yield* engine.sendTcpSteps(socketId, new TextEncoder().encode(answer));
}
})(),
);
return received;
};
it('takes a result once, also when it comes twice', () => {
const engine = watchedEngine(scenario, withSnapshot(snapshotOf(scenario), { gnutellaByNodeId: new Map([[CLIENT, { maximumKnownPeers: 3 }]]) })).engine;
const ids = [11, 12];
engine.drawGnutellaGuid = () => ids.shift()!;
const hit = '12//0x81//0//8//125//1//6346//192.168.0.10//2//a.txt: 1 B// ';
fakePeer(engine, [['11//0x01//0//8//109//192.168.0.10//6346//1//1'], [hit, hit]]);
engine.gnutella(CLIENT)!.join('192.168.0.10');
engine.handle({ type: 'advance', toMilliseconds: 3_000 });
engine.gnutella(CLIENT)!.search('a');
engine.handle({ type: 'advance', toMilliseconds: 6_000 });
expect(engine.gnutella(CLIENT)!.results).toEqual(['192.168.0.10/a.txt: 1 B']);
});
it('sends back a hit only for a query it sent on, and only while the hit lives; a query only while it lives', () => {
const engine = started();
const hit = (guid: number, ttl: number) => `${guid}//0x81//0//${ttl}//125//1//6346//192.168.0.10//2//a.txt: 1 B// `;
// .31 learns .10 as a peer from .10's ping; then each query .31 sends on to .10 gets the hits given.
const received = fakePeer(engine, [[hit(21, 8), hit(99, 8)], [hit(22, 0)], []]);
const join = engine.openTcpClientSocket(DNS, '192.168.0.31', 6346)!;
expect(engine.connectTcp(join)).toBe(true);
engine.sendTcp(join, new TextEncoder().encode('20//0x00//0//8//0//192.168.0.10'));
engine.handle({ type: 'advance', toMilliseconds: 3_000 });
expect(engine.gnutella(ECHO)!.peers).toEqual(['192.168.0.10']);
expect(ping(engine, '21//0x80//0//8//16//1//a').answers).toEqual([hit(21, 7).replace('//0//7//', '//1//7//')]);
expect(ping(engine, '22//0x80//0//8//16//1//a').answers).toEqual([]);
// A query at the end of its time to live is answered, not sent on.
ping(engine, '23//0x80//7//0//16//1//a');
expect(received.map((text) => text.split('//').slice(0, 4).join('//'))).toEqual(['21//0x80//1//7', '22//0x80//1//7']);
});
});
});
+193 -36
View File
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { withSnapshot, type NetworkRuntimeEngine } from '../src/index.js';
import { GNUTELLA_FOLDER, javaBase64, javaFileTypeName, VirtualFileSystem, withSnapshot, type NetworkRuntimeEngine } from '../src/index.js';
import {
engineText,
expectSameCaptures,
@@ -39,7 +39,7 @@ interface JavaRun extends JavaTables {
acts: [number, string, string][];
/** time, host, kind (call or peers), text */
events: [number, string, string, string][];
programs: Record<string, { peers: string[]; results: string[] }>;
programs: Record<string, { peers: string[]; results: string[]; files?: [string, string, string][] }>;
}
/** The stories, and how many connections each makes one after the other (Java looks every 100 ms whether one is made). */
@@ -50,14 +50,17 @@ const STORIES: Record<string, { readonly looks: number }> = {
full: { looks: 3 },
reset: { looks: 1 },
errors: { looks: 1 },
search: { looks: 3 },
searchBoth: { looks: 3 },
};
/** The ids of Java's own pings, in the order they were made: the engine draws its own, these take their place. */
/** The ids of Java's own pings and queries, in the order they were made: the engine draws its own, these take their place. */
function javaPingIds(java: JavaRun): number[] {
const pings = Object.values(java.frames)
.flat()
.flatMap((frame) => {
const match = /^(\d+)\/\/0x00\/\/0\/\//.exec(frame.payload ?? '');
// A ping or a query with no hops yet: the program's own.
const match = /^(\d+)\/\/0x(?:00|80)\/\/0\/\//.exec(frame.payload ?? '');
return match === null ? [] : [[frame.time, Number(match[1])] as const];
})
.sort((lhs, rhs) => lhs[0] - rhs[0]);
@@ -65,19 +68,86 @@ function javaPingIds(java: JavaRun): number[] {
}
/** Each program's known peers as it told them, each change once (Java tells its window also when nothing changed, and an empty list at its start). */
function javaPeers(java: JavaRun): Record<string, [number, string][]> {
const peers: Record<string, [number, string][]> = Object.fromEntries(PEERS.map((host) => [host, []]));
/**
* Each program's known peers, or its results, as it told them, each change
* once (Java tells its window also when nothing changed, and an empty list
* at its start); results only when there are some (Java empties them without
* telling).
*/
function javaTold(java: JavaRun, wanted: 'peers' | 'results'): Record<string, [number, string][]> {
const told: Record<string, [number, string][]> = Object.fromEntries(PEERS.map((host) => [host, []]));
for (const [time, host, kind, text] of java.events) {
if (kind !== 'peers') continue;
const list = peers[host]!;
if (kind !== wanted || (wanted === 'results' && text === '')) continue;
const list = told[host]!;
if ((list.at(-1)?.[1] ?? '') === text) continue;
list.push([time, text]);
}
return peers;
return told;
}
function enginePeers(engine: NetworkRuntimeEngine): Record<string, [number, string][]> {
return Object.fromEntries(PEERS.map((host) => [host, engine.gnutella(host)!.log.all.map((line): [number, string] => [line.timeMilliseconds, line.peers.join(',')])]));
function engineTold(engine: NetworkRuntimeEngine, wanted: 'peers' | 'results'): Record<string, [number, string][]> {
return Object.fromEntries(
PEERS.map((host) => {
const list: [number, string][] = [];
for (const line of engine.gnutella(host)!.log.all) {
const text = line.kind === 'peers' ? (wanted === 'peers' ? line.peers.join(',') : undefined) : wanted === 'results' ? line.results.join('|') : undefined;
if (text === undefined || (wanted === 'results' && text === '') || list.at(-1)?.[1] === text) continue;
list.push([line.timeMilliseconds, text]);
}
return [host, list];
}),
);
}
/** Java's run with each result's text changed, where it tells them and at the end. */
function withResults(java: JavaRun, change: (results: string[], host: string) => string[]): JavaRun {
return {
...java,
events: java.events.map(([time, host, kind, text]) => [time, host, kind, kind === 'results' && text !== '' ? change(text.split('|'), host).join('|') : text]),
programs: Object.fromEntries(
Object.entries(java.programs).map(([host, program]) => [host, { ...program, results: program.results.length === 0 ? [] : change(program.results, host) }]),
),
};
}
/** A hit's size as Java's Datei.holeGroesse says it (the text decoded as Base64), and as the engine (its characters). */
const SIZES: Record<string, [number, number]> = { 'Lied der Woche.txt': [3, 8], 'notizen.txt': [3, 5], 'lied.txt': [6, 10] };
const withSizes = (results: string[]) =>
results.map((result) =>
result.replace(/\/(.+): (\d+) B$/, (whole, name: string, size: string) => (SIZES[name]?.[0] === Number(size) ? `/${name}: ${SIZES[name]![1]} B` : whole)),
);
/**
* Where Java errs, its run corrected: a hit's size in characters (search,
* searchBoth); in searchBoth also the hit of 10.0.0.2, which Java loses
* because .31 closes the connection after its own hit (gnutella.ts).
*/
const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
search: (java) => withResults(java, withSizes),
searchBoth: (java) => {
const sized = withResults(java, withSizes);
const farHit = '10.0.0.2/Lied der Woche.txt: 8 B';
const last = sized.events.findLast(([, host, kind, text]) => host === CLIENT && kind === 'results' && text !== '')!;
return {
...sized,
events: [...sized.events, [last[0] + 1, CLIENT, 'results', `${last[3]}|${farHit}`]],
programs: { ...sized.programs, [CLIENT]: { ...sized.programs[CLIENT]!, results: [...sized.programs[CLIENT]!.results, farHit] } },
};
},
};
/** Where a result's time is not compared: a result Java never had (JAVA_BUGS). */
const RESULTS_WITHOUT_TIMES = new Set(['searchBoth']);
/** Each program's files in /peer2peer as GnutellaRun writes Java's: name, type, content. */
function engineFiles(engine: NetworkRuntimeEngine, host: string): [string, string, string][] {
const files = engine.state.topologySnapshot.fileSystemsByNodeId.get(host)!;
return files.entriesInCreationOrder(GNUTELLA_FOLDER).flatMap((entry): [string, string, string][] => {
const content = entry.content;
if (content.kind === 'directory') return [];
const name = entry.path.slice(entry.path.lastIndexOf('/') + 1);
return [[name, javaFileTypeName(name, content), content.kind === 'text' ? content.text : javaBase64(content.data)]];
});
}
/**
@@ -94,7 +164,38 @@ function enginePeers(engine: NetworkRuntimeEngine): Record<string, [number, stri
* frames between the two hosts on the server's port, in the order the
* connections began — with their data; ARP on its own.
*/
const BY_CONNECTION = new Set(['twoPaths', 'full']);
const BY_CONNECTION = new Set(['twoPaths', 'full', 'search', 'searchBoth']);
/**
* search, searchBoth: a hit names a size, Java's another than the engine's
* (JAVA_BUGS), as a pong its files' bytes, so the length of the text and
* the numbers after it on its connection differ; the sizes, bytes,
* sequence numbers and lengths are left out of the frames compared. A connection Java's server closes after its
* hits (gnutella.ts) is compared up to that FIN; one the client closes (a
* download's) wholly.
*/
const SEARCHING = new Set(['search', 'searchBoth']);
const withoutSizes = (text: string) =>
text
.replace(/ seq=\d+ ack=\d+ length=\d+/, '')
.replace(/: \d+ B\/\//g, ': n B//')
// A pong's bytes, and its payload length that counts their bits.
.replace(/(\/\/0x01\/\/\d+\/\/\d+\/\/)\d+(\/\/.*\/\/6346\/\/\d+\/\/)\d+$/, '$1n$2n');
/** A card's frames without those of each connection from the FIN its server sent first. */
function beforeServerFins(frames: readonly JavaFrame[]): JavaFrame[] {
const closedAt = new Map<string, number>();
for (const frame of frames) {
const client = frame.sourcePort === 6346 ? `${frame.destinationIp}:${frame.destinationPort}` : `${frame.sourceIp}:${frame.sourcePort}`;
if (frame.tcpFlags?.includes('F') !== true || closedAt.has(client)) continue;
closedAt.set(client, frame.sourcePort === 6346 ? frame.time : Number.POSITIVE_INFINITY);
}
return frames.filter((frame) => {
if (frame.tcpFlags === undefined) return true;
const client = frame.sourcePort === 6346 ? `${frame.destinationIp}:${frame.destinationPort}` : `${frame.sourceIp}:${frame.sourcePort}`;
return frame.time < (closedAt.get(client) ?? Number.POSITIVE_INFINITY);
});
}
interface FlowFrame {
readonly time: number;
@@ -146,22 +247,54 @@ const UNTIL: Record<string, (java: JavaRun) => number> = {
twoPaths: secondPing,
};
/** searchBoth: each side's frames up to its first hit, after which Java loses the hit of 10.0.0.2 (JAVA_BUGS). */
const UP_TO_FIRST_HIT = new Set(['searchBoth']);
const FIRST_HIT = '//0x81//';
/** When the engine's first hit showed on a card. */
function firstEngineHit(engine: NetworkRuntimeEngine, scenario: Scenario): number {
const times = scenarioCards(scenario).flatMap((card) =>
engine.capture.frames(card.portId).flatMap(({ timeMilliseconds, frame }) => {
const packet = frame.payload.kind === 'ipv4' ? frame.payload.packet : undefined;
const text = packet?.payload.kind === 'tcp' ? new TextDecoder().decode(packet.payload.segment.payload) : '';
return text.includes(FIRST_HIT) ? [timeMilliseconds] : [];
}),
);
return Math.min(...times);
}
describe('the Gnutella program as Java FILIUS runs it', () => {
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'terminal-net.scenario.json'), 'utf8')) as Scenario;
for (const [name, story] of Object.entries(STORIES)) {
it(name, () => {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `gnutella-java-${name}.java.json`), 'utf8')) as JavaRun;
const measured = JSON.parse(readFileSync(join(scenarioDirectory, `gnutella-java-${name}.java.json`), 'utf8')) as JavaRun;
const java = JAVA_BUGS[name]?.(measured) ?? measured;
const maximumKnownPeers = (host: string) =>
name === 'full' ? (host === ECHO ? 2 : host === FAR ? 3 : 1) : SEARCHING.has(name) ? (host === CLIENT || host === FAR ? 1 : 2) : 3;
const files = new Map(PEERS.map((host) => [host, new VirtualFileSystem()]));
if (SEARCHING.has(name)) {
for (const host of [ECHO, FAR]) files.get(host)!.createDirectory(GNUTELLA_FOLDER);
if (name === 'searchBoth') files.get(ECHO)!.writeTextFile(`${GNUTELLA_FOLDER}/lied.txt`, 'Hallo Welt');
files.get(ECHO)!.writeTextFile(`${GNUTELLA_FOLDER}/notizen.txt`, 'Notiz');
files.get(FAR)!.writeTextFile(`${GNUTELLA_FOLDER}/Lied der Woche.txt`, 'La la la');
}
const snapshot = withSnapshot(snapshotOf(scenario), {
gnutellaByNodeId: new Map(PEERS.map((host) => [host, { maximumKnownPeers: name !== 'full' ? 3 : host === ECHO ? 2 : host === FAR ? 3 : 1 }])),
gnutellaByNodeId: new Map(PEERS.map((host) => [host, { maximumKnownPeers: maximumKnownPeers(host) }])),
fileSystemsByNodeId: files,
});
const { engine } = watchedEngine(scenario, snapshot);
const ids = javaPingIds(java);
engine.drawGnutellaGuid = () => ids.shift() ?? 1;
for (const [time, host, call] of java.acts) {
engine.handle({ type: 'advance', toMilliseconds: time });
if (call.startsWith('join ')) engine.gnutella(host)!.join(call.slice('join '.length));
else engine.gnutella(host)!.reset();
const program = engine.gnutella(host)!;
if (call.startsWith('join ')) program.join(call.slice('join '.length));
else if (call.startsWith('search ')) program.search(call.slice('search '.length));
else if (call.startsWith('download ')) program.download(Number(call.slice('download '.length)));
else if (call.startsWith('remove ')) files.get(host)!.deleteItem(`${GNUTELLA_FOLDER}/${call.slice('remove '.length)}`);
else if (call === 'clear') program.clearResults();
else program.reset();
}
engine.handle({ type: 'advance', toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 10_000 });
@@ -169,21 +302,37 @@ describe('the Gnutella program as Java FILIUS runs it', () => {
const until = UNTIL[name]?.(java) ?? Number.POSITIVE_INFINITY;
if (BY_CONNECTION.has(name)) {
const portOf = new Map(scenarioCards(scenario).map((card) => [card.key, card.portId]));
const masked = (text: string) => (SEARCHING.has(name) ? withoutSizes(text) : text);
// search, searchBoth: a bare acknowledgement left out (Java's server closes after its hits, and the client's
// acknowledgement of them falls after that FIN).
const compared = (frame: FlowFrame) => !SEARCHING.has(name) || !/ TCP \S+ > \S+ A $/.test(frame.text);
const data = (text: string | undefined) => withClientPortText(text ?? '').replace(/^\d+\/\/0x/, 'x//0x');
for (const [card, list] of Object.entries(java.frames)) {
const javaUntil = UP_TO_FIRST_HIT.has(name)
? Math.min(
...Object.values(java.frames)
.flat()
.filter((frame) => frame.payload?.includes(FIRST_HIT) === true)
.map((frame) => frame.time),
)
: until;
const expected = connections(
list
.filter((frame) => frame.time < until)
.map((frame) => ({ time: frame.time, raw: javaText(frame), text: `${withClientPortText(javaText(frame))} ${data(frame.payload)}` })),
(SEARCHING.has(name) ? beforeServerFins(list) : list)
.filter((frame) => frame.time < javaUntil)
.map((frame) => ({ time: frame.time, raw: javaText(frame), text: masked(`${withClientPortText(javaText(frame))} ${data(frame.payload)}`) }))
.filter(compared),
);
const captured = engine.capture.frames(portOf.get(card)!).filter((each) => each.timeMilliseconds < until);
const engineUntil = UP_TO_FIRST_HIT.has(name) ? firstEngineHit(engine, scenario) : until;
const captured = engine.capture.frames(portOf.get(card)!).filter((each) => each.timeMilliseconds < engineUntil);
const actual = connections(
captured.map(({ timeMilliseconds, frame }) => {
const packet = frame.payload.kind === 'ipv4' ? frame.payload.packet : undefined;
const bytes = packet?.payload.kind === 'tcp' ? packet.payload.segment.payload : new Uint8Array();
const text = engineText(frame);
return { time: timeMilliseconds, raw: text, text: `${withClientPortText(text)} ${data(bytes.length === 0 ? undefined : new TextDecoder().decode(bytes))}` };
}),
captured
.map(({ timeMilliseconds, frame }) => {
const packet = frame.payload.kind === 'ipv4' ? frame.payload.packet : undefined;
const bytes = packet?.payload.kind === 'tcp' ? packet.payload.segment.payload : new Uint8Array();
const text = engineText(frame);
return { time: timeMilliseconds, raw: text, text: masked(`${withClientPortText(text)} ${data(bytes.length === 0 ? undefined : new TextDecoder().decode(bytes))}`) };
})
.filter(compared),
);
expect([...actual.keys()].sort(), `${card}'s connections`).toEqual([...expected.keys()].sort());
for (const [key, flows] of expected) {
@@ -200,17 +349,25 @@ describe('the Gnutella program as Java FILIUS runs it', () => {
expectSameCaptures(engine, scenario, java.frames, { text: withClientPortText, tables: java, until: UNTIL[name]?.(java), tolerance });
}
const expected = javaPeers(java);
const actual = enginePeers(engine);
for (const host of PEERS) {
expect(
actual[host]!.map(([, peers]) => peers),
`${host}'s peers`,
).toEqual(expected[host]!.map(([, peers]) => peers));
for (const [index, [javaTime]] of expected[host]!.entries()) {
expect(Math.abs(javaTime - actual[host]![index]![0]), `${host}'s peers #${index + 1}`).toBeLessThanOrEqual(tolerance);
for (const wanted of ['peers', 'results'] as const) {
const expected = javaTold(java, wanted);
const actual = engineTold(engine, wanted);
for (const host of PEERS) {
expect(
actual[host]!.map(([, told]) => told),
`${host}'s ${wanted}`,
).toEqual(expected[host]!.map(([, told]) => told));
if (wanted === 'results' && RESULTS_WITHOUT_TIMES.has(name)) continue;
for (const [index, [javaTime]] of expected[host]!.entries()) {
expect(Math.abs(javaTime - actual[host]![index]![0]), `${host}'s ${wanted} #${index + 1}`).toBeLessThanOrEqual(tolerance);
}
}
expect(engine.gnutella(host)!.peers, `${host}'s peers at the end`).toEqual(java.programs[host]!.peers);
}
for (const host of PEERS) {
const program = engine.gnutella(host)!;
expect(program.peers, `${host}'s peers at the end`).toEqual(java.programs[host]!.peers);
expect(program.results, `${host}'s results at the end`).toEqual(java.programs[host]!.results);
if (java.programs[host]!.files !== undefined) expect(engineFiles(engine, host), `${host}'s files`).toEqual(java.programs[host]!.files);
}
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff