Files
julian.kiedaischandClaude Opus 5.5 aa54e5400e feat(editor): F4.6d the web browser and web server on a computer's desktop
The oracle BrowserWindowRun uses Java's browser and web server windows as a
person does; the room keeps a browser window per computer and sends a
program window's view, the start names the person's language. The editor
shows the page in a sandboxed frame, links and forms go to the room, the web
server has its button, virtual hosts and lines. Better than Java: relative
images and links count from the page's folder, a mark stays in the page, the
error page from Java's template, the virtual hosts' file with complete rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:09:05 +00:00

317 lines
18 KiB
TypeScript

// 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,
linkTarget,
pageReference,
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("a page's references", () => {
it('count from the page\'s folder, written as Java writes them for a page in the root (Java counts from the root)', () => {
expect(pageReference('/', 'bild.png')).toBe('bild.png');
expect(pageReference('/seite.html?x=1/2', 'unter/bild.png')).toBe('unter/bild.png');
expect(pageReference('/ordner/seite.html', 'bild.png')).toBe('ordner/bild.png');
expect(pageReference('/vhost1/', '../bild.png')).toBe('vhost1/../bild.png');
expect(pageReference('ohne/schraegstrich.html', 'a.png')).toBe('ohne/a.png');
expect(pageReference('/ordner/seite.html', '/bild.png')).toBe('/bild.png');
expect(pageReference('/ordner/seite.html', 'http://h/x.png')).toBe('http://h/x.png');
});
it('lead a link to the page\'s host, an http address to its own, and nowhere for a mark or another scheme', () => {
const page = { host: 'www.filius.de', path: '/ordner/seite.html' };
expect(linkTarget(page, 'vier.html')).toEqual({ host: 'www.filius.de', path: '/ordner/vier.html' });
expect(linkTarget(page, ' /text.txt ')).toEqual({ host: 'www.filius.de', path: '/text.txt' });
expect(linkTarget(page, 'ziel.html?a=1#oben')).toEqual({ host: 'www.filius.de', path: '/ordner/ziel.html?a=1' });
expect(linkTarget({ host: 'h', path: '/' }, 'a.html')).toEqual({ host: 'h', path: '/a.html' });
expect(linkTarget(page, 'HTTP://192.168.0.30/x/y#z')).toEqual({ host: '192.168.0.30', path: '/x/y' });
expect(linkTarget(page, 'http://kein host/')).toBeUndefined();
for (const href of ['', ' ', '#oben', 'mailto:anna@filius.de', 'javascript:alert(1)', 'ftp://h/']) expect(linkTarget(page, href), href).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('answers only GET and POST as Java writes them: a method in small letters is read, then answered 501 (WebServerMitarbeiter)', () => {
const engine = startedEngine(network(files({ '/webserver/index.html': 'Start' })));
expect(ask(engine, 'get /', 'Post /\n\nx=1')).toEqual(['HTTP/1.1 501 Not Implemented\n', 'HTTP/1.1 501 Not Implemented\n']);
});
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('switches its virtual hosts and writes their table with the complete rows, which it reads again as written', () => {
const fileSystem = files({ '/webserver/index.html': 'Start', '/webserver/v/index.html': 'V' });
const engine = startedEngine(network(fileSystem));
const server = engine.webServer(SERVER)!;
expect([server.useVirtualHosts, server.virtualHostTable]).toEqual([false, [['', ''], ['', ''], ['', ''], ['', ''], ['', '']]]);
server.setVirtualHost(0, 0, 'h.de');
// An incomplete row is not written: the folder is made, the file empty.
expect(fileSystem.textFile('/www.conf/vhosts')).toBe('');
server.setVirtualHost(0, 1, 'v');
server.setVirtualHost(2, 1, 'nur');
server.setVirtualHost(4, 0, 'z.de');
server.setVirtualHost(4, 1, 'w');
server.setVirtualHost(5, 0, 'nein.de');
expect(server.virtualHostTable).toEqual([['h.de', 'v'], ['', ''], ['', 'nur'], ['', ''], ['z.de', 'w']]);
expect(fileSystem.textFile('/www.conf/vhosts')).toBe('h.de\nv\nz.de\nw\n');
// Off, the table counts for nothing; on, from the next request.
expect(ask(engine, 'GET / HTTP/1.1\nHost: h.de')[0]!.endsWith('\n\nStart')).toBe(true);
server.setUseVirtualHosts(true);
expect(ask(engine, 'GET / HTTP/1.1\nHost: H.DE', 'GET / HTTP/1.1\nHost: x.de')[0]!.endsWith('\n\nV')).toBe(true);
const again = startedEngine(network(fileSystem)).webServer(SERVER)!;
expect([again.useVirtualHosts, again.virtualHostTable]).toEqual([true, [['h.de', 'v'], ['z.de', 'w'], ['', ''], ['', ''], ['', '']]]);
});
it('keeps its table where /www.conf is a file and the vhosts cannot be written', () => {
const fileSystem = files({ '/www.conf': 'kein Ordner' });
const server = startedEngine(network(fileSystem)).webServer(SERVER)!;
server.setVirtualHost(0, 0, 'h.de');
server.setVirtualHost(0, 1, 'v');
expect(server.virtualHostTable[0]).toEqual(['h.de', 'v']);
expect(fileSystem.textFile('/www.conf')).toBe('kein Ordner');
// Without files at all, the table holds too.
const bare = startedEngine({ ...network(new VirtualFileSystem()), fileSystemsByNodeId: new Map() }).webServer(SERVER)!;
bare.setVirtualHost(1, 1, 'x');
expect(bare.virtualHostTable[1]).toEqual(['', 'x']);
});
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("asks for a page's images from the page's folder and keeps them by their src, with the page's host and path", () => {
const fileSystem = files({ '/webserver/ordner/seite.html': '<img src="a.png"><img src="/b.png">' });
fileSystem.writeImageFile('/webserver/ordner/a.png', new Uint8Array([1]), 'image/png');
fileSystem.writeImageFile('/webserver/b.png', new Uint8Array([2]), 'image/png');
const engine = startedEngine(network(fileSystem));
const browser = engine.webBrowser(CLIENT)!;
browser.open('10.0.0.1/ordner/seite.html?x=1');
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
expect(browser.page).toMatchObject({ host: '10.0.0.1', path: '/ordner/seite.html?x=1' });
expect([...browser.page!.images]).toEqual([['a.png', new Uint8Array([1])], ['/b.png', new Uint8Array([2])]]);
const lines = browser.log.all.map(({ timeMilliseconds: _, ...line }) => line);
expect(lines).toEqual([
{ kind: 'page', host: '10.0.0.1', path: '/ordner/seite.html?x=1', statusCode: 200, contentType: 'text/html', body: '<img src="a.png"><img src="/b.png">' },
{ kind: 'image', source: 'a.png', statusCode: 200, contentType: 'image/png', data: new Uint8Array([1]) },
{ kind: 'image', source: '/b.png', statusCode: 200, contentType: 'image/png', data: new Uint8Array([2]) },
]);
});
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']);
});
});