From 0fd42f10e2293441dc726c50bfa516f169193f24 Mon Sep 17 00:00:00 2001 From: Julian Kiedaisch Date: Sat, 3 Oct 2026 08:32:20 +0000 Subject: [PATCH] feat(sim): F4.2 the sim protocol, the simulation room and LocalSimHost; M9 measured packages/protocol holds the one list of /sim messages and checks every field, naming what is wrong. packages/sim has the simulation room for F and the web worker: the document's snapshot at start, the engine's clock on the wall clock with Java's speed as cable delay, subscriptions to cable activity, a card's new frames and a program's new window lines, capture and clock kept over a stop, the command line as first action, a core failure told to every client. LocalSimHost mirrors the document into the worker and back without echo. M9 in Chromium: 17 examples give the same capture as in Node, character for character; the room runs in a real worker. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 + CHANGES.md | 16 ++ README.md | 3 + package-lock.json | 92 +++++++ package.json | 5 +- packages/protocol/package.json | 13 + packages/protocol/src/host.ts | 13 + packages/protocol/src/index.ts | 3 + packages/protocol/src/sim.ts | 123 +++++++++ packages/protocol/test/sim.test.ts | 49 ++++ packages/protocol/test/tsconfig.json | 15 + packages/protocol/tsconfig.json | 16 ++ packages/sim/browser/localSimHostEntry.ts | 30 ++ packages/sim/browser/m9.spec.ts | 57 ++++ packages/sim/browser/m9Entry.ts | 5 + packages/sim/browser/traceDigest.ts | 48 ++++ packages/sim/browser/tsconfig.json | 15 + packages/sim/package.json | 17 ++ packages/sim/src/channel.ts | 22 ++ packages/sim/src/index.ts | 5 + packages/sim/src/localSimHost.ts | 49 ++++ packages/sim/src/room.ts | 320 ++++++++++++++++++++++ packages/sim/src/serveRoom.ts | 28 ++ packages/sim/src/worker.ts | 15 + packages/sim/test/localSimHost.test.ts | 142 ++++++++++ packages/sim/test/room.test.ts | 309 +++++++++++++++++++++ packages/sim/test/tsconfig.json | 15 + packages/sim/tsconfig.json | 22 ++ playwright.config.ts | 11 + tsconfig.json | 6 + 30 files changed, 1465 insertions(+), 1 deletion(-) create mode 100644 packages/protocol/package.json create mode 100644 packages/protocol/src/host.ts create mode 100644 packages/protocol/src/index.ts create mode 100644 packages/protocol/src/sim.ts create mode 100644 packages/protocol/test/sim.test.ts create mode 100644 packages/protocol/test/tsconfig.json create mode 100644 packages/protocol/tsconfig.json create mode 100644 packages/sim/browser/localSimHostEntry.ts create mode 100644 packages/sim/browser/m9.spec.ts create mode 100644 packages/sim/browser/m9Entry.ts create mode 100644 packages/sim/browser/traceDigest.ts create mode 100644 packages/sim/browser/tsconfig.json create mode 100644 packages/sim/package.json create mode 100644 packages/sim/src/channel.ts create mode 100644 packages/sim/src/index.ts create mode 100644 packages/sim/src/localSimHost.ts create mode 100644 packages/sim/src/room.ts create mode 100644 packages/sim/src/serveRoom.ts create mode 100644 packages/sim/src/worker.ts create mode 100644 packages/sim/test/localSimHost.test.ts create mode 100644 packages/sim/test/room.test.ts create mode 100644 packages/sim/test/tsconfig.json create mode 100644 packages/sim/tsconfig.json create mode 100644 playwright.config.ts diff --git a/.gitignore b/.gitignore index da79429..b6069ec 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ coverage/ .vscode/* !.vscode/settings.json !.vscode/extensions.json +test-results/ +playwright-report/ diff --git a/CHANGES.md b/CHANGES.md index 9fe9137..153d139 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -120,6 +120,22 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `test/projectDoc.test.ts` | — | neu (F4.1): alle 23 Korpusdateien → Dokument → Yjs-Update → Projekt: dasselbe Projekt, derselbe Schnappschuss, dieselbe `.fls`, dieselbe Reihenfolge je Ordner; Kennungen absichtlich gegen die Dateireihenfolge | | `test/collaboration.test.ts`, `test/values.test.ts` | — | neu (F4.1): zwei Replikate — zwei Felder eines Geräts, zwei neue Zeilen, gemeinsames Tippen; der Hausmeister nach einem Zusammenführen (I1, I2 auf beiden Replikaten gleich, I4 auch in anderer Schreibweise), idempotent ohne Aktualisierung; `long` einer eigenen Anwendung, Binärdateien | +## packages/protocol + +| Datei | Quelle | Art der Übertragung | +| --- | --- | --- | +| `src/sim.ts`, `src/host.ts` | Plan §6.4 | neu (F4.2): die Nachrichten zwischen Editor und Simulationsraum in einer Liste (Client: sub, unsub, start, stop, speed, input; Raum: state, activity, trace, app, ack, error), Prüfung jedes Felds mit benanntem Fehler statt stillem Verwerfen; `SimHost` | +| `test/sim.test.ts` | — | neu (F4.2): jede Art gelesen, jede Ablehnung mit ihrem Grund | + +## packages/sim + +| Datei | Quelle | Art der Übertragung | +| --- | --- | --- | +| `src/room.ts` | Plan §6.1–6.4; Javas Geschwindigkeit `Verbindung.verzoegerungsFaktor` | neu (F4.2): der Simulationsraum — eine Engine je Projekt, Schnappschuss des Dokuments beim Start, Uhr im Takt der Wanduhr (Geschwindigkeit = Kabelverzögerung wie Java, Nachholen höchstens 2 s), Mitschnitt und Uhr über Stopp und Start, Abonnements (Kabelaktivität bei Änderung, neue Rahmen je Karte in beiden Ansichten, neue Fensterzeilen je Programm), Befehlszeile als erste Aktion; ein Fehler des Kerns wird allen gemeldet (`engineFailed`) statt den Raum zu beenden | +| `src/channel.ts`, `src/serveRoom.ts`, `src/localSimHost.ts`, `src/worker.ts` | Plan §6.1 | neu (F4.2): der Raum in einem Web Worker der Seite — Dokument-Updates in beide Richtungen ohne Echo, Nachrichten geprüft | +| `test/room.test.ts`, `test/localSimHost.test.ts` | — | neu (F4.2): der Raum gegen die von Hand gesteuerte Engine (dieselben Rahmen, dieselben Zeilen), Abonnieren, Geschwindigkeit, Nachholen, Fehler; Host und Worker-Seite über einen `MessageChannel` | +| `browser/m9.spec.ts`, `browser/traceDigest.ts`, `browser/*Entry.ts`, `/playwright.config.ts` | Plan §13 M9 | neu (F4.2): M9 in Chromium — 17 Beispiele, je 30 s mit Eingaben, ergeben Zeichen für Zeichen denselben Mitschnitt wie in Node; der Raum in einem echten Worker | + ## tools/swift-oracle Baut den unveränderten Swift-Kern (Stand `3b5ded0`) unter Linux, nur zwei diff --git a/README.md b/README.md index 95349c9..b0a074a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ die Herkunft je Paket. Lizenz dieses Repos: **GPL-3.0** (`LICENSE`). | `packages/core` | Simulationskern: Modell, Ereignis-Warteschlange, Protokolle, Anwendungen — ohne DOM, ohne Node | | `packages/fls` | `.fls` lesen, schreiben, Unbekanntes bewahren | | `packages/doc` | gemeinsames Dokument (Yjs): Projekt ⇄ Dokument, Schnappschuss des Kerns, Hausmeister | +| `packages/protocol` | die eine Nachrichtenliste zwischen Editor und Simulationsraum (`/sim`) | +| `packages/sim` | Simulationsraum (für Server F und den Web Worker) und `LocalSimHost` | | `packages/editor` | Oberfläche im Iframe des Dashboards *(folgt)* | | `server` | Filius-Dienst auf Server F *(folgt)* | @@ -39,6 +41,7 @@ Plan und Entscheidungen: `knowledge/plans/FILIUS_WEB_PLAN.md` im Dashboard. npm install npm run hooks # Sprachprüfung vor jedem Commit (braucht das Dashboard drumherum) npm run check # Typprüfung + Tests +npm run test:browser # Tests im echten Browser (Playwright, Chromium aus dem Cache) ``` Code, Kommentare und Testnamen sind englisch; sichtbare Texte stehen hinter diff --git a/package-lock.json b/package-lock.json index ccdd336..c675821 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "packages/*" ], "devDependencies": { + "@playwright/test": "^1.62.1", "@types/node": "^20.19.0", + "esbuild": "^0.28.2", "typescript": "^5.9.0", "vitest": "^3.2.0" }, @@ -479,6 +481,14 @@ "resolved": "packages/fls", "link": true }, + "node_modules/@mdg-filius/protocol": { + "resolved": "packages/protocol", + "link": true + }, + "node_modules/@mdg-filius/sim": { + "resolved": "packages/sim", + "link": true + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -496,6 +506,22 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.63.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", @@ -1297,6 +1323,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -1745,6 +1818,25 @@ "fflate": "^0.8.2", "saxes": "^6.0.0" } + }, + "packages/protocol": { + "name": "@mdg-filius/protocol", + "version": "0.0.0", + "license": "GPL-3.0-only", + "dependencies": { + "@mdg-filius/core": "0.0.0" + } + }, + "packages/sim": { + "name": "@mdg-filius/sim", + "version": "0.0.0", + "license": "GPL-3.0-only", + "dependencies": { + "@mdg-filius/core": "0.0.0", + "@mdg-filius/doc": "0.0.0", + "@mdg-filius/protocol": "0.0.0", + "yjs": "^13.6.33" + } } } } diff --git a/package.json b/package.json index 920a197..220ed3c 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,17 @@ }, "scripts": { "test": "vitest run", - "typecheck": "tsc -b && tsc -p packages/core/test && tsc -p packages/fls/test && tsc -p packages/doc/test", + "typecheck": "tsc -b && tsc -p packages/core/test && tsc -p packages/fls/test && tsc -p packages/doc/test && tsc -p packages/protocol/test && tsc -p packages/sim/test && tsc -p packages/sim/browser", + "test:browser": "playwright test", "check": "npm run typecheck && npm run test", "hooks": "git config core.hooksPath scripts/hooks", "oracle": "tools/swift-oracle/run.sh", "oracle:java": "tools/java-oracle/run.sh" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@types/node": "^20.19.0", + "esbuild": "^0.28.2", "typescript": "^5.9.0", "vitest": "^3.2.0" } diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 0000000..e6ddda2 --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,13 @@ +{ + "name": "@mdg-filius/protocol", + "version": "0.0.0", + "private": true, + "license": "GPL-3.0-only", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@mdg-filius/core": "0.0.0" + } +} diff --git a/packages/protocol/src/host.ts b/packages/protocol/src/host.ts new file mode 100644 index 0000000..82f6fdb --- /dev/null +++ b/packages/protocol/src/host.ts @@ -0,0 +1,13 @@ +// The editor's side of a simulation room: LocalSimHost (a web worker) and +// RemoteSimHost (F, from F5) look alike to it. + +import type { ClientMessage, HostMessage } from './sim.js'; + +export interface SimHost { + /** Sends a message to the room. */ + send(message: ClientMessage): void; + /** Hears the room's messages; returns how to stop hearing them. */ + onMessage(listener: (message: HostMessage) => void): () => void; + /** Leaves the room. */ + close(): void; +} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts new file mode 100644 index 0000000..d65ffc4 --- /dev/null +++ b/packages/protocol/src/index.ts @@ -0,0 +1,3 @@ +// Public surface of the wire protocol. +export * from './host.js'; +export * from './sim.js'; diff --git a/packages/protocol/src/sim.ts b/packages/protocol/src/sim.ts new file mode 100644 index 0000000..66094ea --- /dev/null +++ b/packages/protocol/src/sim.ts @@ -0,0 +1,123 @@ +// The messages between the editor and a simulation room (plan §6.4) — the +// one list both sides import, so that a kind one side sends and the other +// does not know is a reported error, never silently dropped (Scratch's trap +// 6.1). The room runs on F (RemoteSimHost, F5) or in a web worker +// (LocalSimHost); the messages are the same. + +import type { CaptureRow } from '@mdg-filius/core'; + +export const SIM_PROTOCOL_VERSION = 1; + +/** The programs whose windows a client can watch and act in. */ +export const APP_KINDS = ['terminal', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'echoServer', 'simpleClient', 'gnutella'] as const; +export type AppKind = (typeof APP_KINDS)[number]; + +/** + * What a client subscribes to: which cables carry frames (`activity`), a + * card's capture (`trace:`), a program's window + * (`app:/`). + */ +export type Topic = 'activity' | `trace:${string}` | `app:${string}/${AppKind}`; + +export const CLIENT_MESSAGE_KINDS = ['sub', 'unsub', 'start', 'stop', 'speed', 'input'] as const; +export type ClientMessageKind = (typeof CLIENT_MESSAGE_KINDS)[number]; + +export type ClientMessage = + | { readonly v: 1; readonly kind: 'sub'; readonly topics: readonly Topic[] } + | { readonly v: 1; readonly kind: 'unsub'; readonly topics: readonly Topic[] } + /** Starts the simulation with the document as it is now. */ + | { readonly v: 1; readonly kind: 'start' } + | { readonly v: 1; readonly kind: 'stop' } + /** Java's speed slider, 1 to 100 per cent: how long a frame takes over a cable. */ + | { readonly v: 1; readonly kind: 'speed'; readonly value: number } + /** An action in a program's window, answered by `ack` or `error` with the same reqId. */ + | { + readonly v: 1; + readonly kind: 'input'; + readonly node: string; + readonly app: AppKind; + readonly action: string; + readonly args: Readonly>; + readonly reqId: string; + }; + +export const HOST_MESSAGE_KINDS = ['state', 'activity', 'trace', 'app', 'ack', 'error'] as const; +export type HostMessageKind = (typeof HOST_MESSAGE_KINDS)[number]; + +export type SimMode = 'design' | 'simulation'; + +/** A frame's rows as the capture window shows them: with addresses (data exchange) and layered. */ +export interface TraceFrame { + readonly number: number; + readonly rows: readonly CaptureRow[]; + readonly layeredRows: readonly CaptureRow[]; +} + +/** engineFailed: the core threw (a part it has not got yet, such as remote links); the room is back in design mode. */ +export const ERROR_CODES = ['badMessage', 'badVersion', 'unknownKind', 'badTopic', 'notRunning', 'unknownNode', 'unknownApp', 'unknownAction', 'badArgs', 'engineFailed'] as const; +export type ErrorCode = (typeof ERROR_CODES)[number]; + +export type HostMessage = + /** The room's mode, speed and time; sent to every client on a change and when it connects. */ + | { readonly v: 1; readonly kind: 'state'; readonly mode: SimMode; readonly speed: number; readonly t: number } + /** The cables that carried a frame since the last tick. */ + | { readonly v: 1; readonly kind: 'activity'; readonly links: readonly string[] } + /** A card's new frames; on subscribing, all it holds (`reset`). */ + | { readonly v: 1; readonly kind: 'trace'; readonly portId: string; readonly frames: readonly TraceFrame[]; readonly reset: boolean } + /** A program's new window lines from line `from` on; on subscribing, all (from 0). */ + | { readonly v: 1; readonly kind: 'app'; readonly node: string; readonly app: AppKind; readonly from: number; readonly lines: readonly unknown[] } + | { readonly v: 1; readonly kind: 'ack'; readonly reqId: string } + | { readonly v: 1; readonly kind: 'error'; readonly code: ErrorCode; readonly reqId?: string; readonly detail?: string }; + +export type Parsed = { readonly message: T } | { readonly error: { readonly code: ErrorCode; readonly detail: string } }; + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +const bad = (code: ErrorCode, detail: string) => ({ error: { code, detail } }); + +/** Whether a topic is one the room knows. */ +export function isTopic(value: unknown): value is Topic { + if (value === 'activity') return true; + if (typeof value !== 'string') return false; + if (value.startsWith('trace:')) return value.length > 'trace:'.length; + const app = /^app:([^/]+)\/(.+)$/.exec(value); + return app !== null && (APP_KINDS as readonly string[]).includes(app[2]!); +} + +/** A client's message as the room reads it: every field checked, an unknown kind named. */ +export function parseClientMessage(value: unknown): Parsed { + if (!isRecord(value)) return bad('badMessage', 'not an object'); + if (value.v !== SIM_PROTOCOL_VERSION) return bad('badVersion', `version ${String(value.v)}`); + const kind = value.kind; + if (!(CLIENT_MESSAGE_KINDS as readonly unknown[]).includes(kind)) return bad('unknownKind', `kind ${String(kind)}`); + switch (kind as ClientMessageKind) { + case 'sub': + case 'unsub': { + const topics = value.topics; + if (!Array.isArray(topics)) return bad('badMessage', 'topics is no list'); + const unknown = topics.find((topic) => !isTopic(topic)); + if (unknown !== undefined) return bad('badTopic', `topic ${String(unknown)}`); + return { message: { v: 1, kind: kind as 'sub' | 'unsub', topics: topics as Topic[] } }; + } + case 'start': + case 'stop': + return { message: { v: 1, kind: kind as 'start' | 'stop' } }; + case 'speed': + if (typeof value.value !== 'number' || !Number.isFinite(value.value)) return bad('badMessage', 'speed is no number'); + return { message: { v: 1, kind: 'speed', value: value.value } }; + case 'input': { + const { node, app, action, args, reqId } = value; + if (typeof reqId !== 'string') return bad('badMessage', 'reqId is no string'); + if (typeof node !== 'string' || typeof action !== 'string' || !isRecord(args)) return bad('badMessage', 'input needs node, action and args'); + if (!(APP_KINDS as readonly unknown[]).includes(app)) return bad('unknownApp', `app ${String(app)}`); + return { message: { v: 1, kind: 'input', node, app: app as AppKind, action, args, reqId } }; + } + } +} + +/** A host's message as the editor reads it: the kind known, else an error the editor shows. */ +export function parseHostMessage(value: unknown): Parsed { + if (!isRecord(value)) return bad('badMessage', 'not an object'); + if (value.v !== SIM_PROTOCOL_VERSION) return bad('badVersion', `version ${String(value.v)}`); + if (!(HOST_MESSAGE_KINDS as readonly unknown[]).includes(value.kind)) return bad('unknownKind', `kind ${String(value.kind)}`); + return { message: value as unknown as HostMessage }; +} diff --git a/packages/protocol/test/sim.test.ts b/packages/protocol/test/sim.test.ts new file mode 100644 index 0000000..8d6bb3d --- /dev/null +++ b/packages/protocol/test/sim.test.ts @@ -0,0 +1,49 @@ +// The one list of messages: what the room accepts from a client, and what +// it refuses, each with its reason. + +import { describe, expect, it } from 'vitest'; +import { CLIENT_MESSAGE_KINDS, HOST_MESSAGE_KINDS, isTopic, parseClientMessage, parseHostMessage } from '../src/index.js'; + +describe("a client's messages", () => { + it('reads each kind', () => { + const messages = [ + { v: 1, kind: 'sub', topics: ['activity', 'trace:port-1', 'app:node-1/terminal'] }, + { v: 1, kind: 'unsub', topics: ['activity'] }, + { v: 1, kind: 'start' }, + { v: 1, kind: 'stop' }, + { v: 1, kind: 'speed', value: 50 }, + { v: 1, kind: 'input', node: 'node-1', app: 'terminal', action: 'enter', args: { line: 'ping 1.2.3.4' }, reqId: 'r1' }, + ]; + expect(messages.map((each) => each.kind)).toEqual([...CLIENT_MESSAGE_KINDS]); + for (const each of messages) expect(parseClientMessage(each)).toEqual({ message: each }); + }); + + it('names what is wrong', () => { + const code = (value: unknown) => ('error' in parseClientMessage(value) ? (parseClientMessage(value) as { error: { code: string } }).error.code : 'ok'); + expect(code(null)).toBe('badMessage'); + expect(code([])).toBe('badMessage'); + expect(code({ v: 2, kind: 'start' })).toBe('badVersion'); + expect(code({ v: 1, kind: 'reset' })).toBe('unknownKind'); + expect(code({ v: 1, kind: 'sub', topics: 'activity' })).toBe('badMessage'); + expect(code({ v: 1, kind: 'sub', topics: ['activity', 'trace:'] })).toBe('badTopic'); + expect(code({ v: 1, kind: 'unsub', topics: ['app:node/paint'] })).toBe('badTopic'); + expect(code({ v: 1, kind: 'speed', value: '50' })).toBe('badMessage'); + expect(code({ v: 1, kind: 'speed', value: Number.NaN })).toBe('badMessage'); + expect(code({ v: 1, kind: 'input', node: 'n', app: 'terminal', action: 'enter', args: {} })).toBe('badMessage'); + expect(code({ v: 1, kind: 'input', node: 'n', app: 'terminal', action: 'enter', args: [], reqId: 'r' })).toBe('badMessage'); + expect(code({ v: 1, kind: 'input', node: 'n', app: 'paint', action: 'enter', args: {}, reqId: 'r' })).toBe('unknownApp'); + }); + + it('knows its topics', () => { + expect(['activity', 'trace:x', 'app:n/gnutella', 'app:n/webBrowser'].every(isTopic)).toBe(true); + expect(['', 'trace:', 'app:/terminal', 'app:n/', 'app:n/terminal/x', 'node:n', 7].some(isTopic)).toBe(false); + }); +}); + +describe("a host's messages", () => { + it('reads each kind, and names an unknown one', () => { + for (const kind of HOST_MESSAGE_KINDS) expect('message' in parseHostMessage({ v: 1, kind })).toBe(true); + expect(parseHostMessage({ v: 1, kind: 'node' })).toEqual({ error: { code: 'unknownKind', detail: 'kind node' } }); + expect(parseHostMessage({ v: 0, kind: 'state' })).toEqual({ error: { code: 'badVersion', detail: 'version 0' } }); + }); +}); diff --git a/packages/protocol/test/tsconfig.json b/packages/protocol/test/tsconfig.json new file mode 100644 index 0000000..b302fb2 --- /dev/null +++ b/packages/protocol/test/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "composite": false, + "declaration": false, + "types": [ + "node" + ], + "resolveJsonModule": true + }, + "include": [ + "." + ] +} diff --git a/packages/protocol/tsconfig.json b/packages/protocol/tsconfig.json new file mode 100644 index 0000000..c26bb01 --- /dev/null +++ b/packages/protocol/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": [] + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../core" + } + ] +} diff --git a/packages/sim/browser/localSimHostEntry.ts b/packages/sim/browser/localSimHostEntry.ts new file mode 100644 index 0000000..8ad2f01 --- /dev/null +++ b/packages/sim/browser/localSimHostEntry.ts @@ -0,0 +1,30 @@ +// The page's side of the worker test: a document, a LocalSimHost on a real +// Worker, a ping from the command line, the frames that come back. + +import * as Y from 'yjs'; +import { readProject, writeProject } from '@mdg-filius/doc'; +import { openProjectArchive } from '@mdg-filius/fls'; +import type { HostMessage } from '@mdg-filius/protocol'; +import { LocalSimHost } from '../src/localSimHost.js'; + +async function run(archive: Uint8Array, workerUrl: string): Promise<{ icmpRows: number; mode: string }> { + let counter = 0; + const doc = new Y.Doc(); + writeProject(doc, openProjectArchive(archive, () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`)); + const notebook = readProject(doc).graph.nodes.find((node) => node.displayName === 'Notebook 1.10')!; + const worker = new Worker(workerUrl); + const host = new LocalSimHost(doc, worker, () => worker.terminate()); + const got: HostMessage[] = []; + host.onMessage((message) => got.push(message)); + host.send({ v: 1, kind: 'sub', topics: [`trace:${notebook.ports[0]!.id}`] }); + host.send({ v: 1, kind: 'start' }); + host.send({ v: 1, kind: 'speed', value: 100 }); + host.send({ v: 1, kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); + const icmpRows = () => got.flatMap((message) => (message.kind === 'trace' ? message.frames : [])).filter((frame) => frame.rows.some((row) => row.protocol === 'ICMP')).length; + for (let waited = 0; waited < 15_000 && icmpRows() < 8; waited += 100) await new Promise((resolve) => setTimeout(resolve, 100)); + const mode = got.filter((message) => message.kind === 'state').at(-1); + host.close(); + return { icmpRows: icmpRows(), mode: mode?.kind === 'state' ? mode.mode : 'none' }; +} + +(globalThis as unknown as { filiusLocal: unknown }).filiusLocal = { run }; diff --git a/packages/sim/browser/m9.spec.ts b/packages/sim/browser/m9.spec.ts new file mode 100644 index 0000000..7c19710 --- /dev/null +++ b/packages/sim/browser/m9.spec.ts @@ -0,0 +1,57 @@ +// M9 and the real worker, in Chromium (Playwright). The packages are bundled +// with esbuild as an editor would bundle them. +// +// - M9: every FILIUS example of the corpus (but the one with modems, which +// the core has not got yet), run 30 s with a few lines typed, gives the +// same capture and windows in Chromium as in Node. +// - LocalSimHost in a real Worker: started, pinged, the frames come back. + +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect, test } from '@playwright/test'; +import { build } from 'esbuild'; +import { traceText } from './traceDigest.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const examples = join(here, '../../../corpus/java-examples'); +/** Remote links (Modem) are not in the core yet: this example's run stops at its first frame to the modem. */ +const NOT_YET = new Set(['modem_beispiel.fls']); + +async function bundle(entry: string): Promise { + const result = await build({ entryPoints: [join(here, entry)], bundle: true, format: 'iife', platform: 'browser', write: false, logLevel: 'silent' }); + return result.outputFiles[0]!.text; +} + +test.describe.configure({ mode: 'serial' }); + +test('M9: the same trace in Node and in Chromium', async ({ page }) => { + test.setTimeout(240_000); + await page.setContent('M9'); + await page.addScriptTag({ content: await bundle('m9Entry.ts') }); + for (const name of readdirSync(examples).filter((file) => file.endsWith('.fls') && !NOT_YET.has(file))) { + const archive = new Uint8Array(readFileSync(join(examples, name))); + const inNode = traceText(archive); + // Every run has traffic: frames on the cards and lines in a window. + expect(inNode.includes('"protocol":"ARP"') && inNode.includes('"kind":"text"'), name).toBe(true); + const inBrowser = await page.evaluate((bytes) => (globalThis as unknown as { filiusM9: { traceText: (archive: Uint8Array) => string } }).filiusM9.traceText(new Uint8Array(bytes)), [...archive]); + expect(inBrowser.length, name).toBe(inNode.length); + expect(inBrowser === inNode, name).toBe(true); + } +}); + +test('LocalSimHost runs the room in a real worker', async ({ page }) => { + await page.setContent('LocalSimHost'); + await page.addScriptTag({ content: await bundle('localSimHostEntry.ts') }); + const archive = [...new Uint8Array(readFileSync(join(examples, 'internetwork_komplett.fls')))]; + const workerCode = await bundle('../src/worker.ts'); + const result = await page.evaluate( + async ({ bytes, code }) => { + const api = (globalThis as unknown as { filiusLocal: { run: (archive: Uint8Array, workerUrl: string) => Promise<{ icmpRows: number; mode: string }> } }).filiusLocal; + return api.run(new Uint8Array(bytes), URL.createObjectURL(new Blob([code], { type: 'text/javascript' }))); + }, + { bytes: archive, code: workerCode }, + ); + expect(result.mode).toBe('simulation'); + expect(result.icmpRows).toBeGreaterThanOrEqual(8); +}); diff --git a/packages/sim/browser/m9Entry.ts b/packages/sim/browser/m9Entry.ts new file mode 100644 index 0000000..bc16d66 --- /dev/null +++ b/packages/sim/browser/m9Entry.ts @@ -0,0 +1,5 @@ +// The browser's side of M9: the same function on the page. + +import { traceText } from './traceDigest.js'; + +(globalThis as unknown as { filiusM9: unknown }).filiusM9 = { traceText }; diff --git a/packages/sim/browser/traceDigest.ts b/packages/sim/browser/traceDigest.ts new file mode 100644 index 0000000..bd4d756 --- /dev/null +++ b/packages/sim/browser/traceDigest.ts @@ -0,0 +1,48 @@ +// M9 (plan §13): does the core give the same packet trace in Node and in a +// browser? One run of an example — read from its .fls, through the shared +// document, started and advanced tick by tick, with a few lines typed (a +// command line installed where the example has none) — as +// text: every card's capture in both views and every command line's window. +// Node and Chromium each compute it; the texts must be equal. + +import * as Y from 'yjs'; +import { NetworkRuntimeEngine } from '@mdg-filius/core'; +import { docSnapshot, readProject, writeProject } from '@mdg-filius/doc'; +import { openProjectArchive } from '@mdg-filius/fls'; +import { traceFramesOf } from '../src/room.js'; + +export const M9_SECONDS = 30; +const SEED = 14; + +export function traceText(archive: Uint8Array): string { + let counter = 0; + const opened = openProjectArchive(archive, () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`); + // An example without a command line gets one on its first PC or notebook, so that every run has traffic. + if (![...opened.installedPrograms.values()].some((programs) => programs.has('commandPrompt'))) { + const host = opened.graph.nodes.find((node) => node.kind === 'pc' || node.kind === 'notebook'); + if (host !== undefined) opened.installedPrograms.set(host.id, new Set([...(opened.installedPrograms.get(host.id) ?? []), 'commandPrompt'])); + } + const doc = new Y.Doc(); + writeProject(doc, opened); + const project = readProject(doc); + const engine = new NetworkRuntimeEngine(SEED, undefined, { wallClock: () => 0 }); + engine.handle({ type: 'start', snapshot: docSnapshot(doc), seed: SEED, initialTimeMilliseconds: 0 }); + // The first command line pings the first other host with an address and asks for a name. + const terminal = project.graph.nodes.find((node) => project.installedPrograms.get(node.id)?.has('commandPrompt') === true); + const target = project.graph.nodes.map((node) => project.deviceConfigurations.get(node.id)?.ipAddress).find((ip) => ip !== undefined && ip !== '' && ip !== project.deviceConfigurations.get(terminal?.id ?? '')?.ipAddress); + const typed: [number, string][] = [ + [5_000, 'ipconfig'], + [6_000, `ping ${target ?? '127.0.0.1'}`], + [15_000, 'host www.filius.de'], + ]; + for (let t = 200; t <= M9_SECONDS * 1_000; t += 200) { + engine.handle({ type: 'advance', toMilliseconds: t }); + for (const [at, line] of typed) if (at === t && terminal !== undefined) engine.terminal(terminal.id)?.enter(line); + } + const cards = engine.capture.portIds().sort().map((portId) => ({ portId, frames: traceFramesOf(engine.capture.frames(portId)) })); + const windows = project.graph.nodes.flatMap((node) => { + const log = engine.terminal(node.id)?.log.all; + return log === undefined ? [] : [{ node: node.id, log }]; + }); + return JSON.stringify({ cards, windows }); +} diff --git a/packages/sim/browser/tsconfig.json b/packages/sim/browser/tsconfig.json new file mode 100644 index 0000000..fac6cf4 --- /dev/null +++ b/packages/sim/browser/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "composite": false, + "declaration": false, + "lib": ["ES2023", "DOM"], + "types": [ + "node" + ] + }, + "include": [ + "." + ] +} diff --git a/packages/sim/package.json b/packages/sim/package.json new file mode 100644 index 0000000..63cc86f --- /dev/null +++ b/packages/sim/package.json @@ -0,0 +1,17 @@ +{ + "name": "@mdg-filius/sim", + "version": "0.0.0", + "private": true, + "license": "GPL-3.0-only", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./worker": "./src/worker.ts" + }, + "dependencies": { + "@mdg-filius/core": "0.0.0", + "@mdg-filius/doc": "0.0.0", + "@mdg-filius/protocol": "0.0.0", + "yjs": "^13.6.33" + } +} diff --git a/packages/sim/src/channel.ts b/packages/sim/src/channel.ts new file mode 100644 index 0000000..cedee8b --- /dev/null +++ b/packages/sim/src/channel.ts @@ -0,0 +1,22 @@ +// The messages between the editor and the worker that holds its room: the +// document's updates both ways (the room reads the document at a start and, +// later, writes what the simulation changes back), and the protocol's +// messages. Both ends are a port as a worker and its page have them. + +import type { ClientMessage, HostMessage } from '@mdg-filius/protocol'; + +export type ChannelMessage = + | { readonly type: 'doc'; readonly update: Uint8Array } + | { readonly type: 'client'; readonly message: ClientMessage } + | { readonly type: 'host'; readonly message: HostMessage }; + +/** One end: a Worker, the worker's own scope, or a MessagePort. */ +export interface Port { + postMessage(message: ChannelMessage): void; + addEventListener(type: 'message', listener: (event: { readonly data: unknown }) => void): void; + /** A MessagePort delivers only after start (a Worker and its scope need none). */ + start?(): void; +} + +/** The origin of an update that came over the channel: it is not sent back. */ +export const CHANNEL_ORIGIN = 'channel'; diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts new file mode 100644 index 0000000..b1f468f --- /dev/null +++ b/packages/sim/src/index.ts @@ -0,0 +1,5 @@ +// Public surface of the simulation room and its local host. +export * from './channel.js'; +export * from './localSimHost.js'; +export * from './room.js'; +export * from './serveRoom.js'; diff --git a/packages/sim/src/localSimHost.ts b/packages/sim/src/localSimHost.ts new file mode 100644 index 0000000..e558898 --- /dev/null +++ b/packages/sim/src/localSimHost.ts @@ -0,0 +1,49 @@ +// The simulation in a web worker of the editor's own page (plan §6.1): for +// development, tests and a preview without F. The worker gets the document +// as it is and every change after; what the room changes in it comes back. + +import * as Y from 'yjs'; +import { parseHostMessage, SIM_PROTOCOL_VERSION, type ClientMessage, type HostMessage, type SimHost } from '@mdg-filius/protocol'; +import { CHANNEL_ORIGIN, type ChannelMessage, type Port } from './channel.js'; + +export class LocalSimHost implements SimHost { + private readonly listeners = new Set<(message: HostMessage) => void>(); + private readonly forward = (update: Uint8Array, origin: unknown) => { + if (origin !== CHANNEL_ORIGIN) this.port.postMessage({ type: 'doc', update }); + }; + + constructor( + private readonly doc: Y.Doc, + private readonly port: Port, + private readonly end: () => void = () => {}, + ) { + port.addEventListener('message', (event) => { + const data = event.data as ChannelMessage; + if (data?.type === 'doc') { + Y.applyUpdate(doc, data.update, CHANNEL_ORIGIN); + } else if (data?.type === 'host') { + const parsed = parseHostMessage(data.message); + const message: HostMessage = 'message' in parsed ? parsed.message : { v: SIM_PROTOCOL_VERSION, kind: 'error', code: parsed.error.code, detail: parsed.error.detail }; + for (const listener of this.listeners) listener(message); + } + }); + port.start?.(); + port.postMessage({ type: 'doc', update: Y.encodeStateAsUpdate(doc) }); + doc.on('update', this.forward); + } + + send(message: ClientMessage): void { + this.port.postMessage({ type: 'client', message }); + } + + onMessage(listener: (message: HostMessage) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + close(): void { + this.doc.off('update', this.forward); + this.listeners.clear(); + this.end(); + } +} diff --git a/packages/sim/src/room.ts b/packages/sim/src/room.ts new file mode 100644 index 0000000..5d12e03 --- /dev/null +++ b/packages/sim/src/room.ts @@ -0,0 +1,320 @@ +// A simulation room (plan §6): one engine for one project, the clients that +// watch it, and what goes to them. The same room runs on F for everyone in a +// project and in a web worker for one (LocalSimHost); it takes the +// document's snapshot when the simulation starts, so that changes made while +// it runs come in at the next start, as in Java. +// +// Time: the engine's clock follows the wall clock, a millisecond for a +// millisecond, as Java's threads do; the speed is Java's slider, the delay of +// a cable (RuntimeSpeed), not a time lapse — unlike Swift's tick (plan §6.3). +// A tick catches up at most MAXIMUM_CATCH_UP_MILLISECONDS: after a tab slept, +// the simulation goes on from where it was rather than running the missed +// minutes in one go. +// +// The engine outlives a stop: the capture stays, as Java's Lauscher does, and +// the next start goes on at the time it stopped, so that the capture's times +// keep rising (plan §6.2). + +import * as Y from 'yjs'; +import { DEFAULT_SPEED_PERCENT, NetworkRuntimeEngine, captureRows, type CapturedFrame, type CaptureRow, type Id, type LogLine } from '@mdg-filius/core'; +import { docSnapshot } from '@mdg-filius/doc'; +import { + parseClientMessage, + SIM_PROTOCOL_VERSION, + type AppKind, + type ClientMessage, + type ErrorCode, + type HostMessage, + type SimMode, + type Topic, + type TraceFrame, +} from '@mdg-filius/protocol'; + +export const TICK_MILLISECONDS = 200; +export const MAXIMUM_CATCH_UP_MILLISECONDS = 2_000; + +export interface RoomOptions { + /** The wall clock, in milliseconds. */ + readonly now: () => number; + /** A seed for each start. */ + readonly seed: () => number; +} + +interface Client { + readonly send: (message: HostMessage) => void; + readonly topics: Set; + /** Per card, the number of the last frame sent. */ + readonly traceSent: Map; + /** Per program window, how many of its program's lines it has; none while the program does not run (a stop empties it). */ + readonly appSent: Map; +} + +/** A program with a window: its lines. */ +interface WithLog { + readonly log: { readonly all: readonly LogLine[] }; +} + +const message = >(fields: T) => ({ v: SIM_PROTOCOL_VERSION, ...fields }) as unknown as HostMessage; + +export class SimulationRoom { + private readonly engine: NetworkRuntimeEngine; + private mode: SimMode = 'design'; + private speed = DEFAULT_SPEED_PERCENT; + private readonly clients = new Map(); + private nextClient = 1; + private lastWall: number | undefined; + /** The cable each card is plugged into, as the simulation started. */ + private linkOfPort = new Map(); + /** Per card, how many frames it held at the last tick. */ + private framesSeen = new Map(); + private lastActivity: string[] = []; + + constructor( + private readonly doc: Y.Doc, + private readonly options: RoomOptions, + ) { + this.engine = new NetworkRuntimeEngine(options.seed(), undefined, { wallClock: options.now }); + } + + /** A new client; it gets the room's state at once. */ + connect(send: (message: HostMessage) => void): number { + const id = this.nextClient++; + this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), appSent: new Map() }); + send(this.state()); + return id; + } + + disconnect(client: number): void { + this.clients.delete(client); + } + + /** How many clients the room has (F stops a room nobody watches). */ + get clientCount(): number { + return this.clients.size; + } + + get currentMode(): SimMode { + return this.mode; + } + + /** A client's message, as it came over the wire. */ + receive(clientId: number, raw: unknown): void { + const client = this.clients.get(clientId); + if (client === undefined) return; + const parsed = parseClientMessage(raw); + if ('error' in parsed) { + const reqId = typeof (raw as { reqId?: unknown } | null)?.reqId === 'string' ? (raw as { reqId: string }).reqId : undefined; + client.send(message({ kind: 'error', code: parsed.error.code, detail: parsed.error.detail, ...(reqId === undefined ? {} : { reqId }) })); + return; + } + this.handle(client, parsed.message); + } + + private handle(client: Client, input: ClientMessage): void { + switch (input.kind) { + case 'sub': + for (const topic of input.topics) { + client.topics.add(topic); + if (topic.startsWith('trace:')) client.traceSent.delete(topic.slice('trace:'.length)); + if (topic.startsWith('app:')) client.appSent.delete(topic); + } + this.flush([client], true); + return; + case 'unsub': + for (const topic of input.topics) client.topics.delete(topic); + return; + case 'start': + this.start(); + return; + case 'stop': + this.stop(); + return; + case 'speed': + this.speed = Math.min(100, Math.max(1, Math.trunc(input.value))); + this.engine.setSimulationSpeed(this.speed); + this.broadcast(this.state()); + return; + case 'input': { + let failure: ErrorCode | undefined = 'engineFailed'; + if (!this.guarded(() => (failure = this.act(input)))) return; + client.send(failure === undefined ? message({ kind: 'ack', reqId: input.reqId }) : message({ kind: 'error', code: failure, reqId: input.reqId })); + this.flush([...this.clients.values()], false); + return; + } + } + } + + private state(): HostMessage { + return message({ kind: 'state', mode: this.mode, speed: this.speed, t: this.engine.state.currentTimeMilliseconds }); + } + + private broadcast(output: HostMessage): void { + for (const client of this.clients.values()) client.send(output); + } + + start(): void { + if (this.mode === 'simulation') return; + const snapshot = docSnapshot(this.doc); + this.engine.setSimulationSpeed(this.speed); + if (!this.guarded(() => this.engine.handle({ type: 'start', snapshot, seed: this.options.seed(), initialTimeMilliseconds: this.engine.state.currentTimeMilliseconds }))) return; + this.linkOfPort = new Map(snapshot.links.flatMap((link) => [[link.sourcePortId, link.id] as const, [link.targetPortId, link.id] as const])); + this.mode = 'simulation'; + this.lastWall = this.options.now(); + this.broadcast(this.state()); + this.flush([...this.clients.values()], false); + } + + stop(): void { + if (this.mode === 'design') return; + this.engine.handle({ type: 'stop' }); + this.mode = 'design'; + this.lastWall = undefined; + this.broadcast(this.state()); + this.flush([...this.clients.values()], false); + } + + /** Lets the simulation run to the wall clock's time, then sends what came of it. */ + tick(): void { + if (this.mode !== 'simulation') return; + const now = this.options.now(); + const elapsed = Math.min(MAXIMUM_CATCH_UP_MILLISECONDS, Math.max(0, now - (this.lastWall ?? now))); + this.lastWall = now; + if (!this.guarded(() => this.engine.handle({ type: 'advance', toMilliseconds: this.engine.state.currentTimeMilliseconds + elapsed }))) return; + this.flush([...this.clients.values()], false); + } + + /** + * Runs a step of the engine. When the core throws (a part it has not got + * yet), the room tells every client and goes back to design mode instead of + * dying with its worker; false then. + */ + private guarded(step: () => void): boolean { + try { + step(); + return true; + } catch (error) { + this.broadcast(message({ kind: 'error', code: 'engineFailed', detail: error instanceof Error ? error.message : String(error) })); + if (this.engine.state.phase === 'running') this.engine.handle({ type: 'stop' }); + this.mode = 'design'; + this.lastWall = undefined; + this.broadcast(this.state()); + this.flush([...this.clients.values()], false); + return false; + } + } + + /** An action in a program's window; the failure, if any. */ + private act(input: Extract): ErrorCode | undefined { + if (this.mode !== 'simulation') return 'notRunning'; + if (!this.engine.state.topologySnapshot.nodes.some((node) => node.id === input.node)) return 'unknownNode'; + if (input.app !== 'terminal') return 'unknownAction'; + const terminal = this.engine.terminal(input.node); + if (terminal === undefined) return 'unknownApp'; + switch (input.action) { + case 'enter': + if (typeof input.args.line !== 'string') return 'badArgs'; + terminal.enter(input.args.line); + return undefined; + case 'interrupt': + terminal.interrupt(); + return undefined; + default: + return 'unknownAction'; + } + } + + private program(node: Id, app: AppKind): WithLog | undefined { + switch (app) { + case 'terminal': + return this.engine.terminal(node); + case 'webBrowser': + return this.engine.webBrowser(node); + case 'webServer': + return this.engine.webServer(node); + case 'emailClient': + return this.engine.emailClient(node); + case 'emailServer': + return this.engine.emailServer(node); + case 'echoServer': + return this.engine.echoServer(node); + case 'simpleClient': + return this.engine.simpleClient(node); + case 'gnutella': + return this.engine.gnutella(node); + } + } + + /** What the clients are owed: the cables' activity, new frames of the cards and new lines of the windows they watch. */ + private flush(clients: readonly Client[], subscribing: boolean): void { + if (!subscribing) this.updateActivity(); + const rowsCache = new Map(); + const traceFrames = (portId: Id, after: number): TraceFrame[] => { + const frames = this.engine.capture.frames(portId).filter((frame) => frame.number > after); + if (frames.length === 0) return []; + if (after === 0 && rowsCache.has(portId)) return rowsCache.get(portId)!; + const result = traceFramesOf(frames); + if (after === 0) rowsCache.set(portId, result); + return result; + }; + for (const client of clients) { + for (const topic of client.topics) { + if (topic === 'activity') { + if (subscribing) client.send(message({ kind: 'activity', links: this.lastActivity })); + } else if (topic.startsWith('trace:')) { + const portId = topic.slice('trace:'.length); + const reset = !client.traceSent.has(portId); + const frames = traceFrames(portId, client.traceSent.get(portId) ?? 0); + if (frames.length === 0 && !reset) continue; + client.traceSent.set(portId, frames.at(-1)?.number ?? client.traceSent.get(portId) ?? 0); + client.send(message({ kind: 'trace', portId, frames, reset })); + } else { + const [, node, app] = /^app:([^/]+)\/(.+)$/.exec(topic)!; + const program = this.program(node!, app as AppKind); + const sent = client.appSent.get(topic); + if (program === undefined) { + // A window of a program that is not running: it starts empty with the next start. + if (sent !== undefined || subscribing) client.send(message({ kind: 'app', node: node!, app: app as AppKind, from: 0, lines: [] })); + client.appSent.delete(topic); + continue; + } + // The window goes on from the lines it has; after a stop (none sent) it begins anew. + const from = sent ?? 0; + const lines = program.log.all.slice(from); + if (sent !== undefined && lines.length === 0) continue; + client.appSent.set(topic, program.log.all.length); + client.send(message({ kind: 'app', node: node!, app: app as AppKind, from, lines })); + } + } + } + } + + /** The cables whose cards got frames since the last tick; sent when that changes. */ + private updateActivity(): void { + const links = new Set(); + for (const portId of this.engine.capture.portIds()) { + const frames = this.engine.capture.frames(portId); + const count = frames.at(-1)?.number ?? 0; + if (count > (this.framesSeen.get(portId) ?? 0)) { + const link = this.linkOfPort.get(portId); + if (link !== undefined) links.add(link); + } + this.framesSeen.set(portId, count); + } + const activity = [...links].sort(); + if (activity.join() === this.lastActivity.join()) return; + this.lastActivity = activity; + for (const client of this.clients.values()) if (client.topics.has('activity')) client.send(message({ kind: 'activity', links: activity })); + } +} + +/** A card's frames as the capture window shows them, both views. */ +export function traceFramesOf(frames: readonly CapturedFrame[]): TraceFrame[] { + const byNumber = (rows: readonly CaptureRow[]) => { + const grouped = new Map(); + for (const row of rows) grouped.set(row.number, [...(grouped.get(row.number) ?? []), row]); + return grouped; + }; + const rows = byNumber(captureRows(frames, { withAddresses: true })); + const layered = byNumber(captureRows(frames, { withAddresses: false })); + return frames.map((frame) => ({ number: frame.number, rows: rows.get(frame.number) ?? [], layeredRows: layered.get(frame.number) ?? [] })); +} diff --git a/packages/sim/src/serveRoom.ts b/packages/sim/src/serveRoom.ts new file mode 100644 index 0000000..4882f5b --- /dev/null +++ b/packages/sim/src/serveRoom.ts @@ -0,0 +1,28 @@ +// The worker's side: a replica of the editor's document and the room that +// reads it, ticking every TICK_MILLISECONDS. + +import * as Y from 'yjs'; +import { CHANNEL_ORIGIN, type ChannelMessage, type Port } from './channel.js'; +import { SimulationRoom, TICK_MILLISECONDS, type RoomOptions } from './room.js'; + +export interface ServeOptions extends RoomOptions { + /** Calls `tick` every so many milliseconds. */ + readonly every: (milliseconds: number, tick: () => void) => void; +} + +export function serveRoom(port: Port, options: ServeOptions): SimulationRoom { + const doc = new Y.Doc(); + doc.on('update', (update: Uint8Array, origin: unknown) => { + if (origin !== CHANNEL_ORIGIN) port.postMessage({ type: 'doc', update }); + }); + const room = new SimulationRoom(doc, options); + const client = room.connect((message) => port.postMessage({ type: 'host', message })); + port.addEventListener('message', (event) => { + const data = event.data as ChannelMessage; + if (data?.type === 'doc') Y.applyUpdate(doc, data.update, CHANNEL_ORIGIN); + else if (data?.type === 'client') room.receive(client, data.message); + }); + port.start?.(); + options.every(TICK_MILLISECONDS, () => room.tick()); + return room; +} diff --git a/packages/sim/src/worker.ts b/packages/sim/src/worker.ts new file mode 100644 index 0000000..c277d36 --- /dev/null +++ b/packages/sim/src/worker.ts @@ -0,0 +1,15 @@ +// The entry of the web worker that holds a LocalSimHost's room. + +import type { Port } from './channel.js'; +import { serveRoom } from './serveRoom.js'; + +// The worker's scope, with what it is used for here (the package has no DOM types). +declare const self: Port; +declare function setInterval(handler: () => void, milliseconds: number): unknown; +declare const performance: { now(): number }; + +serveRoom(self, { + now: () => performance.now(), + seed: () => Math.floor(Math.random() * 2 ** 31), + every: (milliseconds, tick) => setInterval(tick, milliseconds), +}); diff --git a/packages/sim/test/localSimHost.test.ts b/packages/sim/test/localSimHost.test.ts new file mode 100644 index 0000000..76b4129 --- /dev/null +++ b/packages/sim/test/localSimHost.test.ts @@ -0,0 +1,142 @@ +// LocalSimHost and the worker's side (serveRoom) over a MessageChannel, which +// clones as a worker's port does: the editor's document reaches the room, +// changes to it follow, and the room's messages come back parsed. + +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 { readProject, writeProject } from '@mdg-filius/doc'; +import { openProjectArchive } from '@mdg-filius/fls'; +import type { HostMessage } from '@mdg-filius/protocol'; +import { CHANNEL_ORIGIN, LocalSimHost, serveRoom, type Port } from '../src/index.js'; + +const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus'); + +function exampleDoc(): Y.Doc { + let counter = 0; + const file = join(corpus, 'java-examples/internetwork_komplett.fls'); + const project = openProjectArchive(new Uint8Array(readFileSync(file)), () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`); + const doc = new Y.Doc(); + writeProject(doc, project); + return doc; +} + +/** Waits until the messages so far hold one the test wants (the channel delivers on later turns). */ +async function until(find: () => T | undefined): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + const found = find(); + if (found !== undefined) return found; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error('never came'); +} + +function setUp() { + const doc = exampleDoc(); + const channel = new MessageChannel(); + let now = 0; + let tick = () => {}; + const room = serveRoom(channel.port2 as unknown as Port, { now: () => now, seed: () => 14, every: (_, each) => (tick = each) }); + const host = new LocalSimHost(doc, channel.port1 as unknown as Port, () => channel.port1.close()); + const got: HostMessage[] = []; + host.onMessage((message) => got.push(message)); + const run = (milliseconds: number) => { + for (let elapsed = 0; elapsed < milliseconds; elapsed += 200) { + now += 200; + tick(); + } + }; + return { doc, room, host, got, run, close: () => channel.port2.close() }; +} + +describe('the simulation in a worker of the page', () => { + it('runs the document the editor has, and sends back what comes of it', async () => { + const { doc, host, got, run, close } = setUp(); + const notebook = readProject(doc).graph.nodes.find((node) => node.displayName === 'Notebook 1.10')!; + host.send({ v: 1, kind: 'sub', topics: [`trace:${notebook.ports[0]!.id}`] }); + host.send({ v: 1, kind: 'start' }); + await until(() => got.find((message) => message.kind === 'state' && message.mode === 'simulation')); + host.send({ v: 1, kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); + await until(() => got.find((message) => message.kind === 'ack')); + run(6_000); + const frames = await until(() => { + const all = got.flatMap((message) => (message.kind === 'trace' ? message.frames : [])); + return all.filter((frame) => frame.rows.some((row) => row.protocol === 'ICMP')).length >= 8 ? all : undefined; + }); + expect(frames[0]!.rows[0]!.source).toMatch(/^[0-9A-F]{2}(:[0-9A-F]{2}){5}$/); + host.close(); + close(); + }); + + it("gives the room the editor's changes, which count from the next start", async () => { + const { doc, host, got, close } = setUp(); + const notebook = readProject(doc).graph.nodes.find((node) => node.displayName === 'Notebook 1.10')!; + doc.getMap('nodes').delete(notebook.id); + host.send({ v: 1, kind: 'start' }); + host.send({ v: 1, kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r1' }); + expect(await until(() => got.find((message) => message.kind === 'error'))).toMatchObject({ code: 'unknownNode', reqId: 'r1' }); + host.close(); + close(); + }); + + it("takes the room's changes to the document without sending them back", async () => { + const doc = exampleDoc(); + const channel = new MessageChannel(); + const sent: unknown[] = []; + // A stand-in for the worker: it records what the page sends and changes its replica as a room would. + const replica = new Y.Doc(); + channel.port2.addEventListener('message', (event) => { + const data = (event as MessageEvent).data as { type: string; update?: Uint8Array }; + sent.push(data.type); + if (data.type === 'doc') Y.applyUpdate(replica, data.update!, CHANNEL_ORIGIN); + }); + channel.port2.start(); + const host = new LocalSimHost(doc, channel.port1 as unknown as Port, () => channel.port1.close()); + await until(() => (sent.length > 0 ? true : undefined)); + const before = sent.length; + const update = (() => { + let captured: Uint8Array | undefined; + replica.on('update', (each: Uint8Array) => (captured = each)); + replica.getMap('meta').set('note', 'from the room'); + return captured!; + })(); + channel.port2.postMessage({ type: 'doc', update }); + await until(() => (doc.getMap('meta').get('note') === 'from the room' ? true : undefined)); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(sent.length).toBe(before); + host.close(); + channel.port2.close(); + }); + + it('tells the editor of a message it does not know, as an error', async () => { + const doc = exampleDoc(); + const channel = new MessageChannel(); + channel.port2.start(); + const host = new LocalSimHost(doc, channel.port1 as unknown as Port, () => channel.port1.close()); + const got: HostMessage[] = []; + host.onMessage((message) => got.push(message)); + channel.port2.postMessage({ type: 'host', message: { v: 1, kind: 'node', id: 'x' } }); + expect(await until(() => got[0])).toEqual({ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind node' }); + host.close(); + channel.port2.close(); + }); + + it("sends the editor's changes to the worker, and nothing of them back", async () => { + const doc = exampleDoc(); + const channel = new MessageChannel(); + const fromWorker: string[] = []; + channel.port1.addEventListener('message', (event) => fromWorker.push(((event as MessageEvent).data as { type: string }).type)); + const room = serveRoom(channel.port2 as unknown as Port, { now: () => 0, seed: () => 14, every: () => {} }); + const host = new LocalSimHost(doc, channel.port1 as unknown as Port, () => channel.port1.close()); + await until(() => (fromWorker.includes('host') ? true : undefined)); + doc.getMap('meta').set('note', 'changed'); + host.send({ v: 1, kind: 'start' }); + await until(() => (room.currentMode === 'simulation' ? true : undefined)); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(fromWorker.filter((type) => type === 'doc')).toEqual([]); + host.close(); + channel.port2.close(); + }); +}); diff --git a/packages/sim/test/room.test.ts b/packages/sim/test/room.test.ts new file mode 100644 index 0000000..61cd5ff --- /dev/null +++ b/packages/sim/test/room.test.ts @@ -0,0 +1,309 @@ +// The simulation room against the engine run by hand: what a client gets — +// a card's frames, a program's window, the cables' activity, the room's +// state — is what the engine holds, sent once each and only to the clients +// that subscribed. On FILIUS' example internetwork_komplett: Notebook 1.10 +// pings Webserver 5.10 across three routers from its command line. + +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 { NetworkRuntimeEngine, type Id } from '@mdg-filius/core'; +import { docSnapshot, readProject, writeProject } from '@mdg-filius/doc'; +import { openProjectArchive } from '@mdg-filius/fls'; +import type { HostMessage, TraceFrame } from '@mdg-filius/protocol'; +import { MAXIMUM_CATCH_UP_MILLISECONDS, SimulationRoom, TICK_MILLISECONDS, traceFramesOf } from '../src/index.js'; + +const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus'); +const SEED = 14; + +function exampleDoc(file = 'java-examples/internetwork_komplett.fls'): Y.Doc { + let counter = 0; + const project = openProjectArchive(new Uint8Array(readFileSync(join(corpus, file))), () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`); + const doc = new Y.Doc(); + writeProject(doc, project); + return doc; +} + +/** A room on a clock the test moves, and a client that keeps what it gets. */ +function setUp(doc = exampleDoc()) { + let now = 1_000_000; + const room = new SimulationRoom(doc, { now: () => now, seed: () => SEED }); + const got: HostMessage[] = []; + const client = room.connect((message) => got.push(message)); + const project = readProject(doc); + const node = (name: string) => project.graph.nodes.find((each) => each.displayName === name)!; + return { + room, + got, + client, + node, + send: (message: object) => room.receive(client, { v: 1, ...message }), + /** Ticks for so many milliseconds of wall time. */ + run: (milliseconds: number) => { + for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) { + now += TICK_MILLISECONDS; + room.tick(); + } + }, + jump: (milliseconds: number) => { + now += milliseconds; + room.tick(); + }, + }; +} + +const ofKind = (got: readonly HostMessage[], kind: K) => got.filter((message): message is Extract => message.kind === kind); + +describe('a simulation room', () => { + it('tells a new client its state, and every client a change', () => { + const { room, got, send } = setUp(); + const other: HostMessage[] = []; + room.connect((message) => other.push(message)); + expect(got).toEqual([{ v: 1, kind: 'state', mode: 'design', speed: 90, t: 0 }]); + send({ kind: 'start' }); + send({ kind: 'speed', value: 250 }); + send({ kind: 'stop' }); + for (const each of [got, other]) { + expect(ofKind(each, 'state').map(({ mode, speed }) => [mode, speed])).toEqual([ + ['design', 90], + ['simulation', 90], + ['simulation', 100], + ['design', 100], + ]); + } + }); + + it("sends a card's frames and a terminal's lines as the engine has them", () => { + const { got, send, node, run } = setUp(); + const notebook = node('Notebook 1.10'); + const port = notebook.ports[0]!.id; + send({ kind: 'sub', topics: [`trace:${port}`, `app:${notebook.id}/terminal`] }); + send({ kind: 'start' }); + run(1_000); + send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); + run(8_000); + + // The same story on an engine run by hand: started alike, advanced tick by tick, the line typed at the same time. + const engine = new NetworkRuntimeEngine(SEED); + engine.handle({ type: 'start', snapshot: docSnapshot(exampleDoc()), seed: SEED, initialTimeMilliseconds: 0 }); + engine.setSimulationSpeed(90); + for (let t = TICK_MILLISECONDS; t <= 1_000; t += TICK_MILLISECONDS) engine.handle({ type: 'advance', toMilliseconds: t }); + engine.terminal(notebook.id)!.enter('ping 141.99.5.10'); + for (let t = 1_000 + TICK_MILLISECONDS; t <= 9_000; t += TICK_MILLISECONDS) engine.handle({ type: 'advance', toMilliseconds: t }); + + expect(ofKind(got, 'ack')).toEqual([{ v: 1, kind: 'ack', reqId: 'r1' }]); + const traces = ofKind(got, 'trace'); + expect(traces[0]).toMatchObject({ portId: port, reset: true }); + expect(traces.slice(1).every((each) => !each.reset && each.frames.length > 0)).toBe(true); + const frames: TraceFrame[] = traces.flatMap((each) => each.frames); + expect(frames).toEqual(traceFramesOf(engine.capture.frames(port))); + expect(frames.length).toBeGreaterThan(8); + + const windows = ofKind(got, 'app'); + let lines: unknown[] = []; + for (const each of windows) { + expect(each.from).toBe(each.from === 0 ? 0 : lines.length); + lines = [...lines.slice(0, each.from), ...each.lines]; + } + expect(lines).toEqual(engine.terminal(notebook.id)!.log.all); + expect(lines.length).toBeGreaterThan(4); + }); + + it('sends the cables that carry frames when they change, and only to who asked', () => { + const { room, got, send, node, run } = setUp(); + const quiet: HostMessage[] = []; + room.connect((message) => quiet.push(message)); + send({ kind: 'sub', topics: ['activity'] }); + send({ kind: 'start' }); + run(1_000); + const notebook = node('Notebook 1.10'); + send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); + run(10_000); + const activity = ofKind(got, 'activity'); + expect(activity[0]).toEqual({ v: 1, kind: 'activity', links: [] }); + expect(activity.some((each) => each.links.length >= 2)).toBe(true); + // Each message differs from the one before; the last, after the ping, says all is quiet. + for (let index = 1; index < activity.length; index += 1) expect(activity[index]!.links).not.toEqual(activity[index - 1]!.links); + expect(activity.at(-1)!.links).toEqual([]); + expect(ofKind(quiet, 'activity')).toEqual([]); + }); + + it('stops sending a card it no longer watches', () => { + const { got, send, node, run } = setUp(); + const notebook = node('Notebook 1.10'); + send({ kind: 'sub', topics: [`trace:${notebook.ports[0]!.id}`] }); + send({ kind: 'start' }); + send({ kind: 'unsub', topics: [`trace:${notebook.ports[0]!.id}`] }); + const before = got.length; + send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); + run(5_000); + expect(ofKind(got.slice(before), 'trace')).toEqual([]); + }); + + it('keeps the capture over a stop and goes on at the time it stopped', () => { + const { got, send, node, run } = setUp(); + const notebook = node('Notebook 1.10'); + const port = notebook.ports[0]!.id; + send({ kind: 'start' }); + send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); + run(5_000); + send({ kind: 'stop' }); + const stoppedAt = ofKind(got, 'state').at(-1)!.t; + expect(stoppedAt).toBe(5_000); + send({ kind: 'start' }); + expect(ofKind(got, 'state').at(-1)!.t).toBe(stoppedAt); + send({ kind: 'sub', topics: [`trace:${port}`] }); + const reset = ofKind(got, 'trace').at(-1)!; + expect(reset.reset).toBe(true); + expect(reset.frames.length).toBeGreaterThan(8); + }); + + it('empties a window when its program starts anew', () => { + const { got, send, node, run } = setUp(); + const notebook = node('Notebook 1.10'); + send({ kind: 'sub', topics: [`app:${notebook.id}/terminal`] }); + expect(ofKind(got, 'app')).toEqual([{ v: 1, kind: 'app', node: notebook.id, app: 'terminal', from: 0, lines: [] }]); + send({ kind: 'start' }); + send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r1' }); + run(400); + expect(ofKind(got, 'app').at(-1)!.lines.length).toBeGreaterThan(0); + send({ kind: 'stop' }); + expect(ofKind(got, 'app').at(-1)).toMatchObject({ from: 0, lines: [] }); + send({ kind: 'start' }); + expect(ofKind(got, 'app').at(-1)).toMatchObject({ from: 0 }); + }); + + it('answers an action that cannot be done with its reason', () => { + const { got, send, node } = setUp(); + const notebook = node('Notebook 1.10').id; + const reason = (action: object) => { + send({ kind: 'input', reqId: 'r', args: {}, ...action }); + return got.at(-1); + }; + expect(reason({ node: notebook, app: 'terminal', action: 'enter', args: { line: 'ipconfig' } })).toMatchObject({ kind: 'error', code: 'notRunning', reqId: 'r' }); + send({ kind: 'start' }); + expect(reason({ node: 'nobody', app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownNode' }); + expect(reason({ node: node('Switch 1').id, app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownApp' }); + expect(reason({ node: notebook, app: 'terminal', action: 'paint' })).toMatchObject({ code: 'unknownAction' }); + expect(reason({ node: notebook, app: 'webBrowser', action: 'open' })).toMatchObject({ code: 'unknownAction' }); + expect(reason({ node: notebook, app: 'terminal', action: 'enter', args: { line: 7 } })).toMatchObject({ code: 'badArgs' }); + expect(reason({ node: notebook, app: 'terminal', action: 'interrupt' })).toEqual({ v: 1, kind: 'ack', reqId: 'r' }); + send({ kind: 'nonsense', reqId: 'q' }); + expect(got.at(-1)).toEqual({ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind nonsense', reqId: 'q' }); + send({ kind: 'speed' }); + expect(got.at(-1)).toEqual({ v: 1, kind: 'error', code: 'badMessage', detail: 'speed is no number' }); + }); + + it('makes a cable slower with a lower speed, as Java does', () => { + const roundTrip = (speed: number) => { + const { got, send, node, run } = setUp(); + const notebook = node('Notebook 1.10'); + send({ kind: 'sub', topics: [`trace:${notebook.ports[0]!.id}`] }); + send({ kind: 'speed', value: speed }); + send({ kind: 'start' }); + send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); + run(10_000); + const rows = ofKind(got, 'trace').flatMap((each) => each.frames).flatMap((frame) => frame.rows).filter((row) => row.protocol === 'ICMP'); + return rows[1]!.timeMilliseconds - rows[0]!.timeMilliseconds; + }; + expect(roundTrip(80)).toBeGreaterThan(roundTrip(90)); + expect(roundTrip(90)).toBeGreaterThan(roundTrip(100)); + }); + + it('catches up at most two seconds after the clock jumped', () => { + const { got, send, jump } = setUp(); + send({ kind: 'start' }); + jump(60_000); + send({ kind: 'stop' }); + expect(ofKind(got, 'state').at(-1)!.t).toBe(MAXIMUM_CATCH_UP_MILLISECONDS); + }); + + it('starts with the document as it is then', () => { + const doc = exampleDoc(); + const { send, node, room, got } = setUp(doc); + const notebook: Id = node('Notebook 1.10').id; + send({ kind: 'start' }); + doc.getMap>('nodes').get(notebook)!.set('name', 'Neu'); + send({ kind: 'input', node: notebook, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r1' }); + expect(got.at(-1)).toMatchObject({ kind: 'ack' }); + send({ kind: 'stop' }); + // The room snapshots at the start: a node removed while it runs is gone at the next start. + doc.getMap('nodes').delete(notebook); + send({ kind: 'start' }); + send({ kind: 'input', node: notebook, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r2' }); + expect(got.at(-1)).toMatchObject({ kind: 'error', code: 'unknownNode', reqId: 'r2' }); + expect(room.currentMode).toBe('simulation'); + }); + + it('tells every client when the core fails, and goes back to design mode', () => { + // FILIUS' modem example: remote links are not in the core yet, it throws at the first frame to a modem. + const { room, got, send, run, node } = setUp(exampleDoc('java-examples/modem_beispiel.fls')); + const other: HostMessage[] = []; + room.connect((message) => other.push(message)); + const ping = () => send({ kind: 'input', node: node('Notebook 0.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.11' }, reqId: 'r' }); + send({ kind: 'start' }); + ping(); + run(10_000); + for (const each of [got, other]) { + expect(ofKind(each, 'error')).toEqual([{ v: 1, kind: 'error', code: 'engineFailed', detail: expect.stringContaining('remote links') }]); + expect(ofKind(each, 'state').at(-1)).toMatchObject({ mode: 'design' }); + } + expect(room.currentMode).toBe('design'); + // It can start again (and fail again, telling it again). + send({ kind: 'start' }); + ping(); + run(10_000); + expect(ofKind(got, 'error')).toHaveLength(2); + }); + + it('sends a card anew to a client that watches it again', () => { + const { got, send, node, run } = setUp(); + const notebook = node('Notebook 1.10'); + const topic = `trace:${notebook.ports[0]!.id}`; + send({ kind: 'start' }); + send({ kind: 'sub', topics: [topic] }); + send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); + run(3_000); + send({ kind: 'unsub', topics: [topic] }); + run(3_000); + send({ kind: 'sub', topics: [topic] }); + const again = ofKind(got, 'trace').at(-1)!; + expect(again.reset).toBe(true); + expect(again.frames[0]!.number).toBe(1); + }); + + it('changes the speed of a running simulation', () => { + const roundTrip = (change: boolean) => { + const { got, send, node, run } = setUp(); + const notebook = node('Notebook 1.10'); + send({ kind: 'sub', topics: [`trace:${notebook.ports[0]!.id}`] }); + send({ kind: 'start' }); + run(1_000); + if (change) send({ kind: 'speed', value: 100 }); + send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); + run(10_000); + const rows = ofKind(got, 'trace').flatMap((each) => each.frames).flatMap((frame) => frame.rows).filter((row) => row.protocol === 'ICMP'); + return rows[1]!.timeMilliseconds - rows[0]!.timeMilliseconds; + }; + expect(roundTrip(true)).toBeLessThan(roundTrip(false)); + }); + + it('reads the document anew at the start after a failure', () => { + const doc = exampleDoc('java-examples/modem_beispiel.fls'); + const { got, send, run, node } = setUp(doc); + const ping = () => send({ kind: 'input', node: node('Notebook 0.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.11' }, reqId: 'r' }); + send({ kind: 'start' }); + ping(); + run(10_000); + expect(ofKind(got, 'error')).toHaveLength(1); + // Without the modems' cables, nothing reaches a modem: the next start runs. + doc.getMap('links').clear(); + send({ kind: 'start' }); + ping(); + run(10_000); + expect(ofKind(got, 'error')).toHaveLength(1); + expect(ofKind(got, 'state').at(-1)).toMatchObject({ mode: 'simulation' }); + }); +}); diff --git a/packages/sim/test/tsconfig.json b/packages/sim/test/tsconfig.json new file mode 100644 index 0000000..b302fb2 --- /dev/null +++ b/packages/sim/test/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "composite": false, + "declaration": false, + "types": [ + "node" + ], + "resolveJsonModule": true + }, + "include": [ + "." + ] +} diff --git a/packages/sim/tsconfig.json b/packages/sim/tsconfig.json new file mode 100644 index 0000000..5c72cfe --- /dev/null +++ b/packages/sim/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": [] + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../core" + }, + { + "path": "../doc" + }, + { + "path": "../protocol" + } + ] +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d9b6c84 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,11 @@ +// The browser tests: what needs a real browser — the core in Chromium (M9), +// the room in a real web worker, later the editor's gestures (plan §7.2). +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: 'packages', + testMatch: '**/browser/*.spec.ts', + workers: 1, + reporter: 'list', + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/tsconfig.json b/tsconfig.json index 7b03fd6..b5cf1d9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,12 @@ }, { "path": "packages/doc" + }, + { + "path": "packages/protocol" + }, + { + "path": "packages/sim" } ] }