Files
mdg-filius/packages/sim/test/browserWindow.test.ts
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

344 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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: '' });
});
});