// The web server and the web browser as Java FILIUS runs them. // tools/java-oracle's WebRun played stories of the two programs on web-lan's // network in Java FILIUS and wrote every frame each card saw, Java's tables // of them, every line the server wrote into its window, everything the // browser told its window, and the server's files (web-java-.java.json). // The engine plays the same stories with its programs (apps/web.ts), on a // server with the same files: 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 and images in the same order, at // about the same time. // // The browser's error pages are its own (the editor's): for a status other // than 200 the page is compared by its status and content type, not by // Java's text for it. import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { javaBase64Decode, parentPath, VirtualFileSystem, withSnapshot, type LogLine, type NetworkRuntimeEngine, } from '../src/index.js'; import { expectSameCaptures, watchedEngine, withClientPortText, type JavaFrame, type JavaTables, type SeenFrame } from './javaRun.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'); const BROWSER = '00000000-0000-0000-0000-000000000201'; const SERVER = '00000000-0000-0000-0000-000000000203'; type Program = 'server' | 'browser' | 'client'; /** * Java's threads wake a millisecond or two late at each segment; over a page * with its images (some 70 frames, one after the other) Java falls behind by * up to some 130 ms (measured: byName, post, caseInsensitive). */ const TOLERANCE_MILLISECONDS = 200; /** Java's timeout of a TCP attempt (3 round trips at 90 %). */ const JAVA_TIMEOUT_MILLISECONDS = 3 * 2_750; interface JavaRun extends JavaTables { frames: Record; events: [number, Program, string][]; /** The server's files at the end: path, Java's type, content (images in Base64). */ files: [string, string, string][]; } type Act = (engine: NetworkRuntimeEngine) => void; const browser = (engine: NetworkRuntimeEngine) => engine.webBrowser(BROWSER)!; const client = (engine: NetworkRuntimeEngine) => engine.simpleClient(BROWSER)!; const open = (typed: string, post?: string): Act => (engine) => browser(engine).open(typed, post); /** Each story's calls at their times, as WebRun makes them. */ const STORIES: Record = { page: [[500, open('192.168.0.30')]], byName: [ [500, open('www.filius.de/seite.html')], [6000, open('http:///text.txt')], ], notFound: [[500, open('http://www.filius.de/nichts.html')]], vhost: [ [500, open('seite.filius.de')], [5000, open('andere.filius.de')], [9500, open('www.filius.de/vhost1/')], ], noServer: [[500, open('leer.filius.de')]], unresolved: [[500, open('unbekannt.filius.de')]], post: [[500, open('www.filius.de/seite.html', 'name=Filius&x=1')]], encoded: [ [500, open('www.filius.de/mein bild.html')], [5000, open('www.filius.de/grüße.html')], ], traversal: [[500, open('www.filius.de/../geheim.txt')]], caseInsensitive: [[500, open('WWW.Filius.de/SEITE.HTML')]], raw: [ [500, (engine) => client(engine).connect('192.168.0.30', 80)], [2500, (engine) => client(engine).send('GET /text.txt HTTP/1.1\nHost: 192.168.0.30\n')], [4500, (engine) => client(engine).send('HALLO')], [7000, (engine) => client(engine).connect('192.168.0.30', 80)], [9000, (engine) => client(engine).send('GET /')], ], }; const IMAGE_TYPES = new Set(['png', 'gif', 'jpg', 'bmp']); /** The server's files as Java had them: text as it is, images decoded. */ function fileSystemOf(files: [string, string, string][]): VirtualFileSystem { const fileSystem = new VirtualFileSystem(); for (const [path, type, content] of files) { const parent = parentPath(path); if (parent !== '/' && !fileSystem.contains(parent)) fileSystem.createDirectory(parent, true); if (IMAGE_TYPES.has(type)) { fileSystem.writeImageFile(path, javaBase64Decode(content)!, `image/${type === 'jpg' ? 'jpeg' : type}`); } else { fileSystem.writeTextFile(path, content); } } return fileSystem; } type Lines = Record; function javaLines(java: JavaRun): Lines { const lines: Lines = { server: [], browser: [], client: [] }; const firstCall = Math.min(...java.events.filter(([, , text]) => text.startsWith('call ')).map(([time]) => time)); for (const [time, program, text] of java.events) { // Before the first call: Anwendung.starten's empty notice (null, or the browser's "update"). if (time < firstCall || text === 'null' || text.startsWith('call ') || text === 'no url') continue; // A page other than 200 carries Java's error page; the editor makes its own. 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): Lines { const lines = (log: readonly LogLine[] | undefined, text: (entry: E) => string | undefined): [number, string][] => (log ?? []).flatMap((line) => { const each = text(line); return each === undefined ? [] : [[line.timeMilliseconds, withClientPortText(each)] as [number, string]]; }); return { server: lines(engine.webServer(SERVER)!.log.all, serverText), browser: lines(browser(engine).log.all, browserText), client: lines(engine.simpleClient(BROWSER)?.log.all, clientText), }; } /** When the story first made the call. */ /** A frame's text with placeholders: free ports, and DNS message IDs (drawn at random in Java, from the seed in the engine). */ const frameText = (text: string) => withClientPortText(text).replace(/ID=\d+/g, 'ID=x'); const callTime = (java: JavaRun, call: string) => java.events.find(([, , text]) => text === `call ${call}`)![0]; /** * Where Java errs, per story: its run corrected, as Java means it. * * traversal: the server resolves "/../geheim.txt" below its document root to * /geheim.txt and serves it; any file of the host is open to every browser. * The engine's server answers 404. * * raw: "HALLO" (no request) makes HTTPNachricht throw; the server's thread * closes the connection and writes "null" (which its window skips). The * engine's server answers 501 Not Implemented — what WebServerMitarbeiter * means for a method it does not know — and stays connected. * * noServer: Java's browser sends one SYN, not three (tcpJava.test.ts, * closedPort); its page "server not reachable" comes two attempts later in * the engine. */ const JAVA_BUGS: Record JavaRun> = { noServer: (java) => ({ ...java, events: java.events.map(([time, who, text]) => (who === 'browser' && text.startsWith('page 0') ? [time + 2 * JAVA_TIMEOUT_MILLISECONDS, who, text] : [time, who, text])), }), traversal: (java) => ({ ...java, events: java.events.map(([time, who, text]) => who === 'browser' && text.startsWith('page 200') ? [time, who, 'page 404 null\n'] : who === 'server' && text.startsWith('< { const unreadable = java.events.findIndex(([, who, text]) => who === 'server' && text === '>>HALLO'); const [time] = java.events[unreadable]!; const answer = 'HTTP/1.1 501 Not Implemented\n'; return { ...java, events: [ ...java.events.slice(0, unreadable + 1), [time + 111, 'client', '<>${answer}`], [time + 279, 'server', `<<${answer}`], ], }; }, }; /** * Where a story is compared only up to the request whose answer Java gets * wrong (each card's frames up to it, the lines up to its arrival): after * it, the engine goes on beyond Java's error. traversal: the answer differs * (404, not the file), and with it every later sequence number. raw: after * "HALLO" Java's connection is gone, the engine's lives on. */ const UNTIL_REQUEST: Record = { traversal: 'GET /../geheim.txt', raw: 'HALLO' }; /** Java's tables of each card up to the frame that carries the request (with it). */ function tablesUpTo(java: JavaRun, text: string | undefined): JavaTables { if (text === undefined) return java; const upToRequest = (rows: Record | undefined) => Object.fromEntries( Object.entries(rows ?? {}).map(([card, cardRows]) => { const index = java.frames[card]!.findIndex((frame) => frame.payload?.startsWith(text) === true); return [card, index < 0 ? cardRows : cardRows.filter((row) => Number(row[0]) <= index + 1)]; }), ); return { rows: upToRequest(java.rows), layeredRows: upToRequest(java.layeredRows) }; } /** * Where a story is compared only up to a time (Java's). noServer: the * engine's second SYN, which Java does not send (the switch floods it to * every card). */ const UNTIL: Record number> = { noServer: (java) => java.events.find(([, who, text]) => who === 'browser' && text.startsWith('page 0'))![0] - 250, }; /** Where the lines are compared only up to a time (Java's). raw: the story's second connection, which the engine's client does not need. */ const LINES_UNTIL: Record number> = { raw: (java) => java.events.filter(([, , text]) => text === 'call verbinden')[1]![0], }; /** Each card's frames up to the one that carries the request in Java's list (with it); all where it does not show. */ function upTo(java: JavaRun, text: string | undefined) { return (frames: SeenFrame[], card: string) => { const index = text === undefined ? -1 : java.frames[card]!.findIndex((frame) => frame.payload?.startsWith(text) === true); return index < 0 ? frames : frames.slice(0, index + 1); }; } describe('the web server and the web browser as Java FILIUS runs them', () => { it('corrects Java only where it errs, and cuts only after the story began', () => { for (const [name, correct] of Object.entries(JAVA_BUGS)) { const java = JSON.parse(readFileSync(join(scenarioDirectory, `web-java-${name}.java.json`), 'utf8')) as JavaRun; expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java)); const firstCall = java.events.find(([, , text]) => text.startsWith('call '))![0]; for (const until of [UNTIL[name], LINES_UNTIL[name]]) if (until !== undefined) expect(until(java), name).toBeGreaterThan(firstCall + 500); } }); const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'web-lan.scenario.json'), 'utf8')) as Scenario; for (const [name, acts] of Object.entries(STORIES)) { it(name, () => { const measured = JSON.parse(readFileSync(join(scenarioDirectory, `web-java-${name}.java.json`), 'utf8')) as JavaRun; const java = JAVA_BUGS[name]?.(measured) ?? measured; 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, fileSystemOf(measured.files)]]), simpleClientsByNodeId: name === 'raw' ? new Map([[BROWSER, { targetAddress: '', targetPort: 80 }]]) : new Map(), }); const { engine } = watchedEngine(scenario, snapshot); for (const [time, act] of acts) { engine.handle({ type: 'advance', toMilliseconds: time }); act(engine); } engine.handle({ type: 'advance', toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 5_000 }); const until = UNTIL[name]?.(measured); expectSameCaptures(engine, scenario, java.frames, { text: frameText, normalized: upTo(measured, UNTIL_REQUEST[name]), until, tables: tablesUpTo(measured, UNTIL_REQUEST[name]), tolerance: TOLERANCE_MILLISECONDS, }); const linesUntil = LINES_UNTIL[name]?.(measured) ?? until; const before = (lines: [number, string][]) => lines.filter(([time]) => linesUntil === undefined || time < linesUntil); const expected = javaLines(java); const actual = engineLines(engine); for (const program of ['server', 'browser', 'client'] as const) { const expectedLines = before(expected[program]); const actualLines = before(actual[program]); expect(actualLines.map(([, text]) => text), `${program}'s lines`).toEqual(expectedLines.map(([, text]) => text)); for (const [index, [javaTime, text]] of expectedLines.entries()) { const late = javaTime - actualLines[index]![0]; const most = (isPolled(text) ? POLL_MILLISECONDS : 0) + TOLERANCE_MILLISECONDS; expect(late, `${program}'s line ${JSON.stringify(text.slice(0, 60))}: Java ${late} ms later`).toBeGreaterThanOrEqual(-TOLERANCE_MILLISECONDS); expect(late, `${program}'s line ${JSON.stringify(text.slice(0, 60))}: Java ${late} ms later`).toBeLessThanOrEqual(most); } } }); } });