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:
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 { DeviceSheet } from './sheet/DeviceSheet.js';
|
||||
import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js';
|
||||
import { useTranslate } from './i18n/i18n.js';
|
||||
import { useLanguage, useTranslate } from './i18n/i18n.js';
|
||||
import { ModeBar } from './simulation/ModeBar.js';
|
||||
import { useSimulation, type SimHostFactory } from './simulation/useSimulation.js';
|
||||
|
||||
@@ -90,6 +90,7 @@ const toFront = (open: readonly OpenDesktop[], id: string): readonly OpenDesktop
|
||||
|
||||
export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly createSimHost?: SimHostFactory }) {
|
||||
const t = useTranslate();
|
||||
const language = useLanguage();
|
||||
const view = useTopologyView(doc);
|
||||
const simulation = useSimulation(doc, createSimHost);
|
||||
const simulating = simulation.mode === 'simulation';
|
||||
@@ -285,7 +286,7 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
|
||||
}}
|
||||
/>
|
||||
<div role="toolbar" style={{ position: 'absolute', left: 12, top: 12, zIndex: 50, display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<ModeBar mode={simulation.mode} speed={simulation.speed} onMode={(mode) => (mode === 'simulation' ? simulation.start() : simulation.stop())} onSpeed={simulation.setSpeed} />
|
||||
<ModeBar mode={simulation.mode} speed={simulation.speed} onMode={(mode) => (mode === 'simulation' ? simulation.start(language) : simulation.stop())} onSpeed={simulation.setSpeed} />
|
||||
{simulating ? null : (
|
||||
<>
|
||||
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,104 @@
|
||||
// A computer's web browser, after Java's GUIApplicationWebBrowserWindow: an
|
||||
// address line with Java's "Start" button above the page. The window is the
|
||||
// room's (BrowserWindow) — its address line, title and page, the same for
|
||||
// everyone who opens it —; this panel types into it and shows its page in a
|
||||
// sandboxed frame (plan §7.3): no script runs, nothing loads but the images
|
||||
// that came, and a link or a form's button goes to the room as Java's window
|
||||
// hands it to its program. A mark in the page (#top) stays in the page: the
|
||||
// panel scrolls to it, as the frame's own link would lead to the editor's
|
||||
// address (a srcdoc frame's links count from its parent's).
|
||||
//
|
||||
// Java's start page, its browser's logo, shows before the first load.
|
||||
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { BROWSER_START, type BrowserView } from '@mdg-filius/protocol';
|
||||
import logo from '../assets/desktop/browser_waterwolf_logo.png';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import type { Simulation } from '../simulation/useSimulation.js';
|
||||
import { pageDocument } from './browserPage.js';
|
||||
|
||||
/** The frame's sandbox: no script, no popup, no navigation of its own; forms submit only to the panel (and the policy sends none). */
|
||||
export const PAGE_SANDBOX = 'allow-same-origin allow-forms';
|
||||
|
||||
/** The view the room sent for a computer's browser; Java's start while it sent none. */
|
||||
export function browserView(simulation: Simulation, node: string): BrowserView {
|
||||
return (simulation.views.get(`app:${node}/webBrowser`) as BrowserView | null | undefined) ?? BROWSER_START;
|
||||
}
|
||||
|
||||
/** What a form sends, as Java's window sends it: its fields in order with the button pressed, form-encoded; a GET form's after a "?" in the address. */
|
||||
export function formRequest(form: HTMLFormElement, submitter: HTMLElement | null): { readonly href: string; readonly post?: string } {
|
||||
const data = new URLSearchParams(new FormData(form, submitter) as unknown as Record<string, string>).toString();
|
||||
const action = form.getAttribute('action') ?? '';
|
||||
return form.method.toLowerCase() === 'post' ? { href: action, post: data } : { href: `${action.split('?')[0]}?${data}` };
|
||||
}
|
||||
|
||||
export function BrowserPanel({ node, simulation }: { readonly node: string; readonly simulation: Simulation }) {
|
||||
const t = useTranslate();
|
||||
const topic = `app:${node}/webBrowser` as const;
|
||||
const { watch, act } = simulation;
|
||||
useEffect(() => watch([topic]), [watch, topic]);
|
||||
const view = browserView(simulation, node);
|
||||
const [typed, setTyped] = useState(view.address);
|
||||
const frame = useRef<HTMLIFrameElement>(null);
|
||||
// Java's window writes each load's address into its line, and "http://" for one that is none.
|
||||
useEffect(() => setTyped(view.address), [view.address, view.loading]);
|
||||
const document = pageDocument(view.page, t);
|
||||
|
||||
const open = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
act(node, 'webBrowser', 'open', { address: typed });
|
||||
};
|
||||
|
||||
// The page's links and forms go to the room; the frame runs no script, so the panel listens in it.
|
||||
const onLoad = () => {
|
||||
const page = frame.current?.contentDocument;
|
||||
if (page === null || page === undefined) return;
|
||||
page.addEventListener('click', (event) => {
|
||||
const link = (event.target as Element | null)?.closest?.('a[href]');
|
||||
if (link === null || link === undefined) return;
|
||||
const href = link.getAttribute('href')!.trim();
|
||||
event.preventDefault();
|
||||
// A mark in the page: scrolled to here (the frame's own links would lead to the editor's address).
|
||||
if (href.startsWith('#')) {
|
||||
const name = decodeURIComponent(href.slice(1));
|
||||
(page.getElementById(name) ?? page.getElementsByName(name)[0])?.scrollIntoView();
|
||||
return;
|
||||
}
|
||||
act(node, 'webBrowser', 'follow', { href });
|
||||
});
|
||||
page.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const request = formRequest(event.target as HTMLFormElement, (event as SubmitEvent).submitter);
|
||||
act(node, 'webBrowser', 'follow', request.post === undefined ? { href: request.href } : { href: request.href, post: request.post });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="browser">
|
||||
<form className="browser-address" onSubmit={open}>
|
||||
<input
|
||||
name="address"
|
||||
value={typed}
|
||||
aria-label={t('browser.address')}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
inputMode="url"
|
||||
enterKeyHint="go"
|
||||
onChange={(event) => setTyped(event.target.value)}
|
||||
/>
|
||||
<button type="submit">{t('browser.go')}</button>
|
||||
</form>
|
||||
<div className="browser-page">
|
||||
{document === undefined ? (
|
||||
<div className="browser-start">
|
||||
<img src={logo} alt="" />
|
||||
</div>
|
||||
) : (
|
||||
<iframe ref={frame} title={t('browser.page')} sandbox={PAGE_SANDBOX} srcDoc={document} onLoad={onLoad} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,11 +11,14 @@
|
||||
// On a wide screen the window lies over the surface and moves by its title
|
||||
// bar, as Java's windows do; on a phone it fills the surface. A program's
|
||||
// icon opens its window over the desktop (ProgramFrame), the home button
|
||||
// leaves it; so far the command line has one (F4.6b), the others follow.
|
||||
// leaves it; so far the command line (F4.6b), the web browser and the web
|
||||
// server (F4.6d) have one, the others follow. The browser's window takes its
|
||||
// title from its page, as Java's does.
|
||||
|
||||
import { useCallback, useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
import type * as Y from 'yjs';
|
||||
import { desktopView } from '@mdg-filius/doc';
|
||||
import { browserTitle } from '@mdg-filius/protocol';
|
||||
import background from '../assets/desktop/hintergrundbild.png';
|
||||
import networkOff from '../assets/desktop/netzwek_aus.png';
|
||||
import networkOn from '../assets/desktop/netzwek_c.png';
|
||||
@@ -24,8 +27,11 @@ import { useLanguage, useTranslate } from '../i18n/i18n.js';
|
||||
import type { Simulation } from '../simulation/useSimulation.js';
|
||||
import { ProgramFrame } from './ProgramFrame.js';
|
||||
import { desktopPrograms, INSTALLATION_ICON, JAVA_PROGRAMS, type DesktopProgram } from './programs.js';
|
||||
import { BrowserPanel, browserView } from './BrowserPanel.js';
|
||||
import { pageTitle } from './browserPage.js';
|
||||
import { InstallationPanel } from './InstallationPanel.js';
|
||||
import { TerminalPanel } from './TerminalPanel.js';
|
||||
import { WebServerPanel } from './WebServerPanel.js';
|
||||
import './desktop.css';
|
||||
|
||||
export interface DesktopWindowProps {
|
||||
@@ -54,7 +60,7 @@ export interface DesktopWindowProps {
|
||||
export type DesktopWindowName = DesktopProgram | 'installation';
|
||||
|
||||
/** The programs whose windows are built so far. */
|
||||
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt']);
|
||||
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer']);
|
||||
|
||||
/** The document's desktop of a computer, read anew after every change (a program installed, the label changed). */
|
||||
function useDesktopView(doc: Y.Doc, id: string) {
|
||||
@@ -146,8 +152,14 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
|
||||
<InstallationPanel doc={doc} node={id} installed={view.programs} origin={origin} onDone={home} />
|
||||
</ProgramFrame>
|
||||
) : shown === null ? null : (
|
||||
<ProgramFrame icon={JAVA_PROGRAMS[shown].icon} title={t(`program.${shown}`)} program={shown}>
|
||||
<ProgramFrame
|
||||
icon={JAVA_PROGRAMS[shown].icon}
|
||||
title={shown === 'webBrowser' ? browserTitle(browserView(simulation, id), t('program.webBrowser'), (page) => pageTitle(page, t)) : t(`program.${shown}`)}
|
||||
program={shown}
|
||||
>
|
||||
{shown === 'commandPrompt' ? <TerminalPanel node={id} simulation={simulation} onExit={home} /> : null}
|
||||
{shown === 'webBrowser' ? <BrowserPanel node={id} simulation={simulation} /> : null}
|
||||
{shown === 'webServer' ? <WebServerPanel node={id} simulation={simulation} /> : null}
|
||||
</ProgramFrame>
|
||||
)}
|
||||
{info ? (
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// A computer's web server, after Java's GUIApplicationWebServerWindow: the
|
||||
// start button ("Starten"/"Anhalten"), beside it the virtual hosts' check
|
||||
// box; while it is ticked their table (five rows of host name and folder,
|
||||
// each cell written when it is left, as Java's table writes it when its
|
||||
// editing stops) and below the server's lines. The server is the room's: what
|
||||
// the panel shows is its view and lines, what it does goes to the room.
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import type { LogLine, ServerLogEntry } from '@mdg-filius/core';
|
||||
import type { WebServerView } from '@mdg-filius/protocol';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import type { Simulation } from '../simulation/useSimulation.js';
|
||||
import { serverLine } from './serverLines.js';
|
||||
|
||||
const NO_LINES: readonly LogLine<ServerLogEntry>[] = [];
|
||||
|
||||
/** A cell of the table, written to the server when it is left or Enter is pressed and it changed. */
|
||||
function HostCell({ value, label, onCommit }: { readonly value: string; readonly label: string; readonly onCommit: (value: string) => void }) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
useEffect(() => setDraft(value), [value]);
|
||||
const commit = () => {
|
||||
if (draft !== value) onCommit(draft);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') commit();
|
||||
if (event.key === 'Escape') setDraft(value);
|
||||
};
|
||||
return <input value={draft} aria-label={label} autoCapitalize="off" autoCorrect="off" spellCheck={false} onChange={(event) => setDraft(event.target.value)} onBlur={commit} onKeyDown={onKeyDown} />;
|
||||
}
|
||||
|
||||
export function WebServerPanel({ node, simulation }: { readonly node: string; readonly simulation: Simulation }) {
|
||||
const t = useTranslate();
|
||||
const topic = `app:${node}/webServer` as const;
|
||||
const { watch, act } = simulation;
|
||||
useEffect(() => watch([topic]), [watch, topic]);
|
||||
const view = simulation.views.get(topic) as WebServerView | null | undefined;
|
||||
const lines = (simulation.apps.get(topic)?.lines as readonly LogLine<ServerLogEntry>[] | undefined) ?? NO_LINES;
|
||||
const text = lines.map((line) => `${serverLine(line, t)}\n`).join('');
|
||||
const log = useRef<HTMLPreElement>(null);
|
||||
|
||||
// The newest line in sight, as Java's text area scrolls.
|
||||
useLayoutEffect(() => {
|
||||
const element = log.current;
|
||||
if (element !== null) element.scrollTop = element.scrollHeight;
|
||||
}, [text]);
|
||||
|
||||
if (view === null || view === undefined) return <div className="web-server" />;
|
||||
return (
|
||||
<div className="web-server">
|
||||
<div className="web-server-bar">
|
||||
<button type="button" onClick={() => act(node, 'webServer', 'setActive', { active: !view.active })}>
|
||||
{t(view.active ? 'server.stop' : 'server.start')}
|
||||
</button>
|
||||
<label>
|
||||
<input type="checkbox" checked={view.useVirtualHosts} onChange={(event) => act(node, 'webServer', 'useVirtualHosts', { use: event.target.checked })} />
|
||||
{t('webServer.useVirtualHosts')}
|
||||
</label>
|
||||
</div>
|
||||
{view.useVirtualHosts ? (
|
||||
<div className="web-server-hosts">
|
||||
<span id={`${node}-virtual-hosts`}>{t('webServer.virtualHosts')}</span>
|
||||
<table aria-labelledby={`${node}-virtual-hosts`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('webServer.hostName')}</th>
|
||||
<th>{t('webServer.folder')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{view.virtualHosts.map(([host, folder], row) => (
|
||||
<tr key={row}>
|
||||
{[host, folder].map((value, column) => (
|
||||
<td key={column}>
|
||||
<HostCell
|
||||
value={value}
|
||||
label={`${t(column === 0 ? 'webServer.hostName' : 'webServer.folder')} ${row + 1}`}
|
||||
onCommit={(written) => act(node, 'webServer', 'setVirtualHost', { row, column, value: written })}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
<pre className="web-server-log" ref={log} aria-label={t('server.log')} tabIndex={0}>
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// What the browser window shows of a page (BrowserPage), as a document for a
|
||||
// sandboxed frame (plan §7.3): the page's HTML as it came, without what
|
||||
// could reach beyond the frame — scripts, a refresh, a base, other
|
||||
// documents —, under a policy that loads nothing but the images that came
|
||||
// (as data) and sends no form; the error pages as Java's browser writes
|
||||
// them, in the person's language. Java's start page (its logo) is not a
|
||||
// document: the panel shows it itself.
|
||||
|
||||
import { htmlTitle, type BrowserImage, type BrowserPage } from '@mdg-filius/protocol';
|
||||
import { httpStatusText } from '@mdg-filius/core';
|
||||
import type { Translate } from '../i18n/i18n.js';
|
||||
|
||||
/** Loads nothing but images given as data, runs no script (the frame's sandbox too), sends no form (the panel takes it). */
|
||||
export const PAGE_POLICY = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; form-action 'none'";
|
||||
|
||||
/** Elements that would load, run or lead somewhere by themselves. */
|
||||
const REMOVED = 'script, noscript, base, link, iframe, frame, frameset, object, embed, applet, meta[http-equiv]';
|
||||
|
||||
const escape = (text: string) => text.replace(/&/g, '&').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;
|
||||
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.lan": "LAN-Anschluss (intern)",
|
||||
"cable.lanOccupied": "LAN-Anschluss (intern) bereits belegt!",
|
||||
@@ -117,6 +124,18 @@
|
||||
"router.cancel": "Abbrechen",
|
||||
"router.prompt": "Wählen Sie die Anzahl der Schnittstellen des Vermittlungsrechners aus!",
|
||||
"router.title": "Anzahl Schnittstellen",
|
||||
"server.acceptingStarted": "Annahme von Verbindungsanfragen gestartet",
|
||||
"server.acceptingStopped": "Annahme von Verbindungsanfragen beendet",
|
||||
"server.connectionAccepted": "Verbindung zu {address} hergestellt",
|
||||
"server.connectionClosed": "Socket zu {address} geschlossen",
|
||||
"server.log": "Meldungen",
|
||||
"server.sendFailed.connectionLost": "Verbindung wurde unterbrochen",
|
||||
"server.sendFailed.notConnected": "Verbindung nicht aufgebaut!",
|
||||
"server.sendFailed.timedOut": "Zeitüberschreitung während des Versands einer Nachricht",
|
||||
"server.serverSocketClosed": "Server-Socket geschlossen",
|
||||
"server.serverSocketFailed": "Server-Socket konnte nicht geöffnet werden.",
|
||||
"server.start": "Starten",
|
||||
"server.stop": "Anhalten",
|
||||
"sheet.back": "Zurück",
|
||||
"sheet.cable": "Kabelgebunden (LAN)",
|
||||
"sheet.cablePlugged": "Das Kabel ist noch eingesteckt.",
|
||||
@@ -165,6 +184,10 @@
|
||||
"terminal.interrupt": "Befehl abbrechen (Strg+C)",
|
||||
"terminal.next": "Nächster Befehl",
|
||||
"terminal.previous": "Vorheriger Befehl",
|
||||
"webServer.folder": "Unterverzeichnis",
|
||||
"webServer.hostName": "Hostname",
|
||||
"webServer.useVirtualHosts": "Verwende virtuelle Hosts",
|
||||
"webServer.virtualHosts": "Virtuelle Hosts:",
|
||||
"zoom.fit": "Alles zeigen",
|
||||
"zoom.in": "Vergrößern",
|
||||
"zoom.level": "{percent} %",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"browser.address": "Address",
|
||||
"browser.errorText": "An error occurred (HTTP-Status: {code})",
|
||||
"browser.errorTitle": "Error {message} ({code})",
|
||||
"browser.go": "Start",
|
||||
"browser.page": "Page",
|
||||
"browser.unreachable": "Server does not exist!",
|
||||
"browser.unreachableTitle": "Error",
|
||||
"cable.alreadyConnected": "Components already connected",
|
||||
"cable.lan": "LAN port (internal)",
|
||||
"cable.lanOccupied": "LAN port (internal) already occupied!",
|
||||
@@ -117,6 +124,18 @@
|
||||
"router.cancel": "Cancel",
|
||||
"router.prompt": "Choose number of NIC of the router!",
|
||||
"router.title": "Number of NIC",
|
||||
"server.acceptingStarted": "Start accepting connections.",
|
||||
"server.acceptingStopped": "Stop accepting connections.",
|
||||
"server.connectionAccepted": "Connection to {address} established",
|
||||
"server.connectionClosed": "Socket to {address} closed",
|
||||
"server.log": "Messages",
|
||||
"server.sendFailed.connectionLost": "Connection was interrupted",
|
||||
"server.sendFailed.notConnected": "Connection could not be established!",
|
||||
"server.sendFailed.timedOut": "Timeout while sending a message",
|
||||
"server.serverSocketClosed": "Server socket closed",
|
||||
"server.serverSocketFailed": "Server socket could not be initialized.",
|
||||
"server.start": "Start",
|
||||
"server.stop": "Stop",
|
||||
"sheet.back": "Back",
|
||||
"sheet.cable": "Cable (LAN)",
|
||||
"sheet.cablePlugged": "The cable is still plugged in.",
|
||||
@@ -165,6 +184,10 @@
|
||||
"terminal.interrupt": "Stop the command (Ctrl+C)",
|
||||
"terminal.next": "Next command",
|
||||
"terminal.previous": "Previous command",
|
||||
"webServer.folder": "Subdirectory",
|
||||
"webServer.hostName": "Hostname",
|
||||
"webServer.useVirtualHosts": "Enable virtual hosts",
|
||||
"webServer.virtualHosts": "Virtual hosts:",
|
||||
"zoom.fit": "Show all",
|
||||
"zoom.in": "Zoom in",
|
||||
"zoom.level": "{percent} %",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The editor's side of the simulation room (plan §6): its mode and speed,
|
||||
// the cables that carry frames, a computer's network as it runs, the lines
|
||||
// of the programs' windows and the actions taken in them. The room
|
||||
// and views of the programs' windows and the actions taken in them. The room
|
||||
// runs behind a SimHost — in a worker of this page (LocalSimHost) until F
|
||||
// holds it (RemoteSimHost, F5); the editor keeps no simulation of its own.
|
||||
|
||||
@@ -9,6 +9,7 @@ import type * as Y from 'yjs';
|
||||
import type { AppKind, NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol';
|
||||
import { LocalSimHost } from '@mdg-filius/sim';
|
||||
import SimWorker from '@mdg-filius/sim/worker?worker';
|
||||
import type { Language } from '../i18n/i18n.js';
|
||||
|
||||
/** Makes the room of a document. */
|
||||
export type SimHostFactory = (doc: Y.Doc) => SimHost;
|
||||
@@ -35,9 +36,12 @@ export interface Simulation {
|
||||
readonly nodes: ReadonlyMap<string, NodeSettings>;
|
||||
/** Each watched program window's lines, by its topic. */
|
||||
readonly apps: ReadonlyMap<string, AppLines>;
|
||||
/** Each watched program window's view (a browser's page, a server's button and table), by its topic; null while it has none. */
|
||||
readonly views: ReadonlyMap<string, unknown>;
|
||||
/** Whether the core failed and the room went back to design mode, since the last start. */
|
||||
readonly failed: boolean;
|
||||
start(): void;
|
||||
/** Starts in the language the person runs FILIUS in: the routers' pages come in it, as Java's in its locale. */
|
||||
start(language?: Language): void;
|
||||
stop(): void;
|
||||
setSpeed(value: number): void;
|
||||
/** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */
|
||||
@@ -57,6 +61,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
|
||||
const [nodes, setNodes] = useState<ReadonlyMap<string, NodeSettings>>(new Map());
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [apps, setApps] = useState<ReadonlyMap<string, AppLines>>(new Map());
|
||||
const [views, setViews] = useState<ReadonlyMap<string, unknown>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const made = createHost(doc);
|
||||
@@ -83,6 +88,9 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
|
||||
return new Map(before).set(topic, { lines, live: message.from === 0 ? lines.length : (before.get(topic)?.live ?? 0) });
|
||||
});
|
||||
return;
|
||||
case 'view':
|
||||
setViews((before) => new Map(before).set(`app:${message.node}/${message.app}`, message.view));
|
||||
return;
|
||||
case 'error':
|
||||
if (message.code === 'engineFailed') setFailed(true);
|
||||
return;
|
||||
@@ -98,9 +106,9 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
|
||||
};
|
||||
}, [doc, createHost]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
const start = useCallback((language?: Language) => {
|
||||
setFailed(false);
|
||||
host.current?.send({ v: 1, kind: 'start' });
|
||||
host.current?.send(language === undefined ? { v: 1, kind: 'start' } : { v: 1, kind: 'start', language });
|
||||
}, []);
|
||||
const stop = useCallback(() => host.current?.send({ v: 1, kind: 'stop' }), []);
|
||||
const setSpeed = useCallback((value: number) => host.current?.send({ v: 1, kind: 'speed', value }), []);
|
||||
@@ -126,5 +134,5 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
|
||||
host.current?.send({ v: 1, kind: 'input', node, app, action, args, reqId: crypto.randomUUID() });
|
||||
}, []);
|
||||
|
||||
return { mode, speed, activeLinks, nodes, apps, failed, start, stop, setSpeed, watch, act };
|
||||
return { mode, speed, activeLinks, nodes, apps, views, failed, start, stop, setSpeed, watch, act };
|
||||
}
|
||||
|
||||
@@ -122,6 +122,20 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
|
||||
'network.dnsServer': 'network_msg2',
|
||||
'network.gateway': 'network_msg3',
|
||||
'network.macAddress': 'network_msg9',
|
||||
'browser.go': 'webbrowser_msg2',
|
||||
'server.start': 'webserver_msg1',
|
||||
'server.stop': 'webserver_msg2',
|
||||
'webServer.virtualHosts': 'webserver_msg3',
|
||||
'webServer.hostName': 'webserver_msg4',
|
||||
'webServer.folder': 'webserver_msg5',
|
||||
'webServer.useVirtualHosts': 'webserver_msg6',
|
||||
'server.acceptingStopped': 'sw_serveranwendung_msg1',
|
||||
'server.acceptingStarted': 'sw_serveranwendung_msg2',
|
||||
'server.serverSocketClosed': 'sw_serveranwendung_msg3',
|
||||
'server.serverSocketFailed': 'sw_serveranwendung_msg6',
|
||||
'server.sendFailed.notConnected': 'sw_tcpsocket_msg6',
|
||||
'server.sendFailed.connectionLost': 'sw_tcpsocket_msg7',
|
||||
'server.sendFailed.timedOut': 'sw_tcpsocket_msg8',
|
||||
};
|
||||
|
||||
/** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */
|
||||
@@ -156,6 +170,20 @@ const FROM_JAVA_MENDED: Readonly<Record<string, readonly [string, (java: string)
|
||||
],
|
||||
};
|
||||
|
||||
/** Java's words around a value, as its programs join them („Verbindung zu“ + address + „hergestellt“). */
|
||||
const FROM_JAVA_JOINED: Readonly<Record<string, readonly [string, string, string]>> = {
|
||||
'server.connectionAccepted': ['sw_serveranwendung_msg4', '{address}', 'sw_serveranwendung_msg5'],
|
||||
'server.connectionClosed': ['sw_servermitarbeiter_msg1', '{address}', 'sw_servermitarbeiter_msg2'],
|
||||
};
|
||||
|
||||
/** Java's page for a server it cannot reach (sw_webbrowser_msg1): its title and its heading. */
|
||||
const FROM_JAVA_PAGE: Readonly<Record<string, readonly [string, RegExp]>> = {
|
||||
'browser.unreachableTitle': ['sw_webbrowser_msg1', /<title>(.*)<\/title>/],
|
||||
'browser.unreachable': ['sw_webbrowser_msg1', /<h1>(.*)<\/h1>/],
|
||||
};
|
||||
|
||||
const templates = join(dirname(fileURLToPath(import.meta.url)), '../../../tools/java-oracle/.upstream/src/main/resources/tmpl');
|
||||
|
||||
describe('the catalogs', () => {
|
||||
it('have the same keys in German and English, each with a text', () => {
|
||||
expect(Object.keys(CATALOGS.en).sort()).toEqual(Object.keys(CATALOGS.de).sort());
|
||||
@@ -184,5 +212,22 @@ describe('the catalogs', () => {
|
||||
expect(CATALOGS.de[key], key).toBe(mend(de.get(javaKey)!));
|
||||
expect(CATALOGS.en[key], key).toBe(mend(en.get(javaKey)!));
|
||||
}
|
||||
for (const [key, [before, value, after]] of Object.entries(FROM_JAVA_JOINED)) {
|
||||
expect(CATALOGS.de[key], key).toBe(`${de.get(before)} ${value} ${de.get(after)}`);
|
||||
expect(CATALOGS.en[key], key).toBe(`${en.get(before)} ${value} ${en.get(after)}`);
|
||||
}
|
||||
for (const [key, [javaKey, part]] of Object.entries(FROM_JAVA_PAGE)) {
|
||||
expect(CATALOGS.de[key], key).toBe(part.exec(de.get(javaKey)!)![1]);
|
||||
expect(CATALOGS.en[key], key).toBe(part.exec(en.get(javaKey)!)![1]);
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(!existsSync(templates))("write the browser's error page as Java's template does (tmpl/http_fehler)", () => {
|
||||
for (const [language, file] of [['de', 'http_fehler_de_DE.txt'], ['en', 'http_fehler_en_GB.txt']] as const) {
|
||||
const template = readFileSync(join(templates, file), 'utf8').replaceAll(':meldung:', '{message}').replaceAll(':code:', '{code}');
|
||||
expect(CATALOGS[language]['browser.errorTitle']).toBe(/<title>(.*)<\/title>/.exec(template)![1]);
|
||||
expect(CATALOGS[language]['browser.errorText']).toBe(/<p>(.*)<\/p>/.exec(template)![1]!.trim());
|
||||
expect(/<h1>(.*)<\/h1>/.exec(template)![1]).toBe('{message}');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user