The program sends to its account's SMTP server, one connection for each recipient, and fetches its mails by POP3; the server now sends mails on to other domains by MX and returns notices to their senders. The program's folders are in the snapshot and go back into the project. EmailClientRun plays six stories with two servers; the mail oracles run in the language's locale, so Java's notice dates match the core's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
260 lines
11 KiB
TypeScript
260 lines
11 KiB
TypeScript
// The e-mail program as Java FILIUS runs it. tools/java-oracle's
|
|
// EmailClientRun let FILIUS' e-mail program send and fetch mails with two
|
|
// e-mail servers on mail-lan (filius.de, andere.de; the DNS server knows
|
|
// both domains' MX) and wrote every frame each card saw, Java's tables of
|
|
// them, what the program and each server told their windows, and at the end
|
|
// the program's inbox and sent mails and each server's accounts
|
|
// (email-client-java-<story>.java.json). The engine plays the same stories
|
|
// with its programs (apps/emailClient.ts, apps/emailServer.ts): each card's
|
|
// frames in the same order, with the same content and rows, at about the
|
|
// same time; the same lines; the same mails at the end.
|
|
|
|
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 EmailClientAccount, type LogLine, type MailboxEntry, 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 { emailClientText, emailServerText, 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';
|
|
const OTHER_SERVER = '00000000-0000-0000-0000-000000000204';
|
|
|
|
type Program = 'client' | 'server' | 'otherServer';
|
|
type JavaMail = [string, string, string];
|
|
|
|
interface JavaRun extends JavaTables {
|
|
frames: Record<string, JavaFrame[]>;
|
|
events: [number, Program, string][];
|
|
inbox: JavaMail[];
|
|
sent: JavaMail[];
|
|
accounts: [string, JavaMail[]][];
|
|
otherAccounts: [string, JavaMail[]][];
|
|
}
|
|
|
|
type Act = (engine: NetworkRuntimeEngine) => void;
|
|
|
|
const send =
|
|
(to: string, cc: string, subject: string, text: string): Act =>
|
|
(engine) => {
|
|
expect(engine.emailClient(CLIENT)!.send({ to, cc, bcc: '', subject, text })).toBeUndefined();
|
|
};
|
|
const fetch: Act = (engine) => {
|
|
expect(engine.emailClient(CLIENT)!.fetch()).toBe(true);
|
|
};
|
|
|
|
interface Story {
|
|
readonly host: string;
|
|
readonly password?: string;
|
|
readonly acts: readonly [number, Act][];
|
|
/** The waits Java ends only at a look every 100 ms, one after the other: each connection opened, and the DNS answer of unknownHost (see tolerance). */
|
|
readonly looks: number;
|
|
}
|
|
|
|
const STORIES: Record<string, Story> = {
|
|
send: {
|
|
host: '192.168.0.30',
|
|
looks: 3,
|
|
acts: [[500, send('bob@filius.de', 'Carla <carla@andere.de>', 'Treffen', 'Hallo zusammen,\nbis morgen!')]],
|
|
},
|
|
fetch: { host: '192.168.0.30', looks: 1, acts: [[500, fetch]] },
|
|
byName: {
|
|
host: 'mail.filius.de',
|
|
looks: 5,
|
|
acts: [
|
|
[500, send('niemand@andere.de', '', 'Hallo?', 'Ist da wer?')],
|
|
[12_000, send('jemand@nirgendwo.de', '', 'Hallo?', 'Ist da wer?')],
|
|
[25_000, fetch],
|
|
],
|
|
},
|
|
wrongPass: {
|
|
host: '192.168.0.30',
|
|
password: 'falsch',
|
|
looks: 1,
|
|
acts: [[500, fetch]],
|
|
},
|
|
noServer: {
|
|
host: '192.168.0.10',
|
|
looks: 1,
|
|
acts: [[500, send('bob@filius.de', '', 'Ins Leere', 'Niemand da')]],
|
|
},
|
|
unknownHost: { host: 'gibtsnicht.filius.de', looks: 1, acts: [[500, send('bob@filius.de', '', 'Ins Leere', 'Niemand da')]] },
|
|
};
|
|
|
|
/** Java's wait for a SYN's answer (TCPSocket: three times 2.75 s, its timeout here). */
|
|
const JAVA_TIMEOUT_MILLISECONDS = 3 * 2_750;
|
|
|
|
/**
|
|
* Where Java errs: its run corrected, as Java means it. noServer: the
|
|
* program's SYN goes once in Java, not three times (tcpJava.test.ts,
|
|
* closedPort); its failure comes two attempts later in the engine.
|
|
*/
|
|
const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
|
|
noServer: (java) => ({
|
|
...java,
|
|
events: java.events.map(([time, who, text]) => (who === 'client' && text.includes('Exception') ? [time + 2 * JAVA_TIMEOUT_MILLISECONDS, who, text] : [time, who, text])),
|
|
}),
|
|
};
|
|
|
|
/** Where the frames are compared only up to a time (Java's). noServer: the engine's second SYN, which Java does not send. */
|
|
const UNTIL: Record<string, (java: JavaRun) => number> = {
|
|
noServer: (java) => java.events.find(([, who, text]) => who === 'client' && text.includes('Exception'))![0] - 250,
|
|
};
|
|
|
|
/**
|
|
* Java's client and server look every 100 ms whether a connection is made
|
|
* (TCPSocket.verbinden, ServerSocket.oeffnen); the engine knows at once. Java
|
|
* falls behind by up to a look with each such wait of the story (Story.looks).
|
|
*/
|
|
const tolerance = (story: Story) => GAP_TOLERANCE_MILLISECONDS + story.looks * POLL_MILLISECONDS;
|
|
|
|
/**
|
|
* byName: which socket draws the next initial sequence number, the program's
|
|
* second or the server's to andere.de, is a race of Java's threads (both
|
|
* are made within a few milliseconds); compared below the millions.
|
|
*/
|
|
const WITHOUT_INITIAL_NUMBERS = new Set(['byName']);
|
|
const withoutInitialNumbers = (text: string) =>
|
|
text
|
|
.replace(/(seq|ack)=(\d+)/g, (_, name: string, value: string) => `${name}=${Number(value) % 1_000_000}`)
|
|
.replace(/(SEQ|ACK): \d+\.(\d{3}\.\d{3})/g, '$1: n.$2');
|
|
|
|
/** 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');
|
|
|
|
/** A frame's text with placeholders: the client's port, a DNS query's random ID, a notice's date. */
|
|
const frameText = (text: string) => withoutDate(withClientPortText(text)).replace(/ID=\d+/g, 'ID=x');
|
|
|
|
type Lines = Record<Program, [number, string][]>;
|
|
|
|
function javaLines(java: JavaRun): Lines {
|
|
const lines: Lines = { client: [], server: [], otherServer: [] };
|
|
for (const [time, program, text] of java.events) {
|
|
// Anwendung.starten and the refreshes tell the 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 {
|
|
client: lines(engine.emailClient(CLIENT)!.log.all, emailClientText),
|
|
server: lines(engine.emailServer(SERVER)!.log.all, (entry) => emailServerText(entry)),
|
|
otherServer: lines(engine.emailServer(OTHER_SERVER)!.log.all, (entry) => emailServerText(entry)),
|
|
};
|
|
}
|
|
|
|
const mails = (entries: readonly MailboxEntry[]): JavaMail[] =>
|
|
entries.map((entry) => [withoutDate(formatMail(entry.mail)), String(entry.isNew), String(entry.isMarkedForDeletion)]);
|
|
const javaMails = (list: JavaMail[]): JavaMail[] => list.map(([text, isNew, deleted]) => [withoutDate(text), isNew, deleted]);
|
|
|
|
describe('the e-mail program as Java FILIUS runs it', () => {
|
|
it('corrects Java only where it errs, and cuts only after the story began', () => {
|
|
for (const [name, correct] of Object.entries(JAVA_BUGS)) {
|
|
const java = JSON.parse(readFileSync(join(scenarioDirectory, `email-client-java-${name}.java.json`), 'utf8')) as JavaRun;
|
|
expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java));
|
|
expect(UNTIL[name]?.(java) ?? Number.POSITIVE_INFINITY, name).toBeGreaterThan(1_000);
|
|
}
|
|
});
|
|
|
|
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'mail-lan.scenario.json'), 'utf8')) as Scenario;
|
|
|
|
for (const [name, story] of Object.entries(STORIES)) {
|
|
it(name, () => {
|
|
const measured = JSON.parse(readFileSync(join(scenarioDirectory, `email-client-java-${name}.java.json`), 'utf8')) as JavaRun;
|
|
const java = JAVA_BUGS[name]?.(measured) ?? measured;
|
|
const account: EmailClientAccount = {
|
|
pop3Host: story.host,
|
|
pop3Port: 110,
|
|
smtpHost: story.host,
|
|
smtpPort: 25,
|
|
username: 'anna',
|
|
password: story.password ?? 'geheim',
|
|
firstName: 'Anna',
|
|
lastName: 'Muster',
|
|
email: 'anna@filius.de',
|
|
};
|
|
const snapshot = withSnapshot(snapshotOf(scenario), {
|
|
emailServersByNodeId: new Map([
|
|
[SERVER, { domain: 'filius.de', accounts: MAIL_ACCOUNTS, isActive: true }],
|
|
[
|
|
OTHER_SERVER,
|
|
{
|
|
domain: 'andere.de',
|
|
accounts: [
|
|
{
|
|
username: 'carla',
|
|
password: 'carla',
|
|
firstName: 'Carla',
|
|
lastName: 'Carla',
|
|
mailbox: [],
|
|
},
|
|
],
|
|
isActive: true,
|
|
},
|
|
],
|
|
]),
|
|
emailClientsByNodeId: new Map([
|
|
[
|
|
CLIENT,
|
|
{
|
|
account,
|
|
inbox: [],
|
|
sent: [],
|
|
drafts: [],
|
|
addressBook: [],
|
|
nextMessageID: 1,
|
|
},
|
|
],
|
|
]),
|
|
});
|
|
const { engine } = watchedEngine(scenario, snapshot);
|
|
for (const [time, act] of story.acts) {
|
|
engine.handle({ type: 'advance', toMilliseconds: time });
|
|
act(engine);
|
|
}
|
|
engine.handle({
|
|
type: 'advance',
|
|
toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 5_000,
|
|
});
|
|
|
|
const numbers = WITHOUT_INITIAL_NUMBERS.has(name) ? withoutInitialNumbers : (text: string) => text;
|
|
expectSameCaptures(engine, scenario, java.frames, {
|
|
text: (text) => numbers(frameText(text)),
|
|
tables: java,
|
|
cell: (text) => numbers(withoutDate(text).replace(/ID=\d+/g, 'ID=x')),
|
|
until: UNTIL[name]?.(measured),
|
|
tolerance: tolerance(story),
|
|
});
|
|
|
|
const expected = javaLines(java);
|
|
const actual = engineLines(engine);
|
|
for (const program of ['client', 'server', 'otherServer'] as const) {
|
|
const lineText = (text: string) => numbers(withoutDate(text));
|
|
expect(
|
|
actual[program].map(([, text]) => lineText(text)),
|
|
`${program}'s lines`,
|
|
).toEqual(expected[program].map(([, text]) => lineText(text)));
|
|
for (const [index, [javaTime, text]] of expected[program].entries()) {
|
|
const late = javaTime - actual[program][index]![0];
|
|
expect(Math.abs(late), `${program}'s line ${JSON.stringify(text.slice(0, 60))}: Java ${late} ms later`).toBeLessThanOrEqual(tolerance(story));
|
|
}
|
|
}
|
|
|
|
const client = engine.emailClient(CLIENT)!;
|
|
expect(mails(client.inbox), 'inbox').toEqual(javaMails(java.inbox));
|
|
expect(mails(client.sent), 'sent').toEqual(javaMails(java.sent));
|
|
const accounts = (nodeId: string) => engine.emailServer(nodeId)!.state.accounts.map((each) => [each.username, mails(each.mailbox)]);
|
|
expect(accounts(SERVER), 'filius.de').toEqual(java.accounts.map(([username, list]) => [username, javaMails(list)]));
|
|
expect(accounts(OTHER_SERVER), 'andere.de').toEqual(java.otherAccounts.map(([username, list]) => [username, javaMails(list)]));
|
|
});
|
|
}
|
|
});
|