One row per frame (its highest layer's), Java's detail tree, the table as Java's text file, "Tabellen löschen" for every card (clearCapture in the room), a tab per card with Java's title, Java's layer colours as tokens, the chosen frame and autoscroll per tab. New oracle ExchangeWindowRun. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
9.9 KiB
TypeScript
186 lines
9.9 KiB
TypeScript
// 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<string, JavaTable>;
|
|
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<CaptureLayer, string> = { 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<number, CaptureRow[]> {
|
|
const frames = new Map<number, CaptureRow[]>();
|
|
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);
|
|
});
|
|
});
|