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>
265 lines
13 KiB
TypeScript
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');
|
|
});
|
|
});
|