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:
File diff suppressed because one or more lines are too long
@@ -10,7 +10,13 @@
|
|||||||
// cannot read gets 400 or 501, one without a version is HTTP/1.1 (Java's
|
// 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
|
// 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
|
// 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
|
// 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).
|
// 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']);
|
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 {
|
export class WebServer extends TcpServerProgram {
|
||||||
private readonly documentRoot: string;
|
private readonly documentRoot: string;
|
||||||
private readonly virtualHosts: readonly (readonly [string, string])[];
|
private virtualHosts: [string, string][];
|
||||||
private readonly useVirtualHosts: boolean;
|
private useVirtualHostsValue: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
engine: NetworkRuntimeEngine,
|
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) {
|
for (let index = 0; index < lines.length && rows.length < VIRTUAL_HOST_ROWS; index += 2) {
|
||||||
rows.push([lines[index]!, lines[index + 1] ?? '']);
|
rows.push([lines[index]!, lines[index + 1] ?? '']);
|
||||||
}
|
}
|
||||||
|
while (rows.length < VIRTUAL_HOST_ROWS) rows.push(['', '']);
|
||||||
this.virtualHosts = rows;
|
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. */
|
/** WebServer.vhostPrefix: the folder of the host name, ignoring case; "" when none. */
|
||||||
private virtualHostFolder(host: string | undefined): string {
|
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] ?? '';
|
return this.virtualHosts.find(([name]) => name.toLowerCase() === host.toLowerCase())?.[1] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +234,35 @@ function javaSplit(text: string, separator: string): string[] {
|
|||||||
return parts;
|
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. */
|
/** 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}$/;
|
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 =
|
export const IP_ADDRESS_PATTERN =
|
||||||
@@ -218,15 +292,24 @@ export function imageSources(html: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type WebBrowserLogEntry =
|
export type WebBrowserLogEntry =
|
||||||
/** A page came: shown as it is with status 200, else as the browser's error page for the status. */
|
/** 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 statusCode: number; readonly contentType: string | undefined; readonly body: string | undefined }
|
| {
|
||||||
|
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). */
|
/** sw_webbrowser_msg1: the server could not be reached (no address, no connection, no answer). */
|
||||||
| { readonly kind: 'unreachable' }
|
| { readonly kind: 'unreachable' }
|
||||||
/** An image of the page came (or not: its status); Java tells its window, which loads it anew. */
|
/** 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 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 {
|
export interface WebBrowserPage {
|
||||||
|
readonly host: string;
|
||||||
|
readonly path: string;
|
||||||
readonly statusCode: number;
|
readonly statusCode: number;
|
||||||
readonly contentType: string | undefined;
|
readonly contentType: string | undefined;
|
||||||
readonly body: string | undefined;
|
readonly body: string | undefined;
|
||||||
@@ -238,7 +321,7 @@ export class WebBrowser {
|
|||||||
private hostValue: string | undefined;
|
private hostValue: string | undefined;
|
||||||
private socketId: Id | undefined;
|
private socketId: Id | undefined;
|
||||||
private images: string[] = [];
|
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;
|
private readonly commands: CommandThread;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -326,22 +409,22 @@ export class WebBrowser {
|
|||||||
if (response.statusCode === 200 && response.contentType?.toLowerCase() === 'text/html' && response.body !== undefined) {
|
if (response.statusCode === 200 && response.contentType?.toLowerCase() === 'text/html' && response.body !== undefined) {
|
||||||
this.images = imageSources(response.body);
|
this.images = imageSources(response.body);
|
||||||
}
|
}
|
||||||
this.shown = { statusCode: response.statusCode, contentType: response.contentType, body: response.body, images: new Map() };
|
const page = { host: this.hostValue ?? '', path, statusCode: response.statusCode, contentType: response.contentType, body: response.body };
|
||||||
this.log.write({ kind: 'page', 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 {
|
private *retrieveImages(): Steps {
|
||||||
|
const pagePath = this.shown?.path ?? '/';
|
||||||
for (const source of this.images) {
|
for (const source of this.images) {
|
||||||
if (!this.isConnected) return;
|
if (!this.isConnected) return;
|
||||||
const response = yield* this.exchange(source, undefined);
|
const response = yield* this.exchange(pageReference(pagePath, source), undefined);
|
||||||
if (response === undefined) continue;
|
if (response === undefined) continue;
|
||||||
const isImage = response.contentType !== undefined && IMAGE_TYPES.has(response.contentType.toLowerCase());
|
const isImage = response.contentType !== undefined && IMAGE_TYPES.has(response.contentType.toLowerCase());
|
||||||
if (response.statusCode === 200 && isImage) {
|
const data = response.statusCode === 200 && isImage ? javaBase64Decode(response.body ?? '') : undefined;
|
||||||
const data = javaBase64Decode(response.body ?? '');
|
if (data !== undefined) this.shown?.images.set(source, data);
|
||||||
if (data !== undefined) this.shown?.images.set(source, data);
|
this.log.write({ kind: 'image', source, statusCode: response.statusCode, contentType: response.contentType, ...(data === undefined ? {} : { data }) });
|
||||||
}
|
|
||||||
this.log.write({ kind: 'image', source, statusCode: response.statusCode, contentType: response.contentType });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
formatHttpResponse,
|
formatHttpResponse,
|
||||||
imageSources,
|
imageSources,
|
||||||
javaBase64,
|
javaBase64,
|
||||||
|
linkTarget,
|
||||||
|
pageReference,
|
||||||
javaBase64Decode,
|
javaBase64Decode,
|
||||||
parseHttpRequest,
|
parseHttpRequest,
|
||||||
parseHttpResponse,
|
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', () => {
|
describe('the images of a page', () => {
|
||||||
it('finds each img tag, in any case, its src in any quotes', () => {
|
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([
|
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');
|
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', () => {
|
it('serves nothing without files', () => {
|
||||||
const engine = startedEngine({ ...network(new VirtualFileSystem()), fileSystemsByNodeId: new Map() });
|
const engine = startedEngine({ ...network(new VirtualFileSystem()), fileSystemsByNodeId: new Map() });
|
||||||
expect(ask(engine, 'GET / HTTP/1.1')).toEqual(['HTTP/1.1 404 Not Found\n']);
|
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);
|
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', () => {
|
it('fetches images only for an HTML page', () => {
|
||||||
const engine = startedEngine(network(files({ '/webserver/text.txt': '<img src="a.png">' })));
|
const engine = startedEngine(network(files({ '/webserver/text.txt': '<img src="a.png">' })));
|
||||||
const browser = engine.webBrowser(CLIENT)!;
|
const browser = engine.webBrowser(CLIENT)!;
|
||||||
|
|||||||
Binary file not shown.
@@ -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 });
|
||||||
|
});
|
||||||
@@ -20,7 +20,7 @@ import { Palette, type PaletteKind } from './design/Palette.js';
|
|||||||
import { RouterDialog } from './design/RouterDialog.js';
|
import { RouterDialog } from './design/RouterDialog.js';
|
||||||
import { DeviceSheet } from './sheet/DeviceSheet.js';
|
import { DeviceSheet } from './sheet/DeviceSheet.js';
|
||||||
import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.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 { ModeBar } from './simulation/ModeBar.js';
|
||||||
import { useSimulation, type SimHostFactory } from './simulation/useSimulation.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 }) {
|
export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly createSimHost?: SimHostFactory }) {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
const language = useLanguage();
|
||||||
const view = useTopologyView(doc);
|
const view = useTopologyView(doc);
|
||||||
const simulation = useSimulation(doc, createSimHost);
|
const simulation = useSimulation(doc, createSimHost);
|
||||||
const simulating = simulation.mode === 'simulation';
|
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' }}>
|
<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 : (
|
{simulating ? null : (
|
||||||
<>
|
<>
|
||||||
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,11 +11,14 @@
|
|||||||
// On a wide screen the window lies over the surface and moves by its title
|
// 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
|
// 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
|
// 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 { useCallback, useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||||
import type * as Y from 'yjs';
|
import type * as Y from 'yjs';
|
||||||
import { desktopView } from '@mdg-filius/doc';
|
import { desktopView } from '@mdg-filius/doc';
|
||||||
|
import { browserTitle } from '@mdg-filius/protocol';
|
||||||
import background from '../assets/desktop/hintergrundbild.png';
|
import background from '../assets/desktop/hintergrundbild.png';
|
||||||
import networkOff from '../assets/desktop/netzwek_aus.png';
|
import networkOff from '../assets/desktop/netzwek_aus.png';
|
||||||
import networkOn from '../assets/desktop/netzwek_c.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 type { Simulation } from '../simulation/useSimulation.js';
|
||||||
import { ProgramFrame } from './ProgramFrame.js';
|
import { ProgramFrame } from './ProgramFrame.js';
|
||||||
import { desktopPrograms, INSTALLATION_ICON, JAVA_PROGRAMS, type DesktopProgram } from './programs.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 { InstallationPanel } from './InstallationPanel.js';
|
||||||
import { TerminalPanel } from './TerminalPanel.js';
|
import { TerminalPanel } from './TerminalPanel.js';
|
||||||
|
import { WebServerPanel } from './WebServerPanel.js';
|
||||||
import './desktop.css';
|
import './desktop.css';
|
||||||
|
|
||||||
export interface DesktopWindowProps {
|
export interface DesktopWindowProps {
|
||||||
@@ -54,7 +60,7 @@ export interface DesktopWindowProps {
|
|||||||
export type DesktopWindowName = DesktopProgram | 'installation';
|
export type DesktopWindowName = DesktopProgram | 'installation';
|
||||||
|
|
||||||
/** The programs whose windows are built so far. */
|
/** 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). */
|
/** 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) {
|
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} />
|
<InstallationPanel doc={doc} node={id} installed={view.programs} origin={origin} onDone={home} />
|
||||||
</ProgramFrame>
|
</ProgramFrame>
|
||||||
) : shown === null ? null : (
|
) : 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 === '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>
|
</ProgramFrame>
|
||||||
)}
|
)}
|
||||||
{info ? (
|
{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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -379,3 +379,160 @@
|
|||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
flex: none;
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.alreadyConnected": "Komponenten bereits verbunden",
|
||||||
"cable.lan": "LAN-Anschluss (intern)",
|
"cable.lan": "LAN-Anschluss (intern)",
|
||||||
"cable.lanOccupied": "LAN-Anschluss (intern) bereits belegt!",
|
"cable.lanOccupied": "LAN-Anschluss (intern) bereits belegt!",
|
||||||
@@ -117,6 +124,18 @@
|
|||||||
"router.cancel": "Abbrechen",
|
"router.cancel": "Abbrechen",
|
||||||
"router.prompt": "Wählen Sie die Anzahl der Schnittstellen des Vermittlungsrechners aus!",
|
"router.prompt": "Wählen Sie die Anzahl der Schnittstellen des Vermittlungsrechners aus!",
|
||||||
"router.title": "Anzahl Schnittstellen",
|
"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.back": "Zurück",
|
||||||
"sheet.cable": "Kabelgebunden (LAN)",
|
"sheet.cable": "Kabelgebunden (LAN)",
|
||||||
"sheet.cablePlugged": "Das Kabel ist noch eingesteckt.",
|
"sheet.cablePlugged": "Das Kabel ist noch eingesteckt.",
|
||||||
@@ -165,6 +184,10 @@
|
|||||||
"terminal.interrupt": "Befehl abbrechen (Strg+C)",
|
"terminal.interrupt": "Befehl abbrechen (Strg+C)",
|
||||||
"terminal.next": "Nächster Befehl",
|
"terminal.next": "Nächster Befehl",
|
||||||
"terminal.previous": "Vorheriger 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.fit": "Alles zeigen",
|
||||||
"zoom.in": "Vergrößern",
|
"zoom.in": "Vergrößern",
|
||||||
"zoom.level": "{percent} %",
|
"zoom.level": "{percent} %",
|
||||||
|
|||||||
@@ -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.alreadyConnected": "Components already connected",
|
||||||
"cable.lan": "LAN port (internal)",
|
"cable.lan": "LAN port (internal)",
|
||||||
"cable.lanOccupied": "LAN port (internal) already occupied!",
|
"cable.lanOccupied": "LAN port (internal) already occupied!",
|
||||||
@@ -117,6 +124,18 @@
|
|||||||
"router.cancel": "Cancel",
|
"router.cancel": "Cancel",
|
||||||
"router.prompt": "Choose number of NIC of the router!",
|
"router.prompt": "Choose number of NIC of the router!",
|
||||||
"router.title": "Number of NIC",
|
"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.back": "Back",
|
||||||
"sheet.cable": "Cable (LAN)",
|
"sheet.cable": "Cable (LAN)",
|
||||||
"sheet.cablePlugged": "The cable is still plugged in.",
|
"sheet.cablePlugged": "The cable is still plugged in.",
|
||||||
@@ -165,6 +184,10 @@
|
|||||||
"terminal.interrupt": "Stop the command (Ctrl+C)",
|
"terminal.interrupt": "Stop the command (Ctrl+C)",
|
||||||
"terminal.next": "Next command",
|
"terminal.next": "Next command",
|
||||||
"terminal.previous": "Previous 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.fit": "Show all",
|
||||||
"zoom.in": "Zoom in",
|
"zoom.in": "Zoom in",
|
||||||
"zoom.level": "{percent} %",
|
"zoom.level": "{percent} %",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// The editor's side of the simulation room (plan §6): its mode and speed,
|
// 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
|
// 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
|
// 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.
|
// 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 type { AppKind, NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol';
|
||||||
import { LocalSimHost } from '@mdg-filius/sim';
|
import { LocalSimHost } from '@mdg-filius/sim';
|
||||||
import SimWorker from '@mdg-filius/sim/worker?worker';
|
import SimWorker from '@mdg-filius/sim/worker?worker';
|
||||||
|
import type { Language } from '../i18n/i18n.js';
|
||||||
|
|
||||||
/** Makes the room of a document. */
|
/** Makes the room of a document. */
|
||||||
export type SimHostFactory = (doc: Y.Doc) => SimHost;
|
export type SimHostFactory = (doc: Y.Doc) => SimHost;
|
||||||
@@ -35,9 +36,12 @@ export interface Simulation {
|
|||||||
readonly nodes: ReadonlyMap<string, NodeSettings>;
|
readonly nodes: ReadonlyMap<string, NodeSettings>;
|
||||||
/** Each watched program window's lines, by its topic. */
|
/** Each watched program window's lines, by its topic. */
|
||||||
readonly apps: ReadonlyMap<string, AppLines>;
|
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. */
|
/** Whether the core failed and the room went back to design mode, since the last start. */
|
||||||
readonly failed: boolean;
|
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;
|
stop(): void;
|
||||||
setSpeed(value: number): void;
|
setSpeed(value: number): void;
|
||||||
/** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */
|
/** 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 [nodes, setNodes] = useState<ReadonlyMap<string, NodeSettings>>(new Map());
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const [apps, setApps] = useState<ReadonlyMap<string, AppLines>>(new Map());
|
const [apps, setApps] = useState<ReadonlyMap<string, AppLines>>(new Map());
|
||||||
|
const [views, setViews] = useState<ReadonlyMap<string, unknown>>(new Map());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const made = createHost(doc);
|
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 new Map(before).set(topic, { lines, live: message.from === 0 ? lines.length : (before.get(topic)?.live ?? 0) });
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
case 'view':
|
||||||
|
setViews((before) => new Map(before).set(`app:${message.node}/${message.app}`, message.view));
|
||||||
|
return;
|
||||||
case 'error':
|
case 'error':
|
||||||
if (message.code === 'engineFailed') setFailed(true);
|
if (message.code === 'engineFailed') setFailed(true);
|
||||||
return;
|
return;
|
||||||
@@ -98,9 +106,9 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
|
|||||||
};
|
};
|
||||||
}, [doc, createHost]);
|
}, [doc, createHost]);
|
||||||
|
|
||||||
const start = useCallback(() => {
|
const start = useCallback((language?: Language) => {
|
||||||
setFailed(false);
|
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 stop = useCallback(() => host.current?.send({ v: 1, kind: 'stop' }), []);
|
||||||
const setSpeed = useCallback((value: number) => host.current?.send({ v: 1, kind: 'speed', value }), []);
|
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() });
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,20 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
|
|||||||
'network.dnsServer': 'network_msg2',
|
'network.dnsServer': 'network_msg2',
|
||||||
'network.gateway': 'network_msg3',
|
'network.gateway': 'network_msg3',
|
||||||
'network.macAddress': 'network_msg9',
|
'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). */
|
/** 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', () => {
|
describe('the catalogs', () => {
|
||||||
it('have the same keys in German and English, each with a text', () => {
|
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());
|
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.de[key], key).toBe(mend(de.get(javaKey)!));
|
||||||
expect(CATALOGS.en[key], key).toBe(mend(en.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}');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 & 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">ü&=</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']));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,3 +2,4 @@
|
|||||||
export * from './host.js';
|
export * from './host.js';
|
||||||
export * from './sim.js';
|
export * from './sim.js';
|
||||||
export * from './terminal.js';
|
export * from './terminal.js';
|
||||||
|
export * from './web.js';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// 6.1). The room runs on F (RemoteSimHost, F5) or in a web worker
|
// 6.1). The room runs on F (RemoteSimHost, F5) or in a web worker
|
||||||
// (LocalSimHost); the messages are the same.
|
// (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;
|
export const SIM_PROTOCOL_VERSION = 1;
|
||||||
|
|
||||||
@@ -26,8 +26,13 @@ export type ClientMessageKind = (typeof CLIENT_MESSAGE_KINDS)[number];
|
|||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
| { readonly v: 1; readonly kind: 'sub'; readonly topics: readonly Topic[] }
|
| { readonly v: 1; readonly kind: 'sub'; readonly topics: readonly Topic[] }
|
||||||
| { readonly v: 1; readonly kind: 'unsub'; 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' }
|
| { readonly v: 1; readonly kind: 'stop' }
|
||||||
/** Java's speed slider, 1 to 100 per cent: how long a frame takes over a cable. */
|
/** 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 }
|
| { readonly v: 1; readonly kind: 'speed'; readonly value: number }
|
||||||
@@ -42,7 +47,7 @@ export type ClientMessage =
|
|||||||
readonly reqId: string;
|
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 HostMessageKind = (typeof HOST_MESSAGE_KINDS)[number];
|
||||||
|
|
||||||
export type SimMode = 'design' | 'simulation';
|
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 }
|
| { 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). */
|
/** 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[] }
|
| { 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):
|
* 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.
|
* 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[] } };
|
return { message: { v: 1, kind: kind as 'sub' | 'unsub', topics: topics as Topic[] } };
|
||||||
}
|
}
|
||||||
case 'start':
|
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':
|
case 'stop':
|
||||||
return { message: { v: 1, kind: kind as 'start' | 'stop' } };
|
return { message: { v: 1, kind: 'stop' } };
|
||||||
case 'speed':
|
case 'speed':
|
||||||
if (typeof value.value !== 'number' || !Number.isFinite(value.value)) return bad('badMessage', 'speed is no number');
|
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 } };
|
return { message: { v: 1, kind: 'speed', value: value.value } };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
// it refuses, each with its reason.
|
// it refuses, each with its reason.
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
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", () => {
|
describe("a client's messages", () => {
|
||||||
it('reads each kind', () => {
|
it('reads each kind', () => {
|
||||||
@@ -16,6 +16,9 @@ describe("a client's messages", () => {
|
|||||||
];
|
];
|
||||||
expect(messages.map((each) => each.kind)).toEqual([...CLIENT_MESSAGE_KINDS]);
|
expect(messages.map((each) => each.kind)).toEqual([...CLIENT_MESSAGE_KINDS]);
|
||||||
for (const each of messages) expect(parseClientMessage(each)).toEqual({ message: each });
|
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', () => {
|
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' })).toBe('badMessage');
|
||||||
expect(code({ v: 1, kind: 'sub', topics: ['activity', 'trace:'] })).toBe('badTopic');
|
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: '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: '50' })).toBe('badMessage');
|
||||||
expect(code({ v: 1, kind: 'speed', value: Number.NaN })).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');
|
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' } });
|
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 & mehr</TITLE><title>Zwei</title></head></html>')).toBe('Links & 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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, "&" 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 ?? '' };
|
||||||
|
}
|
||||||
+132
-17
@@ -15,8 +15,12 @@
|
|||||||
//
|
//
|
||||||
// The engine outlives a stop: the capture stays, as Java's Lauscher does, and
|
// 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
|
// 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
|
// keep rising (plan §6.2). So do the command lines' and browsers' windows, as
|
||||||
// (TerminalWindow).
|
// 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
|
// While the simulation runs, the document's programs are watched: a program
|
||||||
// installed or removed on the desktop (Java's software installation) is
|
// installed or removed on the desktop (Java's software installation) is
|
||||||
@@ -35,6 +39,8 @@ import {
|
|||||||
type Id,
|
type Id,
|
||||||
type InstallableProgram,
|
type InstallableProgram,
|
||||||
type LogLine,
|
type LogLine,
|
||||||
|
type PageLanguage,
|
||||||
|
type TopologySnapshot,
|
||||||
} from '@mdg-filius/core';
|
} from '@mdg-filius/core';
|
||||||
import { docSnapshot, installedSoftware, ROOTS } from '@mdg-filius/doc';
|
import { docSnapshot, installedSoftware, ROOTS } from '@mdg-filius/doc';
|
||||||
import {
|
import {
|
||||||
@@ -48,7 +54,9 @@ import {
|
|||||||
type SimMode,
|
type SimMode,
|
||||||
type Topic,
|
type Topic,
|
||||||
type TraceFrame,
|
type TraceFrame,
|
||||||
|
type WebServerView,
|
||||||
} from '@mdg-filius/protocol';
|
} from '@mdg-filius/protocol';
|
||||||
|
import { BrowserWindow } from './browserWindow.js';
|
||||||
import { TerminalWindow } from './terminalWindow.js';
|
import { TerminalWindow } from './terminalWindow.js';
|
||||||
|
|
||||||
export const TICK_MILLISECONDS = 200;
|
export const TICK_MILLISECONDS = 200;
|
||||||
@@ -70,6 +78,8 @@ interface Client {
|
|||||||
readonly appSent: Map<string, number>;
|
readonly appSent: Map<string, number>;
|
||||||
/** Per computer, the settings last sent (as text). */
|
/** Per computer, the settings last sent (as text). */
|
||||||
readonly nodeSent: Map<Id, string>;
|
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. */
|
/** A program with a window: its lines. */
|
||||||
@@ -93,6 +103,10 @@ export class SimulationRoom {
|
|||||||
private lastActivity: string[] = [];
|
private lastActivity: string[] = [];
|
||||||
/** Each computer's command line window, kept over stops. */
|
/** Each computer's command line window, kept over stops. */
|
||||||
private readonly terminalWindows = new Map<Id, TerminalWindow>();
|
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. */
|
/** Each computer's programs as the running engine has them. */
|
||||||
private software = new Map<Id, readonly InstallableProgram[]>();
|
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 (installed.length > 0 || removed.length > 0) changes.push({ node, installed, removed });
|
||||||
}
|
}
|
||||||
if (changes.length === 0) return;
|
if (changes.length === 0) return;
|
||||||
const source = docSnapshot(this.doc);
|
const source = this.snapshot();
|
||||||
for (const { node, installed, removed } of changes) {
|
for (const { node, installed, removed } of changes) {
|
||||||
for (const program of removed) this.engine.removeProgram(node, program);
|
for (const program of removed) this.engine.removeProgram(node, program);
|
||||||
for (const program of installed) this.engine.installProgram(node, program, source);
|
for (const program of installed) this.engine.installProgram(node, program, source);
|
||||||
this.software.set(node, now.get(node)!);
|
this.software.set(node, now.get(node)!);
|
||||||
}
|
}
|
||||||
this.syncTerminalWindows();
|
this.syncWindows();
|
||||||
this.flush([...this.clients.values()], false);
|
this.flush([...this.clients.values()], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A window for each command line the engine runs, the same over stops (TerminalWindow). */
|
/** The document as the engine takes it, in the language of the start. */
|
||||||
private syncTerminalWindows(): void {
|
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) {
|
for (const node of this.engine.state.topologySnapshot.nodes) {
|
||||||
const terminal = this.engine.terminal(node.id);
|
const terminal = this.engine.terminal(node.id);
|
||||||
if (terminal === undefined) continue;
|
if (terminal !== undefined) {
|
||||||
const window = this.terminalWindows.get(node.id);
|
const window = this.terminalWindows.get(node.id);
|
||||||
if (window === undefined) this.terminalWindows.set(node.id, new TerminalWindow(terminal));
|
if (window === undefined) this.terminalWindows.set(node.id, new TerminalWindow(terminal));
|
||||||
else window.attach(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. */
|
/** A new client; it gets the room's state at once. */
|
||||||
connect(send: (message: HostMessage) => void): number {
|
connect(send: (message: HostMessage) => void): number {
|
||||||
const id = this.nextClient++;
|
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());
|
send(this.state());
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
@@ -190,7 +217,10 @@ export class SimulationRoom {
|
|||||||
for (const topic of input.topics) {
|
for (const topic of input.topics) {
|
||||||
client.topics.add(topic);
|
client.topics.add(topic);
|
||||||
if (topic.startsWith('trace:')) client.traceSent.delete(topic.slice('trace:'.length));
|
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));
|
if (topic.startsWith('node:')) client.nodeSent.delete(topic.slice('node:'.length));
|
||||||
}
|
}
|
||||||
this.flush([client], true);
|
this.flush([client], true);
|
||||||
@@ -199,7 +229,7 @@ export class SimulationRoom {
|
|||||||
for (const topic of input.topics) client.topics.delete(topic);
|
for (const topic of input.topics) client.topics.delete(topic);
|
||||||
return;
|
return;
|
||||||
case 'start':
|
case 'start':
|
||||||
this.start();
|
this.start(input.language);
|
||||||
return;
|
return;
|
||||||
case 'stop':
|
case 'stop':
|
||||||
this.stop();
|
this.stop();
|
||||||
@@ -227,14 +257,15 @@ export class SimulationRoom {
|
|||||||
for (const client of this.clients.values()) client.send(output);
|
for (const client of this.clients.values()) client.send(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(language?: PageLanguage): void {
|
||||||
if (this.mode === 'simulation') return;
|
if (this.mode === 'simulation') return;
|
||||||
const snapshot = docSnapshot(this.doc);
|
this.language = language;
|
||||||
|
const snapshot = this.snapshot();
|
||||||
this.engine.setSimulationSpeed(this.speed);
|
this.engine.setSimulationSpeed(this.speed);
|
||||||
if (!this.guarded(() => this.engine.handle({ type: 'start', snapshot, seed: this.options.seed(), initialTimeMilliseconds: this.engine.state.currentTimeMilliseconds }))) return;
|
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.linkOfPort = new Map(snapshot.links.flatMap((link) => [[link.sourcePortId, link.id] as const, [link.targetPortId, link.id] as const]));
|
||||||
this.software = this.documentSoftware();
|
this.software = this.documentSoftware();
|
||||||
this.syncTerminalWindows();
|
this.syncWindows();
|
||||||
this.mode = 'simulation';
|
this.mode = 'simulation';
|
||||||
this.lastWall = this.options.now();
|
this.lastWall = this.options.now();
|
||||||
this.broadcast(this.state());
|
this.broadcast(this.state());
|
||||||
@@ -286,7 +317,19 @@ export class SimulationRoom {
|
|||||||
private act(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
private act(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
||||||
if (this.mode !== 'simulation') return 'notRunning';
|
if (this.mode !== 'simulation') return 'notRunning';
|
||||||
if (!this.engine.state.topologySnapshot.nodes.some((node) => node.id === input.node)) return 'unknownNode';
|
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.
|
// A window has a program while the simulation runs (checked above), from its first start on.
|
||||||
const window = this.terminalWindows.get(input.node);
|
const window = this.terminalWindows.get(input.node);
|
||||||
if (window === undefined || this.engine.terminal(input.node) === undefined) return 'unknownApp';
|
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 {
|
private program(node: Id, app: AppKind): WithLog | undefined {
|
||||||
switch (app) {
|
switch (app) {
|
||||||
case 'terminal':
|
case 'terminal':
|
||||||
@@ -328,6 +431,7 @@ export class SimulationRoom {
|
|||||||
private flush(clients: readonly Client[], subscribing: boolean): void {
|
private flush(clients: readonly Client[], subscribing: boolean): void {
|
||||||
if (!subscribing) this.updateActivity();
|
if (!subscribing) this.updateActivity();
|
||||||
for (const window of this.terminalWindows.values()) window.pull();
|
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 rowsCache = new Map<Id, TraceFrame[]>();
|
||||||
const traceFrames = (portId: Id, after: number): TraceFrame[] => {
|
const traceFrames = (portId: Id, after: number): TraceFrame[] => {
|
||||||
const frames = this.engine.capture.frames(portId).filter((frame) => frame.number > after);
|
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 }));
|
client.send(message({ kind: 'trace', portId, frames, reset }));
|
||||||
} else {
|
} else {
|
||||||
const [, node, app] = /^app:([^/]+)\/(.+)$/.exec(topic)!;
|
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);
|
const sent = client.appSent.get(topic);
|
||||||
if (app === 'terminal') {
|
if (app === 'terminal') {
|
||||||
// The window's lines, kept over stops: a client goes on from those it has.
|
// 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
@@ -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 & 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&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">ü&=</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 & 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: '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: '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: 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: '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: 'enter', args: { line: 7 } })).toMatchObject({ code: 'badArgs' });
|
||||||
expect(reason({ node: notebook, app: 'terminal', action: 'interrupt' })).toEqual({ v: 1, kind: 'ack', reqId: 'r' });
|
expect(reason({ node: notebook, app: 'terminal', action: 'interrupt' })).toEqual({ v: 1, kind: 'ack', reqId: 'r' });
|
||||||
send({ kind: 'nonsense', reqId: 'q' });
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1292,3 +1292,43 @@ Gemessen (03.10.2026):
|
|||||||
Darum misst `GetterDefaults` jetzt auch `#priority` der gespeicherten
|
Darum misst `GetterDefaults` jetzt auch `#priority` der gespeicherten
|
||||||
Thread-Klassen (alle 5), die vorher unaufgelöst als 0 gelesen wurden.
|
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; 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.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then
|
|||||||
fi
|
fi
|
||||||
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
|
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
|
||||||
mkdir -p "$here/.build"
|
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.
|
# FILIUS writes its settings below $HOME; keep them out of the real one.
|
||||||
mkdir -p "$here/.build/home"
|
mkdir -p "$here/.build/home"
|
||||||
# Removed first: a crashed run must not leave the previous result standing.
|
# 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
|
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"
|
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
|
# 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)
|
# 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).
|
# and the classics of F3 (web, email, gnutella; packages/fls/test/classicsJava.test.ts).
|
||||||
|
|||||||
@@ -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 & 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&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\">ü&=</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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user