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:
@@ -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 next start goes on at the time it stopped, so that the capture's times
|
||||
// keep rising (plan §6.2). So do the command lines' windows, as Java's do
|
||||
// (TerminalWindow).
|
||||
// keep rising (plan §6.2). So do the command lines' and browsers' windows, as
|
||||
// Java's do (TerminalWindow, BrowserWindow).
|
||||
//
|
||||
// A program's window may show more than its lines (plan §7.3): the browser's
|
||||
// page, the web server's button and table go to the clients as a view,
|
||||
// whole, when they change.
|
||||
//
|
||||
// While the simulation runs, the document's programs are watched: a program
|
||||
// installed or removed on the desktop (Java's software installation) is
|
||||
@@ -35,6 +39,8 @@ import {
|
||||
type Id,
|
||||
type InstallableProgram,
|
||||
type LogLine,
|
||||
type PageLanguage,
|
||||
type TopologySnapshot,
|
||||
} from '@mdg-filius/core';
|
||||
import { docSnapshot, installedSoftware, ROOTS } from '@mdg-filius/doc';
|
||||
import {
|
||||
@@ -48,7 +54,9 @@ import {
|
||||
type SimMode,
|
||||
type Topic,
|
||||
type TraceFrame,
|
||||
type WebServerView,
|
||||
} from '@mdg-filius/protocol';
|
||||
import { BrowserWindow } from './browserWindow.js';
|
||||
import { TerminalWindow } from './terminalWindow.js';
|
||||
|
||||
export const TICK_MILLISECONDS = 200;
|
||||
@@ -70,6 +78,8 @@ interface Client {
|
||||
readonly appSent: Map<string, number>;
|
||||
/** Per computer, the settings last sent (as text). */
|
||||
readonly nodeSent: Map<Id, string>;
|
||||
/** Per program window, the view last sent: the browser's as it is, others as text. */
|
||||
readonly viewSent: Map<string, unknown>;
|
||||
}
|
||||
|
||||
/** A program with a window: its lines. */
|
||||
@@ -93,6 +103,10 @@ export class SimulationRoom {
|
||||
private lastActivity: string[] = [];
|
||||
/** Each computer's command line window, kept over stops. */
|
||||
private readonly terminalWindows = new Map<Id, TerminalWindow>();
|
||||
/** Each computer's browser window, kept over stops. */
|
||||
private readonly browserWindows = new Map<Id, BrowserWindow>();
|
||||
/** The language of the last start (the pages the devices write themselves). */
|
||||
private language: PageLanguage | undefined;
|
||||
/** Each computer's programs as the running engine has them. */
|
||||
private software = new Map<Id, readonly InstallableProgram[]>();
|
||||
|
||||
@@ -129,31 +143,44 @@ export class SimulationRoom {
|
||||
if (installed.length > 0 || removed.length > 0) changes.push({ node, installed, removed });
|
||||
}
|
||||
if (changes.length === 0) return;
|
||||
const source = docSnapshot(this.doc);
|
||||
const source = this.snapshot();
|
||||
for (const { node, installed, removed } of changes) {
|
||||
for (const program of removed) this.engine.removeProgram(node, program);
|
||||
for (const program of installed) this.engine.installProgram(node, program, source);
|
||||
this.software.set(node, now.get(node)!);
|
||||
}
|
||||
this.syncTerminalWindows();
|
||||
this.syncWindows();
|
||||
this.flush([...this.clients.values()], false);
|
||||
}
|
||||
|
||||
/** A window for each command line the engine runs, the same over stops (TerminalWindow). */
|
||||
private syncTerminalWindows(): void {
|
||||
/** The document as the engine takes it, in the language of the start. */
|
||||
private snapshot(): TopologySnapshot {
|
||||
const snapshot = docSnapshot(this.doc);
|
||||
return this.language === undefined ? snapshot : { ...snapshot, language: this.language };
|
||||
}
|
||||
|
||||
/** A window for each command line and browser the engine runs, the same over stops (TerminalWindow, BrowserWindow). */
|
||||
private syncWindows(): void {
|
||||
for (const node of this.engine.state.topologySnapshot.nodes) {
|
||||
const terminal = this.engine.terminal(node.id);
|
||||
if (terminal === undefined) continue;
|
||||
const window = this.terminalWindows.get(node.id);
|
||||
if (window === undefined) this.terminalWindows.set(node.id, new TerminalWindow(terminal));
|
||||
else window.attach(terminal);
|
||||
if (terminal !== undefined) {
|
||||
const window = this.terminalWindows.get(node.id);
|
||||
if (window === undefined) this.terminalWindows.set(node.id, new TerminalWindow(terminal));
|
||||
else window.attach(terminal);
|
||||
}
|
||||
const browser = this.engine.webBrowser(node.id);
|
||||
if (browser !== undefined) {
|
||||
const window = this.browserWindows.get(node.id);
|
||||
if (window === undefined) this.browserWindows.set(node.id, new BrowserWindow(browser));
|
||||
else window.attach(browser);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A new client; it gets the room's state at once. */
|
||||
connect(send: (message: HostMessage) => void): number {
|
||||
const id = this.nextClient++;
|
||||
this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), appSent: new Map(), nodeSent: new Map() });
|
||||
this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), appSent: new Map(), nodeSent: new Map(), viewSent: new Map() });
|
||||
send(this.state());
|
||||
return id;
|
||||
}
|
||||
@@ -190,7 +217,10 @@ export class SimulationRoom {
|
||||
for (const topic of input.topics) {
|
||||
client.topics.add(topic);
|
||||
if (topic.startsWith('trace:')) client.traceSent.delete(topic.slice('trace:'.length));
|
||||
if (topic.startsWith('app:')) client.appSent.delete(topic);
|
||||
if (topic.startsWith('app:')) {
|
||||
client.appSent.delete(topic);
|
||||
client.viewSent.delete(topic);
|
||||
}
|
||||
if (topic.startsWith('node:')) client.nodeSent.delete(topic.slice('node:'.length));
|
||||
}
|
||||
this.flush([client], true);
|
||||
@@ -199,7 +229,7 @@ export class SimulationRoom {
|
||||
for (const topic of input.topics) client.topics.delete(topic);
|
||||
return;
|
||||
case 'start':
|
||||
this.start();
|
||||
this.start(input.language);
|
||||
return;
|
||||
case 'stop':
|
||||
this.stop();
|
||||
@@ -227,14 +257,15 @@ export class SimulationRoom {
|
||||
for (const client of this.clients.values()) client.send(output);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
start(language?: PageLanguage): void {
|
||||
if (this.mode === 'simulation') return;
|
||||
const snapshot = docSnapshot(this.doc);
|
||||
this.language = language;
|
||||
const snapshot = this.snapshot();
|
||||
this.engine.setSimulationSpeed(this.speed);
|
||||
if (!this.guarded(() => this.engine.handle({ type: 'start', snapshot, seed: this.options.seed(), initialTimeMilliseconds: this.engine.state.currentTimeMilliseconds }))) return;
|
||||
this.linkOfPort = new Map(snapshot.links.flatMap((link) => [[link.sourcePortId, link.id] as const, [link.targetPortId, link.id] as const]));
|
||||
this.software = this.documentSoftware();
|
||||
this.syncTerminalWindows();
|
||||
this.syncWindows();
|
||||
this.mode = 'simulation';
|
||||
this.lastWall = this.options.now();
|
||||
this.broadcast(this.state());
|
||||
@@ -286,7 +317,19 @@ export class SimulationRoom {
|
||||
private act(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
||||
if (this.mode !== 'simulation') return 'notRunning';
|
||||
if (!this.engine.state.topologySnapshot.nodes.some((node) => node.id === input.node)) return 'unknownNode';
|
||||
if (input.app !== 'terminal') return 'unknownAction';
|
||||
switch (input.app) {
|
||||
case 'terminal':
|
||||
return this.actInTerminal(input);
|
||||
case 'webBrowser':
|
||||
return this.actInBrowser(input);
|
||||
case 'webServer':
|
||||
return this.actInWebServer(input);
|
||||
default:
|
||||
return 'unknownAction';
|
||||
}
|
||||
}
|
||||
|
||||
private actInTerminal(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
||||
// A window has a program while the simulation runs (checked above), from its first start on.
|
||||
const window = this.terminalWindows.get(input.node);
|
||||
if (window === undefined || this.engine.terminal(input.node) === undefined) return 'unknownApp';
|
||||
@@ -303,6 +346,66 @@ export class SimulationRoom {
|
||||
}
|
||||
}
|
||||
|
||||
/** The address line (open) and the page's links and forms (follow). */
|
||||
private actInBrowser(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
||||
const window = this.browserWindows.get(input.node);
|
||||
if (window === undefined || this.engine.webBrowser(input.node) === undefined) return 'unknownApp';
|
||||
switch (input.action) {
|
||||
case 'open':
|
||||
if (typeof input.args.address !== 'string') return 'badArgs';
|
||||
window.open(input.args.address);
|
||||
return undefined;
|
||||
case 'follow': {
|
||||
const { href, post } = input.args;
|
||||
if (typeof href !== 'string' || (post !== undefined && typeof post !== 'string')) return 'badArgs';
|
||||
window.follow(href, post);
|
||||
return undefined;
|
||||
}
|
||||
default:
|
||||
return 'unknownAction';
|
||||
}
|
||||
}
|
||||
|
||||
/** The start button, the virtual hosts' check box and a cell of their table. */
|
||||
private actInWebServer(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
||||
const server = this.engine.webServer(input.node);
|
||||
if (server === undefined) return 'unknownApp';
|
||||
const { args } = input;
|
||||
switch (input.action) {
|
||||
case 'setActive':
|
||||
if (typeof args.active !== 'boolean') return 'badArgs';
|
||||
server.setActive(args.active);
|
||||
return undefined;
|
||||
case 'useVirtualHosts':
|
||||
if (typeof args.use !== 'boolean') return 'badArgs';
|
||||
server.setUseVirtualHosts(args.use);
|
||||
return undefined;
|
||||
case 'setVirtualHost':
|
||||
if (!Number.isInteger(args.row) || (args.row as number) < 0 || (args.row as number) >= server.virtualHostTable.length) return 'badArgs';
|
||||
if ((args.column !== 0 && args.column !== 1) || typeof args.value !== 'string') return 'badArgs';
|
||||
server.setVirtualHost(args.row as number, args.column, args.value);
|
||||
return undefined;
|
||||
default:
|
||||
return 'unknownAction';
|
||||
}
|
||||
}
|
||||
|
||||
/** What a program's window shows besides its lines; null when it has nothing (the program does not run, a browser never ran). */
|
||||
private viewOf(node: Id, app: AppKind): unknown {
|
||||
switch (app) {
|
||||
case 'webBrowser':
|
||||
return this.browserWindows.get(node)?.view ?? null;
|
||||
case 'webServer': {
|
||||
const server = this.engine.webServer(node);
|
||||
if (server === undefined) return null;
|
||||
const view: WebServerView = { active: server.isActive, useVirtualHosts: server.useVirtualHosts, virtualHosts: server.virtualHostTable.map(([host, folder]) => [host, folder] as const) };
|
||||
return view;
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private program(node: Id, app: AppKind): WithLog | undefined {
|
||||
switch (app) {
|
||||
case 'terminal':
|
||||
@@ -328,6 +431,7 @@ export class SimulationRoom {
|
||||
private flush(clients: readonly Client[], subscribing: boolean): void {
|
||||
if (!subscribing) this.updateActivity();
|
||||
for (const window of this.terminalWindows.values()) window.pull();
|
||||
for (const window of this.browserWindows.values()) window.pull();
|
||||
const rowsCache = new Map<Id, TraceFrame[]>();
|
||||
const traceFrames = (portId: Id, after: number): TraceFrame[] => {
|
||||
const frames = this.engine.capture.frames(portId).filter((frame) => frame.number > after);
|
||||
@@ -358,6 +462,17 @@ export class SimulationRoom {
|
||||
client.send(message({ kind: 'trace', portId, frames, reset }));
|
||||
} else {
|
||||
const [, node, app] = /^app:([^/]+)\/(.+)$/.exec(topic)!;
|
||||
const view = this.viewOf(node!, app as AppKind);
|
||||
if (view !== undefined) {
|
||||
// The browser's view is made anew only when it changes; the server's is small, compared as text.
|
||||
const key = app === 'webBrowser' ? view : JSON.stringify(view);
|
||||
if (!client.viewSent.has(topic) || client.viewSent.get(topic) !== key) {
|
||||
client.viewSent.set(topic, key);
|
||||
client.send(message({ kind: 'view', node: node!, app: app as AppKind, view }));
|
||||
}
|
||||
// The browser's lines are its view.
|
||||
if (app === 'webBrowser') continue;
|
||||
}
|
||||
const sent = client.appSent.get(topic);
|
||||
if (app === 'terminal') {
|
||||
// The window's lines, kept over stops: a client goes on from those it has.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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: node('Switch 1').id, app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownApp' });
|
||||
expect(reason({ node: notebook, app: 'terminal', action: 'paint' })).toMatchObject({ code: 'unknownAction' });
|
||||
expect(reason({ node: notebook, app: 'webBrowser', action: 'open' })).toMatchObject({ code: 'unknownAction' });
|
||||
expect(reason({ node: notebook, app: 'emailClient', action: 'send' })).toMatchObject({ code: 'unknownAction' });
|
||||
expect(reason({ node: notebook, app: 'terminal', action: 'enter', args: { line: 7 } })).toMatchObject({ code: 'badArgs' });
|
||||
expect(reason({ node: notebook, app: 'terminal', action: 'interrupt' })).toEqual({ v: 1, kind: 'ack', reqId: 'r' });
|
||||
send({ kind: 'nonsense', reqId: 'q' });
|
||||
@@ -371,3 +371,99 @@ describe('a simulation room', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a simulation room's browser and web server windows", () => {
|
||||
const views = (got: readonly HostMessage[], node: Id, app: string) => ofKind(got, 'view').filter((each) => each.node === node && each.app === app).map((each) => each.view);
|
||||
|
||||
it("sends the browser's view and the web server's whole when they change, and none of the browser's lines", () => {
|
||||
const { node, send, run, got } = setUp();
|
||||
const notebook = node('Notebook 1.10').id;
|
||||
const server = node('Webserver 5.10').id;
|
||||
send({ kind: 'sub', topics: [`app:${notebook}/webBrowser`, `app:${server}/webServer`] });
|
||||
// Before a start: neither has anything.
|
||||
expect([views(got, notebook, 'webBrowser'), views(got, server, 'webServer')]).toEqual([[null], [null]]);
|
||||
send({ kind: 'start' });
|
||||
run(TICK_MILLISECONDS);
|
||||
expect(views(got, notebook, 'webBrowser')).toEqual([null, { address: 'http://', loading: null, arrived: false, page: { kind: 'start' } }]);
|
||||
expect(views(got, server, 'webServer')).toEqual([null, { active: true, useVirtualHosts: false, virtualHosts: [['', ''], ['', ''], ['', ''], ['', ''], ['', '']] }]);
|
||||
send({ kind: 'input', node: notebook, app: 'webBrowser', action: 'open', args: { address: '141.99.5.10' }, reqId: 'open' });
|
||||
expect(ofKind(got, 'ack').map((each) => each.reqId)).toEqual(['open']);
|
||||
expect(views(got, notebook, 'webBrowser').at(-1)).toEqual({ address: 'http://141.99.5.10/', loading: 'http://141.99.5.10/', arrived: false, page: { kind: 'start' } });
|
||||
run(20_000);
|
||||
const page = views(got, notebook, 'webBrowser').at(-1) as { arrived: boolean; page: { kind: string; html: string; images: Record<string, unknown> } };
|
||||
expect(page.arrived).toBe(true);
|
||||
expect(page.page.kind).toBe('html');
|
||||
expect(Object.keys(page.page.images).length).toBeGreaterThan(0);
|
||||
// The browser's lines are its view; the server's go on as lines.
|
||||
expect(ofKind(got, 'app').some((each) => each.app === 'webBrowser')).toBe(false);
|
||||
expect(ofKind(got, 'app').filter((each) => each.app === 'webServer').flatMap((each) => each.lines as { kind: string }[]).some((line) => line.kind === 'received')).toBe(true);
|
||||
// Nothing new: no view sent again.
|
||||
const before = got.length;
|
||||
run(1_000);
|
||||
expect(ofKind(got.slice(before), 'view')).toEqual([]);
|
||||
// The server's button, check box and table.
|
||||
send({ kind: 'input', node: server, app: 'webServer', action: 'setActive', args: { active: false }, reqId: 's1' });
|
||||
send({ kind: 'input', node: server, app: 'webServer', action: 'useVirtualHosts', args: { use: true }, reqId: 's2' });
|
||||
send({ kind: 'input', node: server, app: 'webServer', action: 'setVirtualHost', args: { row: 4, column: 1, value: 'ordner' }, reqId: 's3' });
|
||||
expect(views(got, server, 'webServer').at(-1)).toEqual({ active: false, useVirtualHosts: true, virtualHosts: [['', ''], ['', ''], ['', ''], ['', ''], ['', 'ordner']] });
|
||||
expect(views(got, server, 'webServer')).toHaveLength(5);
|
||||
// A stop: the browser keeps its page (Java's window does), the server has none; a client subscribing again gets both.
|
||||
send({ kind: 'stop' });
|
||||
expect(views(got, server, 'webServer').at(-1)).toBeNull();
|
||||
const again = got.length;
|
||||
send({ kind: 'sub', topics: [`app:${notebook}/webBrowser`] });
|
||||
expect(views(got.slice(again), notebook, 'webBrowser')).toEqual([page]);
|
||||
});
|
||||
|
||||
it('follows the links of a page, and writes the routers’ pages in the language of the start', () => {
|
||||
const { node, send, run, got, room } = setUp();
|
||||
const notebook = node('Notebook 1.10').id;
|
||||
const gateway = docSnapshot(exampleDoc()).deviceConfigurations.get(notebook)!.defaultGateway;
|
||||
send({ kind: 'sub', topics: [`app:${notebook}/webBrowser`] });
|
||||
const page = () => (views(got, notebook, 'webBrowser').at(-1) as { address: string; page: { html?: string } });
|
||||
send({ kind: 'start' });
|
||||
run(TICK_MILLISECONDS);
|
||||
send({ kind: 'input', node: notebook, app: 'webBrowser', action: 'open', args: { address: gateway }, reqId: 'r' });
|
||||
run(20_000);
|
||||
expect(page().page.html).toContain('Administration des Vermittlungsrechners');
|
||||
send({ kind: 'input', node: notebook, app: 'webBrowser', action: 'follow', args: { href: 'routes' }, reqId: 'f' });
|
||||
run(20_000);
|
||||
expect([page().address, page().page.html]).toEqual([`http://${gateway}/routes`, expect.stringContaining('Routing-/Weiterleitungs-Tabelle')]);
|
||||
room.stop();
|
||||
send({ kind: 'start', language: 'en' });
|
||||
run(TICK_MILLISECONDS);
|
||||
send({ kind: 'input', node: notebook, app: 'webBrowser', action: 'open', args: { address: gateway }, reqId: 'r' });
|
||||
run(20_000);
|
||||
expect(page().page.html).toContain('Router Administration');
|
||||
});
|
||||
|
||||
it('names a wrong action in the windows', () => {
|
||||
const { node, send, run, got } = setUp();
|
||||
const notebook = node('Notebook 1.10').id;
|
||||
const server = node('Webserver 5.10').id;
|
||||
const dns = node('DNS-Server 2.10').id;
|
||||
send({ kind: 'start' });
|
||||
run(TICK_MILLISECONDS);
|
||||
const cases: [string, string, string, Record<string, unknown>, string][] = [
|
||||
[notebook, 'webBrowser', 'open', {}, 'badArgs'],
|
||||
[notebook, 'webBrowser', 'follow', { href: 7 }, 'badArgs'],
|
||||
[notebook, 'webBrowser', 'follow', { href: 'a', post: 1 }, 'badArgs'],
|
||||
[notebook, 'webBrowser', 'back', {}, 'unknownAction'],
|
||||
[dns, 'webBrowser', 'open', { address: 'x' }, 'unknownApp'],
|
||||
[server, 'webServer', 'setActive', { active: 'yes' }, 'badArgs'],
|
||||
[server, 'webServer', 'useVirtualHosts', { use: 1 }, 'badArgs'],
|
||||
[server, 'webServer', 'setVirtualHost', { row: 5, column: 0, value: 'a' }, 'badArgs'],
|
||||
[server, 'webServer', 'setVirtualHost', { row: -1, column: 0, value: 'a' }, 'badArgs'],
|
||||
[server, 'webServer', 'setVirtualHost', { row: 1.5, column: 0, value: 'a' }, 'badArgs'],
|
||||
[server, 'webServer', 'setVirtualHost', { row: 0, column: 2, value: 'a' }, 'badArgs'],
|
||||
[server, 'webServer', 'setVirtualHost', { row: 0, column: 1, value: 2 }, 'badArgs'],
|
||||
[server, 'webServer', 'restart', {}, 'unknownAction'],
|
||||
[notebook, 'webServer', 'setActive', { active: true }, 'unknownApp'],
|
||||
[server, 'emailServer', 'setActive', { active: true }, 'unknownAction'],
|
||||
];
|
||||
for (const [target, app, action, args, code] of cases) {
|
||||
send({ kind: 'input', node: target, app, action, args, reqId: `${app}.${action}.${JSON.stringify(args)}` });
|
||||
expect(ofKind(got, 'error').at(-1), `${app} ${action} ${JSON.stringify(args)}`).toMatchObject({ code, reqId: `${app}.${action}.${JSON.stringify(args)}` });
|
||||
}
|
||||
expect(ofKind(got, 'ack')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user