// The echo server's and the generic client's windows in the room, against // Java's (echoWindow.java.json, EchoWindowRun): Java's steps played as the // editor's panels play them — the start button with the port typed, // "Verbinden" with the address and port typed (or "Trennen" while // connected), "Senden" only while connected —, the buttons, fields and lines // both windows show after each; and what Java's project keeps of them after // a run (fixtures/after-run-echo.fls, SaveAfterRunRun). // // Named: a port that is no number is not handed to the room (Java's window // throws and does nothing; the panel marks the field); a connection attempt // lasts three SYNs (F2.3c), so the one to the stopped server is awaited // before it starts again. The client's own port is the threads' draw, in // Java as here: written as PORT. import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import * as Y from 'yjs'; import type { EchoServerLogEntry, SimpleClientLogEntry } from '@mdg-filius/core'; import { addDevice, connect, emptyProject, readProject, setHostAddress, setSoftware, writeProject } from '@mdg-filius/doc'; import { openProjectArchive } from '@mdg-filius/fls'; import { portOfText, type EchoServerView, type HostMessage, type SimpleClientView } from '@mdg-filius/protocol'; import { clientText, serverText } from '../../core/test/programLines.js'; import { SimulationRoom, TICK_MILLISECONDS } from '../src/index.js'; interface WindowField { readonly text: string; readonly editable: boolean; } interface JavaStep { readonly step: string; readonly server: { readonly button: string; readonly port: WindowField; readonly log: string }; readonly client: { readonly connect: string; readonly sendEnabled: boolean; readonly address: WindowField; readonly port: WindowField; readonly message: string; readonly output: string }; readonly thrown: readonly string[]; } const here = dirname(fileURLToPath(import.meta.url)); const java = JSON.parse(readFileSync(join(here, 'echoWindow.java.json'), 'utf8')) as { readonly steps: readonly JavaStep[] }; const corpus = join(here, '../../../corpus/java-examples'); /** The client's own port, drawn by the threads. */ const ownPort = (text: string) => text.replace(/192\.168\.0\.20:\d+/g, '192.168.0.20:PORT'); function setUp() { const doc = new Y.Doc(); writeProject(doc, emptyProject()); let counter = 0; const newId = () => `port-${++counter}`; addDevice(doc, { kind: 'pc', id: 'client', name: 'Client', x: 0, y: 0, newId }, 'me'); addDevice(doc, { kind: 'pc', id: 'server', name: 'Server', x: 200, y: 0, newId }, 'me'); addDevice(doc, { kind: 'networkSwitch', id: 'switch', name: 'Switch', x: 100, y: 100, newId }, 'me'); connect(doc, { node: 'client' }, { node: 'switch' }, 'cable-1', 'me'); connect(doc, { node: 'server' }, { node: 'switch' }, 'cable-2', 'me'); setHostAddress(doc, 'client', 'ipAddress', '192.168.0.20', 'me'); setHostAddress(doc, 'server', 'ipAddress', '192.168.0.30', 'me'); setSoftware(doc, 'server', { install: ['echoServer'], remove: [] }, 'de', 'me'); setSoftware(doc, 'client', { install: ['simpleClient'], remove: [] }, 'de', 'me'); let now = 0; const room = new SimulationRoom(doc, { now: () => now, seed: () => 3 }); const views = new Map(); const lines = new Map(); const errors: string[] = []; const id = room.connect((message: HostMessage) => { if (message.kind === 'view') views.set(message.app, message.view); if (message.kind === 'app') lines.set(message.app, [...(lines.get(message.app) ?? []).slice(0, message.from), ...message.lines]); if (message.kind === 'error') errors.push(message.code); }); const send = (fields: object) => room.receive(id, { v: 1, ...fields }); send({ kind: 'sub', topics: ['app:server/echoServer', 'app:client/simpleClient'] }); send({ kind: 'start' }); const run = (milliseconds: number) => { for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) { now += TICK_MILLISECONDS; room.tick(); } }; const act = (node: string, app: string, action: string, args: object = {}) => send({ kind: 'input', node, app, action, args, reqId: action }); return { doc, room, views, lines, errors, run, act, send }; } describe("the echo server's and the generic client's windows, against Java's", () => { it("play Java's steps as the panels play them", () => { const { views, lines, run, act, errors } = setUp(); run(TICK_MILLISECONDS); // What the panels hold: the fields as typed, the message. let serverPortText = ''; let clientAddressText = ''; let clientPortText = ''; let message = ''; const server = () => views.get('echoServer') as EchoServerView; const client = () => views.get('simpleClient') as SimpleClientView; const serverLog = () => ((lines.get('echoServer') ?? []) as EchoServerLogEntry[]).map((line) => `${serverText(line)}\n`).join(''); const clientOutput = () => ((lines.get('simpleClient') ?? []) as SimpleClientLogEntry[]).map((line) => `${clientText(line)}\n`).join(''); // The fields follow the program when it changes (Java's aktualisieren sets them from it). let seenServer = server(); let seenClient = client(); const follow = () => { if (server() !== seenServer) serverPortText = String(server().port); if (client() !== seenClient) { clientAddressText = client().address; clientPortText = String(client().port); } seenServer = server(); seenClient = client(); }; serverPortText = String(server().port); clientAddressText = client().address; clientPortText = String(client().port); const shown = () => { follow(); return { server: { button: server().active ? 'Anhalten' : 'Starten', port: { text: serverPortText, editable: !server().active }, log: ownPort(serverLog()) }, client: { connect: client().connected ? 'Trennen' : 'Verbinden', sendEnabled: client().connected, address: { text: clientAddressText, editable: !client().connected }, port: { text: clientPortText, editable: !client().connected }, message, output: clientOutput(), }, }; }; // The failed attempt, awaited (see below), is in the window one step before Java's shows it. const timedOut = `${clientText({ kind: 'connectFailed', failure: 'timedOut' })}\n`; const expected = (step: JavaStep) => ({ server: { ...step.server, log: ownPort(step.server.log) }, client: step.step === 'server: start again' ? { ...step.client, output: step.client.output + timedOut } : step.client, }); // The panels' buttons. const startStop = () => { if (server().active) act('server', 'echoServer', 'setActive', { active: false }); else if (portOfText(serverPortText) !== undefined) act('server', 'echoServer', 'setActive', { active: true, port: portOfText(serverPortText) }); }; const connectButton = () => { if (client().connected) act('client', 'simpleClient', 'disconnect'); else if (portOfText(clientPortText) !== undefined) act('client', 'simpleClient', 'connect', { address: clientAddressText, port: portOfText(clientPortText) }); }; const sendButton = () => { if (!client().connected) return; act('client', 'simpleClient', 'send', { text: message }); message = ''; }; const plays: Record void> = { 'server: start with port abc': () => ((serverPortText = 'abc'), startStop(), run(500)), 'server: start with port 55555': () => ((serverPortText = '55555'), startStop(), run(1000)), 'client: connect with port x': () => ((clientAddressText = '192.168.0.30'), (clientPortText = 'x'), connectButton(), run(500)), 'client: connect to 192.168.0.30:55555': () => ((clientAddressText = '192.168.0.30'), (clientPortText = '55555'), connectButton(), run(6000)), 'client: send Hallo Echo': () => ((message = 'Hallo Echo'), sendButton(), run(4000)), 'client: send nothing': () => ((message = ''), sendButton(), run(4000)), 'client: send two lines': () => ((message = 'Zeile 1\nZeile 2'), sendButton(), run(4000)), 'server: stop while connected': () => (startStop(), run(4000)), 'client: send after the server stopped': () => ((message = 'noch da?'), sendButton(), run(8000)), 'client: Trennen': () => (connectButton(), run(3000)), 'server: start again': () => (startStop(), run(1000)), 'client: connect again': () => (connectButton(), run(6000)), 'client: Trennen again': () => (connectButton(), run(3000)), // Three SYNs (F2.3c) last longer than Java's one: until the attempt failed. 'client: connect to a port nobody listens on': () => { clientPortText = '4711'; connectButton(); for (let waited = 0; !clientOutput().endsWith(timedOut) && waited < 60_000; waited += 1000) run(1000); }, 'client: connect to a name nobody knows': () => ((clientAddressText = 'gibtsnicht'), (clientPortText = '55555'), connectButton(), run(12000)), }; for (const step of java.steps) { if (step.step !== 'open') plays[step.step]!(); expect(shown(), step.step).toEqual(expected(step)); // The press on "Verbinden" with the server stopped: Java's one SYN times out before the server starts again, // the core's three (named in F2.3c) only later — awaited here, so that the server starts after it as in Java. if (step.step === 'client: Trennen') { for (let waited = 0; !clientOutput().endsWith(timedOut) && waited < 60_000; waited += 1000) run(1000); } // Java's window threw for a port that is no number; the panel hands nothing over. expect(step.thrown.length > 0, step.step).toBe(step.step.includes('port abc') || step.step.includes('port x')); } expect(errors).toEqual([]); }); it("leave in the project what Java's leaves after a run: the server's port and that it runs, the client's server", () => { let ids = 0; const project = openProjectArchive(new Uint8Array(readFileSync(join(corpus, 'email_komplett.fls'))), () => `00000000-0000-0000-0000-${(++ids).toString(16).padStart(12, '0')}`); const doc = new Y.Doc(); writeProject(doc, project); const id = (name: string) => readProject(doc).graph.nodes.find((node) => node.displayName === name)!.id; setSoftware(doc, id('Rechner 2'), { install: ['echoServer'], remove: [] }, 'de', 'me'); setSoftware(doc, id('Rechner 1'), { install: ['simpleClient'], remove: [] }, 'de', 'me'); let now = 0; const room = new SimulationRoom(doc, { now: () => now, seed: () => 5 }); const client = room.connect(() => {}); const act = (node: string, app: string, action: string, args: object = {}) => room.receive(client, { v: 1, kind: 'input', node: id(node), app, action, args, reqId: action }); const run = (milliseconds: number) => { for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) { now += TICK_MILLISECONDS; room.tick(); } }; room.receive(client, { v: 1, kind: 'start' }); run(1000); act('Rechner 2', 'echoServer', 'setActive', { active: true, port: 7777 }); run(1000); const address = readProject(doc).deviceConfigurations.get(id('Rechner 2'))!.ipAddress; act('Rechner 1', 'simpleClient', 'connect', { address, port: 7777 }); run(6000); act('Rechner 1', 'simpleClient', 'send', { text: 'Hallo' }); run(4000); room.receive(client, { v: 1, kind: 'stop' }); let javaIds = 0; const saved = openProjectArchive(new Uint8Array(readFileSync(join(here, 'fixtures/after-run-echo.fls'))), () => `00000000-0000-0000-0000-${(++javaIds).toString(16).padStart(12, '0')}`); const ours = readProject(doc); for (const name of ['Rechner 1', 'Rechner 2']) { const of = (each: typeof ours) => { const node = each.graph.nodes.find((candidate) => candidate.displayName === name)!.id; return { echoServer: each.echoServerConfigurations.get(node), simpleClient: each.simpleClientConfigurations.get(node), active: [...(each.activePrograms.get(node) ?? [])].sort() }; }; expect(of(ours), name).toEqual(of(saved)); } }); it('refuses what the panels never hand over: a port that is no port, an action the program has not', () => { const { run, act, errors, views } = setUp(); run(TICK_MILLISECONDS); act('server', 'echoServer', 'setActive', { active: true, port: 70_000 }); act('server', 'echoServer', 'setActive', { active: true, port: 1.5 }); act('server', 'echoServer', 'setActive', { active: 'yes' }); act('server', 'echoServer', 'setActive', { active: 0 }); act('server', 'echoServer', 'restart'); act('client', 'simpleClient', 'connect', { address: '192.168.0.30', port: -1 }); act('client', 'simpleClient', 'connect', { address: 5, port: 80 }); act('client', 'simpleClient', 'send', {}); act('client', 'simpleClient', 'reset'); act('client', 'echoServer', 'setActive', { active: false }); act('server', 'simpleClient', 'disconnect'); expect(errors).toEqual(['badArgs', 'badArgs', 'badArgs', 'badArgs', 'unknownAction', 'badArgs', 'badArgs', 'badArgs', 'unknownAction', 'unknownApp', 'unknownApp']); expect([portOfText('0'), portOfText('65535'), portOfText('000080'), portOfText('65536'), portOfText('+5'), portOfText(' 5'), portOfText('123456')]).toEqual([ 0, 65_535, 80, undefined, undefined, undefined, undefined, ]); // What is handed over is in the view: the port. act('server', 'echoServer', 'setActive', { active: true, port: 7777 }); run(TICK_MILLISECONDS); expect(views.get('echoServer')).toEqual({ active: true, port: 7777 }); }); });