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>
This commit is contained in:
2026-10-03 16:09:05 +00:00
co-authored by Claude Opus 5.5
parent f0e7c73ec7
commit aa54e5400e
31 changed files with 2773 additions and 55 deletions
+343
View File
@@ -0,0 +1,343 @@
// The room's browser window and the web server's window against Java's
// (tools/java-oracle BrowserWindowRun) on web-lan's network: the same
// addresses typed, links followed and form buttons pressed, the same address
// line, title, page and requests after each; the server's start button, its
// virtual hosts' check box and table. Named where the window is better than
// Java's:
// - an HTTP error shows the browser's error page for its status (Java's
// never finds its template and shows "Fehler"),
// - a page's relative image counts from the page's folder (Java's from the
// server's root),
// - a mark in the page (#oben) stays in the page (Java loads the start page),
// - the virtual hosts' file holds the complete rows only (Java's holds
// every row and, read again, moves a cell of an incomplete row into the
// row before).
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { httpStatusText, installationFiles, NetworkRuntimeEngine, VirtualFileSystem, withSnapshot, type WebServer } from '@mdg-filius/core';
import { BROWSER_START, browserTitle, htmlTitle, type BrowserPage } from '@mdg-filius/protocol';
import { snapshotOf, type Scenario } from '../../core/test/scenario.js';
import { BrowserWindow, browserPage } from '../src/browserWindow.js';
interface JavaBrowserStep {
readonly step: string;
readonly address: string;
readonly title: string;
readonly pages: readonly string[];
readonly requests: readonly string[];
}
interface JavaServerStep {
readonly step: string;
readonly button: string;
readonly virtualHosts: boolean;
readonly serverVirtualHosts: boolean;
readonly table: readonly (readonly [string, string])[];
readonly vhostsFile: string | null;
readonly log: string;
}
const here = dirname(fileURLToPath(import.meta.url));
const java = JSON.parse(readFileSync(join(here, 'browserWindow.java.json'), 'utf8')) as { readonly steps: readonly (JavaBrowserStep | JavaServerStep)[] };
const scenario = JSON.parse(readFileSync(join(here, '../../core/test/scenarios/web-lan.scenario.json'), 'utf8')) as Scenario;
const BROWSER = '00000000-0000-0000-0000-000000000201';
const SERVER = '00000000-0000-0000-0000-000000000203';
/** The pages BrowserWindowRun gives the server, besides those it brings. */
const PAGES: Record<string, string> = {
'/webserver/links.html':
'<html><head><title>Links &amp; mehr</title></head><body>\n' +
'<a href="ziel.html">rel</a> <a href="/text.txt">abs</a> <a href="http://192.168.0.30/">host</a>\n' +
'<a href="ordner/drei.html">folder</a> <a href="ziel.html?a=1&amp;b=2">query</a> <a href="#oben">anchor</a>\n' +
'<form action="ziel.html" method="get"><input name="q" value="a b"><input type="submit" name="los" value="Los"></form>\n' +
'<form action="ziel.html" method="post"><input name="name" value="Fil ius"><input type="checkbox" name="c" value="1" checked>' +
'<input type="checkbox" name="d" value="1"><select name="s"><option>x</option><option selected value="y">Y</option></select>' +
'<textarea name="t">&uuml;&amp;=</textarea><input type="submit" name="ok" value="Gut"></form>\n' +
'</body></html>',
'/webserver/ziel.html': '<html><head><title>Ziel</title></head><body>Ziel</body></html>',
'/webserver/ohne.html': '<html><body>Ohne Titel</body></html>',
'/webserver/text.txt': 'Nur Text\nzwei Zeilen',
'/webserver/ordner/drei.html': '<html><head><title>Drei</title></head><body><a href="vier.html">vier</a><img src="bild.png"></body></html>',
'/webserver/ordner/vier.html': '<html><body>Vier im Ordner</body></html>',
'/webserver/vier.html': '<html><body>Vier oben</body></html>',
};
/** What the form buttons send, as Java's window sends it (and the editor's panel, browserPanel.test.ts). */
const FORMS: Record<string, readonly [string, string?]> = {
Los: ['ziel.html?q=a+b&los=Los'],
Gut: ['ziel.html', 'name=Fil+ius&c=1&s=y&t=%C3%BC%26%3D&ok=Gut'],
};
function serverFiles(): VirtualFileSystem {
const files = new VirtualFileSystem();
for (const file of installationFiles('webServer', 'de')) {
if (file.content.kind === 'directory') files.createDirectory(file.path);
else if (file.content.kind === 'text') files.writeTextFile(file.path, file.content.text);
else if (file.content.kind === 'image') files.writeImageFile(file.path, file.content.data, file.content.mediaType);
}
files.writeImageFile('/webserver/bild.png', files.imageFile('/webserver/splashscreen-mini.png').data, 'image/png');
files.createDirectory('/webserver/ordner');
for (const [path, text] of Object.entries(PAGES)) files.writeTextFile(path, text);
return files;
}
function setUp(files: VirtualFileSystem = serverFiles()) {
const engine = new NetworkRuntimeEngine(scenario.seed);
const snapshot = withSnapshot(snapshotOf(scenario), {
webServersByNodeId: new Map([[SERVER, { port: 80, documentRoot: '/webserver', virtualHosts: false, isActive: false }]]),
webBrowserNodeIds: new Set([BROWSER]),
fileSystemsByNodeId: new Map([[SERVER, files]]),
});
engine.handle({ type: 'start', snapshot, seed: scenario.seed, initialTimeMilliseconds: 0 });
const server = engine.webServer(SERVER)!;
const window = new BrowserWindow(engine.webBrowser(BROWSER)!);
let requestsSeen = 0;
/** The requests the server got since the last call (its ">>" lines). */
const requests = () => {
const received = server.log.all.filter((line) => line.kind === 'received').map((line) => (line.kind === 'received' ? line.text : ''));
const fresh = received.slice(requestsSeen);
requestsSeen = received.length;
return fresh;
};
/** As the oracle waits: until a page came and nothing happened for 2.5 s, at most 25 s. */
const settle = () => {
const browserLog = engine.webBrowser(BROWSER)!.log;
const start = browserLog.all.length;
const until = engine.state.currentTimeMilliseconds + 25_000;
let known = -1;
let quiet = engine.state.currentTimeMilliseconds;
while (engine.state.currentTimeMilliseconds < until) {
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 100 });
const count = browserLog.all.length + server.log.all.length;
const page = browserLog.all.slice(start).some((line) => line.kind !== 'image');
if (count !== known) {
known = count;
quiet = engine.state.currentTimeMilliseconds;
} else if (page && engine.state.currentTimeMilliseconds - quiet > 2_500) {
break;
}
}
window.pull();
};
return { engine, server, window, requests, settle };
}
/** The title as the editor's German window shows it, an HTML page's as written (Java's way; the editor decodes it). */
const pageTitle = (page: BrowserPage) =>
page.kind === 'html' ? htmlTitle(page.html) : page.kind === 'unreachable' ? 'Fehler' : page.kind === 'error' ? `Fehler ${httpStatusText(page.statusCode)} (${page.statusCode})` : undefined;
/** Java's last page of a step, as the window's page. */
function javaPage(pages: readonly string[]): BrowserPage | undefined {
const page = pages.filter((each) => each !== 'update').at(-1);
if (page === undefined) return undefined;
const [, status, type, body] = /^(\d+) (\S+)\n([\s\S]*)$/.exec(page)!;
if (status === '0') return { kind: 'unreachable' };
if (status !== '200') return { kind: 'error', statusCode: Number(status) };
return type === 'text/html' ? { kind: 'html', html: body!, images: {} } : { kind: 'text', text: body! };
}
/** Where Java errs, per step: its record corrected, as the window does it. */
const JAVA_BUGS: Record<string, (step: JavaBrowserStep) => JavaBrowserStep> = {
// Java's error page never finds its template: its title is "Fehler".
'type:http://www.filius.de/nichts.html': (step) => ({ ...step, title: 'Fehler Not Found (404)' }),
// The image beside the page in its folder, which is not there (Java asks for the root's).
'follow:ordner/drei.html': (step) => ({ ...step, pages: step.pages.filter((page) => page !== 'update'), requests: [step.requests[0]!, 'GET ordner/bild.png HTTP/1.1\nHost: www.filius.de\n'] }),
// A mark in the page: nothing loads (Java loads the host's start page).
'follow:#oben': (step) => ({ ...step, address: 'http://www.filius.de/links.html', title: 'Links &amp; mehr', pages: [], requests: [] }),
};
const isServerStep = (step: JavaBrowserStep | JavaServerStep): step is JavaServerStep => step.step.startsWith('server:');
describe("the browser's window, against Java's", () => {
it('shows the address line, title and page Java shows, and sends the requests Java sends', () => {
const { server, window, requests, settle } = setUp();
let page: BrowserPage = { kind: 'start' };
for (const recorded of java.steps) {
if (isServerStep(recorded)) {
if (recorded.step === 'server:start') server.setActive(true);
continue;
}
const step = JAVA_BUGS[recorded.step]?.(recorded) ?? recorded;
const [action, argument] = [step.step.slice(0, step.step.indexOf(':')), step.step.slice(step.step.indexOf(':') + 1).replace(/ \(server off\)$/, '')];
if (action === 'type') window.open(argument);
if (action === 'follow' || action === 'press') {
const [href, post] = action === 'follow' ? [argument] : FORMS[argument]!;
window.follow(href, post);
}
if (step.step !== 'open') settle();
expect(window.view.address, step.step).toBe(step.address);
expect(browserTitle(window.view, 'Webbrowser', pageTitle), step.step).toBe(step.title);
page = javaPage(step.pages) ?? page;
const shown = window.view.page;
expect(shown.kind === 'html' ? { ...shown, images: {} } : shown, step.step).toEqual(page);
// An image that came: Java tells its window ("update").
const images = shown.kind === 'html' ? Object.keys(shown.images).length : 0;
if (step.pages.includes('update') && step.step !== 'open') expect(images, step.step).toBeGreaterThan(0);
expect(requests(), step.step).toEqual(step.requests);
// Java's oracle loads the links page anew after each link, without a step.
if (action === 'follow' || step.step === 'press:Los') {
window.open('www.filius.de/links.html');
settle();
requests();
page = { kind: 'html', html: PAGES['/webserver/links.html']!, images: {} };
}
}
expect(window.view.page).toMatchObject({ kind: 'html', html: PAGES['/webserver/ziel.html'] });
});
it('shows its start page with the program as its title before it loads anything', () => {
const { window } = setUp();
expect(window.view).toBe(BROWSER_START);
const open = java.steps.find((step) => step.step === 'open') as JavaBrowserStep;
expect([window.view.address, browserTitle(window.view, 'Webbrowser', pageTitle)]).toEqual([open.address, open.title]);
});
it("shows a page's images from their data, the images of a page in a folder from beside it", () => {
const files = serverFiles();
files.writeImageFile('/webserver/ordner/bild.png', new Uint8Array([1, 2, 3]), 'image/png');
const { server, window, settle, requests } = setUp(files);
server.setActive(true);
window.open('192.168.0.30');
settle();
expect(window.view.page).toMatchObject({ kind: 'html', images: { 'splashscreen-mini.png': { contentType: 'image/png' } } });
window.open('www.filius.de/ordner/drei.html');
settle();
requests();
expect(window.view.page).toMatchObject({ kind: 'html', images: { 'bild.png': { contentType: 'image/png', data: 'AQID' } } });
// Its link, also from beside it.
window.follow('vier.html');
settle();
expect([window.view.address, window.view.page]).toEqual(['http://www.filius.de/ordner/vier.html', { kind: 'html', html: PAGES['/webserver/ordner/vier.html'], images: {} }]);
});
it('shows plain text as text and an image as an image (Java shows both as HTML)', () => {
const { server, window, settle } = setUp();
server.setActive(true);
window.open('www.filius.de/text.txt');
settle();
expect(window.view.page).toEqual({ kind: 'text', text: 'Nur Text\nzwei Zeilen' });
window.open('www.filius.de/bild.png');
settle();
expect(window.view.page).toMatchObject({ kind: 'image', image: { contentType: 'image/png' } });
expect((window.view.page as { image: { data: string } }).image.data).toMatch(/^iVBOR[\w+/=]+$/);
});
it('follows no link without a page, nor one of another scheme, and keeps its page over a new program', () => {
const { engine, server, window, settle, requests } = setUp();
window.follow('ziel.html');
settle();
expect(window.view).toBe(BROWSER_START);
server.setActive(true);
window.open('www.filius.de/links.html');
settle();
requests();
const before = window.view;
window.follow('mailto:anna@filius.de');
window.follow('ftp://www.filius.de/');
settle();
expect([window.view, requests()]).toEqual([before, []]);
// A new start's program (Java's window outlives the stop): the page stays, the new program's lines are read.
engine.handle({ type: 'stop' });
engine.handle({ type: 'start', snapshot: engine.state.topologySnapshot, seed: scenario.seed, initialTimeMilliseconds: engine.state.currentTimeMilliseconds });
window.attach(engine.webBrowser(BROWSER)!);
expect(window.view).toBe(before);
engine.webServer(SERVER)!.setActive(true);
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 1_000 });
// The new program writes its page before the window reads it again: read from its first line.
window.follow('ziel.html');
settle();
expect(engine.webBrowser(BROWSER)!.log.all.map((line) => line.kind)).toEqual(['page']);
expect(window.view.page).toEqual({ kind: 'html', html: PAGES['/webserver/ziel.html'], images: {} });
});
it("writes the browser's last host into the address line for an address without one, as Java's window does", () => {
const { server, window, settle } = setUp();
server.setActive(true);
window.open('www.filius.de/links.html');
settle();
window.open('http:///ziel.html?x=1');
expect(window.view.address).toBe('http://www.filius.de/ziel.html');
settle();
expect(window.view.page).toEqual({ kind: 'html', html: PAGES['/webserver/ziel.html'], images: {} });
});
});
describe("the web server's window, against Java's", () => {
const steps = java.steps.filter(isServerStep);
const step = (name: string) => steps.find((each) => each.step === `server:${name}`)!;
const table = (server: WebServer) => server.virtualHostTable.map(([host, folder]) => [host, folder]);
const vhosts = (files: VirtualFileSystem) => (files.contains('/www.conf/vhosts') ? files.textFile('/www.conf/vhosts') : null);
it('starts and stops, switches the virtual hosts and writes their table as Java does', () => {
const files = serverFiles();
const { server } = setUp(files);
const state = () => ({ active: server.isActive, virtualHosts: server.useVirtualHosts, table: table(server), vhostsFile: vhosts(files) });
const javaState = (each: JavaServerStep) => ({ active: each.button === 'Anhalten', virtualHosts: each.virtualHosts, table: each.table, vhostsFile: each.vhostsFile });
expect(state()).toEqual(javaState(step('open')));
server.setActive(true);
expect(state()).toEqual(javaState(step('start')));
server.setUseVirtualHosts(true);
expect(state()).toEqual(javaState(step('virtual hosts on')));
for (const [row, column, value] of [
[0, 0, 'seite.filius.de'],
[0, 1, 'vhost1'],
[1, 0, 'nurhost.filius.de'],
[2, 1, 'nurordner'],
[3, 0, 'andere.filius.de'],
[3, 1, 'vhost2'],
] as const) {
server.setVirtualHost(row, column, value);
}
// The complete rows only (Java writes every row, its incomplete ones as empty lines).
expect(state()).toEqual({ ...javaState(step('rows written')), vhostsFile: 'seite.filius.de\nvhost1\nandere.filius.de\nvhost2\n' });
expect(step('rows written').vhostsFile).toBe('seite.filius.de\nvhost1\nnurhost.filius.de\n\n\nnurordner\nandere.filius.de\nvhost2\n\n\n');
});
it('reads its table again at the next start as it wrote it (Java moves the incomplete rows’ cells)', () => {
const files = serverFiles();
const { server } = setUp(files);
server.setUseVirtualHosts(true);
for (const [row, column, value] of [
[0, 0, 'seite.filius.de'],
[0, 1, 'vhost1'],
[1, 0, 'nurhost.filius.de'],
[2, 1, 'nurordner'],
[3, 0, 'andere.filius.de'],
[3, 1, 'vhost2'],
] as const) {
server.setVirtualHost(row, column, value);
}
const again = setUp(files).server;
// A file of virtual hosts switches them on, as in Java.
expect(again.useVirtualHosts).toBe(step('restart').serverVirtualHosts);
expect(table(again)).toEqual([['seite.filius.de', 'vhost1'], ['andere.filius.de', 'vhost2'], ['', ''], ['', ''], ['', '']]);
expect(step('restart').table).toEqual([['seite.filius.de', 'vhost1'], ['nurhost.filius.de', 'nurordner'], ['andere.filius.de', 'vhost2'], ['', ''], ['', '']]);
// Switched off, they are on again at the next start while the file is there, as in Java (until the window's setting is kept, F4.6f).
again.setUseVirtualHosts(false);
expect([again.useVirtualHosts, setUp(files).server.useVirtualHosts]).toEqual([step('virtual hosts off').serverVirtualHosts, step('restart after off').serverVirtualHosts]);
});
it("writes Java's lines into its window when it starts and stops", () => {
const { engine, server } = setUp();
server.setActive(true);
expect(server.log.all.map((line) => line.kind)).toEqual(['acceptingStarted']);
expect(step('start').log).toBe('Annahme von Verbindungsanfragen gestartet\n');
server.setActive(false);
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 200 });
expect(server.log.all.map((line) => line.kind).slice(-2)).toEqual(['acceptingStopped', 'serverSocketClosed']);
expect(step('stop').log.endsWith('Annahme von Verbindungsanfragen beendet\nServer-Socket geschlossen\n')).toBe(true);
});
});
describe('the page for what came', () => {
it("is the error page for any status but 200, else by the content type in any case", () => {
for (const statusCode of [0, 201, 301, 400, 404, 500, 501]) expect(browserPage(statusCode, 'text/html', '<p>x</p>')).toEqual({ kind: 'error', statusCode });
expect(browserPage(200, 'TEXT/HTML', undefined)).toEqual({ kind: 'html', html: '', images: {} });
expect(browserPage(200, 'image/GIF', 'R0lG\nODlh')).toEqual({ kind: 'image', image: { contentType: 'image/gif', data: 'R0lGODlh' } });
expect(browserPage(200, 'image/jpeg', 'x')).toEqual({ kind: 'text', text: 'x' });
expect(browserPage(200, undefined, undefined)).toEqual({ kind: 'text', text: '' });
});
});