SMTP on port 25 stores mails for the domain's accounts as Java reads them anew and tells a local sender of unknown recipients; POP3 on port 110 hands them out. The mailboxes are in the snapshot and go back into the project. EmailRun plays four stories over the network; MailCases feeds Java's workers without one (26 cases, de and en). 18 cases name Java bugs that the core does not take. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
161 lines
7.1 KiB
TypeScript
161 lines
7.1 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 { 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][]][];
|
|
}
|
|
|
|
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])]));
|
|
});
|
|
}
|
|
});
|