// FILIUS' data exchange window against Java's (exchangeWindow.java.json; // tools/java-oracle's ExchangeWindowRun on web-lan): the browser's card while // it fetches a page by name — one row per frame, its highest layer's; each // frame's details; the table as a text file —, "Tabellen löschen", which // empties every card's list and numbers anew, and a second fetch. The rows // themselves are compared layer by layer with Java's in webJava.test.ts; // Java's time of day is the editor's to write (TIME here). import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { captureCommentText, captureDetailLines, captureRows, exchangeExportText, exchangeRow, VirtualFileSystem, withSnapshot, type CaptureLayer, type CaptureRow, type NetworkRuntimeEngine, } from '../src/index.js'; import { watchedEngine, withClientPortText } from './javaRun.js'; import { snapshotOf, type Scenario } from './scenario.js'; type Cells = (string | null)[]; interface JavaTable { readonly rows: Cells[]; readonly layerRows: Cells[]; } interface JavaStep { readonly step: string; readonly at: number; readonly titles: string[]; readonly selected: number; readonly tables: Record; readonly details?: [number, string][][]; readonly export?: string; readonly menu?: string[]; } const here = dirname(fileURLToPath(import.meta.url)); const java = JSON.parse(readFileSync(join(here, 'exchangeWindow.java.json'), 'utf8')) as { readonly steps: readonly JavaStep[] }; const at = (name: string) => java.steps.find((step) => step.step === name)!; const scenario = JSON.parse(readFileSync(join(here, 'scenarios/web-lan.scenario.json'), 'utf8')) as Scenario; const BROWSER = '00000000-0000-0000-0000-000000000201'; const SERVER = '00000000-0000-0000-0000-000000000203'; const portOf = (node: string) => scenario.nodes.find((each) => each.id === node)!.ports[0]!.id; /** Java's German words (its message bundle) and number format (Locale.GERMANY). */ const LAYERS: Record = { networkAccess: 'Netzzugang', internet: 'Vermittlung', transport: 'Transport', application: 'Anwendung' }; const COMMENT_WORDS = { protocol: 'Protokoll', searchForMac: 'Suche nach MAC f\u00fcr', macIs: 'MAC ist' }; const DETAIL_WORDS = { number: 'Nr.', time: 'Zeit', source: 'Quelle', destination: 'Ziel', protocol: 'Protokoll', comment: 'Bemerkungen / Details' }; const HEADER = ['Nr.', 'Zeit', 'Quelle', 'Ziel', 'Protokoll', 'Schicht', 'Bemerkungen / Details']; const german = new Intl.NumberFormat('de-DE'); const comment = (row: CaptureRow) => captureCommentText(row.comment, COMMENT_WORDS, (value) => german.format(value)); /** A cell with placeholders: the browser's free ports and the DNS message's ID (drawn) and Java's time of day. */ const cell = (text: string) => withClientPortText(text) .replace(/(^|\s)(\d{5})$/, (whole, before: string, port: string) => (Number(port) >= 49_152 ? `${before}client` : whole)) .replace(/ID=\d+/g, 'ID=x'); const TIME = /^\d\d:\d\d:\d\d\.\d\d\d$/; function run() { const files = new VirtualFileSystem(); files.createDirectory('/webserver', true); files.writeTextFile('/webserver/text.txt', 'Nur Text\nzwei Zeilen'); const snapshot = withSnapshot(snapshotOf(scenario), { webServersByNodeId: new Map([[SERVER, { port: 80, documentRoot: '/webserver', virtualHosts: false, isActive: true }]]), webBrowserNodeIds: new Set([BROWSER]), fileSystemsByNodeId: new Map([[SERVER, files]]), }); const { engine } = watchedEngine(scenario, snapshot); const until = (milliseconds: number) => engine.handle({ type: 'advance', toMilliseconds: milliseconds }); return { engine, until }; } /** The window's table of a card: each frame's highest row, without its time. */ const table = (engine: NetworkRuntimeEngine, node: string) => [...groupByFrame(captureRows(engine.capture.frames(portOf(node)), { withAddresses: true })).values()].map((rows) => { const row = exchangeRow(rows); return [String(row.number), row.source, row.destination, row.protocol, LAYERS[row.layer], comment(row)].map((each) => (each === null ? null : cell(each))); }); const javaTable = (step: JavaStep, node: string) => step.tables[node]!.rows.map(([number, , ...rest]) => [number!, ...rest].map((each) => (each === null ? null : cell(each)))); function groupByFrame(rows: readonly CaptureRow[]): Map { const frames = new Map(); for (const row of rows) frames.set(row.number, [...(frames.get(row.number) ?? []), row]); return frames; } describe("the data exchange window, against Java's", () => { it('shows one row per frame, its details, its text file; is emptied by "Tabellen löschen" and numbers anew', () => { const { engine, until } = run(); until(500); engine.webBrowser(BROWSER)!.open('http://www.filius.de/text.txt'); const fetched = at('after the fetch'); until(fetched.at); // One row per frame: the highest layer's, with addresses. expect(table(engine, BROWSER)).toEqual(javaTable(fetched, BROWSER)); expect(fetched.tables[BROWSER]!.rows.length).toBeLessThan(fetched.tables[BROWSER]!.layerRows.length); // Each frame's details, from its layered rows (ports only). const frames = groupByFrame(captureRows(engine.capture.frames(portOf(BROWSER)), { withAddresses: false })); const details = [...frames.values()].map((rows) => captureDetailLines(rows, 'TIME', DETAIL_WORDS, (row) => LAYERS[row.layer], comment).map(({ depth, text }) => [depth, cell(text)])); const javaDetails = fetched.details!.map((lines) => lines.map(([depth, text]) => [depth, cell(text.replace(/(Zeit: )\S+$/, '$1TIME'))])); expect(details).toEqual(javaDetails); // The text file of Java's own rows, as Java writes it. const javaRows = fetched.tables[BROWSER]!.rows.map((row) => row.map((each) => each ?? '')); expect(exchangeExportText(HEADER, javaRows)).toBe(fetched.export); expect(fetched.tables[BROWSER]!.rows.every((row) => TIME.test(row[1]!))).toBe(true); // "Tabellen löschen" (Lauscher.reset): every card's list empties. const cleared = at('cleared'); until(cleared.at); engine.clearCapture(); expect([table(engine, BROWSER), table(engine, SERVER)]).toEqual([javaTable(cleared, BROWSER), javaTable(cleared, SERVER)]); expect(cleared.menu).toEqual(['Tabellen l\u00f6schen', 'Als Textdatei exportieren', 'Automatisches Scrollen']); // Fetched again: numbered from 1. engine.webBrowser(BROWSER)!.open('http://www.filius.de/text.txt'); const again = at('fetched again'); until(again.at); expect(table(engine, BROWSER)).toEqual(javaTable(again, BROWSER)); expect(table(engine, SERVER)).toEqual(javaTable(again, SERVER)); }); it('breaks a long comment as Java does: words onto lines of 40, a long word cut, five lines at most', () => { const header = ['N', 'T', 'S', 'D', 'P', 'L', 'C']; const row = (text: string) => ['1', 't', 's', 'd', 'p', 'l', text]; const lines = (text: string) => exchangeExportText(header, [row(text)]) .split('\r\n') .slice(3, -2) .map((line) => line.split(' | ')[6]!.trimEnd()); expect(lines('')).toEqual(['']); expect(lines(' \n ')).toEqual(['']); expect(lines(`${'a'.repeat(40)} b`)).toEqual(['a'.repeat(40), 'b']); expect(lines(`x ${'a'.repeat(90)} y`)).toEqual(['x', 'a'.repeat(40), 'a'.repeat(40), `${'a'.repeat(10)} y`]); expect(lines(`${'a'.repeat(41)}`)).toEqual(['a'.repeat(40), 'a']); // Whitespace at the ends does not count (StringUtils.normalizeSpace). expect(lines(` \n${'a'.repeat(40)} `)).toEqual(['a'.repeat(40)]); expect(lines(Array.from({ length: 6 }, () => 'w'.repeat(39)).join(' '))).toEqual([...Array.from({ length: 5 }, () => 'w'.repeat(39)), '...']); expect(lines(Array.from({ length: 5 }, () => 'w'.repeat(39)).join(' '))).toEqual(Array.from({ length: 5 }, () => 'w'.repeat(39))); // An empty comment is empty (Java writes "null"). expect(exchangeExportText(header, [row('')]).split('\r\n')[3]).toBe( `| 1 | t | s | d | p | l | ${' '.repeat(40)} | `, ); }); it('writes a short comment beside its label and a long one below it, and the length of the data in Bytes', () => { const base = { number: 4, timeMilliseconds: 0, source: null, destination: null } as const; const rows: CaptureRow[] = [ { ...base, source: 'a', destination: '', protocol: '', layer: 'networkAccess', comment: { kind: 'ethernetType', type: '0x800' } }, { ...base, source: '', destination: 'b', protocol: 'IP', layer: 'internet', comment: { kind: 'ipProtocol', protocol: 6, timeToLive: 64 } }, { ...base, protocol: 'HTTP', layer: 'application', comment: { kind: 'data', text: 'ä'.repeat(60) } }, { ...base, protocol: 'HTTP', layer: 'application', comment: { kind: 'data', text: 'b'.repeat(61) } }, ]; expect(captureDetailLines(rows, '12:00', DETAIL_WORDS, (row) => LAYERS[row.layer], comment)).toEqual([ { depth: 0, text: 'Nr.: 4 / Zeit: 12:00' }, { depth: 1, text: 'Netzzugang' }, { depth: 2, text: 'Quelle: a' }, { depth: 2, text: 'Bemerkungen / Details: 0x800' }, { depth: 1, text: 'Vermittlung' }, { depth: 2, text: 'Ziel: b' }, { depth: 2, text: 'Protokoll: IP' }, { depth: 2, text: 'Bemerkungen / Details: Protokoll: 6, TTL: 64' }, { depth: 1, text: 'Anwendung' }, { depth: 2, text: 'Protokoll: HTTP' }, { depth: 2, text: `Bemerkungen / Details (60 Bytes): ${'ä'.repeat(60)}` }, { depth: 1, text: 'Anwendung' }, { depth: 2, text: 'Protokoll: HTTP' }, { depth: 2, text: 'Bemerkungen / Details (61 Bytes): ' }, { depth: 3, text: 'b'.repeat(61) }, ]); expect(captureDetailLines([], '12:00', DETAIL_WORDS, (row) => LAYERS[row.layer], comment)).toEqual([]); expect(() => exchangeRow([])).toThrow(RangeError); }); });