// What a run leaves in the document, against what Java's project holds when // it is saved after the same run (tools/java-oracle SaveAfterRunRun, // fixtures/after-run-*.fls; both read with the same reader): the files a // command line made, the accounts and mails, which servers run, the virtual // hosts, a new domain; the addresses DHCP clients got. Named: the virtual // hosts' file holds the complete rows only (F4.6d). import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import * as Y from 'yjs'; import { VirtualFileSystem as VirtualFileSystemOf, type Id, type VirtualFileSystem } from '@mdg-filius/core'; import { addDevice, changeFiles, createUndoManager, emptyProject, fileText, readProject, ROOTS, setSoftware, SIM_ORIGIN, writeProject, writeRunState } from '@mdg-filius/doc'; import { openProjectArchive, type ImportedProject } from '@mdg-filius/fls'; import type { HostMessage } from '@mdg-filius/protocol'; import { SimulationRoom, TICK_MILLISECONDS, WRITE_BACK_MILLISECONDS } from '../src/index.js'; const here = dirname(fileURLToPath(import.meta.url)); const corpus = join(here, '../../../corpus/java-examples'); function open(path: string): ImportedProject { let ids = 0; return openProjectArchive(new Uint8Array(readFileSync(path)), () => `00000000-0000-0000-0000-${(++ids).toString(16).padStart(12, '0')}`); } function setUp(file: string, writeBackMilliseconds?: number) { const doc = new Y.Doc(); writeProject(doc, open(join(corpus, file))); let now = 1_000_000; const room = new SimulationRoom(doc, { now: () => now, seed: () => 5, ...(writeBackMilliseconds === undefined ? {} : { writeBackMilliseconds }) }); const got: HostMessage[] = []; const client = room.connect((message) => got.push(message)); const id = (name: string) => readProject(doc).graph.nodes.find((node) => node.displayName === name)!.id; const act = (node: string, app: string, action: string, args: object = {}) => room.receive(client, { v: 1, kind: 'input', node: id(node), app, action, args, reqId: `${app}.${action}` }); const run = (milliseconds: number) => { for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) { now += TICK_MILLISECONDS; room.tick(); } }; /** What the room sent of a window since the last call, as text. */ const sent = (app: string) => JSON.stringify(got.splice(0).filter((message) => message.kind === 'app' && message.app === app)); return { doc, room, got, id, act, run, sent, send: (message: object) => room.receive(client, { v: 1, ...message }) }; } /** A computer's files: text as it is, the rest by kind. */ function filesOf(files: VirtualFileSystem | undefined): Record { return Object.fromEntries((files?.allEntries() ?? []).map((entry) => [entry.path, entry.content.kind === 'text' ? entry.content.text : entry.content.kind])); } /** What a run leaves of a node, as the model holds it. */ function slice(project: ImportedProject, name: string) { const id = project.graph.nodes.find((node) => node.displayName === name)!.id; return { files: filesOf(project.virtualFileSystems.get(id)), programs: [...(project.installedPrograms.get(id) ?? [])].sort(), active: [...(project.activePrograms.get(id) ?? [])].sort(), webServer: project.webServerConfigurations.get(id), emailServer: project.emailServerConfigurations.get(id), emailClient: project.emailClientConfigurations.get(id), firewall: project.firewallConfigurationsByNodeId.get(id), }; } const ACCOUNT = (user: string, last: string) => ({ name: `Rechner ${last}`, email: `${user}@filius.de`, pop3Host: '141.99.50.232', pop3Port: '110', smtpHost: '141.99.50.232', smtpPort: '25', username: user, password: user }); describe("what a run leaves in the document, against Java's project saved after it", () => { it('keeps the files, accounts, mails, servers, virtual hosts and domain of a run', () => { const { doc, id, act, run, send } = setUp('email_komplett.fls'); setSoftware(doc, id('Rechner 1'), { install: ['commandPrompt'], remove: [] }, 'de', 'me'); send({ kind: 'start' }); run(2_000); act('Rechner 1', 'terminal', 'enter', { line: 'mkdir ordner' }); run(500); act('Rechner 1', 'terminal', 'enter', { line: 'touch ordner/notiz.txt' }); run(500); act('Rechner 1', 'emailClient', 'setAccount', ACCOUNT('rechner1', '1')); act('Rechner 2', 'emailClient', 'setAccount', ACCOUNT('rechner2', '2')); act('Rechner 1', 'emailClient', 'send', { to: 'rechner2@filius.de', cc: '', bcc: '', subject: 'Nach dem Lauf', text: 'Erste Zeile\nZweite Zeile' }); run(8_000); act('Rechner 2', 'emailClient', 'fetch'); run(8_000); act('Mail-Server (filius.de)', 'emailServer', 'addAccount', { username: 'neu', password: 'pw', firstName: 'Vorname', lastName: 'Nachname' }); act('Web-Server', 'webServer', 'setActive', { active: false }); act('Web-Server', 'webServer', 'useVirtualHosts', { use: true }); act('Web-Server', 'webServer', 'setVirtualHost', { row: 0, column: 0, value: 'a.de' }); act('Web-Server', 'webServer', 'setVirtualHost', { row: 0, column: 1, value: 'ordner' }); act('Mail-Server (senior.de)', 'emailServer', 'setActive', { active: false }); act('Mail-Server (senior.de)', 'emailServer', 'setDomain', { domain: 'senior.org' }); run(1_000); send({ kind: 'stop' }); const ours = readProject(doc); const java = open(join(here, 'fixtures/after-run-email.fls')); for (const name of ['Rechner 1', 'Rechner 2', 'Rechner 3', 'Mail-Server (filius.de)', 'Mail-Server (senior.de)', 'DNS-Server']) { expect(slice(ours, name), name).toEqual(slice(java, name)); } // The virtual hosts' file holds the complete rows (Java's every row, the empty ones as empty lines). const web = slice(java, 'Web-Server'); expect(web.files['/www.conf/vhosts']).toBe('a.de\nordner\n\n\n\n\n\n\n\n\n'); expect(slice(ours, 'Web-Server')).toEqual({ ...web, files: { ...web.files, '/www.conf/vhosts': 'a.de\nordner\n' } }); }); it('keeps the addresses DHCP clients got', () => { const { doc, run, send } = setUp('einfaches_rechnernetz_komplett.fls'); send({ kind: 'start' }); run(20_000); // Written while it runs, not only at the stop. const running = readProject(doc); send({ kind: 'stop' }); expect(readProject(doc).deviceConfigurations).toEqual(running.deviceConfigurations); const devices = (project: ImportedProject) => project.graph.nodes .filter((node) => project.dhcpClientConfigurations.get(node.id)?.isEnabled === true) .map((node) => project.deviceConfigurations.get(node.id)!) // Which client gets which address is the threads' race in Java. .map(({ ipAddress, ...rest }) => ({ ...rest, ipAddress: ipAddress.startsWith('192.168.0.') ? 'leased' : ipAddress })); const java = open(join(here, 'fixtures/after-run-dhcp.fls')); const before = open(join(corpus, 'einfaches_rechnernetz_komplett.fls')); expect(devices(readProject(doc))).toEqual(devices(java)); expect(devices(before)).not.toEqual(devices(java)); const addresses = (project: ImportedProject) => project.graph.nodes.filter((node) => project.dhcpClientConfigurations.get(node.id)?.isEnabled === true).map((node) => project.deviceConfigurations.get(node.id)!.ipAddress).sort(); expect(addresses(readProject(doc))).toEqual(addresses(java)); }); }); describe("the room's write-back", () => { it('writes while the simulation runs, at most once a second, after an action and at the stop, as the room', () => { const { doc, id, act, run, send } = setUp('email_komplett.fls'); const origins: unknown[] = []; doc.on('update', (_update: Uint8Array, origin: unknown) => origins.push(origin)); const node = id('Mail-Server (filius.de)'); const accounts = () => (readProject(doc).emailServerConfigurations.get(node)?.accounts ?? []).map((account) => account.username); send({ kind: 'start' }); run(TICK_MILLISECONDS); act('Mail-Server (filius.de)', 'emailServer', 'addAccount', { username: 'neu', password: 'pw', firstName: 'A', lastName: 'B' }); expect(accounts()).toContain('neu'); expect(origins.length).toBeGreaterThan(0); expect(new Set(origins)).toEqual(new Set([SIM_ORIGIN])); // Nothing new: nothing is written. const written = origins.length; run(3 * WRITE_BACK_MILLISECONDS); expect(origins.length).toBe(written); send({ kind: 'stop' }); expect(origins.length).toBe(written); }); it('writes at the stop what it left since the last write', () => { const { doc, id, run, send } = setUp('einfaches_rechnernetz_komplett.fls', 60_000); const notebook = id('Notebook 1'); const before = readProject(doc).deviceConfigurations.get(notebook); send({ kind: 'start' }); run(20_000); expect(readProject(doc).deviceConfigurations.get(notebook)).toEqual(before); send({ kind: 'stop' }); expect(readProject(doc).deviceConfigurations.get(notebook)!.ipAddress).toMatch(/^192\.168\.0\./); }); it('removes from the document what a command line removed', () => { const { doc, id, act, run, send } = setUp('email_komplett.fls'); const node = id('Rechner 1'); setSoftware(doc, node, { install: ['commandPrompt'], remove: [] }, 'de', 'me'); const files = () => Object.keys(Object.fromEntries((readProject(doc).virtualFileSystems.get(node)?.allEntries() ?? []).map((entry) => [entry.path, true]))); expect(files()).toContain('/konten.txt'); send({ kind: 'start' }); run(TICK_MILLISECONDS); act('Rechner 1', 'terminal', 'enter', { line: 'rm konten.txt' }); expect(files()).not.toContain('/konten.txt'); }); it("is never a person's step to take back", () => { const { doc, id, act, run, send } = setUp('email_komplett.fls'); const undo = createUndoManager(doc, 'me'); send({ kind: 'start' }); run(TICK_MILLISECONDS); act('Web-Server', 'webServer', 'setActive', { active: false }); send({ kind: 'stop' }); expect(readProject(doc).activePrograms.get(id('Web-Server'))).toBeUndefined(); expect(undo.undoStack).toHaveLength(0); }); it('writes a text where it changed, keeps a file another wrote meanwhile, and removes a file the run removed', () => { const doc = new Y.Doc(); const { doc: source } = setUp('email_komplett.fls'); writeProject(doc, readProject(source)); const nodeId = readProject(doc).graph.nodes.find((node) => node.displayName === 'Rechner 1')!.id as Id; const files = () => doc.getMap>(ROOTS.nodes).get(nodeId)!.get('files') as Y.Map>; const fileSystem = readProject(doc).virtualFileSystems.get(nodeId)!; fileSystem.createDirectory('/neu'); fileSystem.writeTextFile('/neu/a.txt', 'Anfang Mitte Ende'); writeRunState(doc, new Map([[nodeId, { files: fileSystem }]])); const text = files().get('/neu/a.txt')!.get('text') as Y.Text; expect(text.toString()).toBe('Anfang Mitte Ende'); const order = files().get('/neu/a.txt')!.get('order'); // The run changes the middle; the entry and its text stay the same objects. fileSystem.writeTextFile('/neu/a.txt', 'Anfang neue Mitte Ende'); const changes: string[] = []; text.observe((event) => changes.push(JSON.stringify(event.delta))); writeRunState(doc, new Map([[nodeId, { files: fileSystem }]])); expect([files().get('/neu/a.txt')!.get('text'), text.toString(), files().get('/neu/a.txt')!.get('order')]).toEqual([text, 'Anfang neue Mitte Ende', order]); expect(changes).toEqual([JSON.stringify([{ retain: 7 }, { insert: 'neue ' }])]); // A file turned into a folder, and one removed. fileSystem.deleteItem('/neu/a.txt'); fileSystem.createDirectory('/neu/a.txt'); fileSystem.deleteItem('/konten.txt'); writeRunState(doc, new Map([[nodeId, { files: fileSystem }]])); expect(files().get('/neu/a.txt')!.get('kind')).toBe('directory'); expect(files().get('/neu/a.txt')!.get('order')).toBe(order); expect(files().has('/konten.txt')).toBe(false); // An image's bytes and type. fileSystem.writeImageFile('/bild.png', new Uint8Array([1, 2]), 'image/png'); writeRunState(doc, new Map([[nodeId, { files: fileSystem }]])); fileSystem.writeImageFile('/bild.png', new Uint8Array([1, 3]), 'image/png'); writeRunState(doc, new Map([[nodeId, { files: fileSystem }]])); expect(files().get('/bild.png')!.get('data')).toEqual(new Uint8Array([1, 3])); fileSystem.writeImageFile('/bild.png', new Uint8Array([1, 3]), 'image/gif'); writeRunState(doc, new Map([[nodeId, { files: fileSystem }]])); expect(files().get('/bild.png')!.get('mediaType')).toBe('image/gif'); // A setting written again alike, its fields in another order or one undefined: nothing; with a field less: written. const settingsDoc = new Y.Doc(); writeProject(settingsDoc, readProject(source)); const server = readProject(settingsDoc).graph.nodes.find((node) => node.displayName === 'Web-Server')!.id; const web = readProject(settingsDoc).webServerConfigurations.get(server)!; const updates: unknown[] = []; settingsDoc.on('update', (update: Uint8Array) => updates.push(update)); writeRunState(settingsDoc, new Map([[server, { settings: { webServer: { virtualHosts: web.virtualHosts, documentRoot: web.documentRoot, port: web.port, extra: undefined } } }]])); expect(updates).toHaveLength(0); writeRunState(settingsDoc, new Map([[server, { settings: { webServer: { port: web.port, documentRoot: web.documentRoot } } }]])); expect(updates).toHaveLength(1); expect(readProject(settingsDoc).webServerConfigurations.get(server)).toEqual({ port: web.port, documentRoot: web.documentRoot }); writeRunState(settingsDoc, new Map([[server, { settings: { webServer: { port: web.port, documentRoot: web.documentRoot, virtualHosts: true } } }]])); expect(updates).toHaveLength(2); // A node without files whose run has none: no files. const switchId = readProject(settingsDoc).graph.nodes.find((node) => node.kind === 'networkSwitch')!.id; writeRunState(settingsDoc, new Map([[switchId, { files: new VirtualFileSystemOf() }]])); expect(settingsDoc.getMap>(ROOTS.nodes).get(switchId)!.has('files')).toBe(false); // A node the document no longer has, and settings it has not: nothing. writeRunState(doc, new Map([['niemand', { files: fileSystem }]])); writeRunState(doc, new Map([[nodeId, { settings: { webServer: { port: 80 } } }]])); expect(readProject(doc).webServerConfigurations.has(nodeId)).toBe(false); }); }); describe('files both ways while the simulation runs', () => { it("takes what a person writes into the document at once, and writes the engine's files at the tick they changed", () => { const { doc, id, act, run, send, sent } = setUp('email_komplett.fls', 60_000); const one = id('Rechner 1'); setSoftware(doc, one, { install: ['commandPrompt'], remove: [] }, 'de', 'me'); send({ kind: 'sub', topics: [`app:${one}/terminal`] }); send({ kind: 'start' }); run(TICK_MILLISECONDS); // A file saved by a person (the text editor), then typed into: the command line reads it. changeFiles(doc, one, 'me', (files) => files.writeTextFile('/notiz.txt', 'Hallo')); act('Rechner 1', 'terminal', 'enter', { line: 'cat notiz.txt' }); expect(sent('terminal')).toContain('Hallo'); doc.transact(() => fileText(doc, one, '/notiz.txt')!.insert(5, ' Welt'), 'me'); act('Rechner 1', 'terminal', 'enter', { line: 'cat notiz.txt' }); expect(sent('terminal')).toContain('Hallo Welt'); // Removed by a person: gone for the command line. changeFiles(doc, one, 'me', (files) => files.deleteItem('/notiz.txt')); act('Rechner 1', 'terminal', 'enter', { line: 'cat notiz.txt' }); expect(sent('terminal')).not.toContain('Hallo'); // A mail taken by the server changes its accounts file in a tick: in the document then, the settings a minute later. const server = id('Mail-Server (filius.de)'); const accountsFile = () => fileText(doc, server, '/mailserver/konten.txt')!.toString(); const before = accountsFile(); const mailboxes = () => readProject(doc).emailServerConfigurations.get(server)!.accounts.map((account) => account.mailbox.length); const settings = mailboxes(); act('Rechner 1', 'emailClient', 'setAccount', ACCOUNT('rechner1', '1')); act('Rechner 1', 'emailClient', 'send', { to: 'rechner2@filius.de', cc: '', bcc: '', subject: 'Im Lauf', text: 'Text' }); const afterAction = accountsFile(); let tick = 0; while (accountsFile() === afterAction && tick++ < 50) run(TICK_MILLISECONDS); expect(accountsFile()).not.toBe(before); expect(accountsFile()).toContain('Im Lauf'); expect(mailboxes()).toEqual(settings); // A person types into the file the engine just wrote: both stay. doc.transact(() => fileText(doc, server, '/mailserver/konten.txt')!.insert(0, '# '), 'me'); expect(accountsFile().startsWith('# ')).toBe(true); expect(accountsFile()).toContain('Im Lauf'); }); it('takes the first file of a computer that had none, leaves out one whose engine has no files, and writes nothing back of what it took', () => { const doc = new Y.Doc(); writeProject(doc, emptyProject()); let counter = 0; addDevice(doc, { kind: 'pc', id: 'pc', name: 'PC', x: 0, y: 0, newId: () => `port-${++counter}` }, 'me'); addDevice(doc, { kind: 'pc', id: 'bare', name: 'Bare', x: 100, y: 0, newId: () => `port-${++counter}` }, 'me'); const programs = doc.getMap>(ROOTS.nodes).get('pc')!.get('programs') as Y.Map>; const entry = new Y.Map(); entry.set('active', true); programs.set('commandPrompt', entry); // Two computers without files (a new one has a few). for (const id of ['pc', 'bare']) doc.getMap>(ROOTS.nodes).get(id)!.delete('files'); let now = 0; const room = new SimulationRoom(doc, { now: () => now, seed: () => 7 }); const got: HostMessage[] = []; const client = room.connect((message) => got.push(message)); const send = (fields: object) => room.receive(client, { v: 1, ...fields }); const run = (milliseconds: number) => { for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) { now += TICK_MILLISECONDS; room.tick(); } }; send({ kind: 'sub', topics: ['app:pc/terminal'] }); send({ kind: 'start' }); run(TICK_MILLISECONDS); expect(doc.getMap>(ROOTS.nodes).get('pc')!.has('files')).toBe(false); const writes: unknown[] = []; doc.on('afterTransaction', (transaction: Y.Transaction) => transaction.origin === SIM_ORIGIN && transaction.changed.size > 0 && writes.push(transaction)); changeFiles(doc, 'pc', 'me', (files) => files.writeTextFile('/erste.txt', 'Erste Datei')); // A computer whose programs use no files: its file waits for the next start, the simulation runs on. changeFiles(doc, 'bare', 'me', (files) => files.writeTextFile('/x.txt', 'x')); run(3 * WRITE_BACK_MILLISECONDS); expect(room.currentMode).toBe('simulation'); expect(got.filter((message) => message.kind === 'error')).toEqual([]); // What it took it does not write back: the document has it. expect(writes).toHaveLength(0); send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'cat erste.txt' }, reqId: 'cat' }); expect(JSON.stringify(got)).toContain('Erste Datei'); }); });