// 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-.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; 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 = { send: { host: '192.168.0.30', looks: 3, acts: [[500, send('bob@filius.de', 'Carla ', '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 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 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: \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; 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 = (log: readonly LogLine[], 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)])); }); } });