feat(core): FILIUS' web server and web browser after Java, measured against Java
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>
This commit is contained in:
@@ -17,8 +17,9 @@ 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 EchoServerLogEntry, type LogLine, type NetworkRuntimeEngine, type SendFailure, type SimpleClientLogEntry } from '../src/index.js';
|
||||
import { withSnapshot, type LogLine, type NetworkRuntimeEngine } from '../src/index.js';
|
||||
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, watchedEngine, withClientPortText, type JavaFrame, type JavaTables } from './javaRun.js';
|
||||
import { clientText, CONNECT_FAILURES, isPolled, POLL_MILLISECONDS, serverText } from './programLines.js';
|
||||
import { snapshotOf, type Scenario } from './scenario.js';
|
||||
|
||||
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
||||
@@ -26,7 +27,6 @@ const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenari
|
||||
const SERVER = '00000000-0000-0000-0000-000000000101';
|
||||
const CLIENT = '00000000-0000-0000-0000-000000000102';
|
||||
const OTHER_CLIENT = '00000000-0000-0000-0000-000000000103';
|
||||
const POLL_MILLISECONDS = 100;
|
||||
|
||||
type Program = 'server' | 'client' | 'other';
|
||||
|
||||
@@ -36,70 +36,6 @@ interface JavaRun extends JavaTables {
|
||||
events: [number, Program, string][];
|
||||
}
|
||||
|
||||
/** sw_tcpsocket_msg6, msg7, msg8. */
|
||||
const SEND_FAILURES: Record<SendFailure, string> = {
|
||||
notConnected: 'Verbindung nicht aufgebaut!',
|
||||
connectionLost: 'Verbindung wurde unterbrochen',
|
||||
timedOut: 'Zeitüberschreitung während des Versands einer Nachricht',
|
||||
};
|
||||
|
||||
/** A server's line as Java's window shows it (German message bundle). */
|
||||
function serverText(entry: EchoServerLogEntry): string {
|
||||
switch (entry.kind) {
|
||||
case 'acceptingStarted':
|
||||
return 'Annahme von Verbindungsanfragen gestartet';
|
||||
case 'acceptingStopped':
|
||||
return 'Annahme von Verbindungsanfragen beendet';
|
||||
case 'serverSocketClosed':
|
||||
return 'Server-Socket geschlossen';
|
||||
case 'serverSocketFailed':
|
||||
return 'Server-Socket konnte nicht geöffnet werden.';
|
||||
case 'connectionAccepted':
|
||||
return `Verbindung zu ${entry.address}:${entry.port} hergestellt`;
|
||||
case 'received':
|
||||
return `>>${entry.text}`;
|
||||
case 'sent':
|
||||
return `<<${entry.text}`;
|
||||
case 'sendFailed':
|
||||
return SEND_FAILURES[entry.failure];
|
||||
case 'connectionClosed':
|
||||
return `Socket zu ${entry.address}:${entry.port} geschlossen`;
|
||||
}
|
||||
}
|
||||
|
||||
const CONNECT_FAILURES = {
|
||||
unresolved: 'Ziel konnte nicht bestimmt werden!',
|
||||
timedOut: 'Zeitüberschreitung beim Verbindungsaufbau',
|
||||
failed: 'Aktives Öffnen des Sockets fehlgeschlagen',
|
||||
};
|
||||
|
||||
/** A client's line as Java's window shows it. */
|
||||
function clientText(entry: SimpleClientLogEntry): string {
|
||||
switch (entry.kind) {
|
||||
case 'connected':
|
||||
return 'Verbindung hergestellt';
|
||||
case 'connectFailed':
|
||||
return `Fehler beim Verbindungsaufbau\n${CONNECT_FAILURES[entry.failure]}`;
|
||||
case 'disconnected':
|
||||
return 'Verbindung getrennt';
|
||||
case 'notConnected':
|
||||
return 'Fehler beim Verbindungsaufbau';
|
||||
case 'sent':
|
||||
return `<<${entry.text}`;
|
||||
case 'received':
|
||||
return `>>${entry.text}`;
|
||||
case 'sendFailed':
|
||||
return SEND_FAILURES[entry.failure];
|
||||
case 'connectionClosed':
|
||||
return `Socket zu ${entry.address}:${entry.port} getrennt`;
|
||||
case 'receiveFailed':
|
||||
return 'Keine Verbindung hergestellt!';
|
||||
}
|
||||
}
|
||||
|
||||
/** Lines Java writes after one of its 100 ms looks. */
|
||||
const POLLED = new Set(['Verbindung hergestellt', 'Server-Socket geschlossen']);
|
||||
const isPolled = (text: string) => POLLED.has(text) || /^Verbindung zu .* hergestellt$/.test(text);
|
||||
|
||||
type Act = (engine: NetworkRuntimeEngine) => void;
|
||||
const server = (engine: NetworkRuntimeEngine) => engine.echoServer(SERVER)!;
|
||||
|
||||
@@ -197,7 +197,7 @@ export function expectSameCaptures(
|
||||
java: Record<string, JavaFrame[]>,
|
||||
options: {
|
||||
text?: (text: string) => string;
|
||||
normalized?: (frames: SeenFrame[]) => SeenFrame[];
|
||||
normalized?: (frames: SeenFrame[], card: string) => SeenFrame[];
|
||||
from?: number | undefined;
|
||||
until?: number | undefined;
|
||||
tolerance?: number;
|
||||
@@ -223,7 +223,7 @@ export function expectSameCaptures(
|
||||
const javaStart = options.starts?.java ?? first(javaByCard);
|
||||
const from = (options.from ?? Number.NEGATIVE_INFINITY) - javaStart;
|
||||
const until = (options.until ?? Number.POSITIVE_INFINITY) - javaStart;
|
||||
const normalized = options.normalized ?? ((frames: SeenFrame[]) => frames);
|
||||
const normalized = options.normalized ?? ((frames: SeenFrame[], _card: string) => frames);
|
||||
const window = (frames: SeenFrame[], start: number) =>
|
||||
frames.filter((frame) => frame.time - start >= from && frame.time - start < until).map((frame) => ({ time: frame.time - start, text: frame.text }));
|
||||
const inWindow = (time: number, start: number) => time - start >= from && time - start < until;
|
||||
@@ -249,8 +249,8 @@ export function expectSameCaptures(
|
||||
}
|
||||
}
|
||||
}
|
||||
const expected = normalized(window(javaList, javaStart));
|
||||
const actual = normalized(window(engineByCard.get(card)!, engineStart));
|
||||
const expected = normalized(window(javaList, javaStart), card);
|
||||
const actual = normalized(window(engineByCard.get(card)!, engineStart), card);
|
||||
for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
|
||||
expect(actual[index]?.text, `${card} frame #${index + 1}`).toBe(expected[index]?.text);
|
||||
expect(Math.abs(actual[index]!.time - expected[index]!.time), `${card} time of frame #${index + 1}`).toBeLessThanOrEqual(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// The lines FILIUS' programs write into their windows, as Java's German
|
||||
// message bundle says them: for comparing the core's structured lines with
|
||||
// what the Java oracle recorded (echoJava.test.ts, webJava.test.ts).
|
||||
|
||||
import type { EchoServerLogEntry, SendFailure, SimpleClientLogEntry } from '../src/index.js';
|
||||
|
||||
/** Java's client and server look every 100 ms whether they are connected, or switched off; the core at once. */
|
||||
export const POLL_MILLISECONDS = 100;
|
||||
|
||||
/** Lines Java writes after one of its 100 ms looks. */
|
||||
const POLLED = new Set(['Verbindung hergestellt', 'Server-Socket geschlossen']);
|
||||
export const isPolled = (text: string) => POLLED.has(text) || /^Verbindung zu .* hergestellt$/.test(text);
|
||||
|
||||
/** sw_tcpsocket_msg6, msg7, msg8. */
|
||||
export const SEND_FAILURES: Record<SendFailure, string> = {
|
||||
notConnected: 'Verbindung nicht aufgebaut!',
|
||||
connectionLost: 'Verbindung wurde unterbrochen',
|
||||
timedOut: 'Zeitüberschreitung während des Versands einer Nachricht',
|
||||
};
|
||||
|
||||
/** A server's line as Java's window shows it (German message bundle). */
|
||||
export function serverText(entry: EchoServerLogEntry): string {
|
||||
switch (entry.kind) {
|
||||
case 'acceptingStarted':
|
||||
return 'Annahme von Verbindungsanfragen gestartet';
|
||||
case 'acceptingStopped':
|
||||
return 'Annahme von Verbindungsanfragen beendet';
|
||||
case 'serverSocketClosed':
|
||||
return 'Server-Socket geschlossen';
|
||||
case 'serverSocketFailed':
|
||||
return 'Server-Socket konnte nicht geöffnet werden.';
|
||||
case 'connectionAccepted':
|
||||
return `Verbindung zu ${entry.address}:${entry.port} hergestellt`;
|
||||
case 'received':
|
||||
return `>>${entry.text}`;
|
||||
case 'sent':
|
||||
return `<<${entry.text}`;
|
||||
case 'sendFailed':
|
||||
return SEND_FAILURES[entry.failure];
|
||||
case 'connectionClosed':
|
||||
return `Socket zu ${entry.address}:${entry.port} geschlossen`;
|
||||
}
|
||||
}
|
||||
|
||||
export const CONNECT_FAILURES = {
|
||||
unresolved: 'Ziel konnte nicht bestimmt werden!',
|
||||
timedOut: 'Zeitüberschreitung beim Verbindungsaufbau',
|
||||
failed: 'Aktives Öffnen des Sockets fehlgeschlagen',
|
||||
};
|
||||
|
||||
/** A client's line as Java's window shows it. */
|
||||
export function clientText(entry: SimpleClientLogEntry): string {
|
||||
switch (entry.kind) {
|
||||
case 'connected':
|
||||
return 'Verbindung hergestellt';
|
||||
case 'connectFailed':
|
||||
return `Fehler beim Verbindungsaufbau\n${CONNECT_FAILURES[entry.failure]}`;
|
||||
case 'disconnected':
|
||||
return 'Verbindung getrennt';
|
||||
case 'notConnected':
|
||||
return 'Fehler beim Verbindungsaufbau';
|
||||
case 'sent':
|
||||
return `<<${entry.text}`;
|
||||
case 'received':
|
||||
return `>>${entry.text}`;
|
||||
case 'sendFailed':
|
||||
return SEND_FAILURES[entry.failure];
|
||||
case 'connectionClosed':
|
||||
return `Socket zu ${entry.address}:${entry.port} getrennt`;
|
||||
case 'receiveFailed':
|
||||
return 'Keine Verbindung hergestellt!';
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"seed": 61,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000201",
|
||||
"kind": "pc",
|
||||
"ports": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000211",
|
||||
"label": "eth0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000202",
|
||||
"kind": "pc",
|
||||
"ports": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000212",
|
||||
"label": "eth0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000203",
|
||||
"kind": "pc",
|
||||
"ports": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000213",
|
||||
"label": "eth0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000204",
|
||||
"kind": "pc",
|
||||
"ports": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000214",
|
||||
"label": "eth0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000205",
|
||||
"kind": "networkSwitch",
|
||||
"ports": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000221",
|
||||
"label": "sw1"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000222",
|
||||
"label": "sw2"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000223",
|
||||
"label": "sw3"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000224",
|
||||
"label": "sw4"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-00000000F201",
|
||||
"sourceNode": "00000000-0000-0000-0000-000000000201",
|
||||
"sourcePort": "00000000-0000-0000-0000-000000000211",
|
||||
"targetNode": "00000000-0000-0000-0000-000000000205",
|
||||
"targetPort": "00000000-0000-0000-0000-000000000221"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-00000000F202",
|
||||
"sourceNode": "00000000-0000-0000-0000-000000000202",
|
||||
"sourcePort": "00000000-0000-0000-0000-000000000212",
|
||||
"targetNode": "00000000-0000-0000-0000-000000000205",
|
||||
"targetPort": "00000000-0000-0000-0000-000000000222"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-00000000F203",
|
||||
"sourceNode": "00000000-0000-0000-0000-000000000203",
|
||||
"sourcePort": "00000000-0000-0000-0000-000000000213",
|
||||
"targetNode": "00000000-0000-0000-0000-000000000205",
|
||||
"targetPort": "00000000-0000-0000-0000-000000000223"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-00000000F204",
|
||||
"sourceNode": "00000000-0000-0000-0000-000000000204",
|
||||
"sourcePort": "00000000-0000-0000-0000-000000000214",
|
||||
"targetNode": "00000000-0000-0000-0000-000000000205",
|
||||
"targetPort": "00000000-0000-0000-0000-000000000224"
|
||||
}
|
||||
],
|
||||
"devices": {
|
||||
"00000000-0000-0000-0000-000000000201": {
|
||||
"ip": "192.168.0.20",
|
||||
"mask": "255.255.255.0",
|
||||
"dns": "192.168.0.10"
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000202": {
|
||||
"ip": "192.168.0.10",
|
||||
"mask": "255.255.255.0"
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000203": {
|
||||
"ip": "192.168.0.30",
|
||||
"mask": "255.255.255.0"
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000204": {
|
||||
"ip": "192.168.0.31",
|
||||
"mask": "255.255.255.0"
|
||||
}
|
||||
},
|
||||
"dnsServers": {
|
||||
"00000000-0000-0000-0000-000000000202": {
|
||||
"records": [
|
||||
"www.filius.de A 192.168.0.30",
|
||||
"seite.filius.de A 192.168.0.30",
|
||||
"andere.filius.de A 192.168.0.30",
|
||||
"leer.filius.de A 192.168.0.31"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// HTTP as FILIUS writes it (http.ts), and the web server and browser
|
||||
// (apps/web.ts) where webJava.test.ts's stories do not go.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
decodeUriPath,
|
||||
deviceConfiguration,
|
||||
encodeUriPath,
|
||||
formatHttpRequest,
|
||||
formatHttpResponse,
|
||||
imageSources,
|
||||
javaBase64,
|
||||
javaBase64Decode,
|
||||
parseHttpRequest,
|
||||
parseHttpResponse,
|
||||
VirtualFileSystem,
|
||||
webBrowserAddress,
|
||||
type NetworkRuntimeEngine,
|
||||
type TopologySnapshotInput,
|
||||
} from '../src/index.js';
|
||||
import { link, pc, startedEngine, uuid } from './fixtures.js';
|
||||
|
||||
describe('HTTP as FILIUS writes it', () => {
|
||||
it('writes a request with the path encoded and without its query, and a POST with its data', () => {
|
||||
expect(formatHttpRequest({ method: 'GET', path: '/mein bild.html?x=1', host: 'www.filius.de' })).toBe('GET /mein%20bild.html HTTP/1.1\nHost: www.filius.de\n');
|
||||
expect(formatHttpRequest({ method: 'POST', path: '', host: undefined, data: 'a=b' })).toBe('POST / HTTP/1.1\n\na=b');
|
||||
});
|
||||
|
||||
it('reads a request: method in any case, Host header, POST data after the empty line', () => {
|
||||
expect(parseHttpRequest('post /form%C3%BC HTTP/1.0\nhost: a.de\n\nx=1\ny=2')).toEqual({
|
||||
type: 'request',
|
||||
request: { method: 'post', path: '/formü', protocolVersion: 'HTTP/1.0', host: 'a.de', data: '\nx=1\ny=2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('answers what it cannot read with 400 or 501, and takes a request without a version as HTTP/1.1 (Java: the thread dies)', () => {
|
||||
expect(parseHttpRequest('')).toEqual({ type: 'invalid', statusCode: 400 });
|
||||
expect(parseHttpRequest('GET')).toEqual({ type: 'invalid', statusCode: 400 });
|
||||
expect(parseHttpRequest('HALLO Welt')).toEqual({ type: 'invalid', statusCode: 501 });
|
||||
expect(parseHttpRequest('GET /%zz HTTP/1.1')).toEqual({ type: 'invalid', statusCode: 400 });
|
||||
expect(parseHttpRequest('GET /')).toMatchObject({ type: 'request', request: { path: '/', protocolVersion: 'HTTP/1.1' } });
|
||||
});
|
||||
|
||||
it('writes and reads a response', () => {
|
||||
const text = formatHttpResponse({ protocolVersion: 'HTTP/1.1', statusCode: 200, contentType: 'text/html', body: '<p>\n</p>' });
|
||||
expect(text).toBe('HTTP/1.1 200 OK\nContent-type: text/html\n\n<p>\n</p>');
|
||||
expect(parseHttpResponse(text)).toEqual({ protocolVersion: 'HTTP/1.1', statusCode: 200, contentType: 'text/html', body: '<p>\n</p>' });
|
||||
expect(parseHttpResponse('HTTP/1.1 404 Not Found\n')).toEqual({ protocolVersion: 'HTTP/1.1', statusCode: 404, contentType: undefined, body: undefined });
|
||||
expect(parseHttpResponse('HTTP/1.1 200 OK\ncontent-TYPE: image/png\n\nx')).toMatchObject({ contentType: 'image/png', body: 'x' });
|
||||
expect(parseHttpResponse('Hallo Welt')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('encodes a path as Spring UriUtils.encodePath and decodes it back', () => {
|
||||
expect(encodeUriPath("/a b/ü?#%[]-._~!$&'()*+,;=:@")).toBe("/a%20b/%C3%BC%3F%23%25%5B%5D-._~!$&'()*+,;=:@");
|
||||
expect(decodeUriPath('/a%20b/%C3%BC+')).toBe('/a b/ü+');
|
||||
expect(decodeUriPath('/50%')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads and writes Base64 as FILIUS does, line breaks and all', () => {
|
||||
const bytes = new Uint8Array(100).map((_, index) => index * 7);
|
||||
expect(javaBase64Decode(javaBase64(bytes))).toEqual(bytes);
|
||||
expect(javaBase64Decode('QUJD\nRA==')).toEqual(new TextEncoder().encode('ABCD'));
|
||||
expect(javaBase64Decode('QUJ')).toBeUndefined();
|
||||
expect(javaBase64Decode('QU=D')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the browser's address line (erzeugeURL)", () => {
|
||||
it('takes a host with or without http://, the path without a final slash', () => {
|
||||
expect(webBrowserAddress('www.filius.de')).toEqual({ host: 'www.filius.de', path: '/' });
|
||||
expect(webBrowserAddress('HTTP://192.168.0.30/a/b/')).toEqual({ host: '192.168.0.30', path: '/a/b' });
|
||||
expect(webBrowserAddress('http:///text.txt')).toEqual({ host: '', path: '/text.txt' });
|
||||
expect(webBrowserAddress('/text.txt')).toEqual({ host: '', path: '/text.txt' });
|
||||
});
|
||||
|
||||
it('is no address for what Java does not take as a host', () => {
|
||||
expect(webBrowserAddress('www.filius.de:8080/')).toBeUndefined();
|
||||
expect(webBrowserAddress('1fehler.de')).toBeUndefined();
|
||||
expect(webBrowserAddress('http:')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the images of a page', () => {
|
||||
it('finds each img tag, in any case, its src in any quotes', () => {
|
||||
expect(imageSources('<img src="a.png"><IMG SRC=\'b.png\'> <img alt="x" src=c/d.png><img\nsrc = "e.png" /><imgx src="no.png"><img alt="no src">')).toEqual([
|
||||
'a.png',
|
||||
'b.png',
|
||||
'c/d.png',
|
||||
'e.png',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const SERVER = uuid(0x3001);
|
||||
const CLIENT = uuid(0x3002);
|
||||
|
||||
function network(files: VirtualFileSystem, changes: Partial<TopologySnapshotInput> = {}): TopologySnapshotInput {
|
||||
return {
|
||||
nodes: [pc(SERVER, uuid(0x3011)), pc(CLIENT, uuid(0x3012))],
|
||||
links: [link(SERVER, uuid(0x3011), CLIENT, uuid(0x3012))],
|
||||
deviceConfigurations: new Map([
|
||||
[SERVER, deviceConfiguration('10.0.0.1', '255.255.255.0', '', '')],
|
||||
[CLIENT, deviceConfiguration('10.0.0.2', '255.255.255.0', '', '')],
|
||||
]),
|
||||
webServersByNodeId: new Map([[SERVER, { port: 80, documentRoot: '/webserver', virtualHosts: false, isActive: true }]]),
|
||||
webBrowserNodeIds: new Set([CLIENT]),
|
||||
fileSystemsByNodeId: new Map([[SERVER, files]]),
|
||||
...changes,
|
||||
};
|
||||
}
|
||||
|
||||
function files(entries: Record<string, string>): VirtualFileSystem {
|
||||
const fileSystem = new VirtualFileSystem();
|
||||
fileSystem.createDirectory('/webserver', true);
|
||||
for (const [path, text] of Object.entries(entries)) {
|
||||
const parent = path.slice(0, path.lastIndexOf('/'));
|
||||
if (parent !== '' && !fileSystem.contains(parent)) fileSystem.createDirectory(parent, true);
|
||||
fileSystem.writeTextFile(path, text);
|
||||
}
|
||||
return fileSystem;
|
||||
}
|
||||
|
||||
/** The server's answers to the requests, one after the other over one connection. */
|
||||
function ask(engine: NetworkRuntimeEngine, ...requests: string[]): string[] {
|
||||
const socket = engine.openTcpClientSocket(CLIENT, '10.0.0.1', 80)!;
|
||||
expect(engine.connectTcpWithResult(socket)).toBe('connected');
|
||||
return requests.map((request) => {
|
||||
expect(engine.sendTcp(socket, new TextEncoder().encode(request))).toBe(true);
|
||||
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 2_000 });
|
||||
return new TextDecoder().decode(engine.receiveTcp(socket) ?? new Uint8Array());
|
||||
});
|
||||
}
|
||||
|
||||
describe('the web server', () => {
|
||||
it('answers 400 and 501 and stays connected, where Java closes the connection', () => {
|
||||
const engine = startedEngine(network(files({ '/webserver/index.html': 'Start' })));
|
||||
expect(ask(engine, 'HALLO', 'GET', 'GET /')).toEqual(['HTTP/1.1 501 Not Implemented\n', 'HTTP/1.1 400 Bad Request\n', 'HTTP/1.1 200 OK\nContent-type: text/html\n\nStart']);
|
||||
});
|
||||
|
||||
it('serves nothing outside its document root (Java serves any file of the host), and no folder', () => {
|
||||
const engine = startedEngine(network(files({ '/geheim.txt': 'geheim', '/webserver/ordner/a.txt': 'a' })));
|
||||
expect(ask(engine, 'GET /../geheim.txt HTTP/1.1', 'GET /ordner/../../geheim.txt HTTP/1.1', 'GET /ordner HTTP/1.1', 'GET /ordner/./a.txt?x HTTP/1.1')).toEqual([
|
||||
'HTTP/1.1 404 Not Found\n',
|
||||
'HTTP/1.1 404 Not Found\n',
|
||||
'HTTP/1.1 404 Not Found\n',
|
||||
'HTTP/1.1 200 OK\nContent-type: text/plain\n\na',
|
||||
]);
|
||||
});
|
||||
|
||||
it('names the content by the file name: htm, bmp, gif, jpg; jpeg is plain text, as in Java', () => {
|
||||
const fileSystem = files({ '/webserver/a.HTM': 'a' });
|
||||
const image = new Uint8Array([1, 2, 3, 4]);
|
||||
for (const name of ['b.bmp', 'c.gif', 'd.jpg', 'e.jpeg']) fileSystem.writeImageFile(`/webserver/${name}`, image, 'image/png');
|
||||
fileSystem.writeBinaryFile('/webserver/f.bin', image);
|
||||
const engine = startedEngine(network(fileSystem));
|
||||
const types = ask(engine, ...['a.HTM', 'b.bmp', 'c.gif', 'd.jpg', 'e.jpeg', 'f.bin'].map((name) => `GET /${name} HTTP/1.1`)).map(
|
||||
(answer) => answer.split('\n')[1],
|
||||
);
|
||||
expect(types).toEqual(['Content-type: text/html', 'Content-type: image/bmp', 'Content-type: image/gif', 'Content-type: image/jpg', 'Content-type: text/plain', 'Content-type: text/plain']);
|
||||
expect(ask(engine, 'GET /f.bin HTTP/1.1')[0]!.endsWith(`\n\n${javaBase64(image)}`)).toBe(true);
|
||||
});
|
||||
|
||||
it('serves by host name only from the first five rows of www.conf/vhosts, ignoring case, and from its configuration', () => {
|
||||
const vhosts = Array.from({ length: 6 }, (_, index) => `h${index}.de\nv${index}`).join('\n');
|
||||
const entries = Object.fromEntries(Array.from({ length: 6 }, (_, index) => [`/webserver/v${index}/index.html`, `V${index}`]));
|
||||
const engine = startedEngine(network(files({ ...entries, '/webserver/index.html': 'Start', '/www.conf/vhosts': vhosts })));
|
||||
const pages = ask(engine, 'GET / HTTP/1.1\nHost: H4.DE', 'GET / HTTP/1.1\nHost: h5.de', 'GET / HTTP/1.1').map((answer) => answer.split('\n\n')[1]);
|
||||
expect(pages).toEqual(['V4', 'Start', 'Start']);
|
||||
|
||||
// Without the file, the table is empty: the configuration's switch alone serves the root.
|
||||
const plain = startedEngine(
|
||||
network(files({ '/webserver/index.html': 'Start' }), {
|
||||
webServersByNodeId: new Map([[SERVER, { port: 80, documentRoot: '/webserver', virtualHosts: true, isActive: true }]]),
|
||||
}),
|
||||
);
|
||||
expect(ask(plain, 'GET / HTTP/1.1\nHost: h0.de')[0]).toBe('HTTP/1.1 200 OK\nContent-type: text/html\n\nStart');
|
||||
});
|
||||
|
||||
it('serves nothing without files', () => {
|
||||
const engine = startedEngine({ ...network(new VirtualFileSystem()), fileSystemsByNodeId: new Map() });
|
||||
expect(ask(engine, 'GET / HTTP/1.1')).toEqual(['HTTP/1.1 404 Not Found\n']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the web browser', () => {
|
||||
it('keeps the page and its images, and goes to the last host for a link without one', () => {
|
||||
const fileSystem = files({ '/webserver/index.html': '<img src="a.png"><img src="b.png">', '/webserver/seite.html': 'Seite' });
|
||||
fileSystem.writeImageFile('/webserver/a.png', new Uint8Array([9, 8, 7]), 'image/png');
|
||||
const engine = startedEngine(network(fileSystem));
|
||||
const browser = engine.webBrowser(CLIENT)!;
|
||||
expect(browser.open('10.0.0.1')).toBe(true);
|
||||
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
||||
expect(browser.page).toMatchObject({ statusCode: 200, body: '<img src="a.png"><img src="b.png">' });
|
||||
expect([...browser.page!.images]).toEqual([['a.png', new Uint8Array([9, 8, 7])]]);
|
||||
expect(browser.log.all.filter((line) => line.kind === 'image').map((line) => line.kind === 'image' && line.statusCode)).toEqual([200, 404]);
|
||||
|
||||
browser.load('', '/seite.html');
|
||||
engine.handle({ type: 'advance', toMilliseconds: 20_000 });
|
||||
expect(browser.host).toBe('10.0.0.1');
|
||||
expect(browser.page).toMatchObject({ statusCode: 200, body: 'Seite' });
|
||||
expect(browser.page!.images.size).toBe(0);
|
||||
});
|
||||
|
||||
it('fetches images only for an HTML page', () => {
|
||||
const engine = startedEngine(network(files({ '/webserver/text.txt': '<img src="a.png">' })));
|
||||
const browser = engine.webBrowser(CLIENT)!;
|
||||
browser.open('10.0.0.1/text.txt');
|
||||
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
||||
expect(browser.log.all.map((line) => line.kind)).toEqual(['page']);
|
||||
});
|
||||
|
||||
it('does nothing for what is no address', () => {
|
||||
const engine = startedEngine(network(files({})));
|
||||
expect(engine.webBrowser(CLIENT)!.open('kein host:80')).toBe(false);
|
||||
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
|
||||
expect(engine.webBrowser(CLIENT)!.log.all).toEqual([]);
|
||||
expect(engine.capture.frames(uuid(0x3012))).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows "server not reachable" when the server closes without an answer (Java shows nothing)', () => {
|
||||
const engine = startedEngine(network(files({}), { webServersByNodeId: new Map() }));
|
||||
const listener = engine.openTcpServerSocket(SERVER, 80)!;
|
||||
const browser = engine.webBrowser(CLIENT)!;
|
||||
browser.open('10.0.0.1');
|
||||
for (let time = 100; time < 20_000; time += 100) {
|
||||
engine.handle({ type: 'advance', toMilliseconds: time });
|
||||
const accepted = engine.state.tcpAcceptedSocketIdsByListenerId.get(listener)?.[0];
|
||||
if (accepted !== undefined && engine.state.socketsById.get(accepted)?.tcpState === 'ESTABLISHED') engine.closeTcpSocket(accepted);
|
||||
}
|
||||
expect(browser.log.all.map((line) => line.kind)).toEqual(['unreachable']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user