// The e-mail server as Java FILIUS runs it. tools/java-oracle's EmailRun let // FILIUS' generic client talk SMTP and POP3 to an e-mail server on // mail-lan's network and wrote every frame each card saw, Java's tables of // them, the lines of the server's and the client's windows, and the // server's accounts at the end (email-java-.java.json). The engine // plays the same stories with its programs (apps/emailServer.ts, the // generic client of apps/echo.ts): each card's frames in the same order, // with the same content and rows, at about the same time; each window's // lines as Java's, at about the same time; the same accounts at the end. // Java's errors in these dialogues are measured without a network // (emailServer.test.ts); these stories keep to what Java does right. import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { formatMail, withSnapshot, type LogLine, type NetworkRuntimeEngine } from '../src/index.js'; import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, watchedEngine, withClientPortText, type JavaFrame, type JavaTables } from './javaRun.js'; import { MAIL_ACCOUNTS } from './mailAccounts.js'; import { clientText, emailServerText, isPolled, POLL_MILLISECONDS } from './programLines.js'; import { snapshotOf, type Scenario } from './scenario.js'; const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios'); const CLIENT = '00000000-0000-0000-0000-000000000201'; const SERVER = '00000000-0000-0000-0000-000000000203'; type Program = 'server' | 'client'; interface JavaRun extends JavaTables { frames: Record; events: [number, Program, string][]; /** Each account's name and its messages: Email.toString, new, marked for deletion. */ accounts: [string, ...[string, string, string][]][]; } type Act = (engine: NetworkRuntimeEngine) => void; /** EmailRun's calls at their times: connecting takes 2.5 s, each message 1.5 s, from 500 ms on. */ function story(...parts: (readonly ['connect', number] | readonly ['send', ...string[]] | readonly ['wait', number])[]): [number, Act][] { const acts: [number, Act][] = []; let at = 500; for (const [kind, ...rest] of parts) { if (kind === 'connect') { const port = rest[0] as number; acts.push([at, (engine) => engine.simpleClient(CLIENT)!.connect('192.168.0.30', port)]); at += 2_500; } else if (kind === 'send') { for (const text of rest as string[]) { acts.push([at, (engine) => engine.simpleClient(CLIENT)!.send(text)]); at += 1_500; } } else { at += rest[0] as number; } } return acts; } const STORIES: Record = { smtp: story( ['connect', 25], [ 'send', 'HELO 192.168.0.20', 'MAIL FROM: ', 'RCPT TO:', 'DATA', "From: Bob Beispiel \nTo: \nSubject: Gruß\n\nHallo Anna,\nwie geht's?\r\n.\r\n", 'QUIT', ], ), smtpErrors: story( ['connect', 25], [ 'send', 'NOOP', 'EHLO', 'DATA', 'MAIL FROM: bob@filius.de', 'RCPT TO:', 'DATA', 'MAIL FROM:', 'RCPT TO: anna', 'NOOP', 'DATA', 'From: \nTo: \nSubject: Wer?\n\nIst da jemand?\n.\n', 'QUIT', ], ), pop3: story( ['connect', 110], ['send', 'USER anna', 'PASS geheim', 'STAT', 'LIST', 'RETR 0', 'RETR 1', 'DELE 0', 'DELE 1', 'QUIT'], ['wait', 1_000], ['connect', 110], ['send', 'USER anna', 'PASS geheim', 'STAT', 'QUIT'], ), pop3Errors: story(['connect', 110], ['send', 'NOOP', 'USER niemand', 'PASS x', 'USER anna', 'PASS falsch', 'PASS geheim', 'NOOP', 'USER bob', 'QUIT']), }; /** A notice's date: Java's in its locale and the time it ran. */ const withoutDate = (text: string) => text.replace(/\nDate: [^\n]*\n/g, '\nDate: \n'); type Lines = Record; function javaLines(java: JavaRun): Lines { const lines: Lines = { server: [], client: [] }; for (const [time, program, text] of java.events) { // Anwendung.starten and the server's refreshes tell their observers nothing (null), which the windows skip. if (text === 'null' || text.startsWith('call ')) continue; lines[program].push([time, withClientPortText(text)]); } return lines; } function engineLines(engine: NetworkRuntimeEngine): Lines { const lines = (log: readonly LogLine[], text: (entry: E) => string): [number, string][] => log.map((line) => [line.timeMilliseconds, withClientPortText(text(line))]); return { server: lines(engine.emailServer(SERVER)!.log.all, (entry) => emailServerText(entry)), client: lines(engine.simpleClient(CLIENT)!.log.all, clientText) }; } describe('the e-mail server as Java FILIUS runs it', () => { const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'mail-lan.scenario.json'), 'utf8')) as Scenario; const snapshot = withSnapshot(snapshotOf(scenario), { emailServersByNodeId: new Map([[SERVER, { domain: 'filius.de', accounts: MAIL_ACCOUNTS, isActive: true }]]), simpleClientsByNodeId: new Map([[CLIENT, { targetAddress: '', targetPort: 25 }]]), }); for (const [name, acts] of Object.entries(STORIES)) { it(name, () => { const java = JSON.parse(readFileSync(join(scenarioDirectory, `email-java-${name}.java.json`), 'utf8')) as JavaRun; const { engine } = watchedEngine(scenario, snapshot); for (const [time, act] of acts) { engine.handle({ type: 'advance', toMilliseconds: time }); act(engine); } engine.handle({ type: 'advance', toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 5_000 }); expectSameCaptures(engine, scenario, java.frames, { text: (text) => withoutDate(withClientPortText(text)), tables: java, cell: withoutDate }); const expected = javaLines(java); const actual = engineLines(engine); for (const program of ['server', 'client'] as const) { expect(actual[program].map(([, text]) => text), `${program}'s lines`).toEqual(expected[program].map(([, text]) => text)); for (const [index, [javaTime, text]] of expected[program].entries()) { const late = javaTime - actual[program][index]![0]; const most = (isPolled(text) ? POLL_MILLISECONDS : 0) + GAP_TOLERANCE_MILLISECONDS; expect(late, `${program}'s line ${JSON.stringify(text.slice(0, 60))}`).toBeGreaterThanOrEqual(-GAP_TOLERANCE_MILLISECONDS); expect(late, `${program}'s line ${JSON.stringify(text.slice(0, 60))}`).toBeLessThanOrEqual(most); } } const accounts = engine.emailServer(SERVER)!.state.accounts.map((account) => [ account.username, ...account.mailbox.map((entry) => [withoutDate(formatMail(entry.mail)), String(entry.isNew), String(entry.isMarkedForDeletion)]), ]); expect(accounts).toEqual(java.accounts.map(([username, ...mails]) => [username, ...mails.map(([text, isNew, deleted]) => [withoutDate(text), isNew, deleted])])); }); } });