Files
mdg-filius/packages/core/test/emailJava.test.ts
T
julian.kiedaischandClaude Opus 5.5 f622959ecc feat(core): the e-mail server keeps /mailserver/konten.txt up to date at run time, measured against Java
The account file's writer moves from the export (packages/fls) into the
core, so that the export and the running server write the same. The
server writes the file at its start and whenever its accounts change.
EmailRun now records Java's file as it left it and as Java writes the
accounts it ended with; Java leaves the notice to a local sender out until
its next write.

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

181 lines
8.2 KiB
TypeScript

// 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-<story>.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 { EMAIL_SERVER_ACCOUNTS_FILE, 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<string, JavaFrame[]>;
events: [number, Program, string][];
/** Each account's name and its messages: Email.toString, new, marked for deletion. */
accounts: [string, ...[string, string, string][]][];
/** The account file as Java left it, and as Java writes the accounts it ended with (kontenSpeichern). */
file: string;
savedFile: 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<string, [number, Act][]> = {
smtp: story(
['connect', 25],
[
'send',
'HELO 192.168.0.20',
'MAIL FROM: <bob@filius.de>',
'RCPT TO:<anna@filius.de>',
'DATA',
"From: Bob Beispiel <bob@filius.de>\nTo: <anna@filius.de>\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:<niemand@filius.de>',
'DATA',
'MAIL FROM:<bob@filius.de>',
'RCPT TO: anna',
'NOOP',
'DATA',
'From: <bob@filius.de>\nTo: <niemand@filius.de>\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: <date>\n');
type Lines = Record<Program, [number, string][]>;
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 = <E>(log: readonly LogLine<E>[], 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])]));
// The account file at the end: as Java writes those accounts (kontenSpeichern), the engine at every change.
const file = snapshotFiles(engine).textFile(EMAIL_SERVER_ACCOUNTS_FILE);
expect(fileWithoutDate(file), 'account file').toBe(fileWithoutDate(java.savedFile));
expect(fileWithoutDate(java.file) === fileWithoutDate(java.savedFile), "Java's file as it left it is up to date").toBe(!STALE_FILE.has(name));
});
}
});
/**
* Where Java left its account file behind its accounts: smtpErrors, the
* notice to the local sender (SMTPMitarbeiter saves the accounts before it
* sends the notice); the engine writes the file at every change.
*/
const STALE_FILE = new Set(['smtpErrors']);
/** A notice's date in the account file, where its line breaks are "|". */
const fileWithoutDate = (text: string) => text.replace(/\|Date: [^|]*\|/g, '|Date: <date>|');
const snapshotFiles = (engine: NetworkRuntimeEngine) => engine.state.topologySnapshot.fileSystemsByNodeId.get(SERVER)!;