feat(core): the command line with its local commands after Java, measured against Java

The terminal splits a line as Java's window does and runs ls, cd, pwd,
mkdir, touch, cp, mv, rm, cat, test, help, ipconfig and route on the
node's files, which are the project's own. Its texts are generated from
the bundle Java loads. TerminalCases feeds Java's Terminal without a
network (16 cases, de and en); eight cases name Java bugs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 20:32:35 +00:00
co-authored by Claude Opus 5.5
parent 058675a148
commit 016f9461df
17 changed files with 14404 additions and 7 deletions
+2
View File
@@ -26,6 +26,8 @@ export * from './runtime/apps/routerPages.js';
export * from './runtime/apps/firewallSettings.js';
export * from './runtime/apps/emailServer.js';
export * from './runtime/apps/emailClient.js';
export * from './runtime/apps/terminal.js';
export * from './runtime/apps/terminalTexts.js';
export * from './runtime/email.js';
export * from './runtime/http.js';
export * from './util/equal.js';
@@ -261,6 +261,19 @@ export class VirtualFileSystem {
});
}
/**
* A directory's entries in the order they were made, as Java's file tree
* keeps them (its command line lists them so); a file written anew keeps
* its place.
*/
entriesInCreationOrder(rawDirectoryPath: string): VirtualFileEntry[] {
const directoryPath = this.existingPath(rawDirectoryPath);
if (this.get(directoryPath)?.kind !== 'directory') fail('expectedDirectory', directoryPath);
return [...this.store.values()]
.filter(({ path }) => !swiftEqual(path, directoryPath) && swiftEqual(parentPath(path), directoryPath))
.map(({ path, content }) => ({ path, content }));
}
textFile(rawPath: string): string {
const entry = this.entry(rawPath);
if (entry.content.kind !== 'text') fail('expectedTextFile', entry.path);
+363
View File
@@ -0,0 +1,363 @@
// FILIUS' command line (filius.software.lokal.Terminal, with the input
// handling of GUIApplicationTerminalWindow): a line's first word is the
// command, the others its parameters; the program tells its window texts,
// in the project's language, as Java writes them. The files are the node's
// (the snapshot's file system, the same the project keeps).
//
// Departures from Java (terminal.test.ts names each):
// - a text file's size is its length in characters (Java decodes the text as
// Base64 and counts the bytes: "Hallo Welt" is 6);
// - a folder that is not there is "not there" for ls (Java: "empty"), and
// ls / names the root (Java leaves the path out);
// - cd to a file is "not there" (Java makes the file the working directory);
// - touch and mkdir in a folder that is not there fail (Java says the file
// was made, or the folder is there already); cp onto a folder's name
// fails (Java throws);
// - ".." at the root stays there (Java finds nothing, or throws);
// - rm and mv take an absolute path as absolute (Java puts the working
// directory in front of the path it removes); the root cannot be removed (Java removes the working
// directory and says it removed the file); after the working directory
// is removed, the nearest folder above it that is left is the working
// directory (Java goes on in the removed one);
// - mv onto the file itself, also in another case, renames it (Java copies
// the file onto itself and removes it).
import type { Id } from '../../ids.js';
import type { VirtualFileContent, VirtualFileSystem } from '../../model/virtualFileSystem.js';
import { entryName, parentPath } from '../../model/virtualFileSystem.js';
import type { NetworkRuntimeEngine } from '../engine.js';
import { javaTrim } from '../http.js';
import type { Steps } from '../threads.js';
import { javaBase64 } from '../../util/javaBase64.js';
import { CommandThread, ProgramLog } from './program.js';
import type { PageLanguage } from './routerPages.js';
import { TERMINAL_TEXTS, type TerminalTextKey } from './terminalTexts.js';
/** What the command line tells its window: a text, or whether a command runs on (Java's Boolean). */
export type TerminalLogEntry = { readonly kind: 'text'; readonly text: string } | { readonly kind: 'busy'; readonly busy: boolean };
/** What the window does itself with a line (GUIApplicationTerminalWindow). */
export type TerminalWindowAction = 'blank' | 'exit' | 'clear';
/** GUIApplicationTerminalWindow.MENU_LINE. */
export const TERMINAL_MENU_LINE = '==========================================================================\n';
/** The usage texts' keys by command (Terminal.initUsageInfo). */
const USAGE: Record<string, string> = {
arp: 'arp',
arpsend: 'arpsend',
cat: 'cat',
cd: 'cd',
copy: 'copy',
cp: 'copy',
del: 'del',
dir: 'dir',
help: 'help',
host: 'host',
ipconfig: 'ipconfig',
ls: 'dir',
mkdir: 'mkdir',
move: 'move',
mv: 'move',
netstat: 'netstat',
nslookup: 'nslookup',
ping: 'ping',
pwd: 'pwd',
rm: 'del',
route: 'route',
tcpdump: 'tcpdump',
touch: 'touch',
traceroute: 'traceroute',
type: 'cat',
};
/**
* Dateisystem.evaluatePathString for an absolute path: "." and empty parts
* left out, ".." one up (at the root it stays there).
*/
function evaluatedPath(path: string): string {
const parts: string[] = [];
for (const part of path.split('/')) {
if (part === '..') parts.pop();
else if (part !== '.' && part !== '') parts.push(part);
}
return `/${parts.join('/')}`;
}
/** A path's last part (Dateisystem.getBasename). */
const basenameOf = (path: string) => path.slice(path.lastIndexOf('/') + 1);
export class Terminal {
readonly log: ProgramLog<TerminalLogEntry>;
private directoryValue = '/';
/** The program's thread (Anwendung.ausfuehren): one command after the other. */
private readonly commands: CommandThread;
constructor(
private readonly engine: NetworkRuntimeEngine,
private readonly nodeId: Id,
private readonly files: VirtualFileSystem,
) {
this.log = new ProgramLog(engine);
this.commands = new CommandThread(engine);
}
private get language(): PageLanguage {
return this.engine.state.topologySnapshot.language;
}
private text(key: TerminalTextKey): string {
return TERMINAL_TEXTS[this.language][key];
}
/** The working directory, as Dateisystem.absoluterPfad writes it. */
get directory(): string {
return this.directoryValue;
}
/** The window's prompt. */
get prompt(): string {
return `${this.directoryValue}> `;
}
/** GUIApplicationTerminalWindow.showStartScreen. */
get startScreen(): string {
return `${this.text('sw_terminal_msg57')}\n${TERMINAL_MENU_LINE}${this.text('sw_terminal_msg26')}\n${TERMINAL_MENU_LINE}`;
}
/**
* A line typed into the window: blank, exit, cls and reset are the
* window's; any other line is the program's command, run by its thread.
*/
enter(line: string): TerminalWindowAction | undefined {
const words = line.split(' ').filter((word) => word !== '');
if (javaTrim(line) === '' || words.length === 0) return 'blank';
const [command, ...parameters] = words as [string, ...string[]];
if (command === 'exit') return 'exit';
if (command === 'reset' || command === 'cls') return 'clear';
this.commands.hand(() => this.run(command, parameters));
this.engine.resumeThreads();
return undefined;
}
private tell(text: string): void {
this.log.write({ kind: 'text', text });
}
/** Terminal.terminalEingabeAuswerten: the method of the command's name, if Java has one. */
private *run(command: string, parameters: readonly string[]): Steps {
const local: Record<string, (parameters: readonly string[]) => void> = {
cat: (each) => this.cat(each),
cd: (each) => this.cd(each),
copy: (each) => this.copy(each, 'cp'),
cp: (each) => this.copy(each, 'cp'),
del: (each) => this.remove(each),
dir: (each) => this.list(each),
help: (each) => this.usage(each[0] ?? ''),
ipconfig: (each) => this.ipconfig(each),
ls: (each) => this.list(each),
mkdir: (each) => this.makeDirectory(each),
move: (each) => this.move(each),
mv: (each) => this.move(each),
pwd: (each) => (each.length === 0 ? this.tell(this.directoryValue) : this.usage('pwd')),
rm: (each) => this.remove(each),
route: (each) => this.route(each),
test: () => this.test(),
touch: (each) => this.touch(each),
type: (each) => this.cat(each),
};
const handler = Object.hasOwn(local, command) ? local[command] : undefined;
if (handler === undefined) {
this.tell(`${this.text('terminal_msg2')}\n${this.text('terminal_msg3')}`);
return;
}
handler(parameters);
}
/** Terminal.usage: the command's usage, or the list of commands. */
private usage(command: string): void {
const key = Object.hasOwn(USAGE, command) ? USAGE[command] : undefined;
if (key === undefined) {
this.tell(this.text('sw_terminal_msg25'));
return;
}
const texts = TERMINAL_TEXTS[this.language] as Record<string, string>;
this.tell(`${texts[`sw_terminal_${key}`]}\n${texts[`sw_terminal_usage_${key}`]}\n`);
}
/** A parameter as an absolute path: from the root, or from the working directory. */
private absolute(path: string): string {
return evaluatedPath(path.startsWith('/') ? path : `${this.directoryValue}/${path}`);
}
/** The content at a path, found in any case; undefined when there is none. */
private content(path: string): { readonly path: string; readonly content: VirtualFileContent } | undefined {
try {
return this.files.entry(path);
} catch {
return undefined;
}
}
/** Datei.holeGroesse, a text in characters (see the departures above). */
private static size(content: VirtualFileContent): number {
return content.kind === 'text' ? content.text.length : content.kind === 'directory' ? 0 : content.data.length;
}
/** Terminal.dir. */
private list(parameters: readonly string[]): void {
if (parameters.length > 1) return this.usage('dir');
const shown = parameters.length === 0 ? this.directoryValue : this.absolute(parameters[0]!);
const found = this.content(shown);
if (found === undefined || found.content.kind !== 'directory') return this.tell(this.text('sw_terminal_msg20'));
const entries = this.files.entriesInCreationOrder(found.path);
if (entries.length === 0) return this.tell(this.text('sw_terminal_msg8'));
let text = `${this.text('sw_terminal_msg9')} ${shown}:`;
let directories = 0;
let files = 0;
for (const entry of entries) {
const name = entryName(entry.path);
if (entry.content.kind === 'directory') {
directories += 1;
text += `\n[${name}]`;
} else {
files += 1;
text += `\n${name}${'.'.repeat(Math.max(0, 40 - name.length))}${Terminal.size(entry.content)}`;
}
}
this.tell(`${text}\n${this.text('sw_terminal_msg10')}${directories}${this.text('sw_terminal_msg11')}${files}`);
}
/** Terminal.cd. */
private cd(parameters: readonly string[]): void {
if (parameters.length > 1) return this.usage('cd');
if (parameters.length === 0) return this.tell(this.directoryValue);
const found = this.content(this.absolute(parameters[0]!));
if (found === undefined || found.content.kind !== 'directory') return this.tell(this.text('sw_terminal_msg20'));
this.directoryValue = found.path;
this.tell('');
}
/** A new item at a path (touch, mkdir): the parent must be a folder, the name new. */
private create(parameters: readonly string[], command: 'touch' | 'mkdir', make: (path: string) => void): void {
if (parameters.length !== 1) return this.usage(command);
const keys =
command === 'touch'
? ({ noName: 'sw_terminal_msg15', exists: 'sw_terminal_msg14', made: 'sw_terminal_msg13', failed: 'sw_terminal_msg12' } as const)
: ({ noName: 'sw_terminal_msg19', exists: 'sw_terminal_msg18', made: 'sw_terminal_msg17', failed: 'sw_terminal_msg16' } as const);
const path = this.absolute(parameters[0]!);
if (basenameOf(path) === '') return this.tell(this.text(keys.noName));
if (this.content(path) !== undefined) return this.tell(this.text(keys.exists));
// The file system takes nothing in a folder that is not there (or is a file).
try {
make(path);
this.tell(this.text(keys.made));
} catch {
this.tell(this.text(keys.failed));
}
}
private touch(parameters: readonly string[]): void {
this.create(parameters, 'touch', (path) => this.files.writeTextFile(path, '', false));
}
private makeDirectory(parameters: readonly string[]): void {
this.create(parameters, 'mkdir', (path) => this.files.createDirectory(path));
}
/** Writes content as a new file or over a file of that name (Dateisystem.speicherDatei). */
private write(path: string, content: VirtualFileContent): void {
if (content.kind === 'text') this.files.writeTextFile(path, content.text);
else if (content.kind === 'image') this.files.writeImageFile(path, content.data, content.mediaType);
else if (content.kind === 'binary') this.files.writeBinaryFile(path, content.data, content.mediaType);
}
/** Terminal.pureCopy: a file to a new name or over a file; false where it cannot. */
private copied(parameters: readonly string[]): { readonly source: string; readonly destination: string } | undefined {
const source = this.content(this.absolute(parameters[0]!));
const destination = this.absolute(parameters[1]!);
if (source === undefined || source.content.kind === 'directory' || basenameOf(destination) === '') return undefined;
// The file system writes over a file only, in a folder that is there; onto the file itself it changes nothing.
try {
this.write(destination, source.content);
} catch {
return undefined;
}
return { source: source.path, destination };
}
private copy(parameters: readonly string[], command: 'cp'): void {
if (parameters.length !== 2) return this.usage(command);
this.tell(this.text(this.copied(parameters) === undefined ? 'sw_terminal_msg34' : 'sw_terminal_msg33'));
}
/** Terminal.mv: copied, then the source removed; onto the file itself, renamed. */
private move(parameters: readonly string[]): void {
if (parameters.length !== 2) return this.usage('mv');
const done = this.copied(parameters);
if (done === undefined) return this.tell(this.text('sw_terminal_msg36'));
if (done.source.toLowerCase() === done.destination.toLowerCase()) {
const content = this.files.entry(done.source).content;
this.files.deleteItem(done.source);
this.write(done.destination, content);
} else {
this.files.deleteItem(done.source, true);
}
this.tell(this.text('sw_terminal_msg35'));
}
/** Terminal.del: a file or a folder with all in it. */
private remove(parameters: readonly string[]): void {
if (parameters.length !== 1) return this.usage('del');
const found = this.content(this.absolute(parameters[0]!));
if (found === undefined || found.path === '/') return this.tell(this.text('sw_terminal_msg38'));
this.files.deleteItem(found.path, true);
while (this.content(this.directoryValue) === undefined) this.directoryValue = parentPath(this.directoryValue);
this.tell(this.text('sw_terminal_msg37'));
}
/** Terminal.cat: a file's content as Java keeps it (an image or binary file in Base64). */
private cat(parameters: readonly string[]): void {
if (parameters.length === 0) {
this.usage('cat');
this.tell('');
return;
}
const found = this.content(this.absolute(parameters[0]!));
if (found === undefined || found.content.kind === 'directory') return this.tell(this.text('sw_terminal_msg54'));
this.tell(found.content.kind === 'text' ? found.content.text : javaBase64(found.content.data));
}
/** Terminal.test: test.txt in the working directory. */
private test(): void {
try {
this.files.writeTextFile(`${this.directoryValue === '/' ? '' : this.directoryValue}/test.txt`, 'blaaa');
this.tell(this.text('sw_terminal_msg24'));
} catch {
this.tell(this.text('sw_terminal_msg23'));
}
}
/** Terminal.ipconfig: the node's first address, mask, MAC, gateway and DNS server. */
private ipconfig(parameters: readonly string[]): void {
if (parameters.length !== 0) return this.usage('ipconfig');
const configuration = this.engine.state.topologySnapshot.deviceConfigurations.get(this.nodeId);
const card = this.engine.networkInterfaces(this.nodeId)[0];
this.tell(
`${this.text('sw_terminal_msg4')} ${card?.ipAddress ?? configuration?.ipAddress ?? ''}\n` +
`${this.text('sw_terminal_msg5')} ${card?.subnetMask ?? configuration?.subnetMask ?? ''}\n` +
`${this.text('sw_terminal_msg6666')} ${card?.macAddress ?? ''}\n` +
`${this.text('sw_terminal_msg6')} ${configuration?.defaultGateway ?? ''}\n` +
`${this.text('sw_terminal_msg27')} ${configuration?.dnsServer ?? ''}\n`,
);
}
/** Terminal.route: the forwarding table, each cell padded to 15. */
private route(parameters: readonly string[]): void {
if (parameters.length !== 0) return this.usage('route');
const rows = this.engine.forwardingTable(this.nodeId);
const cell = (text: string) => `${text}${' '.repeat(Math.max(0, 15 - text.length))} | `;
const table = rows.map((row) => `| ${[row.destinationNetwork, row.subnetMask, row.nextHop, row.interfaceIPAddress].map(cell).join('')}\n`).join('');
this.tell(this.text('sw_terminal_msg7') + table);
}
}
@@ -0,0 +1,226 @@
// The command line's texts as Java FILIUS's message bundle gives them
// (MessagesBundle_de_DE, _en_GB; the keys sw_terminal_* and terminal_msg*),
// written from what tools/java-oracle's TerminalCases read from Java (texts
// of terminal.<de|en>.java.json), so that continued lines and spacing are
// Java's own.
import type { PageLanguage } from './routerPages.js';
export const TERMINAL_TEXTS = {
de: {
sw_terminal_arp: 'arp - show or manipulate Address Resolution Protocol (ARP) table',
sw_terminal_arpsend: 'arpsend - send an ARP reply',
sw_terminal_cat: 'cat / type - show file contents',
sw_terminal_cd: 'cd - change directory',
sw_terminal_copy: 'copy / cp - copy file',
sw_terminal_del: 'del / rm - delete file/directory',
sw_terminal_dir: 'dir / ls - show list of files in current directory',
sw_terminal_exit: 'exit - exit terminal application',
sw_terminal_help: 'help - show the list of commands or info on one command',
sw_terminal_host: 'host - resolve hostname to IP address',
sw_terminal_ipconfig: 'ipconfig - show network configuration',
sw_terminal_mkdir: 'mkdir - create directory',
sw_terminal_move: 'move / mv - move/rename file',
sw_terminal_msg10: '\nAnzahl Verzeichnisse:',
sw_terminal_msg11: '\nAnzahl Dateien:',
sw_terminal_msg12: 'Fehler bei Touch\n',
sw_terminal_msg13: 'Datei erstellt\n',
sw_terminal_msg14: 'Datei bereits vorhanden\n',
sw_terminal_msg15: 'Bitte einen Dateinamen angeben!\n',
sw_terminal_msg16: 'Fehler beim Ordner erstellen\n',
sw_terminal_msg17: 'Ordner erstellt\n',
sw_terminal_msg18: 'Ordner bereits vorhanden\n',
sw_terminal_msg19: 'Bitte einen Ordnernamen angeben!\n',
sw_terminal_msg20: 'Verzeichnis nicht vorhanden\n',
sw_terminal_msg23: 'Test\n',
sw_terminal_msg24: 'Erfolgreich erstellt!',
sw_terminal_msg25:
'Liste verfügbarer Befehle:\n arp zeige Adresstabelle des "Address Resolution Protocol" (ARP)\n arpsend versende eine ARP-Antwort\n cat / type zeige Dateiinhalt an \n cd Verzeichnis wechseln\n cls Anzeige zurücksetzen\n copy / cp Datei kopieren\n del / rm lösche Datei/Verzeichnis\n dir / ls zeige Liste der Dateien im aktuellen Verzeichnis\n exit beende Terminal-Anwendung\n help zeige diese Befehlsliste\n host löse Hostnamen zu IP-Adresse auf\n ipconfig Netzwerkkonfiguration anzeigen\n mkdir erstelle Verzeichnis\n move / mv Datei verschieben/umbenennen\n netstat zeige Liste aller Verbindungen\n nslookup löse Hostnamen zu IP-Adresse auf\n ping teste Verbindung zu anderem Rechner\n pwd gib Pfad des aktuellen Arbeitsverzeichnisses aus\n route Routing-/Weiterleitungstabelle anzeigen\n tcpdump Datenaustausch anzeigen\n touch erstelle Datei\n traceroute analysiere Stationen des Übertragungsweges \n',
sw_terminal_msg26: "Verwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.",
sw_terminal_msg27: 'DNS-Server . . . :',
sw_terminal_msg28: 'hat die IP-Adresse',
sw_terminal_msg29: 'Ungültige Host-Angabe!\nNur korrekte URLs ohne Pfadangabe erlaubt.\n',
sw_terminal_msg30: 'Hostname konnte nicht aufgelöst werden!\nEventuell ist kein DNS-Server verfügbar.\n',
sw_terminal_msg31: 'Zeitüberschreitung! Keine Antwort empfangen.',
sw_terminal_msg33: 'Datei erfolgreich kopiert\n',
sw_terminal_msg34: 'Datei konnte nicht kopiert werden\n',
sw_terminal_msg35: 'Datei erfolgreich verschoben/umbenannt\n',
sw_terminal_msg36: 'Datei konnte nicht verschoben/umbenannt werden\n',
sw_terminal_msg37: 'Datei erfolgreich gelöscht\n',
sw_terminal_msg38: 'Datei konnte nicht gelöscht werden\n',
sw_terminal_msg4: 'IP Adresse . . . :',
sw_terminal_msg45: 'Paketstatistik',
sw_terminal_msg47: 'Paket(e) empfangen',
sw_terminal_msg48: 'Paketverlust',
sw_terminal_msg49: '| Proto | Lokale Adresse | Entfernte Adresse | Zustand |\n',
sw_terminal_msg5: 'Netzmaske. . . . :',
sw_terminal_msg50: '| Internetadresse | Physische Adresse |\n',
sw_terminal_msg52: 'Zieladresse nicht erreichbar',
sw_terminal_msg53: 'Ping zu Broadcast-Adressen wird nicht unterstützt.',
sw_terminal_msg54: 'Datei nicht vorhanden',
sw_terminal_msg55: 'Beenden mit Strg+C',
sw_terminal_msg57:
"\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n",
sw_terminal_msg58: 'Mails werden behandelt durch',
sw_terminal_msg6: 'Standardgateway. :',
sw_terminal_msg6666: 'Physische Adresse:',
sw_terminal_msg7: '| Ziel | Netzmaske | Gateway | Schnittstelle |\n -----------------------------------------------------------------------\n',
sw_terminal_msg8: 'Verzeichnis ist leer\n',
sw_terminal_msg9: 'Verzeichnis',
sw_terminal_netstat: 'netstat - show list of connections',
sw_terminal_nslookup: 'nslookup - resolve hostname to IP address',
sw_terminal_ping: 'ping - test connection to other computer',
sw_terminal_pwd: 'pwd - print working directory',
sw_terminal_route: 'route - show routing table',
sw_terminal_tcpdump: 'tcpdump - show data exchange',
sw_terminal_touch: 'touch - create file',
sw_terminal_trace_msg10: 'Fehler: %s antwortet "ICMP Destination Unreachable"',
sw_terminal_trace_msg11: '%s wurde nach einem Sprung (hop) erreicht.',
sw_terminal_trace_msg12: '%s wurde nach %s Sprüngen (hops) erreicht.',
sw_terminal_trace_msg13: 'Fehler: nächste Station konnte mehrfach nicht erreicht werden.',
sw_terminal_trace_msg2: 'Fehler: Verbindung zum DNS-Server nicht möglich.',
sw_terminal_trace_msg3: 'Fehler: Domainname konnte nicht aufgelöst werden.',
sw_terminal_trace_msg4: 'Verbindung nach %s wird bestimmt (max. %s Stationen).',
sw_terminal_trace_msg5: 'Verbindung nach %s (%s) wird bestimmt (max. %s Stationen).',
sw_terminal_trace_msg6: '%s konnte über %s Stationen (hops) nicht erreicht werden.',
sw_terminal_trace_msg7: 'Analyse abgebrochen!',
sw_terminal_trace_msg8: 'Fehler: %s antwortet "ICMP Network Unreachable"',
sw_terminal_trace_msg9: 'Fehler: %s antwortet "ICMP Host Unreachable"',
sw_terminal_traceroute: 'traceroute - analyse hops of connecting path',
sw_terminal_usage_arp:
'Usage: arp [-d [<IP address>]] [-a <IP address>] \n -d \t delete ARP entry for IP address or reset ARP table \n -a \t show ARP entry for specified IP address',
sw_terminal_usage_arpsend:
'Usage: arpsend <sender IP address> <target IP address>\n <sender IP address> address to be published for own MAC address\n <target IP address> target the reply must be sent to',
sw_terminal_usage_cat: 'Usage: cat <file name>',
sw_terminal_usage_cd: 'Usage: cd <directory path>',
sw_terminal_usage_copy: 'Usage: copy <original file> <new file name>',
sw_terminal_usage_del: 'Usage: del <file | folder> \n <file | folder> relative path to current directory',
sw_terminal_usage_dir: 'Usage: dir',
sw_terminal_usage_exit: 'Usage: exit',
sw_terminal_usage_help: 'Usage: help [<command>]',
sw_terminal_usage_host: 'Usage: host <domain> \n <domain> the domain for which to look up A and MX records',
sw_terminal_usage_ipconfig: 'Usage: ipconfig',
sw_terminal_usage_mkdir: 'Usage: mkdir <directory name> \n <directory name> name of new directory either as absolute path \n or relative path',
sw_terminal_usage_move: 'Usage: move <original file> <new file name>',
sw_terminal_usage_netstat: 'Usage: netstat',
sw_terminal_usage_nslookup: 'Usage: nslookup <domain> \n <domain> the domain for which to look up A (and related NS) records',
sw_terminal_usage_ping: 'Usage: ping <remote host> \n <remote host> the remote address as IP address or host name',
sw_terminal_usage_pwd: 'Usage: pwd',
sw_terminal_usage_route: 'Usage: route',
sw_terminal_usage_tcpdump: 'Usage: tcpdump',
sw_terminal_usage_touch: 'Usage: touch <file name>',
sw_terminal_usage_traceroute: 'Usage: traceroute <remote host> \n <remote host> the remote address as IP address or host name',
terminal_msg1: 'root>',
terminal_msg2: 'Unbekannter Befehl!',
terminal_msg3: "Mit 'help' erhalten Sie eine Liste bekannter Befehle.",
},
en: {
sw_terminal_arp: 'arp - show or manipulate Address Resolution Protocol (ARP) table',
sw_terminal_arpsend: 'arpsend - send an ARP reply',
sw_terminal_cat: 'cat / type - show file contents',
sw_terminal_cd: 'cd - change directory',
sw_terminal_copy: 'copy / cp - copy file',
sw_terminal_del: 'del / rm - delete file/directory',
sw_terminal_dir: 'dir / ls - show list of files in current directory',
sw_terminal_exit: 'exit - exit terminal application',
sw_terminal_help: 'help - show the list of commands or info on one command',
sw_terminal_host: 'host - resolve hostname to IP address',
sw_terminal_ipconfig: 'ipconfig - show network configuration',
sw_terminal_mkdir: 'mkdir - create directory',
sw_terminal_move: 'move / mv - move/rename file',
sw_terminal_msg10: '\nNumber of directories:',
sw_terminal_msg11: '\nNumber of files:',
sw_terminal_msg12: 'Error in Touch\n',
sw_terminal_msg13: 'File created\n',
sw_terminal_msg14: 'File does already exist\n',
sw_terminal_msg15: 'Please enter a file name!\n',
sw_terminal_msg16: 'Error while creating new folder\n',
sw_terminal_msg17: 'Folder is created\n',
sw_terminal_msg18: 'Folder does already exist\n',
sw_terminal_msg19: 'Please enter folder name!\n',
sw_terminal_msg20: 'Directory does not exist\n',
sw_terminal_msg23: 'Test\n',
sw_terminal_msg24: 'Successfully built!',
sw_terminal_msg25:
'List of available commands:\n arp show or manipulate Address Resolution Protocol (ARP) table\n arpsend send an ARP reply\n cat / type show file contents \n cd change directory\n cls clear screen \n copy / cp copy file\n del / rm delete file/directory\n dir / ls show list of files in current directory\n exit exit terminal application\n help show this list of commands\n host resolve hostname to IP address\n ipconfig show network configuration\n mkdir create directory\n move / mv move/rename file\n netstat show list of connections\n nslookup resolve hostname to IP address\n ping test connection to other computer\n pwd print working directory\n route show routing table\n tcpdump show data exchange\n touch create file\n traceroute analyse hops of connecting path \n',
sw_terminal_msg26: "Use command 'help' to show list of available commands \n or 'help <command>' for further information on the command.",
sw_terminal_msg27: 'DNS server . . .:',
sw_terminal_msg28: 'has the IP address',
sw_terminal_msg29: 'Invalid host string!\nOnly correct URLs without paths are allowed.\n',
sw_terminal_msg30: 'Hostname could not be resolved!\nPossibly no DNS server is available.\n',
sw_terminal_msg31: 'Timeout! No response received during waiting period.',
sw_terminal_msg33: 'File successfully copied\n',
sw_terminal_msg34: 'File could not be copied\n',
sw_terminal_msg35: 'File successfully moved/renamed\n',
sw_terminal_msg36: 'File could not be moved/renamed\n',
sw_terminal_msg37: 'File successfully deleted\n',
sw_terminal_msg38: 'File could not be deleted\n',
sw_terminal_msg4: 'IP address . . .:',
sw_terminal_msg45: 'packet statistics',
sw_terminal_msg47: 'packet(s) received',
sw_terminal_msg48: 'packet loss',
sw_terminal_msg49: '| Proto | Local Address | Remote Address | State |\n',
sw_terminal_msg5: 'Netmask. . . . .:',
sw_terminal_msg50: '| Internet Address | Physical Address |\n',
sw_terminal_msg52: 'Destination not reachable',
sw_terminal_msg53: 'Ping to Broadcast is not supported.',
sw_terminal_msg54: 'No such file',
sw_terminal_msg55: 'Cancel with Ctrl+C',
sw_terminal_msg57:
"\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n",
sw_terminal_msg58: 'mail is handled by',
sw_terminal_msg6: 'Standard gateway:',
sw_terminal_msg6666: 'Physical address:',
sw_terminal_msg7: '| Destination | Netmask | Gateway | Interface |\n -------------------------------------------------------------------------\n',
sw_terminal_msg8: 'Directory is empty\n',
sw_terminal_msg9: 'Directory',
sw_terminal_netstat: 'netstat - show list of connections',
sw_terminal_nslookup: 'nslookup - resolve hostname to IP address',
sw_terminal_ping: 'ping - test connection to other computer',
sw_terminal_pwd: 'pwd - print working directory',
sw_terminal_route: 'route - show routing table',
sw_terminal_tcpdump: 'tcpdump - show data exchange',
sw_terminal_touch: 'touch - create file',
sw_terminal_trace_msg10: 'Error: response from %s was "ICMP Destination Unreachable"',
sw_terminal_trace_msg11: '%s was reached with one hop.',
sw_terminal_trace_msg12: '%s was reached with %s hops.',
sw_terminal_trace_msg13: 'Error: next hop could not be processed.',
sw_terminal_trace_msg2: 'Error: could not connect to DNS server.',
sw_terminal_trace_msg3: 'Error: domain could not be resolved.',
sw_terminal_trace_msg4: 'trace route to %s started (max. %s hops).',
sw_terminal_trace_msg5: 'trace route to %s (%s) started (max. %s hops).',
sw_terminal_trace_msg6: '%s could not be reached via %s hops.',
sw_terminal_trace_msg7: 'Analysis interrupted!',
sw_terminal_trace_msg8: 'Error: response from %s was "ICMP Network Unreachable"',
sw_terminal_trace_msg9: 'Error: response from %s was "ICMP Host Unreachable"',
sw_terminal_traceroute: 'traceroute - analyse hops of connecting path',
sw_terminal_usage_arp:
'Usage: arp [-d [<IP address>]] [-a <IP address>] \n -d \t delete ARP entry for IP address or reset ARP table \n -a \t show ARP entry for specified IP address',
sw_terminal_usage_arpsend:
'Usage: arpsend <sender IP address> <target IP address>\n <sender IP address> address to be published for own MAC address\n <target IP address> target the reply must be sent to',
sw_terminal_usage_cat: 'Usage: cat <file name>',
sw_terminal_usage_cd: 'Usage: cd <directory path>',
sw_terminal_usage_copy: 'Usage: copy <original file> <new file name>',
sw_terminal_usage_del: 'Usage: del <file | folder> \n <file | folder> relative path to current directory',
sw_terminal_usage_dir: 'Usage: dir',
sw_terminal_usage_exit: 'Usage: exit',
sw_terminal_usage_help: 'Usage: help [<command>]',
sw_terminal_usage_host: 'Usage: host <domain> \n <domain> the domain for which to look up A and MX records',
sw_terminal_usage_ipconfig: 'Usage: ipconfig',
sw_terminal_usage_mkdir: 'Usage: mkdir <directory name> \n <directory name> name of new directory either as absolute path \n or relative path',
sw_terminal_usage_move: 'Usage: move <original file> <new file name>',
sw_terminal_usage_netstat: 'Usage: netstat',
sw_terminal_usage_nslookup: 'Usage: nslookup <domain> \n <domain> the domain for which to look up A (and related NS) records',
sw_terminal_usage_ping: 'Usage: ping <remote host> \n <remote host> the remote address as IP address or host name',
sw_terminal_usage_pwd: 'Usage: pwd',
sw_terminal_usage_route: 'Usage: route',
sw_terminal_usage_tcpdump: 'Usage: tcpdump',
sw_terminal_usage_touch: 'Usage: touch <file name>',
sw_terminal_usage_traceroute: 'Usage: traceroute <remote host> \n <remote host> the remote address as IP address or host name',
terminal_msg1: 'root>',
terminal_msg2: 'Unknown command!',
terminal_msg3: "Type 'help' to get a list of available commands.",
},
} as const satisfies Record<PageLanguage, Record<string, string>>;
export type TerminalTextKey = keyof (typeof TERMINAL_TEXTS)['de'];
+20
View File
@@ -118,6 +118,7 @@ import { EchoServer, SimpleClient } from './apps/echo.js';
import { WebBrowser, WebServer, type WebServerPage } from './apps/web.js';
import { EmailClient, type EmailClientFolders } from './apps/emailClient.js';
import { EmailServer } from './apps/emailServer.js';
import { Terminal } from './apps/terminal.js';
import { firewallLogPage, forwardingTablePage, ripTablePage, routerIndexPage } from './apps/routerPages.js';
import { applyFirewallSettings, firewallSettingsPage } from './apps/firewallSettings.js';
import { VirtualFileSystem } from '../model/virtualFileSystem.js';
@@ -915,6 +916,7 @@ export class NetworkRuntimeEngine {
private readonly webBrowsersByNodeId = new Map<Id, WebBrowser>();
private readonly emailServersByNodeId = new Map<Id, EmailServer>();
private readonly emailClientsByNodeId = new Map<Id, EmailClient>();
private readonly terminalsByNodeId = new Map<Id, Terminal>();
/** The time of day a program writes into a mail (Java's new Date()). */
private readonly wallClock: () => number;
/** Each node's firewall log; it outlives a stop and a new start, as Java's log page does. */
@@ -1046,6 +1048,7 @@ export class NetworkRuntimeEngine {
this.webBrowsersByNodeId.clear();
this.emailServersByNodeId.clear();
this.emailClientsByNodeId.clear();
this.terminalsByNodeId.clear();
this.arpLookups = [];
this.protocolThreads.clear();
this.dnsQueriesBySocketId.clear();
@@ -1136,6 +1139,11 @@ export class NetworkRuntimeEngine {
return this.emailServersByNodeId.get(nodeId);
}
/** The node's command line while the simulation runs, if it has one installed. */
terminal(nodeId: Id): Terminal | undefined {
return this.terminalsByNodeId.get(nodeId);
}
/** The node's e-mail program while the simulation runs, if it has one installed. */
emailClient(nodeId: Id): EmailClient | undefined {
return this.emailClientsByNodeId.get(nodeId);
@@ -1171,6 +1179,7 @@ export class NetworkRuntimeEngine {
this.webBrowsersByNodeId.clear();
this.emailServersByNodeId.clear();
this.emailClientsByNodeId.clear();
this.terminalsByNodeId.clear();
for (const node of snapshot.nodes) {
const echoServer = snapshot.echoServersByNodeId.get(node.id);
if (echoServer !== undefined) this.echoServersByNodeId.set(node.id, new EchoServer(this, node.id, echoServer));
@@ -1196,6 +1205,17 @@ export class NetworkRuntimeEngine {
),
);
}
if (snapshot.terminalNodeIds.has(node.id)) {
let files = snapshot.fileSystemsByNodeId.get(node.id);
if (files === undefined) {
// A node without files gets an empty file system, kept in the snapshot.
files = new VirtualFileSystem();
this.stateValue.topologySnapshot = withSnapshot(this.stateValue.topologySnapshot, {
fileSystemsByNodeId: new Map([...this.stateValue.topologySnapshot.fileSystemsByNodeId, [node.id, files]]),
});
}
this.terminalsByNodeId.set(node.id, new Terminal(this, node.id, files));
}
const emailClient = snapshot.emailClientsByNodeId.get(node.id);
if (emailClient !== undefined) {
const nodeId = node.id;
+4
View File
@@ -103,6 +103,8 @@ export interface TopologySnapshot {
readonly webBrowserNodeIds: ReadonlySet<Id>;
/** The installed e-mail servers, by node; a run's deliveries change their mailboxes here. */
readonly emailServersByNodeId: ReadonlyMap<Id, EmailServerSnapshot>;
/** The nodes with the command line installed (Terminal); it works on the node's files. */
readonly terminalNodeIds: ReadonlySet<Id>;
/** The installed e-mail programs, by node; a run's sent and fetched mails change their folders here. */
readonly emailClientsByNodeId: ReadonlyMap<Id, EmailClientConfiguration>;
/** Each node's files, which its programs read (a web server its pages). */
@@ -131,6 +133,7 @@ export interface TopologySnapshotInput {
webBrowserNodeIds?: ReadonlySet<Id>;
emailServersByNodeId?: ReadonlyMap<Id, EmailServerSnapshot>;
emailClientsByNodeId?: ReadonlyMap<Id, EmailClientConfiguration>;
terminalNodeIds?: ReadonlySet<Id>;
fileSystemsByNodeId?: ReadonlyMap<Id, VirtualFileSystem>;
language?: PageLanguage;
}
@@ -156,6 +159,7 @@ export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot
webBrowserNodeIds: input.webBrowserNodeIds ?? new Set(),
emailServersByNodeId: input.emailServersByNodeId ?? new Map(),
emailClientsByNodeId: input.emailClientsByNodeId ?? new Map(),
terminalNodeIds: input.terminalNodeIds ?? new Set(),
fileSystemsByNodeId: input.fileSystemsByNodeId ?? new Map(),
language: input.language ?? 'de',
};
+140
View File
@@ -0,0 +1,140 @@
# Cases for tools/java-oracle's TerminalCases: FILIUS' command line fed with
# lines as typed, one per line. Read by terminal.test.ts.
case listing
ls
dir
ls Dokumente
ls /Dokumente/Alt
ls gibtsnicht
ls ..
dir /
ls a b
case navigate
pwd
cd Dokumente
pwd
cd Alt
cd ..
cd ../..
cd /Dokumente/Alt
cd /
cd gibtsnicht
cd DOKUMENTE
pwd
cd
cd /
cd notizen.txt
pwd
ls
case files
touch neu.txt
touch neu.txt
touch NEU.TXT
touch Dokumente/neu2.txt
touch /gibtsnicht/x.txt
touch ..
mkdir Neu
mkdir Neu
mkdir neu
mkdir Dokumente/Alt/Tief
mkdir /gibtsnicht/x
mkdir ..
ls
case copy
cp notizen.txt kopie.txt
cp gibtsnicht.txt x.txt
cp notizen.txt NOTIZEN.TXT
copy bild.png Dokumente/bild2.png
cp kurz.txt /leer/kurz.txt
cp notizen.txt
cp notizen.txt Dokumente
ls
case move
mv notizen.txt umbenannt.txt
mv kurz.txt Dokumente/kurz.txt
mv gibtsnicht.txt y.txt
move bild.png /leer/bild.png
mv Dokumente Ordner
ls
case rename
mv notizen.txt NOTIZEN.TXT
ls
mv Dokumente/brief.txt /brief.txt
ls
case delete
rm notizen.txt
rm notizen.txt
del Dokumente
rm /leer
cd leer
rm ../bild.png
ls
case show
cat notizen.txt
type Dokumente/brief.txt
cat gibtsnicht
cat
cat Dokumente
cat bild.png
case info
ipconfig
ipconfig x
route
route x
test
ls
case help
help
help ls
help gibtsnicht
help exit
case window
cls
reset
exit
unbekannt
LS
move
setInterrupt x
initUsageInfo
case spaces
ls Dokumente
cd Dokumente/
pwd
cd ./Alt/../Alt
pwd
case deleteInside
cd Dokumente/Alt
rm /Dokumente
pwd
ls
cd ..
pwd
rm /
ls
case sameName
mv notizen.txt notizen.txt
cp kurz.txt kurz.txt
ls
case usage
pwd x
rm
rm a b
cd a b
touch a b
mkdir a b
cp a b c
mv a b c
help type
help rm
help cp
help mv
\u0009
case paths
ls ./Dokumente
ls notizen.txt
cd Dokumente
test
ls
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+259
View File
@@ -0,0 +1,259 @@
// The command line (apps/terminal.ts) against Java FILIUS' own:
// tools/java-oracle's TerminalCases fed Java's Terminal, as its window does,
// with the lines of terminal.cases.txt on host 10.0.0.2 of router-web
// (terminal.<de|en>.java.json). After each line: what the window does
// itself, what the program told it, the working directory, and every file
// must be Java's, in both languages, except where Java errs.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
entryName,
javaBase64,
javaBase64Decode,
TERMINAL_TEXTS,
VirtualFileSystem,
withSnapshot,
type NetworkRuntimeEngine,
type PageLanguage,
type TerminalTextKey,
type VirtualFileContent,
} from '../src/index.js';
import { watchedEngine } from './javaRun.js';
import { snapshotOf, type Scenario } from './scenario.js';
const here = dirname(fileURLToPath(import.meta.url));
const HOST = '00000000-0000-0000-0000-000000000301';
/** A file as TerminalCases writes it: path, and for a file its type, content and size. */
type JavaNode = [string] | [string, string, string, number];
type Told = ['text', string] | ['busy', boolean];
interface JavaStep {
typed: string;
window: 'blank' | 'exit' | 'clear' | null;
told: Told[];
directory: string;
files: JavaNode[];
}
interface JavaCase {
name: string;
start: JavaNode[];
steps: JavaStep[];
}
/** TerminalCases.files: the host's files at the start, in their order. */
function startFiles(start: JavaNode[]): VirtualFileSystem {
const files = new VirtualFileSystem();
for (const node of start) {
if (node.length === 1) files.createDirectory(node[0]);
else if (node[1] === 'png') files.writeImageFile(node[0], javaBase64Decode(node[2])!, 'image/png');
else files.writeTextFile(node[0], node[2]);
}
return files;
}
/** The engine's files as TerminalCases writes Java's: preorder, each directory's entries in their order. */
function engineFiles(files: VirtualFileSystem, directory = '/'): JavaNode[] {
return files.entriesInCreationOrder(directory).flatMap((entry): JavaNode[] => {
const content: VirtualFileContent = entry.content;
if (content.kind === 'directory') return [[entry.path], ...engineFiles(files, entry.path)];
const text = content.kind === 'text' ? content.text : javaBase64(content.data);
return [[entry.path, content.kind === 'text' ? 'text' : 'binary', text, content.kind === 'text' ? content.text.length : content.data.length]];
});
}
/** Java's file types as the engine's kinds: an image FILIUS keeps in Base64, every other file as text. */
const kindOf = (type: string) => (type === 'png' ? 'binary' : 'text');
/**
* Java's sizes of text files: Datei.holeGroesse decodes the text as Base64
* and counts the bytes ("Hallo Welt" is 6). As Java means it: the
* characters. Applied to every step, in the file list and in each listing.
*/
function withTextSizes(step: JavaStep): JavaStep {
const files = step.files.map((node): JavaNode => (node.length === 1 ? node : [node[0], kindOf(node[1]), node[2], kindOf(node[1]) === 'text' ? node[2].length : node[3]]));
const sizes = new Map(files.flatMap((node) => (node.length === 4 && node[1] === 'text' ? [[entryName(node[0]), node[3]] as const] : [])));
const told = step.told.map(([kind, value]): Told => {
if (kind !== 'text') return [kind, value];
return ['text', value.replace(/^(?!\[)(.+?)(\.+)(\d+)$/gm, (line, name: string, dots: string, size: string) => (sizes.has(name) ? `${name}${dots}${sizes.get(name)}` : line))];
});
return { ...step, files, told };
}
type Fix = (steps: JavaStep[], text: (key: TerminalTextKey) => string) => JavaStep[];
const said = (step: JavaStep, ...texts: string[]): JavaStep => ({ ...step, told: texts.map((each): Told => ['text', each]) });
const at = (steps: JavaStep[], changes: Record<number, (step: JavaStep) => JavaStep>) => steps.map((step, index) => changes[index]?.(step) ?? step);
/**
* Where Java errs, the case as Java means it (the engine's departures, see
* terminal.ts).
*/
const JAVA_BUGS: Record<string, Fix> = {
// A folder that is not there: Java says "empty"; ls .. at the root lists
// nothing (".." goes above the root); ls / names no path.
listing: (steps, text) =>
at(steps, {
4: (step) => said(step, text('sw_terminal_msg20')),
5: (step) => ({ ...step, told: steps[0]!.told }),
6: (step) => said(step, (step.told[0]![1] as string).replace(/^(\S+) :/, '$1 /:')),
}),
// cd to a file makes it the working directory.
navigate: (steps, text) =>
at(steps, {
13: (step) => ({ ...said(step, text('sw_terminal_msg20')), directory: '/' }),
14: (step) => ({ ...said(step, '/'), directory: '/' }),
15: (step) => ({ ...step, directory: '/', told: listingOfStart(text) }),
}),
// touch and mkdir in a folder that is not there: "made" and "there already".
files: (steps, text) =>
at(steps, {
4: (step) => said(step, text('sw_terminal_msg12')),
10: (step) => said(step, text('sw_terminal_msg16')),
}),
// cp onto a folder's name throws (ClassCastException).
copy: (steps, text) => at(steps, { 6: (step) => said(step, text('sw_terminal_msg34')) }),
// mv onto the file in another case copies it onto itself, then removes it.
rename: (steps) => {
const renamed: JavaNode = ['/NOTIZEN.TXT', 'text', 'Hallo Welt', 10];
return at(steps, {
0: (step) => ({ ...step, files: [...step.files, renamed] }),
1: (step) => ({ ...step, files: [...step.files, renamed], told: [['text', addedFile(step.told, 'NOTIZEN.TXT', 10)]] }),
2: (step) => ({ ...step, files: [...step.files.filter((node) => node[0] !== '/brief.txt'), renamed, step.files.find((node) => node[0] === '/brief.txt')!] }),
3: (step) => ({
...step,
files: [...step.files.filter((node) => node[0] !== '/brief.txt'), renamed, step.files.find((node) => node[0] === '/brief.txt')!],
told: [['text', addedFile(step.told, 'NOTIZEN.TXT', 10, 'brief.txt')]],
}),
});
},
// ".." above the root: Java's path evaluation throws (rm ../bild.png from the root).
delete: (steps, text) =>
at(steps, {
5: (step) => ({ ...said(step, text('sw_terminal_msg37')), files: step.files.filter((node) => node[0] !== '/bild.png') }),
6: (step) => ({
...step,
files: step.files.filter((node) => node[0] !== '/bild.png'),
told: [['text', (step.told[0]![1] as string).replace(/\nbild\.png\.+\d+/, '').replace(/(:)2$/, '$11')]],
}),
}),
// rm /Dokumente from inside: Java puts the working directory in front of
// the path and finds nothing; rm / then removes the working directory.
deleteInside: (steps, text) => {
const withoutDocuments = (step: JavaStep) => step.files.filter((node) => !node[0].startsWith('/Dokumente'));
const start = steps[0]!.files;
const remaining = start.filter((node) => !node[0].startsWith('/Dokumente'));
const rootListing = listing(text, '/', remaining);
return at(steps, {
1: (step) => ({ ...said(step, text('sw_terminal_msg37')), directory: '/', files: withoutDocuments(step) }),
2: (step) => ({ ...said(step, '/'), directory: '/', files: remaining }),
3: (step) => ({ ...said(step, rootListing), directory: '/', files: remaining }),
4: (step) => ({ ...said(step, ''), directory: '/', files: remaining }),
5: (step) => ({ ...said(step, '/'), directory: '/', files: remaining }),
6: (step) => ({ ...said(step, text('sw_terminal_msg38')), directory: '/', files: remaining }),
7: (step) => ({ ...said(step, rootListing), directory: '/', files: remaining }),
});
},
// ls of a file: Java lists its children (none: "empty").
paths: (steps, text) => at(steps, { 1: (step) => said(step, text('sw_terminal_msg20')) }),
// mv onto the file itself: Java copies it onto itself, then removes it.
sameName: (steps) => {
const notes: JavaNode = ['/notizen.txt', 'text', 'Hallo Welt', 10];
return at(steps, {
0: (step) => ({ ...step, files: [...step.files, notes] }),
1: (step) => ({ ...step, files: [...step.files, notes] }),
2: (step) => ({ ...step, files: [...step.files, notes], told: [['text', addedFile(step.told, 'notizen.txt', 10)]] }),
});
},
};
/** A listing's text with a file added at its end (after `before`'s line when given), one file more counted. */
function addedFile(told: Told[], name: string, size: number, before?: string): string {
const text = told[0]![1] as string;
const line = `\n${name}${'.'.repeat(40 - name.length)}${size}`;
const withLine = before === undefined ? text.replace(/\n\n/, `${line}\n\n`) : text.replace(new RegExp(`\\n${before.replace('.', '\\.')}`), `${line}\n${before}`);
return withLine.replace(/(\d+)$/, (count) => String(Number(count) + 1));
}
/** Terminal.dir's listing of a directory as Java writes it, from TerminalCases' file list. */
function listing(text: (key: TerminalTextKey) => string, directory: string, files: JavaNode[]): string {
const children = files.filter((node) => node[0] !== directory && node[0].slice(0, node[0].lastIndexOf('/') || 1) === directory);
const lines = children.map((node) => (node.length === 1 ? `[${entryName(node[0])}]` : `${entryName(node[0])}${'.'.repeat(Math.max(0, 40 - entryName(node[0]).length))}${node[3]}`));
const directories = children.filter((node) => node.length === 1).length;
return `${text('sw_terminal_msg9')} ${directory}:\n${lines.join('\n')}\n${text('sw_terminal_msg10')}${directories}${text('sw_terminal_msg11')}${children.length - directories}`;
}
let startNodes: JavaNode[] = [];
/** The root's listing of the start files (with the sizes as Java means them). */
const listingOfStart = (text: (key: TerminalTextKey) => string): Told[] => [['text', listing(text, '/', startNodes)]];
function javaCases(language: PageLanguage): JavaCase[] {
return (JSON.parse(readFileSync(join(here, `terminal.${language}.java.json`), 'utf8')) as { cases: JavaCase[] }).cases;
}
function play(javaCase: JavaCase, language: PageLanguage): JavaStep[] {
const scenario = JSON.parse(readFileSync(join(here, 'scenarios', 'router-web.scenario.json'), 'utf8')) as Scenario;
const files = startFiles(javaCase.start);
const snapshot = withSnapshot(snapshotOf(scenario), { language, terminalNodeIds: new Set([HOST]), fileSystemsByNodeId: new Map([[HOST, files]]) });
const { engine } = watchedEngine(scenario, snapshot);
const terminal = (each: NetworkRuntimeEngine) => each.terminal(HOST)!;
let seen = 0;
return javaCase.steps.map((step) => {
const window = terminal(engine).enter(step.typed) ?? null;
const told = terminal(engine)
.log.all.slice(seen)
.map((line): Told => (line.kind === 'text' ? ['text', line.text] : ['busy', line.busy]));
seen = terminal(engine).log.all.length;
return { typed: step.typed, window, told, directory: terminal(engine).directory, files: engineFiles(files) };
});
}
describe('the command line as Java FILIUS answers it', () => {
for (const language of ['de', 'en'] as const) {
const cases = javaCases(language);
const text = (key: TerminalTextKey) => TERMINAL_TEXTS[language][key];
it(`corrects Java only where it errs (${language})`, () => {
for (const [name, fix] of Object.entries(JAVA_BUGS)) {
const javaCase = cases.find((each) => each.name === name)!;
startNodes = withTextSizes({ ...javaCase.steps[0]!, files: javaCase.start }).files;
const steps = javaCase.steps.map(withTextSizes);
expect(JSON.stringify(fix(steps, text)), name).not.toBe(JSON.stringify(steps));
}
});
for (const javaCase of cases) {
it(`${javaCase.name} (${language})`, () => {
startNodes = withTextSizes({ ...javaCase.steps[0]!, files: javaCase.start }).files;
const steps = javaCase.steps.map(withTextSizes);
const expected = JAVA_BUGS[javaCase.name]?.(steps, text) ?? steps;
const actual = play(javaCase, language);
for (const [index, step] of expected.entries()) {
const { typed, window, told, directory, files } = step;
expect(actual[index], `step ${index}: ${JSON.stringify(typed)}`).toEqual({ typed, window, told, directory, files });
}
});
}
}
});
describe('the command line beyond the cases', () => {
it('copies an image as an image', () => {
const scenario = JSON.parse(readFileSync(join(here, 'scenarios', 'router-web.scenario.json'), 'utf8')) as Scenario;
const files = new VirtualFileSystem();
files.writeImageFile('/bild.png', new Uint8Array([1, 2, 3]), 'image/png');
const { engine } = watchedEngine(scenario, withSnapshot(snapshotOf(scenario), { terminalNodeIds: new Set([HOST]), fileSystemsByNodeId: new Map([[HOST, files]]) }));
engine.terminal(HOST)!.enter('cp bild.png kopie.png');
expect(files.entry('/kopie.png').content).toEqual({ kind: 'image', data: new Uint8Array([1, 2, 3]), mediaType: 'image/png' });
});
it('lists in the order of making only a folder', () => {
const files = new VirtualFileSystem();
files.writeTextFile('/a.txt', 'a');
expect(() => files.entriesInCreationOrder('/a.txt')).toThrow('expectedDirectory');
});
});
+4 -1
View File
@@ -7,7 +7,8 @@
// A DNS server runs only when it is switched on (Java: DNSServer.aktiv); an
// echo, web or e-mail server is there either way, switched on or off (it can be
// switched at run time). The programs read the nodes' files (a web server its
// pages).
// pages); the command line changes them, and as they are the project's own,
// a project saved after the run holds the change, as in Java.
//
// Back the other way: what the simulation changes that Java keeps in the
// project (its objects are the project's), so a project saved after it holds
@@ -38,6 +39,7 @@ export function projectSnapshot(project: ImportedProject): TopologySnapshot {
emailServers.set(nodeId, { domain: configuration.domain, accounts: configuration.accounts, isActive: project.activePrograms.get(nodeId)?.has('emailServer') === true });
}
const emailClients = new Map([...project.emailClientConfigurations].filter(([nodeId]) => project.installedPrograms.get(nodeId)?.has('emailClient') === true));
const terminals = new Set([...project.installedPrograms].filter(([, programs]) => programs.has('commandPrompt')).map(([nodeId]) => nodeId));
const webBrowsers = new Set([...project.installedPrograms].filter(([, programs]) => programs.has('webBrowser')).map(([nodeId]) => nodeId));
return topologySnapshot({
nodes: project.graph.nodes.map((node) => ({
@@ -63,6 +65,7 @@ export function projectSnapshot(project: ImportedProject): TopologySnapshot {
webBrowserNodeIds: webBrowsers,
emailServersByNodeId: emailServers,
emailClientsByNodeId: emailClients,
terminalNodeIds: terminals,
fileSystemsByNodeId: project.virtualFileSystems,
});
}
+16
View File
@@ -857,6 +857,22 @@ describe('importProject rules', () => {
expect(client('Rechner 3').account).toEqual(project.emailClientConfigurations.get(receiver)!.account);
});
it("keeps what the command line did to a node's files in the project saved after the run", () => {
const name = 'java-examples/Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls';
const project = openProjectArchive(new Uint8Array(readFileSync(join(corpusRoot, name))), counter());
const notebook = project.graph.nodes.find((each) => each.displayName === 'Notebook' && project.deviceConfigurations.get(each.id)?.ipAddress === '10.10.20.11')!.id;
const snapshot = projectSnapshot(project);
expect(snapshot.terminalNodeIds.has(notebook)).toBe(true);
const engine = new NetworkRuntimeEngine(14);
engine.handle({ type: 'start', snapshot, seed: 14, initialTimeMilliseconds: 0 });
for (const line of ['mkdir Projekt', 'cd Projekt', 'touch plan.txt']) engine.terminal(notebook)!.enter(line);
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
const reopened = openProjectArchive(saveProjectArchive(projectWithSimulationChanges(project, engine.state.topologySnapshot)).archive, counter());
const reopenedNotebook = reopened.graph.nodes.find((each) => each.displayName === 'Notebook' && reopened.deviceConfigurations.get(each.id)?.ipAddress === '10.10.20.11')!.id;
expect(reopened.virtualFileSystems.get(reopenedNotebook)!.entry('/Projekt/plan.txt').content).toEqual({ kind: 'text', text: '' });
});
it("keeps a node's IP forwarding where it differs from its kind's: a host that forwards, a router that does not", () => {
const system = (ipForwardingEnabled: boolean) => ({ manualRoutes: [], ipForwardingEnabled });
const project = importProject(