The servers share ServerAnwendung's frame (server.ts); the web server serves /webserver with virtual hosts, the browser loads a page and its images over one connection to port 80 (web.ts); HTTP in Java's text form (http.ts). WebRun records eleven stories on the new web-lan network, with the server's files; webJava.test.ts plays them card by card. A program's close is a request, as Java's schliessen. Not taken from Java: /../ serving any file of the host; an unreadable request closing the connection without a word. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
306 lines
14 KiB
TypeScript
306 lines
14 KiB
TypeScript
// 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-<story>.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,
|
|
type WebBrowserLogEntry,
|
|
} from '../src/index.js';
|
|
import { expectSameCaptures, watchedEngine, withClientPortText, type JavaFrame, type JavaTables, type SeenFrame } from './javaRun.js';
|
|
import { 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<string, JavaFrame[]>;
|
|
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<string, [number, Act][]> = {
|
|
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;
|
|
}
|
|
|
|
/** A browser's line as its window took it: a page (with its text only for 200), or "update" for an image. */
|
|
function browserText(entry: WebBrowserLogEntry): string | undefined {
|
|
switch (entry.kind) {
|
|
case 'page':
|
|
return `page ${entry.statusCode} ${entry.contentType ?? 'null'}${entry.statusCode === 200 ? `\n${entry.body ?? 'null'}` : ''}`;
|
|
case 'unreachable':
|
|
return 'page 0 null';
|
|
case 'image': {
|
|
// WebBrowser.retrieveImages tells the window of an image that came, or of a status other than 200.
|
|
const isImage = entry.contentType !== undefined && IMAGE_TYPES.has(entry.contentType.toLowerCase().replace('image/', ''));
|
|
return entry.statusCode !== 200 || isImage ? 'update' : undefined;
|
|
}
|
|
}
|
|
}
|
|
|
|
type Lines = Record<Program, [number, string][]>;
|
|
|
|
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 = <E>(log: readonly LogLine<E>[] | 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<string, (java: JavaRun) => 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('<<HTTP/1.1 200')
|
|
? [time, who, '<<HTTP/1.1 404 Not Found\n']
|
|
: [time, who, text],
|
|
),
|
|
}),
|
|
raw: (java) => {
|
|
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', '<<HALLO'],
|
|
[time + 167, '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<string, string> = { 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<string, (string | null)[][]> | 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<string, (java: JavaRun) => 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<string, (java: JavaRun) => 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);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|