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
+6
View File
File diff suppressed because one or more lines are too long
+102 -19
View File
@@ -10,7 +10,13 @@
// cannot read gets 400 or 501, one without a version is HTTP/1.1 (Java's
// thread dies of it and closes the connection); the browser shows "server
// not reachable" when its request cannot go out or the answer cannot be
// read (Java shows nothing).
// read (Java shows nothing). A page's relative images and links count from
// its folder, as in every browser (Java's window counts them from the
// server's root, so that a page in a folder misses its images and links;
// for a page in the root the requests are Java's). The table of virtual
// hosts is written with its complete rows only (Java writes every row, and
// reading the file again a row with an empty cell takes the next row's
// cell).
//
// A server may make pages itself (Java's plug-ins, WebServerPlugIn): a
// router's or home router's firewall log and routing table (routerPages.ts).
@@ -74,10 +80,13 @@ function contentTypeOf(name: string): string {
const IMAGE_TYPES = new Set(['image/bmp', 'image/gif', 'image/jpg', 'image/png']);
/** WebServer's table of virtual hosts: five rows of host name and folder, "" where empty. */
export type VirtualHostTable = readonly (readonly [string, string])[];
export class WebServer extends TcpServerProgram {
private readonly documentRoot: string;
private readonly virtualHosts: readonly (readonly [string, string])[];
private readonly useVirtualHosts: boolean;
private virtualHosts: [string, string][];
private useVirtualHostsValue: boolean;
constructor(
engine: NetworkRuntimeEngine,
@@ -96,13 +105,49 @@ export class WebServer extends TcpServerProgram {
for (let index = 0; index < lines.length && rows.length < VIRTUAL_HOST_ROWS; index += 2) {
rows.push([lines[index]!, lines[index + 1] ?? '']);
}
while (rows.length < VIRTUAL_HOST_ROWS) rows.push(['', '']);
this.virtualHosts = rows;
this.useVirtualHosts = configuration.virtualHosts || vhosts !== undefined;
this.useVirtualHostsValue = configuration.virtualHosts || vhosts !== undefined;
}
/** Whether the server looks up the request's host in its table (the window's check box). */
get useVirtualHosts(): boolean {
return this.useVirtualHostsValue;
}
/** WebServer.setUseVHost: from the next request on. */
setUseVirtualHosts(use: boolean): void {
this.useVirtualHostsValue = use;
}
get virtualHostTable(): VirtualHostTable {
return this.virtualHosts;
}
/**
* WebServer.changeVHostTable: a cell of the table (column 0 the host name,
* 1 the folder), saved to /www.conf/vhosts — with the complete rows only,
* so that Java reads the file as the table is.
*/
setVirtualHost(row: number, column: 0 | 1, value: string): void {
const cells = this.virtualHosts[row];
if (cells === undefined) return;
cells[column] = value;
const text = this.virtualHosts
.filter(([host, folder]) => host !== '' && folder !== '')
.map(([host, folder]) => `${host}\n${folder}\n`)
.join('');
try {
if (this.fileSystem !== undefined && !this.fileSystem.contains('/www.conf')) this.fileSystem.createDirectory('/www.conf');
this.fileSystem?.writeTextFile('/www.conf/vhosts', text);
} catch {
// A file where the folder should be: the table holds, unsaved, as Java's does when it cannot save.
}
}
/** WebServer.vhostPrefix: the folder of the host name, ignoring case; "" when none. */
private virtualHostFolder(host: string | undefined): string {
if (!this.useVirtualHosts || host === undefined) return '';
if (!this.useVirtualHostsValue || host === undefined) return '';
return this.virtualHosts.find(([name]) => name.toLowerCase() === host.toLowerCase())?.[1] ?? '';
}
@@ -189,6 +234,35 @@ function javaSplit(text: string, separator: string): string[] {
return parts;
}
/**
* A page's reference (an image's src) as the browser requests it: one with
* a leading slash or a scheme as it is, a relative one from the page's
* folder, without a leading slash as Java writes it (for a page in the
* root, Java's request).
*/
export function pageReference(pagePath: string, reference: string): string {
if (reference.startsWith('/') || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(reference)) return reference;
const path = pagePath.split('?')[0]!;
return path.slice(path.startsWith('/') ? 1 : 0, path.lastIndexOf('/') + 1) + reference;
}
/**
* Where a link of a page leads, as the browser window loads it: an http
* address to its host, a path to the page's host (relative from the page's
* folder, with a leading slash as Java's window writes it); undefined for a
* link the browser does not follow (another scheme, a mark in the page).
*/
export function linkTarget(page: { readonly host: string; readonly path: string }, href: string): { readonly host: string; readonly path: string } | undefined {
const reference = href.trim();
if (reference === '' || reference.startsWith('#')) return undefined;
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(reference)) {
if (!/^http:/i.test(reference)) return undefined;
return webBrowserAddress(reference.split('#')[0]!);
}
const target = pageReference(page.path, reference.split('#')[0]!);
return { host: page.host, path: target.startsWith('/') ? target : `/${target}` };
}
/** EingabenUeberpruefung.musterDomain and musterIpAdresse. */
const DOMAIN_PATTERN = /^([a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9]){0,1}(\.[a-zA-Z]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9]){0,1})*){0,1}\.{0,1}$/;
export const IP_ADDRESS_PATTERN =
@@ -218,15 +292,24 @@ export function imageSources(html: string): string[] {
}
export type WebBrowserLogEntry =
/** A page came: shown as it is with status 200, else as the browser's error page for the status. */
| { readonly kind: 'page'; readonly statusCode: number; readonly contentType: string | undefined; readonly body: string | undefined }
/** A page came from the host at the path: shown as it is with status 200, else as the browser's error page for the status. */
| {
readonly kind: 'page';
readonly host: string;
readonly path: string;
readonly statusCode: number;
readonly contentType: string | undefined;
readonly body: string | undefined;
}
/** sw_webbrowser_msg1: the server could not be reached (no address, no connection, no answer). */
| { readonly kind: 'unreachable' }
/** An image of the page came (or not: its status); Java tells its window, which loads it anew. */
| { readonly kind: 'image'; readonly source: string; readonly statusCode: number; readonly contentType: string | undefined };
/** An image of the page (its src as written) came, with its data — or not: its status; Java tells its window, which loads it anew. */
| { readonly kind: 'image'; readonly source: string; readonly statusCode: number; readonly contentType: string | undefined; readonly data?: Uint8Array };
/** The page the browser shows: as it came, with the images that came for it. */
/** The page the browser shows: as it came, with the images that came for it (by their src as written). */
export interface WebBrowserPage {
readonly host: string;
readonly path: string;
readonly statusCode: number;
readonly contentType: string | undefined;
readonly body: string | undefined;
@@ -238,7 +321,7 @@ export class WebBrowser {
private hostValue: string | undefined;
private socketId: Id | undefined;
private images: string[] = [];
private shown: { statusCode: number; contentType: string | undefined; body: string | undefined; images: Map<string, Uint8Array> } | undefined;
private shown: (Omit<WebBrowserPage, 'images'> & { readonly images: Map<string, Uint8Array> }) | undefined;
private readonly commands: CommandThread;
constructor(
@@ -326,22 +409,22 @@ export class WebBrowser {
if (response.statusCode === 200 && response.contentType?.toLowerCase() === 'text/html' && response.body !== undefined) {
this.images = imageSources(response.body);
}
this.shown = { statusCode: response.statusCode, contentType: response.contentType, body: response.body, images: new Map() };
this.log.write({ kind: 'page', statusCode: response.statusCode, contentType: response.contentType, body: response.body });
const page = { host: this.hostValue ?? '', path, statusCode: response.statusCode, contentType: response.contentType, body: response.body };
this.shown = { ...page, images: new Map() };
this.log.write({ kind: 'page', ...page });
}
/** WebBrowser.retrieveImages: each over the same connection, as long as it lasts. */
/** WebBrowser.retrieveImages: each over the same connection, as long as it lasts, from the page's folder. */
private *retrieveImages(): Steps {
const pagePath = this.shown?.path ?? '/';
for (const source of this.images) {
if (!this.isConnected) return;
const response = yield* this.exchange(source, undefined);
const response = yield* this.exchange(pageReference(pagePath, source), undefined);
if (response === undefined) continue;
const isImage = response.contentType !== undefined && IMAGE_TYPES.has(response.contentType.toLowerCase());
if (response.statusCode === 200 && isImage) {
const data = javaBase64Decode(response.body ?? '');
const data = response.statusCode === 200 && isImage ? javaBase64Decode(response.body ?? '') : undefined;
if (data !== undefined) this.shown?.images.set(source, data);
}
this.log.write({ kind: 'image', source, statusCode: response.statusCode, contentType: response.contentType });
this.log.write({ kind: 'image', source, statusCode: response.statusCode, contentType: response.contentType, ...(data === undefined ? {} : { data }) });
}
}
+79
View File
@@ -10,6 +10,8 @@ import {
formatHttpResponse,
imageSources,
javaBase64,
linkTarget,
pageReference,
javaBase64Decode,
parseHttpRequest,
parseHttpResponse,
@@ -80,6 +82,29 @@ describe("the browser's address line (erzeugeURL)", () => {
});
});
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([
@@ -181,6 +206,42 @@ describe('the web server', () => {
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']);
@@ -206,6 +267,24 @@ describe('the web browser', () => {
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)!;
Binary file not shown.
+205
View File
@@ -0,0 +1,205 @@
// The web browser and the web server on a computer's desktop in Chromium
// (F4.6d), on browser/fixtures/web-pages.fls (test/webPagesFixture.test.ts):
// Java's address line and title, the page in a sandbox that runs no script
// and loads nothing but the images that came, links and forms handed to the
// room as Java's window hands them to its program, the server's button,
// virtual hosts and lines, the routers' pages in the language FILIUS runs in.
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test, type Page } from '@playwright/test';
import { drawing, onDevice, serveEditor } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
test.beforeAll(async () => {
server = await serveEditor();
});
test.afterAll(async () => {
await server.close();
});
const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/web-pages.fls');
async function openPages(page: Page, query = ''): Promise<void> {
await page.goto(`${server.base}?example=new${query}`);
await page.waitForSelector('svg.filius-canvas');
await page.locator('input[type="file"]').setInputFiles(FIXTURE);
await expect.poll(async () => (await drawing(page).catch(() => ({ nodes: [] }))).nodes.length).toBe(4);
}
async function start(page: Page, name = 'Wechselt in den Aktionsmodus'): Promise<void> {
await page.getByRole('button', { name }).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
}
/** Opens a computer's desktop with a tap and a program's window on it. */
async function openProgram(page: Page, name: string, program: string) {
const id = (await drawing(page)).nodes.find((node) => node.name === name)!.id;
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
const desktop = page.locator(`section[data-desktop="${id}"]`);
await desktop.locator(`[data-program="${program}"]`).click();
const window = desktop.locator(`[data-program-window="${program}"]`);
return { desktop, window, title: window.locator('.program-titlebar h3') };
}
test("browses as Java's browser does: the address line, the page's title, links and forms to the server, the page in a sandbox", async ({ page }) => {
test.setTimeout(120_000);
await openPages(page);
await start(page);
const { desktop, window, title } = await openProgram(page, 'Browser', 'webBrowser');
const address = window.getByRole('textbox', { name: 'Adresse' });
const frame = window.frameLocator('iframe[title="Seite"]');
// Java's start: the program's name, "http://", its browser's logo.
await expect(title).toHaveText('Webbrowser');
await expect(address).toHaveValue('http://');
await expect(window.locator('.browser-start img')).toBeVisible();
await address.fill('192.168.0.20');
await address.press('Enter');
await expect(address).toHaveValue('http://192.168.0.20/');
await expect(title).toHaveText('Seiten & Formulare', { timeout: 30_000 });
await expect(frame.locator('h1')).toHaveText('Start');
// The image that came shows from its data, the missing one does not load; no script ran, nor a handler.
await expect(frame.locator('img[alt="Bild"]')).toHaveAttribute('src', /^data:image\/png;base64,iVBOR/, { timeout: 30_000 });
await expect(frame.locator('img[alt="Fehlt"]')).not.toHaveAttribute('src', /./);
await expect(frame.locator('script')).toHaveCount(0);
expect(await frame.locator('body').getAttribute('data-ran')).toBeNull();
expect(await frame.locator('body').getAttribute('onload')).toBeNull();
await expect(frame.locator('meta[http-equiv="Content-Security-Policy"]')).toHaveAttribute('content', /default-src 'none'; img-src data:/);
await expect(window.locator('iframe')).toHaveAttribute('sandbox', 'allow-same-origin allow-forms');
// A mark in the page stays in it; a link goes to the server.
await frame.getByRole('link', { name: 'Unten' }).click();
await expect(frame.locator('#unten')).toBeInViewport();
await expect(address).toHaveValue('http://192.168.0.20/');
await frame.getByRole('link', { name: 'Drei' }).click();
await expect(title).toHaveText('Drei', { timeout: 30_000 });
await expect(address).toHaveValue('http://192.168.0.20/ordner/drei.html');
// A link of a page in a folder goes beside it (Java's from the server's root).
await frame.getByRole('link', { name: 'Vier' }).click();
await expect(title).toHaveText('Vier im Ordner', { timeout: 30_000 });
await expect(address).toHaveValue('http://192.168.0.20/ordner/vier.html');
// The forms: the POST one sends its fields as Java's window sends them.
await address.fill('http://192.168.0.20/');
await window.getByRole('button', { name: 'Start' }).click();
await expect(title).toHaveText('Seiten & Formulare', { timeout: 30_000 });
await frame.getByRole('button', { name: 'Gut' }).click();
await expect(title).toHaveText('Ziel', { timeout: 30_000 });
await expect(address).toHaveValue('http://192.168.0.20/ziel.html');
await address.fill('192.168.0.20');
await address.press('Enter');
await expect(title).toHaveText('Seiten & Formulare', { timeout: 30_000 });
// The GET one's data stays in the address, which Java's browser does not send (nor show).
await frame.getByRole('button', { name: 'Los' }).click();
await expect(title).toHaveText('Ziel', { timeout: 30_000 });
await expect(address).toHaveValue('http://192.168.0.20/ziel.html');
// An error: the browser's page for its status (Java's never finds its template).
await address.fill('192.168.0.20/nichts.html');
await address.press('Enter');
await expect(title).toHaveText('Fehler Not Found (404)', { timeout: 30_000 });
await expect(frame.locator('h1')).toHaveText('Not Found');
await expect(frame.locator('p')).toHaveText('Es ist ein Fehler aufgetreten (HTTP-Status: 404)');
// No address: the line goes back to "http://", the page stays.
await address.fill('kein host!');
await address.press('Enter');
await expect(address).toHaveValue('http://');
await expect(title).toHaveText('Fehler Not Found (404)');
// The server got the forms as Java's browser sends them.
await desktop.getByRole('button', { name: 'Schließen' }).click();
const web = await openProgram(page, 'Server', 'webServer');
const log = web.window.getByLabel('Meldungen');
await expect(log).toContainText('>>POST /ziel.html HTTP/1.1');
await expect(log).toContainText('name=Fil+ius&c=1&s=y&t=%C3%BC%26%3D&ok=Gut');
await expect(log).toContainText('>>GET /ziel.html HTTP/1.1');
await expect(log).not.toContainText('q=a+b');
await expect(log).toContainText('Verbindung zu 192.168.0.10:');
});
test("starts and stops the web server, and edits its virtual hosts, as Java's window does", async ({ page }) => {
test.setTimeout(120_000);
await openPages(page);
await start(page);
const { desktop, window, title } = await openProgram(page, 'Server', 'webServer');
await expect(title).toHaveText('Webserver');
const button = window.getByRole('button', { name: 'Anhalten' });
await expect(button).toBeVisible();
const box = window.getByRole('checkbox', { name: 'Verwende virtuelle Hosts' });
await expect(box).not.toBeChecked();
await expect(window.getByRole('table')).toHaveCount(0);
await button.click();
await expect(window.getByRole('button', { name: 'Starten' })).toBeVisible();
await expect(window.getByLabel('Meldungen')).toContainText('Annahme von Verbindungsanfragen beendet');
await expect(window.getByLabel('Meldungen')).toContainText('Server-Socket geschlossen');
// The virtual hosts' table: five rows, a cell written when it is left.
// The box shows what the server holds: ticked once the room answers.
await box.click();
await expect(box).toBeChecked();
const table = window.getByRole('table', { name: 'Virtuelle Hosts:' });
await expect(table.getByRole('columnheader')).toHaveText(['Hostname', 'Unterverzeichnis']);
await expect(table.getByRole('textbox')).toHaveCount(10);
await table.getByRole('textbox', { name: 'Hostname 1' }).fill('www.filius.de');
await table.getByRole('textbox', { name: 'Unterverzeichnis 1' }).fill('ordner');
await table.getByRole('textbox', { name: 'Unterverzeichnis 1' }).press('Enter');
// Escape goes back to what the server holds: what Enter wrote.
await table.getByRole('textbox', { name: 'Unterverzeichnis 1' }).press('Escape');
await expect(table.getByRole('textbox', { name: 'Unterverzeichnis 1' })).toHaveValue('ordner');
await table.getByRole('textbox', { name: 'Hostname 2' }).fill('weg');
await table.getByRole('textbox', { name: 'Hostname 2' }).press('Escape');
await expect(table.getByRole('textbox', { name: 'Hostname 2' })).toHaveValue('');
// Left and opened again, the window shows what the server holds.
await desktop.getByRole('button', { name: 'Startbildschirm' }).click();
await desktop.locator('[data-program="webServer"]').click();
await expect(table.getByRole('textbox', { name: 'Hostname 1' })).toHaveValue('www.filius.de');
await expect(table.getByRole('textbox', { name: 'Unterverzeichnis 1' })).toHaveValue('ordner');
await window.getByRole('button', { name: 'Starten' }).click();
await expect(window.getByLabel('Meldungen')).toContainText('Annahme von Verbindungsanfragen gestartet');
// Asked for by its address, which no row names, the server gives the root's pages.
await desktop.getByRole('button', { name: 'Schließen' }).click();
const browser = await openProgram(page, 'Browser', 'webBrowser');
const address = browser.window.getByRole('textbox', { name: 'Adresse' });
await address.fill('192.168.0.20');
await address.press('Enter');
await expect(browser.title).toHaveText('Seiten & Formulare', { timeout: 30_000 });
});
test("shows the server it cannot reach as Java's browser does", async ({ page }) => {
test.setTimeout(120_000);
await openPages(page);
await start(page);
const web = await openProgram(page, 'Server', 'webServer');
await web.window.getByRole('button', { name: 'Anhalten' }).click();
await web.desktop.getByRole('button', { name: 'Schließen' }).click();
const { window, title } = await openProgram(page, 'Browser', 'webBrowser');
const address = window.getByRole('textbox', { name: 'Adresse' });
await address.fill('192.168.0.20');
await address.press('Enter');
await expect(title).toHaveText('http://192.168.0.20/');
await expect(title).toHaveText('Fehler', { timeout: 60_000 });
await expect(window.frameLocator('iframe').locator('h1')).toHaveText('Server konnte nicht erreicht werden!');
});
test("shows a router's pages in English when FILIUS speaks English", async ({ page }) => {
test.setTimeout(120_000);
await openPages(page, '&lang=en');
await start(page, 'Change to Simulation Mode');
const { window, title } = await openProgram(page, 'Browser', 'webBrowser');
await expect(title).toHaveText('Webbrowser');
const address = window.getByRole('textbox', { name: 'Address' });
await address.fill('192.168.0.1');
await address.press('Enter');
await expect(title).toHaveText('Router Administration', { timeout: 30_000 });
await window.frameLocator('iframe').getByRole('link', { name: 'Show current routing/forwarding table' }).click();
await expect(title).toHaveText('Routing/Forwarding Table', { timeout: 30_000 });
await expect(address).toHaveValue('http://192.168.0.1/routes');
await address.fill('192.168.0.20/nichts.html');
await address.press('Enter');
await expect(title).toHaveText('Error Not Found (404)', { timeout: 30_000 });
});
+3 -2
View File
@@ -20,7 +20,7 @@ import { Palette, type PaletteKind } from './design/Palette.js';
import { RouterDialog } from './design/RouterDialog.js';
import { DeviceSheet } from './sheet/DeviceSheet.js';
import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js';
import { useTranslate } from './i18n/i18n.js';
import { useLanguage, useTranslate } from './i18n/i18n.js';
import { ModeBar } from './simulation/ModeBar.js';
import { useSimulation, type SimHostFactory } from './simulation/useSimulation.js';
@@ -90,6 +90,7 @@ const toFront = (open: readonly OpenDesktop[], id: string): readonly OpenDesktop
export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly createSimHost?: SimHostFactory }) {
const t = useTranslate();
const language = useLanguage();
const view = useTopologyView(doc);
const simulation = useSimulation(doc, createSimHost);
const simulating = simulation.mode === 'simulation';
@@ -285,7 +286,7 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
}}
/>
<div role="toolbar" style={{ position: 'absolute', left: 12, top: 12, zIndex: 50, display: 'flex', gap: 6, alignItems: 'center' }}>
<ModeBar mode={simulation.mode} speed={simulation.speed} onMode={(mode) => (mode === 'simulation' ? simulation.start() : simulation.stop())} onSpeed={simulation.setSpeed} />
<ModeBar mode={simulation.mode} speed={simulation.speed} onMode={(mode) => (mode === 'simulation' ? simulation.start(language) : simulation.stop())} onSpeed={simulation.setSpeed} />
{simulating ? null : (
<>
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,104 @@
// A computer's web browser, after Java's GUIApplicationWebBrowserWindow: an
// address line with Java's "Start" button above the page. The window is the
// room's (BrowserWindow) — its address line, title and page, the same for
// everyone who opens it —; this panel types into it and shows its page in a
// sandboxed frame (plan §7.3): no script runs, nothing loads but the images
// that came, and a link or a form's button goes to the room as Java's window
// hands it to its program. A mark in the page (#top) stays in the page: the
// panel scrolls to it, as the frame's own link would lead to the editor's
// address (a srcdoc frame's links count from its parent's).
//
// Java's start page, its browser's logo, shows before the first load.
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { BROWSER_START, type BrowserView } from '@mdg-filius/protocol';
import logo from '../assets/desktop/browser_waterwolf_logo.png';
import { useTranslate } from '../i18n/i18n.js';
import type { Simulation } from '../simulation/useSimulation.js';
import { pageDocument } from './browserPage.js';
/** The frame's sandbox: no script, no popup, no navigation of its own; forms submit only to the panel (and the policy sends none). */
export const PAGE_SANDBOX = 'allow-same-origin allow-forms';
/** The view the room sent for a computer's browser; Java's start while it sent none. */
export function browserView(simulation: Simulation, node: string): BrowserView {
return (simulation.views.get(`app:${node}/webBrowser`) as BrowserView | null | undefined) ?? BROWSER_START;
}
/** What a form sends, as Java's window sends it: its fields in order with the button pressed, form-encoded; a GET form's after a "?" in the address. */
export function formRequest(form: HTMLFormElement, submitter: HTMLElement | null): { readonly href: string; readonly post?: string } {
const data = new URLSearchParams(new FormData(form, submitter) as unknown as Record<string, string>).toString();
const action = form.getAttribute('action') ?? '';
return form.method.toLowerCase() === 'post' ? { href: action, post: data } : { href: `${action.split('?')[0]}?${data}` };
}
export function BrowserPanel({ node, simulation }: { readonly node: string; readonly simulation: Simulation }) {
const t = useTranslate();
const topic = `app:${node}/webBrowser` as const;
const { watch, act } = simulation;
useEffect(() => watch([topic]), [watch, topic]);
const view = browserView(simulation, node);
const [typed, setTyped] = useState(view.address);
const frame = useRef<HTMLIFrameElement>(null);
// Java's window writes each load's address into its line, and "http://" for one that is none.
useEffect(() => setTyped(view.address), [view.address, view.loading]);
const document = pageDocument(view.page, t);
const open = (event: FormEvent) => {
event.preventDefault();
act(node, 'webBrowser', 'open', { address: typed });
};
// The page's links and forms go to the room; the frame runs no script, so the panel listens in it.
const onLoad = () => {
const page = frame.current?.contentDocument;
if (page === null || page === undefined) return;
page.addEventListener('click', (event) => {
const link = (event.target as Element | null)?.closest?.('a[href]');
if (link === null || link === undefined) return;
const href = link.getAttribute('href')!.trim();
event.preventDefault();
// A mark in the page: scrolled to here (the frame's own links would lead to the editor's address).
if (href.startsWith('#')) {
const name = decodeURIComponent(href.slice(1));
(page.getElementById(name) ?? page.getElementsByName(name)[0])?.scrollIntoView();
return;
}
act(node, 'webBrowser', 'follow', { href });
});
page.addEventListener('submit', (event) => {
event.preventDefault();
const request = formRequest(event.target as HTMLFormElement, (event as SubmitEvent).submitter);
act(node, 'webBrowser', 'follow', request.post === undefined ? { href: request.href } : { href: request.href, post: request.post });
});
};
return (
<div className="browser">
<form className="browser-address" onSubmit={open}>
<input
name="address"
value={typed}
aria-label={t('browser.address')}
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
spellCheck={false}
inputMode="url"
enterKeyHint="go"
onChange={(event) => setTyped(event.target.value)}
/>
<button type="submit">{t('browser.go')}</button>
</form>
<div className="browser-page">
{document === undefined ? (
<div className="browser-start">
<img src={logo} alt="" />
</div>
) : (
<iframe ref={frame} title={t('browser.page')} sandbox={PAGE_SANDBOX} srcDoc={document} onLoad={onLoad} />
)}
</div>
</div>
);
}
+15 -3
View File
@@ -11,11 +11,14 @@
// On a wide screen the window lies over the surface and moves by its title
// bar, as Java's windows do; on a phone it fills the surface. A program's
// icon opens its window over the desktop (ProgramFrame), the home button
// leaves it; so far the command line has one (F4.6b), the others follow.
// leaves it; so far the command line (F4.6b), the web browser and the web
// server (F4.6d) have one, the others follow. The browser's window takes its
// title from its page, as Java's does.
import { useCallback, useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
import type * as Y from 'yjs';
import { desktopView } from '@mdg-filius/doc';
import { browserTitle } from '@mdg-filius/protocol';
import background from '../assets/desktop/hintergrundbild.png';
import networkOff from '../assets/desktop/netzwek_aus.png';
import networkOn from '../assets/desktop/netzwek_c.png';
@@ -24,8 +27,11 @@ import { useLanguage, useTranslate } from '../i18n/i18n.js';
import type { Simulation } from '../simulation/useSimulation.js';
import { ProgramFrame } from './ProgramFrame.js';
import { desktopPrograms, INSTALLATION_ICON, JAVA_PROGRAMS, type DesktopProgram } from './programs.js';
import { BrowserPanel, browserView } from './BrowserPanel.js';
import { pageTitle } from './browserPage.js';
import { InstallationPanel } from './InstallationPanel.js';
import { TerminalPanel } from './TerminalPanel.js';
import { WebServerPanel } from './WebServerPanel.js';
import './desktop.css';
export interface DesktopWindowProps {
@@ -54,7 +60,7 @@ export interface DesktopWindowProps {
export type DesktopWindowName = DesktopProgram | 'installation';
/** The programs whose windows are built so far. */
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt']);
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer']);
/** The document's desktop of a computer, read anew after every change (a program installed, the label changed). */
function useDesktopView(doc: Y.Doc, id: string) {
@@ -146,8 +152,14 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
<InstallationPanel doc={doc} node={id} installed={view.programs} origin={origin} onDone={home} />
</ProgramFrame>
) : shown === null ? null : (
<ProgramFrame icon={JAVA_PROGRAMS[shown].icon} title={t(`program.${shown}`)} program={shown}>
<ProgramFrame
icon={JAVA_PROGRAMS[shown].icon}
title={shown === 'webBrowser' ? browserTitle(browserView(simulation, id), t('program.webBrowser'), (page) => pageTitle(page, t)) : t(`program.${shown}`)}
program={shown}
>
{shown === 'commandPrompt' ? <TerminalPanel node={id} simulation={simulation} onExit={home} /> : null}
{shown === 'webBrowser' ? <BrowserPanel node={id} simulation={simulation} /> : null}
{shown === 'webServer' ? <WebServerPanel node={id} simulation={simulation} /> : null}
</ProgramFrame>
)}
{info ? (
@@ -0,0 +1,92 @@
// A computer's web server, after Java's GUIApplicationWebServerWindow: the
// start button ("Starten"/"Anhalten"), beside it the virtual hosts' check
// box; while it is ticked their table (five rows of host name and folder,
// each cell written when it is left, as Java's table writes it when its
// editing stops) and below the server's lines. The server is the room's: what
// the panel shows is its view and lines, what it does goes to the room.
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from 'react';
import type { LogLine, ServerLogEntry } from '@mdg-filius/core';
import type { WebServerView } from '@mdg-filius/protocol';
import { useTranslate } from '../i18n/i18n.js';
import type { Simulation } from '../simulation/useSimulation.js';
import { serverLine } from './serverLines.js';
const NO_LINES: readonly LogLine<ServerLogEntry>[] = [];
/** A cell of the table, written to the server when it is left or Enter is pressed and it changed. */
function HostCell({ value, label, onCommit }: { readonly value: string; readonly label: string; readonly onCommit: (value: string) => void }) {
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
const commit = () => {
if (draft !== value) onCommit(draft);
};
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') commit();
if (event.key === 'Escape') setDraft(value);
};
return <input value={draft} aria-label={label} autoCapitalize="off" autoCorrect="off" spellCheck={false} onChange={(event) => setDraft(event.target.value)} onBlur={commit} onKeyDown={onKeyDown} />;
}
export function WebServerPanel({ node, simulation }: { readonly node: string; readonly simulation: Simulation }) {
const t = useTranslate();
const topic = `app:${node}/webServer` as const;
const { watch, act } = simulation;
useEffect(() => watch([topic]), [watch, topic]);
const view = simulation.views.get(topic) as WebServerView | null | undefined;
const lines = (simulation.apps.get(topic)?.lines as readonly LogLine<ServerLogEntry>[] | undefined) ?? NO_LINES;
const text = lines.map((line) => `${serverLine(line, t)}\n`).join('');
const log = useRef<HTMLPreElement>(null);
// The newest line in sight, as Java's text area scrolls.
useLayoutEffect(() => {
const element = log.current;
if (element !== null) element.scrollTop = element.scrollHeight;
}, [text]);
if (view === null || view === undefined) return <div className="web-server" />;
return (
<div className="web-server">
<div className="web-server-bar">
<button type="button" onClick={() => act(node, 'webServer', 'setActive', { active: !view.active })}>
{t(view.active ? 'server.stop' : 'server.start')}
</button>
<label>
<input type="checkbox" checked={view.useVirtualHosts} onChange={(event) => act(node, 'webServer', 'useVirtualHosts', { use: event.target.checked })} />
{t('webServer.useVirtualHosts')}
</label>
</div>
{view.useVirtualHosts ? (
<div className="web-server-hosts">
<span id={`${node}-virtual-hosts`}>{t('webServer.virtualHosts')}</span>
<table aria-labelledby={`${node}-virtual-hosts`}>
<thead>
<tr>
<th>{t('webServer.hostName')}</th>
<th>{t('webServer.folder')}</th>
</tr>
</thead>
<tbody>
{view.virtualHosts.map(([host, folder], row) => (
<tr key={row}>
{[host, folder].map((value, column) => (
<td key={column}>
<HostCell
value={value}
label={`${t(column === 0 ? 'webServer.hostName' : 'webServer.folder')} ${row + 1}`}
onCommit={(written) => act(node, 'webServer', 'setVirtualHost', { row, column, value: written })}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : null}
<pre className="web-server-log" ref={log} aria-label={t('server.log')} tabIndex={0}>
{text}
</pre>
</div>
);
}
@@ -0,0 +1,71 @@
// What the browser window shows of a page (BrowserPage), as a document for a
// sandboxed frame (plan §7.3): the page's HTML as it came, without what
// could reach beyond the frame — scripts, a refresh, a base, other
// documents —, under a policy that loads nothing but the images that came
// (as data) and sends no form; the error pages as Java's browser writes
// them, in the person's language. Java's start page (its logo) is not a
// document: the panel shows it itself.
import { htmlTitle, type BrowserImage, type BrowserPage } from '@mdg-filius/protocol';
import { httpStatusText } from '@mdg-filius/core';
import type { Translate } from '../i18n/i18n.js';
/** Loads nothing but images given as data, runs no script (the frame's sandbox too), sends no form (the panel takes it). */
export const PAGE_POLICY = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; form-action 'none'";
/** Elements that would load, run or lead somewhere by themselves. */
const REMOVED = 'script, noscript, base, link, iframe, frame, frameset, object, embed, applet, meta[http-equiv]';
const escape = (text: string) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const dataUrl = (image: BrowserImage) => `data:${image.contentType};base64,${image.data}`;
/** The texts of an error page: Java's sw_webbrowser_msg1, its template tmpl/http_fehler. */
function errorTexts(page: Extract<BrowserPage, { kind: 'unreachable' | 'error' }>, t: Translate) {
if (page.kind === 'unreachable') return { title: t('browser.unreachableTitle'), heading: t('browser.unreachable'), text: undefined };
const values = { code: page.statusCode, message: httpStatusText(page.statusCode) };
return { title: t('browser.errorTitle', values), heading: values.message, text: t('browser.errorText', values) };
}
/** The window's title for the page shown, where it has one: an HTML page's (as it reads), an error page's. */
export function pageTitle(page: BrowserPage, t: Translate): string | undefined {
if (page.kind === 'unreachable' || page.kind === 'error') return errorTexts(page, t).title;
if (page.kind !== 'html') return undefined;
const title = htmlTitle(page.html);
if (title === undefined) return undefined;
const text = new DOMParser().parseFromString(`<title>${title}</title>`, 'text/html').title;
return text === '' ? undefined : text;
}
/** The document the frame shows; undefined for the start page. */
export function pageDocument(page: BrowserPage, t: Translate): string | undefined {
const policy = `<meta http-equiv="Content-Security-Policy" content="${PAGE_POLICY}">`;
switch (page.kind) {
case 'start':
return undefined;
case 'unreachable':
case 'error': {
const { title, heading, text } = errorTexts(page, t);
return `<html><head>${policy}<title>${escape(title)}</title></head><body><h1>${escape(heading)}</h1>${text === undefined ? '' : `<p> ${escape(text)} </p>`}</body></html>`;
}
case 'text':
return `<html><head>${policy}</head><body><pre style="white-space:pre-wrap">${escape(page.text)}</pre></body></html>`;
case 'image':
return `<html><head>${policy}</head><body style="margin:0"><img src="${dataUrl(page.image)}" alt=""></body></html>`;
case 'html': {
const document = new DOMParser().parseFromString(page.html, 'text/html');
for (const element of document.querySelectorAll(REMOVED)) element.remove();
for (const element of document.querySelectorAll('*')) {
for (const attribute of [...element.attributes]) if (/^on/i.test(attribute.name)) element.removeAttribute(attribute.name);
}
for (const image of document.querySelectorAll('img')) {
const came = page.images[image.getAttribute('src') ?? ''];
image.removeAttribute('srcset');
// An image that did not come shows as missing, as in Java (its alt text).
if (came === undefined) image.removeAttribute('src');
else image.setAttribute('src', dataUrl(came));
}
document.head.insertAdjacentHTML('afterbegin', policy);
return document.documentElement.outerHTML;
}
}
}
+157
View File
@@ -379,3 +379,160 @@
padding: 0 16px;
flex: none;
}
/* The web browser (GUIApplicationWebBrowserWindow): the address line with
* its button above the page, five pixels around each as in Java; the page
* in a frame of its own on white. */
.browser {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: #eeeeee;
font: 13px Dialog, sans-serif;
}
.browser-address {
display: flex;
gap: 5px;
flex: none;
padding: 5px;
}
.browser-address input {
flex: 1;
min-width: 0;
min-height: 36px;
padding: 0 6px;
border: 1px solid #a0a0a0;
background: #ffffff;
color: #000000;
font: 16px Dialog, sans-serif;
}
.browser-address button,
.web-server-bar button {
min-width: 44px;
min-height: 36px;
padding: 0 14px;
border: 1px solid #a0a0a0;
border-radius: 6px;
background: #f8f8f8;
color: #000000;
font: 13px Dialog, sans-serif;
cursor: pointer;
}
.browser-page {
flex: 1;
min-height: 0;
margin: 0 5px 5px;
display: flex;
background: #ffffff;
border: 1px solid #a0a0a0;
}
.browser-page iframe {
flex: 1;
width: 100%;
border: none;
background: #ffffff;
}
.browser-start {
flex: 1;
display: flex;
justify-content: center;
align-items: flex-start;
}
.browser-start img {
max-width: 100%;
}
/* The web server (GUIApplicationWebServerWindow): the button and the check
* box above, the virtual hosts' table while it is ticked, the lines below. */
.web-server {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 5px 10px 10px;
background: #eeeeee;
color: #000000;
font: 13px Dialog, sans-serif;
}
.web-server-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 24px;
flex: none;
}
.web-server-bar label {
display: flex;
align-items: center;
gap: 6px;
min-height: 44px;
cursor: pointer;
}
.web-server-bar input[type='checkbox'] {
width: 20px;
height: 20px;
}
.web-server-hosts {
display: flex;
flex-direction: column;
gap: 5px;
flex: none;
}
.web-server-hosts table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
background: #ffffff;
}
.web-server-hosts th {
padding: 4px;
border: 1px solid #a0a0a0;
background: #e4e4e4;
font-weight: normal;
text-align: left;
}
.web-server-hosts td {
padding: 0;
border: 1px solid #c0c0c0;
}
.web-server-hosts td input {
width: 100%;
min-height: 32px;
box-sizing: border-box;
padding: 0 5px;
border: none;
background: transparent;
color: #000000;
font: 16px Dialog, sans-serif;
}
.web-server-log {
flex: 1;
min-height: 120px;
margin: 0;
overflow: auto;
padding: 4px;
border: 1px solid #a0a0a0;
background: #ffffff;
font: 12px ui-monospace, Menlo, Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
user-select: text;
}
@@ -0,0 +1,25 @@
// A server's lines as Java's server windows write them (ServerAnwendung,
// ServerMitarbeiter, TCPSocket's failures), in the person's language: one
// line each, as Java appends them to its window's text area.
import type { ServerLogEntry } from '@mdg-filius/core';
import type { Translate } from '../i18n/i18n.js';
export function serverLine(entry: ServerLogEntry, t: Translate): string {
switch (entry.kind) {
case 'acceptingStarted':
case 'acceptingStopped':
case 'serverSocketClosed':
case 'serverSocketFailed':
return t(`server.${entry.kind}`);
case 'connectionAccepted':
case 'connectionClosed':
return t(`server.${entry.kind}`, { address: `${entry.address}:${entry.port}` });
case 'received':
return `>>${entry.text}`;
case 'sent':
return `<<${entry.text}`;
case 'sendFailed':
return t(`server.sendFailed.${entry.failure}`);
}
}
+23
View File
@@ -1,4 +1,11 @@
{
"browser.address": "Adresse",
"browser.errorText": "Es ist ein Fehler aufgetreten (HTTP-Status: {code})",
"browser.errorTitle": "Fehler {message} ({code})",
"browser.go": "Start",
"browser.page": "Seite",
"browser.unreachable": "Server konnte nicht erreicht werden!",
"browser.unreachableTitle": "Fehler",
"cable.alreadyConnected": "Komponenten bereits verbunden",
"cable.lan": "LAN-Anschluss (intern)",
"cable.lanOccupied": "LAN-Anschluss (intern) bereits belegt!",
@@ -117,6 +124,18 @@
"router.cancel": "Abbrechen",
"router.prompt": "Wählen Sie die Anzahl der Schnittstellen des Vermittlungsrechners aus!",
"router.title": "Anzahl Schnittstellen",
"server.acceptingStarted": "Annahme von Verbindungsanfragen gestartet",
"server.acceptingStopped": "Annahme von Verbindungsanfragen beendet",
"server.connectionAccepted": "Verbindung zu {address} hergestellt",
"server.connectionClosed": "Socket zu {address} geschlossen",
"server.log": "Meldungen",
"server.sendFailed.connectionLost": "Verbindung wurde unterbrochen",
"server.sendFailed.notConnected": "Verbindung nicht aufgebaut!",
"server.sendFailed.timedOut": "Zeitüberschreitung während des Versands einer Nachricht",
"server.serverSocketClosed": "Server-Socket geschlossen",
"server.serverSocketFailed": "Server-Socket konnte nicht geöffnet werden.",
"server.start": "Starten",
"server.stop": "Anhalten",
"sheet.back": "Zurück",
"sheet.cable": "Kabelgebunden (LAN)",
"sheet.cablePlugged": "Das Kabel ist noch eingesteckt.",
@@ -165,6 +184,10 @@
"terminal.interrupt": "Befehl abbrechen (Strg+C)",
"terminal.next": "Nächster Befehl",
"terminal.previous": "Vorheriger Befehl",
"webServer.folder": "Unterverzeichnis",
"webServer.hostName": "Hostname",
"webServer.useVirtualHosts": "Verwende virtuelle Hosts",
"webServer.virtualHosts": "Virtuelle Hosts:",
"zoom.fit": "Alles zeigen",
"zoom.in": "Vergrößern",
"zoom.level": "{percent} %",
+23
View File
@@ -1,4 +1,11 @@
{
"browser.address": "Address",
"browser.errorText": "An error occurred (HTTP-Status: {code})",
"browser.errorTitle": "Error {message} ({code})",
"browser.go": "Start",
"browser.page": "Page",
"browser.unreachable": "Server does not exist!",
"browser.unreachableTitle": "Error",
"cable.alreadyConnected": "Components already connected",
"cable.lan": "LAN port (internal)",
"cable.lanOccupied": "LAN port (internal) already occupied!",
@@ -117,6 +124,18 @@
"router.cancel": "Cancel",
"router.prompt": "Choose number of NIC of the router!",
"router.title": "Number of NIC",
"server.acceptingStarted": "Start accepting connections.",
"server.acceptingStopped": "Stop accepting connections.",
"server.connectionAccepted": "Connection to {address} established",
"server.connectionClosed": "Socket to {address} closed",
"server.log": "Messages",
"server.sendFailed.connectionLost": "Connection was interrupted",
"server.sendFailed.notConnected": "Connection could not be established!",
"server.sendFailed.timedOut": "Timeout while sending a message",
"server.serverSocketClosed": "Server socket closed",
"server.serverSocketFailed": "Server socket could not be initialized.",
"server.start": "Start",
"server.stop": "Stop",
"sheet.back": "Back",
"sheet.cable": "Cable (LAN)",
"sheet.cablePlugged": "The cable is still plugged in.",
@@ -165,6 +184,10 @@
"terminal.interrupt": "Stop the command (Ctrl+C)",
"terminal.next": "Next command",
"terminal.previous": "Previous command",
"webServer.folder": "Subdirectory",
"webServer.hostName": "Hostname",
"webServer.useVirtualHosts": "Enable virtual hosts",
"webServer.virtualHosts": "Virtual hosts:",
"zoom.fit": "Show all",
"zoom.in": "Zoom in",
"zoom.level": "{percent} %",
@@ -1,6 +1,6 @@
// The editor's side of the simulation room (plan §6): its mode and speed,
// the cables that carry frames, a computer's network as it runs, the lines
// of the programs' windows and the actions taken in them. The room
// and views of the programs' windows and the actions taken in them. The room
// runs behind a SimHost — in a worker of this page (LocalSimHost) until F
// holds it (RemoteSimHost, F5); the editor keeps no simulation of its own.
@@ -9,6 +9,7 @@ import type * as Y from 'yjs';
import type { AppKind, NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol';
import { LocalSimHost } from '@mdg-filius/sim';
import SimWorker from '@mdg-filius/sim/worker?worker';
import type { Language } from '../i18n/i18n.js';
/** Makes the room of a document. */
export type SimHostFactory = (doc: Y.Doc) => SimHost;
@@ -35,9 +36,12 @@ export interface Simulation {
readonly nodes: ReadonlyMap<string, NodeSettings>;
/** Each watched program window's lines, by its topic. */
readonly apps: ReadonlyMap<string, AppLines>;
/** Each watched program window's view (a browser's page, a server's button and table), by its topic; null while it has none. */
readonly views: ReadonlyMap<string, unknown>;
/** Whether the core failed and the room went back to design mode, since the last start. */
readonly failed: boolean;
start(): void;
/** Starts in the language the person runs FILIUS in: the routers' pages come in it, as Java's in its locale. */
start(language?: Language): void;
stop(): void;
setSpeed(value: number): void;
/** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */
@@ -57,6 +61,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
const [nodes, setNodes] = useState<ReadonlyMap<string, NodeSettings>>(new Map());
const [failed, setFailed] = useState(false);
const [apps, setApps] = useState<ReadonlyMap<string, AppLines>>(new Map());
const [views, setViews] = useState<ReadonlyMap<string, unknown>>(new Map());
useEffect(() => {
const made = createHost(doc);
@@ -83,6 +88,9 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
return new Map(before).set(topic, { lines, live: message.from === 0 ? lines.length : (before.get(topic)?.live ?? 0) });
});
return;
case 'view':
setViews((before) => new Map(before).set(`app:${message.node}/${message.app}`, message.view));
return;
case 'error':
if (message.code === 'engineFailed') setFailed(true);
return;
@@ -98,9 +106,9 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
};
}, [doc, createHost]);
const start = useCallback(() => {
const start = useCallback((language?: Language) => {
setFailed(false);
host.current?.send({ v: 1, kind: 'start' });
host.current?.send(language === undefined ? { v: 1, kind: 'start' } : { v: 1, kind: 'start', language });
}, []);
const stop = useCallback(() => host.current?.send({ v: 1, kind: 'stop' }), []);
const setSpeed = useCallback((value: number) => host.current?.send({ v: 1, kind: 'speed', value }), []);
@@ -126,5 +134,5 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
host.current?.send({ v: 1, kind: 'input', node, app, action, args, reqId: crypto.randomUUID() });
}, []);
return { mode, speed, activeLinks, nodes, apps, failed, start, stop, setSpeed, watch, act };
return { mode, speed, activeLinks, nodes, apps, views, failed, start, stop, setSpeed, watch, act };
}
+45
View File
@@ -122,6 +122,20 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
'network.dnsServer': 'network_msg2',
'network.gateway': 'network_msg3',
'network.macAddress': 'network_msg9',
'browser.go': 'webbrowser_msg2',
'server.start': 'webserver_msg1',
'server.stop': 'webserver_msg2',
'webServer.virtualHosts': 'webserver_msg3',
'webServer.hostName': 'webserver_msg4',
'webServer.folder': 'webserver_msg5',
'webServer.useVirtualHosts': 'webserver_msg6',
'server.acceptingStopped': 'sw_serveranwendung_msg1',
'server.acceptingStarted': 'sw_serveranwendung_msg2',
'server.serverSocketClosed': 'sw_serveranwendung_msg3',
'server.serverSocketFailed': 'sw_serveranwendung_msg6',
'server.sendFailed.notConnected': 'sw_tcpsocket_msg6',
'server.sendFailed.connectionLost': 'sw_tcpsocket_msg7',
'server.sendFailed.timedOut': 'sw_tcpsocket_msg8',
};
/** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */
@@ -156,6 +170,20 @@ const FROM_JAVA_MENDED: Readonly<Record<string, readonly [string, (java: string)
],
};
/** Java's words around a value, as its programs join them („Verbindung zu“ + address + „hergestellt“). */
const FROM_JAVA_JOINED: Readonly<Record<string, readonly [string, string, string]>> = {
'server.connectionAccepted': ['sw_serveranwendung_msg4', '{address}', 'sw_serveranwendung_msg5'],
'server.connectionClosed': ['sw_servermitarbeiter_msg1', '{address}', 'sw_servermitarbeiter_msg2'],
};
/** Java's page for a server it cannot reach (sw_webbrowser_msg1): its title and its heading. */
const FROM_JAVA_PAGE: Readonly<Record<string, readonly [string, RegExp]>> = {
'browser.unreachableTitle': ['sw_webbrowser_msg1', /<title>(.*)<\/title>/],
'browser.unreachable': ['sw_webbrowser_msg1', /<h1>(.*)<\/h1>/],
};
const templates = join(dirname(fileURLToPath(import.meta.url)), '../../../tools/java-oracle/.upstream/src/main/resources/tmpl');
describe('the catalogs', () => {
it('have the same keys in German and English, each with a text', () => {
expect(Object.keys(CATALOGS.en).sort()).toEqual(Object.keys(CATALOGS.de).sort());
@@ -184,5 +212,22 @@ describe('the catalogs', () => {
expect(CATALOGS.de[key], key).toBe(mend(de.get(javaKey)!));
expect(CATALOGS.en[key], key).toBe(mend(en.get(javaKey)!));
}
for (const [key, [before, value, after]] of Object.entries(FROM_JAVA_JOINED)) {
expect(CATALOGS.de[key], key).toBe(`${de.get(before)} ${value} ${de.get(after)}`);
expect(CATALOGS.en[key], key).toBe(`${en.get(before)} ${value} ${en.get(after)}`);
}
for (const [key, [javaKey, part]] of Object.entries(FROM_JAVA_PAGE)) {
expect(CATALOGS.de[key], key).toBe(part.exec(de.get(javaKey)!)![1]);
expect(CATALOGS.en[key], key).toBe(part.exec(en.get(javaKey)!)![1]);
}
});
it.skipIf(!existsSync(templates))("write the browser's error page as Java's template does (tmpl/http_fehler)", () => {
for (const [language, file] of [['de', 'http_fehler_de_DE.txt'], ['en', 'http_fehler_en_GB.txt']] as const) {
const template = readFileSync(join(templates, file), 'utf8').replaceAll(':meldung:', '{message}').replaceAll(':code:', '{code}');
expect(CATALOGS[language]['browser.errorTitle']).toBe(/<title>(.*)<\/title>/.exec(template)![1]);
expect(CATALOGS[language]['browser.errorText']).toBe(/<p>(.*)<\/p>/.exec(template)![1]!.trim());
expect(/<h1>(.*)<\/h1>/.exec(template)![1]).toBe('{message}');
}
});
});
+41
View File
@@ -0,0 +1,41 @@
// A server's lines as Java's web server window wrote them
// (packages/sim/test/browserWindow.java.json, BrowserWindowRun): each kind
// the window got there, in Java's words.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import type { ServerLogEntry } from '@mdg-filius/core';
import { serverLine } from '../src/desktop/serverLines.js';
import { translate } from '../src/i18n/i18n.js';
const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../../sim/test/browserWindow.java.json'), 'utf8')) as { steps: { step: string; log?: string }[] };
const log = java.steps.find((step) => step.step === 'server:stop')!.log!;
const t = (key: string, values?: Readonly<Record<string, string | number>>) => translate('de', key, values);
describe("a server's lines", () => {
it("are Java's, line by line", () => {
const [, address, port] = /Verbindung zu ([\d.]+):(\d+) hergestellt/.exec(log)!;
const request = 'POST /ziel.html HTTP/1.1\nHost: www.filius.de\n\nname=Fil+ius&c=1&s=y&t=%C3%BC%26%3D&ok=Gut';
const answer = 'HTTP/1.1 200 OK\nContent-type: text/html\n\n<html><head><title>Ziel</title></head><body>Ziel</body></html>';
const entries: ServerLogEntry[] = [
{ kind: 'acceptingStarted' },
{ kind: 'connectionAccepted', address: address!, port: Number(port) },
{ kind: 'received', text: request },
{ kind: 'sent', text: answer },
{ kind: 'connectionClosed', address: address!, port: Number(port) },
{ kind: 'acceptingStopped' },
{ kind: 'serverSocketClosed' },
];
for (const entry of entries) expect(log, entry.kind).toContain(`${serverLine(entry, t)}\n`);
expect(log.startsWith(`${serverLine({ kind: 'acceptingStarted' }, t)}\n`)).toBe(true);
});
it('name a failed send and a port that is taken', () => {
expect(serverLine({ kind: 'sendFailed', failure: 'timedOut' }, t)).toBe('Zeitüberschreitung während des Versands einer Nachricht');
expect(serverLine({ kind: 'sendFailed', failure: 'notConnected' }, (key) => translate('en', key))).toBe('Connection could not be established!');
expect(serverLine({ kind: 'serverSocketFailed' }, t)).toBe('Server-Socket konnte nicht geöffnet werden.');
expect(serverLine({ kind: 'connectionAccepted', address: '1.2.3.4', port: 5 }, (key, values) => translate('en', key, values))).toBe('Connection to 1.2.3.4:5 established');
});
});
@@ -0,0 +1,92 @@
// The project the browser tests open for the web browser and web server
// (browser/fixtures/web-pages.fls), made here with the document's commands:
// a computer "Browser" (192.168.0.10) with a web browser, a computer
// "Server" (192.168.0.20) whose web server runs with the pages below — links,
// a mark in the page, a page in a folder, a GET and a POST form, an image
// there and one missing, a script and a handler that must not run —, and a
// router (192.168.0.1) for its pages, all on one switch. Compared on each run;
// written anew with FILIUS_WRITE_FIXTURES=1.
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { installationFiles, type VirtualFileSystem } from '@mdg-filius/core';
import { addDevice, connect, docArchive, emptyProject, fileEntry, readProject, ROOTS, setHostAddress, setInterfaceAddress, setSoftware, writeProject } from '@mdg-filius/doc';
import { openProjectArchive } from '@mdg-filius/fls';
const fixture = join(dirname(fileURLToPath(import.meta.url)), '../browser/fixtures/web-pages.fls');
/** The server's pages besides those its installation brings (index.html replaced). */
export const WEB_PAGES: Readonly<Record<string, string>> = {
'/webserver/index.html':
'<html><head><title>Seiten &amp; Formulare</title><script>document.title = "Skript"</script></head>' +
'<body onload="document.body.dataset.ran = \'yes\'"><h1 id="oben">Start</h1>' +
'<img src="splashscreen-mini.png" alt="Bild"><img src="fehlt.png" alt="Fehlt">' +
'<p><a href="zwei.html">Zwei</a> <a href="#unten">Unten</a> <a href="ordner/drei.html">Drei</a></p>' +
'<form action="ziel.html" method="get"><input name="q" value="a b"><input type="submit" name="los" value="Los"></form>' +
'<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>' +
'<p id="unten" style="margin-top: 3000px">Unten</p></body></html>',
'/webserver/zwei.html': '<html><head><title>Zwei</title></head><body><a href="index.html">Start</a></body></html>',
'/webserver/ziel.html': '<html><head><title>Ziel</title></head><body>Ziel</body></html>',
'/webserver/ordner/drei.html': '<html><head><title>Drei</title></head><body><a href="vier.html">Vier</a></body></html>',
'/webserver/ordner/vier.html': '<html><head><title>Vier im Ordner</title></head><body>Vier</body></html>',
};
function webPagesDoc(): Y.Doc {
const doc = new Y.Doc();
writeProject(doc, emptyProject());
let ids = 0;
const newId = () => `port-${++ids}`;
addDevice(doc, { kind: 'pc', id: 'browser', name: 'Browser', x: 60, y: 60, newId }, 'me');
addDevice(doc, { kind: 'pc', id: 'server', name: 'Server', x: 360, y: 60, newId }, 'me');
addDevice(doc, { kind: 'router', id: 'router', name: 'Router', x: 210, y: 260, newId }, 'me');
addDevice(doc, { kind: 'networkSwitch', id: 'switch', name: 'Switch', x: 210, y: 140, newId }, 'me');
for (const [node, link] of [['browser', 'cable-1'], ['server', 'cable-2'], ['router', 'cable-3']] as const) connect(doc, { node }, { node: 'switch' }, link, 'me');
setHostAddress(doc, 'browser', 'ipAddress', '192.168.0.10', 'me');
setHostAddress(doc, 'server', 'ipAddress', '192.168.0.20', 'me');
// The router's second card, unplugged, away from the computers' network (its own address would be 192.168.0.10, the browser's).
const routerPorts = (doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get('router')!.get('ports') as Y.Array<Y.Map<unknown>>).map((port) => port.get('id') as string);
setInterfaceAddress(doc, 'router', routerPorts[0]!, 'ipAddress', '192.168.0.1', 'me');
setInterfaceAddress(doc, 'router', routerPorts[1]!, 'ipAddress', '10.0.0.1', 'me');
setSoftware(doc, 'browser', { install: ['webBrowser'], remove: [] }, 'de', 'me');
setSoftware(doc, 'server', { install: ['webServer'], remove: [] }, 'de', 'me');
const server = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get('server')!;
(server.get('programs') as Y.Map<Y.Map<unknown>>).get('webServer')!.set('active', true);
const files = server.get('files') as Y.Map<Y.Map<unknown>>;
let order = files.size;
files.set('/webserver/ordner', fileEntry(order++, { kind: 'directory' }));
for (const [path, text] of Object.entries(WEB_PAGES)) {
if (files.has(path)) (files.get(path)!.get('text') as Y.Text).delete(0, (files.get(path)!.get('text') as Y.Text).length);
if (files.has(path)) (files.get(path)!.get('text') as Y.Text).insert(0, text);
else files.set(path, fileEntry(order++, { kind: 'text', text }));
}
return doc;
}
/** A computer's files, text as it is, the rest by kind. */
function filesOf(files: VirtualFileSystem | undefined): Record<string, string> {
return Object.fromEntries((files?.allEntries() ?? []).map((entry) => [entry.path, entry.content.kind === 'text' ? entry.content.text : entry.content.kind]));
}
describe("the browser tests' project with web pages", () => {
it('is made from the pages above', () => {
const made = docArchive(webPagesDoc()).archive;
if (process.env.FILIUS_WRITE_FIXTURES === '1' || !existsSync(fixture)) writeFileSync(fixture, made);
let ids = 0;
const open = (archive: Uint8Array) => openProjectArchive(archive, () => `id-${++ids}`);
const [saved, fresh] = [open(new Uint8Array(readFileSync(fixture))), open(made)];
const server = (project: typeof saved) => project.graph.nodes.find((node) => node.displayName === 'Server')!.id;
expect(filesOf(saved.virtualFileSystems.get(server(saved)))).toEqual(filesOf(fresh.virtualFileSystems.get(server(fresh))));
expect(filesOf(saved.virtualFileSystems.get(server(saved)))).toMatchObject(WEB_PAGES);
expect(saved.activePrograms.get(server(saved))).toEqual(new Set(['webServer']));
expect(saved.graph.nodes.map((node) => node.displayName).sort()).toEqual(['Browser', 'Router', 'Server', 'Switch']);
expect(saved.graph.links).toHaveLength(3);
// The installation's picture is there for the start page's image.
expect(installationFiles('webServer', 'de').map((file) => file.path)).toContain('/webserver/splashscreen-mini.png');
expect(readProject(webPagesDoc()).installedPrograms.get('browser')).toEqual(new Set(['webBrowser']));
});
});
+1
View File
@@ -2,3 +2,4 @@
export * from './host.js';
export * from './sim.js';
export * from './terminal.js';
export * from './web.js';
+20 -5
View File
@@ -4,7 +4,7 @@
// 6.1). The room runs on F (RemoteSimHost, F5) or in a web worker
// (LocalSimHost); the messages are the same.
import type { CaptureRow } from '@mdg-filius/core';
import type { CaptureRow, PageLanguage } from '@mdg-filius/core';
export const SIM_PROTOCOL_VERSION = 1;
@@ -26,8 +26,13 @@ export type ClientMessageKind = (typeof CLIENT_MESSAGE_KINDS)[number];
export type ClientMessage =
| { readonly v: 1; readonly kind: 'sub'; readonly topics: readonly Topic[] }
| { readonly v: 1; readonly kind: 'unsub'; readonly topics: readonly Topic[] }
/** Starts the simulation with the document as it is now. */
| { readonly v: 1; readonly kind: 'start' }
/**
* Starts the simulation with the document as it is now; the pages the
* devices write themselves (a router's) come in the language FILIUS runs in
* for the person who starts it, as Java's come in its locale (German
* without one).
*/
| { readonly v: 1; readonly kind: 'start'; readonly language?: PageLanguage }
| { readonly v: 1; readonly kind: 'stop' }
/** Java's speed slider, 1 to 100 per cent: how long a frame takes over a cable. */
| { readonly v: 1; readonly kind: 'speed'; readonly value: number }
@@ -42,7 +47,7 @@ export type ClientMessage =
readonly reqId: string;
};
export const HOST_MESSAGE_KINDS = ['state', 'activity', 'trace', 'app', 'node', 'ack', 'error'] as const;
export const HOST_MESSAGE_KINDS = ['state', 'activity', 'trace', 'app', 'view', 'node', 'ack', 'error'] as const;
export type HostMessageKind = (typeof HOST_MESSAGE_KINDS)[number];
export type SimMode = 'design' | 'simulation';
@@ -67,6 +72,13 @@ export type HostMessage =
| { readonly v: 1; readonly kind: 'trace'; readonly portId: string; readonly frames: readonly TraceFrame[]; readonly reset: boolean }
/** A program's new window lines from line `from` on; on subscribing, all (from 0). */
| { readonly v: 1; readonly kind: 'app'; readonly node: string; readonly app: AppKind; readonly from: number; readonly lines: readonly unknown[] }
/**
* What a program's window shows besides its lines (plan §7.3), whole: on
* subscribing and when it changes; null while it has nothing (its program
* does not run, a browser that never ran). The browser's (BrowserView) and
* the web server's (WebServerView) so far.
*/
| { readonly v: 1; readonly kind: 'view'; readonly node: string; readonly app: AppKind; readonly view: unknown }
/**
* A computer's first card as the simulation runs it (DHCP may change it):
* on subscribing, when it changes, and none while the simulation never ran.
@@ -115,8 +127,11 @@ export function parseClientMessage(value: unknown): Parsed<ClientMessage> {
return { message: { v: 1, kind: kind as 'sub' | 'unsub', topics: topics as Topic[] } };
}
case 'start':
if (value.language === undefined) return { message: { v: 1, kind: 'start' } };
if (value.language !== 'de' && value.language !== 'en') return bad('badMessage', `language ${String(value.language)}`);
return { message: { v: 1, kind: 'start', language: value.language } };
case 'stop':
return { message: { v: 1, kind: kind as 'start' | 'stop' } };
return { message: { v: 1, kind: 'stop' } };
case 'speed':
if (typeof value.value !== 'number' || !Number.isFinite(value.value)) return bad('badMessage', 'speed is no number');
return { message: { v: 1, kind: 'speed', value: value.value } };
+74
View File
@@ -0,0 +1,74 @@
// What the web browser's and the web server's windows show, as the room
// keeps them and every client shows them (plan §7.3), after
// GUIApplicationWebBrowserWindow and GUIApplicationWebServerWindow
// (measured with tools/java-oracle's BrowserWindowRun).
//
// The browser: Java's address line shows "http://", then the address of each
// load as "http://" + host + path without its query (also when nothing
// comes); its title is the program's name, then that address, then the
// title of the page once it came. The page itself goes to the client as it
// came — the client shows it in a sandbox, its images from the data that
// came for them.
/** The page the browser shows. */
export type BrowserPage =
/** Java's start page: the browser's logo. */
| { readonly kind: 'start' }
/** sw_webbrowser_msg1: the server could not be reached. */
| { readonly kind: 'unreachable' }
/** A status other than 200: the browser's error page for it (tmpl/http_fehler). */
| { readonly kind: 'error'; readonly statusCode: number }
/** An HTML page, with the images that came for it by their src as written (their content type and data in Base64). */
| { readonly kind: 'html'; readonly html: string; readonly images: Readonly<Record<string, BrowserImage>> }
/** Any other text (Java shows it as HTML). */
| { readonly kind: 'text'; readonly text: string }
/** An image on its own (Java shows its Base64 as HTML). */
| { readonly kind: 'image'; readonly image: BrowserImage };
export interface BrowserImage {
readonly contentType: string;
readonly data: string;
}
export interface BrowserView {
/** The address line as the room last set it: "http://" at first and after an address that is none. */
readonly address: string;
/** The address of the last load, the title until its page came; null before the first load (the title is the program's name). */
readonly loading: string | null;
/** Whether the page shown came for the last load (its title is the window's). */
readonly arrived: boolean;
readonly page: BrowserPage;
}
/** The browser before it loaded anything. */
export const BROWSER_START: BrowserView = { address: 'http://', loading: null, arrived: false, page: { kind: 'start' } };
/** The web server's window besides its lines: the start button, the virtual hosts' check box and table (five rows of host name and folder). */
export interface WebServerView {
readonly active: boolean;
readonly useVirtualHosts: boolean;
readonly virtualHosts: readonly (readonly [string, string])[];
}
/**
* The text of an HTML page's first title, as it is written (entities not
* decoded); undefined without one or with an empty one (Java keeps the
* title it had).
*/
export function htmlTitle(html: string): string | undefined {
const match = /<title(?:\s[^>]*)?>([\s\S]*?)<\/title\s*>/i.exec(html);
if (match === null) return undefined;
const text = match[1]!.replace(/<[^>]*>/g, '');
return text.trim() === '' ? undefined : text;
}
/**
* The browser window's title, as Java's: the program's name before the
* first load, then the address of the last load, then the title of its page
* once it came, where the page has one (`pageTitle`: an HTML page's, an
* error page's in the client's language).
*/
export function browserTitle(view: BrowserView, program: string, pageTitle: (page: BrowserPage) => string | undefined): string {
if (view.loading === null) return program;
return (view.arrived ? pageTitle(view.page) : undefined) ?? view.loading;
}
+28 -1
View File
@@ -2,7 +2,7 @@
// it refuses, each with its reason.
import { describe, expect, it } from 'vitest';
import { CLIENT_MESSAGE_KINDS, HOST_MESSAGE_KINDS, isTopic, parseClientMessage, parseHostMessage } from '../src/index.js';
import { BROWSER_START, browserTitle, CLIENT_MESSAGE_KINDS, HOST_MESSAGE_KINDS, htmlTitle, isTopic, parseClientMessage, parseHostMessage, type BrowserView } from '../src/index.js';
describe("a client's messages", () => {
it('reads each kind', () => {
@@ -16,6 +16,9 @@ describe("a client's messages", () => {
];
expect(messages.map((each) => each.kind)).toEqual([...CLIENT_MESSAGE_KINDS]);
for (const each of messages) expect(parseClientMessage(each)).toEqual({ message: each });
// A start in a language, and the language dropped where it is none.
for (const language of ['de', 'en']) expect(parseClientMessage({ v: 1, kind: 'start', language })).toEqual({ message: { v: 1, kind: 'start', language } });
expect(parseClientMessage({ v: 1, kind: 'stop', language: 'en' })).toEqual({ message: { v: 1, kind: 'stop' } });
});
it('names what is wrong', () => {
@@ -27,6 +30,8 @@ describe("a client's messages", () => {
expect(code({ v: 1, kind: 'sub', topics: 'activity' })).toBe('badMessage');
expect(code({ v: 1, kind: 'sub', topics: ['activity', 'trace:'] })).toBe('badTopic');
expect(code({ v: 1, kind: 'unsub', topics: ['app:node/paint'] })).toBe('badTopic');
expect(code({ v: 1, kind: 'start', language: 'fr' })).toBe('badMessage');
expect(code({ v: 1, kind: 'start', language: null })).toBe('badMessage');
expect(code({ v: 1, kind: 'speed', value: '50' })).toBe('badMessage');
expect(code({ v: 1, kind: 'speed', value: Number.NaN })).toBe('badMessage');
expect(code({ v: 1, kind: 'input', node: 'n', app: 'terminal', action: 'enter', args: {} })).toBe('badMessage');
@@ -47,3 +52,25 @@ describe("a host's messages", () => {
expect(parseHostMessage({ v: 0, kind: 'state' })).toEqual({ error: { code: 'badVersion', detail: 'version 0' } });
});
});
describe("the browser window's view", () => {
it("reads an HTML page's first title as it is written, none where it is empty", () => {
expect(htmlTitle('<html><head><TITLE lang="de">Links &amp; mehr</TITLE><title>Zwei</title></head></html>')).toBe('Links &amp; mehr');
expect(htmlTitle('<title>\nRouting-Tabelle\n </title >')).toBe('\nRouting-Tabelle\n ');
expect(htmlTitle('<title><b>Fett</b> und mehr</title>')).toBe('Fett und mehr');
expect(htmlTitle('<title> </title>')).toBeUndefined();
expect(htmlTitle('<titles>Nein</titles><p>Kein Titel</p>')).toBeUndefined();
});
it("titles the window with the program's name, then the address being loaded, then the page's title once it came", () => {
const pageTitle = (page: BrowserView['page']) => (page.kind === 'html' ? htmlTitle(page.html) : page.kind === 'unreachable' ? 'Fehler' : undefined);
expect(browserTitle(BROWSER_START, 'Webbrowser', pageTitle)).toBe('Webbrowser');
const shown: BrowserView = { address: 'http://a/', loading: 'http://a/', arrived: true, page: { kind: 'html', html: '<title>A</title>', images: {} } };
expect(browserTitle(shown, 'Webbrowser', pageTitle)).toBe('A');
// The next load: its address, while the page before still shows.
expect(browserTitle({ ...shown, loading: 'http://b/', arrived: false }, 'Webbrowser', pageTitle)).toBe('http://b/');
// A page without a title keeps the address.
expect(browserTitle({ ...shown, page: { kind: 'text', text: 'x' } }, 'Webbrowser', pageTitle)).toBe('http://a/');
expect(browserTitle({ ...shown, page: { kind: 'unreachable' } }, 'Webbrowser', pageTitle)).toBe('Fehler');
});
});
+102
View File
@@ -0,0 +1,102 @@
// A computer's web browser window as the room keeps it (plan §7.3), after
// Java's GUIApplicationWebBrowserWindow (measured with tools/java-oracle's
// BrowserWindowRun): the address line, the title and the page, the same for
// everyone who opens it, kept over stops as Java's window keeps it. An
// address typed or a link followed goes to the program as Java's window
// hands it over; what the program got (a page, an image, no server) comes
// back into the view with the next pull.
//
// Better than Java: the title is the page's title as the page means it
// (Java shows its HTML, "&amp;" and all — read by the client); a link counts
// from the page's folder (linkTarget; Java from the server's root), a mark in
// the page (#top) stays in the page (Java loads the host's start page).
import { javaBase64, linkTarget, webBrowserAddress, type WebBrowser } from '@mdg-filius/core';
import { BROWSER_START, type BrowserPage, type BrowserView } from '@mdg-filius/protocol';
const IMAGE_TYPES = new Set(['image/bmp', 'image/gif', 'image/jpg', 'image/png']);
export class BrowserWindow {
private viewValue: BrowserView = BROWSER_START;
/** How many of its program's lines the window has read. */
private seen = 0;
/** Where the page shown came from: its links go from there. */
private pageAddress: { readonly host: string; readonly path: string } | undefined;
constructor(private browser: WebBrowser) {}
get view(): BrowserView {
return this.viewValue;
}
/** The program of a new start (Java's window outlives the stop; its page stays). */
attach(browser: WebBrowser): void {
if (browser === this.browser) return;
this.browser = browser;
this.seen = 0;
}
/** What the program got since the last pull, into the view. */
pull(): void {
const lines = this.browser.log.all;
for (const line of lines.slice(this.seen)) {
switch (line.kind) {
case 'page':
this.pageAddress = { host: line.host, path: line.path };
this.show(browserPage(line.statusCode, line.contentType, line.body));
break;
case 'unreachable':
this.show({ kind: 'unreachable' });
break;
case 'image': {
const page = this.viewValue.page;
if (page.kind !== 'html' || line.data === undefined || line.contentType === undefined) break;
const image = { contentType: line.contentType.toLowerCase(), data: javaBase64(line.data).replace(/\s/g, '') };
this.viewValue = { ...this.viewValue, page: { ...page, images: { ...page.images, [line.source]: image } } };
break;
}
}
}
this.seen = lines.length;
}
/** Java's address line, Enter or the button: the address loaded, or the line set back to "http://" when it is none. */
open(typed: string): void {
const address = webBrowserAddress(typed);
if (address === undefined) {
this.viewValue = { ...this.viewValue, address: 'http://' };
return;
}
this.load(address.host, address.path);
}
/**
* A link of the page shown followed, or its form sent (a GET form's data
* after a "?" in the href, a POST form's as `post`). Nothing happens for a
* link the browser does not follow, or without a page.
*/
follow(href: string, post?: string): void {
const target = this.pageAddress === undefined ? undefined : linkTarget(this.pageAddress, href);
if (target !== undefined) this.load(target.host, target.path, post);
}
private load(host: string, path: string, post?: string): void {
// Java's window writes the URL it made: the browser's last host where it has none, the path without its query.
const shown = `http://${host === '' ? (this.browser.host ?? '') : host}${path.split('?')[0]}`;
this.viewValue = { ...this.viewValue, address: shown, loading: shown, arrived: false };
this.browser.load(host, path, post);
}
private show(page: BrowserPage): void {
this.viewValue = { ...this.viewValue, arrived: true, page };
}
}
/** The page for what came: an error page for a status other than 200, else by its content type. */
export function browserPage(statusCode: number, contentType: string | undefined, body: string | undefined): BrowserPage {
if (statusCode !== 200) return { kind: 'error', statusCode };
const type = contentType?.toLowerCase();
if (type === 'text/html') return { kind: 'html', html: body ?? '', images: {} };
if (type !== undefined && IMAGE_TYPES.has(type)) return { kind: 'image', image: { contentType: type, data: (body ?? '').replace(/\s/g, '') } };
return { kind: 'text', text: body ?? '' };
}
+129 -14
View File
@@ -15,8 +15,12 @@
//
// The engine outlives a stop: the capture stays, as Java's Lauscher does, and
// the next start goes on at the time it stopped, so that the capture's times
// keep rising (plan §6.2). So do the command lines' windows, as Java's do
// (TerminalWindow).
// keep rising (plan §6.2). So do the command lines' and browsers' windows, as
// Java's do (TerminalWindow, BrowserWindow).
//
// A program's window may show more than its lines (plan §7.3): the browser's
// page, the web server's button and table go to the clients as a view,
// whole, when they change.
//
// While the simulation runs, the document's programs are watched: a program
// installed or removed on the desktop (Java's software installation) is
@@ -35,6 +39,8 @@ import {
type Id,
type InstallableProgram,
type LogLine,
type PageLanguage,
type TopologySnapshot,
} from '@mdg-filius/core';
import { docSnapshot, installedSoftware, ROOTS } from '@mdg-filius/doc';
import {
@@ -48,7 +54,9 @@ import {
type SimMode,
type Topic,
type TraceFrame,
type WebServerView,
} from '@mdg-filius/protocol';
import { BrowserWindow } from './browserWindow.js';
import { TerminalWindow } from './terminalWindow.js';
export const TICK_MILLISECONDS = 200;
@@ -70,6 +78,8 @@ interface Client {
readonly appSent: Map<string, number>;
/** Per computer, the settings last sent (as text). */
readonly nodeSent: Map<Id, string>;
/** Per program window, the view last sent: the browser's as it is, others as text. */
readonly viewSent: Map<string, unknown>;
}
/** A program with a window: its lines. */
@@ -93,6 +103,10 @@ export class SimulationRoom {
private lastActivity: string[] = [];
/** Each computer's command line window, kept over stops. */
private readonly terminalWindows = new Map<Id, TerminalWindow>();
/** Each computer's browser window, kept over stops. */
private readonly browserWindows = new Map<Id, BrowserWindow>();
/** The language of the last start (the pages the devices write themselves). */
private language: PageLanguage | undefined;
/** Each computer's programs as the running engine has them. */
private software = new Map<Id, readonly InstallableProgram[]>();
@@ -129,31 +143,44 @@ export class SimulationRoom {
if (installed.length > 0 || removed.length > 0) changes.push({ node, installed, removed });
}
if (changes.length === 0) return;
const source = docSnapshot(this.doc);
const source = this.snapshot();
for (const { node, installed, removed } of changes) {
for (const program of removed) this.engine.removeProgram(node, program);
for (const program of installed) this.engine.installProgram(node, program, source);
this.software.set(node, now.get(node)!);
}
this.syncTerminalWindows();
this.syncWindows();
this.flush([...this.clients.values()], false);
}
/** A window for each command line the engine runs, the same over stops (TerminalWindow). */
private syncTerminalWindows(): void {
/** The document as the engine takes it, in the language of the start. */
private snapshot(): TopologySnapshot {
const snapshot = docSnapshot(this.doc);
return this.language === undefined ? snapshot : { ...snapshot, language: this.language };
}
/** A window for each command line and browser the engine runs, the same over stops (TerminalWindow, BrowserWindow). */
private syncWindows(): void {
for (const node of this.engine.state.topologySnapshot.nodes) {
const terminal = this.engine.terminal(node.id);
if (terminal === undefined) continue;
if (terminal !== undefined) {
const window = this.terminalWindows.get(node.id);
if (window === undefined) this.terminalWindows.set(node.id, new TerminalWindow(terminal));
else window.attach(terminal);
}
const browser = this.engine.webBrowser(node.id);
if (browser !== undefined) {
const window = this.browserWindows.get(node.id);
if (window === undefined) this.browserWindows.set(node.id, new BrowserWindow(browser));
else window.attach(browser);
}
}
}
/** A new client; it gets the room's state at once. */
connect(send: (message: HostMessage) => void): number {
const id = this.nextClient++;
this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), appSent: new Map(), nodeSent: new Map() });
this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), appSent: new Map(), nodeSent: new Map(), viewSent: new Map() });
send(this.state());
return id;
}
@@ -190,7 +217,10 @@ export class SimulationRoom {
for (const topic of input.topics) {
client.topics.add(topic);
if (topic.startsWith('trace:')) client.traceSent.delete(topic.slice('trace:'.length));
if (topic.startsWith('app:')) client.appSent.delete(topic);
if (topic.startsWith('app:')) {
client.appSent.delete(topic);
client.viewSent.delete(topic);
}
if (topic.startsWith('node:')) client.nodeSent.delete(topic.slice('node:'.length));
}
this.flush([client], true);
@@ -199,7 +229,7 @@ export class SimulationRoom {
for (const topic of input.topics) client.topics.delete(topic);
return;
case 'start':
this.start();
this.start(input.language);
return;
case 'stop':
this.stop();
@@ -227,14 +257,15 @@ export class SimulationRoom {
for (const client of this.clients.values()) client.send(output);
}
start(): void {
start(language?: PageLanguage): void {
if (this.mode === 'simulation') return;
const snapshot = docSnapshot(this.doc);
this.language = language;
const snapshot = this.snapshot();
this.engine.setSimulationSpeed(this.speed);
if (!this.guarded(() => this.engine.handle({ type: 'start', snapshot, seed: this.options.seed(), initialTimeMilliseconds: this.engine.state.currentTimeMilliseconds }))) return;
this.linkOfPort = new Map(snapshot.links.flatMap((link) => [[link.sourcePortId, link.id] as const, [link.targetPortId, link.id] as const]));
this.software = this.documentSoftware();
this.syncTerminalWindows();
this.syncWindows();
this.mode = 'simulation';
this.lastWall = this.options.now();
this.broadcast(this.state());
@@ -286,7 +317,19 @@ export class SimulationRoom {
private act(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
if (this.mode !== 'simulation') return 'notRunning';
if (!this.engine.state.topologySnapshot.nodes.some((node) => node.id === input.node)) return 'unknownNode';
if (input.app !== 'terminal') return 'unknownAction';
switch (input.app) {
case 'terminal':
return this.actInTerminal(input);
case 'webBrowser':
return this.actInBrowser(input);
case 'webServer':
return this.actInWebServer(input);
default:
return 'unknownAction';
}
}
private actInTerminal(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
// A window has a program while the simulation runs (checked above), from its first start on.
const window = this.terminalWindows.get(input.node);
if (window === undefined || this.engine.terminal(input.node) === undefined) return 'unknownApp';
@@ -303,6 +346,66 @@ export class SimulationRoom {
}
}
/** The address line (open) and the page's links and forms (follow). */
private actInBrowser(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
const window = this.browserWindows.get(input.node);
if (window === undefined || this.engine.webBrowser(input.node) === undefined) return 'unknownApp';
switch (input.action) {
case 'open':
if (typeof input.args.address !== 'string') return 'badArgs';
window.open(input.args.address);
return undefined;
case 'follow': {
const { href, post } = input.args;
if (typeof href !== 'string' || (post !== undefined && typeof post !== 'string')) return 'badArgs';
window.follow(href, post);
return undefined;
}
default:
return 'unknownAction';
}
}
/** The start button, the virtual hosts' check box and a cell of their table. */
private actInWebServer(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
const server = this.engine.webServer(input.node);
if (server === undefined) return 'unknownApp';
const { args } = input;
switch (input.action) {
case 'setActive':
if (typeof args.active !== 'boolean') return 'badArgs';
server.setActive(args.active);
return undefined;
case 'useVirtualHosts':
if (typeof args.use !== 'boolean') return 'badArgs';
server.setUseVirtualHosts(args.use);
return undefined;
case 'setVirtualHost':
if (!Number.isInteger(args.row) || (args.row as number) < 0 || (args.row as number) >= server.virtualHostTable.length) return 'badArgs';
if ((args.column !== 0 && args.column !== 1) || typeof args.value !== 'string') return 'badArgs';
server.setVirtualHost(args.row as number, args.column, args.value);
return undefined;
default:
return 'unknownAction';
}
}
/** What a program's window shows besides its lines; null when it has nothing (the program does not run, a browser never ran). */
private viewOf(node: Id, app: AppKind): unknown {
switch (app) {
case 'webBrowser':
return this.browserWindows.get(node)?.view ?? null;
case 'webServer': {
const server = this.engine.webServer(node);
if (server === undefined) return null;
const view: WebServerView = { active: server.isActive, useVirtualHosts: server.useVirtualHosts, virtualHosts: server.virtualHostTable.map(([host, folder]) => [host, folder] as const) };
return view;
}
default:
return undefined;
}
}
private program(node: Id, app: AppKind): WithLog | undefined {
switch (app) {
case 'terminal':
@@ -328,6 +431,7 @@ export class SimulationRoom {
private flush(clients: readonly Client[], subscribing: boolean): void {
if (!subscribing) this.updateActivity();
for (const window of this.terminalWindows.values()) window.pull();
for (const window of this.browserWindows.values()) window.pull();
const rowsCache = new Map<Id, TraceFrame[]>();
const traceFrames = (portId: Id, after: number): TraceFrame[] => {
const frames = this.engine.capture.frames(portId).filter((frame) => frame.number > after);
@@ -358,6 +462,17 @@ export class SimulationRoom {
client.send(message({ kind: 'trace', portId, frames, reset }));
} else {
const [, node, app] = /^app:([^/]+)\/(.+)$/.exec(topic)!;
const view = this.viewOf(node!, app as AppKind);
if (view !== undefined) {
// The browser's view is made anew only when it changes; the server's is small, compared as text.
const key = app === 'webBrowser' ? view : JSON.stringify(view);
if (!client.viewSent.has(topic) || client.viewSent.get(topic) !== key) {
client.viewSent.set(topic, key);
client.send(message({ kind: 'view', node: node!, app: app as AppKind, view }));
}
// The browser's lines are its view.
if (app === 'webBrowser') continue;
}
const sent = client.appSent.get(topic);
if (app === 'terminal') {
// The window's lines, kept over stops: a client goes on from those it has.
File diff suppressed because one or more lines are too long
+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: '' });
});
});
+97 -1
View File
@@ -196,7 +196,7 @@ describe('a simulation room', () => {
expect(reason({ node: 'nobody', app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownNode' });
expect(reason({ node: node('Switch 1').id, app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownApp' });
expect(reason({ node: notebook, app: 'terminal', action: 'paint' })).toMatchObject({ code: 'unknownAction' });
expect(reason({ node: notebook, app: 'webBrowser', action: 'open' })).toMatchObject({ code: 'unknownAction' });
expect(reason({ node: notebook, app: 'emailClient', action: 'send' })).toMatchObject({ code: 'unknownAction' });
expect(reason({ node: notebook, app: 'terminal', action: 'enter', args: { line: 7 } })).toMatchObject({ code: 'badArgs' });
expect(reason({ node: notebook, app: 'terminal', action: 'interrupt' })).toEqual({ v: 1, kind: 'ack', reqId: 'r' });
send({ kind: 'nonsense', reqId: 'q' });
@@ -371,3 +371,99 @@ describe('a simulation room', () => {
});
});
describe("a simulation room's browser and web server windows", () => {
const views = (got: readonly HostMessage[], node: Id, app: string) => ofKind(got, 'view').filter((each) => each.node === node && each.app === app).map((each) => each.view);
it("sends the browser's view and the web server's whole when they change, and none of the browser's lines", () => {
const { node, send, run, got } = setUp();
const notebook = node('Notebook 1.10').id;
const server = node('Webserver 5.10').id;
send({ kind: 'sub', topics: [`app:${notebook}/webBrowser`, `app:${server}/webServer`] });
// Before a start: neither has anything.
expect([views(got, notebook, 'webBrowser'), views(got, server, 'webServer')]).toEqual([[null], [null]]);
send({ kind: 'start' });
run(TICK_MILLISECONDS);
expect(views(got, notebook, 'webBrowser')).toEqual([null, { address: 'http://', loading: null, arrived: false, page: { kind: 'start' } }]);
expect(views(got, server, 'webServer')).toEqual([null, { active: true, useVirtualHosts: false, virtualHosts: [['', ''], ['', ''], ['', ''], ['', ''], ['', '']] }]);
send({ kind: 'input', node: notebook, app: 'webBrowser', action: 'open', args: { address: '141.99.5.10' }, reqId: 'open' });
expect(ofKind(got, 'ack').map((each) => each.reqId)).toEqual(['open']);
expect(views(got, notebook, 'webBrowser').at(-1)).toEqual({ address: 'http://141.99.5.10/', loading: 'http://141.99.5.10/', arrived: false, page: { kind: 'start' } });
run(20_000);
const page = views(got, notebook, 'webBrowser').at(-1) as { arrived: boolean; page: { kind: string; html: string; images: Record<string, unknown> } };
expect(page.arrived).toBe(true);
expect(page.page.kind).toBe('html');
expect(Object.keys(page.page.images).length).toBeGreaterThan(0);
// The browser's lines are its view; the server's go on as lines.
expect(ofKind(got, 'app').some((each) => each.app === 'webBrowser')).toBe(false);
expect(ofKind(got, 'app').filter((each) => each.app === 'webServer').flatMap((each) => each.lines as { kind: string }[]).some((line) => line.kind === 'received')).toBe(true);
// Nothing new: no view sent again.
const before = got.length;
run(1_000);
expect(ofKind(got.slice(before), 'view')).toEqual([]);
// The server's button, check box and table.
send({ kind: 'input', node: server, app: 'webServer', action: 'setActive', args: { active: false }, reqId: 's1' });
send({ kind: 'input', node: server, app: 'webServer', action: 'useVirtualHosts', args: { use: true }, reqId: 's2' });
send({ kind: 'input', node: server, app: 'webServer', action: 'setVirtualHost', args: { row: 4, column: 1, value: 'ordner' }, reqId: 's3' });
expect(views(got, server, 'webServer').at(-1)).toEqual({ active: false, useVirtualHosts: true, virtualHosts: [['', ''], ['', ''], ['', ''], ['', ''], ['', 'ordner']] });
expect(views(got, server, 'webServer')).toHaveLength(5);
// A stop: the browser keeps its page (Java's window does), the server has none; a client subscribing again gets both.
send({ kind: 'stop' });
expect(views(got, server, 'webServer').at(-1)).toBeNull();
const again = got.length;
send({ kind: 'sub', topics: [`app:${notebook}/webBrowser`] });
expect(views(got.slice(again), notebook, 'webBrowser')).toEqual([page]);
});
it('follows the links of a page, and writes the routers’ pages in the language of the start', () => {
const { node, send, run, got, room } = setUp();
const notebook = node('Notebook 1.10').id;
const gateway = docSnapshot(exampleDoc()).deviceConfigurations.get(notebook)!.defaultGateway;
send({ kind: 'sub', topics: [`app:${notebook}/webBrowser`] });
const page = () => (views(got, notebook, 'webBrowser').at(-1) as { address: string; page: { html?: string } });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
send({ kind: 'input', node: notebook, app: 'webBrowser', action: 'open', args: { address: gateway }, reqId: 'r' });
run(20_000);
expect(page().page.html).toContain('Administration des Vermittlungsrechners');
send({ kind: 'input', node: notebook, app: 'webBrowser', action: 'follow', args: { href: 'routes' }, reqId: 'f' });
run(20_000);
expect([page().address, page().page.html]).toEqual([`http://${gateway}/routes`, expect.stringContaining('Routing-/Weiterleitungs-Tabelle')]);
room.stop();
send({ kind: 'start', language: 'en' });
run(TICK_MILLISECONDS);
send({ kind: 'input', node: notebook, app: 'webBrowser', action: 'open', args: { address: gateway }, reqId: 'r' });
run(20_000);
expect(page().page.html).toContain('Router Administration');
});
it('names a wrong action in the windows', () => {
const { node, send, run, got } = setUp();
const notebook = node('Notebook 1.10').id;
const server = node('Webserver 5.10').id;
const dns = node('DNS-Server 2.10').id;
send({ kind: 'start' });
run(TICK_MILLISECONDS);
const cases: [string, string, string, Record<string, unknown>, string][] = [
[notebook, 'webBrowser', 'open', {}, 'badArgs'],
[notebook, 'webBrowser', 'follow', { href: 7 }, 'badArgs'],
[notebook, 'webBrowser', 'follow', { href: 'a', post: 1 }, 'badArgs'],
[notebook, 'webBrowser', 'back', {}, 'unknownAction'],
[dns, 'webBrowser', 'open', { address: 'x' }, 'unknownApp'],
[server, 'webServer', 'setActive', { active: 'yes' }, 'badArgs'],
[server, 'webServer', 'useVirtualHosts', { use: 1 }, 'badArgs'],
[server, 'webServer', 'setVirtualHost', { row: 5, column: 0, value: 'a' }, 'badArgs'],
[server, 'webServer', 'setVirtualHost', { row: -1, column: 0, value: 'a' }, 'badArgs'],
[server, 'webServer', 'setVirtualHost', { row: 1.5, column: 0, value: 'a' }, 'badArgs'],
[server, 'webServer', 'setVirtualHost', { row: 0, column: 2, value: 'a' }, 'badArgs'],
[server, 'webServer', 'setVirtualHost', { row: 0, column: 1, value: 2 }, 'badArgs'],
[server, 'webServer', 'restart', {}, 'unknownAction'],
[notebook, 'webServer', 'setActive', { active: true }, 'unknownApp'],
[server, 'emailServer', 'setActive', { active: true }, 'unknownAction'],
];
for (const [target, app, action, args, code] of cases) {
send({ kind: 'input', node: target, app, action, args, reqId: `${app}.${action}.${JSON.stringify(args)}` });
expect(ofKind(got, 'error').at(-1), `${app} ${action} ${JSON.stringify(args)}`).toMatchObject({ code, reqId: `${app}.${action}.${JSON.stringify(args)}` });
}
expect(ofKind(got, 'ack')).toEqual([]);
});
});
+40
View File
@@ -1292,3 +1292,43 @@ Gemessen (03.10.2026):
Darum misst `GetterDefaults` jetzt auch `#priority` der gespeicherten
Thread-Klassen (alle 5), die vorher unaufgelöst als 0 gelesen wurden.
### Webbrowser und Webserver (F4.6d)
`BrowserWindowRun` öffnet Javas `GUIApplicationWebBrowserWindow` und
`GUIApplicationWebServerWindow` auf je einem `GUIDesktopPanel` im Netz von
web-lan (Browser 192.168.0.20, Webserver .30 mit eigenen Seiten) und benutzt
sie wie eine Person: eine Adresse in die Adresszeile und Enter, ein Link
(das Ereignis, das Swings `HTMLEditorKit` daraus macht: das href gegen die
Basis des Dokuments), ein Formularknopf (der echte Knopf, den Swing für das
Formular zeichnet, mit `doClick`), am Server Startknopf, Kästchen und
Tabelle der virtuellen Hosts, dazu Stopp und Start des Systems. Nach jedem
Schritt schreibt es Adresszeile, Titel, die Seiten, die das Programm dem
Fenster gab, und die Anfragen des Servers, am Server Knopf, Kästchen,
Tabelle, Datei und Zeilen (`packages/sim/test/browserWindow.java.json`).
Gemessen (03.10.2026):
* Am Anfang zeigt die Adresszeile „http://“, der Titel den Programmnamen,
die Seite Javas Logo. Jeder Abruf schreibt „http://“ + Host + Pfad ohne
Query in Zeile und Titel; kam die Seite, wird ihr Titel der Titel des
Fensters — als HTML („Links &amp;amp; mehr“). Keine Adresse: die Zeile
zurück auf „http://“, der Titel bleibt, nichts lädt.
* Ein Query geht nie hinaus (`HTTPNachricht.getPfad` schneidet ihn ab):
weder getippt noch aus einem Link noch aus einem GET-Formular. Ein
POST-Formular schickt seine Felder in Reihenfolge samt gedrücktem Knopf,
form-codiert (`name=Fil+ius&c=1&s=y&t=%C3%BC%26%3D&ok=Gut`).
* Relative Links und Bilder zählen vom Wurzelordner des Servers: die Basis
des Dokuments ist der Bildzwischenspeicher, nicht die Seite (ein Bild in
`/ordner/drei.html` holt `bild.png` aus der Wurzel). Ein `#anker` lädt die
Startseite des Hosts.
* Bei einem Status außer 200 zeigt Java immer `sw_webbrowser_msg2`
(„Fehler“): `einlesenTextdatei` löst den schon aufgelösten Pfad der
Vorlage `tmpl/http_fehler_*` ein zweites Mal auf und findet sie nie. Ohne
Server: `sw_webbrowser_msg1`. Text und Bilder zeigt das Fenster als HTML.
* Der Webserver: „Starten“/„Anhalten“, das Kästchen schaltet die Tabelle
ein (ohne Datei). Jede geänderte Zelle schreibt `/www.conf/vhosts` mit
allen fünf Zeilen, leere als Leerzeilen; neu gelesen überspringt
`StringTokenizer` die Leerzeilen, und die Zelle einer unvollständigen
Zeile rückt in die davor. Liegt die Datei da, ist das Kästchen nach dem
nächsten Start wieder an, auch abgeschaltet.
+11 -1
View File
@@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then
fi
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
mkdir -p "$here/.build"
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java" "$here/src/TerminalWindowRun.java" "$here/src/InstallRun.java"
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java" "$here/src/TerminalWindowRun.java" "$here/src/InstallRun.java" "$here/src/BrowserWindowRun.java"
# FILIUS writes its settings below $HOME; keep them out of the real one.
mkdir -p "$here/.build/home"
# Removed first: a crashed run must not leave the previous result standing.
@@ -368,6 +368,16 @@ rm -f "$output"
HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -cp "$classpath:$here/.build" TerminalWindowRun "$output" 2>&1 | grep -v 'logback\|^$' || true
test -s "$output"
# FILIUS' web browser and web server windows used as a person does — an
# address typed, a link clicked, a form's button pressed, the server's
# button, check box and table — on web-lan's network (BrowserWindowRun): the
# room's browser window and the server's (packages/sim/test/browserWindow.test.ts).
output="$root/packages/sim/test/browserWindow.java.json"
rm -f "$output"
HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -cp "$classpath:$here/.build" BrowserWindowRun \
"$root/packages/core/test/scenarios/web-lan.scenario.json" "$output" 2>&1 | grep -v 'logback\|^$\|^\s*at \|AWT-EventQueue' || true
test -s "$output"
# FILIUS' own examples as Java FILIUS runs them, opened and started as FILIUS
# does (ExampleRun): the acceptance of F2 (packages/fls/test/examplesJava.test.ts)
# and the classics of F3 (web, email, gnutella; packages/fls/test/classicsJava.test.ts).
+338
View File
@@ -0,0 +1,338 @@
// Uses FILIUS' own web browser and web server windows
// (GUIApplicationWebBrowserWindow, GUIApplicationWebServerWindow on their
// GUIDesktopPanels) on web-lan's network as a person does: an address typed
// into the browser's address line and Enter pressed, a link clicked (the
// event Swing's HTMLEditorKit makes of it: the href against the document's
// base), a form's button pressed (the real button Swing draws for it), and
// on the server's window the start button, the virtual hosts' check box and
// table. After each step it writes what the windows show: the browser's
// address line and title, the page its program handed the window; the
// server's button, check box, table and log, and the requests it got. The
// reference for the editor's browser and web server windows
// (packages/sim/test/browserWindow.test.ts).
//
// Usage: java -cp filius.jar:lib/*:classes BrowserWindowRun <scenario.json> <output json>
import filius.gui.anwendungssicht.GUIApplicationWebBrowserWindow;
import filius.gui.anwendungssicht.GUIApplicationWebServerWindow;
import filius.gui.anwendungssicht.GUIApplicationWindow;
import filius.gui.anwendungssicht.GUIDesktopPanel;
import filius.software.system.Betriebssystem;
import filius.software.system.Datei;
import filius.software.system.Dateisystem;
import filius.software.www.HTTPNachricht;
import filius.software.www.WebBrowser;
import filius.software.www.WebServer;
import java.awt.Component;
import java.awt.Container;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.io.FileOutputStream;
import java.lang.reflect.Field;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.swing.AbstractButton;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JEditorPane;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.SwingUtilities;
import javax.swing.event.HyperlinkEvent;
import javax.swing.event.HyperlinkListener;
import javax.swing.text.html.HTMLDocument;
public class BrowserWindowRun {
static final String BROWSER = "00000000-0000-0000-0000-000000000201";
static final String SERVER = "00000000-0000-0000-0000-000000000203";
static final List<Object> steps = new ArrayList<>();
/** What the browser's program handed its window since the last step. */
static final List<String> pages = new ArrayList<>();
/** The requests the server got since the last step (its ">>" lines). */
static final List<String> requests = new ArrayList<>();
static Object field(Class<?> owner, Object object, String name) throws Exception {
Field field = owner.getDeclaredField(name);
field.setAccessible(true);
return field.get(object);
}
static String title(GUIApplicationWindow window) throws Exception {
return ((JLabel) field(GUIApplicationWindow.class, window, "titleLabel")).getText();
}
static void browserStep(GUIApplicationWebBrowserWindow window, String step) throws Exception {
Map<String, Object> result = new LinkedHashMap<>();
result.put("step", step);
result.put("address", ((JTextField) field(GUIApplicationWebBrowserWindow.class, window, "urlFeld")).getText());
result.put("title", title(window));
synchronized (pages) {
result.put("pages", new ArrayList<>(pages));
pages.clear();
}
synchronized (requests) {
result.put("requests", new ArrayList<>(requests));
requests.clear();
}
steps.add(result);
}
static void serverStep(GUIApplicationWebServerWindow window, WebServer server, Dateisystem files, String step) throws Exception {
Map<String, Object> result = new LinkedHashMap<>();
result.put("step", step);
result.put("title", title(window));
result.put("button", ((JButton) field(GUIApplicationWebServerWindow.class, window, "buttonStart")).getText());
result.put("virtualHosts", ((JCheckBox) field(GUIApplicationWebServerWindow.class, window, "showVHosts")).isSelected());
result.put("serverVirtualHosts", server.isUseVHost());
List<Object> rows = new ArrayList<>();
for (String[] row : server.getVHostArray()) rows.add(Arrays.asList(row[0], row[1]));
result.put("table", rows);
Datei vhosts = files.holeDatei("/www.conf/vhosts");
result.put("vhostsFile", vhosts == null ? null : vhosts.getDateiInhalt());
result.put("log", ((JTextArea) field(GUIApplicationWebServerWindow.class, window, "logArea")).getText());
steps.add(result);
}
static void type(GUIApplicationWebBrowserWindow window, String typed) throws Exception {
JTextField address = (JTextField) field(GUIApplicationWebBrowserWindow.class, window, "urlFeld");
SwingUtilities.invokeAndWait(() -> {
address.setText(typed);
KeyEvent event = new KeyEvent(address, KeyEvent.KEY_PRESSED, System.currentTimeMillis(), 0, KeyEvent.VK_ENTER, KeyEvent.CHAR_UNDEFINED);
for (KeyListener listener : address.getKeyListeners()) listener.keyPressed(event);
});
settle();
}
/** Waits until the browser's program handed its window a page and its images came (or 25 seconds passed: nothing loads). */
static void settle() throws Exception {
long until = System.currentTimeMillis() + 25000;
int known = -1;
long quiet = 0;
while (System.currentTimeMillis() < until) {
Thread.sleep(100);
int count;
boolean page;
synchronized (pages) {
count = pages.size();
page = pages.stream().anyMatch(each -> !each.equals("update"));
}
synchronized (requests) {
count += requests.size();
}
if (count != known) {
known = count;
quiet = System.currentTimeMillis();
} else if (page && System.currentTimeMillis() - quiet > 2500) {
return;
}
}
}
/** A click on a link: HTMLEditorKit.LinkController's event, the href against the document's base. */
static void follow(GUIApplicationWebBrowserWindow window, String href) throws Exception {
JEditorPane pane = (JEditorPane) field(GUIApplicationWebBrowserWindow.class, window, "anzeigeFeld");
SwingUtilities.invokeAndWait(() -> {
try {
URL base = ((HTMLDocument) pane.getDocument()).getBase();
HyperlinkEvent event = new HyperlinkEvent(pane, HyperlinkEvent.EventType.ACTIVATED, new URL(base, href), href);
for (HyperlinkListener listener : pane.getHyperlinkListeners()) listener.hyperlinkUpdate(event);
} catch (Exception error) {
throw new RuntimeException(error);
}
});
settle();
}
static void reload(GUIApplicationWebBrowserWindow window) throws Exception {
type(window, "www.filius.de/links.html");
synchronized (pages) {
pages.clear();
}
synchronized (requests) {
requests.clear();
}
}
static AbstractButton find(Container container, String text) {
for (Component component : container.getComponents()) {
if (component instanceof AbstractButton && text.equals(((AbstractButton) component).getText())) return (AbstractButton) component;
if (component instanceof Container) {
AbstractButton found = find((Container) component, text);
if (found != null) return found;
}
}
return null;
}
/** A press on the button Swing drew for a form's submit input. */
static void press(GUIApplicationWebBrowserWindow window, String label) throws Exception {
JEditorPane pane = (JEditorPane) field(GUIApplicationWebBrowserWindow.class, window, "anzeigeFeld");
SwingUtilities.invokeAndWait(() -> {
pane.revalidate();
pane.doLayout();
});
Thread.sleep(500);
AbstractButton[] button = new AbstractButton[1];
SwingUtilities.invokeAndWait(() -> button[0] = find(pane, label));
if (button[0] == null) throw new IllegalStateException("no button " + label);
SwingUtilities.invokeAndWait(() -> button[0].doClick());
settle();
}
static void file(Dateisystem files, String directory, String name, String type, String content) {
files.speicherDatei(directory, new Datei(name, type, content));
}
public static void main(String[] args) throws Exception {
try {
run(args);
} catch (Throwable error) {
error.printStackTrace();
System.exit(1);
}
System.exit(0);
}
static void run(String[] args) throws Exception {
ScenarioNetwork network = ScenarioNetwork.read(args[0]);
Betriebssystem serverSystem = (Betriebssystem) network.nodes.get(SERVER).getSystemSoftware();
Betriebssystem browserSystem = (Betriebssystem) network.nodes.get(BROWSER).getSystemSoftware();
serverSystem.installApp("filius.software.www.WebServer", false);
browserSystem.installApp("filius.software.www.WebBrowser", false);
WebServer server = (WebServer) serverSystem.holeSoftware("filius.software.www.WebServer");
WebBrowser browser = (WebBrowser) browserSystem.holeSoftware("filius.software.www.WebBrowser");
Dateisystem files = serverSystem.getDateisystem();
String image = files.holeDatei("/webserver/splashscreen-mini.png").getDateiInhalt();
file(files, "/webserver", "links.html", "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>");
file(files, "/webserver", "ziel.html", "html", "<html><head><title>Ziel</title></head><body>Ziel</body></html>");
file(files, "/webserver", "ohne.html", "html", "<html><body>Ohne Titel</body></html>");
file(files, "/webserver", "text.txt", "txt", "Nur Text\nzwei Zeilen");
file(files, "/webserver", "bild.png", "png", image);
files.erstelleVerzeichnis("/webserver", "ordner");
file(files, "/webserver/ordner", "drei.html", "html", "<html><head><title>Drei</title></head><body><a href=\"vier.html\">vier</a><img src=\"bild.png\"></body></html>");
file(files, "/webserver/ordner", "vier.html", "html", "<html><body>Vier im Ordner</body></html>");
file(files, "/webserver", "vier.html", "html", "<html><body>Vier oben</body></html>");
server.hinzuBeobachter((observable, data) -> {
String text = String.valueOf(data);
if (text.startsWith(">>")) synchronized (requests) { requests.add(text.substring(2)); }
});
browser.hinzuBeobachter((observable, data) -> {
synchronized (pages) {
if (data instanceof HTTPNachricht) {
HTTPNachricht page = (HTTPNachricht) data;
pages.add(page.getStatusCode() + " " + page.getContentType() + "\n" + page.getDaten());
} else {
pages.add("update");
}
}
});
network.start();
Thread.sleep(500);
JFrame frame = new JFrame();
GUIDesktopPanel serverDesktop = new GUIDesktopPanel(serverSystem);
GUIDesktopPanel browserDesktop = new GUIDesktopPanel(browserSystem);
frame.getContentPane().setLayout(new java.awt.FlowLayout());
frame.getContentPane().add(serverDesktop);
frame.getContentPane().add(browserDesktop);
GUIApplicationWebServerWindow serverWindow = (GUIApplicationWebServerWindow) serverDesktop.starteAnwendung("filius.software.www.WebServer");
GUIApplicationWebBrowserWindow window = (GUIApplicationWebBrowserWindow) browserDesktop.starteAnwendung("filius.software.www.WebBrowser");
SwingUtilities.invokeAndWait(() -> {
frame.pack();
frame.setVisible(true);
});
Thread.sleep(500);
serverStep(serverWindow, server, files, "server:open");
browserStep(window, "open");
type(window, "192.168.0.30");
browserStep(window, "type:192.168.0.30 (server off)");
// The links page loaded anew before each link and button, without a step of its own.
SwingUtilities.invokeAndWait(() -> {
try {
((JButton) field(GUIApplicationWebServerWindow.class, serverWindow, "buttonStart")).doClick();
} catch (Exception error) {
throw new RuntimeException(error);
}
});
Thread.sleep(500);
serverStep(serverWindow, server, files, "server:start");
for (String typed : new String[] { "192.168.0.30", "www.filius.de/ohne.html", "kein host!", "http://www.filius.de/nichts.html",
"leer.filius.de", "www.filius.de/text.txt", "www.filius.de/links.html?x=1", "www.filius.de/links.html" }) {
type(window, typed);
browserStep(window, "type:" + typed);
}
serverStep(serverWindow, server, files, "server:after pages");
for (String href : new String[] { "ziel.html", "/text.txt", "http://192.168.0.30/", "ordner/drei.html", "vier.html" }) {
follow(window, href);
browserStep(window, "follow:" + href);
reload(window);
}
for (String href : new String[] { "ziel.html?a=1&b=2", "#oben" }) {
follow(window, href);
browserStep(window, "follow:" + href);
reload(window);
}
press(window, "Los");
browserStep(window, "press:Los");
reload(window);
press(window, "Gut");
browserStep(window, "press:Gut");
// The virtual hosts: switched on, rows written as the table's editor does (changeVHostTable), a stop and a start.
JCheckBox box = (JCheckBox) field(GUIApplicationWebServerWindow.class, serverWindow, "showVHosts");
SwingUtilities.invokeAndWait(box::doClick);
Thread.sleep(300);
serverStep(serverWindow, server, files, "server:virtual hosts on");
String[][] cells = { { "0", "0", "seite.filius.de" }, { "0", "1", "vhost1" }, { "1", "0", "nurhost.filius.de" }, { "2", "1", "nurordner" },
{ "3", "0", "andere.filius.de" }, { "3", "1", "vhost2" } };
for (String[] cell : cells) server.changeVHostTable(Integer.parseInt(cell[0]), Integer.parseInt(cell[1]), cell[2]);
serverStep(serverWindow, server, files, "server:rows written");
serverSystem.beenden();
serverSystem.starten();
Thread.sleep(500);
serverStep(serverWindow, server, files, "server:restart");
SwingUtilities.invokeAndWait(box::doClick);
Thread.sleep(300);
serverStep(serverWindow, server, files, "server:virtual hosts off");
serverSystem.beenden();
serverSystem.starten();
Thread.sleep(500);
serverStep(serverWindow, server, files, "server:restart after off");
SwingUtilities.invokeAndWait(() -> {
try {
((JButton) field(GUIApplicationWebServerWindow.class, serverWindow, "buttonStart")).doClick();
} catch (Exception error) {
throw new RuntimeException(error);
}
});
Thread.sleep(1000);
serverStep(serverWindow, server, files, "server:stop");
Map<String, Object> output = new LinkedHashMap<>();
output.put("steps", steps);
try (FileOutputStream out = new FileOutputStream(args[1])) {
out.write((FlsOracle.Json.write(output, "") + "\n").getBytes(StandardCharsets.UTF_8));
}
network.stop();
System.out.println("browser window steps: " + steps.size());
}
}