Files
julian.kiedaischandClaude Opus 5.5 495c449ef2 feat(core): the command line's network commands ping, host, nslookup, arp and netstat after Java, measured against Java
TerminalRun types lines into Java's command line on terminal-net at set
times; the engine types the same lines. New in the engine: an ICMP echo
in a program's thread (the first ICMP message after the send, whatever
it is), Java's route choice, the ARP table with Java's broadcast row, DNS
in a program's thread, ICMP broadcasts of a node's own network, and an
ARP wait that ends after what else happens at that millisecond.

Departures from Java: an echo is answered from the address asked
(RFC 792), network unreachable carries the packet with its sender, ping
names the host and takes only -b before it, arp and netstat list in the
order learned, netstat lists a UDP server's listening socket only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 08:03:10 +00:00

265 lines
13 KiB
TypeScript

// 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(`has Java's texts (${language})`, () => {
const javaTexts = (JSON.parse(readFileSync(join(here, `terminal.${language}.java.json`), 'utf8')) as { texts: Record<string, string> }).texts;
for (const [key, value] of Object.entries(TERMINAL_TEXTS[language])) expect(value, key).toBe(javaTexts[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');
});
});